把 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>
171 lines
4.8 KiB
Go
171 lines
4.8 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
func TestInMemoryPairingStore_CreateAndValidate(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := NewInMemoryPairingStore()
|
||
|
||
plain, info, err := s.Create(ctx, "user-1", 15*time.Minute)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, plain)
|
||
require.NotNil(t, info)
|
||
|
||
assert.True(t, IsValidPairingToken(plain))
|
||
assert.Equal(t, "user-1", info.UserID)
|
||
assert.Equal(t, KindPairing, info.Kind)
|
||
assert.NotNil(t, info.ExpiresAt)
|
||
assert.Nil(t, info.UsedAt)
|
||
|
||
got, err := s.Validate(ctx, plain)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "user-1", got.UserID)
|
||
}
|
||
|
||
func TestInMemoryPairingStore_Validate_UnknownToken(t *testing.T) {
|
||
s := NewInMemoryPairingStore()
|
||
_, err := s.Validate(context.Background(), "vAc_unknown0000000000000000000000")
|
||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||
}
|
||
|
||
func TestInMemoryPairingStore_MarkUsed_IsOneTime(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := NewInMemoryPairingStore()
|
||
|
||
plain, _, err := s.Create(ctx, "user-1", 15*time.Minute)
|
||
require.NoError(t, err)
|
||
|
||
require.NoError(t, s.MarkUsed(ctx, plain, "device-1"))
|
||
|
||
// Validate 必須失敗(一次性 token 已消費)。
|
||
_, err = s.Validate(ctx, plain)
|
||
assert.ErrorIs(t, err, ErrTokenUsed)
|
||
|
||
// 再次 MarkUsed 應為 no-op(冪等)。
|
||
assert.NoError(t, s.MarkUsed(ctx, plain, "another-device"))
|
||
}
|
||
|
||
func TestInMemoryPairingStore_Revoke(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := NewInMemoryPairingStore()
|
||
|
||
plain, _, err := s.Create(ctx, "user-1", 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)
|
||
}
|
||
|
||
func TestInMemoryPairingStore_CleanupExpired(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := NewInMemoryPairingStore()
|
||
|
||
// 產生一個已過期的 token(ttl = 1ms)
|
||
expired, _, err := s.Create(ctx, "user-1", 1*time.Millisecond)
|
||
require.NoError(t, err)
|
||
|
||
// 另一個尚未過期
|
||
fresh, _, err := s.Create(ctx, "user-1", 1*time.Hour)
|
||
require.NoError(t, err)
|
||
|
||
// 等 10ms 確保第一個過期
|
||
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, "過期的 token 應被清掉")
|
||
|
||
_, err = s.Validate(ctx, fresh)
|
||
assert.NoError(t, err, "未過期的 token 不應被清")
|
||
}
|
||
|
||
func TestInMemoryPairingStore_List_ByUser(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := NewInMemoryPairingStore()
|
||
|
||
_, _, err := s.Create(ctx, "user-A", time.Hour)
|
||
require.NoError(t, err)
|
||
_, _, err = s.Create(ctx, "user-A", time.Hour)
|
||
require.NoError(t, err)
|
||
_, _, err = s.Create(ctx, "user-B", time.Hour)
|
||
require.NoError(t, err)
|
||
|
||
listA, err := s.List(ctx, "user-A")
|
||
require.NoError(t, err)
|
||
assert.Len(t, listA, 2)
|
||
|
||
listB, err := s.List(ctx, "user-B")
|
||
require.NoError(t, err)
|
||
assert.Len(t, listB, 1)
|
||
|
||
listNone, err := s.List(ctx, "user-X")
|
||
require.NoError(t, err)
|
||
assert.Empty(t, listNone)
|
||
}
|
||
|
||
func TestInMemoryPairingStore_Validate_Expired(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := NewInMemoryPairingStore()
|
||
|
||
plain, _, err := s.Create(ctx, "user-1", 1*time.Millisecond)
|
||
require.NoError(t, err)
|
||
|
||
time.Sleep(5 * time.Millisecond)
|
||
|
||
_, err = s.Validate(ctx, plain)
|
||
assert.ErrorIs(t, err, ErrTokenExpired)
|
||
}
|
||
|
||
// TestInMemoryPairingStore_RevokeByDevice 驗證 cascade 撤銷(塊 5.2):
|
||
// 只撤指定 device 名下未撤銷的 token,回傳撤銷數。
|
||
func TestInMemoryPairingStore_RevokeByDevice(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := NewInMemoryPairingStore()
|
||
|
||
// 綁 dev-1 的兩個 token(其中一個先撤)
|
||
p1, _, err := s.Create(ctx, "user-1", 15*time.Minute)
|
||
require.NoError(t, err)
|
||
require.NoError(t, s.MarkUsed(ctx, p1, "dev-1"))
|
||
|
||
p2, _, err := s.Create(ctx, "user-1", 15*time.Minute)
|
||
require.NoError(t, err)
|
||
require.NoError(t, s.MarkUsed(ctx, p2, "dev-1"))
|
||
require.NoError(t, s.Revoke(ctx, p2)) // 已撤,不應重複計數
|
||
|
||
// 綁 dev-2(不應被撤)
|
||
p3, _, err := s.Create(ctx, "user-1", 15*time.Minute)
|
||
require.NoError(t, err)
|
||
require.NoError(t, s.MarkUsed(ctx, p3, "dev-2"))
|
||
|
||
revoked, err := s.RevokeByDevice(ctx, "dev-1")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 1, revoked, "只有 p1(未撤)被撤;p2 已撤不計")
|
||
|
||
// p1 已撤
|
||
_, err = s.Validate(ctx, p1)
|
||
assert.ErrorIs(t, err, ErrTokenRevoked)
|
||
// p3(dev-2)不受影響
|
||
_, err = s.Validate(ctx, p3)
|
||
assert.ErrorIs(t, err, ErrTokenUsed) // 已 used 但未 revoke
|
||
|
||
// 空 deviceID 不撤任何 token
|
||
n, err := s.RevokeByDevice(ctx, "")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 0, n)
|
||
}
|