package httpapi import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "basicstack.de/proofedmesh/services/dummy-sink/internal/dsse" "basicstack.de/proofedmesh/services/dummy-sink/internal/signer" "basicstack.de/proofedmesh/services/dummy-sink/internal/sink" "basicstack.de/proofedmesh/services/dummy-sink/internal/tlog" "basicstack.de/proofedmesh/services/dummy-sink/internal/verify" ) type fakeVerifier struct { calls int results []verify.Result lastReq verify.Request err error } func (f *fakeVerifier) Verify(_ context.Context, req verify.Request) (verify.Result, error) { f.lastReq = req if f.err != nil { return verify.Result{}, f.err } idx := f.calls if idx >= len(f.results) { idx = len(f.results) - 1 } f.calls++ return f.results[idx], nil } type fakeSigner struct { calls int result signer.SignedResult err error lastStm signer.Statement lastRef string } func (f *fakeSigner) Sign(_ context.Context, keyRef string, stmt signer.Statement) (signer.SignedResult, error) { f.calls++ f.lastStm = stmt f.lastRef = keyRef if f.err != nil { return signer.SignedResult{}, f.err } return f.result, nil } type fakeTLog struct { calls int result tlog.Receipt err error lastEnv dsse.Envelope lastHsh string } func (f *fakeTLog) Submit(_ context.Context, env dsse.Envelope, contentHash string) (tlog.Receipt, error) { f.calls++ f.lastEnv = env f.lastHsh = contentHash if f.err != nil { return tlog.Receipt{}, f.err } r := f.result r.ContentHash = contentHash return r, nil } func makeServerAndDeps(t *testing.T, verifier Verifier, signr Signer, tl TLog) (Server, string) { t.Helper() dir := t.TempDir() return Server{ Verifier: verifier, Store: sink.Store{DataDir: dir}, Signer: signr, TLog: tl, SinkID: "sink:dummy-sink", Actor: "agent-dummy-sink", SigningKeyRef: "trust-api/agent-dummy-sink-2026-q4", Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) }, }, dir } func startTestServer(t *testing.T, srv Server) *httptest.Server { t.Helper() mux := http.NewServeMux() srv.Mount(mux) ts := httptest.NewServer(mux) t.Cleanup(ts.Close) return ts } func deliverBody(t *testing.T, payload any, decisionID, approvalRef, transformRef string) ([]byte, string) { t.Helper() payloadRaw, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal payload: %v", err) } sum := sha256.Sum256(payloadRaw) payloadHash := "sha256:" + hex.EncodeToString(sum[:]) body, err := json.Marshal(map[string]any{ "decision_id": decisionID, "payload_hash": payloadHash, "payload": json.RawMessage(payloadRaw), "approval_ref": approvalRef, "transform_ref": transformRef, }) if err != nil { t.Fatalf("marshal body: %v", err) } return body, payloadHash } func TestDeliverHappyPath(t *testing.T) { verifier := &fakeVerifier{results: []verify.Result{{Valid: true, DecisionID: "dec-1"}}} signr := &fakeSigner{result: signer.SignedResult{ Envelope: dsse.Envelope{ PayloadType: "application/vnd.in-toto+json", Payload: "payload-b64", Signatures: []dsse.EnvelopeSignature{{KeyID: "k1", Sig: "sig1"}}, }, ContentHash: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", SignedAt: time.Now(), }} tl := &fakeTLog{result: tlog.Receipt{LogIndex: 123}} srv, dataDir := makeServerAndDeps(t, verifier, signr, tl) ts := startTestServer(t, srv) payload := map[string]any{"workflow_id": "WF-0042", "amount": 42.5, "memo": "coffee"} body, payloadHash := deliverBody(t, payload, "dec-1", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", ) resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("POST: %v", err) } defer resp.Body.Close() rawResp, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201; body=%s", resp.StatusCode, rawResp) } var out deliverResponse if err := json.Unmarshal(rawResp, &out); err != nil { t.Fatalf("decode resp: %v", err) } if out.HandoverRef != signr.result.ContentHash { t.Fatalf("handover_ref = %q, want %q", out.HandoverRef, signr.result.ContentHash) } if out.LogIndex != 123 { t.Fatalf("log_index = %d, want 123", out.LogIndex) } if out.DecisionID != "dec-1" { t.Fatalf("decision_id = %q, want dec-1", out.DecisionID) } // Payload file was written atomically. content, err := os.ReadFile(filepath.Join(dataDir, "dec-1.json")) if err != nil { t.Fatalf("read written file: %v", err) } var got map[string]any if err := json.Unmarshal(content, &got); err != nil { t.Fatalf("decode written file: %v", err) } if got["workflow_id"] != "WF-0042" { t.Fatalf("written payload workflow_id = %v", got["workflow_id"]) } // Verifier was called with the right fields. if verifier.lastReq.DecisionID != "dec-1" || verifier.lastReq.ApprovalRef == "" || verifier.lastReq.TransformRef == "" || verifier.lastReq.SinkTarget != "sink:dummy-sink" || verifier.lastReq.PayloadHash != payloadHash { t.Fatalf("verify req = %+v", verifier.lastReq) } // Signer was called with the handover statement. if signr.calls != 1 { t.Fatalf("signer.calls = %d, want 1", signr.calls) } if signr.lastRef != "trust-api/agent-dummy-sink-2026-q4" { t.Fatalf("key_ref = %q", signr.lastRef) } if signr.lastStm["predicateType"] != "https://basicstack.de/attestations/handover/v1" { t.Fatalf("predicateType = %v", signr.lastStm["predicateType"]) } // TLog was called with the signed envelope + content_hash. if tl.calls != 1 { t.Fatalf("tlog.calls = %d, want 1", tl.calls) } if tl.lastHsh != signr.result.ContentHash { t.Fatalf("tlog content_hash = %q, want %q", tl.lastHsh, signr.result.ContentHash) } if tl.lastEnv.Payload != "payload-b64" { t.Fatalf("tlog envelope payload = %q", tl.lastEnv.Payload) } } func TestDeliverInvalidChainReturns403NoWriteNoSign(t *testing.T) { verifier := &fakeVerifier{results: []verify.Result{{ Valid: false, Reasons: []string{"approval_signature_invalid", "sink_not_allowed"}, }}} signr := &fakeSigner{} tl := &fakeTLog{} srv, dataDir := makeServerAndDeps(t, verifier, signr, tl) ts := startTestServer(t, srv) body, _ := deliverBody(t, map[string]any{"x": 1}, "dec-bad", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", ) resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("POST: %v", err) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusForbidden { t.Fatalf("status = %d, want 403; body=%s", resp.StatusCode, raw) } var out errResponse if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("decode: %v", err) } if len(out.Error.Reasons) != 2 || out.Error.Reasons[0] != "approval_signature_invalid" { t.Fatalf("reasons = %v", out.Error.Reasons) } // No file written. if _, err := os.Stat(filepath.Join(dataDir, "dec-bad.json")); !os.IsNotExist(err) { t.Fatalf("expected no file, stat err = %v", err) } if signr.calls != 0 { t.Fatalf("signer must not be called; got %d", signr.calls) } if tl.calls != 0 { t.Fatalf("tlog must not be called; got %d", tl.calls) } } func TestDeliverReplayReturns409(t *testing.T) { // First call succeeds; second call is marked as replay by the // verification-service and must map to 409 with no fresh file or // signing call. envelope := dsse.Envelope{ PayloadType: "application/vnd.in-toto+json", Payload: "p1", Signatures: []dsse.EnvelopeSignature{{KeyID: "k1", Sig: "sig"}}, } verifier := &fakeVerifier{results: []verify.Result{ {Valid: true, DecisionID: "dec-replay"}, {Valid: false, Replay: true, DecisionID: "dec-replay", Reasons: []string{"decision_id already consumed"}}, }} signr := &fakeSigner{result: signer.SignedResult{ Envelope: envelope, ContentHash: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", }} tl := &fakeTLog{result: tlog.Receipt{LogIndex: 7}} srv, dataDir := makeServerAndDeps(t, verifier, signr, tl) ts := startTestServer(t, srv) body, _ := deliverBody(t, map[string]any{"x": "once"}, "dec-replay", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", ) resp1, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("POST1: %v", err) } resp1.Body.Close() if resp1.StatusCode != http.StatusCreated { t.Fatalf("first call status = %d, want 201", resp1.StatusCode) } if _, err := os.Stat(filepath.Join(dataDir, "dec-replay.json")); err != nil { t.Fatalf("expected file after first call: %v", err) } callsAfterFirst := signr.calls resp2, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("POST2: %v", err) } defer resp2.Body.Close() rawResp, _ := io.ReadAll(resp2.Body) if resp2.StatusCode != http.StatusConflict { t.Fatalf("second call status = %d, want 409; body=%s", resp2.StatusCode, rawResp) } if signr.calls != callsAfterFirst { t.Fatalf("signer must not be called on replay; calls = %d (was %d)", signr.calls, callsAfterFirst) } } func TestDeliverPayloadHashMismatch(t *testing.T) { verifier := &fakeVerifier{results: []verify.Result{{Valid: true}}} signr := &fakeSigner{} tl := &fakeTLog{} srv, _ := makeServerAndDeps(t, verifier, signr, tl) ts := startTestServer(t, srv) payload, _ := json.Marshal(map[string]any{"x": 1}) bogus := "sha256:" + strings.Repeat("0", 64) body, _ := json.Marshal(map[string]any{ "decision_id": "dec-bad-hash", "payload_hash": bogus, "payload": json.RawMessage(payload), "approval_ref": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "transform_ref": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", }) resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("POST: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } if verifier.calls != 0 { t.Fatalf("verify must not be called on bad hash; calls=%d", verifier.calls) } } func TestDeliverInvalidBody(t *testing.T) { verifier := &fakeVerifier{results: []verify.Result{{Valid: true}}} srv, _ := makeServerAndDeps(t, verifier, &fakeSigner{}, &fakeTLog{}) ts := startTestServer(t, srv) for _, body := range []string{ `{}`, `{"decision_id":"d","payload_hash":"nothex","payload":{"x":1},"approval_ref":"a","transform_ref":"t"}`, `{"decision_id":"d","payload_hash":"sha256:` + strings.Repeat("0", 64) + `","approval_ref":"a","transform_ref":"t"}`, } { resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader([]byte(body))) if err != nil { t.Fatalf("POST: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 for body %s", resp.StatusCode, body) } } } func TestDeliverVerifierUnavailableReturns502(t *testing.T) { verifier := &fakeVerifier{err: verify.ErrVerifyUnavailable} srv, _ := makeServerAndDeps(t, verifier, &fakeSigner{}, &fakeTLog{}) ts := startTestServer(t, srv) body, _ := deliverBody(t, map[string]any{"x": 1}, "d1", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", ) resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("POST: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadGateway { t.Fatalf("status = %d, want 502", resp.StatusCode) } } func TestDeliverSigningDeniedPropagates403(t *testing.T) { verifier := &fakeVerifier{results: []verify.Result{{Valid: true}}} signr := &fakeSigner{err: errors.New("wrapped: " + signer.ErrSigningDenied.Error())} // Ensure signer.err is the ErrSigningDenied sentinel so errors.Is matches. signr.err = signer.ErrSigningDenied srv, _ := makeServerAndDeps(t, verifier, signr, &fakeTLog{}) ts := startTestServer(t, srv) body, _ := deliverBody(t, map[string]any{"x": 1}, "d1", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", ) resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("POST: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("status = %d, want 403", resp.StatusCode) } }