package admin_test import ( "context" "database/sql" "errors" "net/url" "testing" "time" "galaxy/backend/internal/admin" "galaxy/backend/internal/config" backendpg "galaxy/backend/internal/postgres" pgshared "galaxy/postgres" testcontainers "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" ) const ( pgImage = "postgres:16-alpine" pgUser = "galaxy" pgPassword = "galaxy" pgDatabase = "galaxy_backend" pgSchema = "backend" pgStartup = 90 * time.Second pgOpTO = 10 * time.Second ) // startPostgres spins up a Postgres testcontainer with the backend // migrations applied. The returned *sql.DB is closed and the container // terminated by t.Cleanup hooks. Tests skip cleanly when Docker is // unavailable. func startPostgres(t *testing.T) *sql.DB { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) t.Cleanup(cancel) pgContainer, err := tcpostgres.Run(ctx, pgImage, tcpostgres.WithDatabase(pgDatabase), tcpostgres.WithUsername(pgUser), tcpostgres.WithPassword(pgPassword), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(pgStartup), ), ) if err != nil { t.Skipf("postgres testcontainer unavailable, skipping: %v", err) } t.Cleanup(func() { if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil { t.Errorf("terminate postgres container: %v", termErr) } }) baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatalf("connection string: %v", err) } scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema) if err != nil { t.Fatalf("scope dsn: %v", err) } cfg := pgshared.DefaultConfig() cfg.PrimaryDSN = scopedDSN cfg.OperationTimeout = pgOpTO db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...) if err != nil { t.Fatalf("open primary: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil { t.Fatalf("ping: %v", err) } if err := backendpg.ApplyMigrations(ctx, db); err != nil { t.Fatalf("apply migrations: %v", err) } return db } func dsnWithSearchPath(baseDSN, schema string) (string, error) { parsed, err := url.Parse(baseDSN) if err != nil { return "", err } values := parsed.Query() values.Set("search_path", schema) if values.Get("sslmode") == "" { values.Set("sslmode", "disable") } parsed.RawQuery = values.Encode() return parsed.String(), nil } func buildService(t *testing.T, db *sql.DB) (*admin.Service, *admin.Store, *admin.Cache) { t.Helper() store := admin.NewStore(db) cache := admin.NewCache() if err := cache.Warm(context.Background(), store); err != nil { t.Fatalf("warm admin cache: %v", err) } svc := admin.NewService(admin.Deps{ Store: store, Cache: cache, Logger: zap.NewNop(), }) return svc, store, cache } func TestBootstrapInsertsThenSkips(t *testing.T) { t.Parallel() db := startPostgres(t) store := admin.NewStore(db) cfg := config.AdminBootstrapConfig{User: "root", Password: "root-secret"} logger := zap.NewNop() if err := admin.Bootstrap(context.Background(), store, cfg, logger); err != nil { t.Fatalf("first bootstrap: %v", err) } first, hash, err := store.Lookup(context.Background(), "root") if err != nil { t.Fatalf("lookup after first bootstrap: %v", err) } if first.Username != "root" { t.Fatalf("Username = %q, want root", first.Username) } if err := bcrypt.CompareHashAndPassword(hash, []byte("root-secret")); err != nil { t.Fatalf("CompareHashAndPassword: %v", err) } // Second call must not modify the row even when the password value // supplied via env vars differs. cfg.Password = "different" if err := admin.Bootstrap(context.Background(), store, cfg, logger); err != nil { t.Fatalf("second bootstrap: %v", err) } _, sameHash, err := store.Lookup(context.Background(), "root") if err != nil { t.Fatalf("lookup after second bootstrap: %v", err) } if string(hash) != string(sameHash) { t.Fatalf("password_hash mutated by idempotent bootstrap") } } func TestBootstrapSkipsWhenUserEmpty(t *testing.T) { t.Parallel() db := startPostgres(t) store := admin.NewStore(db) if err := admin.Bootstrap(context.Background(), store, config.AdminBootstrapConfig{}, zap.NewNop()); err != nil { t.Fatalf("bootstrap: %v", err) } admins, _, err := store.ListAll(context.Background()) if err != nil { t.Fatalf("list: %v", err) } if len(admins) != 0 { t.Fatalf("ListAll = %d rows, want 0", len(admins)) } } func TestVerifyHappyPath(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) created, err := svc.Create(context.Background(), admin.CreateInput{ Username: "alice", Password: "alice-secret", }) if err != nil { t.Fatalf("create: %v", err) } if created.Username != "alice" { t.Fatalf("Username = %q, want alice", created.Username) } ok, err := svc.Verify(context.Background(), "alice", "alice-secret") if err != nil || !ok { t.Fatalf("Verify(correct) = (%v, %v), want (true, nil)", ok, err) } } func TestVerifyRejectsWrongPassword(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"}) if err != nil { t.Fatalf("create: %v", err) } ok, err := svc.Verify(context.Background(), "alice", "bad") if err != nil { t.Fatalf("Verify returned error: %v", err) } if ok { t.Fatalf("Verify(wrong) = true, want false") } } func TestVerifyRejectsUnknownUser(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) ok, err := svc.Verify(context.Background(), "ghost", "x") if err != nil || ok { t.Fatalf("Verify(ghost) = (%v, %v), want (false, nil)", ok, err) } } func TestVerifyRejectsDisabledAccount(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"}); err != nil { t.Fatalf("create: %v", err) } if _, err := svc.Disable(context.Background(), "alice"); err != nil { t.Fatalf("disable: %v", err) } ok, err := svc.Verify(context.Background(), "alice", "good") if err != nil || ok { t.Fatalf("Verify(disabled) = (%v, %v), want (false, nil)", ok, err) } } func TestEnableReversesDisable(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"}); err != nil { t.Fatalf("create: %v", err) } if _, err := svc.Disable(context.Background(), "alice"); err != nil { t.Fatalf("disable: %v", err) } got, err := svc.Enable(context.Background(), "alice") if err != nil { t.Fatalf("enable: %v", err) } if got.DisabledAt != nil { t.Fatalf("DisabledAt = %v, want nil after enable", got.DisabledAt) } ok, err := svc.Verify(context.Background(), "alice", "good") if err != nil || !ok { t.Fatalf("Verify after enable = (%v, %v), want (true, nil)", ok, err) } } func TestCreateRejectsDuplicateUsername(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "x"}); err != nil { t.Fatalf("create #1: %v", err) } if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "y"}); !errors.Is(err, admin.ErrUsernameTaken) { t.Fatalf("Create #2 err = %v, want ErrUsernameTaken", err) } } func TestCreateRejectsEmptyFields(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "", Password: "x"}); !errors.Is(err, admin.ErrInvalidInput) { t.Fatalf("Create(empty username) err = %v, want ErrInvalidInput", err) } if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: ""}); !errors.Is(err, admin.ErrInvalidInput) { t.Fatalf("Create(empty password) err = %v, want ErrInvalidInput", err) } } func TestResetPasswordReplacesHash(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "old"}); err != nil { t.Fatalf("create: %v", err) } if _, err := svc.ResetPassword(context.Background(), "alice", "new-secret"); err != nil { t.Fatalf("reset: %v", err) } if ok, _ := svc.Verify(context.Background(), "alice", "old"); ok { t.Fatalf("Verify(old) = true after reset") } if ok, err := svc.Verify(context.Background(), "alice", "new-secret"); err != nil || !ok { t.Fatalf("Verify(new) = (%v, %v), want (true, nil)", ok, err) } } func TestResetPasswordOnUnknownUser(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) if _, err := svc.ResetPassword(context.Background(), "ghost", "x"); !errors.Is(err, admin.ErrNotFound) { t.Fatalf("ResetPassword(ghost) err = %v, want ErrNotFound", err) } } func TestListReturnsAllRows(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) for _, u := range []string{"alice", "bob", "carol"} { if _, err := svc.Create(context.Background(), admin.CreateInput{Username: u, Password: "x"}); err != nil { t.Fatalf("create %s: %v", u, err) } } got, err := svc.List(context.Background()) if err != nil { t.Fatalf("list: %v", err) } if len(got) != 3 { t.Fatalf("List = %d rows, want 3", len(got)) } // Order is by username ASC at the SQL level. if got[0].Username != "alice" || got[1].Username != "bob" || got[2].Username != "carol" { t.Fatalf("List order = %v, want [alice bob carol]", []string{got[0].Username, got[1].Username, got[2].Username}) } } func TestVerifyTouchesLastUsedAt(t *testing.T) { t.Parallel() db := startPostgres(t) svc, store, _ := buildService(t, db) if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"}); err != nil { t.Fatalf("create: %v", err) } if ok, err := svc.Verify(context.Background(), "alice", "good"); err != nil || !ok { t.Fatalf("Verify: (%v, %v)", ok, err) } // last_used_at is updated by a fire-and-forget goroutine. Poll until // it lands or the deadline passes. deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { got, _, err := store.Lookup(context.Background(), "alice") if err != nil { t.Fatalf("lookup: %v", err) } if got.LastUsedAt != nil { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("LastUsedAt not populated after Verify") } func TestDisableIsIdempotent(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "x"}); err != nil { t.Fatalf("create: %v", err) } first, err := svc.Disable(context.Background(), "alice") if err != nil { t.Fatalf("disable #1: %v", err) } if first.DisabledAt == nil { t.Fatalf("DisabledAt = nil after disable") } second, err := svc.Disable(context.Background(), "alice") if err != nil { t.Fatalf("disable #2: %v", err) } if second.DisabledAt == nil { t.Fatalf("DisabledAt = nil on second disable") } } func TestDisableUnknownUser(t *testing.T) { t.Parallel() db := startPostgres(t) svc, _, _ := buildService(t, db) if _, err := svc.Disable(context.Background(), "ghost"); !errors.Is(err, admin.ErrNotFound) { t.Fatalf("Disable(ghost) err = %v, want ErrNotFound", err) } }