jim800121chen b73c9b7b7e fix(api): 裝置 detail endpoint tunnel 判定給獨立 3s ctx,修 R-3 誤判離線
裝置 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>
2026-07-30 18:18:34 +08:00

193 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 顯式注入 UserContexthandler 強制要求 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 應判離線")
}