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