Add member accounts: invites, registration, login, sessions, ban
Step 2 of the build order. The admin mints an invite link, the recipient registers with it, and from then on has a session. - The invite is spent in the same transaction that creates the account, so a failed signup leaves the code usable - Sessions are idle timeouts, 24h or 30 days with remember me, read from a cookie or a bearer header, extended at most once a minute - Ban is a reversible toggle that drops the member's live sessions - No password minimum; login is rate limited instead, 10 failures per email in 15 minutes, cleared by a correct password - Invite codes render as links carrying ?code=, which the register form prefills; PUBLIC_URL makes them pasteable from the loopback admin panel Tests cover invite spending, the idle timeout, ban, and the rate limiter.
This commit is contained in:
+240
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema.
|
||||
func testApp(t *testing.T) *app {
|
||||
t.Helper()
|
||||
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, pool: pool}
|
||||
}
|
||||
|
||||
func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
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.pool.QueryRow(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.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.pool.Exec(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.pool.QueryRow(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
|
||||
}
|
||||
|
||||
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.pool.Exec(ctx,
|
||||
`update sessions set expires_at = now() - interval '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.pool.Exec(ctx,
|
||||
`update sessions set expires_at = now() + interval '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.pool.QueryRow(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.pool.Exec(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.pool.QueryRow(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminMux := a.adminMux()
|
||||
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("ban: status = %d, want 303", w.Code)
|
||||
}
|
||||
|
||||
var sessions int
|
||||
if err := a.pool.QueryRow(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 := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user