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