裝置 detail endpoint(/api/devices/:id)原用單一 2s ctx,先跑 DeviceRepo.Get 殘餘時間才輪到打 relay 的 store.List → tunnel 狀態查詢逾時被靜默判離線 → 影片分頁 R-3 誤擋上傳(列表頁 3s 判在線、詳情頁 2s 判離線,同裝置相反)。 修法:tunnel 判定改用獨立 ctx(源自 request context、完整 3s、defer cancel), 與 list 對齊;detail 原 2s ctx 保留給 DeviceRepo.Get。list 也一併改獨立 ctx。 未動 resolveTunnelStatus 的逾時判離線 fail-safe 語意,只給足夠時間。 加可觀測性 log(deadline_exceeded / no-matching / 命中三分支,不含敏感資訊), 供 stage 分辨「真逾時」vs「UserID 比對不中」。前端未動(行為正確)。 reviewer 通過(0C/0M)。build/vet/全套 test 綠 + 3 新 test(含 ctx 完整預算斷言)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
193 lines
6.7 KiB
Go
193 lines
6.7 KiB
Go
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 應判離線")
|
||
}
|