From dcbc05b642b2168d3d52ae27c5c1de1ed6e602a5 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 16 May 2026 20:43:31 +0200 Subject: [PATCH] test(14): add Nyquist validation tests for AUTH-UI-01/02/03 auth_login_test.go: LoginPage renders AuthLayout structure (login-screen, auth-card-shell, brand-logo, h1, auth-body, divider-pill), HTMX form attributes, password not echoed. auth_components_test.go: AnimatedBackground exactly 35 elements, GoogleButton configured/unconfigured variants, AuthDivider or-pill. handlers_auth_test.go: extend configured provider tests to assert class="gsi-material-button" on the anchor element (AUTH-UI-03). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/internal/web/handlers_auth_test.go | 2 + backend/templates/auth_components_test.go | 90 ++++++++++++++++ backend/templates/auth_login_test.go | 113 +++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 backend/templates/auth_components_test.go create mode 100644 backend/templates/auth_login_test.go diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 27c638a..308cb49 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -169,6 +169,7 @@ func TestSignupProviderButtonsConfigured(t *testing.T) { for _, want := range []string{ "Sign in with Google", `href="/auth/google/start"`, + `class="gsi-material-button"`, ">or<", `name="email"`, `name="password"`, @@ -560,6 +561,7 @@ func TestLoginProviderButtonsConfigured(t *testing.T) { for _, want := range []string{ "Sign in with Google", `href="/auth/google/start"`, + `class="gsi-material-button"`, ">or<", `name="email"`, `name="password"`, diff --git a/backend/templates/auth_components_test.go b/backend/templates/auth_components_test.go new file mode 100644 index 0000000..4ba2bcd --- /dev/null +++ b/backend/templates/auth_components_test.go @@ -0,0 +1,90 @@ +package templates + +import ( + "bytes" + "context" + "strings" + "testing" +) + +// TestAnimatedBackground_Exactly35Elements verifies that AnimatedBackground +// renders exactly 35 decorative logo elements (AUTH-UI-03, T-14-01-03). +func TestAnimatedBackground_Exactly35Elements(t *testing.T) { + var buf bytes.Buffer + err := AnimatedBackground().Render(context.Background(), &buf) + if err != nil { + t.Fatalf("AnimatedBackground.Render: %v", err) + } + body := buf.String() + count := strings.Count(body, "background-logo") + if count != 35 { + t.Errorf("AnimatedBackground: got %d background-logo elements; want exactly 35", count) + } +} + +// TestGoogleButton_ConfiguredRendersLink verifies that a configured GoogleButton +// renders a clickable anchor with the Material Design CSS class and start URL +// (AUTH-UI-03, D-G03). +func TestGoogleButton_ConfiguredRendersLink(t *testing.T) { + var buf bytes.Buffer + err := GoogleButton("/auth/google/start", true).Render(context.Background(), &buf) + if err != nil { + t.Fatalf("GoogleButton.Render (configured): %v", err) + } + body := buf.String() + + for _, want := range []string{ + `class="gsi-material-button"`, + `href="/auth/google/start"`, + "Sign in with Google", + } { + if !strings.Contains(body, want) { + t.Errorf("configured GoogleButton missing %q; body: %s", want, body) + } + } + if strings.Contains(body, "disabled") { + t.Errorf("configured GoogleButton must not contain 'disabled'; body: %s", body) + } +} + +// TestGoogleButton_UnconfiguredRendersDisabledButton verifies that an +// unconfigured GoogleButton renders a disabled button element (no href) +// (AUTH-UI-03). +func TestGoogleButton_UnconfiguredRendersDisabledButton(t *testing.T) { + var buf bytes.Buffer + err := GoogleButton("", false).Render(context.Background(), &buf) + if err != nil { + t.Fatalf("GoogleButton.Render (unconfigured): %v", err) + } + body := buf.String() + + for _, want := range []string{ + `class="gsi-material-button" disabled`, + `aria-disabled="true"`, + } { + if !strings.Contains(body, want) { + t.Errorf("unconfigured GoogleButton missing %q; body: %s", want, body) + } + } + if strings.Contains(body, "href=") { + t.Errorf("unconfigured GoogleButton must not contain href=; body: %s", body) + } +} + +// TestAuthDivider_RendersOrPill verifies that AuthDivider renders the "or" +// separator pill used between OAuth buttons and the email form (AUTH-UI-03). +func TestAuthDivider_RendersOrPill(t *testing.T) { + var buf bytes.Buffer + err := AuthDivider().Render(context.Background(), &buf) + if err != nil { + t.Fatalf("AuthDivider.Render: %v", err) + } + body := buf.String() + + if !strings.Contains(body, "divider-pill") { + t.Errorf("AuthDivider missing 'divider-pill' CSS class; body: %s", body) + } + if !strings.Contains(body, "or") { + t.Errorf("AuthDivider missing 'or' label text; body: %s", body) + } +} diff --git a/backend/templates/auth_login_test.go b/backend/templates/auth_login_test.go new file mode 100644 index 0000000..ebd29cd --- /dev/null +++ b/backend/templates/auth_login_test.go @@ -0,0 +1,113 @@ +package templates + +import ( + "bytes" + "context" + "strings" + "testing" +) + +// TestLoginPage_RendersAuthLayoutStructure verifies that LoginPage wraps output +// in the AuthLayout shell with all required structural CSS classes and assets +// (AUTH-UI-01). +func TestLoginPage_RendersAuthLayoutStructure(t *testing.T) { + var buf bytes.Buffer + err := LoginPage(LoginForm{Email: "a@b.com"}, LoginErrors{}, "tok", EmptyAuthProviderButtons()).Render(context.Background(), &buf) + if err != nil { + t.Fatalf("LoginPage.Render: %v", err) + } + body := buf.String() + + for _, want := range []string{ + "login-screen", + "auth-card-shell", + "brand-logo", + `src="/static/logo_dark.png"`, + "Sign in to Xtablo", + `class="auth-body"`, + `class="divider-pill"`, + } { + if !strings.Contains(body, want) { + t.Errorf("LoginPage body missing %q", want) + } + } +} + +// TestLoginPage_HTMXFormAttributes verifies that LoginFormFragment renders the +// HTMX swap attributes, CSRF field, and signup navigation link (AUTH-UI-01, +// D-19, D-20). +func TestLoginPage_HTMXFormAttributes(t *testing.T) { + var buf bytes.Buffer + err := LoginFormFragment(LoginForm{}, LoginErrors{}, "tok").Render(context.Background(), &buf) + if err != nil { + t.Fatalf("LoginFormFragment.Render: %v", err) + } + body := buf.String() + + for _, want := range []string{ + `hx-post="/login"`, + `hx-target="#login-form"`, + `hx-swap="outerHTML"`, + `id="login-form"`, + `class="login-form"`, + `name="_csrf"`, + `href="/signup"`, + } { + if !strings.Contains(body, want) { + t.Errorf("LoginFormFragment body missing %q", want) + } + } + + // Signup copy — templ HTML-encodes apostrophes as ' so check both forms. + if !strings.Contains(body, "Don't have an account") && !strings.Contains(body, "Don't have an account") { + t.Errorf("LoginFormFragment body missing signup copy; body: %s", body) + } +} + +// TestLoginPage_DoesNotEchoPassword verifies the password input field does not +// carry a pre-populated value attribute in the rendered HTML (security parity +// with signup, T-2-01). LoginForm intentionally has no Password field so the +// value can never round-trip; this test asserts the password input has no +// value= attribute. +func TestLoginPage_DoesNotEchoPassword(t *testing.T) { + var buf bytes.Buffer + // LoginForm has no Password field by design — only Email round-trips. + err := LoginPage(LoginForm{Email: "a@b.com"}, LoginErrors{}, "tok", EmptyAuthProviderButtons()).Render(context.Background(), &buf) + if err != nil { + t.Fatalf("LoginPage.Render: %v", err) + } + body := buf.String() + // The password input must not carry a value= attribute. + // Find the password input block and check it has no value="..." attribute. + pwIdx := strings.Index(body, `name="password"`) + if pwIdx == -1 { + t.Fatal("LoginPage missing password input") + } + // Look in the vicinity of the password input for value="..." (within 200 chars). + vicinity := body[max(0, pwIdx-100) : min(len(body), pwIdx+200)] + // Acceptable: no value= at all, or value="" (empty string). + if strings.Contains(vicinity, `value="`) { + // Only an empty value is acceptable — any non-empty value would echo. + // Extract the value content to verify it is empty. + vidx := strings.Index(vicinity, `value="`) + after := vicinity[vidx+len(`value="`):] + end := strings.Index(after, `"`) + if end > 0 && after[:end] != "" { + t.Errorf("LoginPage password input has non-empty value= attribute: %q", after[:end]) + } + } +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +}