package api import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "sync" "testing" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" ) // resetUsers wipes the entire users table (including any non-prefixed admin // row). Used only by tests that need the empty-users bootstrap path; normal // tests call seedUser which uses the TestUserPrefix prefix and is cleaned up // by dbtest.ResetDB inside testHandlers. func resetUsers(t *testing.T, h *handlers) { t.Helper() if _, err := h.pool.Exec(context.Background(), "DELETE FROM users"); err != nil { t.Fatalf("resetUsers: %v", err) } } // resetInvites wipes the user_invites table. Called by tests that seed their // own invites so subsequent runs start clean. func resetInvites(t *testing.T, h *handlers) { t.Helper() if _, err := h.pool.Exec(context.Background(), "DELETE FROM user_invites"); err != nil { t.Fatalf("resetInvites: %v", err) } } func TestRegister_FirstUserBecomesAdmin(t *testing.T) { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } h, _ := testHandlers(t) resetUsers(t, h) body := `{"username":"firstuser","password":"abcd1234"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) rec := httptest.NewRecorder() h.handleRegister(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } // Verify is_admin = true on the inserted row. var isAdmin bool if err := h.pool.QueryRow(context.Background(), "SELECT is_admin FROM users WHERE username = 'firstuser'").Scan(&isAdmin); err != nil { t.Fatalf("verify: %v", err) } if !isAdmin { t.Errorf("is_admin = false; first registered user must be admin") } // Cookie set? gotCookie := false for _, c := range rec.Result().Cookies() { if c.Name == auth.SessionCookieName { gotCookie = true } } if !gotCookie { t.Errorf("session cookie not set") } } func TestRegister_InviteOnlyMode_RequiresToken(t *testing.T) { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } h, pool := testHandlers(t) // Seed an admin user so we're past the empty-users state. seedUser(t, pool, "existingadmin", "pw", true) // Mode is default invite_only. body := `{"username":"newuser","password":"abcd1234"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) rec := httptest.NewRecorder() h.handleRegister(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400 (invite_required)", rec.Code) } } func TestRegister_InviteOnlyMode_ValidTokenSucceeds(t *testing.T) { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } h, pool := testHandlers(t) resetInvites(t, h) admin := seedUser(t, pool, "admin1", "pw", true) // Insert a fresh invite directly. token := "test-invite-abc123" if _, err := pool.Exec(context.Background(), `INSERT INTO user_invites (token, invited_by, expires_at) VALUES ($1, $2, now() + interval '24 hours')`, token, admin.ID, ); err != nil { t.Fatalf("seed invite: %v", err) } body := `{"username":"invitee1","password":"abcd1234","invite_token":"` + token + `"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) rec := httptest.NewRecorder() h.handleRegister(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } // Invite redeemed? var redeemedAt *string if err := pool.QueryRow(context.Background(), `SELECT redeemed_at::text FROM user_invites WHERE token = $1`, token, ).Scan(&redeemedAt); err != nil { t.Fatalf("verify redeem: %v", err) } if redeemedAt == nil { t.Errorf("invite not redeemed after successful registration") } // New user is_admin = false? var isAdmin bool if err := pool.QueryRow(context.Background(), `SELECT is_admin FROM users WHERE username = 'invitee1'`).Scan(&isAdmin); err != nil { t.Fatalf("verify user: %v", err) } if isAdmin { t.Errorf("new invitee marked as admin; only first user should be admin") } } func TestRegister_InviteOnlyMode_InvalidTokenRejects(t *testing.T) { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } h, pool := testHandlers(t) seedUser(t, pool, "adm", "pw", true) body := `{"username":"baduser","password":"abcd1234","invite_token":"nonexistent-token"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) rec := httptest.NewRecorder() h.handleRegister(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400 (invite_invalid)", rec.Code) } } func TestRegister_OpenMode_NoTokenNeeded(t *testing.T) { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } h, pool := testHandlers(t) seedUser(t, pool, "preexisting", "pw", true) // Switch to open mode; restore invite_only afterwards so other tests are // not affected (registration_settings is a singleton not truncated by ResetDB). if _, err := pool.Exec(context.Background(), "UPDATE registration_settings SET mode = 'open' WHERE id = true"); err != nil { t.Fatalf("set mode: %v", err) } t.Cleanup(func() { if _, err := pool.Exec(context.Background(), "UPDATE registration_settings SET mode = 'invite_only' WHERE id = true"); err != nil { t.Logf("cleanup: restore mode failed: %v", err) } }) body := `{"username":"openreg","password":"abcd1234"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) rec := httptest.NewRecorder() h.handleRegister(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } } func TestRegister_DuplicateUsername_409(t *testing.T) { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } h, pool := testHandlers(t) resetUsers(t, h) // Switch to open mode so the second attempt is not rejected by invite // check before it can reach the INSERT and hit the unique violation. if _, err := pool.Exec(context.Background(), "UPDATE registration_settings SET mode = 'open' WHERE id = true"); err != nil { t.Fatalf("set mode: %v", err) } t.Cleanup(func() { if _, err := pool.Exec(context.Background(), "UPDATE registration_settings SET mode = 'invite_only' WHERE id = true"); err != nil { t.Logf("cleanup: restore mode failed: %v", err) } }) // First register succeeds (empty-users state → first-admin path). body := `{"username":"taken","password":"abcd1234"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) h.handleRegister(httptest.NewRecorder(), req) // Second register with same username in open mode → 409 (unique violation). req2 := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) rec2 := httptest.NewRecorder() h.handleRegister(rec2, req2) if rec2.Code != http.StatusConflict { t.Errorf("status = %d, want 409 (username_taken)", rec2.Code) } } func TestRegister_PasswordTooShort_400(t *testing.T) { h, _ := testHandlers(t) body := `{"username":"shortpw","password":"abc"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) rec := httptest.NewRecorder() h.handleRegister(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rec.Code) } } func TestRegister_UsernameInvalid_400(t *testing.T) { h, _ := testHandlers(t) body := `{"username":"has spaces","password":"abcd1234"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) rec := httptest.NewRecorder() h.handleRegister(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rec.Code) } } func TestRegister_FirstAdminRace(t *testing.T) { // Concurrent registrations from empty-users state. Both // usernames are different, so users.username uniqueness doesn't // arbitrate. Both will see "no users yet" and both insert as // admin. Test asserts the at-least-one-admin invariant. if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } h, _ := testHandlers(t) resetUsers(t, h) const N = 5 var wg sync.WaitGroup for i := 0; i < N; i++ { wg.Add(1) go func(idx int) { defer wg.Done() body := json.RawMessage(`{"username":"raceuser` + string(rune('A'+idx)) + `","password":"abcd1234"}`) req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) rec := httptest.NewRecorder() h.handleRegister(rec, req) }(i) } wg.Wait() var adminCount int if err := h.pool.QueryRow(context.Background(), `SELECT count(*) FROM users WHERE is_admin = true`).Scan(&adminCount); err != nil { t.Fatalf("count: %v", err) } if adminCount < 1 { t.Errorf("admin count = 0; expected at least 1 (race-safety invariant violated)") } // adminCount can be > 1 — that's fine. The invariant is "at least one," // not "exactly one" (which would require serializable isolation). t.Logf("admin count after race = %d (>=1 is the invariant)", adminCount) }