Files
member-console/internal/server/error_page_test.go
T
cgalo5758 0b28a9dc29 Remediate security audit findings
- Replace gorilla/csrf with net/http CrossOriginProtection
- Require valkey-password and add TLS options for session store
- End session at /logout and revoke refresh tokens
- Re-derive identity and roles from provider every five minutes
- Process each Stripe webhook event in its own Temporal workflow
- Give each outbox entry its own workflow with Temporal retries
- Guard against stale Stripe events with provider timestamps
- Derive transport security from base-url scheme
2026-09-09 13:25:43 -05:00

206 lines
7.0 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"html/template"
"io/fs"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
func errorPageTemplates(t *testing.T) *SafeTemplates {
t.Helper()
sub, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
t.Fatalf("sub templates FS: %v", err)
}
tmpl, err := template.New("root").Funcs(template.FuncMap{
"deploymentName": config.DeploymentName,
}).ParseFS(sub, "error.html")
if err != nil {
t.Fatalf("parse error.html: %v", err)
}
tmpl = template.Must(web.ParseUIPartials(tmpl))
return NewSafeTemplates(tmpl, slog.Default())
}
func TestRenderErrorPageNavigation404(t *testing.T) {
st := errorPageTemplates(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/no-such-page", nil)
st.RenderErrorPage(rec, req, 404, "The page you requested does not exist.")
if rec.Code != 404 {
t.Fatalf("status = %d, want 404", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"404", "Not Found", "The page you requested does not exist.", "navbar-brand"} {
if !strings.Contains(body, want) {
t.Errorf("styled 404 body missing %q", want)
}
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
}
func TestRenderErrorPagePanicBodyNeverLeaksInternals(t *testing.T) {
st := errorPageTemplates(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
// The recovery middleware passes only a fixed message; assert the page
// carries exactly that and no request-derived or internal detail slot.
st.RenderErrorPage(rec, req, 500, "An unexpected error occurred. Please try again.")
if rec.Code != 500 {
t.Fatalf("status = %d, want 500", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Internal Server Error") {
t.Errorf("styled 500 body missing status text")
}
for _, banned := range []string{"goroutine", "runtime error", ".go:"} {
if strings.Contains(body, banned) {
t.Errorf("styled 500 body leaks internals: %q", banned)
}
}
}
func TestRenderErrorPageHTMXKeepsPlainText(t *testing.T) {
st := errorPageTemplates(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/partials/whatever", nil)
req.Header.Set("HX-Request", "true")
st.RenderErrorPage(rec, req, 404, "not found")
if rec.Code != 404 {
t.Fatalf("status = %d, want 404", rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, "<html") {
t.Errorf("HTMX request got a full HTML page; want plain text for the toast contract")
}
if strings.TrimSpace(body) != "not found" {
t.Errorf("body = %q, want the plain message", body)
}
}
func TestRenderErrorPageTemplateFailureFallsBack(t *testing.T) {
// A template set without error.html: rendering fails and must fall back
// to a plain-text error with the same status, never a partial page.
tmpl := template.Must(template.New("root").Parse(`{{ define "unrelated" }}x{{ end }}`))
st := NewSafeTemplates(tmpl, slog.Default())
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/broken", nil)
st.RenderErrorPage(rec, req, 500, "An unexpected error occurred. Please try again.")
if rec.Code != 500 {
t.Fatalf("status = %d, want 500", rec.Code)
}
if strings.Contains(rec.Body.String(), "<html") {
t.Errorf("fallback emitted HTML; want plain text")
}
}
// The ordinary error page's one way out is the dashboard, and it says so.
// Pinned because the button became data (ErrorPageAction) when the auth
// failure page needed a different destination; a caller that forgets to fill
// it would render a button with no label and no href.
func TestRenderErrorPageOffersTheDashboard(t *testing.T) {
st := errorPageTemplates(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/no-such-page", nil)
st.RenderErrorPage(rec, req, 404, "The page you requested does not exist.")
body := rec.Body.String()
if !strings.Contains(body, `href="/"`) {
t.Errorf("error page carries no link to the dashboard: %q", body)
}
if !strings.Contains(body, "Back to the dashboard") {
t.Errorf("error page's action has no label: %q", body)
}
}
// A refused sign-in gets the failure named as the heading and a way back into
// the flow, instead of "400 Bad Request" and a dashboard the person cannot
// reach. This is the server half of internal/auth's FailurePage seam.
func TestRenderAuthFailureNamesTheFailureAndTheWayBack(t *testing.T) {
st := errorPageTemplates(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/callback?state=stale", nil)
st.RenderAuthFailure(rec, req, 400, "Sign-in expired", "Sign in again", "/login")
if rec.Code != 400 {
t.Fatalf("status = %d, want 400: the refusal must stay a refusal", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Sign-in expired", "Sign in again", `href="/login"`, "navbar-brand"} {
if !strings.Contains(body, want) {
t.Errorf("auth failure page missing %q", want)
}
}
if strings.Contains(body, "400 Bad Request") {
t.Errorf("the status line is still the heading: %q", body)
}
if strings.Contains(body, "Back to the dashboard") {
t.Errorf("the page still offers the dashboard the person cannot reach: %q", body)
}
}
// The HTMX branch answers with text, and it must carry something to show even
// when the page named a heading instead of a message.
func TestRenderAuthFailureHTMXFallsBackToTheHeading(t *testing.T) {
st := errorPageTemplates(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/callback?state=stale", nil)
req.Header.Set("HX-Request", "true")
st.RenderAuthFailure(rec, req, 400, "Sign-in expired", "Sign in again", "/login")
if got := strings.TrimSpace(rec.Body.String()); got != "Sign-in expired" {
t.Errorf("HTMX body = %q, want the heading", got)
}
}
// The error page carries no location trail. It is reachable without a session,
// so a trail rooted at the surface would offer a link the visitor may not be
// able to follow, and the page is not at a location to begin with.
func TestErrorPagesCarryNoTrail(t *testing.T) {
st := errorPageTemplates(t)
for _, tc := range []struct {
name string
render func(*httptest.ResponseRecorder, *http.Request)
}{
{"ordinary error", func(rec *httptest.ResponseRecorder, req *http.Request) {
st.RenderErrorPage(rec, req, 404, "The page you requested does not exist.")
}},
{"auth failure", func(rec *httptest.ResponseRecorder, req *http.Request) {
st.RenderAuthFailure(rec, req, 400, "Sign-in expired", "Sign in again", "/login")
}},
} {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
tc.render(rec, httptest.NewRequest("GET", "/whatever", nil))
if body := rec.Body.String(); strings.Contains(body, "breadcrumb") {
t.Errorf("error page still renders a location trail: %q", body)
}
})
}
}