diff --git a/mailer/mailer_test.go b/mailer/mailer_test.go new file mode 100644 index 0000000..6ca530b --- /dev/null +++ b/mailer/mailer_test.go @@ -0,0 +1,52 @@ +/* + * Copyright © 2026 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package mailer + +import ( + netmail "net/mail" + "testing" + + mail "github.com/xhit/go-simple-mail/v2" +) + +func TestFormatAddress(t *testing.T) { + cases := []struct { + name, address string + }{ + {"Le blog", "le-blog@example.com"}, + {"A, B, C et D", "sender@example.net"}, + {"", "noreply@example.com"}, + } + for _, c := range cases { + got := FormatAddress(c.name, c.address) + addr, err := netmail.ParseAddress(got) + if err != nil { + t.Fatalf("FormatAddress(%q, %q) = %q, which failed to parse: %v", c.name, c.address, got, err) + } + if addr.Address != c.address { + t.Errorf("FormatAddress(%q, %q) = %q, parsed address = %q, want %q", c.name, c.address, got, addr.Address, c.address) + } + } +} + +func TestNewMessageDoesNotAddPhantomRecipients(t *testing.T) { + m := &Mailer{smtp: mail.NewSMTPClient()} + msg, err := m.NewMessage("Test ", "Subject", "body", "") + if err != nil { + t.Fatalf("NewMessage returned error: %v", err) + } + if len(msg.smtpMsg.recipients) != 1 { + t.Fatalf("expected 1 recipient, got %d: %+v", len(msg.smtpMsg.recipients), msg.smtpMsg.recipients) + } + if msg.smtpMsg.recipients[0].email != "" { + t.Errorf("unexpected recipient: %+v", msg.smtpMsg.recipients[0]) + } +} diff --git a/signup_test.go b/signup_test.go new file mode 100644 index 0000000..2ee9032 --- /dev/null +++ b/signup_test.go @@ -0,0 +1,388 @@ +//go:build sqlite + +/* + * Copyright © 2026 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/gorilla/schema" + "github.com/stretchr/testify/assert" + "github.com/writeas/impart" + "github.com/writefreely/writefreely/config" + "github.com/writefreely/writefreely/key" +) + +var initTemplatesOnce sync.Once + +// newSignupTestApp builds a real, sqlite-backed App suitable for exercising +// the HTTP signup handlers end-to-end. It's intentionally minimal: only the +// pieces those handlers actually touch (db, cfg, keys, session store, form +// decoder, and the global page/template caches) are initialized. +func newSignupTestApp(t *testing.T) *App { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "writefreely.db") + db, err := sql.Open("sqlite3_with_regex", dbPath+"?parseTime=true&cached=shared") + if err != nil { + t.Fatalf("open test db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + cfg := config.New() + cfg.UseSQLite(true) + cfg.Database.FileName = dbPath + cfg.App.Host = "http://localhost:0" + cfg.App.SingleUser = false + cfg.App.OpenRegistration = false + cfg.App.MinUsernameLen = 3 + cfg.Server.HashSeed = "test-hash-seed" + + keys := &key.Keychain{} + if err := keys.GenerateKeys(); err != nil { + t.Fatalf("generate keys: %v", err) + } + + app := &App{ + db: &datastore{DB: db, driverName: driverSQLite}, + cfg: cfg, + keys: keys, + } + app.formDecoder = schema.NewDecoder() + app.InitSession() + + if err := adminInitDatabase(app); err != nil { + t.Fatalf("init schema: %v", err) + } + + var tmplErr error + initTemplatesOnce.Do(func() { + tmplErr = InitTemplates(cfg) + }) + if tmplErr != nil { + t.Fatalf("init templates: %v", tmplErr) + } + + return app +} + +// seedInvite inserts a directly-usable invite code owned by ownerID, with no +// expiration or use limit. +func seedInvite(t *testing.T, app *App, code string, ownerID int64) { + t.Helper() + _, err := app.db.Exec("INSERT INTO userinvites (id, owner_id, max_uses, created, expires, inactive) VALUES (?, ?, NULL, ?, NULL, 0)", + code, ownerID, time.Now().UTC().Format("2006-01-02 15:04:05")) + if err != nil { + t.Fatalf("seed invite: %v", err) + } +} + +func userExists(t *testing.T, app *App, username string) bool { + t.Helper() + var id int64 + err := app.db.QueryRow("SELECT id FROM users WHERE username = ?", username).Scan(&id) + if err == sql.ErrNoRows { + return false + } + if err != nil { + t.Fatalf("query user: %v", err) + } + return true +} + +// --- Regression coverage for #1722: POST /auth/signup (handleWebSignup) --- +// No test previously covered this path even though the fix landed there. + +func TestWebSignupClosedRegistration(t *testing.T) { + cases := []struct { + name string + invite string + wantStatus int + wantUser bool + }{ + {"no invite code", "", http.StatusForbidden, false}, + {"bogus invite code", "not-a-real-code", http.StatusForbidden, false}, + {"valid invite code", "webvalid1", http.StatusFound, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := newSignupTestApp(t) + app.cfg.App.OpenRegistration = false + + if tc.invite == "webvalid1" { + seedInvite(t, app, "webvalid1", 0) + } + + username := "websignup-" + strings.ReplaceAll(tc.name, " ", "-") + form := url.Values{} + form.Set("alias", username) + form.Set("pass", "sup3rSecret!") + form.Set("invite_code", tc.invite) + + req := httptest.NewRequest("POST", "/auth/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + err := handleWebSignup(app, w, req) + + // handleWebSignup always returns an impart.HTTPError: a 302 to + // "to" on success, or the rejection status on failure. + httpErr, ok := err.(impart.HTTPError) + if !ok { + t.Fatalf("expected impart.HTTPError, got %T (%v)", err, err) + } + if httpErr.Status != tc.wantStatus { + t.Errorf("status = %d, want %d", httpErr.Status, tc.wantStatus) + } + + assert.Equal(t, tc.wantUser, userExists(t, app, username), + "unexpected account-creation outcome for case %q", tc.name) + }) + } +} + +// --- Finding A: POST /api/auth/signup (apiSignup -> signup) --- + +func TestAPISignupClosedRegistration(t *testing.T) { + cases := []struct { + name string + invite string + wantStatus int + wantUser bool + }{ + {"no invite code", "", http.StatusForbidden, false}, + {"bogus invite code", "not-a-real-code", http.StatusForbidden, false}, + {"valid invite code", "valid1", http.StatusFound /* signupWithRegistration succeeds */, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := newSignupTestApp(t) + app.cfg.App.OpenRegistration = false + + if tc.invite == "valid1" { + seedInvite(t, app, "valid1", 0) + } + + username := "apisignup-" + strings.ReplaceAll(tc.name, " ", "-") + form := url.Values{} + form.Set("alias", username) + form.Set("pass", "sup3rSecret!") + form.Set("invite_code", tc.invite) + + req := httptest.NewRequest("POST", "/api/auth/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + err := apiSignup(app, w, req) + + if !tc.wantUser { + httpErr, ok := err.(impart.HTTPError) + if !ok { + t.Fatalf("expected impart.HTTPError, got %T (%v)", err, err) + } + if httpErr.Status != tc.wantStatus { + t.Errorf("status = %d, want %d", httpErr.Status, tc.wantStatus) + } + } else if err != nil { + t.Fatalf("expected signup to succeed, got error: %v", err) + } + + assert.Equal(t, tc.wantUser, userExists(t, app, username), + "unexpected account-creation outcome for case %q", tc.name) + }) + } +} + +func TestAPISignupOpenRegistrationStillWorks(t *testing.T) { + app := newSignupTestApp(t) + app.cfg.App.OpenRegistration = true + + form := url.Values{} + form.Set("alias", "apisignup-open") + form.Set("pass", "sup3rSecret!") + + req := httptest.NewRequest("POST", "/api/auth/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + if err := apiSignup(app, w, req); err != nil { + t.Fatalf("expected signup to succeed with open registration, got: %v", err) + } + assert.True(t, userExists(t, app, "apisignup-open")) +} + +// TestInitRoutesAlwaysRegistersAPISignup guards against the routing-time +// bypass in Finding A: /api/auth/signup used to only be registered when +// OpenRegistration was true *at boot*, so toggling the setting off at +// runtime (via the admin panel) left the route live with no invite check +// at all. It must now always resolve, regardless of the setting. +func TestInitRoutesAlwaysRegistersAPISignup(t *testing.T) { + app := newSignupTestApp(t) + app.cfg.App.OpenRegistration = false + + router := InitRoutes(app, mux.NewRouter()) + + req := httptest.NewRequest("POST", "/api/auth/signup", nil) + var match mux.RouteMatch + if !router.Match(req, &match) { + t.Fatal("expected /api/auth/signup to match a route even when OpenRegistration is false") + } +} + +// --- Finding B: POST /oauth/signup (viewOauthSignup) --- + +func newTestOauthHandler(app *App) oauthHandler { + return oauthHandler{ + Config: app.cfg, + DB: app.db, + Store: app.sessionStore, + EmailKey: app.keys.EmailKey, + } +} + +func signOauthParams(hashSeed string, p oauthSignupPageParams) string { + return p.HashTokenParams(hashSeed) +} + +func TestOAuthSignupClosedRegistration(t *testing.T) { + cases := []struct { + name string + invite string + wantUser bool + }{ + {"no invite code", "", false}, + {"bogus invite code", "not-a-real-code", false}, + {"valid invite code", "oauthok", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := newSignupTestApp(t) + app.cfg.App.OpenRegistration = false + h := newTestOauthHandler(app) + + if tc.invite == "oauthok" { + seedInvite(t, app, "oauthok", 0) + } + + username := "oauthsignup-" + strings.ReplaceAll(tc.name, " ", "-") + tp := oauthSignupPageParams{ + AccessToken: "tok-" + username, + TokenUsername: username, + TokenAlias: username, + TokenEmail: "", + TokenRemoteUser: "remote-" + username, + ClientID: "client1", + Provider: "generic", + InviteCode: tc.invite, + } + sig := signOauthParams(app.cfg.Server.HashSeed, tp) + + form := url.Values{} + form.Set(oauthParamAccessToken, tp.AccessToken) + form.Set(oauthParamTokenUsername, tp.TokenUsername) + form.Set(oauthParamTokenAlias, tp.TokenAlias) + form.Set(oauthParamTokenEmail, tp.TokenEmail) + form.Set(oauthParamTokenRemoteUserID, tp.TokenRemoteUser) + form.Set(oauthParamClientID, tp.ClientID) + form.Set(oauthParamProvider, tp.Provider) + form.Set(oauthParamInviteCode, tp.InviteCode) + form.Set(oauthParamHash, sig) + form.Set(oauthParamUsername, username) + + req := httptest.NewRequest("POST", "/oauth/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + err := h.viewOauthSignup(app, w, req) + + // On rejection, viewOauthSignup re-renders the signup-oauth page + // inline with a flash message rather than returning an + // impart.HTTPError (mirroring showOauthSignupPage's contract), so + // the account-creation outcome in the DB is the real signal here. + if !tc.wantUser { + if err != nil { + t.Fatalf("expected inline re-render (nil error), got: %v", err) + } + if !strings.Contains(w.Body.String(), "Registration is closed") { + t.Errorf("expected rejection message in response body, got: %s", w.Body.String()) + } + } else if err != nil { + t.Fatalf("expected oauth signup to succeed, got error: %v", err) + } + + assert.Equal(t, tc.wantUser, userExists(t, app, username), + "unexpected account-creation outcome for case %q", tc.name) + }) + } +} + +// TestOAuthSignupCannotSwapInviteCodeWithoutInvalidatingSignature proves the +// replay bypass from Finding B is closed: a signature minted for one +// invite_code (or none) can no longer be reused with a different invite_code +// value, since InviteCode is now part of the signed payload. +func TestOAuthSignupCannotSwapInviteCodeWithoutInvalidatingSignature(t *testing.T) { + app := newSignupTestApp(t) + app.cfg.App.OpenRegistration = true // signature minted while registration was open + h := newTestOauthHandler(app) + + username := "oauthsignup-swap" + tp := oauthSignupPageParams{ + AccessToken: "tok-swap", + TokenUsername: username, + TokenAlias: username, + TokenRemoteUser: "remote-swap", + ClientID: "client1", + Provider: "generic", + InviteCode: "", // signed with an empty invite code + } + sig := signOauthParams(app.cfg.Server.HashSeed, tp) + + // Registration is closed by the time the (replayed) form is submitted. + app.cfg.App.OpenRegistration = false + + form := url.Values{} + form.Set(oauthParamAccessToken, tp.AccessToken) + form.Set(oauthParamTokenUsername, tp.TokenUsername) + form.Set(oauthParamTokenAlias, tp.TokenAlias) + form.Set(oauthParamTokenRemoteUserID, tp.TokenRemoteUser) + form.Set(oauthParamClientID, tp.ClientID) + form.Set(oauthParamProvider, tp.Provider) + form.Set(oauthParamInviteCode, "swapped-in-bogus-code") // tampered after signing + form.Set(oauthParamHash, sig) + form.Set(oauthParamUsername, username) + + req := httptest.NewRequest("POST", "/oauth/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + err := h.viewOauthSignup(app, w, req) + httpErr, ok := err.(impart.HTTPError) + if !ok { + t.Fatalf("expected impart.HTTPError, got %T (%v)", err, err) + } + if httpErr.Status != http.StatusBadRequest { + t.Errorf("status = %d, want %d (tampered request)", httpErr.Status, http.StatusBadRequest) + } + assert.False(t, userExists(t, app, username)) +} diff --git a/template_render_test.go b/template_render_test.go new file mode 100644 index 0000000..53233b1 --- /dev/null +++ b/template_render_test.go @@ -0,0 +1,321 @@ +/* + * Copyright © 2026 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + stdlog "log" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/gorilla/mux" + "github.com/writeas/web-core/auth" + wflog "github.com/writeas/web-core/log" + + "github.com/writefreely/writefreely/config" + "github.com/writefreely/writefreely/key" +) + +// This file contains integration tests that render real pages -- especially +// authenticated "/me" pages and blog pages -- against a real (SQLite-backed) +// App and asserts that template execution didn't fail. +// +// Detecting a failed template render is trickier than checking the HTTP +// status code alone: html/template writes to the ResponseWriter as it +// executes, so a mid-render failure can leave a 200 status already sent +// with a truncated body, while the error itself only surfaces as a +// log.Error call and, if the handler propagates it, a *second* attempt to +// write the 500 error page on top of the partial output. So each check +// here does three things: +// - capture web-core/log's error output during the request and fail if +// it contains "template:", the prefix Go's text/template package uses +// for every execution error +// - fail on a >=500 status +// - fail if the body contains the 500 page's own text, which would only +// appear if the internal-error template got rendered (possibly +// appended after partial output from the page that actually failed) + +// newTemplateTestApp builds a fully-initialized App backed by a temporary +// SQLite database and disposable in-memory keys, for use in rendering +// tests. It skips the test if the binary wasn't built with `-tags sqlite`. +func newTemplateTestApp(t *testing.T, mutate func(cfg *config.Config)) (*App, *mux.Router) { + t.Helper() + if !SQLiteEnabled { + t.Skip("SQLite support not compiled in; run with `go test -tags sqlite` to run this test") + } + + dir := t.TempDir() + + cfg := config.New() + cfg.Server.TemplatesParentDir = "" + cfg.Server.PagesParentDir = "" + cfg.Server.StaticParentDir = "testdata" + cfg.App.Host = "http://localhost:8080" + cfg.App.SiteName = "Test Instance" + cfg.App.Federation = true + cfg.App.UpdateChecks = false + cfg.App.MinUsernameLen = 1 + cfg.App.UserInvites = "user" + // Make the auto-created user blog fully public so we exercise the + // normal collection/post templates rather than the password-gate flow. + cfg.App.DefaultVisibility = "public" + cfg.App.LocalTimeline = true + cfg.Database.Type = driverSQLite + cfg.Database.FileName = filepath.Join(dir, "writefreely.db") + + if mutate != nil { + mutate(cfg) + } + + app := &App{cfg: cfg} + + app.keys = &key.Keychain{} + if err := app.keys.GenerateKeys(); err != nil { + t.Fatalf("generate keys: %v", err) + } + + if err := InitTemplates(app.cfg); err != nil { + t.Fatalf("init templates: %v", err) + } + + app.InitSession() + app.InitDecoder() + + connectToDatabase(app) + t.Cleanup(func() { app.db.Close() }) + + if err := adminInitDatabase(app); err != nil { + t.Fatalf("init database: %v", err) + } + + initActivityPub(app) + if app.cfg.App.LocalTimeline { + initLocalTimeline(app) + } + + router := mux.NewRouter() + InitRoutes(app, router) + app.InitStaticRoutes(router) + + return app, router +} + +// loginCookie returns a session cookie that authenticates as the given user, +// bypassing the login form/handler entirely. +func loginCookie(t *testing.T, app *App, u *User) *http.Cookie { + t.Helper() + + r := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + session, err := app.sessionStore.New(r, cookieName) + if err != nil { + t.Fatalf("create session: %v", err) + } + session.Values[cookieUserVal] = u.Cookie() + if err := session.Save(r, w); err != nil { + t.Fatalf("save session: %v", err) + } + + for _, c := range w.Result().Cookies() { + if c.Name == cookieName { + return c + } + } + t.Fatal("login did not produce a session cookie") + return nil +} + +// renderedRequest performs an HTTP request against the router and returns +// the response along with any error-level log output produced while +// handling it. +func renderedRequest(t *testing.T, router *mux.Router, method, path string, cookies []*http.Cookie) (*httptest.ResponseRecorder, string) { + t.Helper() + + var logBuf strings.Builder + prevErrorLog := wflog.ErrorLog + wflog.ErrorLog = stdlog.New(&logBuf, "", 0) + defer func() { wflog.ErrorLog = prevErrorLog }() + + req := httptest.NewRequest(method, path, nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + return rec, logBuf.String() +} + +// assertRendersCleanly performs the request and fails the test if there's +// any sign the page failed to render -- see the file-level comment for what +// "any sign" means. +func assertRendersCleanly(t *testing.T, router *mux.Router, method, path string, cookies []*http.Cookie, wantStatus int) *httptest.ResponseRecorder { + t.Helper() + + rec, logOutput := renderedRequest(t, router, method, path, cookies) + + if wantStatus > 0 && rec.Code != wantStatus { + t.Errorf("%s %s: status = %d, want %d (body: %s)", method, path, rec.Code, wantStatus, truncateForLog(rec.Body.String())) + } + if rec.Code >= http.StatusInternalServerError { + t.Errorf("%s %s: got server error status %d (body: %s)", method, path, rec.Code, truncateForLog(rec.Body.String())) + } + if strings.Contains(logOutput, "template:") { + t.Errorf("%s %s: template rendering error logged: %s", method, path, logOutput) + } + if strings.Contains(rec.Body.String(), "There seems to be an issue with this server") { + t.Errorf("%s %s: response contains the embedded 500 page, meaning rendering failed partway through:\n%s", method, path, truncateForLog(rec.Body.String())) + } + + return rec +} + +func truncateForLog(s string) string { + const max = 1000 + if len(s) <= max { + return s + } + return s[:max] + "...(truncated)" +} + +// createTemplateTestUser creates a user (and their auto-generated blog) and +// a single published post in it, returning all three. +func createTemplateTestUser(t *testing.T, app *App, username string) (*User, *Collection, *Post) { + t.Helper() + + hashedPass, err := auth.HashPass([]byte("testpassword")) + if err != nil { + t.Fatalf("hash password: %v", err) + } + + u := &User{Username: username, HashedPass: hashedPass} + if err := app.db.CreateUser(app.cfg, u, "", ""); err != nil { + t.Fatalf("create user %q: %v", username, err) + } + + coll, err := app.db.GetCollection(username) + if err != nil { + t.Fatalf("get collection for %q: %v", username, err) + } + + title := "Hello World" + content := "This is a **test** post used to exercise template rendering.\n\nIt has multiple paragraphs, and a [link](https://example.com)." + post, err := app.db.CreatePost(u.ID, coll.ID, &SubmittedPost{Title: &title, Content: &content}) + if err != nil { + t.Fatalf("create post: %v", err) + } + + return u, coll, post +} + +// TestTemplateRendering_MeAndBlogPages exercises the authenticated "/me" +// pages and blog rendering across single-user and multi-user instances (the +// latter with Chorus both on and off), asserting nothing fails to render. +func TestTemplateRendering_MeAndBlogPages(t *testing.T) { + configs := []struct { + name string + mutate func(cfg *config.Config) + }{ + { + name: "SingleUser", + mutate: func(cfg *config.Config) { + cfg.App.SingleUser = true + }, + }, + { + name: "MultiUser_ChorusOff", + mutate: func(cfg *config.Config) { + cfg.App.SingleUser = false + cfg.App.Chorus = false + }, + }, + { + name: "MultiUser_ChorusOn", + mutate: func(cfg *config.Config) { + cfg.App.SingleUser = false + cfg.App.Chorus = true + }, + }, + } + + for _, tc := range configs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + app, router := newTemplateTestApp(t, tc.mutate) + u, coll, post := createTemplateTestUser(t, app, "tester") + cookie := loginCookie(t, app, u) + + slug := post.Slug.String + if slug == "" { + slug = post.ID + } + + blogPrefix := "" + if !app.cfg.App.SingleUser { + blogPrefix = "/" + coll.Alias + } + + type pageCase struct { + name string + path string + authed bool + wantStatus int // 0 means "just don't 5xx or fail to render" + } + + cases := []pageCase{ + // Blog rendering + {name: "blog index (anon)", path: blogPrefix + "/", authed: false, wantStatus: http.StatusOK}, + {name: "blog index (owner)", path: blogPrefix + "/", authed: true, wantStatus: http.StatusOK}, + {name: "blog archive (anon)", path: blogPrefix + "/archive/", authed: false, wantStatus: http.StatusOK}, + {name: "post view (anon)", path: blogPrefix + "/" + slug, authed: false, wantStatus: http.StatusOK}, + {name: "post view (owner)", path: blogPrefix + "/" + slug, authed: true, wantStatus: http.StatusOK}, + {name: "post edit (owner)", path: blogPrefix + "/" + slug + "/edit", authed: true, wantStatus: http.StatusOK}, + + // Reader + {name: "reader", path: "/read", authed: false, wantStatus: http.StatusOK}, + + // Authenticated "/me" pages + {name: "me: collections list", path: "/me/c/", authed: true, wantStatus: http.StatusOK}, + {name: "me: edit collection", path: "/me/c/" + coll.Alias, authed: true, wantStatus: http.StatusOK}, + {name: "me: collection stats", path: "/me/c/" + coll.Alias + "/stats", authed: true, wantStatus: http.StatusOK}, + {name: "me: posts list", path: "/me/posts/", authed: true, wantStatus: http.StatusOK}, + {name: "me: export", path: "/me/export", authed: true, wantStatus: http.StatusOK}, + {name: "me: import", path: "/me/import", authed: true, wantStatus: http.StatusOK}, + {name: "me: invites", path: "/me/invites", authed: true, wantStatus: http.StatusOK}, + {name: "me: settings", path: "/me/settings", authed: true, wantStatus: http.StatusOK}, + } + + if app.cfg.App.SingleUser { + cases = append(cases, pageCase{name: "pad: new post", path: "/me/new", authed: true, wantStatus: http.StatusOK}) + } else { + cases = append(cases, pageCase{name: "pad: new post", path: "/new", authed: true, wantStatus: http.StatusOK}) + cases = append(cases, + pageCase{name: "home (anon)", path: "/", authed: false}, + pageCase{name: "home (authed)", path: "/", authed: true}, + ) + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + var cookies []*http.Cookie + if c.authed { + cookies = []*http.Cookie{cookie} + } + assertRendersCleanly(t, router, "GET", c.path, cookies, c.wantStatus) + }) + } + }) + } +}