Files
admin a2cad11224 后台/会话三诉求:子会话(一台设备一会话)+ 离线消息队列 + 被登出推送 + 后台唤醒层
- Req 1 子会话(iOS 多进程在用户视角=一台设备一会话,内部多条独立刷新逻辑会话):brokerclient 加 MintParams.Sub + SessionInfo.Sub + RevokeDeviceSessions(user,meta) 级联;device-session sub!="" 挂在主 device_id 之下、走 tier=clipboard 且不建第二个 device 行;handleSessionsList 按 meta 归并、隐藏 sub!=""(但保留“仅控件会话存活”的设备,不简单丢弃);revokeDevice 改走 meta 级联(移除设备连带吊控件子会话)。iOS provisionWidgetSessionIfNeeded 改用主 device_id + sub="widget"(弃用独立 dev_widget),控件令牌仍隔离持有、独立刷新——不碰引擎主会话,#7 隔离不变。蓝本 auth/docs/子会话方案.md(cdrop 提案 + Broker 评审接受 + cdrop 确认 6 点:用 sub、R1 cdrop 归并、scope 复用 tier=clipboard、级联 DELETE …?meta=)。
- Req 2a 离线消息队列:新增 pending_messages 表(0001_init + sqlc 查询);message.go 收件设备离线即入队(无论是否配推送都入队,恒 202),修“离线消息只随推送横幅一闪、不入收件列表”;GET /api/messages/pending 取即删(DELETE..RETURNING,单次投递);web hub.ts onOpen 每次 SSE 连接 / 重连即拉取补收、逐条 addMessage(全端受益,iOS 经桥推原生 + 累积未读);复用 login-request reaper 清 TTL。
- Req 3 被登出推送:push 加 KindSessionRevoked + 本地化文案,apns/web 双通道;revokeDevice 加 notifyRevoked 参(跨端 revoke=true、自登出=false),跨端移除时推送告知被踢设备;iOS AppDelegate 收 session:revoked 即清 Keychain 主会话 + 控件会话、发 .cdropSessionRevoked,前台经 AppRoot 即时回登录页、关闭态下次启动即登出。
- #6 后台唤醒层:apns apsEnvelope 加 content-available:1(服务端有消息时既弹可点横幅又短暂后台唤醒);iOS DeviceItem 加 Codable、presence 快照持久化(冷启即时显示设备列表,缓解“长时间重连”观感,SSE 一连即整组替换自校正);控件会话回前台补铸(scenePhase active)+ content-available 唤醒时刷新隔离的控件会话(剪贴板保活,绝不碰引擎主会话以免 #7 回归)。
- 测试:qr_test mock broker 加 sub 幂等键 + 级联吊销端点;bootstrap_test 期望表集加 pending_messages。
2026-06-27 17:42:53 +08:00

525 lines
20 KiB
Go

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, sub, 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 && sess.sub == str(body, "sub") {
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, sub: str(body, "sub"), 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, "sub": sess.sub, "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 && r.URL.Path == "/internal/sessions":
q := r.URL.Query()
st.lastRevokeApp = r.Header.Get("X-Broker-App")
revoked := 0
for _, sess := range st.sessions {
if st.revoked[sess.sid] || sess.userID != q.Get("user_id") || sess.app != q.Get("app") || sess.meta != q.Get("meta") {
continue
}
st.revoked[sess.sid] = true
revoked += 1
}
_ = json.NewEncoder(w).Encode(map[string]any{"revoked": revoked})
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"])
}
}