package movementtest import ( "io" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" "testing" "github.com/carlosframework/rastrillo/db" movement "amadan.net/carlos/movement/internal/movement" ) const adminAddr = "paul@example.com" // newAdminApp is the app with an allowlist, so the admin surface exists at // all. newApp deliberately has none. func newAdminApp(t *testing.T) *testApp { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) movement.SetLogger(logger) box := &recorder{} env := testEnv() env.Admins = []string{adminAddr} env.Mailer = box mux, err := movement.App(d, env) if err != nil { t.Fatalf("App: %v", err) } return &testApp{Handler: mux, DB: d, Mail: box} } // adminRoutes is every route the admin surface adds. The fail-closed gate // iterates this rather than spot-checking, because a route added later // without being listed here is exactly the one that would stay live. var adminRoutes = []struct{ method, path string }{ {http.MethodGet, "/signin"}, {http.MethodPost, "/signin"}, {http.MethodGet, "/auth/callback"}, {http.MethodGet, "/auth/verify"}, {http.MethodPost, "/signout"}, {http.MethodGet, "/admin"}, {http.MethodGet, "/admin/export.csv"}, } // With no allowlist there is no admin surface: not 403, not an open page, // but genuinely absent — indistinguishable from an app that never had one. // // Sign-in has to be included. Left live under an empty allowlist it would let // anyone make this app email a link from our verified sending identity to any // address they named. func TestAdminSurfaceIsAbsentWithoutAnAllowlist(t *testing.T) { h := newApp(t) // testEnv sets no admins for _, rt := range adminRoutes { var rec *httptest.ResponseRecorder if rt.method == http.MethodGet { rec = get(t, h, rt.path) } else { rec = post(t, h, rt.path, url.Values{"address": {adminAddr}}) } if rec.Code != http.StatusNotFound { t.Errorf("%s %s: status %d, want 404", rt.method, rt.path, rec.Code) } } if h.Mail.count() != 0 { t.Error("a request to the unmounted admin surface still sent mail") } } // A refused address gets exactly the response an accepted one gets. Saying // "that address cannot sign in" would turn this endpoint into a way to // enumerate who the admins are. func TestSigninRefusesAStrangerWithoutSayingSo(t *testing.T) { h := newAdminApp(t) stranger := post(t, h, "/signin", url.Values{"address": {"nobody@example.com"}}) if h.Mail.countTo("nobody@example.com") != 0 { t.Error("a stranger's address was mailed a live sign-in link") } admin := post(t, h, "/signin", url.Values{"address": {adminAddr}}) if stranger.Code != admin.Code { t.Errorf("stranger got %d, admin got %d — the difference is an oracle", stranger.Code, admin.Code) } if stranger.Header().Get("Location") != admin.Header().Get("Location") { t.Errorf("stranger redirected to %q, admin to %q", stranger.Header().Get("Location"), admin.Header().Get("Location")) } if h.Mail.countTo(adminAddr) != 1 { t.Error("the admin was not sent a link") } } var anyLink = regexp.MustCompile(`https?://[^\s>"]+`) // signInAsAdmin drives the whole magic-link round trip and returns the // session cookies. func signInAsAdmin(t *testing.T, h *testApp) []*http.Cookie { t.Helper() post(t, h, "/signin", url.Values{"address": {adminAddr}}) body := h.Mail.waitForMailTo(t, adminAddr, 1) link := anyLink.FindString(body) if link == "" { t.Fatalf("no sign-in link in the message:\n%s", body) } u, err := url.Parse(link) if err != nil { t.Fatalf("parsing %q: %v", link, err) } req := httptest.NewRequest(http.MethodGet, u.RequestURI(), nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) cookies := rec.Result().Cookies() if len(cookies) == 0 { t.Fatalf("redeeming the link minted no session (status %d)", rec.Code) } return cookies } func getAs(t *testing.T, h *testApp, target string, cookies []*http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, target, nil) for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // The export restriction is the one rule's first half made mechanical: a row // nobody has confirmed is not on the list, so it is not in the file. func TestExportCarriesOnlyConfirmedRows(t *testing.T) { h := newAdminApp(t) postSignup(t, h, submission(t, "in@example.com", "curious")) confirmSignup(t, h, "in@example.com") postSignup(t, h, submission(t, "waiting@example.com", "never")) cookies := signInAsAdmin(t, h) rec := getAs(t, h, "/admin/export.csv", cookies) if rec.Code != http.StatusOK { t.Fatalf("GET /admin/export.csv: %d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "in@example.com") { t.Error("a confirmed address is missing from the export") } if strings.Contains(body, "waiting@example.com") { t.Error("an unconfirmed address reached the export") } } // This file is opened in a spreadsheet, and a cell beginning =, +, -, @, tab // or carriage return executes when it is. The background field is free text // somebody else wrote, which makes it the obvious carrier. func TestExportDefusesFormulaInjection(t *testing.T) { h := newAdminApp(t) form := submission(t, "sneaky@example.com", "curious") form.Set("background", `=cmd|' /c calc'!A1`) postSignup(t, h, form) confirmSignup(t, h, "sneaky@example.com") cookies := signInAsAdmin(t, h) body := getAs(t, h, "/admin/export.csv", cookies).Body.String() if strings.Contains(body, ",=cmd") || strings.Contains(body, "\"=cmd") { t.Errorf("a formula reached the export unescaped:\n%s", body) } if !strings.Contains(body, "'=cmd") { t.Errorf("the escaping prefix is missing:\n%s", body) } } func TestAdminPageDoesNotListPendingRows(t *testing.T) { h := newAdminApp(t) postSignup(t, h, submission(t, "shown@example.com", "curious")) confirmSignup(t, h, "shown@example.com") postSignup(t, h, submission(t, "hidden@example.com", "never")) cookies := signInAsAdmin(t, h) body := getAs(t, h, "/admin", cookies).Body.String() if !strings.Contains(body, "shown@example.com") { t.Error("a confirmed address is missing from the admin list") } if strings.Contains(body, "hidden@example.com") { t.Error("a pending address — unproven, and joined to a stance — was listed") } } func TestAdminNeedsASession(t *testing.T) { h := newAdminApp(t) for _, path := range []string{"/admin", "/admin/export.csv"} { rec := get(t, h, path) if rec.Code == http.StatusOK { t.Errorf("GET %s answered 200 with no session", path) } } } // auth consults Authorize once, when a link is redeemed. Without a // per-request re-check, removing somebody from the allowlist would do nothing // until their session expired. // // The test restarts the app rather than changing an environment variable // mid-run: a process's environment is fixed at exec, so t.Setenv would pass // while proving nothing about how removal actually takes effect. func TestRemovedAdminLosesAccessOnTheNextRequest(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) dir := t.TempDir() box := &recorder{} env := testEnv() env.Admins = []string{adminAddr} env.Mailer = box d, err := db.Open(filepath.Join(dir, "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } movement.SetLogger(logger) mux, err := movement.App(d, env) if err != nil { t.Fatalf("App: %v", err) } first := &testApp{Handler: mux, DB: d, Mail: box} cookies := signInAsAdmin(t, first) if rec := getAs(t, first, "/admin", cookies); rec.Code != http.StatusOK { t.Fatalf("setup: admin got %d before removal", rec.Code) } d.Close() // Same database, same session cookie, allowlist without them — which is // the state the platform leaves behind after `carlos env set` converges // and bounces the instance. d2, err := db.Open(filepath.Join(dir, "app.db"), logger) if err != nil { t.Fatalf("reopening: %v", err) } t.Cleanup(func() { d2.Close() }) env.Admins = []string{"someone.else@example.com"} mux2, err := movement.App(d2, env) if err != nil { t.Fatalf("App: %v", err) } second := &testApp{Handler: mux2, DB: d2, Mail: box} if rec := getAs(t, second, "/admin", cookies); rec.Code == http.StatusOK { t.Error("a removed admin still reached /admin with their old session") } } // Signing in has exactly one purpose here, so a fresh session lands on the // list. auth's default is "/", which would send an admin who has just // authenticated back to the public bill they started from. func TestSigningInLandsOnTheAdminList(t *testing.T) { h := newAdminApp(t) post(t, h, "/signin", url.Values{"address": {adminAddr}}) body := h.Mail.waitForMailTo(t, adminAddr, 1) link := anyLink.FindString(body) if link == "" { t.Fatalf("no sign-in link in the message:\n%s", body) } u, err := url.Parse(link) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, u.RequestURI(), nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if got := rec.Header().Get("Location"); got != "/admin" { t.Errorf("a fresh session landed on %q, want /admin", got) } }