Files
volumen/internal/admin/setup_test.go
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

237 lines
8.6 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 (
"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")
}
}