ProofedMesh/services/dummy-sink/internal/httpapi/server_test.go

408 lines
13 KiB
Go
Raw Normal View History

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