jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。

OIDC / pairing FK 修復(接 DB 上線必要):
- 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback
  驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID)
- pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token;
  自建 device 空 serial 寫 NULL(避免撞 partial unique)
- device.SaveTx / session.CreateTx 新增 tx-aware 版本

B4 model metadata:
- 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model:
  converter_client → flow → adapter → model.Model → PG → ModelResponse DTO
- input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性
- 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md

nginx healthz(部署層):
- 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB
- 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因)

storage error 統一映射(不洩漏 storage 後端細節)。

測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、
internal/db(redis)、relay/session 弱處,含 testcontainers integration。
DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:36:35 +08:00

310 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.

//go:build dbtest
// e2e_dbon_test.go — 塊 6 完整版6.4 整鏈持久化 e2e + 6.5 DB-on 回歸驗證)。
//
// 這是「DB 接入」的最終 e2e 驗收:證明 6 個 store 都接上 Postgres / Redis 後,
//
// 1. 既有 e2e 路徑OIDC 登入 → model 上傳 → 列 → get/ownership在 DB-on 模式仍跑通;
// 2. **重啟pool 重建)後資料還在**持久化的核心承諾——in-memory 模式做不到);
// 3. **unpair device 後該 device 的 pairing + session token 真的被撤銷**(塊 5.2 cascade
// 的 e2e/store 層驗證,含直接查 DB 斷言 revoked_at / deleted_at
//
// build tag `dbtest`:需要 DockerPG 容器)。本機無 docker → Orchestrator 在 130 補跑:
//
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
// go test -tags=dbtest -run 'DBOn' ./cmd/api-server/... -v
//
// 設計取捨(重要,與既有 in-memory e2e 的差異點,對應回報給 Orchestrator 的發現):
//
// - **owner_user_id FK**DB-on 下 models/devices/*_tokens 的 owner 是 `UUID NOT NULL
// REFERENCES users(id)`。AuthenticatedClient 的 OIDC sub 必須是合法 UUID且對應 users 列
// 必須先存在production OIDC callback 不 auto-provision users → 見回報的疑似 bug
// 故 DB-on e2e 一律用 uuid.NewString() 當 sub + 先 f.ensureUser(...)。
//
// - **pairing→exchange 整鏈走不到 HTTP**session_tokens.device_id 是 `UUID NOT NULL
// REFERENCES devices(id)`,但雛形 pairing exchange handler 傳的 info.DeviceID 為空
// pairing token 未綁 device→ 在 DB-on 下 Create session token 會因 NOT NULL/FK 失敗。
// 因此「unpair cascade」這條無法靠 HTTP exchange 製造已綁 device 的 session token改在
// store 層直接建user → device → 已綁 device 的 pairing token + session token再經
// DeviceUnpairer.Unpair 驗 cascade。這如實反映雛形現況且仍覆蓋塊 5.2 的 Postgres tx 路徑。
// (此差異已回報 OrchestratorHTTP exchange 在 DB-on 模式需綁 device_id 才能完整跑通。)
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
"visiona-backend/internal/db"
"visiona-backend/internal/device"
)
// discardLoggerDBOn 回傳一個丟棄輸出的 logger避免測試噪音
func discardLoggerDBOn() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// noopLocal 是不需要 tunnel 的 e2e 用的空 local handler。
func noopLocal() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
}
// uploadModelDBOn 走完整兩階段上傳init → PUT → finalize回傳 model id。
// 對齊 b5_integration_test.go 的 TestB5_ModelUploadFlow但跑在 DB-on fixture 上。
func uploadModelDBOn(t *testing.T, f *dbOnFixture, client *http.Client, name string) string {
t.Helper()
// 1. init
initBody, _ := json.Marshal(map[string]any{
"name": name,
"file_size": 11,
"target_chip": "kl520",
})
initResp, err := client.Post(f.apiServer.URL+"/api/models/init", "application/json", bytes.NewReader(initBody))
require.NoError(t, err)
require.Equal(t, http.StatusOK, initResp.StatusCode, "init 應成功DB-on")
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 檔案HMAC presigned不走 auth
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. finalize
finResp, err := client.Post(f.apiServer.URL+"/api/models/"+modelID+"/finalize", "application/json", nil)
require.NoError(t, err)
defer finResp.Body.Close()
require.Equal(t, http.StatusOK, finResp.StatusCode, "finalize 應成功DB-on")
var fbody map[string]any
require.NoError(t, json.NewDecoder(finResp.Body).Decode(&fbody))
fdata := fbody["data"].(map[string]any)
assert.Equal(t, "ready", fdata["status"])
assert.Equal(t, modelID, fdata["id"])
return modelID
}
// listModelIDsDBOn 打 GET /api/models 回 model id 集合。
func listModelIDsDBOn(t *testing.T, f *dbOnFixture, client *http.Client) map[string]bool {
t.Helper()
resp, err := client.Get(f.apiServer.URL + "/api/models")
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))
out := map[string]bool{}
for _, raw := range body["data"].([]any) {
m := raw.(map[string]any)
out[m["id"].(string)] = true
}
return out
}
// TestDBOn_ModelUploadFlow_E2E 驗證 OIDC 登入 → model 上傳 → 列 → get/ownership 在 DB-on 跑通。
//
// 這是把 b5 in-memory 上傳 e2e「平移到 Postgres」的回歸驗證證明改注入 PG model repo +
// PG token store + Redis session 後handler 一行不改仍端對端跑通。
func TestDBOn_ModelUploadFlow_E2E(t *testing.T) {
f := setupFixtureDBOn(t, noopLocal())
defer f.Close()
sub := uuid.NewString()
email := "dbon-upload@visiona.local"
f.ensureUser(t, sub, email)
client := f.AuthenticatedClient(t, sub, email)
modelID := uploadModelDBOn(t, f, client, "YOLOv5 DB-on")
// GET /api/models — 看得到剛上傳的
ids := listModelIDsDBOn(t, f, client)
assert.True(t, ids[modelID], "list 應含剛上傳的 modelDB-on")
// GET /api/models/:id — owner 取得成功
getResp, err := client.Get(f.apiServer.URL + "/api/models/" + modelID)
require.NoError(t, err)
defer getResp.Body.Close()
require.Equal(t, http.StatusOK, getResp.StatusCode, "owner get 應 200")
// 直接查 DB 斷言 row 真的落地(不只是 handler 記憶體)。
var cnt int
require.NoError(t, f.pool.Pool().QueryRow(context.Background(),
`SELECT count(*) FROM models WHERE id = $1 AND owner_user_id = $2 AND deleted_at IS NULL`,
modelID, sub).Scan(&cnt))
assert.Equal(t, 1, cnt, "model row 應已落 Postgres")
// 另一個 user 不該看到owner 隔離 + FK 也成立)。
otherSub := uuid.NewString()
f.ensureUser(t, otherSub, "other-dbon@visiona.local")
otherClient := f.AuthenticatedClient(t, otherSub, "other-dbon@visiona.local")
otherGet, err := otherClient.Get(f.apiServer.URL + "/api/models/" + modelID)
require.NoError(t, err)
defer otherGet.Body.Close()
assert.Equal(t, http.StatusForbidden, otherGet.StatusCode, "非 owner 取他人 model 應 403")
}
// TestDBOn_PersistAcrossRestart 是塊 6 的核心承諾:**重啟後資料還在**。
//
// 重啟模擬:上傳 model 後,關掉 fixture 當前 pool、用同一個 tdb.Cfg 新建第二個 pool
// (指向同一個 Postgres 容器),證明資料落在 DB 而非 process 記憶體。
// 第二個 pool 直接查 + 經一個「新 router新 PG repo」打 API 都看得到。
//
// 對照in-memory 模式NewInMemoryRepository一旦 process 重啟資料即消失,
// 本 test 在 in-memory fixture 下會失敗——正是 DB 接入要解決的問題。
func TestDBOn_PersistAcrossRestart(t *testing.T) {
f := setupFixtureDBOn(t, noopLocal())
defer f.Close()
sub := uuid.NewString()
email := "dbon-restart@visiona.local"
f.ensureUser(t, sub, email)
client := f.AuthenticatedClient(t, sub, email)
modelID := uploadModelDBOn(t, f, client, "Persist-Across-Restart")
// === 模擬「process 重啟」:丟棄當前 pool新建一個指向同一容器的 pool ===
f.pool.Close() // 關掉 fixture 原 pool模擬舊 process 退出)
newPool, err := db.NewPool(context.Background(), f.tdb.Cfg, discardLoggerDBOn())
require.NoError(t, err, "重啟後新建 pool")
defer newPool.Close()
// 新 pool 直接查:資料還在。
var cnt int
require.NoError(t, newPool.Pool().QueryRow(context.Background(),
`SELECT count(*) FROM models WHERE id = $1 AND deleted_at IS NULL`, modelID).Scan(&cnt))
assert.Equal(t, 1, cnt, "重啟(新 pool後 model row 仍在 Postgres —— 持久化成立")
// 進一步owner_user_id 也仍正確FK + 欄位都落地)。
var owner string
require.NoError(t, newPool.Pool().QueryRow(context.Background(),
`SELECT owner_user_id::text FROM models WHERE id = $1`, modelID).Scan(&owner))
assert.Equal(t, sub, owner, "重啟後 model.owner_user_id 仍為原 user")
}
// TestDBOn_SeedNotDuplicatedAcrossRestart 驗證 seed 行為在「重啟(重跑 ensure」下不重複。
//
// seed.go 用 `INSERT ... ON CONFLICT (id) DO NOTHING` upsert demo user多次呼叫不應產生
// 重複 user 列。本 test 直接重複 upsert 同一 demo user 兩次(模擬重啟兩次都跑 seed
// 斷言 users 仍只有一筆——對應「重啟不重複 seed」的承諾塊 6 完整版要點)。
func TestDBOn_SeedNotDuplicatedAcrossRestart(t *testing.T) {
f := setupFixtureDBOn(t, noopLocal())
defer f.Close()
const demoUser = "00000000-0000-0000-0000-0000000000d3" // 對齊 seed.go demoSeedUserID
ctx := context.Background()
upsert := func() {
_, err := f.pool.Pool().Exec(ctx,
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING`,
demoUser, "demo@visiona.local", "Demo User (seeded)")
require.NoError(t, err)
}
upsert() // 第一次「啟動 seed」
upsert() // 第二次「重啟 seed」
var cnt int
require.NoError(t, f.pool.Pool().QueryRow(ctx,
`SELECT count(*) FROM users WHERE id = $1`, demoUser).Scan(&cnt))
assert.Equal(t, 1, cnt, "重複 seed重啟後 demo user 仍只有一筆 —— ON CONFLICT 生效")
}
// TestDBOn_UnpairCascade_RevokesTokens 是塊 5.2 cascade 的 e2e/store 層驗證Postgres tx 路徑)。
//
// 建一條完整資料鏈user → device → 已綁該 device 的 pairing tokenMarkUsed 綁 device+
// 已綁該 device 的 session token再經 DeviceUnpairer.Unpair= main.go 在 DB-on 注入的
// pgDeviceUnpairer單一交易內軟刪 device + 撤兩張 token最後**直接查 DB** 斷言:
// - device.deleted_at 非 NULL軟刪
// - 該 device 的 pairing token revoked_at 非 NULL
// - 該 device 的 session token revoked_at 非 NULL
// - Unpair 回報撤銷數正確
//
// 為什麼不走 HTTP /unpairunpair handler 需要 AuthMiddleware + device 屬於登入者;本 test 聚焦
// cascade 的「真撤 token」核心跨三張表的交易一致性直接調 unpairer 並查 DB 最直接、最少噪音。
// HTTP /unpair 的 happy/404 路徑由 in-memory 的 devices unpair test 覆蓋(行為一致)。
func TestDBOn_UnpairCascade_RevokesTokens(t *testing.T) {
f := setupFixtureDBOn(t, noopLocal())
defer f.Close()
ctx := context.Background()
tdb := f.tdb
// 1. user + device用 testsupport fixture 直接寫,滿足 FK
userID := tdb.InsertUser(t, uuid.NewString(), "unpair-dbon@visiona.local")
deviceID := tdb.InsertDevice(t, uuid.NewString(), userID)
// 2. pairing token綁 deviceCreate → MarkUsed(deviceID) 寫入 device_id。
pairingStore := auth.NewPostgresPairingStore(f.pool.Pool())
ptPlain, _, err := pairingStore.Create(ctx, userID, time.Hour)
require.NoError(t, err, "create pairing token")
require.NoError(t, pairingStore.MarkUsed(ctx, ptPlain, deviceID), "mark pairing token used + bind device")
// 3. session token綁 devicedevice_id NOT NULL FK這裡傳真 device → 寫入成功。
sessionStore := auth.NewPostgresSessionTokenStore(f.pool.Pool())
stPlain, _, err := sessionStore.Create(ctx, userID, deviceID, "", 90*24*time.Hour)
require.NoError(t, err, "create session token bound to device")
// 前置斷言:兩 token 撤銷前都「未撤銷」。
require.Equal(t, 0, countRevokedTokensDBOn(t, f, "pairing_tokens", deviceID), "撤銷前 pairing 應為 0 revoked")
require.Equal(t, 0, countRevokedTokensDBOn(t, f, "session_tokens", deviceID), "撤銷前 session 應為 0 revoked")
// 4. UnpairPostgres txdevice 軟刪 + cascade 撤兩張 token
unpairer := api.NewPostgresDeviceUnpairer(f.pool.Pool(),
device.NewPostgresRepository(f.pool.Pool()),
pairingStore, sessionStore, discardLoggerDBOn())
res, err := unpairer.Unpair(ctx, deviceID)
require.NoError(t, err, "unpair 應成功")
assert.Equal(t, 1, res.PairingRevoked, "應撤 1 個 pairing token")
assert.Equal(t, 1, res.SessionRevoked, "應撤 1 個 session token")
// 5. 直接查 DB 斷言 cascade 落地
// 5a. device 軟刪
var deletedAt *time.Time
require.NoError(t, f.pool.Pool().QueryRow(ctx,
`SELECT deleted_at FROM devices WHERE id = $1`, deviceID).Scan(&deletedAt))
assert.NotNil(t, deletedAt, "device 應被軟刪deleted_at 非 NULL")
// 5b. 兩張 token 都撤銷
assert.Equal(t, 1, countRevokedTokensDBOn(t, f, "pairing_tokens", deviceID), "pairing token 應 revoked")
assert.Equal(t, 1, countRevokedTokensDBOn(t, f, "session_tokens", deviceID), "session token 應 revoked")
// 5c. 撤銷後 session token Get 應失敗(不可再用 → tunnel 連不上)。
_, getErr := sessionStore.Get(ctx, stPlain)
assert.ErrorIs(t, getErr, auth.ErrTokenRevoked, "撤銷後 session token Get 應回 ErrTokenRevoked")
}
// countRevokedTokensDBOn 查某 device 名下已撤銷的 token 數pairing_tokens / session_tokens 通用)。
func countRevokedTokensDBOn(t *testing.T, f *dbOnFixture, table, deviceID string) int {
t.Helper()
var n int
// table 來自測試常數(非使用者輸入),直接內插安全。
require.NoError(t, f.pool.Pool().QueryRow(context.Background(),
`SELECT count(*) FROM `+table+` WHERE device_id = $1 AND revoked_at IS NOT NULL`,
deviceID).Scan(&n))
return n
}