fluid-core/internal/control/api_test.go
tegwick 03ff7a8ad7
Some checks failed
ci / build (push) Has been cancelled
Add control APIs, control-plane binary, and close the CLI gate bypass
Completes FLUID-WP-0004. The Revision and Intent APIs (Blueprint 44.1
and 44.5) are served by fluid-control, which is deliberately off the
request path and must never be reachable by interface consumers: it is
the mechanism that evolves the interface in response to their behaviour.

Intent amendment is a proposal, never an edit. Rewriting a recorded
version returns 409, because changing what a version says would change
what already-published revisions were governed by.

A rejected candidate comes back as 422 with its full stage report rather
than as a server fault. Rejection is a normal outcome (invariant 14) and
the reasons are the evidence a later hypothesis needs.

Also closes a real hole this workplan opened: `fluid revision publish`
previously wrote straight into the evidence store, which was a way
around the deterministic policy gate for anyone with shell access. It
now runs the same pipeline the control plane does and requires a signing
key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 03:00:17 +02:00

249 lines
7.5 KiB
Go

package control
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
"github.com/tegwick/fluid-core/internal/intent"
"github.com/tegwick/fluid-core/internal/policy"
"github.com/tegwick/fluid-core/internal/publish"
"github.com/tegwick/fluid-core/internal/signing"
)
const intentDoc = `# Interface Evolution Intent
**Current operational authority mode:**
FLUID-2
`
func newServer(t *testing.T) (*http.ServeMux, *evidence.SQLStore) {
t.Helper()
ctx := context.Background()
store, err := evidence.OpenSQLite(ctx, filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
intents := intent.New(store, "hall-publishing")
if _, err := intents.Put(ctx, "IEI-1", intentDoc); err != nil {
t.Fatal(err)
}
if err := intents.SetActive(ctx, "IEI-1"); err != nil {
t.Fatal(err)
}
signer, _, err := signing.GenerateKey("test-key")
if err != nil {
t.Fatal(err)
}
gate := policy.NewGate(policy.DefaultLimits())
pipeline, err := publish.New(publish.Options{
Gate: gate, Signer: signer, Store: store, Intents: intents,
})
if err != nil {
t.Fatal(err)
}
srv := NewServer(NewRevisionAPI(store, pipeline), NewIntentAPI(intents, gate))
return srv.Routes(), store
}
func descriptorJSON() contract.Revision {
pc := contract.RevisionPolicyPolicyCheckPassed
return contract.Revision{
SchemaVersion: "0.1",
ID: "R-2",
Interface: "hall-publishing",
State: contract.RevisionStateCandidate,
Contract: contract.RevisionContract{
Type: contract.RevisionContractTypeOpenapi,
Digest: contract.Digest("sha256:" + strings.Repeat("1", 64)),
},
Runtime: contract.RevisionRuntime{Upstream: "http://adapter:8080"},
Intent: contract.RevisionIntent{Version: "IEI-1"},
Policy: contract.RevisionPolicy{
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
PolicyCheck: &pc,
},
}
}
func post(t *testing.T, mux *http.ServeMux, path string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)))
return rec
}
func TestCreateRevisionVerifiesAndPublishes(t *testing.T) {
mux, store := newServer(t)
rec := post(t, mux, "/control/v1/revisions", CreateRevisionRequest{
Descriptor: descriptorJSON(),
Origin: contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
ComplexityDelta: 0.2,
RequestedTrafficShare: 0.1,
Approved: true,
ApprovedBy: &contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
})
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
var resp CreateRevisionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Descriptor == nil || resp.Descriptor.Signature == nil {
t.Fatal("published descriptor came back unsigned")
}
if !resp.Report.Passed() {
t.Errorf("report says not passed: %+v", resp.Report.Stages)
}
if _, err := store.Record(context.Background(), contract.KindRevision, "R-2"); err != nil {
t.Errorf("revision was not persisted: %v", err)
}
}
// TestRejectedCandidateIsAResultNotAFault: a rejection is normal (invariant 14)
// and must come back with its evidence rather than as a 500.
func TestRejectedCandidateIsAResultNotAFault(t *testing.T) {
mux, _ := newServer(t)
rec := post(t, mux, "/control/v1/revisions", CreateRevisionRequest{
Descriptor: descriptorJSON(),
Origin: contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
Approved: false, // the default gate requires approval
})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, want 422; body %s", rec.Code, rec.Body.String())
}
var resp CreateRevisionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.State != "REJECTED" {
t.Errorf("state = %q", resp.State)
}
failure, ok := resp.Report.FirstFailure()
if !ok {
t.Fatal("rejection carries no failing stage")
}
if failure.Stage != publish.StagePolicyCheck {
t.Errorf("failed at %s, want POLICY_CHECK", failure.Stage)
}
if len(failure.Evidence) == 0 {
t.Error("rejection carries no reasons")
}
}
func TestCreateRevisionValidatesInput(t *testing.T) {
mux, _ := newServer(t)
// A hypothesis id where a revision id belongs must not be accepted.
d := descriptorJSON()
d.ID = "H-2"
rec := post(t, mux, "/control/v1/revisions", CreateRevisionRequest{
Descriptor: d,
Origin: contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
})
if rec.Code != http.StatusBadRequest {
t.Errorf("mis-prefixed id: status = %d, want 400", rec.Code)
}
// Every candidate must name its origin: an artifact with no provenance
// cannot be audited later.
rec = post(t, mux, "/control/v1/revisions", CreateRevisionRequest{Descriptor: descriptorJSON()})
if rec.Code != http.StatusBadRequest {
t.Errorf("missing origin: status = %d, want 400", rec.Code)
}
}
func TestIntentEndpoints(t *testing.T) {
mux, _ := newServer(t)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/control/v1/intents/active", nil))
if rec.Code != http.StatusOK {
t.Fatalf("active intent: status = %d, body %s", rec.Code, rec.Body.String())
}
var got IntentResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Version != "IEI-1" || got.Mode != "FLUID-2" {
t.Errorf("active intent = %+v", got)
}
// Historical read by version.
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/control/v1/intents/IEI-1", nil))
if rec.Code != http.StatusOK {
t.Errorf("historical read: status = %d", rec.Code)
}
}
// TestRecordedIntentCannotBeRewritten: changing what a version says would
// change what already-published revisions were governed by.
func TestRecordedIntentCannotBeRewritten(t *testing.T) {
mux, _ := newServer(t)
rec := post(t, mux, "/control/v1/intents", RecordIntentRequest{
Version: "IEI-1",
Document: strings.Replace(intentDoc, "FLUID-2", "FLUID-5", 1),
})
if rec.Code != http.StatusConflict {
t.Errorf("status = %d, want 409; body %s", rec.Code, rec.Body.String())
}
}
func TestUnfilledTemplateIsRefused(t *testing.T) {
mux, _ := newServer(t)
rec := post(t, mux, "/control/v1/intents", RecordIntentRequest{
Version: "IEI-2",
Document: "mode is one of FLUID-0 FLUID-1 FLUID-2 FLUID-3 FLUID-4 FLUID-5 FLUID-6",
})
if rec.Code != http.StatusBadRequest {
t.Errorf("an unresolved template was accepted: status = %d", rec.Code)
}
}
func TestMethodsAreConstrained(t *testing.T) {
mux, _ := newServer(t)
for _, tc := range []struct{ method, path string }{
{http.MethodDelete, "/control/v1/revisions/R-2"},
{http.MethodPut, "/control/v1/intents/IEI-1"},
} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s %s: status = %d, want 405", tc.method, tc.path, rec.Code)
}
}
}