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>
310 lines
14 KiB
Go
310 lines
14 KiB
Go
//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`:需要 Docker(PG 容器)。本機無 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 路徑。
|
||
// (此差異已回報 Orchestrator:HTTP 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 應含剛上傳的 model(DB-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 token(MarkUsed 綁 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 /unpair:unpair 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(綁 device):Create → 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(綁 device):device_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. Unpair(Postgres tx:device 軟刪 + 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
|
||
}
|