// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package admin import ( "bytes" "maps" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync/atomic" "testing" "sourcedock.dev/petrbalvin/volumen/internal/config" "sourcedock.dev/petrbalvin/volumen/internal/web" "sourcedock.dev/petrbalvin/volumen/internal/webhooks" ) func settingsForm(t *testing.T, f *fixture, path string, extra url.Values) *httptest.ResponseRecorder { t.Helper() cookie := login(t, f, "admin", "correct-horse-9") csrf := csrfFromSession(t, f, cookie) form := url.Values{"_csrf": {csrf}} maps.Copy(form, extra) return postForm(t, f, path, form, cookie) } func TestSettingsPageRenders(t *testing.T) { f := newFixture(t) cookie := login(t, f, "admin", "correct-horse-9") req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil) req.AddCookie(cookie) rec := f.do(t, req) if rec.Code != http.StatusOK { t.Fatalf("code = %d", rec.Code) } body := rec.Body.String() for _, want := range []string{ "Settings", "Account", "Users", "Templates", "Backup", "API tokens", "Webhooks", "admin", } { if !strings.Contains(body, want) { t.Fatalf("missing %q", want) } } // Every field that sets a password carries the strength meter: the // own-account change and the add-user form are on the page itself, // the per-user reset form rides each non-self user row. The floor // attribute rides the same inputs and nowhere else on the page. meters := strings.Count(body, `data-min-length=`) wantMeters := 2 for _, row := range f.admin.deps.Users.All() { if row.Username != "admin" { wantMeters++ } } if meters != wantMeters { t.Fatalf("password meters = %d, want %d", meters, wantMeters) } if !strings.Contains(body, `class="pw-level__bar"`) { t.Fatal("meter bar markup missing") } } func TestPasswordChange(t *testing.T) { f := newFixture(t) // Wrong current password. rec := settingsForm(t, f, "/admin/settings/password", url.Values{ "current_password": {"nope"}, "new_password": {"another-good-pass"}, }) if !strings.Contains(rec.Body.String(), "Current password is incorrect.") { t.Fatal("wrong-password message missing") } // Weak new password. rec = settingsForm(t, f, "/admin/settings/password", url.Values{ "current_password": {"correct-horse-9"}, "new_password": {"short"}, }) if !strings.Contains(rec.Body.String(), "at least") { t.Fatal("policy message missing") } // Success. rec = settingsForm(t, f, "/admin/settings/password", url.Values{ "current_password": {"correct-horse-9"}, "new_password": {"a-brand-new-passphrase"}, }) if !strings.Contains(rec.Body.String(), "Password updated.") { t.Fatal("success message missing") } if f.users.Authenticate("admin", "a-brand-new-passphrase") == nil { t.Fatal("new password does not authenticate") } } func TestUsernameChangeUpdatesSession(t *testing.T) { f := newFixture(t) cookie := login(t, f, "admin", "correct-horse-9") csrf := csrfFromSession(t, f, cookie) rec := postForm(t, f, "/admin/settings/username", url.Values{"_csrf": {csrf}, "username": {"bad name!"}}, cookie) if !strings.Contains(rec.Body.String(), "letters, numbers") { t.Fatal("format message missing") } rec = postForm(t, f, "/admin/settings/username", url.Values{"_csrf": {csrf}, "username": {"petr"}}, cookie) if !strings.Contains(rec.Body.String(), "Username updated.") { t.Fatalf("body = %s", rec.Body.String()) } if f.users.Find("petr") == nil { t.Fatal("rename not applied") } // The session cookie was re-signed with the new username. newCookie := sessionCookie(t, rec) req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil) req.AddCookie(newCookie) rec = f.do(t, req) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `value="petr"`) { t.Fatalf("session lost after rename: code=%d", rec.Code) } } func TestThemeChange(t *testing.T) { f := newFixture(t) rec := settingsForm(t, f, "/admin/settings/theme", url.Values{"theme": {"plasma"}}) if !strings.Contains(rec.Body.String(), "The colour scheme is set.") { t.Fatalf("body = %s", rec.Body.String()) } if f.users.Find("admin").Theme != "plasma" { t.Fatal("theme not stored on the account") } found := false for _, c := range rec.Result().Cookies() { if c.Name == web.ThemeCookie && c.Value == "plasma" { found = true } } if !found { t.Fatal("theme cookie missing") } // The picker re-renders with the choice marked pressed. if !strings.Contains(rec.Body.String(), `value="plasma" class="chip" aria-pressed="true"`) { t.Fatal("picked scheme not marked active") } // An unknown scheme is refused and does not overwrite the choice. rec = settingsForm(t, f, "/admin/settings/theme", url.Values{"theme": {"sepia"}}) if !strings.Contains(rec.Body.String(), "Unsupported colour scheme.") { t.Fatalf("body = %s", rec.Body.String()) } if f.users.Find("admin").Theme != "plasma" { t.Fatal("invalid scheme overwrote the stored choice") } } func TestNameAndFediverseChange(t *testing.T) { f := newFixture(t) rec := settingsForm(t, f, "/admin/settings/name", url.Values{"name": {"Petr Balvín"}}) if !strings.Contains(rec.Body.String(), "Display name updated.") { t.Fatal("name message missing") } rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {"nope"}}) if !strings.Contains(rec.Body.String(), "@user@host") { t.Fatal("fediverse validation missing") } rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {"@petr@social"}}) if !strings.Contains(rec.Body.String(), "Fediverse handle updated.") { t.Fatal("fediverse message missing") } rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {""}}) if !strings.Contains(rec.Body.String(), "Fediverse handle cleared.") { t.Fatal("fediverse clear missing") } } func TestOrcidChange(t *testing.T) { f := newFixture(t) // A malformed iD is refused and nothing is stored. rec := settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {"0000-0002-1825-0098"}}) if !strings.Contains(rec.Body.String(), "ORCID must look like") { t.Fatalf("orcid validation missing: %s", rec.Body.String()) } if f.users.Find("admin").Orcid != "" { t.Fatal("invalid orcid was stored") } // A valid iD is kept, normalised to upper case. rec = settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {"0000-0002-1825-0097"}}) if !strings.Contains(rec.Body.String(), "ORCID updated.") { t.Fatal("orcid message missing") } if got := f.users.Find("admin").Orcid; got != "0000-0002-1825-0097" { t.Fatalf("stored orcid = %q", got) } // An empty value clears it. rec = settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {""}}) if !strings.Contains(rec.Body.String(), "ORCID cleared.") { t.Fatal("orcid clear missing") } if got := f.users.Find("admin").Orcid; got != "" { t.Fatalf("orcid not cleared: %q", got) } } // A password an admin sets keeps its edge spaces: only the emptiness // check may trim, the stored value must not, or the trimmed form would // work where the typed one does not. func TestUserCreateKeepsPasswordSpaces(t *testing.T) { f := newFixture(t) rec := settingsForm(t, f, "/admin/settings/users", url.Values{ "username": {"joe"}, "password": {" padded-passphrase "}, "role": {"author"}, }) if !strings.Contains(rec.Body.String(), "User added.") { t.Fatalf("body = %s", rec.Body.String()) } if f.users.Authenticate("joe", " padded-passphrase ") == nil { t.Fatal("the exact password, spaces included, must authenticate") } if f.users.Authenticate("joe", "padded-passphrase") != nil { t.Fatal("the trimmed password must not authenticate") } } func TestUserManagement(t *testing.T) { f := newFixture(t) // Create a second user. rec := settingsForm(t, f, "/admin/settings/users", url.Values{ "username": {"joe"}, "password": {"joes-good-passphrase"}, "role": {"author"}, }) if !strings.Contains(rec.Body.String(), "User added.") { t.Fatalf("body = %s", rec.Body.String()) } if f.users.Find("joe") == nil { t.Fatal("user not created") } // Duplicate rejected. rec = settingsForm(t, f, "/admin/settings/users", url.Values{ "username": {"joe"}, "password": {"joes-good-passphrase"}, "role": {"author"}, }) if !strings.Contains(rec.Body.String(), "could not be added") { t.Fatal("duplicate message missing") } // Role change. cookie := login(t, f, "admin", "correct-horse-9") csrf := csrfFromSession(t, f, cookie) rec = postForm(t, f, "/admin/settings/users/joe/role", url.Values{"_csrf": {csrf}, "role": {"admin"}}, cookie) if !strings.Contains(rec.Body.String(), "Role updated.") { t.Fatalf("body = %s", rec.Body.String()) } if f.users.Find("joe").Role != "admin" { t.Fatal("role not applied") } // Own role cannot change. rec = postForm(t, f, "/admin/settings/users/admin/role", url.Values{"_csrf": {csrf}, "role": {"author"}}, cookie) if !strings.Contains(rec.Body.String(), "own role") { t.Fatal("self role-change not blocked") } // Delete. rec = postForm(t, f, "/admin/settings/users/joe/delete", url.Values{"_csrf": {csrf}}, cookie) if !strings.Contains(rec.Body.String(), "User removed.") { t.Fatalf("body = %s", rec.Body.String()) } if f.users.Find("joe") != nil { t.Fatal("user not deleted") } // Own account cannot be deleted. rec = postForm(t, f, "/admin/settings/users/admin/delete", url.Values{"_csrf": {csrf}}, cookie) if !strings.Contains(rec.Body.String(), "own account") { t.Fatal("self delete not blocked") } } func TestUserManagementRequiresAdmin(t *testing.T) { f := newFixture(t) f.users.Add("joe", "joes-good-passphrase", "author") cookie := login(t, f, "joe", "joes-good-passphrase") csrf := csrfFromSession(t, f, cookie) req := httptest.NewRequest(http.MethodPost, "/admin/settings/users", strings.NewReader(url.Values{ "_csrf": {csrf}, "username": {"x"}, "password": {"good-enough-pass"}, }.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) if rec := f.do(t, req); rec.Code != http.StatusForbidden { t.Fatalf("code = %d, want 403", rec.Code) } } func TestTemplateCRUD(t *testing.T) { f := newFixture(t) rec := settingsForm(t, f, "/admin/settings/templates", url.Values{ "name": {"Review"}, "tags": {"review, opinion"}, "body": {"## Summary"}, }) if !strings.Contains(rec.Body.String(), "Template added.") { t.Fatalf("body = %s", rec.Body.String()) } if len(f.admin.deps.Templates.All()) != 1 { t.Fatal("template not stored") } rec = settingsForm(t, f, "/admin/settings/templates", url.Values{"name": {"Review"}}) if !strings.Contains(rec.Body.String(), "could not be added") { t.Fatal("duplicate message missing") } // A fields box pre-fills the scientific editor inputs; the values are // TOML, so strings are quoted and a bare number arrives as text. rec = settingsForm(t, f, "/admin/settings/templates", url.Values{ "name": {"Paper"}, "fields": {"series = \"tds\"\ndoi = \"10.5281/zenodo.1\"\nseries_order = 3\n"}, }) if !strings.Contains(rec.Body.String(), "Template added.") { t.Fatalf("fields template rejected: %s", rec.Body.String()) } var paperFields map[string]string for _, tpl := range f.admin.deps.Templates.All() { if tpl.Name == "Paper" { paperFields = tpl.Fields } } if paperFields["series"] != "tds" || paperFields["doi"] != "10.5281/zenodo.1" || paperFields["series_order"] != "3" { t.Fatalf("stored fields = %v", paperFields) } // A key outside the editor's inputs is refused, so a typo cannot // silently seed every new post with a dead key. rec = settingsForm(t, f, "/admin/settings/templates", url.Values{ "name": {"Nope"}, "fields": {"journal = \"Nature\"\n"}, }) if !strings.Contains(rec.Body.String(), "Unknown template field") { t.Fatal("unknown field accepted") } rec = settingsForm(t, f, "/admin/settings/templates", url.Values{ "name": {"Broken"}, "fields": {"series == tds\n\n("}, }) if !strings.Contains(rec.Body.String(), "must be key = value") { t.Fatal("unparsable fields accepted") } // The new-post form embeds the templates with the lowercase keys its // picker reads, fields included. cookie := login(t, f, "admin", "correct-horse-9") newReq := httptest.NewRequest(http.MethodGet, "/admin/posts/new", nil) newReq.AddCookie(cookie) rec = f.do(t, newReq) if !strings.Contains(rec.Body.String(), `"fields":{`) || !strings.Contains(rec.Body.String(), `"series":"tds"`) { t.Fatal("template fields missing from the editor payload") } csrf := csrfFromSession(t, f, cookie) rec = postForm(t, f, "/admin/settings/templates/Review/delete", url.Values{"_csrf": {csrf}}, cookie) if !strings.Contains(rec.Body.String(), "Template deleted.") { t.Fatalf("body = %s", rec.Body.String()) } } func TestTokenCRUD(t *testing.T) { f := newFixture(t) cookie := login(t, f, "admin", "correct-horse-9") csrf := csrfFromSession(t, f, cookie) rec := postForm(t, f, "/admin/settings/tokens", url.Values{"_csrf": {csrf}, "name": {"ci"}}, cookie) body := rec.Body.String() if !strings.Contains(body, "Copy this token now") || !strings.Contains(body, "vol_") { t.Fatalf("new token not shown: %s", body) } rec = postForm(t, f, "/admin/settings/tokens/ci/delete", url.Values{"_csrf": {csrf}}, cookie) if !strings.Contains(rec.Body.String(), "Token revoked.") { t.Fatalf("body = %s", rec.Body.String()) } if len(f.admin.deps.Tokens.All()) != 0 { t.Fatal("token not revoked") } } func TestBackupExportImport(t *testing.T) { f := newFixture(t) writeTestPost(t, f, "keep.md", "+++\nslug = \"keep\"\ntitle = \"Keep\"\n+++\nbody\n") cookie := login(t, f, "admin", "correct-horse-9") req := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil) req.AddCookie(cookie) rec := f.do(t, req) if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/gzip" { t.Fatalf("code=%d type=%q", rec.Code, rec.Header().Get("Content-Type")) } archive := rec.Body.Bytes() if len(archive) == 0 { t.Fatal("empty archive") } // Wipe the content dir, then restore. if err := os.Remove(filepath.Join(f.contentDir, "keep.md")); err != nil { t.Fatalf("remove: %v", err) } csrf := csrfFromSession(t, f, cookie) rec = multipartBytes(t, f, "/admin/settings/import", cookie, csrf, "backup", archive) if !strings.Contains(rec.Body.String(), "Backup restored") { t.Fatalf("body = %s", rec.Body.String()) } if f.storeObj.Find("keep", "") == nil { t.Fatal("post not restored from backup") } } func TestCheckUpdateWithoutWiring(t *testing.T) { f := newFixture(t) rec := settingsForm(t, f, "/admin/settings/check-update", nil) if !strings.Contains(rec.Body.String(), "not available in this build") { t.Fatalf("body = %s", rec.Body.String()) } } func TestMediaLibraryAndDelete(t *testing.T) { f := newFixture(t) webpData := append([]byte("RIFF"), 0, 0, 0, 0) webpData = append(webpData, []byte("WEBPVP8 ")...) uploaded, err := f.storeObj.StoreUpload("pic.webp", webpData) if err != nil { t.Fatalf("upload: %v", err) } name := strings.TrimPrefix(uploaded, "/media/") cookie := login(t, f, "admin", "correct-horse-9") req := httptest.NewRequest(http.MethodGet, "/admin/media", nil) req.AddCookie(cookie) rec := f.do(t, req) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), name) { t.Fatalf("code=%d", rec.Code) } csrf := csrfFromSession(t, f, cookie) rec = postForm(t, f, "/admin/media/"+name+"/delete", url.Values{"_csrf": {csrf}}, cookie) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/media" { t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location")) } if _, err := f.storeObj.MediaPath(name); err == nil { t.Fatal("media not deleted") } rec = postForm(t, f, "/admin/media/ghost.webp/delete", url.Values{"_csrf": {csrf}}, cookie) if rec.Code != http.StatusNotFound { t.Fatalf("code = %d", rec.Code) } } // multipartBytes posts a binary file field. func multipartBytes(t *testing.T, f *fixture, path string, cookie *http.Cookie, csrf, field string, data []byte) *httptest.ResponseRecorder { t.Helper() body, contentType := buildMultipart(t, csrf, field, "backup.tar.gz", data) req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("Content-Type", contentType) req.AddCookie(cookie) return f.do(t, req) } // buildMultipart renders a single-file multipart body. func buildMultipart(t *testing.T, csrf, field, filename string, data []byte) (string, string) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) _ = mw.WriteField("_csrf", csrf) part, err := mw.CreateFormFile(field, filename) if err != nil { t.Fatalf("create form file: %v", err) } if _, err := part.Write(data); err != nil { t.Fatalf("write part: %v", err) } _ = mw.Close() return buf.String(), mw.FormDataContentType() } func TestPhotoUploadAndRemove(t *testing.T) { f := newFixture(t) cookie := login(t, f, "admin", "correct-horse-9") csrf := csrfFromSession(t, f, cookie) webpData := append([]byte("RIFF"), 0, 0, 0, 0) webpData = append(webpData, []byte("WEBPVP8 ")...) body, contentType := buildMultipart(t, csrf, "photo", "me.webp", webpData) req := httptest.NewRequest(http.MethodPost, "/admin/settings/photo", strings.NewReader(body)) req.Header.Set("Content-Type", contentType) req.AddCookie(cookie) rec := f.do(t, req) if !strings.Contains(rec.Body.String(), "Profile photo updated.") { t.Fatalf("body = %s", rec.Body.String()) } if f.users.Find("admin").Photo == "" { t.Fatal("photo not stored on the user") } rec = postForm(t, f, "/admin/settings/photo/remove", url.Values{"_csrf": {csrf}}, cookie) if !strings.Contains(rec.Body.String(), "Profile photo removed.") { t.Fatalf("body = %s", rec.Body.String()) } if f.users.Find("admin").Photo != "" { t.Fatal("photo not cleared") } } func TestWebhookTestDelivery(t *testing.T) { var hits int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { atomic.AddInt32(&hits, 1) })) defer srv.Close() f := newFixture(t) f.admin.deps.Config.Webhooks = []config.Webhook{{URL: srv.URL}} f.admin.deps.Webhooks = webhooks.NewManager( []webhooks.Webhook{{URL: srv.URL, Enabled: true}}, "0.0.0-test") rec := settingsForm(t, f, "/admin/settings/webhooks/0/test", nil) if !strings.Contains(rec.Body.String(), "Test delivery sent.") { t.Fatalf("body = %s", rec.Body.String()) } if atomic.LoadInt32(&hits) != 1 { t.Fatalf("hits = %d", hits) } rec = settingsForm(t, f, "/admin/settings/webhooks/9/test", nil) if !strings.Contains(rec.Body.String(), "Webhook not found.") { t.Fatalf("body = %s", rec.Body.String()) } } func TestUpdateHooksFlow(t *testing.T) { f := newFixture(t) f.admin.SetUpdateHooks(func() (string, error) { return "9.9.9", nil }, func() (string, error) { return "9.9.9", nil }) // Banner appears on the dashboard. cookie := login(t, f, "admin", "correct-horse-9") req := httptest.NewRequest(http.MethodGet, "/admin/", nil) req.AddCookie(cookie) rec := f.do(t, req) if !strings.Contains(rec.Body.String(), "is available") { t.Fatal("update banner missing") } csrf := csrfFromSession(t, f, cookie) rec = postForm(t, f, "/admin/settings/update", url.Values{"_csrf": {csrf}}, cookie) // The version is printed as the toolchain recorded it, prefix included. if !strings.Contains(rec.Body.String(), "9.9.9") { t.Fatalf("update page body = %s", rec.Body.String()) } f.admin.SetUpdateHooks(func() (string, error) { return "", nil }, nil) rec = settingsForm(t, f, "/admin/settings/check-update", nil) if !strings.Contains(rec.Body.String(), "already the latest release") { t.Fatalf("body = %s", rec.Body.String()) } } func TestTokenCreateWithScopes(t *testing.T) { f := newFixture(t) cookie := login(t, f, "admin", "correct-horse-9") csrf := csrfFromSession(t, f, cookie) form := url.Values{"_csrf": {csrf}, "name": {"scoped"}, "scope": {"write", "delete"}} rec := postForm(t, f, "/admin/settings/tokens", form, cookie) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "vol_") { t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String()) } list := f.admin.deps.Tokens.All() if len(list) != 1 { t.Fatalf("tokens = %v", list) } if len(list[0].Scopes) != 2 || !list[0].HasScope("write") || !list[0].HasScope("delete") { t.Fatalf("scopes = %v", list[0].Scopes) } if list[0].HasScope("read") { t.Fatal("the removed read scope was granted") } } func TestEditorSaveKeepsUnknownMetadata(t *testing.T) { f := newFixture(t) writeTestPost(t, f, "aliased.md", `+++ title = "Aliased" slug = "aliased" aliases = ["old-slug"] custom_field = "keep me" [translations] en = "aliased-en" +++ body `) cookie := login(t, f, "admin", "correct-horse-9") csrf := csrfFromSession(t, f, cookie) rec := postForm(t, f, "/admin/posts/aliased", url.Values{ "_csrf": {csrf}, "title": {"Aliased v2"}, "slug": {"aliased"}, "lang": {"cs"}, "body": {"new body"}, }, cookie) if rec.Code != http.StatusSeeOther { t.Fatalf("code = %d", rec.Code) } p := f.storeObj.Find("aliased", "") if p == nil { t.Fatal("post lost") } if got := p.Aliases(); len(got) != 1 || got[0] != "old-slug" { t.Fatalf("aliases lost: %v", got) } if got := p.Translations(); got["en"] != "aliased-en" { t.Fatalf("translations lost: %v", got) } if _, ok := p.Metadata.Get("custom_field"); !ok { t.Fatal("custom field lost") } if p.Title() != "Aliased v2" { t.Fatalf("title = %q", p.Title()) } } // A webhook added in Settings lands in webhooks.toml and reaches the // manager without a restart; a config-declared hook stays read-only. func TestSettingsWebhookLifecycle(t *testing.T) { f := newFixture(t) f.admin.deps.WebhooksFile = filepath.Join(t.TempDir(), "webhooks.toml") f.admin.deps.Webhooks = webhooks.NewManager(nil, "t") f.admin.deps.StaticWebhooks = []webhooks.Webhook{{URL: "https://cfg.example/hook", Enabled: true}} f.admin.deps.Webhooks.SetHooks(f.admin.deps.StaticWebhooks) // A config hook renders as read-only: no toggle form for it. cookie := login(t, f, "admin", "correct-horse-9") req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil) req.AddCookie(cookie) rec := f.do(t, req) if !strings.Contains(rec.Body.String(), "https://cfg.example/hook") || strings.Contains(rec.Body.String(), "Remove this webhook?") { t.Fatalf("config hook row wrong: %d", rec.Code) } // Add one. rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{ "url": {"https://example.com/hook"}, "secret": {"s3cret"}, "events": {"post.created, post.updated"}, "enabled": {"on"}, }) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Webhook added.") { t.Fatalf("add: %d %s", rec.Code, rec.Body.String()) } stored, err := webhooks.LoadFile(f.admin.deps.WebhooksFile) if err != nil || len(stored) != 1 || stored[0].URL != "https://example.com/hook" || stored[0].Secret != "s3cret" || !stored[0].Enabled || len(stored[0].Events) != 2 { t.Fatalf("stored = %+v err = %v", stored, err) } hooks := f.admin.deps.Webhooks.Hooks() if len(hooks) != 2 || hooks[0].URL != "https://cfg.example/hook" || hooks[1].URL != "https://example.com/hook" { t.Fatalf("manager = %+v", hooks) } // A duplicate URL and a broken URL are refused. rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{ "url": {"https://example.com/hook"}, "enabled": {"on"}, }) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "already configured") { t.Fatalf("duplicate: %d %s", rec.Code, rec.Body.String()) } rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{ "url": {"ftp://example.com/hook"}, "enabled": {"on"}, }) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "not a valid") { t.Fatalf("invalid url: %d %s", rec.Code, rec.Body.String()) } // Toggle flips the stored flag and the manager's. rec = settingsForm(t, f, "/admin/settings/webhooks/toggle", url.Values{ "url": {"https://example.com/hook"}, }) if rec.Code != http.StatusOK { t.Fatalf("toggle: %d %s", rec.Code, rec.Body.String()) } stored, _ = webhooks.LoadFile(f.admin.deps.WebhooksFile) if stored[0].Enabled { t.Fatal("toggle did not disable the hook") } if f.admin.deps.Webhooks.Hooks()[1].Enabled { t.Fatal("manager kept the hook enabled") } // A config-declared URL is not toggleable. rec = settingsForm(t, f, "/admin/settings/webhooks/toggle", url.Values{ "url": {"https://cfg.example/hook"}, }) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Webhook not found.") { t.Fatalf("config hook toggle: %d %s", rec.Code, rec.Body.String()) } // Delete removes the hook from the file and the manager. rec = settingsForm(t, f, "/admin/settings/webhooks/delete", url.Values{ "url": {"https://example.com/hook"}, }) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Webhook removed.") { t.Fatalf("delete: %d %s", rec.Code, rec.Body.String()) } stored, _ = webhooks.LoadFile(f.admin.deps.WebhooksFile) if len(stored) != 0 { t.Fatalf("store = %+v", stored) } if len(f.admin.deps.Webhooks.Hooks()) != 1 { t.Fatalf("manager = %+v", f.admin.deps.Webhooks.Hooks()) } } func TestOversizedBodyRejected(t *testing.T) { f := newFixture(t) cookie := login(t, f, "admin", "correct-horse-9") csrf := csrfFromSession(t, f, cookie) huge := strings.Repeat("a", 1_048_577) rec := postForm(t, f, "/admin/posts", url.Values{ "_csrf": {csrf}, "title": {"Big"}, "slug": {"big"}, "body": {huge}, }, cookie) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("code = %d, want 422", rec.Code) } if !strings.Contains(rec.Body.String(), "at most 1048576 bytes") { t.Fatal("size message missing") } } // A changed password retires every session issued before it: the cookie // carries a fingerprint of the hash, and only the device the change was // made on gets re-bound. func TestPasswordChangeSignsOutOtherSessions(t *testing.T) { f := newFixture(t) first := login(t, f, "admin", "correct-horse-9") second := login(t, f, "admin", "correct-horse-9") csrf := csrfFromSession(t, f, first) rec := postForm(t, f, "/admin/settings/password", url.Values{ "_csrf": {csrf}, "current_password": {"correct-horse-9"}, "new_password": {"new-good-passphrase"}, }, first) if !strings.Contains(rec.Body.String(), "Password updated.") { t.Fatalf("body = %s", rec.Body.String()) } // The change re-signs this device.s session; the cookie to test // with is the one the response just set. first = sessionCookie(t, rec) // The other device.s session is dead. req := httptest.NewRequest(http.MethodGet, "/admin/", nil) req.AddCookie(second) if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" { t.Fatalf("other session survived: %d %s", rec.Code, rec.Header().Get("Location")) } // The device the change was made on stays signed in. req = httptest.NewRequest(http.MethodGet, "/admin/", nil) req.AddCookie(first) if rec := f.do(t, req); rec.Code != http.StatusOK { t.Fatalf("current session died: %d", rec.Code) } // The old password no longer signs in; the new one does. if f.users.Authenticate("admin", "correct-horse-9") != nil { t.Fatal("the old password still authenticates") } if f.users.Authenticate("admin", "new-good-passphrase") == nil { t.Fatal("the new password does not authenticate") } } // An admin can reset another account's password; the account's sessions // die with it, and the admin cannot shortcut their own current-password // check through the route. func TestAdminPasswordReset(t *testing.T) { f := newFixture(t) if _, err := f.users.Add("author", "authors-good-passphrase", "author"); err != nil { t.Fatalf("author not created: %v", err) } authorCookie := login(t, f, "author", "authors-good-passphrase") // Self-reset is refused. rec := settingsForm(t, f, "/admin/settings/users/admin/password", url.Values{"password": {"shortcut-passphrase"}}) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "own password") { t.Fatalf("self reset not blocked: %d %s", rec.Code, rec.Body.String()) } // Reset the author's password. rec = settingsForm(t, f, "/admin/settings/users/author/password", url.Values{"password": {"reset-passphrase-9"}}) if !strings.Contains(rec.Body.String(), "sessions were signed out") { t.Fatalf("body = %s", rec.Body.String()) } // The author's session is dead, and the new password works. req := httptest.NewRequest(http.MethodGet, "/admin/", nil) req.AddCookie(authorCookie) if rec := f.do(t, req); rec.Code != http.StatusSeeOther { t.Fatalf("author session survived the reset: %d", rec.Code) } if f.users.Authenticate("author", "reset-passphrase-9") == nil { t.Fatal("the reset password does not authenticate") } }