284 lines
7.9 KiB
Go
284 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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|