Files
galaxy-game/backend/internal/admin/admin_e2e_test.go
T
2026-05-07 00:58:53 +03:00

399 lines
12 KiB
Go

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