package publish import ( "context" "crypto/ed25519" "errors" "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/signing" ) const intentDoc = `# Interface Evolution Intent **Current operational authority mode:** FLUID-2 ` type fixture struct { pipeline *Pipeline store *evidence.SQLStore intents *intent.Store pub ed25519.PublicKey keyID string } func newFixture(t *testing.T, limits policy.Limits, checks ...Check) *fixture { 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, pub, err := signing.GenerateKey("test-key") if err != nil { t.Fatal(err) } p, err := New(Options{ Checks: checks, Gate: policy.NewGate(limits), Signer: signer, Store: store, Intents: intents, }) if err != nil { t.Fatal(err) } return &fixture{pipeline: p, store: store, intents: intents, pub: pub, keyID: signer.KeyID()} } func candidate() Candidate { pc := contract.RevisionPolicyPolicyCheckPassed return NewCandidate(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, }, }, contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"}) } func request() PromotionRequest { return PromotionRequest{ AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation}, ComplexityDelta: 0.2, RequestedTrafficShare: 0.10, Approved: true, ApprovedBy: &contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"}, } } func passingCheck(stage Stage) Check { return CheckFunc{StageName: stage, Fn: func(context.Context, Candidate) StageResult { return StageResult{Passed: true, Detail: "ok", Evidence: []string{"test:" + string(stage)}} }} } func failingCheck(stage Stage, why string) Check { return CheckFunc{StageName: stage, Fn: func(context.Context, Candidate) StageResult { return StageResult{Passed: false, Detail: why} }} } func TestPipelineVerifiesSignsAndPublishes(t *testing.T) { ctx := context.Background() f := newFixture(t, policy.DefaultLimits(), passingCheck(StageContractCheck), passingCheck(StageTest), passingCheck(StageSecurityCheck), ) verified, report, err := f.pipeline.Run(ctx, candidate(), request()) if err != nil { t.Fatalf("pipeline rejected a conforming candidate: %v", err) } if !report.Passed() { t.Fatalf("report says not passed: %+v", report.Stages) } // The signature is what the checks earn, and it must verify against the // signer's key. d := verified.Descriptor() if d.Signature == nil { t.Fatal("verified descriptor carries no signature") } v := signing.NewVerifier(map[string]ed25519.PublicKey{f.keyID: f.pub}) sig := &signing.Signature{ Algorithm: string(d.Signature.Algorithm), KeyID: d.Signature.KeyID, Value: d.Signature.Value, } if err := v.Verify(contract.RevisionDescriptorDocument{Revision: d}, sig); err != nil { t.Fatalf("signature does not verify: %v", err) } if err := f.pipeline.Publish(ctx, verified); err != nil { t.Fatalf("publish: %v", err) } // Publication must bind the revision to its governing intent, or the // Blueprint 27 audit question becomes unanswerable. got, err := f.intents.GoverningIntent(ctx, "R-2") if err != nil { t.Fatalf("no governing intent recorded: %v", err) } if got.Version != "IEI-1" { t.Errorf("governing intent = %s, want IEI-1", got.Version) } } func TestPipelineStopsAtFirstFailingStage(t *testing.T) { ctx := context.Background() f := newFixture(t, policy.DefaultLimits(), passingCheck(StageContractCheck), failingCheck(StageTest, "regression suite failed: 3 of 41"), passingCheck(StageSecurityCheck), ) _, report, err := f.pipeline.Run(ctx, candidate(), request()) if err == nil { t.Fatal("pipeline accepted a candidate whose tests failed") } var rejected *ErrRejected if !errors.As(err, &rejected) { t.Fatalf("got %T, want *ErrRejected", err) } if rejected.Failure.Stage != StageTest { t.Errorf("failed at %s, want TEST", rejected.Failure.Stage) } // Nothing after the failure should have run: a security check that never // executed must not appear as passed. for _, s := range report.Stages { if s.Stage == StageSecurityCheck { t.Error("a stage after the failure was executed") } } } // TestUncheckedStagesAreVisible guards against a pipeline with no security // check looking identical to one that passed. func TestUncheckedStagesAreVisible(t *testing.T) { f := newFixture(t, policy.DefaultLimits()) _, report, err := f.pipeline.Run(context.Background(), candidate(), request()) if err != nil { t.Fatal(err) } var security StageResult for _, s := range report.Stages { if s.Stage == StageSecurityCheck { security = s } } if security.Stage == "" { t.Fatal("security stage missing from the report entirely") } if !strings.Contains(security.Detail, "no check configured") { t.Errorf("an unchecked stage does not say so: %q", security.Detail) } } // TestPolicyGateRejectionIsRecorded confirms the gate sits inside the pipeline // rather than beside it. func TestPolicyGateRejectionIsRecorded(t *testing.T) { ctx := context.Background() f := newFixture(t, policy.DefaultLimits()) req := request() req.Approved = false req.ApprovedBy = nil _, _, err := f.pipeline.Run(ctx, candidate(), req) if err == nil { t.Fatal("an unapproved candidate was verified") } events, qerr := f.store.Events(ctx, evidence.EventFilter{EntityID: "R-2"}) if qerr != nil { t.Fatal(qerr) } found := false for _, ev := range events { if ev.EventType == "POLICY_CHECK_FAILED" { found = true if !strings.Contains(ev.Reason, "authorization") { t.Errorf("rejection reason not recorded usefully: %q", ev.Reason) } } } if !found { t.Error("the policy gate rejection left no audit event") } } // TestPublishRequiresVerification is the type-level check behind invariant 9. // A zero Verified value has no signature and must be refused. func TestPublishRequiresVerification(t *testing.T) { f := newFixture(t, policy.DefaultLimits()) if err := f.pipeline.Publish(context.Background(), Verified{}); !errors.Is(err, ErrNotVerified) { t.Errorf("publishing an unverified value returned %v, want ErrNotVerified", err) } } func TestEveryStageLeavesAnEvent(t *testing.T) { ctx := context.Background() f := newFixture(t, policy.DefaultLimits(), passingCheck(StageTest)) if _, _, err := f.pipeline.Run(ctx, candidate(), request()); err != nil { t.Fatal(err) } events, err := f.store.Events(ctx, evidence.EventFilter{EntityID: "R-2"}) if err != nil { t.Fatal(err) } // Every arrow should be independently observable (Blueprint 46). seen := map[string]bool{} for _, ev := range events { seen[ev.EventType] = true } for _, want := range []string{ "SOURCE_PASSED", "BUILD_PASSED", "CONTRACT_CHECK_PASSED", "TEST_PASSED", "SECURITY_CHECK_PASSED", "POLICY_CHECK_PASSED", "SIGN_PASSED", } { if !seen[want] { t.Errorf("no audit event for %s", want) } } }