ProofedMesh/services/approval-service/internal/signer/signer_test.go
Paperclip 3a01cc651f approval-service: Phase-1 content-binding approval service (DEV-608)
New services/approval-service with the Session → Preview → Confirm
pipeline required by DEV-606/DEV-608:

* POST /api/v1/sessions starts a Pocket-ID-bound approval session.
* POST /api/v1/approvals/preview renders content server-side, computes
  H_content = sha256(canonical JSON), stores the (nonce, salt, canonical,
  challenge) tuple and returns the human verification digits plus the
  WebAuthn challenge = nonce || H_content.
* POST /api/v1/approvals/confirm verifies the WebAuthn assertion against
  the stored challenge, DSSE-signs the Approval statement via the
  signing-service (key_ref=trust-api/approval-signer-2026-q4), submits
  to the transparency-log, and returns {approval_id, attestation_ref,
  log_index}. TLog failures leave the row at attestation-pending for
  the reconciler.

* db/trust/migrations/0004_approval_sessions.{up,down}.sql: new tables
  approval_sessions, approval_previews, approvals (schema matches the
  Phase 1 store contract).

* internal/content: canonical JSON + H_content + 6-digit verification
  code (byte-compatible with signing-service/internal/canonical).
* internal/webauthn: stdlib-only Phase-1 verifier; wire shape stays
  WebAuthn-compatible for the Phase-2 swap to go-webauthn/webauthn.
* internal/signer, internal/tlog: HTTP clients mapping 5xx / connection
  errors to typed ErrSignerUnavailable / ErrTLogUnavailable so the
  HTTP layer can return 502 for signing failures and persist
  attestation-pending on TLog failure.
* internal/service.Orchestrator ties the pipeline together and persists
  the Approval BEFORE the TLog submit (never return a log_index for
  an un-persisted approval).

Tests cover: happy path, tampered H_content → 400, expired challenge
→ 403, signer 5xx + conn-refused → 502, TLog failure → row stays
attestation-pending, duplicate confirm → 409, session expired → 403.
go vet clean, go test ./... passes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 19:42:06 +00:00

88 lines
2.6 KiB
Go

package signer
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestSignDSSE_HappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/sign/dsse" {
t.Errorf("unexpected path %q", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
t.Errorf("auth header = %q", got)
}
var body signRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.KeyRef != "key-x" {
t.Errorf("keyRef = %q", body.KeyRef)
}
resp := signResponse{
Envelope: Envelope{PayloadType: "application/vnd.in-toto+json", Payload: "aGk"},
ContentHash: "sha256:deadbeef",
SignedAt: time.Now(),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
c := Client{BaseURL: srv.URL, TokenSource: StaticToken("tok")}
env, hash, err := c.SignDSSE(context.Background(), "key-x", Statement{"_type": "x"})
if err != nil {
t.Fatalf("SignDSSE: %v", err)
}
if hash != "sha256:deadbeef" {
t.Errorf("hash = %q", hash)
}
if env.PayloadType != "application/vnd.in-toto+json" {
t.Errorf("payloadType = %q", env.PayloadType)
}
}
func TestSignDSSE_5xxMapsToUnavailable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer srv.Close()
c := Client{BaseURL: srv.URL}
_, _, err := c.SignDSSE(context.Background(), "k", Statement{"_type": "x"})
if !errors.Is(err, ErrSignerUnavailable) {
t.Fatalf("want ErrSignerUnavailable, got %v", err)
}
}
func TestSignDSSE_ConnRefusedMapsToUnavailable(t *testing.T) {
// Nonexistent address — the dial will fail immediately.
c := Client{
BaseURL: "http://127.0.0.1:1", // reserved, almost always refused
HTTPClient: &http.Client{Timeout: 500 * time.Millisecond},
}
_, _, err := c.SignDSSE(context.Background(), "k", Statement{"_type": "x"})
if !errors.Is(err, ErrSignerUnavailable) {
t.Fatalf("want ErrSignerUnavailable, got %v", err)
}
}
func TestSignDSSE_4xxDoesNotLookUnavailable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
c := Client{BaseURL: srv.URL}
_, _, err := c.SignDSSE(context.Background(), "k", Statement{"_type": "x"})
if err == nil {
t.Fatalf("expected error")
}
if errors.Is(err, ErrSignerUnavailable) {
t.Fatalf("4xx must not be reported as ErrSignerUnavailable: %v", err)
}
}