// b5_integration_test.go — B5 各 handler 的 end-to-end integration tests。 // // 和 integration_test.go 使用同一個 testFixture / startFakeTunnelClient,只是 // 驗證的端點不同。這個檔案聚焦 B5 新增的 handler: // - /api/auth/login + /api/auth/me(OIDC 流程跑通) // - /api/pairing/tokens(list) // - /api/devices 列表(驗證合併雲端 repo + session 狀態) // - /api/devices/scan 走 tunnel(proxy 到 fake local agent) // - /api/models/init + PUT /storage/... + /api/models/:id/finalize(完整上傳流程) // - /api/clusters 回空陣列 // // 命名刻意加 `B5_` 前綴便於從失敗輸出快速定位到本檔。 // // OB5 起:所有打 /api/* 的 test 都改用 fixture.AuthenticatedClient(t, userID, email) // 走完整 OIDC login flow 拿 cookie 再呼叫,不再使用 StaticAuthService。 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/device" ) // TestB5_AuthLoginAndMe 驗證 OIDC login flow + /auth/me 能跑通。 // // OB5 起 POST /api/auth/login 一律 410 Gone(指向 GET /api/auth/login redirect flow); // 真正的登入是 AuthenticatedClient 內部執行的 GET /api/auth/login → callback → cookie。 func TestB5_AuthLoginAndMe(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer f.Close() const wantSub = "user-b5-login" const wantEmail = "b5-login@visiona.local" client := f.AuthenticatedClient(t, wantSub, wantEmail) // 1. POST /api/auth/login → 410 Gone(OIDC mode) loginBody := map[string]string{"email": "foo@bar.local", "password": "whatever"} bodyBytes, _ := json.Marshal(loginBody) resp, err := client.Post(f.apiServer.URL+"/api/auth/login", "application/json", bytes.NewReader(bodyBytes)) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusGone, resp.StatusCode, "OIDC mode 下 POST /api/auth/login 應回 410") // 2. GET /api/auth/me — 應該回 OIDC sub resp2, err := client.Get(f.apiServer.URL + "/api/auth/me") require.NoError(t, err) defer resp2.Body.Close() require.Equal(t, http.StatusOK, resp2.StatusCode) var meResp map[string]any require.NoError(t, json.NewDecoder(resp2.Body).Decode(&meResp)) meData := meResp["data"].(map[string]any) assert.Equal(t, wantSub, meData["user_id"]) assert.Equal(t, wantEmail, meData["email"]) } // TestB5_AuthRegisterReturns501 驗證雛形不實作註冊。 // // 註:此 endpoint 走 AuthMiddleware(需 cookie),雛形語意上「已登入也不能註冊」。 func TestB5_AuthRegisterReturns501(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer f.Close() client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") resp, err := client.Post(f.apiServer.URL+"/api/auth/register", "application/json", strings.NewReader(`{}`)) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusNotImplemented, resp.StatusCode) } // TestB5_PairingTokensListAndRevoke 驗證 list / revoke 端對端流程。 func TestB5_PairingTokensListAndRevoke(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer f.Close() client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") // 建 2 個 token for i := 0; i < 2; i++ { resp, err := client.Post(f.apiServer.URL+"/api/pairing/token", "", nil) require.NoError(t, err) resp.Body.Close() } // GET list — 應該看到 2 筆 resp, err := client.Get(f.apiServer.URL + "/api/pairing/tokens") require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) var listBody map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&listBody)) arr := listBody["data"].([]any) assert.Len(t, arr, 2, "應有 2 個 pairing token") // 取其中一個 token 的 prefix(雛形 path 傳 plaintext)— 這個 test 改走 create 拿 plaintext resp2, err := client.Post(f.apiServer.URL+"/api/pairing/token", "", nil) require.NoError(t, err) defer resp2.Body.Close() var newTok map[string]any require.NoError(t, json.NewDecoder(resp2.Body).Decode(&newTok)) plaintext := newTok["data"].(map[string]any)["token"].(string) // DELETE revoke req, _ := http.NewRequest(http.MethodDelete, f.apiServer.URL+"/api/pairing/tokens/"+plaintext, nil) revResp, err := client.Do(req) require.NoError(t, err) defer revResp.Body.Close() assert.Equal(t, http.StatusNoContent, revResp.StatusCode) // 再 revoke 不存在的 token → 404 req2, _ := http.NewRequest(http.MethodDelete, f.apiServer.URL+"/api/pairing/tokens/vAc_00000000000000000000000000000000", nil) notFoundResp, err := client.Do(req2) require.NoError(t, err) defer notFoundResp.Body.Close() assert.Equal(t, http.StatusNotFound, notFoundResp.StatusCode) } // TestB5_DevicesList 驗證 GET /api/devices 讀 repo + 合併 session 狀態。 func TestB5_DevicesList(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer f.Close() client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") // 塞一台 device 到 repo(模擬使用者已配對) ctx := context.Background() // 注意:setupFixture 的 router 內部 repo 是新的,不能從外部取到; // 這個 test 只能走「先 pairing token → tunnel 連上 → session.List 有東西」 // 的間接驗證,但雲端 repo 內沒有 device。因此預期回空陣列 + 200。 resp, err := client.Get(f.apiServer.URL + "/api/devices") require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) assert.Equal(t, true, body["success"]) arr, ok := body["data"].([]any) require.True(t, ok, "data 應為 array") assert.Empty(t, arr, "預設沒有 device") _ = ctx // device.ErrNotFound 在這裡不會出現;留 import 避免 lint _ = device.ErrNotFound } // TestB5_DevicesScan_ViaTunnel 驗證 scan 端點走 tunnel proxy 到 fake local agent。 func TestB5_DevicesScan_ViaTunnel(t *testing.T) { // fake local agent:對 /api/devices/scan 回一段 JSON localHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/api/devices/scan", r.URL.Path) require.Equal(t, http.MethodPost, r.Method) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]any{ "scanned": 1, "devices": []map[string]any{ {"id": "kl520-abc", "type": "kl520"}, }, }) }) f := setupFixture(t, localHandler) defer f.Close() client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") // 建立 tunnel const token = "vAc_ccccccccccccccccccccccccccccccc1" stop := startFakeTunnelClient(t, f.tunnelSrv.URL, token, strings.TrimPrefix(f.localBackend.URL, "http://")) defer stop() require.Eventually(t, func() bool { ok, _ := f.store.Exists(context.Background(), token) return ok }, 2*time.Second, 20*time.Millisecond) // POST /api/devices/scan resp, err := client.Post(f.apiServer.URL+"/api/devices/scan", "application/json", nil) require.NoError(t, err, "scan 應該走 proxy 並成功") defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) assert.EqualValues(t, 1, body["scanned"]) assert.NotEmpty(t, body["devices"]) } // TestB5_DevicesScan_TunnelDisconnected 驗證 tunnel 不存在時回 502 + TUNNEL_DISCONNECTED。 func TestB5_DevicesScan_TunnelDisconnected(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer f.Close() client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") // 不起 tunnel → 直接打 scan resp, err := client.Post(f.apiServer.URL+"/api/devices/scan", "application/json", nil) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusBadGateway, resp.StatusCode) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) errObj := body["error"].(map[string]any) assert.Equal(t, "TUNNEL_DISCONNECTED", errObj["code"]) } // TestB5_ModelUploadFlow 驗證完整的模型上傳流程:init → PUT → finalize。 // // 這個是 B5 最硬的 integration test — 涵蓋 storage presigned URL、model repo、 // 兩階段上傳的 handshake。 func TestB5_ModelUploadFlow(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer f.Close() client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") // 1. POST /api/models/init initBody := map[string]any{ "name": "YOLOv5 Test", "file_size": 11, "target_chip": "kl520", } initBytes, _ := json.Marshal(initBody) initResp, err := client.Post(f.apiServer.URL+"/api/models/init", "application/json", bytes.NewReader(initBytes)) require.NoError(t, err) require.Equal(t, http.StatusOK, initResp.StatusCode, "init 應成功") var initRespBody map[string]any require.NoError(t, json.NewDecoder(initResp.Body).Decode(&initRespBody)) initResp.Body.Close() initData := initRespBody["data"].(map[string]any) modelID := initData["model_id"].(string) uploadURL := initData["upload_url"].(string) require.NotEmpty(t, modelID) require.NotEmpty(t, uploadURL) // 2. PUT 上傳檔案 — 直接用 init 回來的 upload_url(setupFixture 已把 storage.baseURL // 指向 apiServer.URL+"/storage",所以 upload_url 已是可用的完整 URL)。 // PUT /storage/* 不走 AuthMiddleware(HMAC 簽章),用 default client 即可。 _ = initData["storage_key"] // 保留變數供未來驗證 payload := []byte("hello world") // 11 bytes 對上 file_size putReq, err := http.NewRequest(http.MethodPut, uploadURL, bytes.NewReader(payload)) require.NoError(t, err) putReq.ContentLength = int64(len(payload)) putResp, err := http.DefaultClient.Do(putReq) require.NoError(t, err) defer putResp.Body.Close() require.Equal(t, http.StatusNoContent, putResp.StatusCode, "PUT 應該 204") // 3. POST /api/models/:id/finalize finalizeResp, err := client.Post(f.apiServer.URL+"/api/models/"+modelID+"/finalize", "application/json", nil) require.NoError(t, err) defer finalizeResp.Body.Close() // 注意:不要在 require 的錯誤訊息中 readBody() — 那會消耗 body 導致後面 Decode EOF require.Equal(t, http.StatusOK, finalizeResp.StatusCode, "finalize 應成功") var fbody map[string]any require.NoError(t, json.NewDecoder(finalizeResp.Body).Decode(&fbody)) fdata := fbody["data"].(map[string]any) assert.Equal(t, "ready", fdata["status"]) assert.Equal(t, modelID, fdata["id"]) // 4. GET /api/models — 應該看到我們上傳的 listResp, err := client.Get(f.apiServer.URL + "/api/models") require.NoError(t, err) defer listResp.Body.Close() var lbody map[string]any require.NoError(t, json.NewDecoder(listResp.Body).Decode(&lbody)) arr := lbody["data"].([]any) assert.GreaterOrEqual(t, len(arr), 1) } // TestB5_ModelInit_TooLarge 驗證超過 MaxUploadSizeMB 回 413。 func TestB5_ModelInit_TooLarge(t *testing.T) { // 自行 spin fixture 並設一個很小的 max size f := setupFixtureWithMaxUpload(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), 1) // 1 MB defer f.Close() client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") initBody := map[string]any{ "name": "too big", "file_size": int64(2) * 1024 * 1024, // 2 MB > 1 MB limit } initBytes, _ := json.Marshal(initBody) resp, err := client.Post(f.apiServer.URL+"/api/models/init", "application/json", bytes.NewReader(initBytes)) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) errObj := body["error"].(map[string]any) assert.Equal(t, "PAYLOAD_TOO_LARGE", errObj["code"]) } // TestB5_StoragePutDirect 先單獨驗證 /storage/* PUT 走得通(排除 model upload 流程變數)。 func TestB5_StoragePutDirect(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer f.Close() client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") // 先 init 一個 model 取得 upload_url initBytes, _ := json.Marshal(map[string]any{"name": "x", "file_size": 11}) initResp, _ := client.Post(f.apiServer.URL+"/api/models/init", "application/json", bytes.NewReader(initBytes)) var ib map[string]any _ = json.NewDecoder(initResp.Body).Decode(&ib) initResp.Body.Close() uploadURL := ib["data"].(map[string]any)["upload_url"].(string) t.Logf("uploadURL=%s", uploadURL) // PUT /storage/* 不走 AuthMiddleware(HMAC 簽章),用 default client 即可 putReq, _ := http.NewRequest(http.MethodPut, uploadURL, bytes.NewReader([]byte("hello world"))) putReq.ContentLength = 11 resp, err := http.DefaultClient.Do(putReq) require.NoError(t, err, "err=%v", err) defer resp.Body.Close() assert.Equal(t, http.StatusNoContent, resp.StatusCode, "body=%s", readBody(resp.Body)) } // TestB5_ClustersList 驗證 GET /api/clusters 回空陣列(雛形)。 func TestB5_ClustersList(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer f.Close() client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") resp, err := client.Get(f.apiServer.URL + "/api/clusters") require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) assert.Equal(t, true, body["success"]) arr := body["data"].([]any) assert.Empty(t, arr) } // ---------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------- // readBody 非 destructive 讀 response body(供 failure message 用)。 func readBody(r io.Reader) string { b, _ := io.ReadAll(r) return string(b) }