package httpapi_test import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "basicstack.de/proofedmesh/services/approval-service/internal/auth" "basicstack.de/proofedmesh/services/approval-service/internal/httpapi" "basicstack.de/proofedmesh/services/approval-service/internal/service" "basicstack.de/proofedmesh/services/approval-service/internal/sessions" "basicstack.de/proofedmesh/services/approval-service/internal/signer" "basicstack.de/proofedmesh/services/approval-service/internal/store" "basicstack.de/proofedmesh/services/approval-service/internal/tlog" "basicstack.de/proofedmesh/services/approval-service/internal/webauthn" pkgapi "basicstack.de/proofedmesh/services/approval-service/pkg/httpapi" ) // fixture bundles everything a test needs to drive the HTTP server. type fixture struct { t *testing.T server *httptest.Server userSub string credID string credPriv ed25519.PrivateKey rpID string origin string fakeSigner *fakeSigner fakeTLog *fakeTLog store *store.Memory } type fakeSigner struct { mu sync.Mutex err error wantKeyRef string seenStatement signer.Statement } func (f *fakeSigner) SignDSSE(_ context.Context, keyRef string, stmt signer.Statement) (signer.Envelope, string, error) { f.mu.Lock() defer f.mu.Unlock() if f.err != nil { return signer.Envelope{}, "", f.err } if f.wantKeyRef != "" && keyRef != f.wantKeyRef { return signer.Envelope{}, "", errors.New("unexpected keyRef") } f.seenStatement = stmt // Produce a deterministic envelope so tests can assert on it. payload := base64.RawURLEncoding.EncodeToString([]byte("payload-bytes")) env := signer.Envelope{ PayloadType: "application/vnd.in-toto+json", Payload: payload, Signatures: []signer.EnvelopeSignature{{KeyID: keyRef, Sig: "AA"}}, } envBytes, _ := json.Marshal(env) sum := sha256.Sum256(envBytes) return env, "sha256:" + hex(sum[:]), nil } type fakeTLog struct { mu sync.Mutex err error submits int lastHash string } func (f *fakeTLog) Submit(_ context.Context, _ signer.Envelope, contentHash string) (tlog.Receipt, error) { f.mu.Lock() defer f.mu.Unlock() f.submits++ f.lastHash = contentHash if f.err != nil { return tlog.Receipt{}, f.err } return tlog.Receipt{ LogIndex: 42, ContentHash: contentHash, LoggedAt: time.Now().UTC(), }, nil } // newFixture wires a Server backed by an in-memory store + fakes. A single // WebAuthn credential is pre-registered for userSub so tests can confirm // previews without going through a real enrollment ceremony. func newFixture(t *testing.T, opts ...func(*service.Orchestrator)) *fixture { t.Helper() st := store.NewMemory() mgr := sessions.New(st) mgr.SessionTTL = 15 * time.Minute mgr.ChallengeTTL = 2 * time.Minute userSub := "user-alice" pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("gen key: %v", err) } idBytes := make([]byte, 16) _, _ = rand.Read(idBytes) credID := base64.RawURLEncoding.EncodeToString(idBytes) webStore := webauthn.NewStore() if err := webStore.AddCredential(webauthn.Credential{ ID: credID, UserSub: userSub, PublicKey: pub, }); err != nil { t.Fatalf("register credential: %v", err) } rpID := "trust.basicstack.de" origin := "https://trust.basicstack.de" sig := &fakeSigner{wantKeyRef: "trust-api/approval-signer-2026-q4"} submitter := &fakeTLog{} orch := &service.Orchestrator{ Store: st, Sessions: mgr, Webauthn: webStore, WebauthnCfg: webauthn.VerifyConfig{ RPID: rpID, AllowedOrigins: []string{origin}, }, Signer: sig, Submitter: submitter, SigningKeyRef: "trust-api/approval-signer-2026-q4", AttestationIssuer: "https://auth.basicstack.de", } for _, o := range opts { o(orch) } srv := &httpapi.Server{ Auth: httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) { return auth.Principal{Sub: userSub, Groups: []string{"proofedmesh-admin"}}, nil }), Sessions: mgr, Orchestrator: orch, } mux := http.NewServeMux() srv.Mount(mux) ts := httptest.NewServer(mux) t.Cleanup(ts.Close) return &fixture{ t: t, server: ts, userSub: userSub, credID: credID, credPriv: priv, rpID: rpID, origin: origin, fakeSigner: sig, fakeTLog: submitter, store: st, } } func (f *fixture) startSession() pkgapi.CreateSessionResponse { f.t.Helper() var out pkgapi.CreateSessionResponse f.do("POST", "/api/v1/sessions", nil, http.StatusCreated, &out) return out } func (f *fixture) preview(sessionID string, content map[string]any, want int) pkgapi.PreviewResponse { f.t.Helper() var out pkgapi.PreviewResponse f.do("POST", "/api/v1/approvals/preview", pkgapi.PreviewRequest{ SessionID: sessionID, Content: content, }, want, &out) return out } func (f *fixture) confirm(req pkgapi.ConfirmRequest, want int) (*pkgapi.ConfirmResponse, *pkgapi.Error) { f.t.Helper() resp := f.doRaw("POST", "/api/v1/approvals/confirm", req) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != want { f.t.Fatalf("confirm: status %d want %d: %s", resp.StatusCode, want, string(body)) } if want >= 200 && want < 300 { var out pkgapi.ConfirmResponse if err := json.Unmarshal(body, &out); err != nil { f.t.Fatalf("decode confirm: %v (%s)", err, string(body)) } return &out, nil } var ebody pkgapi.Error if err := json.Unmarshal(body, &ebody); err != nil { f.t.Fatalf("decode confirm error: %v (%s)", err, string(body)) } return nil, &ebody } func (f *fixture) do(method, path string, body any, want int, out any) { f.t.Helper() resp := f.doRaw(method, path, body) defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode != want { f.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, string(raw)) } if out != nil { if err := json.Unmarshal(raw, out); err != nil { f.t.Fatalf("decode %s: %v (%s)", path, err, string(raw)) } } } func (f *fixture) doRaw(method, path string, body any) *http.Response { f.t.Helper() var r io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { f.t.Fatalf("marshal body: %v", err) } r = bytes.NewReader(b) } req, err := http.NewRequest(method, f.server.URL+path, r) if err != nil { f.t.Fatalf("new request: %v", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := f.server.Client().Do(req) if err != nil { f.t.Fatalf("do: %v", err) } return resp } func (f *fixture) signAssertion(challenge []byte) pkgapi.Assertion { f.t.Helper() cd := map[string]string{ "type": "webauthn.get", "challenge": base64.RawURLEncoding.EncodeToString(challenge), "origin": f.origin, } cdBytes, _ := json.Marshal(cd) authData := webauthn.MakeAuthenticatorData(f.rpID) cdHash := sha256.Sum256(cdBytes) signed := append(append([]byte(nil), authData...), cdHash[:]...) sig := ed25519.Sign(f.credPriv, signed) return pkgapi.Assertion{ CredentialID: f.credID, ClientDataJSON: base64.RawURLEncoding.EncodeToString(cdBytes), AuthenticatorData: base64.RawURLEncoding.EncodeToString(authData), Signature: base64.RawURLEncoding.EncodeToString(sig), } } func hex(b []byte) string { const hexchars = "0123456789abcdef" out := make([]byte, len(b)*2) for i, c := range b { out[i*2] = hexchars[c>>4] out[i*2+1] = hexchars[c&0x0f] } return string(out) } // --- actual tests --------------------------------------------------------- func TestHappyPath(t *testing.T) { f := newFixture(t) sess := f.startSession() if sess.SessionID == "" { t.Fatalf("no session_id returned") } if sess.UserSub != f.userSub { t.Errorf("session.user_sub = %q want %q", sess.UserSub, f.userSub) } pv := f.preview(sess.SessionID, map[string]any{ "title": "Approve rate change", "action": "sign", }, http.StatusOK) if !strings.HasPrefix(pv.ContentHash, "sha256:") { t.Errorf("preview content_hash: %q", pv.ContentHash) } if pv.VerificationDigits == "" { t.Errorf("empty verification digits") } challenge, err := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) if err != nil { t.Fatalf("decode challenge: %v", err) } assertion := f.signAssertion(challenge) ok, _ := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK) if ok.TLogStatus != "logged" { t.Errorf("tlog status: %q want logged", ok.TLogStatus) } if ok.LogIndex == nil || *ok.LogIndex != 42 { t.Errorf("log_index = %v want 42", ok.LogIndex) } if ok.AttestationRef == "" || !strings.HasPrefix(ok.AttestationRef, "sha256:") { t.Errorf("attestation_ref: %q", ok.AttestationRef) } // Assert the signer saw a Statement whose subject.digest.sha256 is // the H_content of the preview (no "sha256:" prefix per in-toto). f.fakeSigner.mu.Lock() stmt := f.fakeSigner.seenStatement f.fakeSigner.mu.Unlock() subj, _ := stmt["subject"].([]any) if len(subj) != 1 { t.Fatalf("statement subject: %+v", subj) } first, _ := subj[0].(map[string]any) digest, _ := first["digest"].(map[string]any) gotDigest, _ := digest["sha256"].(string) wantDigest := strings.TrimPrefix(pv.ContentHash, "sha256:") if gotDigest != wantDigest { t.Errorf("signed subject digest=%q want %q", gotDigest, wantDigest) } } func TestConfirm_TamperedHContent(t *testing.T) { f := newFixture(t) sess := f.startSession() pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK) challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) // Flip the last byte of the challenge (equivalent to signing with a // different H_content), the signature will be over wrong data. tampered := append([]byte(nil), challenge...) tampered[len(tampered)-1] ^= 0xff assertion := f.signAssertion(tampered) _, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusBadRequest) if errBody.Error.Code != "content_hash_mismatch" { t.Errorf("error code = %q want content_hash_mismatch", errBody.Error.Code) } } func TestConfirm_ExpiredChallenge(t *testing.T) { // Force a short challenge TTL so we can race the clock. f := newFixture(t) sess := f.startSession() pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK) // Walk the preview's ExpiresAt backward directly in the store to // simulate an expired challenge without a sleep. prev, err := f.store.GetPreview(context.Background(), pv.PreviewID) if err != nil { t.Fatalf("GetPreview: %v", err) } prev.ExpiresAt = time.Now().Add(-1 * time.Second) if err := f.store.SavePreview(context.Background(), prev); err != nil { t.Fatalf("SavePreview: %v", err) } challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) assertion := f.signAssertion(challenge) _, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusForbidden) if errBody.Error.Code != "challenge_expired" { t.Errorf("error code = %q want challenge_expired", errBody.Error.Code) } } func TestConfirm_SignerUnavailable(t *testing.T) { f := newFixture(t, func(o *service.Orchestrator) { o.Signer = &fakeSigner{err: signer.ErrSignerUnavailable} }) sess := f.startSession() pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK) challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) assertion := f.signAssertion(challenge) _, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusBadGateway) if errBody.Error.Code != "signer_unavailable" { t.Errorf("error code = %q want signer_unavailable", errBody.Error.Code) } } func TestConfirm_TLogFailureStaysPending(t *testing.T) { f := newFixture(t, func(o *service.Orchestrator) { o.Submitter = &fakeTLog{err: tlog.ErrTLogUnavailable} }) sess := f.startSession() pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK) challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) assertion := f.signAssertion(challenge) ok, _ := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK) if ok.TLogStatus != "attestation-pending" { t.Errorf("tlog status = %q want attestation-pending", ok.TLogStatus) } if ok.LogIndex != nil { t.Errorf("log_index = %v want nil", ok.LogIndex) } // Approval must still have been persisted. appr, ok2 := f.store.GetApproval(ok.ApprovalID) if !ok2 { t.Fatalf("approval not persisted after tlog failure") } if appr.TLogStatus != store.TLogPending { t.Errorf("stored tlog status = %q want attestation-pending", appr.TLogStatus) } if appr.TLogError == "" { t.Errorf("expected tlog_error to be recorded") } } func TestPreview_SessionExpired(t *testing.T) { f := newFixture(t) sess := f.startSession() // Simulate session expiry. raw, err := f.store.GetSession(context.Background(), sess.SessionID) if err != nil { t.Fatalf("GetSession: %v", err) } raw.ActiveUntil = time.Now().Add(-1 * time.Second) if err := f.store.CreateSession(context.Background(), raw); err != nil { t.Fatalf("CreateSession: %v", err) } resp := f.doRaw("POST", "/api/v1/approvals/preview", pkgapi.PreviewRequest{ SessionID: sess.SessionID, Content: map[string]any{"k": "v"}, }) defer resp.Body.Close() raw2, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusForbidden { t.Fatalf("status %d want 403: %s", resp.StatusCode, raw2) } var ebody pkgapi.Error _ = json.Unmarshal(raw2, &ebody) if ebody.Error.Code != "session_expired" { t.Errorf("error code = %q want session_expired", ebody.Error.Code) } } func TestConfirm_PreviewConsumedTwice(t *testing.T) { f := newFixture(t) sess := f.startSession() pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK) challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) assertion := f.signAssertion(challenge) _, _ = f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK) // Second confirm must be refused. _, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusConflict) if errBody.Error.Code != "preview_consumed" { t.Errorf("error code = %q want preview_consumed", errBody.Error.Code) } }