feat: base UI shell with themes, auth pages, profile, and role navigation (phase 4)
- embedded Go templates + vanilla ES-module JS + hand-written CSS, no build step
- exact §10 beige light / dark design tokens, square edges (radius 2px),
system font stack, visible focus rings
- theme toggle persisted to localStorage and the user profile
- login/register/change-password pages wired to the auth API
- profile page: avatar upload (image-sniffed, old file cleanup), bio,
contacts, arbitrary extra key/value fields, optimistic-concurrency 409
- role-based top navigation with placeholders for later-phase areas
- GET /files/{id} with scope-based access (session) or signed token (§5.1)
- security headers incl. CSP without unsafe-inline scripts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoginPageRenders(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
rec := get(t, s.Handler(), "/login")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"login-form", `id="email"`, `id="password"`, "/static/js/login.js"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("login page missing %q", want)
|
||||
}
|
||||
}
|
||||
// OIDC is not configured → no SSO button
|
||||
if strings.Contains(body, "Continue with SSO") {
|
||||
t.Error("SSO button shown although OIDC is disabled")
|
||||
}
|
||||
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "script-src 'self'") {
|
||||
t.Errorf("CSP missing or weak: %q", csp)
|
||||
}
|
||||
if rec.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Error("X-Frame-Options missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPageRenders(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
rec := get(t, s.Handler(), "/register")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "register-form") {
|
||||
t.Error("register form missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousPageRedirectsToLogin(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
for _, path := range []string{"/", "/profile", "/board", "/admin", "/messages"} {
|
||||
rec := get(t, s.Handler(), path)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Errorf("%s: status = %d, want 302", path, rec.Code)
|
||||
continue
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/login?next=") {
|
||||
t.Errorf("%s: redirect to %q", path, loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAssetsServed(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
tests := map[string]string{
|
||||
"/static/css/app.css": "--bg:#f3ead9", // beige light token from §10
|
||||
"/static/js/theme.js": "data-default-theme",
|
||||
"/static/js/api.js": "X-CSRF-Token",
|
||||
"/static/js/login.js": "auth/login",
|
||||
"/static/js/nav.js": "nav-theme",
|
||||
}
|
||||
for path, want := range tests {
|
||||
rec := get(t, s.Handler(), path)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: status = %d", path, rec.Code)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("%s: missing %q", path, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeTokensPresent(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
rec := get(t, s.Handler(), "/static/css/app.css")
|
||||
css := rec.Body.String()
|
||||
// spot-check both §10 palettes and the square-edge radius
|
||||
for _, tok := range []string{
|
||||
"--bg:#f3ead9", "--accent:#8a5a2b", // light
|
||||
"--bg:#191714", "--accent:#caa15e", // dark
|
||||
"--radius:2px",
|
||||
} {
|
||||
if !strings.Contains(css, tok) {
|
||||
t.Errorf("css missing design token %q", tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialsFunc(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"Jane Doe", "JD"},
|
||||
{"single", "S"},
|
||||
{"", "?"},
|
||||
{" spaced out ", "SO"},
|
||||
}
|
||||
f := templateFuncs["initials"].(func(string) string)
|
||||
for _, tt := range tests {
|
||||
if got := f(tt.in); got != tt.want {
|
||||
t.Errorf("initials(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user