把 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>
454 lines
15 KiB
Go
454 lines
15 KiB
Go
//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.go(CreateAndValidate、unknown token、
|
||
// MarkUsed 一次性+冪等、Revoke、CleanupExpired、List by user、Validate expired)。
|
||
// - 3.8 integration/真 DB:hash 當 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_id(FK → 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 當 PK:DB 內實際存的是 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)
|
||
|
||
// 明文不該等於任何 PK(plaintext != 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, "明文不應出現為 PK(DB 永不存明文)")
|
||
}
|
||
|
||
// 狀態優先序: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 層 race:N 併發 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 cascade:RevokeByDeviceTx(pairing)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// 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 兩個 token:p1 未撤、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 的 token(device_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)
|
||
// p3(dev2)仍可用(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)
|
||
}
|