Move the package into src/
Thirty-seven entries in the root, most of them .go files. The assets had to come along: //go:embed cannot reach outside its own directory, so templates/, static/ and migrations/ live beside the code that embeds them, and testdata/ beside the test that reads it. storage/ stays put — runtime data, not source. go build now needs -o. Without it the output would be named after the package directory and collide with src/ itself.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user