package httpapi import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "github.com/go-chi/chi/v5" "commilitia.net/cdrop/internal/brokerclient" "commilitia.net/cdrop/internal/config" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/hub" "commilitia.net/cdrop/internal/jwtauth" ) // mockBrokerState records what the fake Auth Broker was asked to do, so tests can // assert cdrop delegated correctly (mint params, revoke + its X-Broker-App scope). type mockBrokerState struct { mintCount int lastMint map[string]any revoked map[string]bool lastRevokeApp string // sessions holds the live (non-revoked) delegated sessions keyed by sid, so the mock // can answer R1 (GET /internal/sessions) and enforce R2 idempotency (same user+app+meta // → same sid). sessions map[string]*mockSession } type mockSession struct { sid, userID, app, meta, label, scope string createdAt, lastUsedAt int64 } // newMockBroker stands in for the Auth Broker's internal API: POST /internal/sessions // mints (R2-idempotent by user+app+meta) a session, GET /internal/sessions lists the // user's live machine sessions (R1), and DELETE /internal/sessions/{sid} revokes one. func newMockBroker(t *testing.T) (*brokerclient.Client, *mockBrokerState) { t.Helper() st := &mockBrokerState{revoked: map[string]bool{}, sessions: map[string]*mockSession{}} str := func(m map[string]any, k string) string { if v, ok := m[k].(string); ok { return v } return "" } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/internal/sessions": st.mintCount += 1 var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) st.lastMint = body userID, app, meta, label, tier := str(body, "user_id"), str(body, "app"), str(body, "meta"), str(body, "label"), str(body, "tier") scope := "app:" + app if tier != "" { scope += ":" + tier } // R2 idempotency: same (user, app, meta) with non-empty meta rotates in place. sid := "" if meta != "" { for _, sess := range st.sessions { if !st.revoked[sess.sid] && sess.userID == userID && sess.app == app && sess.meta == meta { sid = sess.sid break } } } if sid == "" { sid = fmt.Sprintf("sid-%d", st.mintCount) } now := time.Now().Unix() created := now if existing, ok := st.sessions[sid]; ok { created = existing.createdAt // rotation preserves CreatedAt } st.sessions[sid] = &mockSession{sid: sid, userID: userID, app: app, meta: meta, label: label, scope: scope, createdAt: created, lastUsedAt: now} _ = json.NewEncoder(w).Encode(map[string]any{ "id": sid, "app": "cdrop", "access": "acc-" + sid, "refresh": "rtk-" + sid, "access_expires": time.Now().Add(15 * time.Minute).Unix(), "refresh_expires": time.Now().Add(24 * time.Hour).Unix(), }) case r.Method == http.MethodGet && r.URL.Path == "/internal/sessions": q := r.URL.Query() userID, app := q.Get("user_id"), q.Get("app") if userID == "" || app == "" { w.WriteHeader(http.StatusBadRequest) return } out := []map[string]any{} for _, sess := range st.sessions { if st.revoked[sess.sid] || sess.userID != userID || sess.app != app { continue } out = append(out, map[string]any{ "id": sess.sid, "scope": sess.scope, "label": sess.label, "meta": sess.meta, "created_at": sess.createdAt, "last_used_at": sess.lastUsedAt, "expires_at": time.Now().Add(24 * time.Hour).Unix(), }) } _ = json.NewEncoder(w).Encode(map[string]any{"sessions": out}) case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/internal/sessions/"): st.revoked[strings.TrimPrefix(r.URL.Path, "/internal/sessions/")] = true st.lastRevokeApp = r.Header.Get("X-Broker-App") w.WriteHeader(http.StatusNoContent) default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) return brokerclient.New(srv.URL, "test-key", "cdrop"), st } // newQRTestServer builds a Server with only the fields the scan-login + device // handlers touch, backed by a fresh file-based sqlite and a mock broker. func newQRTestServer(t *testing.T) (*Server, *mockBrokerState) { 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) } q := db.New(conn) broker, st := newMockBroker(t) s := &Server{ cfg: &config.Config{ AuthMode: "prod", QRLoginEnabled: true, QRRequestTTLSeconds: 120, FullAccessTTLSeconds: 900, FullRefreshTTLSeconds: 604800, GuestAccessTTLSeconds: 900, GuestRefreshTTLSeconds: 86400, BrokerBaseURL: "http://broker", // non-empty so QRLoginOn() is true }, queries: q, hub: hub.New(q), broker: broker, siteOrigin: "https://drop.example.net", } return s, st } 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, scope, persist, approver string) int { t.Helper() body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":%q,"persist":%q}`, requestID, code, scope, persist) r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", strings.NewReader(body)) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: approver, Scope: "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 } // Happy path: a new device starts a request, the approver authorises it as a guest, // and the new device collects a live guest session minted by the broker — broker // access + refresh handed back, a device row recorded with the broker sid, the mint // scoped to the device_id, and the request single-use thereafter. func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) { s, st := newQRTestServer(t) start, code := qrStart(t, s, "Borrowed Laptop") if c := qrApprove(t, s, start.RequestID, code, "guest", "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.RefreshToken == "" { t.Fatalf("collected status wrong: %+v", got) } if got.DeviceName != "Borrowed Laptop" || got.DeviceID == "" { t.Fatalf("collected device wrong: %+v", got) } if !strings.HasPrefix(got.DeviceID, "dev_") { t.Errorf("device_id not opaque dev_ token: %q", got.DeviceID) } // The broker was asked to mint at tier guest with our device_id as meta. if st.mintCount != 1 { t.Fatalf("mint count: got %d, want 1", st.mintCount) } if st.lastMint["tier"] != "guest" || st.lastMint["meta"] != got.DeviceID { t.Errorf("mint params wrong: %+v", st.lastMint) } // A device row exists for the approver, tier guest, bound to the broker sid. dev, err := s.queries.GetDevice(context.Background(), got.DeviceID) if err != nil { t.Fatalf("device row missing: %v", err) } if dev.UserID != "approver-1" || dev.Tier != "guest" || dev.BrokerSid != "sid-1" { t.Errorf("device row wrong: %+v", dev) } // 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 full approval mints a full-tier session. func TestQRFlow_ApproveCollectFull(t *testing.T) { s, st := newQRTestServer(t) start, code := qrStart(t, s, "Home PC") if c := qrApprove(t, s, start.RequestID, code, "full", "persist", "approver-2"); c != http.StatusNoContent { t.Fatalf("approve: got %d, want 204", c) } got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)) if got.Status != "approved" { t.Fatalf("status: %+v", got) } if st.lastMint["tier"] != "full" { t.Errorf("mint tier: got %v, want full", st.lastMint["tier"]) } dev, err := s.queries.GetDevice(context.Background(), got.DeviceID) if err != nil || dev.Tier != "full" { t.Errorf("device row: %+v (err=%v)", dev, err) } } func TestQRFlow_WrongPollSecretRejected(t *testing.T) { s, _ := newQRTestServer(t) start, code := qrStart(t, s, "Laptop") _ = qrApprove(t, s, start.RequestID, code, "guest", "once", "approver-1") w := qrStatus(s, start.RequestID, "not-the-secret") if w.Code != http.StatusForbidden { t.Fatalf("wrong poll secret: got %d, want 403", w.Code) } } 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-1", Scope: "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("after deny: got %q, want denied", got.Status) } } func TestQRFlow_PendingLongPoll(t *testing.T) { old := qrStatusPollWindow qrStatusPollWindow = 50 * time.Millisecond defer func() { qrStatusPollWindow = old }() s, _ := newQRTestServer(t) start, _ := qrStart(t, s, "Laptop") got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)) if got.Status != "pending" { t.Errorf("unapproved long-poll: got %q, want pending", got.Status) } } // requireFullSession lets a full session through and blocks a restricted guest. func TestRequireFullSession_GuestBlockedFullPasses(t *testing.T) { handler := requireFullSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) check := func(scope string) int { r := httptest.NewRequest(http.MethodGet, "/x", nil) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: scope})) w := httptest.NewRecorder() handler.ServeHTTP(w, r) return w.Code } if c := check("app:cdrop:guest"); c != http.StatusForbidden { t.Errorf("guest on full-only route: got %d, want 403", c) } if c := check("app:cdrop:full"); c != http.StatusOK { t.Errorf("full on full-only route: got %d, want 200", c) } } // Deleting a device revokes its broker session (scoped by X-Broker-App) and drops the // local row. func TestDeleteDevice_RevokesBrokerSession(t *testing.T) { s, st := newQRTestServer(t) start, code := qrStart(t, s, "Old Phone") _ = qrApprove(t, s, start.RequestID, code, "full", "persist", "owner") got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)) if got.DeviceID == "" { t.Fatal("no device_id from collect") } r := httptest.NewRequest(http.MethodDelete, "/api/devices/"+got.DeviceID, nil) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "owner", Scope: "full"})) rctx := chi.NewRouteContext() rctx.URLParams.Add("device_id", got.DeviceID) r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() s.handleDeleteDevice(w, r) if w.Code != http.StatusNoContent { t.Fatalf("delete device: got %d, want 204 (%s)", w.Code, w.Body.String()) } if !st.revoked["sid-1"] { t.Error("broker session sid-1 was not revoked") } if st.lastRevokeApp != "cdrop" { t.Errorf("revoke X-Broker-App: got %q, want cdrop", st.lastRevokeApp) } if _, err := s.queries.GetDevice(context.Background(), got.DeviceID); err == nil { t.Error("device row should be gone after delete") } } // The session list surfaces the user's devices with their tier as scope. func TestSessionsList_ShowsDevices(t *testing.T) { s, _ := newQRTestServer(t) start, code := qrStart(t, s, "Tablet") _ = qrApprove(t, s, start.RequestID, code, "guest", "once", "owner") got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)) r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "owner", Scope: "app:cdrop:guest", DeviceID: got.DeviceID})) w := httptest.NewRecorder() s.handleSessionsList(w, r) if w.Code != http.StatusOK { t.Fatalf("sessions list: %d %s", w.Code, w.Body.String()) } var resp struct { Sessions []sessionView `json:"sessions"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if len(resp.Sessions) != 1 { t.Fatalf("session count: got %d, want 1", len(resp.Sessions)) } sv := resp.Sessions[0] if sv.DeviceID != got.DeviceID || sv.Scope != "guest" || !sv.Current { t.Errorf("session view wrong: %+v", sv) } } // deviceSession drives the 代铸 endpoint with the given identity and returns the response. func deviceSession(t *testing.T, s *Server, userID, scope, deviceID, name, dtype, origin string) deviceSessionResp { t.Helper() body := fmt.Sprintf(`{"device_id":%q,"device_name":%q,"device_type":%q}`, deviceID, name, dtype) r := httptest.NewRequest(http.MethodPost, "/api/auth/device-session", strings.NewReader(body)) if origin != "" { r.Header.Set("Origin", origin) } r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: userID, Name: "Commilitia", Avatar: "https://example.net/a.png", Scope: scope})) w := httptest.NewRecorder() s.handleDeviceSession(w, r) if w.Code != http.StatusOK { t.Fatalf("device-session: %d %s", w.Code, w.Body.String()) } var resp deviceSessionResp if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode device-session: %v", err) } return resp } // listSessions calls the session-management list for a caller riding device deviceID. func listSessions(t *testing.T, s *Server, userID, scope, deviceID string) []sessionView { t.Helper() r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: userID, Scope: scope, DeviceID: deviceID})) w := httptest.NewRecorder() s.handleSessionsList(w, r) if w.Code != http.StatusOK { t.Fatalf("sessions list: %d %s", w.Code, w.Body.String()) } var resp struct { Sessions []sessionView `json:"sessions"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode list: %v", err) } return resp.Sessions } // 代铸 turns an edge-verified login into a managed device session that joins the unified // list as the current device, with its type overlaid and the verified display name returned. func TestDeviceSession_MintsManagedDevice(t *testing.T) { s, st := newQRTestServer(t) resp := deviceSession(t, s, "owner", "full", "dev_browser01", "Laptop", "browser", "") if resp.AccessToken == "" || resp.RefreshToken == "" { t.Fatal("device-session returned no tokens") } if resp.DeviceID != "dev_browser01" { t.Errorf("device_id: got %q", resp.DeviceID) } if resp.Name != "Commilitia" { t.Errorf("name: got %q, want the verified X-Auth-Name not the subject UUID", resp.Name) } if resp.Avatar != "https://example.net/a.png" { t.Errorf("avatar: got %q, want the verified X-Auth-Avatar", resp.Avatar) } if st.lastMint["tier"] != "full" || st.lastMint["meta"] != "dev_browser01" || st.lastMint["label"] != "Laptop" { t.Errorf("mint params: %+v", st.lastMint) } list := listSessions(t, s, "owner", "app:cdrop:full", "dev_browser01") if len(list) != 1 || list[0].DeviceID != "dev_browser01" || list[0].Kind != "browser" || !list[0].Current { t.Errorf("unified list wrong: %+v", list) } } // Re-login with the same device_id rotates the one session (R2), not piling up duplicates, // and adopts the latest label — the fix for the "duplicate phantom devices" regression. func TestDeviceSession_Idempotent(t *testing.T) { s, _ := newQRTestServer(t) _ = deviceSession(t, s, "owner", "full", "dev_same01", "Laptop", "browser", "") _ = deviceSession(t, s, "owner", "full", "dev_same01", "Laptop Renamed", "browser", "") list := listSessions(t, s, "owner", "app:cdrop:full", "dev_same01") if len(list) != 1 { t.Fatalf("idempotent re-mint: got %d sessions, want 1", len(list)) } if list[0].DeviceName != "Laptop Renamed" { t.Errorf("rotation should adopt the latest label: got %q", list[0].DeviceName) } } // A meta-less machine session (a desktop device-authorize bootstrap) must not surface as a // phantom device — the unified list filters it out, leaving only真正的托管设备. func TestSessionsList_FiltersMetalessBootstrap(t *testing.T) { s, _ := newQRTestServer(t) if _, err := s.broker.MintSession(context.Background(), brokerclient.MintParams{ UserID: "owner", Tier: "full", Label: "bootstrap", }); err != nil { t.Fatalf("bootstrap mint: %v", err) } _ = deviceSession(t, s, "owner", "full", "dev_real01", "Laptop", "browser", "") list := listSessions(t, s, "owner", "app:cdrop:full", "dev_real01") if len(list) != 1 || list[0].DeviceID != "dev_real01" { t.Fatalf("metaless bootstrap not filtered: %+v", list) } } // A cookie-authenticated mint must carry a same-origin Origin; a cross-site forgery (which // would reintroduce phantom devices) is rejected. func TestDeviceSession_RejectsCrossOrigin(t *testing.T) { s, _ := newQRTestServer(t) body := `{"device_id":"dev_x01","device_name":"X","device_type":"browser"}` r := httptest.NewRequest(http.MethodPost, "/api/auth/device-session", strings.NewReader(body)) r.Header.Set("Origin", "https://evil.example.net") r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "owner", Scope: "full"})) w := httptest.NewRecorder() s.handleDeviceSession(w, r) if w.Code != http.StatusForbidden { t.Fatalf("cross-origin device-session: got %d, want 403", w.Code) } } // A restricted guest minting its device session stays guest — no escalation to full. func TestDeviceSession_GuestTierNotEscalated(t *testing.T) { s, st := newQRTestServer(t) _ = deviceSession(t, s, "owner", "app:cdrop:guest", "dev_guest01", "Borrowed", "browser", "") if st.lastMint["tier"] != "guest" { t.Errorf("guest caller minted tier %v, want guest", st.lastMint["tier"]) } }