從 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>
365 lines
14 KiB
Go
365 lines
14 KiB
Go
// 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)
|
||
}
|