把 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>
140 lines
4.6 KiB
Go
140 lines
4.6 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// TestInMemorySessionTokenStore_CreateAndGet 驗證一次完整的建立 → 查詢循環。
|
||
func TestInMemorySessionTokenStore_CreateAndGet(t *testing.T) {
|
||
s := NewInMemorySessionTokenStore()
|
||
ctx := context.Background()
|
||
|
||
plain, info, err := s.Create(ctx, "user-1", "dev-1", "parent-hash", SessionTokenTTL)
|
||
require.NoError(t, err)
|
||
assert.True(t, IsValidSessionToken(plain), "產出 token 應通過格式驗證:%s", plain)
|
||
require.NotNil(t, info)
|
||
assert.Equal(t, "user-1", info.UserID)
|
||
assert.Equal(t, "dev-1", info.DeviceID)
|
||
assert.Equal(t, "parent-hash", info.ParentTokenHash)
|
||
require.NotNil(t, info.ExpiresAt)
|
||
assert.WithinDuration(t, time.Now().UTC().Add(SessionTokenTTL), *info.ExpiresAt, 2*time.Second)
|
||
|
||
got, err := s.Get(ctx, plain)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "user-1", got.UserID)
|
||
assert.Equal(t, info.TokenHash, got.TokenHash)
|
||
}
|
||
|
||
// TestInMemorySessionTokenStore_Get_NotFound 驗證查詢不存在 token 回 ErrInvalidToken。
|
||
func TestInMemorySessionTokenStore_Get_NotFound(t *testing.T) {
|
||
s := NewInMemorySessionTokenStore()
|
||
_, err := s.Get(context.Background(), "vAs_deadbeef")
|
||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||
}
|
||
|
||
// TestInMemorySessionTokenStore_Get_Expired 驗證過期 token 回 ErrTokenExpired。
|
||
func TestInMemorySessionTokenStore_Get_Expired(t *testing.T) {
|
||
s := NewInMemorySessionTokenStore()
|
||
ctx := context.Background()
|
||
|
||
// TTL 設 1ns 確保立即過期
|
||
plain, _, err := s.Create(ctx, "u", "d", "", 1*time.Nanosecond)
|
||
require.NoError(t, err)
|
||
|
||
time.Sleep(5 * time.Millisecond)
|
||
_, err = s.Get(ctx, plain)
|
||
assert.True(t, errors.Is(err, ErrTokenExpired), "應回 ErrTokenExpired,實際:%v", err)
|
||
}
|
||
|
||
// TestInMemorySessionTokenStore_Revoke 驗證撤銷後 Get 回 ErrTokenRevoked。
|
||
func TestInMemorySessionTokenStore_Revoke(t *testing.T) {
|
||
s := NewInMemorySessionTokenStore()
|
||
ctx := context.Background()
|
||
|
||
plain, _, err := s.Create(ctx, "u", "d", "", SessionTokenTTL)
|
||
require.NoError(t, err)
|
||
require.NoError(t, s.Revoke(ctx, plain))
|
||
|
||
_, err = s.Get(ctx, plain)
|
||
assert.ErrorIs(t, err, ErrTokenRevoked)
|
||
|
||
// 冪等:再撤一次不該報錯
|
||
assert.NoError(t, s.Revoke(ctx, plain))
|
||
}
|
||
|
||
// TestInMemorySessionTokenStore_Revoke_NotFound 驗證撤銷不存在 token 回 ErrInvalidToken。
|
||
func TestInMemorySessionTokenStore_Revoke_NotFound(t *testing.T) {
|
||
s := NewInMemorySessionTokenStore()
|
||
err := s.Revoke(context.Background(), "vAs_nope")
|
||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||
}
|
||
|
||
// TestInMemorySessionTokenStore_CleanupExpired 驗證過期 token 會被清掉。
|
||
func TestInMemorySessionTokenStore_CleanupExpired(t *testing.T) {
|
||
s := NewInMemorySessionTokenStore()
|
||
ctx := context.Background()
|
||
|
||
// 一個會過期、一個長效
|
||
expiredTok, _, err := s.Create(ctx, "u1", "d1", "", 1*time.Nanosecond)
|
||
require.NoError(t, err)
|
||
freshTok, _, err := s.Create(ctx, "u2", "d2", "", SessionTokenTTL)
|
||
require.NoError(t, err)
|
||
|
||
time.Sleep(5 * time.Millisecond)
|
||
removed, err := s.CleanupExpired(ctx, time.Now().UTC())
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 1, removed)
|
||
|
||
// 過期的應查不到
|
||
_, err = s.Get(ctx, expiredTok)
|
||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||
|
||
// 新鮮的仍在
|
||
_, err = s.Get(ctx, freshTok)
|
||
assert.NoError(t, err)
|
||
}
|
||
|
||
// TestInMemorySessionTokenStore_NeverExpires 驗證 ttl <= 0 時 ExpiresAt 為 nil。
|
||
func TestInMemorySessionTokenStore_NeverExpires(t *testing.T) {
|
||
s := NewInMemorySessionTokenStore()
|
||
_, info, err := s.Create(context.Background(), "u", "d", "", 0)
|
||
require.NoError(t, err)
|
||
assert.Nil(t, info.ExpiresAt, "ttl=0 時 ExpiresAt 應為 nil")
|
||
}
|
||
|
||
// TestInMemorySessionTokenStore_RevokeByDevice 驗證 cascade 撤銷(塊 5.2):
|
||
// 只撤指定 device 名下未撤銷的 session token,回傳撤銷數。
|
||
func TestInMemorySessionTokenStore_RevokeByDevice(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := NewInMemorySessionTokenStore()
|
||
|
||
s1, _, err := s.Create(ctx, "user-1", "dev-1", "", SessionTokenTTL)
|
||
require.NoError(t, err)
|
||
s2, _, err := s.Create(ctx, "user-1", "dev-1", "", SessionTokenTTL)
|
||
require.NoError(t, err)
|
||
require.NoError(t, s.Revoke(ctx, s2)) // 已撤,不重複計
|
||
|
||
// dev-2 不應被撤
|
||
o1, _, err := s.Create(ctx, "user-1", "dev-2", "", SessionTokenTTL)
|
||
require.NoError(t, err)
|
||
|
||
revoked, err := s.RevokeByDevice(ctx, "dev-1")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 1, revoked)
|
||
|
||
_, err = s.Get(ctx, s1)
|
||
assert.ErrorIs(t, err, ErrTokenRevoked)
|
||
_, err = s.Get(ctx, o1)
|
||
assert.NoError(t, err)
|
||
|
||
n, err := s.RevokeByDevice(ctx, "")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 0, n)
|
||
}
|