package api import ( "bytes" "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/auth" "visiona-backend/internal/session" ) // TestPairingCreateToken_OK 驗證能成功建 pairing token,且回傳格式合法。 func TestPairingCreateToken_OK(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "")) g := r.Group("/api") // Phase 0.7 security fix C1:移除 Deps.StaticUserID,改由 injectStaticUserContext 顯式注入。 registerPairingRoutes(g, Deps{ Logger: nil, PairingStore: auth.NewInMemoryPairingStore(), }) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/pairing/token", nil)) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var body SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.True(t, body.Success) data := body.Data.(map[string]any) tok, _ := data["token"].(string) assert.True(t, strings.HasPrefix(tok, "vAc_"), "token 應為 pairing 格式:%s", tok) assert.True(t, auth.IsValidPairingToken(tok), "token 應通過格式驗證") assert.NotEmpty(t, data["expires_at"]) } // TestPairingCreateToken_NoStore 驗證沒注入 PairingStore 時回 501。 func TestPairingCreateToken_NoStore(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) g := r.Group("/api") registerPairingRoutes(g, Deps{Logger: nil}) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/pairing/token", nil)) assert.Equal(t, 501, w.Code) assert.Contains(t, w.Body.String(), ErrCodeNotImplemented) } // TestPairingStatus_NoSession 驗證沒 session 時回 connected=false。 func TestPairingStatus_NoSession(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) g := r.Group("/api") registerPairingRoutes(g, Deps{ SessionStore: &fakeSessionStore{}, }) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/pairing/status", nil)) require.Equal(t, http.StatusOK, w.Code) var body SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) data := body.Data.(map[string]any) assert.Equal(t, false, data["connected"]) } // ========================================================================== // AB11:POST /api/pairing/exchange 測試 // ========================================================================== // setupExchangeRouter 建立一個只掛 exchange endpoint 的 minimal router。 // // 重點:exchange **不走** AuthMiddleware,故不掛 AuthMiddleware。 // 這也反映了 production 的 NewRouter 實際行為(registerPairingPublicRoutes 在 // engine 層註冊,而不是 apiGroup)。 func setupExchangeRouter(t *testing.T, deps Deps) *gin.Engine { t.Helper() r := gin.New() r.Use(RequestIDMiddleware()) registerPairingPublicRoutes(r, deps) return r } // issuePairingToken 建一個合法 pairing token 供 exchange 測試用。 func issuePairingToken(t *testing.T, store auth.PairingStore, userID string, ttl time.Duration) string { t.Helper() plain, _, err := store.Create(context.Background(), userID, ttl) require.NoError(t, err) return plain } // TestPairingExchange_OK 驗證 happy path:拿合法 pairing token 換到 session token。 func TestPairingExchange_OK(t *testing.T) { pairings := auth.NewInMemoryPairingStore() sessions := auth.NewInMemorySessionTokenStore() pairingTok := issuePairingToken(t, pairings, "demo-user", 15*time.Minute) r := setupExchangeRouter(t, Deps{ PairingStore: pairings, SessionTokenStore: sessions, RelayPublicURL: "wss://relay.test.local", }) body, _ := json.Marshal(PairingExchangeRequest{PairingToken: pairingTok}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var resp SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.True(t, resp.Success) data := resp.Data.(map[string]any) sessTok, _ := data["session_token"].(string) assert.True(t, auth.IsValidSessionToken(sessTok), "session_token 應為合法 vAs_ 格式:%s", sessTok) assert.Equal(t, "wss://relay.test.local", data["relay_url"]) assert.Equal(t, "demo-user@visionA.local", data["account"]) assert.NotEmpty(t, data["expires_at"]) // Session token 應能從 store 查到 _, err := sessions.Get(context.Background(), sessTok) assert.NoError(t, err) // Pairing token 應被標為 used;再 exchange 一次應該失敗 w2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body)) req2.Header.Set("Content-Type", "application/json") r.ServeHTTP(w2, req2) assert.Equal(t, http.StatusUnauthorized, w2.Code) assert.Contains(t, w2.Body.String(), ErrCodePairingTokenUsed) } // TestPairingExchange_InvalidFormat 驗證格式錯的 token 回 401 INVALID_PAIRING_TOKEN。 func TestPairingExchange_InvalidFormat(t *testing.T) { r := setupExchangeRouter(t, Deps{ PairingStore: auth.NewInMemoryPairingStore(), SessionTokenStore: auth.NewInMemorySessionTokenStore(), }) // 格式錯(缺前綴) body, _ := json.Marshal(PairingExchangeRequest{PairingToken: "not-a-real-token"}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Contains(t, w.Body.String(), ErrCodeInvalidPairingToken) } // TestPairingExchange_MissingField 驗證 body 沒 pairing_token 回 400 VALIDATION_FAILED。 func TestPairingExchange_MissingField(t *testing.T) { r := setupExchangeRouter(t, Deps{ PairingStore: auth.NewInMemoryPairingStore(), SessionTokenStore: auth.NewInMemorySessionTokenStore(), }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), ErrCodeValidationFailed) } // TestPairingExchange_Unknown 驗證合法格式但 store 找不到的 token 回 401 INVALID_PAIRING_TOKEN。 func TestPairingExchange_Unknown(t *testing.T) { r := setupExchangeRouter(t, Deps{ PairingStore: auth.NewInMemoryPairingStore(), SessionTokenStore: auth.NewInMemorySessionTokenStore(), }) // 格式合法但 store 沒存過 unknown, err := auth.GeneratePairingToken() require.NoError(t, err) body, _ := json.Marshal(PairingExchangeRequest{PairingToken: unknown}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Contains(t, w.Body.String(), ErrCodeInvalidPairingToken) } // TestPairingExchange_Expired 驗證過期 token 回 401 PAIRING_TOKEN_EXPIRED。 func TestPairingExchange_Expired(t *testing.T) { pairings := auth.NewInMemoryPairingStore() // TTL 1ns → 幾乎立刻過期 pairingTok := issuePairingToken(t, pairings, "demo-user", 1*time.Nanosecond) time.Sleep(5 * time.Millisecond) r := setupExchangeRouter(t, Deps{ PairingStore: pairings, SessionTokenStore: auth.NewInMemorySessionTokenStore(), }) body, _ := json.Marshal(PairingExchangeRequest{PairingToken: pairingTok}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Contains(t, w.Body.String(), ErrCodePairingTokenExpired) } // TestPairingExchange_Revoked 驗證撤銷 token 回 401 PAIRING_TOKEN_REVOKED。 func TestPairingExchange_Revoked(t *testing.T) { pairings := auth.NewInMemoryPairingStore() pairingTok := issuePairingToken(t, pairings, "demo-user", 15*time.Minute) require.NoError(t, pairings.Revoke(context.Background(), pairingTok)) r := setupExchangeRouter(t, Deps{ PairingStore: pairings, SessionTokenStore: auth.NewInMemorySessionTokenStore(), }) body, _ := json.Marshal(PairingExchangeRequest{PairingToken: pairingTok}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Contains(t, w.Body.String(), ErrCodePairingTokenRevoked) } // TestPairingExchange_NoStore 驗證 SessionTokenStore / PairingStore 缺失時回 501。 func TestPairingExchange_NoStore(t *testing.T) { r := setupExchangeRouter(t, Deps{}) // 兩個 store 都 nil body, _ := json.Marshal(PairingExchangeRequest{PairingToken: "vAc_" + strings.Repeat("0", 32)}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, 501, w.Code) assert.Contains(t, w.Body.String(), ErrCodeNotImplemented) } // TestPairingExchange_DefaultRelayURL 驗證沒設 RelayPublicURL 時會 fallback 到 placeholder。 func TestPairingExchange_DefaultRelayURL(t *testing.T) { pairings := auth.NewInMemoryPairingStore() sessions := auth.NewInMemorySessionTokenStore() pairingTok := issuePairingToken(t, pairings, "demo-user", 15*time.Minute) r := setupExchangeRouter(t, Deps{ PairingStore: pairings, SessionTokenStore: sessions, // RelayPublicURL 刻意留空 }) body, _ := json.Marshal(PairingExchangeRequest{PairingToken: pairingTok}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var resp SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) data := resp.Data.(map[string]any) assert.Equal(t, defaultRelayPublicURL, data["relay_url"]) } // TestPairingStatus_WithSession 驗證有 session 時回 connected=true + 對應欄位。 // // Phase 0.7 security fix M2:pairingStatusHandler 已改為 strict equality, // 必須顯式注入 UserContext 才能拿到匹配 session(不再走「空 UserID 視為 match」捷徑)。 func TestPairingStatus_WithSession(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "")) g := r.Group("/api") registerPairingRoutes(g, Deps{ SessionStore: &fakeSessionStore{ sessions: []*session.Summary{ { Token: "vAc_a", UserID: "demo-user", DeviceID: "dev-1", ConnectedAt: now.Add(-1 * time.Hour), LastHeartbeat: now, }, }, }, }) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/pairing/status", nil)) require.Equal(t, http.StatusOK, w.Code) var body SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) data := body.Data.(map[string]any) assert.Equal(t, true, data["connected"]) assert.Equal(t, "dev-1", data["device_id"]) assert.NotEmpty(t, data["connected_at"]) assert.NotEmpty(t, data["last_seen_at"]) }