問題:連線後裝置詳情頁連線狀態顯示 unknown、載入模型按鈕永遠 disabled。
根因=雲端 GET /api/devices/:id 是純 DB 讀、沒 proxy 到 local agent → DB 只有
tunnel 層 status(online/offline/unknown)、沒有 driver 七態(detected/
connected/...)→ 前端 gate isDriverConnected 永遠 false。(上輪 C1/C5 查證
假設 status 拿得到、沒追到寫入點的漏洞)
修法(方案 Y-2、local agent 零改、gate 零改):
- backend device_driver_status.go(新):driverStatusFetcher 介面 +
forwarderDriverStatusFetcher(走既有 session.Forwarder proxy)+ envelope 解析
- devices.go devicesGetHandler:讀 DB metadata 後,device 有序號時額外 proxy
打 local agent GET /api/devices/{serial}(serial 路由對齊 WP-C)拿即時 driver
status 覆蓋 USBStatus;remoteStatus/tunnel_online 保留(offline banner 不壞)
- graceful fallback(全回 200 不掛 500):無序號/tunnel 離線/不可達/timeout/
非2xx/success:false/空status/Forwarder未配置 → 保留 DB status + Debug log
- 2s 短 timeout(不拖詳情頁)、serial path url.PathEscape 防禦
- frontend:DeviceHardwareStatus 加 unknown + coerceHardwareStatus(非七值→
unknown)+ normalizeDevice fallback disconnected→unknown(修誤顯未連接)+
i18n devices.status.unknown 兩語系(未確認/Unknown)
Reviewer 通過(0C/0M/3Mi/2Sug、Y-2 10/10、端到端追證 gate 放行 + 8 fallback
分支無一掛 500)。backend 8 測試 + frontend 49 passed、build/vet/test 綠、
gitleaks 0。端到端「即時 connected 覆蓋 unknown」需在線 agent+登入實測(單元
測試已覆蓋合併+fallback)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
8.0 KiB
Go
205 lines
8.0 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"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"
|
||
)
|
||
|
||
// stubDriverStatusFetcher 是 driverStatusFetcher 的測試替身。
|
||
//
|
||
// 可設定:回傳的即時 status(liveStatus)、回傳的 error(err),並記錄呼叫參數,
|
||
// 用來驗證 handler 是否有嘗試查詢、以及傳的序號 / userID 正確。
|
||
type stubDriverStatusFetcher struct {
|
||
liveStatus string
|
||
err error
|
||
|
||
called bool
|
||
gotUserID string
|
||
gotSerial string
|
||
}
|
||
|
||
func (s *stubDriverStatusFetcher) FetchDriverStatus(_ context.Context, userID, serial string) (string, error) {
|
||
s.called = true
|
||
s.gotUserID = userID
|
||
s.gotSerial = serial
|
||
return s.liveStatus, s.err
|
||
}
|
||
|
||
// getDeviceStatus 打 GET /api/devices/:id 並解出回應的 data.status 欄。
|
||
func getDeviceStatus(t *testing.T, r *gin.Engine, id string) (int, string) {
|
||
t.Helper()
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices/"+id, nil))
|
||
|
||
var sb SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb), "body=%s", w.Body.String())
|
||
if w.Code != http.StatusOK {
|
||
return w.Code, ""
|
||
}
|
||
data, ok := sb.Data.(map[string]any)
|
||
require.True(t, ok, "data 應為物件,實際 body=%s", w.Body.String())
|
||
status, _ := data["status"].(string)
|
||
return w.Code, status
|
||
}
|
||
|
||
// newGetDeviceFixture 建 router + 塞一顆 device,可注入自訂 Deps 欄位(fetcher)。
|
||
func newGetDeviceFixture(t *testing.T, d *device.Device, mutate func(*Deps)) *gin.Engine {
|
||
t.Helper()
|
||
repo := device.NewInMemoryRepository()
|
||
require.NoError(t, repo.Save(context.Background(), d))
|
||
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.Use(injectStaticUserContext(d.OwnerUserID, ""))
|
||
g := r.Group("/api")
|
||
|
||
deps := Deps{
|
||
DeviceRepo: repo,
|
||
SessionStore: &fakeSessionStore{},
|
||
}
|
||
if mutate != nil {
|
||
mutate(&deps)
|
||
}
|
||
registerDeviceRoutes(g, deps)
|
||
return r
|
||
}
|
||
|
||
// TestGetDevice_ProxySuccess_OverridesDBStatus 驗證:proxy 成功拿到即時 driver status
|
||
// ("connected")時,回應的 status 用即時值覆蓋 DB 的靜態值("unknown")。
|
||
//
|
||
// 這是 gate isDriverConnected 能通過的核心路徑(DB unknown → 即時 connected)。
|
||
func TestGetDevice_ProxySuccess_OverridesDBStatus(t *testing.T) {
|
||
fetcher := &stubDriverStatusFetcher{liveStatus: "connected"}
|
||
r := newGetDeviceFixture(t, &device.Device{
|
||
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
|
||
SerialNumber: "0xB906162C",
|
||
RemoteStatus: device.RemoteStatusOnline,
|
||
Status: device.USBStatusUnknown, // DB 靜態值
|
||
CreatedAt: time.Now().UTC(),
|
||
}, func(d *Deps) { d.DriverStatusFetcher = fetcher })
|
||
|
||
code, status := getDeviceStatus(t, r, "dev1")
|
||
require.Equal(t, http.StatusOK, code)
|
||
assert.Equal(t, "connected", status, "應以即時 driver status 覆蓋 DB unknown")
|
||
|
||
assert.True(t, fetcher.called, "應嘗試打 local agent")
|
||
assert.Equal(t, "0xB906162C", fetcher.gotSerial, "應以序號走 serial 路由")
|
||
assert.Equal(t, "demo-user", fetcher.gotUserID, "應帶當前 user")
|
||
}
|
||
|
||
// TestGetDevice_ProxyFails_FallbackDBStatus 驗證:proxy 回 error(local agent 不可達 /
|
||
// 該序號無 session)時 graceful fallback——回 DB 的 status,且 GET :id 仍是 200。
|
||
func TestGetDevice_ProxyFails_FallbackDBStatus(t *testing.T) {
|
||
fetcher := &stubDriverStatusFetcher{err: errDriverStatusUnavailable}
|
||
r := newGetDeviceFixture(t, &device.Device{
|
||
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
|
||
SerialNumber: "0xB906162C",
|
||
RemoteStatus: device.RemoteStatusOnline,
|
||
Status: device.USBStatusOnline, // DB 靜態值
|
||
CreatedAt: time.Now().UTC(),
|
||
}, func(d *Deps) { d.DriverStatusFetcher = fetcher })
|
||
|
||
code, status := getDeviceStatus(t, r, "dev1")
|
||
require.Equal(t, http.StatusOK, code, "proxy 失敗不能讓詳情頁掛")
|
||
assert.Equal(t, device.USBStatusOnline, status, "應 fallback 回 DB status")
|
||
assert.True(t, fetcher.called)
|
||
}
|
||
|
||
// TestGetDevice_TunnelDisconnected_FallbackDBStatus 驗證:tunnel 離線
|
||
// (fetcher 回 session.ErrSessionNotFound)時 fallback DB status、仍回 200。
|
||
func TestGetDevice_TunnelDisconnected_FallbackDBStatus(t *testing.T) {
|
||
fetcher := &stubDriverStatusFetcher{err: session.ErrSessionNotFound}
|
||
r := newGetDeviceFixture(t, &device.Device{
|
||
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
|
||
SerialNumber: "0xB906162C",
|
||
RemoteStatus: device.RemoteStatusOffline,
|
||
Status: device.USBStatusUnknown,
|
||
CreatedAt: time.Now().UTC(),
|
||
}, func(d *Deps) { d.DriverStatusFetcher = fetcher })
|
||
|
||
code, status := getDeviceStatus(t, r, "dev1")
|
||
require.Equal(t, http.StatusOK, code)
|
||
assert.Equal(t, device.USBStatusUnknown, status, "tunnel 離線應 fallback DB unknown")
|
||
assert.True(t, errors.Is(fetcher.err, session.ErrSessionNotFound))
|
||
}
|
||
|
||
// TestGetDevice_EmptyLiveStatus_FallbackDBStatus 驗證:proxy 成功但回空 status
|
||
// (不該覆蓋成空字串)時,保留 DB status。
|
||
func TestGetDevice_EmptyLiveStatus_FallbackDBStatus(t *testing.T) {
|
||
fetcher := &stubDriverStatusFetcher{liveStatus: ""}
|
||
r := newGetDeviceFixture(t, &device.Device{
|
||
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
|
||
SerialNumber: "0xB906162C",
|
||
RemoteStatus: device.RemoteStatusOnline,
|
||
Status: device.USBStatusOnline,
|
||
CreatedAt: time.Now().UTC(),
|
||
}, func(d *Deps) { d.DriverStatusFetcher = fetcher })
|
||
|
||
code, status := getDeviceStatus(t, r, "dev1")
|
||
require.Equal(t, http.StatusOK, code)
|
||
assert.Equal(t, device.USBStatusOnline, status, "空即時 status 不該覆蓋 DB status")
|
||
}
|
||
|
||
// TestGetDevice_NoSerial_SkipsProxy 驗證:device 無序號時完全不打 local agent
|
||
// (無序號不支援 serial 路由),直接回 DB status。
|
||
func TestGetDevice_NoSerial_SkipsProxy(t *testing.T) {
|
||
fetcher := &stubDriverStatusFetcher{liveStatus: "connected"}
|
||
r := newGetDeviceFixture(t, &device.Device{
|
||
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
|
||
SerialNumber: "", // 無序號
|
||
RemoteStatus: device.RemoteStatusOnline,
|
||
Status: device.USBStatusUnknown,
|
||
CreatedAt: time.Now().UTC(),
|
||
}, func(d *Deps) { d.DriverStatusFetcher = fetcher })
|
||
|
||
code, status := getDeviceStatus(t, r, "dev1")
|
||
require.Equal(t, http.StatusOK, code)
|
||
assert.Equal(t, device.USBStatusUnknown, status, "無序號回 DB status")
|
||
assert.False(t, fetcher.called, "無序號不該打 local agent")
|
||
}
|
||
|
||
// TestGetDevice_NoFetcher_ReturnsDBStatus 驗證:沒有 fetcher(Forwarder/SessionStore
|
||
// 未配置,resolveDriverStatusFetcher 回 nil)時 handler 不 panic,直接回 DB status。
|
||
func TestGetDevice_NoFetcher_ReturnsDBStatus(t *testing.T) {
|
||
r := newGetDeviceFixture(t, &device.Device{
|
||
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
|
||
SerialNumber: "0xB906162C",
|
||
RemoteStatus: device.RemoteStatusOnline,
|
||
Status: device.USBStatusOnline,
|
||
CreatedAt: time.Now().UTC(),
|
||
}, nil) // 不注入 fetcher;fakeSessionStore 存在但 Forwarder 為 nil → newForwarder... 回 nil
|
||
|
||
code, status := getDeviceStatus(t, r, "dev1")
|
||
require.Equal(t, http.StatusOK, code)
|
||
assert.Equal(t, device.USBStatusOnline, status)
|
||
}
|
||
|
||
// TestResolveDriverStatusFetcher_NilWhenNoForwarder 驗證:Forwarder 為 nil 時
|
||
// default fetcher 為 nil(handler 據此略過即時查詢)。
|
||
func TestResolveDriverStatusFetcher_NilWhenNoForwarder(t *testing.T) {
|
||
assert.Nil(t, resolveDriverStatusFetcher(Deps{SessionStore: &fakeSessionStore{}}),
|
||
"Forwarder 為 nil 應回 nil fetcher")
|
||
assert.Nil(t, resolveDriverStatusFetcher(Deps{}),
|
||
"Forwarder + SessionStore 皆 nil 應回 nil fetcher")
|
||
}
|
||
|
||
// TestResolveDriverStatusFetcher_InjectedWins 驗證:Deps.DriverStatusFetcher 非 nil 時
|
||
// 優先用注入的 stub(不 fallback default)。
|
||
func TestResolveDriverStatusFetcher_InjectedWins(t *testing.T) {
|
||
stub := &stubDriverStatusFetcher{}
|
||
got := resolveDriverStatusFetcher(Deps{DriverStatusFetcher: stub})
|
||
assert.Same(t, stub, got)
|
||
}
|