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,236 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||||
)
|
||||
|
||||
// A stale anonymous preview cookie (a leftover of an abandoned or reset
|
||||
// setup) must not greet the operator on the wizard: the first run opens
|
||||
// on the clean defaults.
|
||||
func TestSetupFormIgnoresPreviewCookies(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/setup", nil)
|
||||
req.AddCookie(&http.Cookie{Name: i18n.Cookie, Value: "cs"})
|
||||
req.AddCookie(&http.Cookie{Name: web.ThemeCookie, Value: "magma"})
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `<html lang="en" data-palette="viridis">`) {
|
||||
t.Fatalf("wizard did not open on the clean defaults:\n%s", body[:min(len(body), 400)])
|
||||
}
|
||||
// The response expires both preview cookies so later screens are clean too.
|
||||
var sawLang, sawTheme bool
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == i18n.Cookie && c.MaxAge < 0 {
|
||||
sawLang = true
|
||||
}
|
||||
if c.Name == web.ThemeCookie && c.MaxAge < 0 {
|
||||
sawTheme = true
|
||||
}
|
||||
}
|
||||
if !sawLang || !sawTheme {
|
||||
t.Fatalf("preview cookies not expired on the wizard response: %v", rec.Result().Cookies())
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no accounts shows the wizard in place of the login
|
||||
// screen; the login URL itself redirects, so an old bookmark lands in
|
||||
// the right place too.
|
||||
func TestLoginFormRedirectsToWizard(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/setup" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupFormServesWhileNoAccounts(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Welcome to Volumen", "name=\"password\"", `name="theme"`, `name="language"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The chips are real links, so the language is a server-side choice
|
||||
// too: ?lang renders the whole page in it and the hidden field carries
|
||||
// it into the account.
|
||||
func TestSetupFormHonoursLangQuery(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup?lang=cs", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{`<html lang="cs"`, "Účet", `name="language" id="setup-language" value="cs"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
// The bilingual bundle rides along for the client-side swap.
|
||||
if !strings.Contains(body, "Vítejte ve Volumenu") {
|
||||
t.Fatal("the language bundle is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupFormRetiresWhenAccountsExist(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// The wizard creates the first account, keeps the language and the
|
||||
// colour scheme with it, and signs the operator in on the same trip.
|
||||
func TestSetupCreatesAndSignsIn(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf},
|
||||
"username": {"balvin"},
|
||||
"name": {"Petr Balvín"},
|
||||
"password": {"a-genuinely-unique-passphrase"},
|
||||
"language": {"cs"},
|
||||
"theme": {"plasma"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
||||
t.Fatalf("code=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||
}
|
||||
user := f.users.Find("balvin")
|
||||
if user == nil || user.Role != "admin" {
|
||||
t.Fatalf("first account missing: %v", user)
|
||||
}
|
||||
if user.Language != "cs" || user.Theme != "plasma" {
|
||||
t.Fatalf("wizard choices not stored: lang=%q theme=%q", user.Language, user.Theme)
|
||||
}
|
||||
if user.Name != "Petr Balvín" {
|
||||
t.Fatalf("display name = %q", user.Name)
|
||||
}
|
||||
|
||||
// The session cookie from the wizard opens the dashboard: the
|
||||
// operator is signed in, not sent back through the login.
|
||||
authed := sessionCookie(t, rec)
|
||||
dash := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
dash.AddCookie(authed)
|
||||
if rec := f.do(t, dash); rec.Code != http.StatusOK {
|
||||
t.Fatalf("dashboard after setup: code = %d", rec.Code)
|
||||
}
|
||||
|
||||
// A second claim of the same wizard is refused and pointed at the
|
||||
// login: the installation has exactly one first account. The CSRF
|
||||
// token rides the same session, so the refusal comes from the
|
||||
// accounts already existing, not from the form.
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req2.AddCookie(authed)
|
||||
rec2 := f.do(t, req2)
|
||||
if rec2.Code != http.StatusSeeOther || rec2.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("second claim: code=%d location=%q", rec2.Code, rec2.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// The wizard POST validates with the same server-side rules every
|
||||
// password change uses: the page meter is advice, this is the gate.
|
||||
func TestSetupRejectsWeakPasswordAndBadCSRF(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf},
|
||||
"username": {"admin"},
|
||||
"password": {"short"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("weak password: code = %d", rec.Code)
|
||||
}
|
||||
if f.users.Any() {
|
||||
t.Fatal("a refused wizard still created an account")
|
||||
}
|
||||
|
||||
noCSRF := url.Values{"username": {"admin"}, "password": {"a-genuinely-unique-passphrase"}}
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(noCSRF.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("missing CSRF: code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRejectsBadUsername(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf},
|
||||
"username": {"not a name!"},
|
||||
"password": {"a-genuinely-unique-passphrase"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("bad username: code = %d", rec.Code)
|
||||
}
|
||||
if f.users.Any() {
|
||||
t.Fatal("a refused username still created an account")
|
||||
}
|
||||
}
|
||||
|
||||
// The default username is admin, and an empty field gets it: the form
|
||||
// starts filled, a submit that cleared it still lands on a valid name.
|
||||
func TestSetupDefaultsUsername(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf},
|
||||
"username": {""},
|
||||
"password": {"a-genuinely-unique-passphrase"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("empty username: code = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if f.users.Find("admin") == nil {
|
||||
t.Fatal("the empty username field did not fall back to admin")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user