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) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-05-16 20:43:31 +02:00
parent 9f0c92fc89
commit dcbc05b642
No known key found for this signature in database
3 changed files with 205 additions and 0 deletions

View file

@ -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"`,

View file

@ -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)
}
}

View file

@ -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 &#39; so check both forms.
if !strings.Contains(body, "Don&#39;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
}