package auth_test import ( "chat-agent-system/internal/db" "context" "errors" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/auth" "chat-agent-system/internal/config" ) func openTestPool(t *testing.T) *pgxpool.Pool { t.Helper() dsn := db.SkipIfNoTestDatabaseURL(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) if err != nil { t.Skipf("postgres unavailable: %v", err) return nil } if pingErr := pool.Ping(ctx); pingErr != nil { pool.Close() t.Skipf("postgres ping failed: %v", pingErr) return nil } if !auth.IAMTablesReady(ctx, pool) { pool.Close() t.Skip("IAM tables not migrated on test database") } authStore, storeErr := auth.NewPgStore(pool, &config.Config{Env: "dev"}) if storeErr == nil { _ = auth.ReactivateDevSeedUsers(ctx, authStore, &config.Config{Env: "dev"}) } return pool } func TestPgStore_SessionSurvivesNewStoreInstance(t *testing.T) { pool := openTestPool(t) if pool == nil { return } defer pool.Close() cfg := &config.Config{Env: "dev"} ctx := context.Background() store1, err := auth.NewPgStore(pool, cfg) if err != nil { t.Fatalf("NewPgStore: %v", err) } user, err := store1.GetUserByUsername(ctx, "admin") if err != nil { t.Skipf("admin user not seeded in PG: %v", err) } tokenHash := auth.HashToken("phase1-integration-token-" + time.Now().Format(time.RFC3339Nano)) if err := store1.CreateSession(ctx, user.ID, tokenHash, auth.SessionMeta{UserAgent: "go-test"}); err != nil { t.Fatalf("CreateSession: %v", err) } t.Cleanup(func() { _ = store1.RevokeByTokenHash(context.Background(), tokenHash) }) store2, err := auth.NewPgStore(pool, cfg) if err != nil { t.Fatalf("NewPgStore second: %v", err) } userID, err := store2.ResolveUserID(ctx, tokenHash) if err != nil || userID != user.ID { t.Fatalf("session should persist across store instances, got (%d, %v)", userID, err) } _ = store2.RevokeByTokenHash(ctx, tokenHash) } func TestPgStore_CreateUserDuplicateUsername(t *testing.T) { pool := openTestPool(t) if pool == nil { return } defer pool.Close() cfg := &config.Config{Env: "dev"} ctx := context.Background() store, err := auth.NewPgStore(pool, cfg) if err != nil { t.Fatalf("NewPgStore: %v", err) } username := "dup_" + time.Now().Format("150405.000000") account := &auth.UserAccount{ Username: username, Name: "重复探测", PasswordHash: "hash", Status: auth.StatusActive, RoleID: "role-guest", Position: "访客/待指派", } created, err := store.CreateUser(ctx, account) if err != nil { t.Fatalf("first CreateUser: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, created.ID) }) _, err = store.CreateUser(ctx, &auth.UserAccount{ Username: username, Name: "重复探测", PasswordHash: "hash", Status: auth.StatusActive, RoleID: "role-guest", Position: "访客/待指派", }) if !errors.Is(err, auth.ErrUsernameExists) { t.Fatalf("duplicate username = %v, want ErrUsernameExists", err) } } func TestPgStore_DisabledUserSessionRejected(t *testing.T) { pool := openTestPool(t) if pool == nil { return } defer pool.Close() cfg := &config.Config{Env: "dev"} ctx := context.Background() store, err := auth.NewPgStore(pool, cfg) if err != nil { t.Fatalf("NewPgStore: %v", err) } user, err := store.GetUserByUsername(ctx, "zhanggong") if err != nil { t.Skipf("zhanggong not seeded: %v", err) } tokenHash := auth.HashToken("phase1-disable-token-" + time.Now().Format(time.RFC3339Nano)) if err := store.CreateSession(ctx, user.ID, tokenHash, auth.SessionMeta{}); err != nil { t.Fatalf("CreateSession: %v", err) } t.Cleanup(func() { _ = store.RevokeByTokenHash(context.Background(), tokenHash) }) if _, err := store.UpdateUserStatus(ctx, user.ID, auth.StatusDisabled); err != nil { t.Fatalf("disable: %v", err) } if _, err := store.ResolveUserID(ctx, tokenHash); err == nil { t.Fatal("expected disabled user session to be rejected") } t.Cleanup(func() { _, _ = store.UpdateUserStatus(context.Background(), user.ID, auth.StatusActive) }) }