Some checks failed
ci / build (push) Failing after 3h11m37s
Completes FLUID-WP-0005. Normalization and redaction live on one path, shared by the in-process emitter and the ingest endpoint: two paths with two normalizations would eventually disagree, and the disagreement would surface as a pressure finding that is really a pipeline bug. Telemetry kind is inferred from event shape rather than defaulting to "request", since an error filed as a request understates the interface's failure rate. A malformed event in a batch does not discard the rest. Feedback is stored as evidence and creates no pressure and no hypothesis on its own, per API Standards 15, with the consumer recorded as the actor so their untrusted status stays visible in the audit trail. The feedback endpoint is the only consumer-reachable part of the control plane. The observation endpoints are not served at all when no pseudonymization salt is configured, rather than served with a generated one: a salt that changed per run would make the same consumer look new every time and every cohort count wrong. Adds an end-to-end test driving real traffic through the gateway and confirming it becomes a classified pressure record, with no raw consumer identity reaching the store. 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), 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)
|
|
}
|
|
}
|
|
}
|