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>
59 lines
1.6 KiB
Go
59 lines
1.6 KiB
Go
package sessions
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/store"
|
|
)
|
|
|
|
func TestStartSession_UsesTTL(t *testing.T) {
|
|
st := store.NewMemory()
|
|
m := New(st)
|
|
m.SessionTTL = 5 * time.Minute
|
|
m.Now = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) }
|
|
s, err := m.StartSession(context.Background(), "alice")
|
|
if err != nil {
|
|
t.Fatalf("StartSession: %v", err)
|
|
}
|
|
want := time.Date(2026, 1, 1, 12, 5, 0, 0, time.UTC)
|
|
if !s.ActiveUntil.Equal(want) {
|
|
t.Errorf("ActiveUntil = %v want %v", s.ActiveUntil, want)
|
|
}
|
|
if s.UserSub != "alice" {
|
|
t.Errorf("UserSub = %q", s.UserSub)
|
|
}
|
|
}
|
|
|
|
func TestGetActiveSession_WrongUser(t *testing.T) {
|
|
st := store.NewMemory()
|
|
m := New(st)
|
|
m.Now = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) }
|
|
s, err := m.StartSession(context.Background(), "alice")
|
|
if err != nil {
|
|
t.Fatalf("StartSession: %v", err)
|
|
}
|
|
_, err = m.GetActiveSession(context.Background(), s.ID, "bob")
|
|
if !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatalf("want ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetActiveSession_Expired(t *testing.T) {
|
|
st := store.NewMemory()
|
|
m := New(st)
|
|
m.SessionTTL = time.Minute
|
|
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
m.Now = func() time.Time { return t0 }
|
|
s, err := m.StartSession(context.Background(), "alice")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.Now = func() time.Time { return t0.Add(2 * time.Minute) }
|
|
_, err = m.GetActiveSession(context.Background(), s.ID, "alice")
|
|
if !errors.Is(err, ErrSessionExpired) {
|
|
t.Fatalf("want ErrSessionExpired, got %v", err)
|
|
}
|
|
}
|