// pairing_exchange_test.go — AB11: POST /api/pairing/exchange 的 end-to-end integration test。 // // 覆蓋情境: // - 產 Pairing Token(POST /api/pairing/token,走 AuthMiddleware → 需 OIDC cookie) // - 拿 Pairing Token 換 Session Token(POST /api/pairing/exchange,不走 AuthMiddleware) // - 拿 Session Token 連 tunnel(remote-proxy 只做格式驗證 → 應能接受 vAs_) // - 驗證同一個 Pairing Token 無法重複兌換 // // 雛形取捨: // - remote-proxy 目前**不會**回頭驗證 Session Token 是否出自 api-server(選項 A)。 // 故本測試沒有驗證「跨進程 session store 同步」— 這留給 Phase 1 實作。 package main import ( "bytes" "context" "encoding/json" "io" "net/http" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/api" "visiona-backend/internal/auth" ) // TestAB11_PairingExchange_EndToEnd 跑完整個雛形 exchange → tunnel-connect 流程。 func TestAB11_PairingExchange_EndToEnd(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer f.Close() client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") // 1. POST /api/pairing/token → 拿一個 Pairing Token(走 AuthMiddleware,OIDC cookie 放行) tokResp, err := client.Post(f.apiServer.URL+"/api/pairing/token", "", nil) require.NoError(t, err) defer tokResp.Body.Close() require.Equal(t, http.StatusOK, tokResp.StatusCode) var tokBody map[string]any require.NoError(t, json.NewDecoder(tokResp.Body).Decode(&tokBody)) pairingTok := tokBody["data"].(map[string]any)["token"].(string) require.True(t, auth.IsValidPairingToken(pairingTok), "token 格式應合法:%s", pairingTok) // 2. POST /api/pairing/exchange → 換 Session Token(不走 AuthMiddleware) reqBody, _ := json.Marshal(api.PairingExchangeRequest{PairingToken: pairingTok}) exchResp, err := http.Post(f.apiServer.URL+"/api/pairing/exchange", "application/json", bytes.NewReader(reqBody)) require.NoError(t, err) defer exchResp.Body.Close() bodyBytes, _ := io.ReadAll(exchResp.Body) require.Equal(t, http.StatusOK, exchResp.StatusCode, "body: %s", string(bodyBytes)) var exchBody map[string]any require.NoError(t, json.Unmarshal(bodyBytes, &exchBody)) data := exchBody["data"].(map[string]any) sessionTok := data["session_token"].(string) require.True(t, auth.IsValidSessionToken(sessionTok), "session_token 格式應合法:%s", sessionTok) assert.NotEmpty(t, data["relay_url"]) assert.NotEmpty(t, data["account"]) assert.NotEmpty(t, data["expires_at"]) // account 應綁到 OIDC sub(OB5 升級的關鍵驗證 — 不再是 demo-user@...) assert.Equal(t, "demo-user@visionA.local", data["account"], "OB5 起 account 應 = OIDC sub + suffix;本 test 用 demo-user 當 sub") // 3. 拿 Session Token 連 tunnel — remote-proxy 只做格式驗證,應該接受 stop := startFakeTunnelClient(t, f.tunnelSrv.URL, sessionTok, f.localBackend.URL[len("http://"):]) defer stop() // 等 session 建立(session 進 store 需要非同步 handshake) require.Eventually(t, func() bool { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() summaries, err := f.store.List(ctx) return err == nil && len(summaries) == 1 }, 2*time.Second, 50*time.Millisecond, "tunnel session 應該建立") // 4. 同一 pairing token 再換一次 → 應該 401 PAIRING_TOKEN_USED exchResp2, err := http.Post(f.apiServer.URL+"/api/pairing/exchange", "application/json", bytes.NewReader(reqBody)) require.NoError(t, err) defer exchResp2.Body.Close() assert.Equal(t, http.StatusUnauthorized, exchResp2.StatusCode) body2, _ := io.ReadAll(exchResp2.Body) assert.Contains(t, string(body2), "PAIRING_TOKEN_USED") } // TestAB11_PairingExchange_Unauth 驗證 /api/pairing/exchange 本身不受 AuthMiddleware 管控。 // // OB5 起 AuthMiddleware 已是 OIDC(cookie),exchange endpoint 必須仍然能用「沒登入的 // 純 HTTP client」打通 — 因為 agent 端就是 unauthenticated 來換 session token 的。 func TestAB11_PairingExchange_Unauth(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer f.Close() // 先用 OIDC client 拿一個 pairing token(authenticated) authClient := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") tokResp, err := authClient.Post(f.apiServer.URL+"/api/pairing/token", "", nil) require.NoError(t, err) defer tokResp.Body.Close() var tokBody map[string]any require.NoError(t, json.NewDecoder(tokResp.Body).Decode(&tokBody)) pairingTok := tokBody["data"].(map[string]any)["token"].(string) // 送 exchange,刻意用「沒任何 cookie / Auth header」的 default client — 應該還是 200 OK reqBody, _ := json.Marshal(api.PairingExchangeRequest{PairingToken: pairingTok}) req, _ := http.NewRequest(http.MethodPost, f.apiServer.URL+"/api/pairing/exchange", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) } // TestAB11_PairingExchange_InvalidFormat 驗證不合法格式的 token 回 401 INVALID_PAIRING_TOKEN。 func TestAB11_PairingExchange_InvalidFormat(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer f.Close() resp, err := http.Post(f.apiServer.URL+"/api/pairing/exchange", "application/json", strings.NewReader(`{"pairing_token":"not-valid"}`)) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) body, _ := io.ReadAll(resp.Body) assert.Contains(t, string(body), "INVALID_PAIRING_TOKEN") }