Files
minstrel/internal/api/auth_register_test.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

349 lines
12 KiB
Go

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)
}
// With the gate on (as Mount sets it), the first account needs the setup
// token from the log; a missing or wrong one is refused and creates nothing.
func TestRegister_FirstUserNeedsSetupToken(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, _ := testHandlers(t)
resetUsers(t, h)
token, err := auth.NewSetupToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
h.setupToken = token
h.requireSetupToken = true
register := func(body string) int {
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleRegister(rec, req)
return rec.Code
}
if code := register(`{"username":"squatter","password":"abcd1234"}`); code != http.StatusForbidden {
t.Errorf("no token: status = %d, want 403", code)
}
if code := register(`{"username":"squatter","password":"abcd1234","setup_token":"wrong"}`); code != http.StatusForbidden {
t.Errorf("wrong token: status = %d, want 403", code)
}
var n int
if err := h.pool.QueryRow(context.Background(), `SELECT count(*) FROM users`).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Fatalf("a refused registration created %d account(s)", n)
}
if code := register(`{"username":"operator","password":"abcd1234","setup_token":"` + token.Value() + `"}`); code != http.StatusOK {
t.Fatalf("right token: status = %d, want 200", code)
}
// Once an account exists the token plays no part: the second user is
// governed by registration mode, not the setup token.
if _, err := h.pool.Exec(context.Background(),
`UPDATE registration_settings SET mode = 'open' WHERE id = true`); err != nil {
t.Fatalf("open mode: %v", err)
}
t.Cleanup(func() {
_, _ = h.pool.Exec(context.Background(),
`UPDATE registration_settings SET mode = 'invite_only' WHERE id = true`)
})
if code := register(`{"username":"second","password":"abcd1234"}`); code != http.StatusOK {
t.Errorf("second user without token: status = %d, want 200", code)
}
}
func TestSetupToken_MatchesOnlyItself(t *testing.T) {
tok, err := auth.NewSetupToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
if !tok.Matches(tok.Value()) {
t.Error("token does not match itself")
}
if tok.Matches("") || tok.Matches(tok.Value()+"x") {
t.Error("token matched something else")
}
var none *auth.SetupToken
if none.Matches("") || none.Matches("anything") {
t.Error("a nil token must fail closed")
}
}