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.
306 lines
9.6 KiB
Go
306 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A fresh database file per test, thrown away with the temp dir. No server to point at, so these
|
|
// run everywhere rather than only where someone remembered to set an env var.
|
|
func testApp(t *testing.T) *app {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
db, err := openDB(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
if err := migrate(ctx, db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &app{db: db}
|
|
}
|
|
|
|
func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return postAs(t, h, path, form, "")
|
|
}
|
|
|
|
// postAs is post with a session cookie, which is now the only way to reach an admin route.
|
|
func postAs(t *testing.T, h http.Handler, path string, form url.Values, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if token != "" {
|
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
func (a *app) inviteValid(t *testing.T, code string) bool {
|
|
t.Helper()
|
|
var valid bool
|
|
if err := a.db.QueryRowContext(context.Background(),
|
|
`select is_valid from invites where code = $1`, code).Scan(&valid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return valid
|
|
}
|
|
|
|
// A failed registration must leave the code usable; a successful one must not.
|
|
func TestInviteIsSpentOnlyBySuccess(t *testing.T) {
|
|
a := testApp(t)
|
|
ctx := context.Background()
|
|
mux := a.withMember(a.memberMux())
|
|
|
|
if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu1')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := a.db.ExecContext(ctx,
|
|
`insert into users (name, email, password_hash) values ('Esa', '[email protected]', 'x')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Taken email — the insert fails after the invite has already been marked spent in the tx.
|
|
w := post(t, mux, "/register", url.Values{
|
|
"code": {"kutsu1"}, "name": {"Toinen"},
|
|
"email": {"[email protected]"}, "password": {"salasana1"},
|
|
})
|
|
if w.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("duplicate email: status = %d, want 422", w.Code)
|
|
}
|
|
if !a.inviteValid(t, "kutsu1") {
|
|
t.Fatal("failed registration spent the invite")
|
|
}
|
|
|
|
// Missing password — rejected before the invite is touched at all.
|
|
w = post(t, mux, "/register", url.Values{
|
|
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {""},
|
|
})
|
|
if w.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("short password: status = %d, want 422", w.Code)
|
|
}
|
|
if !a.inviteValid(t, "kutsu1") {
|
|
t.Fatal("rejected registration spent the invite")
|
|
}
|
|
|
|
w = post(t, mux, "/register", url.Values{
|
|
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
|
})
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("valid registration: status = %d, want 303", w.Code)
|
|
}
|
|
if a.inviteValid(t, "kutsu1") {
|
|
t.Fatal("successful registration left the invite usable")
|
|
}
|
|
|
|
// And it cannot be used twice.
|
|
w = post(t, mux, "/register", url.Values{
|
|
"code": {"kutsu1"}, "name": {"Kolmas"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
|
})
|
|
if w.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("reused invite: status = %d, want 422", w.Code)
|
|
}
|
|
}
|
|
|
|
// The limiter has its own unit test; this covers the wiring into the handler.
|
|
func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) {
|
|
a := testApp(t)
|
|
mux := a.withMember(a.memberMux())
|
|
a.seedMember(t, "[email protected]")
|
|
|
|
bad := url.Values{"email": {"[email protected]"}, "password": {"väärin"}}
|
|
for i := range loginMaxFailures {
|
|
if w := post(t, mux, "/login", bad); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
|
|
}
|
|
}
|
|
if w := post(t, mux, "/login", bad); w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("attempt %d: status = %d, want 429", loginMaxFailures+1, w.Code)
|
|
}
|
|
}
|
|
|
|
func (a *app) seedMember(t *testing.T, email string) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
err := a.db.QueryRowContext(context.Background(),
|
|
`insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`,
|
|
email).Scan(&id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// seedAdminMember returns an admin account and a live session token for it.
|
|
func (a *app) seedAdminMember(t *testing.T, email string) (int64, string) {
|
|
t.Helper()
|
|
id := a.seedMember(t, email)
|
|
if _, err := a.db.ExecContext(context.Background(),
|
|
`update users set is_admin = 1 where id = $1`, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, _, err := a.startSession(context.Background(), id, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id, tok
|
|
}
|
|
|
|
func (a *app) sessionFor(t *testing.T, token string) *member {
|
|
t.Helper()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
|
|
return a.session(httptest.NewRecorder(), r)
|
|
}
|
|
|
|
func TestSessionIdleTimeout(t *testing.T) {
|
|
a := testApp(t)
|
|
ctx := context.Background()
|
|
id := a.seedMember(t, "[email protected]")
|
|
|
|
live, _, err := a.startSession(ctx, id, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m := a.sessionFor(t, live); m == nil || m.ID != id {
|
|
t.Fatal("fresh session did not resolve to its member")
|
|
}
|
|
|
|
// Age it past the idle window: the timeout is what expiry means, so this is the whole rule.
|
|
if _, err := a.db.ExecContext(ctx,
|
|
`update sessions set expires_at = datetime('now', '-1 second') where token = $1`, live); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m := a.sessionFor(t, live); m != nil {
|
|
t.Fatal("expired session still resolved")
|
|
}
|
|
|
|
// A session used inside the window slides forward.
|
|
fresh, _, err := a.startSession(ctx, id, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := a.db.ExecContext(ctx,
|
|
`update sessions set expires_at = datetime('now', '+1 hour') where token = $1`, fresh); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m := a.sessionFor(t, fresh); m == nil {
|
|
t.Fatal("session inside the window did not resolve")
|
|
}
|
|
var expires time.Time
|
|
if err := a.db.QueryRowContext(ctx,
|
|
`select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if time.Until(expires) < 23*time.Hour {
|
|
t.Fatalf("session was not extended: expires in %s", time.Until(expires))
|
|
}
|
|
}
|
|
|
|
func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
|
|
a := testApp(t)
|
|
ctx := context.Background()
|
|
mux := a.withMember(a.memberMux())
|
|
|
|
if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu2')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := post(t, mux, "/register", url.Values{
|
|
"code": {"kutsu2"}, "name": {"Esa"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
|
})
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("registration: status = %d, want 303", w.Code)
|
|
}
|
|
var id int64
|
|
if err := a.db.QueryRowContext(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, adminTok := a.seedAdminMember(t, "[email protected]")
|
|
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", id), nil, adminTok); w.Code != http.StatusSeeOther {
|
|
t.Fatalf("ban: status = %d, want 303", w.Code)
|
|
}
|
|
|
|
var sessions int
|
|
if err := a.db.QueryRowContext(ctx,
|
|
`select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sessions != 0 {
|
|
t.Fatalf("banned member kept %d sessions", sessions)
|
|
}
|
|
|
|
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("banned login: status = %d, want 403", w.Code)
|
|
}
|
|
|
|
// Reversible: unban, and the same credentials work again.
|
|
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", id), nil, adminTok); w.Code != http.StatusSeeOther {
|
|
t.Fatalf("unban: status = %d, want 303", w.Code)
|
|
}
|
|
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("login after unban: status = %d, want 303", w.Code)
|
|
}
|
|
}
|
|
|
|
// Self-banning drops your own sessions, and only an admin could undo it. Refuse.
|
|
func TestAdminCannotBanSelf(t *testing.T) {
|
|
a := testApp(t)
|
|
mux := a.withMember(a.memberMux())
|
|
adminID, adminTok := a.seedAdminMember(t, "[email protected]")
|
|
|
|
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", adminID), nil, adminTok); w.Code != http.StatusSeeOther {
|
|
t.Fatalf("self-ban: status = %d, want 303", w.Code)
|
|
}
|
|
var banned bool
|
|
if err := a.db.QueryRowContext(context.Background(),
|
|
`select banned from users where id = $1`, adminID).Scan(&banned); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if banned {
|
|
t.Fatal("admin banned themselves")
|
|
}
|
|
}
|
|
|
|
// A signed-in member who is not an admin must not be able to ban anyone.
|
|
func TestMemberCannotReachAdminRoutes(t *testing.T) {
|
|
a := testApp(t)
|
|
mux := a.withMember(a.memberMux())
|
|
victim := a.seedMember(t, "[email protected]")
|
|
|
|
plain := a.seedMember(t, "[email protected]")
|
|
tok, _, err := a.startSession(context.Background(), plain, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", victim), nil, tok); w.Code != http.StatusNotFound {
|
|
t.Fatalf("member ban: status = %d, want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInitials(t *testing.T) {
|
|
for name, want := range map[string]string{
|
|
"Esa Kataja": "EK",
|
|
"Ärväs Öhman": "ÄÖ", // multi-byte initials must not end the loop early
|
|
"Åke": "Å",
|
|
"": "",
|
|
"a b c": "AB",
|
|
} {
|
|
if got := (&member{Name: name}).Initials(); got != want {
|
|
t.Errorf("Initials(%q) = %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
}
|