package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/device" "visiona-backend/internal/session" ) // newDevicesFixture 建立 router 並塞好必要依賴(InMemory repo + fakeSessionStore)。 // // Phase 0.7 security fix C1:移除 Deps.StaticUserID(見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)。 // 改由 injectStaticUserContext 顯式注入 UserContext,handler 強制要求 UserContext 非空。 func newDevicesFixture(t *testing.T, sessions []any) *gin.Engine { t.Helper() r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "")) g := r.Group("/api") _ = sessions // 暫用,下方 helper 內建 registerDeviceRoutes(g, Deps{ DeviceRepo: device.NewInMemoryRepository(), SessionStore: &fakeSessionStore{}, // 無 session }) return r } // TestDevicesList_Empty 驗證沒 device 時回空陣列。 func TestDevicesList_Empty(t *testing.T) { r := newDevicesFixture(t, nil) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices", nil)) require.Equal(t, http.StatusOK, w.Code) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) arr, ok := sb.Data.([]any) require.True(t, ok) assert.Empty(t, arr) } // TestDevicesList_ReturnsOwnDevicesOnly 驗證只回當前 user 的 device。 func TestDevicesList_ReturnsOwnDevicesOnly(t *testing.T) { repo := device.NewInMemoryRepository() ctx := context.Background() now := time.Now().UTC() require.NoError(t, repo.Save(ctx, &device.Device{ ID: "mine", OwnerUserID: "demo-user", Name: "A", DeviceType: "kl520", RemoteStatus: device.RemoteStatusOnline, Status: device.USBStatusOnline, CreatedAt: now, })) require.NoError(t, repo.Save(ctx, &device.Device{ ID: "theirs", OwnerUserID: "other", Name: "B", DeviceType: "kl520", CreatedAt: now, })) r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "")) g := r.Group("/api") registerDeviceRoutes(g, Deps{ DeviceRepo: repo, SessionStore: &fakeSessionStore{}, }) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices", nil)) require.Equal(t, http.StatusOK, w.Code) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) arr := sb.Data.([]any) require.Len(t, arr, 1, "只應看到自己的 device") first := arr[0].(map[string]any) assert.Equal(t, "mine", first["id"]) assert.Equal(t, false, first["tunnel_online"], "沒 session → tunnel_online=false") } // TestDevicesGet_NotOwner 驗證非 owner 被擋 403。 func TestDevicesGet_NotOwner(t *testing.T) { repo := device.NewInMemoryRepository() require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "x", OwnerUserID: "other", Name: "a", DeviceType: "kl520", })) r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "")) g := r.Group("/api") registerDeviceRoutes(g, Deps{ DeviceRepo: repo, SessionStore: &fakeSessionStore{}, }) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices/x", nil)) assert.Equal(t, http.StatusForbidden, w.Code) } // TestDevicesGet_NotFound 驗證不存在回 404。 func TestDevicesGet_NotFound(t *testing.T) { r := newDevicesFixture(t, nil) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices/ghost", nil)) assert.Equal(t, http.StatusNotFound, w.Code) } // deadlineRecordingStore 記錄 List 收到的 ctx 剩餘 deadline,並回一筆命中 session。 // 用來驗證 detail handler 給 resolveTunnelStatus 的 ctx 有足夠預算(≥3s,非被前面 // DB 呼叫吃掉的殘餘)。 type deadlineRecordingStore struct { fakeSessionStore gotRemaining time.Duration hasDeadline bool } func (s *deadlineRecordingStore) List(ctx context.Context) ([]*session.Summary, error) { if dl, ok := ctx.Deadline(); ok { s.hasDeadline = true s.gotRemaining = time.Until(dl) } return []*session.Summary{ {UserID: "demo-user", LastHeartbeat: time.Now().UTC()}, }, nil } // TestDevicesGet_TunnelCtxHasFullBudget 驗證 R-3 離線誤判修復: // detail endpoint 給 tunnel 判定的 ctx 有完整 3s 預算(獨立於前面的 DeviceRepo.Get), // 且能正確回 tunnel_online=true。修復前 detail 用同一個 2s ctx,前面的 DB 呼叫吃掉時間後 // 打 relay 的 store.List 常逾時被靜默判離線。 func TestDevicesGet_TunnelCtxHasFullBudget(t *testing.T) { repo := device.NewInMemoryRepository() require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "mine", OwnerUserID: "demo-user", Name: "kl520", DeviceType: "kl520", })) store := &deadlineRecordingStore{} r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "")) g := r.Group("/api") registerDeviceRoutes(g, Deps{ DeviceRepo: repo, SessionStore: store, }) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices/mine", nil)) require.Equal(t, http.StatusOK, w.Code) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) item := sb.Data.(map[string]any) assert.Equal(t, true, item["tunnel_online"], "命中 session 應判 tunnel_online=true") // 核心斷言:tunnel 判定拿到的 ctx 剩餘預算應接近完整 3s(獨立 ctx), // 而非修復前殘餘的 <2s。給寬鬆下界 2.5s 容忍測試機排程抖動。 require.True(t, store.hasDeadline, "tunnel ctx 應有 deadline") assert.Greater(t, store.gotRemaining, 2500*time.Millisecond, "tunnel 判定應拿到近乎完整的 3s 預算,不被前面 DeviceRepo.Get 吃掉") } // TestResolveTunnelStatus_ListTimeoutTreatedOffline 驗證嫌疑 1 語意保留: // store.List 逾時(context deadline exceeded)→ 靜默判離線(fail-safe 不變)。 func TestResolveTunnelStatus_ListTimeoutTreatedOffline(t *testing.T) { store := &fakeSessionStore{listErr: context.DeadlineExceeded} alive, _ := resolveTunnelStatus( context.Background(), store, "demo-user", nil, "detail", "test-req") assert.False(t, alive, "List 逾時應判離線(fail-safe 語意保留)") } // TestResolveTunnelStatus_NoMatchTreatedOffline 驗證嫌疑 2 語意: // 拿到 list 但沒有一筆命中 userID → 判離線。 func TestResolveTunnelStatus_NoMatchTreatedOffline(t *testing.T) { store := &fakeSessionStore{sessions: []*session.Summary{ {UserID: "someone-else", LastHeartbeat: time.Now().UTC()}, }} alive, _ := resolveTunnelStatus( context.Background(), store, "demo-user", nil, "detail", "test-req") assert.False(t, alive, "無命中 session 應判離線") }