package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "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" ) // stubLocalTokenIssuer 是 localTokenIssuer 的測試替身。 // // 可設定:回傳的 ticket / error,並記錄呼叫參數,用來驗證 handler 是否有嘗試取 token、 // 以及傳的 serial / userID 正確。 type stubLocalTokenIssuer struct { ticket LocalUploadTicket err error called bool gotUserID string gotSerial string } func (s *stubLocalTokenIssuer) IssueToken(_ context.Context, userID, serial string) (LocalUploadTicket, error) { s.called = true s.gotUserID = userID s.gotSerial = serial return s.ticket, s.err } // newLocalTicketFixture 建 router + 塞一顆 device,可注入自訂 Deps 欄位(issuer)。 // loginUserID 是「已登入 user」(AuthMiddleware 塞的 UserContext),可與 device owner 不同以驗 IDOR。 func newLocalTicketFixture(t *testing.T, d *device.Device, loginUserID string, 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(loginUserID, "")) g := r.Group("/api") deps := Deps{ DeviceRepo: repo, SessionStore: &fakeSessionStore{}, } if mutate != nil { mutate(&deps) } registerDeviceRoutes(g, deps) return r } // postLocalTicket 打 POST /api/devices/:serial/local-upload-ticket 並回 (status, 解出的 body)。 func postLocalTicket(t *testing.T, r *gin.Engine, serial string) (int, map[string]any) { t.Helper() w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/devices/"+serial+"/local-upload-ticket", strings.NewReader("{}")) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) var body map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "body=%s", w.Body.String()) return w.Code, body } func ownedDevice() *device.Device { return &device.Device{ ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520", SerialNumber: "0xB906162C", RemoteStatus: device.RemoteStatusOnline, Status: device.USBStatusOnline, CreatedAt: time.Now().UTC(), } } // TestLocalTicket_Success 驗證:裝置歸屬 + tunnel 正常時,透傳 local-agent 產的 token。 func TestLocalTicket_Success(t *testing.T) { issuer := &stubLocalTokenIssuer{ticket: LocalUploadTicket{ Token: "tok_abc123", ExpiresAt: 1700000000000, TTLSeconds: 120, }} r := newLocalTicketFixture(t, ownedDevice(), "demo-user", func(d *Deps) { d.LocalTokenIssuer = issuer }) code, body := postLocalTicket(t, r, "0xB906162C") require.Equal(t, http.StatusOK, code, "body=%v", body) require.Equal(t, true, body["success"]) data, ok := body["data"].(map[string]any) require.True(t, ok, "data 應為物件,body=%v", body) assert.Equal(t, "tok_abc123", data["token"]) assert.Equal(t, float64(1700000000000), data["expiresAt"]) assert.Equal(t, float64(120), data["ttlSeconds"]) assert.True(t, issuer.called, "應嘗試打 local agent issue-token") assert.Equal(t, "0xB906162C", issuer.gotSerial, "應以 device 記錄的序號打 local agent") assert.Equal(t, "demo-user", issuer.gotUserID, "應帶當前登入 user") } // TestLocalTicket_DeviceNotOwned_404 驗證:serial 不屬於當前登入 user 時回 404, // 且**完全不打 local agent**(歸屬檢查先於 issue-token)。這是 IDOR 防護的核心路徑。 func TestLocalTicket_DeviceNotOwned_404(t *testing.T) { issuer := &stubLocalTokenIssuer{ticket: LocalUploadTicket{Token: "should_not_be_returned"}} // device owner = demo-user;但登入者是 attacker → GetBySerial(attacker, serial) 查不到。 r := newLocalTicketFixture(t, ownedDevice(), "attacker", func(d *Deps) { d.LocalTokenIssuer = issuer }) code, body := postLocalTicket(t, r, "0xB906162C") require.Equal(t, http.StatusNotFound, code, "非 owner 應回 404") errObj, ok := body["error"].(map[string]any) require.True(t, ok, "應有 error 物件,body=%v", body) assert.Equal(t, ErrCodeNotFound, errObj["code"]) assert.False(t, issuer.called, "非 owner 不該打 local agent(歸屬檢查先擋)") } // TestLocalTicket_UnknownSerial_404 驗證:序號不存在(連 owner 自己都沒這顆)→ 404。 func TestLocalTicket_UnknownSerial_404(t *testing.T) { issuer := &stubLocalTokenIssuer{} r := newLocalTicketFixture(t, ownedDevice(), "demo-user", func(d *Deps) { d.LocalTokenIssuer = issuer }) code, body := postLocalTicket(t, r, "0xDEADBEEF") // owner 有 dev1(0xB906162C) 但無此序號 require.Equal(t, http.StatusNotFound, code) errObj, _ := body["error"].(map[string]any) assert.Equal(t, ErrCodeNotFound, errObj["code"]) assert.False(t, issuer.called) } // TestLocalTicket_TokenLimit_429 驗證:local-agent 回 token 上限(errLocalTokenLimit)→ // 透傳 429 RATE_LIMITED,不當成 500 / 502。 func TestLocalTicket_TokenLimit_429(t *testing.T) { issuer := &stubLocalTokenIssuer{err: errLocalTokenLimit} r := newLocalTicketFixture(t, ownedDevice(), "demo-user", func(d *Deps) { d.LocalTokenIssuer = issuer }) code, body := postLocalTicket(t, r, "0xB906162C") require.Equal(t, http.StatusTooManyRequests, code, "token 上限應透傳 429") errObj, _ := body["error"].(map[string]any) assert.Equal(t, ErrCodeRateLimited, errObj["code"]) assert.True(t, issuer.called) } // TestLocalTicket_TunnelDisconnected_502 驗證:tunnel 離線(session.ErrSessionNotFound)→ // 502 TUNNEL_DISCONNECTED(R-3:裝置未連線無法取 token,前端據此 disable 上傳)。 func TestLocalTicket_TunnelDisconnected_502(t *testing.T) { issuer := &stubLocalTokenIssuer{err: session.ErrSessionNotFound} r := newLocalTicketFixture(t, ownedDevice(), "demo-user", func(d *Deps) { d.LocalTokenIssuer = issuer }) code, body := postLocalTicket(t, r, "0xB906162C") require.Equal(t, http.StatusBadGateway, code) errObj, _ := body["error"].(map[string]any) assert.Equal(t, ErrCodeTunnelDisconnect, errObj["code"]) } // TestLocalTicket_TunnelError_502 驗證:local-agent 不可達 / 非預期回應(errLocalTokenUnavailable) // → 502 TUNNEL_ERROR。 func TestLocalTicket_TunnelError_502(t *testing.T) { issuer := &stubLocalTokenIssuer{err: errLocalTokenUnavailable} r := newLocalTicketFixture(t, ownedDevice(), "demo-user", func(d *Deps) { d.LocalTokenIssuer = issuer }) code, body := postLocalTicket(t, r, "0xB906162C") require.Equal(t, http.StatusBadGateway, code) errObj, _ := body["error"].(map[string]any) assert.Equal(t, ErrCodeTunnelError, errObj["code"]) } // TestLocalTicket_NoIssuer_501 驗證:Forwarder/SessionStore 未配置(resolveLocalTokenIssuer // 回 nil)→ 501 NOT_IMPLEMENTED,而非 panic / 500。 func TestLocalTicket_NoIssuer_501(t *testing.T) { // 不注入 LocalTokenIssuer,且 Deps.Forwarder 為 nil → newForwarderLocalTokenIssuer 回 nil。 // fixture 預設有 SessionStore 但無 Forwarder,故 default issuer 為 nil。 r := newLocalTicketFixture(t, ownedDevice(), "demo-user", nil) code, body := postLocalTicket(t, r, "0xB906162C") require.Equal(t, http.StatusNotImplemented, code) errObj, _ := body["error"].(map[string]any) assert.Equal(t, ErrCodeNotImplemented, errObj["code"]) } // TestResolveLocalTokenIssuer_NilWhenNoForwarder 驗證:Forwarder 為 nil 時 default issuer 為 nil。 func TestResolveLocalTokenIssuer_NilWhenNoForwarder(t *testing.T) { assert.Nil(t, resolveLocalTokenIssuer(Deps{SessionStore: &fakeSessionStore{}}), "Forwarder 為 nil 應回 nil issuer") assert.Nil(t, resolveLocalTokenIssuer(Deps{}), "Forwarder + SessionStore 皆 nil 應回 nil issuer") } // TestResolveLocalTokenIssuer_InjectedWins 驗證:Deps.LocalTokenIssuer 非 nil 時優先用注入的 stub。 func TestResolveLocalTokenIssuer_InjectedWins(t *testing.T) { stub := &stubLocalTokenIssuer{} got := resolveLocalTokenIssuer(Deps{LocalTokenIssuer: stub}) assert.Same(t, stub, got) }