From 442668874d021fdcdc5a2a3bbd8e01ee7af42920 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 02:32:58 +0000 Subject: [PATCH] M1/#292: integration test for admin bootstrap (MINSTREL_TEST_DATABASE_URL-gated) --- internal/auth/bootstrap_test.go | 95 +++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 internal/auth/bootstrap_test.go diff --git a/internal/auth/bootstrap_test.go b/internal/auth/bootstrap_test.go new file mode 100644 index 00000000..7fe6ec5b --- /dev/null +++ b/internal/auth/bootstrap_test.go @@ -0,0 +1,95 @@ +package auth + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" + + "git.fabledsword.com/bvandeusen/minstrel/internal/config" + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// TestBootstrap_Integration exercises the admin-bootstrap flow against a real +// Postgres. Gated on MINSTREL_TEST_DATABASE_URL, skipped in -short mode. +func TestBootstrap_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping bootstrap integration in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set; skipping") + } + ctx := context.Background() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + if err := db.Migrate(dsn, logger); err != nil { + t.Fatalf("Migrate: %v", err) + } + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + + // Start from a known-empty users table. Other tests that insert users + // must run in isolation; a shared compose pg is fine for solo dev. + if _, err := pool.Exec(ctx, "TRUNCATE users RESTART IDENTITY CASCADE"); err != nil { + t.Fatalf("truncate: %v", err) + } + + cfg := config.AdminBootstrapConfig{Username: "admin", Password: "hunter2"} + if err := Bootstrap(ctx, pool, cfg, logger); err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + q := dbq.New(pool) + user, err := q.GetUserByUsername(ctx, "admin") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if !user.IsAdmin { + t.Error("bootstrapped user not marked admin") + } + if user.ApiToken == "" { + t.Error("api_token empty") + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("hunter2")); err != nil { + t.Errorf("bcrypt compare: %v", err) + } + + byToken, err := q.GetUserByAPIToken(ctx, user.ApiToken) + if err != nil { + t.Fatalf("GetUserByAPIToken: %v", err) + } + if byToken.Username != "admin" { + t.Errorf("token lookup returned %q", byToken.Username) + } + + // Second call must be a no-op because users table is non-empty. + if err := Bootstrap(ctx, pool, cfg, logger); err != nil { + t.Fatalf("Bootstrap (second call): %v", err) + } + count, err := q.CountUsers(ctx) + if err != nil { + t.Fatalf("CountUsers: %v", err) + } + if count != 1 { + t.Errorf("expected exactly 1 user after idempotent bootstrap, got %d", count) + } +} + +// TestBootstrap_NoUsernameSkips verifies the no-config path without needing +// a database connection. +func TestBootstrap_NoUsernameSkips(t *testing.T) { + err := Bootstrap(context.Background(), nil, config.AdminBootstrapConfig{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Errorf("empty config should return nil, got %v", err) + } +}