Files
minstrel/internal/api/auth_register_test.go
T
bvandeusenandClaude Opus 5.5 2f3fbccab6
test-go / test (push) Successful in 2m3s
test-web / test (push) Successful in 1m14s
test-go / integration (push) Successful in 4m38s
release / Build signed APK (releases and dev) (push) Successful in 5m36s
release / Build + push container image (push) Successful in 1m24s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(auth): first account on a new server needs the setup token from the server log (M462 #4982)
While no accounts exist, the server mints a random setup token at boot and
logs it. Registering the first account (which becomes admin) must carry it,
so whoever reaches a freshly exposed instance first cannot claim it. The
register page asks GET /api/auth/setup-status and shows a "Setup token"
field in place of the invite field while setup is pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:35:52 -04:00

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