- 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
206 lines
7.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|