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:
Esa Kataja
2026-09-05 13:53:04 +03:00
parent 5db19b26ae
commit b01d08b1e1
48 changed files with 9 additions and 3 deletions
+305
View File
@@ -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)
}
}
}