package httpapi import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" "commilitia.net/cdrop/internal/config" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) const qrTestSecret = "qr-test-session-secret-at-least-32-bytes" // newQRTestServer builds a Server with only the fields the scan-login handlers // touch, backed by a fresh file-based sqlite (an in-memory DB would give each // pooled connection its own empty schema). func newQRTestServer(t *testing.T) *Server { t.Helper() conn, err := db.Open(filepath.Join(t.TempDir(), "qr.db")) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { _ = conn.Close() }) if err := db.Bootstrap(context.Background(), conn); err != nil { t.Fatalf("bootstrap: %v", err) } return &Server{ cfg: &config.Config{ AuthMode: "prod", SessionSecret: qrTestSecret, QRLoginEnabled: true, QRRequestTTLSeconds: 120, QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168, SessionTokenTTLSeconds: 900, }, queries: db.New(conn), sessionKey: deriveSessionKey(qrTestSecret), sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret), siteOrigin: "https://drop.example.net", } } func qrStart(t *testing.T, s *Server, deviceName string) (qrStartResp, string) { t.Helper() body := fmt.Sprintf(`{"device_name":%q,"device_type":"browser"}`, deviceName) r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/start", strings.NewReader(body)) w := httptest.NewRecorder() s.handleQRStart(w, r) if w.Code != http.StatusOK { t.Fatalf("qr/start: %d %s", w.Code, w.Body.String()) } var resp qrStartResp if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode start: %v", err) } u, err := url.Parse(resp.QRPayload) if err != nil { t.Fatalf("qr_payload not a url: %v", err) } return resp, u.Query().Get("c") } func qrApprove(t *testing.T, s *Server, requestID, code, persist, approver string) int { t.Helper() body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":"guest","persist":%q}`, requestID, code, persist) r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", strings.NewReader(body)) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: approver, SessionScope: "full"})) w := httptest.NewRecorder() s.handleQRApprove(w, r) return w.Code } func qrStatus(s *Server, requestID, pollSecret string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, "/api/auth/qr/status?request_id="+url.QueryEscape(requestID), nil) r.Header.Set("X-Poll-Secret", pollSecret) w := httptest.NewRecorder() s.handleQRStatus(w, r) return w } func decodeStatus(t *testing.T, w *httptest.ResponseRecorder) qrStatusResp { t.Helper() var resp qrStatusResp if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode status: %v (%s)", err, w.Body.String()) } return resp } // The happy path: a new device starts a request, the approver authorises it as a // one-time guest, and the new device collects a live guest session — cookie set, // access token minted, request single-use thereafter. func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) { s := newQRTestServer(t) start, code := qrStart(t, s, "Borrowed Laptop") if c := qrApprove(t, s, start.RequestID, code, "once", "approver-1"); c != http.StatusNoContent { t.Fatalf("approve: got %d, want 204", c) } w := qrStatus(s, start.RequestID, start.PollSecret) if w.Code != http.StatusOK { t.Fatalf("status: %d %s", w.Code, w.Body.String()) } got := decodeStatus(t, w) if got.Status != "approved" || got.AccessToken == "" || got.DeviceName != "Borrowed Laptop" { t.Fatalf("collected status wrong: %+v", got) } if got.ExpiresIn != 900 { t.Errorf("expires_in: got %d, want 900", got.ExpiresIn) } var hasCookie bool for _, ck := range w.Result().Cookies() { if ck.Name == sessionCookieName && ck.Value != "" { hasCookie = true } } if !hasCookie { t.Error("collection must set the session cookie on the new device's response") } // The minted token is a properly signed guest session token. if scope := selfTokenScope(t, got.AccessToken); scope != "guest" { t.Fatalf("minted token scope: got %q, want guest", scope) } // A guest (one-time) session row exists for the approver and is non-sliding. rows, err := s.queries.ListWebSessionsByUser(context.Background(), "approver-1") if err != nil || len(rows) != 1 { t.Fatalf("expected one session row, got %d (err=%v)", len(rows), err) } if rows[0].Kind != "guest" || rows[0].Scope != "guest" { t.Errorf("session kind/scope: got %s/%s, want guest/guest", rows[0].Kind, rows[0].Scope) } // Single use: a second collection finds the request consumed. if got2 := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got2.Status != "expired" { t.Errorf("second collection: got %q, want expired (consumed)", got2.Status) } } // A persistent ("trust this device") approval yields a sliding self session. func TestQRFlow_PersistYieldsSelfSession(t *testing.T) { s := newQRTestServer(t) start, code := qrStart(t, s, "Home PC") if c := qrApprove(t, s, start.RequestID, code, "persist", "approver-2"); c != http.StatusNoContent { t.Fatalf("approve: got %d, want 204", c) } if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" { t.Fatalf("collect: %+v", got) } rows, _ := s.queries.ListWebSessionsByUser(context.Background(), "approver-2") if len(rows) != 1 || rows[0].Kind != "self" { t.Fatalf("persistent approval must create a kind=self session, got %+v", rows) } } // The poll_secret is the new device's only credential: a wrong one is rejected // even for a real, approved request, so a QR photographer can't collect it. func TestQRFlow_WrongPollSecretRejected(t *testing.T) { s := newQRTestServer(t) start, code := qrStart(t, s, "Laptop") _ = qrApprove(t, s, start.RequestID, code, "once", "approver-3") if w := qrStatus(s, start.RequestID, "not-the-secret"); w.Code != http.StatusForbidden { t.Errorf("wrong poll secret: got %d, want 403", w.Code) } // The real secret still works afterwards (the bad attempt didn't consume it). if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" { t.Errorf("real secret after a bad attempt: got %q, want approved", got.Status) } } // Denial propagates to the polling device. func TestQRFlow_Deny(t *testing.T) { s := newQRTestServer(t) start, code := qrStart(t, s, "Laptop") body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q}`, start.RequestID, code) r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/deny", strings.NewReader(body)) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "approver-4", SessionScope: "full"})) w := httptest.NewRecorder() s.handleQRDeny(w, r) if w.Code != http.StatusNoContent { t.Fatalf("deny: got %d, want 204", w.Code) } if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "denied" { t.Errorf("status after deny: got %q, want denied", got.Status) } } // With step-up enabled, approving without a fresh re-auth is refused (the gate // rejects an empty step-up proof before any IdP call). AUTH.md §6. func TestQRFlow_StepUpRequiredRejectsWithoutReauth(t *testing.T) { s := newQRTestServer(t) s.cfg.StepUpEnabled = true start, code := qrStart(t, s, "Laptop") if c := qrApprove(t, s, start.RequestID, code, "once", "approver-su"); c != http.StatusForbidden { t.Errorf("approve without step-up proof: got %d, want 403", c) } } // A pending request long-polls then reports pending for the client to re-poll. func TestQRFlow_PendingLongPoll(t *testing.T) { saved := qrStatusPollWindow qrStatusPollWindow = 50 * time.Millisecond defer func() { qrStatusPollWindow = saved }() s := newQRTestServer(t) start, _ := qrStart(t, s, "Laptop") w := qrStatus(s, start.RequestID, start.PollSecret) if got := decodeStatus(t, w); got.Status != "pending" { t.Errorf("pending long-poll: got %q, want pending", got.Status) } } // selfTokenScope verifies a self-signed session token under the test's session // key (proving the signature) and returns its scope claim. func selfTokenScope(t *testing.T, token string) string { t.Helper() parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256}) if err != nil { t.Fatalf("parse token: %v", err) } var std jwt.Claims custom := map[string]any{} if err := parsed.Claims(jwtauth.DeriveSessionTokenKey(qrTestSecret), &std, &custom); err != nil { t.Fatalf("verify token signature: %v", err) } if typ, _ := custom["typ"].(string); typ != "session" { t.Fatalf("token typ: got %q, want session", typ) } scope, _ := custom["scope"].(string) return scope }