// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package admin import ( "encoding/base32" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "sourcedock.dev/petrbalvin/volumen/internal/totp" "sourcedock.dev/petrbalvin/volumen/internal/users" ) func currentCode(t *testing.T, secret string) string { t.Helper() return currentCodeIn(t, secret, 0) } // currentCodeIn computes the code of a neighbouring time step, so a // test can answer twice without tripping the replay floor. func currentCodeIn(t *testing.T, secret string, steps int) string { t.Helper() key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) if err != nil { t.Fatalf("decode secret: %v", err) } return totp.Code(key, time.Now().Add(time.Duration(steps)*totp.Step)) } // loginTo opens the first door and returns the session wherever it // stands: the dashboard, or the second-factor step when the account // has one. func loginTo(t *testing.T, f *fixture, username, secret string) *http.Cookie { t.Helper() get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil)) csrf := extractCSRF(t, get.Body.String()) cookie := sessionCookie(t, get) form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}} req := httptest.NewRequest(http.MethodPost, "/admin/login", 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("login failed: code=%d body=%s", rec.Code, rec.Body.String()) } return sessionCookie(t, rec) } // TestLoginWithSecondFactor walks the whole door: password, code, in, // and the recovery path when the application is lost. func TestLoginWithSecondFactor(t *testing.T) { f := newFixture(t) secret := users.GenerateTotpSecret() codes, hashes := users.GenerateRecoveryCodes(10) if _, err := f.users.EnableTotp("admin", secret, hashes); err != nil { t.Fatal(err) } cookie := loginTo(t, f, "admin", "correct-horse-9") // The password alone no longer opens anything: the admin bounces // to the login, which forwards a pending session to the step. req := httptest.NewRequest(http.MethodGet, "/admin/", nil) req.AddCookie(cookie) if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" { t.Fatalf("password step: code=%d location=%q", rec.Code, rec.Header().Get("Location")) } req = httptest.NewRequest(http.MethodGet, "/admin/login", nil) req.AddCookie(cookie) if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/twofactor" { t.Fatalf("login form forwards pending session: code=%d location=%q", rec.Code, rec.Header().Get("Location")) } req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil) req.AddCookie(cookie) if rec := f.do(t, req); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Verification code") { t.Fatalf("twofactor form: code=%d", rec.Code) } // The direct route redirects anonymous traffic to the first step. req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil) if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" { t.Fatalf("anonymous twofactor: code=%d", rec.Code) } csrf := csrfFromSession(t, f, cookie) // A wrong code is refused and changes nothing. rec := postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie) if rec.Code != http.StatusUnauthorized { t.Fatalf("wrong code: code=%d", rec.Code) } rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" { t.Fatalf("right code: code=%d location=%q", rec.Code, rec.Header().Get("Location")) } cookie = sessionCookie(t, rec) req = httptest.NewRequest(http.MethodGet, "/admin/", nil) req.AddCookie(cookie) if rec := f.do(t, req); rec.Code != http.StatusOK { t.Fatalf("dashboard after second factor: %d", rec.Code) } // The recovery path: sign out, in again, spend one code; the same // code never works twice. postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie) cookie = loginTo(t, f, "admin", "correct-horse-9") csrf = csrfFromSession(t, f, cookie) rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" { t.Fatalf("recovery code: code=%d location=%q", rec.Code, rec.Header().Get("Location")) } cookie = sessionCookie(t, rec) postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie) cookie = loginTo(t, f, "admin", "correct-horse-9") csrf = csrfFromSession(t, f, cookie) rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie) if rec.Code != http.StatusUnauthorized { t.Fatalf("reused recovery code: code=%d", rec.Code) } // The typed shapes humans use still work. rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {strings.ReplaceAll(codes[1], "-", " ")}}, cookie) if rec.Code != http.StatusSeeOther { t.Fatalf("spaced recovery code: code=%d", rec.Code) } } // TestTotpEnrolment drives the settings flow: start, the QR page, the // verifying code, the one-time recovery codes, and turning it off. func TestTotpEnrolment(t *testing.T) { f := newFixture(t) cookie := login(t, f, "admin", "correct-horse-9") csrf := csrfFromSession(t, f, cookie) // Before anything, the settings page offers the setup. req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil) req.AddCookie(cookie) body := f.do(t, req).Body.String() if !strings.Contains(body, "Set up two-factor") { t.Fatal("setup offer missing") } // Start shows the QR and the secret, and stores nothing yet. The // candidate rides the cookie, so the jar moves on with it. rec := postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie) if rec.Code != http.StatusSeeOther { t.Fatalf("start: %d", rec.Code) } cookie = sessionCookie(t, rec) req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil) req.AddCookie(cookie) body = f.do(t, req).Body.String() if !strings.Contains(body, "totp__qr") || !strings.Contains(body, `") k := strings.Index(rest[j:], "<") secret := rest[j+1 : j+k] if len(secret) < 26 { t.Fatalf("secret looks wrong: %q", secret) } rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie) if rec.Code != http.StatusOK { t.Fatalf("verify: %d body=%s", rec.Code, rec.Body.String()[:200]) } page := rec.Body.String() if !strings.Contains(page, "recovery__code") { t.Fatal("recovery codes not shown once") } if f.users.Find("admin").TotpSecret == "" { t.Fatal("enabled secret not stored") } // The next sign-in needs the second factor. postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie) cookie = loginTo(t, f, "admin", "correct-horse-9") req = httptest.NewRequest(http.MethodGet, "/admin/login", nil) req.AddCookie(cookie) if rec := f.do(t, req); rec.Header().Get("Location") != "/admin/twofactor" { t.Fatalf("second factor not asked: %q", rec.Header().Get("Location")) } // Turning it off asks for a current code. csrf = csrfFromSession(t, f, cookie) rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie) if rec.Code != http.StatusSeeOther { t.Fatalf("sign-in code: %d", rec.Code) } cookie = sessionCookie(t, rec) rec = postForm(t, f, "/admin/settings/twofactor/disable", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("disable with wrong code: %d", rec.Code) } // The sign-in already spent this window's code: the next window's // code answers, the spent one must not. rec = postForm(t, f, "/admin/settings/twofactor/disable", url.Values{"_csrf": {csrf}, "code": {currentCodeIn(t, secret, 1)}}, cookie) if rec.Code != http.StatusOK { t.Fatalf("disable: %d", rec.Code) } if f.users.Find("admin").TotpSecret != "" { t.Fatal("secret survived disable") } }