visionA/visionA-backend/internal/auth/postgres_pairing_store_db_test.go
jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整
(PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動,
只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。

- 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate
  runner(embed)+ cmd/migrate + testcontainers 測試基礎建設
- 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、
  三維 filter(owner/chip/source)、soft-delete partial index
- 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位
- 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK
- 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine
  (tunnel session 維持 in-memory,yamux handle 不可序列化)
- 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子)
  + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error)

降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗
不自動 fallback in-memory(避免多機 session 不同步)。

DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique
index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈,
in-memory fallback 未受影響。

docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md
(409/503 錯誤碼、/healthz 新行為、device unpair cascade)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:28:04 +08:00

454 lines
15 KiB
Go
Raw 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
// PostgresPairingStore 的真 DB 整合測試DB 接入塊 3子任務 3.6 / 3.8 / 3.9 pairing 部分)。
//
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers
// 預設 `go test ./...`(無 Docker不會觸碰本檔維持綠燈。
//
// 執行:
//
// go test -tags=dbtest ./internal/auth/...
// # 無本機 Docker 時Orchestrator 在 130 補跑:
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
// go test -tags=dbtest ./internal/auth/...
//
// 涵蓋:
// - 3.6 unit/邏輯:對齊既有 inmemory_pairing_store_test.goCreateAndValidate、unknown token、
// MarkUsed 一次性+冪等、Revoke、CleanupExpired、List by user、Validate expired
// - 3.8 integration/真 DBhash 當 PK 查詢正確性、TTL 過期、一次性 used 的 DB 層 race
// (兩併發 MarkUsed 只一筆實際標記、撤銷稽核欄位、兩表隔離pairing 與 session 不互串)。
// - 3.9 邊界:併發 Validate 同 token、CleanupExpired 大量資料、context cancel。
package auth
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/db/testsupport"
)
// newPGPairingStore 啟動一次性測試 DB、truncate token/device/user 表、確保 demo user 存在,
// 回傳 store + tdb + ownerID。
func newPGPairingStore(t *testing.T) (*PostgresPairingStore, *testsupport.TestDB, string) {
t.Helper()
tdb := testsupport.SetupTestDB(t)
tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users")
owner := tdb.EnsureDemoUser(t)
return NewPostgresPairingStore(tdb.Pool), tdb, owner
}
// ---------------------------------------------------------------------------
// 3.6 unit/邏輯(對齊 inmemory_pairing_store_test.go 的 case
// ---------------------------------------------------------------------------
func TestPGPairing_CreateAndValidate(t *testing.T) {
ctx := context.Background()
s, _, owner := newPGPairingStore(t)
plain, info, err := s.Create(ctx, owner, 15*time.Minute)
require.NoError(t, err)
require.NotEmpty(t, plain)
require.NotNil(t, info)
assert.True(t, IsValidPairingToken(plain))
assert.Equal(t, owner, info.UserID)
assert.Equal(t, KindPairing, info.Kind)
assert.NotNil(t, info.ExpiresAt)
assert.Nil(t, info.UsedAt)
assert.Equal(t, HashToken(plain), info.TokenHash, "PK 應為 plaintext 的 hash")
got, err := s.Validate(ctx, plain)
require.NoError(t, err)
assert.Equal(t, owner, got.UserID)
assert.Equal(t, HashToken(plain), got.TokenHash)
assert.Empty(t, got.Plaintext, "DB 不存明文 → 回傳的 Plaintext 應為空")
}
func TestPGPairing_Validate_UnknownToken(t *testing.T) {
s, _, _ := newPGPairingStore(t)
_, err := s.Validate(context.Background(), "vAc_00000000000000000000000000000000")
assert.ErrorIs(t, err, ErrInvalidToken)
}
func TestPGPairing_MarkUsed_IsOneTime(t *testing.T) {
ctx := context.Background()
s, _, owner := newPGPairingStore(t)
plain, _, err := s.Create(ctx, owner, 15*time.Minute)
require.NoError(t, err)
require.NoError(t, s.MarkUsed(ctx, plain, ""))
// Validate 必須失敗(一次性 token 已消費)。
_, err = s.Validate(ctx, plain)
assert.ErrorIs(t, err, ErrTokenUsed)
// 再次 MarkUsed 應為冪等 no-op回 nil
assert.NoError(t, s.MarkUsed(ctx, plain, ""))
}
func TestPGPairing_MarkUsed_UnknownToken(t *testing.T) {
s, _, _ := newPGPairingStore(t)
err := s.MarkUsed(context.Background(), "vAc_ffffffffffffffffffffffffffffffff", "")
assert.ErrorIs(t, err, ErrInvalidToken, "對不存在 token MarkUsed 應回 ErrInvalidToken")
}
// MarkUsed 綁定 device_idFK → devices驗證 device_id 正確寫入。
func TestPGPairing_MarkUsed_BindsDevice(t *testing.T) {
ctx := context.Background()
s, tdb, owner := newPGPairingStore(t)
deviceID := tdb.InsertDevice(t, "", owner)
plain, _, err := s.Create(ctx, owner, 15*time.Minute)
require.NoError(t, err)
require.NoError(t, s.MarkUsed(ctx, plain, deviceID))
list, err := s.List(ctx, owner)
require.NoError(t, err)
require.Len(t, list, 1)
assert.Equal(t, deviceID, list[0].DeviceID, "MarkUsed 應綁定 device_id")
assert.NotNil(t, list[0].UsedAt)
}
func TestPGPairing_Revoke(t *testing.T) {
ctx := context.Background()
s, _, owner := newPGPairingStore(t)
plain, _, err := s.Create(ctx, owner, 15*time.Minute)
require.NoError(t, err)
require.NoError(t, s.Revoke(ctx, plain))
_, err = s.Validate(ctx, plain)
assert.ErrorIs(t, err, ErrTokenRevoked)
// 撤銷不存在的 token → ErrInvalidToken
assert.ErrorIs(t, s.Revoke(ctx, "vAc_abcdef00000000000000000000000000"), ErrInvalidToken)
// 冪等:再撤一次不報錯
assert.NoError(t, s.Revoke(ctx, plain))
}
func TestPGPairing_CleanupExpired(t *testing.T) {
ctx := context.Background()
s, _, owner := newPGPairingStore(t)
expired, _, err := s.Create(ctx, owner, 1*time.Millisecond)
require.NoError(t, err)
fresh, _, err := s.Create(ctx, owner, 1*time.Hour)
require.NoError(t, err)
// 永不過期ttl=0 → expires_at NULL不應被清。
never, _, err := s.Create(ctx, owner, 0)
require.NoError(t, err)
time.Sleep(10 * time.Millisecond)
removed, err := s.CleanupExpired(ctx, time.Now().UTC())
require.NoError(t, err)
assert.Equal(t, 1, removed)
_, err = s.Validate(ctx, expired)
assert.ErrorIs(t, err, ErrInvalidToken, "過期且已清掉 → 查不到")
_, err = s.Validate(ctx, fresh)
assert.NoError(t, err, "未過期不應被清")
_, err = s.Validate(ctx, never)
assert.NoError(t, err, "永不過期NULL expires_at不應被清")
}
func TestPGPairing_List_ByUser(t *testing.T) {
ctx := context.Background()
s, tdb, ownerA := newPGPairingStore(t)
ownerB := tdb.InsertUser(t, "", "")
_, _, err := s.Create(ctx, ownerA, time.Hour)
require.NoError(t, err)
_, _, err = s.Create(ctx, ownerA, time.Hour)
require.NoError(t, err)
_, _, err = s.Create(ctx, ownerB, time.Hour)
require.NoError(t, err)
listA, err := s.List(ctx, ownerA)
require.NoError(t, err)
assert.Len(t, listA, 2)
listB, err := s.List(ctx, ownerB)
require.NoError(t, err)
assert.Len(t, listB, 1)
listNone, err := s.List(ctx, tdb.InsertUser(t, "", ""))
require.NoError(t, err)
assert.Empty(t, listNone)
assert.NotNil(t, listNone, "List 應回 non-nil 空 slice")
}
func TestPGPairing_Validate_Expired(t *testing.T) {
ctx := context.Background()
s, _, owner := newPGPairingStore(t)
plain, _, err := s.Create(ctx, owner, 1*time.Millisecond)
require.NoError(t, err)
time.Sleep(5 * time.Millisecond)
_, err = s.Validate(ctx, plain)
assert.ErrorIs(t, err, ErrTokenExpired)
}
// ---------------------------------------------------------------------------
// 3.8 integration/真 DB
// ---------------------------------------------------------------------------
// hash 當 PKDB 內實際存的是 token_hash明文不出現在表中。
func TestPGPairing_HashIsPK_NoPlaintextStored(t *testing.T) {
ctx := context.Background()
s, tdb, owner := newPGPairingStore(t)
plain, info, err := s.Create(ctx, owner, time.Hour)
require.NoError(t, err)
// DB 內以 token_hash 為 PK 存在、明文不存在。
var stored string
err = tdb.Pool.QueryRow(ctx,
`SELECT token_hash FROM pairing_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&stored)
require.NoError(t, err, "應能用 hash 查到")
assert.Equal(t, HashToken(plain), stored)
// 明文不該等於任何 PKplaintext != hash
var cnt int
err = tdb.Pool.QueryRow(ctx,
`SELECT count(*) FROM pairing_tokens WHERE token_hash = $1`, plain).Scan(&cnt)
require.NoError(t, err)
assert.Equal(t, 0, cnt, "明文不應出現為 PKDB 永不存明文)")
}
// 狀態優先序revoked 優先於 used 與 expired對齊 in-memory
func TestPGPairing_Validate_RevokedBeforeUsed(t *testing.T) {
ctx := context.Background()
s, _, owner := newPGPairingStore(t)
plain, _, err := s.Create(ctx, owner, time.Hour)
require.NoError(t, err)
require.NoError(t, s.MarkUsed(ctx, plain, ""))
require.NoError(t, s.Revoke(ctx, plain))
_, err = s.Validate(ctx, plain)
assert.ErrorIs(t, err, ErrTokenRevoked, "revoked 應優先於 used")
}
// 一次性 used 的 DB 層 raceN 併發 MarkUsed 同 token只一筆實際標記DB 行鎖保證)。
// 所有呼叫都回 nil冪等最終 used_at 恰寫一次。
func TestPGPairing_ConcurrentMarkUsed_OnlyOneWins(t *testing.T) {
ctx := context.Background()
s, tdb, owner := newPGPairingStore(t)
deviceID := tdb.InsertDevice(t, "", owner)
plain, info, err := s.Create(ctx, owner, time.Hour)
require.NoError(t, err)
const n = 30
var wg sync.WaitGroup
errs := make([]error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
errs[i] = s.MarkUsed(ctx, plain, deviceID)
}(i)
}
wg.Wait()
for i, e := range errs {
assert.NoError(t, e, "併發 MarkUsed #%d 應回 nil冪等", i)
}
// DB 層used_at 恰寫一次單一列、used_at 非 NULL
var usedCount int
err = tdb.Pool.QueryRow(ctx,
`SELECT count(*) FROM pairing_tokens WHERE token_hash = $1 AND used_at IS NOT NULL`,
info.TokenHash).Scan(&usedCount)
require.NoError(t, err)
assert.Equal(t, 1, usedCount, "一次性語意:恰一列被標記 used")
// Validate 之後必失敗。
_, err = s.Validate(ctx, plain)
assert.ErrorIs(t, err, ErrTokenUsed)
}
// 撤銷稽核欄位Revoke 後 revoked_at 非 NULL 且固定(冪等不覆寫時間)。
func TestPGPairing_Revoke_AuditField(t *testing.T) {
ctx := context.Background()
s, tdb, owner := newPGPairingStore(t)
plain, info, err := s.Create(ctx, owner, time.Hour)
require.NoError(t, err)
require.NoError(t, s.Revoke(ctx, plain))
var revokedAt1 time.Time
err = tdb.Pool.QueryRow(ctx,
`SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&revokedAt1)
require.NoError(t, err)
assert.False(t, revokedAt1.IsZero())
// 冪等再撤一次revoked_at 不應被覆寫WHERE revoked_at IS NULL 不命中)。
require.NoError(t, s.Revoke(ctx, plain))
var revokedAt2 time.Time
err = tdb.Pool.QueryRow(ctx,
`SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&revokedAt2)
require.NoError(t, err)
assert.True(t, revokedAt1.Equal(revokedAt2), "冪等 Revoke 不應覆寫 revoked_at")
}
// 兩表隔離pairing token 與 session token 各自獨立、互不串。
func TestPGPairing_TwoTableIsolation(t *testing.T) {
ctx := context.Background()
ps, tdb, owner := newPGPairingStore(t)
ss := NewPostgresSessionTokenStore(tdb.Pool)
deviceID := tdb.InsertDevice(t, "", owner)
// 同一個 plaintext 不可能跨兩表token 各自生成),這裡驗證:
// pairing 表的查詢看不到 session 表的列,反之亦然。
pPlain, _, err := ps.Create(ctx, owner, time.Hour)
require.NoError(t, err)
sPlain, _, err := ss.Create(ctx, owner, deviceID, "", time.Hour)
require.NoError(t, err)
// pairing.Validate 對 session token 查不到(不同表 + 不同 hash
_, err = ps.Validate(ctx, sPlain)
assert.ErrorIs(t, err, ErrInvalidToken)
// session.Get 對 pairing token 查不到。
_, err = ss.Get(ctx, pPlain)
assert.ErrorIs(t, err, ErrInvalidToken)
assert.Equal(t, 1, tdb.CountRows(t, "pairing_tokens"))
assert.Equal(t, 1, tdb.CountRows(t, "session_tokens"))
}
// ---------------------------------------------------------------------------
// 3.9 邊界
// ---------------------------------------------------------------------------
// 併發 Validate 同 token純讀、不應 panic / race全部成功。
func TestPGPairing_ConcurrentValidate(t *testing.T) {
ctx := context.Background()
s, _, owner := newPGPairingStore(t)
plain, _, err := s.Create(ctx, owner, time.Hour)
require.NoError(t, err)
const n = 30
var wg sync.WaitGroup
errs := make([]error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, errs[i] = s.Validate(ctx, plain)
}(i)
}
wg.Wait()
for i, e := range errs {
assert.NoError(t, e, "併發 Validate #%d", i)
}
}
// CleanupExpired 大量資料:批次刪除正確(過期全清、未過期保留)。
func TestPGPairing_CleanupExpired_Bulk(t *testing.T) {
ctx := context.Background()
s, _, owner := newPGPairingStore(t)
const expiredN = 50
for i := 0; i < expiredN; i++ {
_, _, err := s.Create(ctx, owner, 1*time.Millisecond)
require.NoError(t, err)
}
const freshN = 10
for i := 0; i < freshN; i++ {
_, _, err := s.Create(ctx, owner, time.Hour)
require.NoError(t, err)
}
time.Sleep(20 * time.Millisecond)
removed, err := s.CleanupExpired(ctx, time.Now().UTC())
require.NoError(t, err)
assert.Equal(t, expiredN, removed, "應清掉所有過期列")
list, err := s.List(ctx, owner)
require.NoError(t, err)
assert.Len(t, list, freshN, "未過期列應保留")
}
// context cancel已取消 ctx 的操作應回 error不 hang、不 panic
func TestPGPairing_ContextCancel(t *testing.T) {
s, _, owner := newPGPairingStore(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err := s.Create(ctx, owner, time.Hour)
assert.Error(t, err, "已取消 ctx 的 Create 應回 error")
_, err = s.Validate(ctx, "vAc_00000000000000000000000000000000")
assert.Error(t, err, "已取消 ctx 的 Validate 應回 error")
err = s.MarkUsed(ctx, "vAc_00000000000000000000000000000000", "")
assert.Error(t, err, "已取消 ctx 的 MarkUsed 應回 error")
}
// ---------------------------------------------------------------------------
// 塊 5.2 cascadeRevokeByDeviceTxpairing
// ---------------------------------------------------------------------------
// TestPGPairing_RevokeByDeviceTx 驗證撤銷某 device 名下未撤銷 pairing token
// 不波及他 device、已撤銷不重複計、device_id NULL未綁定不被撤。
func TestPGPairing_RevokeByDeviceTx(t *testing.T) {
ctx := context.Background()
s, tdb, owner := newPGPairingStore(t)
dev1 := tdb.InsertDevice(t, "", owner)
dev2 := tdb.InsertDevice(t, "", owner)
// dev1 兩個 tokenp1 未撤、p2 已撤
p1, _, err := s.Create(ctx, owner, 15*time.Minute)
require.NoError(t, err)
require.NoError(t, s.MarkUsed(ctx, p1, dev1))
p2, _, err := s.Create(ctx, owner, 15*time.Minute)
require.NoError(t, err)
require.NoError(t, s.MarkUsed(ctx, p2, dev1))
require.NoError(t, s.Revoke(ctx, p2))
// dev2 一個 token不應被撤
p3, _, err := s.Create(ctx, owner, 15*time.Minute)
require.NoError(t, err)
require.NoError(t, s.MarkUsed(ctx, p3, dev2))
// 一個未綁 device 的 tokendevice_id IS NULL不應被撤
p4, _, err := s.Create(ctx, owner, 15*time.Minute)
require.NoError(t, err)
revoked, err := s.RevokeByDeviceTx(ctx, tdb.Pool, dev1)
require.NoError(t, err)
assert.Equal(t, 1, revoked, "只有 p1 被撤p2 已撤不計")
_, err = s.Validate(ctx, p1)
assert.ErrorIs(t, err, ErrTokenRevoked)
// p3dev2仍可用used 但未撤)
_, err = s.Validate(ctx, p3)
assert.ErrorIs(t, err, ErrTokenUsed)
// p4未綁 device仍有效
_, err = s.Validate(ctx, p4)
require.NoError(t, err)
// 空 deviceID 不撤
n, err := s.RevokeByDeviceTx(ctx, tdb.Pool, "")
require.NoError(t, err)
assert.Equal(t, 0, n)
}