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), nil, nil) 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) } } }