feat(server/m7-user-mgmt): self-registration handler
POST /api/auth/register accepts {username, password, invite_token?,
display_name?} and creates a user. Race-safe first-admin path: when
the users table is empty, the SQL query (CreateUserFirstAdminRace)
inserts is_admin computed from a SELECT NOT EXISTS subquery — no
serializable isolation needed; concurrent empty-state registrations
both end up admin (benign).
Past first-admin, registration_settings.mode dictates: 'invite_only'
(default) requires a valid unredeemed unexpired invite token, which
is atomically claimed via RedeemInvite (rows-affected returns from
sqlc's :execrows directive). 'open' mode skips the invite check.
On success: hashes password (bcrypt), mints session token + cookie
matching handleLogin's shape, mints a separate api_token for
Subsonic clients, audits ActionRegister + ActionInviteRedeem
(best-effort — a failed audit write does NOT fail the user-facing
operation).
Validation: 3-32 char usernames (alphanumeric + underscore +
hyphen), 8-char minimum password. Duplicate username surfaces as
409.
Tests cover: first-user-becomes-admin, invite-only requires token,
valid token redeems + non-admin role, invalid token 400, open mode
skips check, duplicate username 409, password too short 400,
username format 400, race scenario asserts at-least-one-admin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user