Make the admin a member with a flag, and drop the second listener
The admin was a set of env credentials on its own loopback listener. That bought network isolation, and charged a second port to tunnel and proxy and a second credential in the password manager. It also sat outside the SameSite protection the member cookie already had, and left every ban and password reset with no actor to log. is_admin on users reuses what was already there: the session, the login rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member mux. A member without the flag gets 404 rather than 403 — the pages are none of their business, and "forbidden" confirms there is something to be forbidden from. Registration needs an invite and invites come from /admin, so an empty database cannot grow its first user. seedAdmin breaks that circle exactly once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a database that already has users. An admin cannot ban themselves: banning drops the target's sessions, and nothing would be left that could undo it. This reverses decision 8, which is rewritten rather than deleted, along with the admin entry in the CONTEXT.md vocabulary.
This commit is contained in:
+52
-15
@@ -7,29 +7,29 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A plain member must not be able to tell that /admin exists, and a stranger must be sent to log in.
|
||||
func TestRequireAdmin(t *testing.T) {
|
||||
a := &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}}
|
||||
h := a.requireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
a := &app{}
|
||||
h := a.requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, user, pass string
|
||||
auth bool
|
||||
want int
|
||||
name string
|
||||
as *member
|
||||
want int
|
||||
}{
|
||||
{name: "no credentials", want: http.StatusUnauthorized},
|
||||
{name: "wrong password", user: "admin", pass: "hunter2", auth: true, want: http.StatusUnauthorized},
|
||||
{name: "wrong user", user: "root", pass: "s3cret", auth: true, want: http.StatusUnauthorized},
|
||||
{name: "correct", user: "admin", pass: "s3cret", auth: true, want: http.StatusTeapot},
|
||||
{name: "signed out", as: nil, want: http.StatusSeeOther},
|
||||
{name: "member", as: &member{ID: 1}, want: http.StatusNotFound},
|
||||
{name: "admin", as: &member{ID: 1, IsAdmin: true}, want: http.StatusTeapot},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/admin", nil)
|
||||
if tc.auth {
|
||||
r.SetBasicAuth(tc.user, tc.pass)
|
||||
if tc.as != nil {
|
||||
r = r.WithContext(context.WithValue(r.Context(), memberKey, tc.as))
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
h(w, r)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("status = %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
@@ -37,6 +37,43 @@ func TestRequireAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The first account cannot arrive by invite, because minting an invite needs an admin.
|
||||
func TestSeedAdminOnlyOnEmptyDatabase(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
cfg := config{adminEmail: "[email protected]", adminName: "Ylläpito", adminPass: "salasana1"}
|
||||
|
||||
if err := seedAdmin(ctx, a.db, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var name, email string
|
||||
var isAdmin bool
|
||||
if err := a.db.QueryRowContext(ctx,
|
||||
`select name, email, is_admin from users`).Scan(&name, &email, &isAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !isAdmin || name != "Ylläpito" {
|
||||
t.Fatalf("seeded %q is_admin=%v, want Ylläpito admin", name, isAdmin)
|
||||
}
|
||||
// Login is by lowercased email, so the seed must not smuggle in a capital.
|
||||
if email != "[email protected]" {
|
||||
t.Fatalf("email = %q, want lowercased", email)
|
||||
}
|
||||
|
||||
// Re-running on a populated database must not add a second account or reset the first.
|
||||
cfg.adminEmail = "[email protected]"
|
||||
if err := seedAdmin(ctx, a.db, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := a.db.QueryRowContext(ctx, `select count(*) from users`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("users = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
a := testApp(t) // already migrated once
|
||||
@@ -52,7 +89,7 @@ func TestMigrateIsIdempotent(t *testing.T) {
|
||||
if err := a.db.QueryRowContext(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("applied migrations = %d, want 1", n)
|
||||
if n != 2 {
|
||||
t.Fatalf("applied migrations = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user