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>
119 lines
5.0 KiB
Go
119 lines
5.0 KiB
Go
//go:build dbtest
|
||
|
||
// Postgres pairing exchange 自建 device 的真 DB 整合測試(DB-on FK 收尾,問題 #2)。
|
||
//
|
||
// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。
|
||
// 預設 `go test ./...`(無 Docker)不觸碰本檔,維持綠燈。
|
||
//
|
||
// 執行:
|
||
//
|
||
// go test -tags=dbtest ./internal/api/...
|
||
// # 無本機 Docker 時,Orchestrator 在 130 補跑:
|
||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||
// go test -tags=dbtest ./internal/api/...
|
||
//
|
||
// 涵蓋:
|
||
// - Provision 成功:自建一筆 device(owner 對齊)+ 建綁該 device 的 session token,session
|
||
// token 的 device_id 不再為空、且確實指向新建的 device(FK 滿足)。
|
||
// - parent_token_hash 寫入(稽核鏈)。
|
||
// - 原子性:device owner 不存在(FK violation)→ 整筆 rollback,device 不會殘留。
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/auth"
|
||
"visiona-backend/internal/db/testsupport"
|
||
"visiona-backend/internal/device"
|
||
)
|
||
|
||
// pgExchangeFixture 建一個已就緒的 Postgres 環境:一個合法 owner user(無 device、無 token)。
|
||
func pgExchangeFixture(t *testing.T) (
|
||
tdb *testsupport.TestDB,
|
||
exchanger PairingExchanger,
|
||
devRepo *device.PostgresRepository,
|
||
sessions *auth.PostgresSessionTokenStore,
|
||
owner string,
|
||
) {
|
||
t.Helper()
|
||
tdb = testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users")
|
||
owner = tdb.EnsureDemoUser(t)
|
||
|
||
devRepo = device.NewPostgresRepository(tdb.Pool)
|
||
sessions = auth.NewPostgresSessionTokenStore(tdb.Pool)
|
||
exchanger = NewPostgresPairingExchanger(tdb.Pool, devRepo, sessions, nil)
|
||
return
|
||
}
|
||
|
||
// TestPGExchange_ProvisionCreatesDeviceAndSession 驗證 exchange 自建 device + session token,
|
||
// 且 session token 的 device_id 綁定到新建的 device(之前 DB-on 會因 device_id 空字串失敗)。
|
||
func TestPGExchange_ProvisionCreatesDeviceAndSession(t *testing.T) {
|
||
ctx := context.Background()
|
||
tdb, exchanger, devRepo, sessions, owner := pgExchangeFixture(t)
|
||
|
||
parentHash := auth.HashToken("vAc_" + uuid.NewString()[:32])
|
||
res, err := exchanger.Provision(ctx, owner, parentHash, auth.SessionTokenTTL)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, res.DeviceID, "應自建一筆 device")
|
||
require.NotEmpty(t, res.SessionPlaintext, "應建一個 session token")
|
||
require.NotNil(t, res.SessionInfo)
|
||
|
||
// 1) device 真的進 DB、owner 對齊
|
||
dev, err := devRepo.Get(ctx, res.DeviceID)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, owner, dev.OwnerUserID)
|
||
assert.Equal(t, defaultPairedDeviceName, dev.Name)
|
||
assert.Equal(t, defaultPairedDeviceType, dev.DeviceType)
|
||
assert.NotNil(t, dev.PairedAt, "自建 device 應設 paired_at")
|
||
|
||
// 2) session token 真的進 DB、device_id 綁到新建 device(非空、FK 滿足)
|
||
tok, err := sessions.Get(ctx, res.SessionPlaintext)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, res.DeviceID, tok.DeviceID, "session token 的 device_id 應綁到自建 device")
|
||
assert.Equal(t, owner, tok.UserID)
|
||
assert.Equal(t, parentHash, tok.ParentTokenHash, "parent_token_hash 應為來源 pairing token hash(稽核鏈)")
|
||
|
||
// 3) 直接查 DB 確認 session_tokens.device_id 非 NULL
|
||
var deviceIDIsNull bool
|
||
require.NoError(t, tdb.Pool.QueryRow(ctx,
|
||
`SELECT device_id IS NULL FROM session_tokens WHERE token_hash = $1`,
|
||
auth.HashToken(res.SessionPlaintext)).Scan(&deviceIDIsNull))
|
||
assert.False(t, deviceIDIsNull, "session_tokens.device_id 不應為 NULL")
|
||
}
|
||
|
||
// TestPGExchange_Provision_RollbackOnBadOwner 驗證原子性:owner 不存在於 users(FK violation)
|
||
// → device INSERT 撞 owner_user_id FK → 整筆 rollback,device 不殘留。
|
||
func TestPGExchange_Provision_RollbackOnBadOwner(t *testing.T) {
|
||
ctx := context.Background()
|
||
tdb, exchanger, _, _, _ := pgExchangeFixture(t)
|
||
|
||
badOwner := uuid.NewString() // 不在 users 表
|
||
_, err := exchanger.Provision(ctx, badOwner, "", auth.SessionTokenTTL)
|
||
require.Error(t, err, "owner 不存在 → device.owner_user_id FK violation")
|
||
|
||
// device 不應殘留(整筆交易 rollback)
|
||
assert.Equal(t, 0, tdb.CountRows(t, "devices"), "FK 失敗應 rollback,無 device 殘留")
|
||
assert.Equal(t, 0, tdb.CountRows(t, "session_tokens"), "session token 也不應建立")
|
||
}
|
||
|
||
// TestPGExchange_Provision_MultipleCreatesDistinctDevices 驗證多次 exchange 各自建新 device
|
||
// (不同 UUID)——對齊「不同次配對視為不同 agent 連線」的冪等語意。
|
||
func TestPGExchange_Provision_MultipleCreatesDistinctDevices(t *testing.T) {
|
||
ctx := context.Background()
|
||
_, exchanger, _, _, owner := pgExchangeFixture(t)
|
||
|
||
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL)
|
||
require.NoError(t, err)
|
||
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL)
|
||
require.NoError(t, err)
|
||
|
||
assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "兩次 exchange 應各自建不同 device")
|
||
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "兩次 session token 應不同")
|
||
}
|