Some checks failed
validate / Rego (opa fmt + opa test) (push) Successful in 16s
validate / JSON Schema (ajv) (push) Successful in 20s
build-images / signing-service image (push) Successful in 24s
validate / DB (migrate up/down dry-run) (push) Failing after 26s
validate / Go (vet + test) (push) Successful in 1m16s
Adds services/verification-service exposing POST /api/v1/verify/chain for sink:dummy-sink. The handler runs the six hard-coded chain checks described in DEV-612 and reserves decision_id in a new `verification_decisions` table so a replayed sink delivery is refused without recomputing signatures. - DSSE signature verification against configurable approval + transform signer key_ids, loaded from VERIFICATION_SIGNER_KEYS (pem/hex/b64/b64u) - RFC 6962 inclusion-proof verifier (shared leaf/node hashing with services/transparency-log/pkg/backend) - Content binding: subject.digest.sha256 must match predicate.content_hash (approval) and request.payload_hash (transform) - Hard-coded coverage of allowed_transformations / allowed_sinks / expires_at predicates (OPA deferred to Phase 2 per the subtask) - Replay guard via new db/trust/migrations/0005_verification_decisions (in-memory store ships; Postgres skeleton mirrors sibling pattern) - Dockerfile mirrors signing-service pattern (distroless static + nonroot) - README documents endpoint, reason-code taxonomy, required predicate fields, and the Phase-2 non-scope Tests: happy path + 7 negatives (bad signature, missing inclusion proof, hash drift, sink not allowed, expired, replay, operation not allowed). Co-Authored-By: Paperclip <noreply@paperclip.ing>
141 lines
4.6 KiB
Go
141 lines
4.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"basicstack.de/proofedmesh/services/verification-service/internal/chain"
|
|
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
|
|
)
|
|
|
|
type stubVerifier struct {
|
|
ret chain.Verdict
|
|
err error
|
|
}
|
|
|
|
func (s *stubVerifier) Verify(_ context.Context, _ chain.Request) (chain.Verdict, error) {
|
|
return s.ret, s.err
|
|
}
|
|
|
|
// Rebuild a Server with the stub by replacing the *chain.Verifier's
|
|
// dependencies. The handler only needs to call Verifier.Verify, so we
|
|
// shim through a tiny indirection: build a Verifier whose TLog.Fetcher
|
|
// is a stub that returns ErrTLogUnavailable to force the 502 path, or
|
|
// wire the stubVerifier via a type-asserting handler clone.
|
|
//
|
|
// Simpler: pass a verifier whose dependencies make the Verify call land
|
|
// on the branch we want. Since Verifier is a concrete struct, we expose
|
|
// a tiny shim type here to avoid pulling the whole chain package into
|
|
// the test as a hard dependency.
|
|
|
|
type fakeFetcher struct{ err error }
|
|
|
|
func (f fakeFetcher) Fetch(_ context.Context, _ string) (tlog.Entry, error) {
|
|
return tlog.Entry{}, f.err
|
|
}
|
|
|
|
func newServerWithFetcher(f fakeFetcher) *Server {
|
|
return &Server{
|
|
Verifier: &chain.Verifier{
|
|
TLog: f,
|
|
// Keyring + Store intentionally nil: the request hits
|
|
// Fetch first and bails out before touching them.
|
|
Approval: chain.ApprovalConfig{
|
|
ExpectedKeyID: "k1",
|
|
TransformKeyID: "k2",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestHandleVerifyChain_BadRequestOnMalformedJSON(t *testing.T) {
|
|
s := &Server{Verifier: &chain.Verifier{}}
|
|
mux := http.NewServeMux()
|
|
s.Mount(mux)
|
|
ts := httptest.NewServer(mux)
|
|
defer ts.Close()
|
|
resp, err := http.Post(ts.URL+"/api/v1/verify/chain", "application/json", strings.NewReader("not-json"))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status: want 400, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestHandleVerifyChain_SinkTargetAliases(t *testing.T) {
|
|
// Even when the request names `target_sink` instead of `sink_target`,
|
|
// the request must reach the verifier. We prove it by observing the
|
|
// verifier produce a verdict reasons list that references the sink
|
|
// field — the simplest signal is to force a TLog-not-found verdict
|
|
// (not an error) by setting ErrNotFound.
|
|
s := newServerWithFetcher(fakeFetcher{err: tlog.ErrNotFound})
|
|
mux := http.NewServeMux()
|
|
s.Mount(mux)
|
|
ts := httptest.NewServer(mux)
|
|
defer ts.Close()
|
|
|
|
for _, body := range []string{
|
|
`{"decision_id":"d","approval_ref":"sha256:` + strings.Repeat("a", 64) + `","transform_ref":"sha256:` + strings.Repeat("b", 64) + `","payload_hash":"sha256:` + strings.Repeat("c", 64) + `","sink_target":"sink:dummy-sink"}`,
|
|
`{"decision_id":"d","approval_ref":"sha256:` + strings.Repeat("a", 64) + `","transform_ref":"sha256:` + strings.Repeat("b", 64) + `","payload_hash":"sha256:` + strings.Repeat("c", 64) + `","target_sink":"sink:dummy-sink"}`,
|
|
} {
|
|
resp, err := http.Post(ts.URL+"/api/v1/verify/chain", "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status: want 200, got %d", resp.StatusCode)
|
|
}
|
|
var out chain.Verdict
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if out.Valid {
|
|
t.Fatalf("verdict unexpectedly valid: %+v", out)
|
|
}
|
|
if len(out.Reasons) == 0 || !strings.HasPrefix(out.Reasons[0], chain.ReasonApprovalFetch) {
|
|
t.Fatalf("reasons: %v", out.Reasons)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleVerifyChain_BadGatewayOnTLogUnavailable(t *testing.T) {
|
|
s := newServerWithFetcher(fakeFetcher{err: tlog.ErrTLogUnavailable})
|
|
mux := http.NewServeMux()
|
|
s.Mount(mux)
|
|
ts := httptest.NewServer(mux)
|
|
defer ts.Close()
|
|
|
|
body := `{"decision_id":"d","approval_ref":"sha256:` + strings.Repeat("a", 64) + `","transform_ref":"sha256:` + strings.Repeat("b", 64) + `","payload_hash":"sha256:` + strings.Repeat("c", 64) + `","sink_target":"sink:dummy-sink"}`
|
|
resp, err := http.Post(ts.URL+"/api/v1/verify/chain", "application/json", bytes.NewReader([]byte(body)))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadGateway {
|
|
t.Fatalf("status: want 502, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestHandleHealth(t *testing.T) {
|
|
s := &Server{}
|
|
mux := http.NewServeMux()
|
|
s.Mount(mux)
|
|
ts := httptest.NewServer(mux)
|
|
defer ts.Close()
|
|
resp, err := http.Get(ts.URL + "/healthz")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status: want 200, got %d", resp.StatusCode)
|
|
}
|
|
}
|