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:
parent
9f0c92fc89
commit
dcbc05b642
3 changed files with 205 additions and 0 deletions
|
|
@ -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"`,
|
||||
|
|
|
|||
90
backend/templates/auth_components_test.go
Normal file
90
backend/templates/auth_components_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
113
backend/templates/auth_login_test.go
Normal file
113
backend/templates/auth_login_test.go
Normal 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 ' 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
|
||||
}
|
||||
Loading…
Reference in a new issue