408 lines
13 KiB
Go
408 lines
13 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|