//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 }