SQLite replaces Postgres, and two fixes from using the thing. - The database is a file under ./storage instead of a second container. Ten members never needed a database server, and the driver is pure Go, so the build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind mount is now the whole backup: no pgdata, no healthcheck-gated depends_on, no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the divisive/unified boards carry their own stddev, and foreign keys are on by pragma. Tests get a database file each and run without any setup - Invites are copied, not clicked. An invite is something to send, and the anchor opened the join form in the admin's own browser - Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita? Palautetta?" and the page behind it invites ideas rather than only bugs - Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
233 lines
7.3 KiB
Go
233 lines
7.3 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{cfg: config{adminUser: "admin", adminPass: "s3cret"}, db: db}
|
|
}
|
|
|
|
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.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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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.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 := 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)
|
|
}
|
|
}
|