Implement Issue #11: Initial admin account setup on first deploy

When the database has no users, the UI redirects to /setup and prompts
for creation of the first admin account. The setup endpoints are
self-disabling — once any user exists, POST /setup/admin returns 403
and the frontend redirects /setup back to /login. The backend uses an
atomic transaction to prevent race conditions on concurrent requests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 19:02:16 -03:00
parent f29d9669f8
commit 3900dff5a1
9 changed files with 585 additions and 12 deletions

84
internal/setup/handler.go Normal file
View File

@@ -0,0 +1,84 @@
package setup
import (
"encoding/json"
"errors"
"net/http"
"git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/respond"
)
// TokenIssuer is the subset of auth.Service the setup handler needs.
type TokenIssuer interface {
IssueToken(volunteerID int64, role string) (string, error)
}
type Handler struct {
store Storer
authSvc TokenIssuer
}
func NewHandler(store *Store, authSvc *auth.Service) *Handler {
return &Handler{store: store, authSvc: authSvc}
}
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
func NewHandlerFromInterfaces(store Storer, authSvc TokenIssuer) *Handler {
return &Handler{store: store, authSvc: authSvc}
}
// Status handles GET /api/v1/setup/status.
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
needs, err := h.store.NeedsSetup(r.Context())
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not check setup status")
return
}
respond.JSON(w, http.StatusOK, map[string]bool{"needs_setup": needs})
}
// CreateAdmin handles POST /api/v1/setup/admin.
func (h *Handler) CreateAdmin(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Name == "" || body.Email == "" || body.Password == "" {
respond.Error(w, http.StatusBadRequest, "name, email, and password are required")
return
}
if len(body.Password) < 8 {
respond.Error(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
hashed, err := auth.HashPassword(body.Password)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not hash password")
return
}
id, err := h.store.CreateAdmin(r.Context(), body.Name, body.Email, hashed)
if errors.Is(err, ErrSetupAlreadyDone) {
respond.Error(w, http.StatusForbidden, "setup already completed")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not create admin account")
return
}
token, err := h.authSvc.IssueToken(id, "admin")
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not issue token")
return
}
respond.JSON(w, http.StatusCreated, map[string]string{"token": token})
}

View File

@@ -0,0 +1,168 @@
package setup_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.unsupervised.ca/walkies/internal/setup"
)
// ---- fakes ---------------------------------------------------------------
type fakeStore struct {
needsSetup bool
needsSetupErr error
createAdminID int64
createAdminErr error
}
func (f *fakeStore) NeedsSetup(_ context.Context) (bool, error) {
return f.needsSetup, f.needsSetupErr
}
func (f *fakeStore) CreateAdmin(_ context.Context, _, _, _ string) (int64, error) {
return f.createAdminID, f.createAdminErr
}
type fakeTokenIssuer struct {
token string
err error
}
func (f *fakeTokenIssuer) IssueToken(_ int64, _ string) (string, error) {
return f.token, f.err
}
// Compile-time interface checks.
var _ setup.Storer = (*fakeStore)(nil)
var _ setup.TokenIssuer = (*fakeTokenIssuer)(nil)
// ---- helpers -------------------------------------------------------------
func do(t *testing.T, handler http.HandlerFunc, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
var b *bytes.Reader
if body != "" {
b = bytes.NewReader([]byte(body))
} else {
b = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, b)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
// ---- Status tests --------------------------------------------------------
func TestStatus_NeedsSetup(t *testing.T) {
h := setup.NewHandlerFromInterfaces(
&fakeStore{needsSetup: true},
&fakeTokenIssuer{},
)
w := do(t, h.Status, "GET", "/api/v1/setup/status", "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
var resp map[string]bool
json.NewDecoder(w.Body).Decode(&resp)
if !resp["needs_setup"] {
t.Error("expected needs_setup=true")
}
}
func TestStatus_SetupDone(t *testing.T) {
h := setup.NewHandlerFromInterfaces(
&fakeStore{needsSetup: false},
&fakeTokenIssuer{},
)
w := do(t, h.Status, "GET", "/api/v1/setup/status", "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
var resp map[string]bool
json.NewDecoder(w.Body).Decode(&resp)
if resp["needs_setup"] {
t.Error("expected needs_setup=false")
}
}
// ---- CreateAdmin tests ---------------------------------------------------
func TestCreateAdmin_Success(t *testing.T) {
h := setup.NewHandlerFromInterfaces(
&fakeStore{createAdminID: 1},
&fakeTokenIssuer{token: "jwt-token"},
)
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin",
`{"name":"Admin","email":"admin@example.com","password":"supersecret"}`)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["token"] != "jwt-token" {
t.Errorf("expected token jwt-token, got %q", resp["token"])
}
}
func TestCreateAdmin_AlreadyDone(t *testing.T) {
h := setup.NewHandlerFromInterfaces(
&fakeStore{createAdminErr: setup.ErrSetupAlreadyDone},
&fakeTokenIssuer{},
)
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin",
`{"name":"Admin","email":"admin@example.com","password":"supersecret"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body)
}
}
func TestCreateAdmin_MissingFields(t *testing.T) {
h := setup.NewHandlerFromInterfaces(
&fakeStore{},
&fakeTokenIssuer{},
)
tests := []struct {
name string
body string
}{
{"missing name", `{"email":"a@b.com","password":"supersecret"}`},
{"missing email", `{"name":"Admin","password":"supersecret"}`},
{"missing password", `{"name":"Admin","email":"a@b.com"}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin", tc.body)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body)
}
})
}
}
func TestCreateAdmin_PasswordTooShort(t *testing.T) {
h := setup.NewHandlerFromInterfaces(
&fakeStore{},
&fakeTokenIssuer{},
)
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin",
`{"name":"Admin","email":"admin@example.com","password":"short"}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body)
}
}

68
internal/setup/setup.go Normal file
View File

@@ -0,0 +1,68 @@
package setup
import (
"context"
"database/sql"
"errors"
)
var ErrSetupAlreadyDone = errors.New("setup already completed")
// Storer is the interface for setup-related DB operations.
type Storer interface {
NeedsSetup(ctx context.Context) (bool, error)
CreateAdmin(ctx context.Context, name, email, hashedPassword string) (int64, error)
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// NeedsSetup returns true when the volunteers table has zero rows.
func (s *Store) NeedsSetup(ctx context.Context) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM volunteers`).Scan(&count)
if err != nil {
return false, err
}
return count == 0, nil
}
// CreateAdmin atomically checks that no users exist and inserts the first admin.
func (s *Store) CreateAdmin(ctx context.Context, name, email, hashedPassword string) (int64, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM volunteers`).Scan(&count); err != nil {
return 0, err
}
if count > 0 {
return 0, ErrSetupAlreadyDone
}
res, err := tx.ExecContext(ctx,
`INSERT INTO volunteers (name, email, password, role, active, operational_roles) VALUES (?, ?, ?, 'admin', 1, '')`,
name, email, hashedPassword,
)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return id, nil
}