package chain import ( "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/hex" "strings" "testing" "time" "basicstack.de/proofedmesh/services/verification-service/internal/canonical" "basicstack.de/proofedmesh/services/verification-service/internal/dsse" "basicstack.de/proofedmesh/services/verification-service/internal/registry" "basicstack.de/proofedmesh/services/verification-service/internal/store" "basicstack.de/proofedmesh/services/verification-service/internal/tlog" ) const ( approvalKeyID = "trust-api/approval-signer-2026-q4" transformKeyID = "trust-api/agent-testagent-transform-2026-q4" ) // fixedClock returns a deterministic Now function. func fixedClock(ts time.Time) func() time.Time { return func() time.Time { return ts } } // fakeFetcher is a map-backed tlog.Fetcher. type fakeFetcher struct { entries map[string]tlog.Entry } func (f *fakeFetcher) Fetch(_ context.Context, ref string) (tlog.Entry, error) { e, ok := f.entries[ref] if !ok { return tlog.Entry{}, tlog.ErrNotFound } return e, nil } // fixture bundles everything a happy-path test needs. Negative-path // tests derive from fixture and mutate exactly one input. type fixture struct { t *testing.T now time.Time payloadHash string decisionID string sinkTarget string approvalEntry tlog.Entry transformEntry tlog.Entry tree *memTree keyring *registry.Static storev store.Store } func newFixture(t *testing.T) *fixture { t.Helper() now := time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC) payloadHash := "sha256:" + hex.EncodeToString(sha(seedBytes("payload-xyz"))) keyring := registry.NewStatic() // Approval signer. apPub, apPriv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("gen approval key: %v", err) } keyring.Register(approvalKeyID, apPub) // Transform signer. trPub, trPriv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("gen transform key: %v", err) } keyring.Register(transformKeyID, trPub) // Approval statement. Content_hash is self-asserted — in the real // pipeline the orchestrator computes it from the preview's canonical // content; here we just mint a stable value and reuse it as the // subject digest to satisfy the content-binding check. approvalContent := "sha256:" + hex.EncodeToString(sha(seedBytes("approval-xyz"))) apStmt := map[string]any{ "_type": "https://in-toto.io/Statement/v1", "subject": []any{map[string]any{ "name": "approval/pv-xyz", "digest": map[string]any{"sha256": stripSha256Prefix(approvalContent)}, }}, "predicateType": "https://basicstack.de/attestations/approval/v1", "predicate": map[string]any{ "approval_schema": "basicstack.de/attestations/approval/v1", "preview_id": "pv-xyz", "session_id": "sess-xyz", "content_hash": approvalContent, "allowed_transformations": []any{"expense.map_to_sink"}, "allowed_sinks": []any{"sink:dummy-sink"}, "expires_at": now.Add(10 * time.Minute).Format(time.RFC3339), "performed_by": map[string]any{ "iss": "https://pocket.local", "sub": "user-xyz", "session_id": "sess-xyz", }, "performed_at": now.Format(time.RFC3339), "verification_digits": "1234", }, } apBytes, err := canonical.Marshal(apStmt) if err != nil { t.Fatalf("canonical approval: %v", err) } apEnv := dsse.Sign("application/vnd.in-toto+json", apBytes, approvalKeyID, apPriv) // Transform statement. Subject digest == payload_hash (hex only). trStmt := map[string]any{ "_type": "https://in-toto.io/Statement/v1", "subject": []any{map[string]any{ "name": "transform/wf-xyz/handover", "digest": map[string]any{"sha256": stripSha256Prefix(payloadHash)}, }}, "predicateType": "https://basicstack.de/attestations/transform/v1", "predicate": map[string]any{ "workflow_id": "wf-xyz", "actor": "agent-testagent-transform", "operation": "expense.map_to_sink", "sink_target": "sink:dummy-sink", "approval_ref": "(filled below)", "transform_version": "v0.1.0", "issued_at": now.Format(time.RFC3339), }, } trBytes, err := canonical.Marshal(trStmt) if err != nil { t.Fatalf("canonical transform: %v", err) } trEnv := dsse.Sign("application/vnd.in-toto+json", trBytes, transformKeyID, trPriv) // Build a two-leaf Merkle tree out of the two envelopes. tree := &memTree{} apEntry := tree.add(apEnv) trEntry := tree.add(trEnv) tree.refreshProofs(&apEntry, &trEntry) return &fixture{ t: t, now: now, payloadHash: payloadHash, decisionID: "dec-xyz-1", sinkTarget: "sink:dummy-sink", approvalEntry: apEntry, transformEntry: trEntry, tree: tree, keyring: keyring, storev: store.NewMemory(), } } func (f *fixture) verifier() *Verifier { fetcher := &fakeFetcher{entries: map[string]tlog.Entry{ f.approvalEntry.ContentHash: f.approvalEntry, f.transformEntry.ContentHash: f.transformEntry, }} return &Verifier{ TLog: fetcher, Keyring: f.keyring, Store: f.storev, Now: fixedClock(f.now), Approval: ApprovalConfig{ ExpectedKeyID: approvalKeyID, TransformKeyID: transformKeyID, }, } } func (f *fixture) request() Request { return Request{ DecisionID: f.decisionID, ApprovalRef: f.approvalEntry.ContentHash, TransformRef: f.transformEntry.ContentHash, PayloadHash: f.payloadHash, SinkTarget: f.sinkTarget, } } func TestVerify_HappyPath(t *testing.T) { f := newFixture(t) v := f.verifier() verdict, err := v.Verify(context.Background(), f.request()) if err != nil { t.Fatalf("verify: %v", err) } if !verdict.Valid { t.Fatalf("verdict not valid: %+v", verdict) } if verdict.Replay { t.Fatalf("verdict replay flag unexpectedly set") } if len(verdict.Actors) != 2 { t.Fatalf("actors: want 2, got %v", verdict.Actors) } if verdict.Actors[0] != approvalKeyID || verdict.Actors[1] != transformKeyID { t.Fatalf("actors: %v", verdict.Actors) } } func TestVerify_Replay(t *testing.T) { f := newFixture(t) v := f.verifier() // First call reserves the decision_id. verdict, err := v.Verify(context.Background(), f.request()) if err != nil || !verdict.Valid { t.Fatalf("first call: want valid=true, got valid=%v err=%v reasons=%v", verdict.Valid, err, verdict.Reasons) } // Second call with the SAME decision_id MUST land as replay=true, // valid=false — the store refuses the insert so the chain does not // re-authorise the sink write. v2 := f.verifier() v2.Store = f.storev // keep the same store so the first row is still there verdict2, err := v2.Verify(context.Background(), f.request()) if err != nil { t.Fatalf("second call err: %v", err) } if verdict2.Valid { t.Fatalf("second call: want valid=false, got %+v", verdict2) } if !verdict2.Replay { t.Fatalf("second call: want replay=true, got %+v", verdict2) } if !containsReason(verdict2.Reasons, ReasonReplay) { t.Fatalf("replay reason missing: %v", verdict2.Reasons) } } func TestVerify_BadSignature(t *testing.T) { f := newFixture(t) // Flip a bit in the transform signature so dsse.Verify rejects it. env := f.transformEntry.Envelope raw, _ := base64.RawURLEncoding.DecodeString(env.Signatures[0].Sig) raw[0] ^= 0xff env.Signatures[0].Sig = base64.RawURLEncoding.EncodeToString(raw) f.transformEntry.Envelope = env v := f.verifier() verdict, err := v.Verify(context.Background(), f.request()) if err != nil { t.Fatalf("verify: %v", err) } if verdict.Valid { t.Fatalf("verdict unexpectedly valid") } if !containsReason(verdict.Reasons, ReasonTransformSignatureInvalid) { t.Fatalf("want %s, got %v", ReasonTransformSignatureInvalid, verdict.Reasons) } } func TestVerify_InclusionProofMissing(t *testing.T) { f := newFixture(t) // Drop the audit path on the approval entry — the proof should fail // to recompute the tree root. f.approvalEntry.InclusionProof.Hashes = nil v := f.verifier() verdict, err := v.Verify(context.Background(), f.request()) if err != nil { t.Fatalf("verify: %v", err) } if verdict.Valid { t.Fatalf("verdict unexpectedly valid") } if !hasPrefix(verdict.Reasons, ReasonApprovalInclusionInvalid) { t.Fatalf("want %s*, got %v", ReasonApprovalInclusionInvalid, verdict.Reasons) } } func TestVerify_HashDrift(t *testing.T) { f := newFixture(t) // Flip the payload_hash the caller claims; transform.subject.digest // no longer matches. driftHash := "sha256:" + hex.EncodeToString(sha(seedBytes("wrong-payload"))) req := f.request() req.PayloadHash = driftHash v := f.verifier() verdict, err := v.Verify(context.Background(), req) if err != nil { t.Fatalf("verify: %v", err) } if verdict.Valid { t.Fatalf("verdict unexpectedly valid") } if !hasPrefix(verdict.Reasons, ReasonTransformHashDrift) { t.Fatalf("want %s*, got %v", ReasonTransformHashDrift, verdict.Reasons) } } func TestVerify_SinkNotAllowed(t *testing.T) { f := newFixture(t) req := f.request() req.SinkTarget = "sink:unknown" v := f.verifier() verdict, err := v.Verify(context.Background(), req) if err != nil { t.Fatalf("verify: %v", err) } if verdict.Valid { t.Fatalf("verdict unexpectedly valid") } if !hasPrefix(verdict.Reasons, ReasonSinkNotAllowed) { t.Fatalf("want %s*, got %v", ReasonSinkNotAllowed, verdict.Reasons) } } func TestVerify_Expired(t *testing.T) { f := newFixture(t) // Push the clock past the approval's expires_at. v := f.verifier() v.Now = fixedClock(f.now.Add(30 * time.Minute)) verdict, err := v.Verify(context.Background(), f.request()) if err != nil { t.Fatalf("verify: %v", err) } if verdict.Valid { t.Fatalf("verdict unexpectedly valid") } if !hasPrefix(verdict.Reasons, ReasonApprovalExpired) { t.Fatalf("want %s*, got %v", ReasonApprovalExpired, verdict.Reasons) } } func TestVerify_OperationNotAllowed(t *testing.T) { // Rebuild the fixture with a transform that uses an operation the // approval does not list — this is a stricter negative than SinkNotAllowed // since it exercises predicate.allowed_transformations. f := newFixtureCustom(t, func(trPredicate map[string]any) { trPredicate["operation"] = "expense.unauthorised" }) v := f.verifier() verdict, err := v.Verify(context.Background(), f.request()) if err != nil { t.Fatalf("verify: %v", err) } if verdict.Valid { t.Fatalf("verdict unexpectedly valid") } if !hasPrefix(verdict.Reasons, ReasonTransformationNotAllowed) { t.Fatalf("want %s*, got %v", ReasonTransformationNotAllowed, verdict.Reasons) } } // ----- helpers ----- // newFixtureCustom builds a fixture with a transform-predicate mutation // applied before signing. Used by tests that need to exercise fields // outside the default fixture (e.g. operation not in allowed_transformations). func newFixtureCustom(t *testing.T, mutateTransform func(map[string]any)) *fixture { t.Helper() f := newFixture(t) // Pull the transform payload out, mutate, re-sign, re-tree. _, trPriv, _ := ed25519.GenerateKey(rand.Reader) // Re-register with the registry so the signing matches. trPub := trPriv.Public().(ed25519.PublicKey) f.keyring.Register(transformKeyID, trPub) trPredicate := map[string]any{ "workflow_id": "wf-xyz", "actor": "agent-testagent-transform", "operation": "expense.map_to_sink", "sink_target": "sink:dummy-sink", "approval_ref": "(filled below)", "transform_version": "v0.1.0", "issued_at": f.now.Format(time.RFC3339), } mutateTransform(trPredicate) trStmt := map[string]any{ "_type": "https://in-toto.io/Statement/v1", "subject": []any{map[string]any{ "name": "transform/wf-xyz/handover", "digest": map[string]any{"sha256": stripSha256Prefix(f.payloadHash)}, }}, "predicateType": "https://basicstack.de/attestations/transform/v1", "predicate": trPredicate, } trBytes, err := canonical.Marshal(trStmt) if err != nil { t.Fatalf("re-canonicalise transform: %v", err) } trEnv := dsse.Sign("application/vnd.in-toto+json", trBytes, transformKeyID, trPriv) // Rebuild the tree so proofs stay consistent. tree := &memTree{} apEntry := tree.add(f.approvalEntry.Envelope) trEntry := tree.add(trEnv) tree.refreshProofs(&apEntry, &trEntry) f.approvalEntry = apEntry f.transformEntry = trEntry f.tree = tree return f } func containsReason(reasons []string, want string) bool { for _, r := range reasons { if r == want { return true } } return false } func hasPrefix(reasons []string, prefix string) bool { for _, r := range reasons { if r == prefix || len(r) > len(prefix) && strings.HasPrefix(r, prefix+":") { return true } } return false }