Initial commit
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
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
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user