fluid-core/internal/publish/pipeline_test.go
tegwick a2d561eae5 Add signing, trust types, policy gate and publication pipeline
FLUID-WP-0004 T01, T02, T05, T06. The pipeline is the only path from
Candidate to Verified: no other code constructs a Verified value, and
Publish takes one, so "AI-generated artifacts are untrusted until
verified" is a property of the type signatures rather than a rule people
are asked to remember.

The policy gate is a pure function of the candidate, the governing
intent and configured limits. It cannot consult a model or take an
opinion as input, because a gate that can be argued with is not a gate.
Two behaviours it enforces are worth naming: the tighter of the
descriptor's own traffic ceiling and the gate's wins, so a descriptor
can restrict itself but never widen; and a daimon cannot authorize its
own promotion below FLUID-5, since generation authority is not promotion
authority.

Signatures cover the canonical document with the signature member
removed, so a signed descriptor round-trips and a tampered one does not.
The registry now refuses anything that does not verify, which is what
makes the pipeline's signature mean something at the router.

An unchecked pipeline stage is recorded as unchecked rather than
omitted, so a pipeline with no security check cannot look identical to
one that passed.

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 02:56:30 +02:00

283 lines
7.9 KiB
Go

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