visionA/visionA-backend/cmd/api-server/b5_integration_test.go
jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:

- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
  (tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
  WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
  - internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
  - internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
    防 session fixation, OWASP ASVS V3.2.1)
  - 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
  - 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
  - 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
    ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
  - OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
    (AuthStyleInParams 強制 token endpoint 不送 client_secret)
  - 預留 ServiceClient* 欄位給未來 client_credentials grant
  - 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
    (Audit C1:multi-tenant 隔離破口)
  - Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
  - 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:20 +08:00

365 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// b5_integration_test.go — B5 各 handler 的 end-to-end integration tests。
//
// 和 integration_test.go 使用同一個 testFixture / startFakeTunnelClient只是
// 驗證的端點不同。這個檔案聚焦 B5 新增的 handler
// - /api/auth/login + /api/auth/meOIDC 流程跑通)
// - /api/pairing/tokenslist
// - /api/devices 列表(驗證合併雲端 repo + session 狀態)
// - /api/devices/scan 走 tunnelproxy 到 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 GoneOIDC 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_urlsetupFixture 已把 storage.baseURL
// 指向 apiServer.URL+"/storage",所以 upload_url 已是可用的完整 URL
// PUT /storage/* 不走 AuthMiddlewareHMAC 簽章),用 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/* 不走 AuthMiddlewareHMAC 簽章),用 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)
}