Files
volumen/internal/admin/settings_routes_test.go
T
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

821 lines
28 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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")
}
}