Some checks failed
ci / build (push) Has been cancelled
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
249 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|