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:
168
internal/setup/handler_test.go
Normal file
168
internal/setup/handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user