diff --git a/features/reset_confirm_screen.feature b/features/reset_confirm_screen.feature new file mode 100644 index 0000000..da90407 --- /dev/null +++ b/features/reset_confirm_screen.feature @@ -0,0 +1,132 @@ +Feature: Password reset confirm screen + + As a user who opened the reset link from their email + I want /register/reset/validate?c=CODE&email=EMAIL to let me choose a new password via POST + /v1/auth/password-reset/confirm, checking length and match in the browser first, and to tell + me plainly when the link is incomplete, the code is wrong or expired, or the server failed + So that finishing a password reset is a single form from the email, with the same neutral + "invalid or expired" message whether the code was wrong, reused, or the account doesn't exist + + Background: + Given /register/reset/validate is a public route (meta.auth "public") rendered as + ResetConfirmPage inside AuthShell, per docs/design/routes.md + And ResetConfirmPage only calls POST /v1/auth/password-reset/confirm + And on mount, ResetConfirmPage reads the "c" and "email" query parameters once: if either is + absent or an empty string, no request is ever made and the Missing parameters state renders + immediately instead of the form + And when both are present, the form has a read-only Email field (value from the "email" query + parameter, help text "From your reset link."), a New password field (password input with + show/hide, a live checklist item "At least 8 characters" that starts unmet/gray, turns + error/red only after a failed submit, and turns met/green once satisfied), and a Confirm new + password field (password input with show/hide, a live checklist item "Passwords match" + with the same unmet/error/met states) + And New password's length is checked on submit only: fewer than 8 characters leaves its + checklist item in its error state and blocks the request + And Confirm new password's match against New password is checked on blur of that field and + again on submit: a mismatch shows the error "The passwords don't match." and leaves the + "Passwords match" checklist item in its error state, and blocks the request + And a submit that passes both checks calls POST /v1/auth/password-reset/confirm with + { email, code: c, password: }; the Confirm new password value is never sent + And on a 200 response, the app navigates to "/login?reason=password-changed", whose + rendering is covered by features/login_screen.feature's "Password changed" scenario, not + re-specified here + And a 403 response (per features/reset_password.feature, covering an unknown email, a wrong + code, an already-used code, or an expired code alike) replaces the whole form with a + ResultPanel + And a 500 response, or a request that never reaches the server, is surfaced with the app's + generic network/server-error alert — icon "i-lucide-circle-x", title "Couldn't reach + RenewCMS", description "Check your connection and try again.", the same copy LoginPage and + RegisterPage already use for the same failure; the form is not replaced, both password + fields keep whatever the user typed, and the form re-enables, since + docs/design/screens/reset-password.md has no state of its own for this response and this + is the app's generic pattern for it (per the user's decision on this slice) + + Scenario: Default — both parameters present, an untouched form + When the user navigates to "/register/reset/validate?c=abc123&email=marta%40renew.example" + Then the heading is "Choose a new password" + And the description is "Pick something you haven't used here before. You'll log in with it + next." + And the Email field shows "marta@renew.example", read-only, with help "From your reset link." + And the New password field shows no value, with checklist item "At least 8 characters" in its + unmet state + And the Confirm new password field shows no value, with checklist item "Passwords match" in + its unmet state + And the submit button reads "Change password" + + Scenario: Too short — fewer than 8 characters blocks submission + Given the user is on the reset-confirm form with New password "lime" and Confirm new + password "lime" + When the user submits + Then POST /v1/auth/password-reset/confirm is never called + And the New password checklist item "At least 8 characters" shows its error state + And the Confirm new password checklist item "Passwords match" shows its met state, since both + fields hold the same value + + Scenario: Mismatch — caught on blur and on submit + Given the user is on the reset-confirm form with New password "limewash2026" + When the user types "limewash2025" into Confirm new password and blurs the field + Then the Confirm new password field shows the error "The passwords don't match." and its + checklist item "Passwords match" shows its error state + When the user submits with these same values + Then POST /v1/auth/password-reset/confirm is never called + And the same mismatch error is still shown + + Scenario: Submitting — fields and button are disabled while the request is in flight + Given the user is on the reset-confirm form with New password "limewash2026" and Confirm new + password "limewash2026" + When the user submits and POST /v1/auth/password-reset/confirm has not yet resolved + Then the New password and Confirm new password fields are disabled + And the submit button reads "Changing password…" and shows a loading state + + Scenario: Success — a 200 hands off to the log-in screen + Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with + New password "limewash2026" and Confirm new password "limewash2026" + When the user submits and POST /v1/auth/password-reset/confirm resolves 200 + Then the app navigates to "/login?reason=password-changed" + + Scenario: Invalid or expired — a 403 replaces the form with a ResultPanel + Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with + New password "limewash2026" and Confirm new password "limewash2026" + When the user submits and POST /v1/auth/password-reset/confirm resolves 403 with the + apperr.PasswordResetFailedErrorMessage body + Then the form is no longer shown + And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This reset + link has expired", text "Reset links work once, for 1 hour. Request a new one and use it + within the hour." + And its actions are a primary "Request a new link" and a secondary "Back to log in" + When the user activates "Request a new link" + Then the app navigates to /register/reset/request + When instead the user activates "Back to log in" + Then the app navigates to /login + + Scenario Outline: Server or network error — the form keeps its values and re-enables + Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with + New password "limewash2026" and Confirm new password "limewash2026" + When the user submits and POST /v1/auth/password-reset/confirm + Then an error alert reads "Couldn't reach RenewCMS" with description "Check your connection + and try again." + And the form is still shown, not replaced by a ResultPanel + And the New password field still shows "limewash2026" and the Confirm new password field + still shows "limewash2026", both re-enabled + And the submit button reads "Change password" again, no longer loading + + Examples: + | failure | + | fails with a 500 response | + | never reaches the server | + + Scenario Outline: Missing parameters — either missing parameter blocks the request + When the user navigates to + Then no request is made + And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This link + is incomplete", text "Part of the reset link is missing. Open it from your email again, or + request a new one." + And its single action is a primary "Request a new link" + When the user activates "Request a new link" + Then the app navigates to /register/reset/request + + Examples: + | path | + | "/register/reset/validate?email=marta%40renew.example" | + | "/register/reset/validate?c=abc123" | + | "/register/reset/validate" | diff --git a/features/reset_confirm_screen.qa.md b/features/reset_confirm_screen.qa.md new file mode 100644 index 0000000..0b8da3d --- /dev/null +++ b/features/reset_confirm_screen.qa.md @@ -0,0 +1,83 @@ +# QA: Password reset confirm screen + +## Automated (Vitest + Vue Test Utils, `task test:web`) + +Mock `useApi` (`POST /v1/auth/password-reset/confirm`) per case; no real backend, no httptest +server. Mount ResetConfirmPage inside AuthShell with a test router whose current route starts at +the query string each case needs. + +1. **Default state** — mount at + "/register/reset/validate?c=abc123&email=marta%40renew.example"; assert heading "Choose a new + password", description, Email "marta@renew.example" read-only with help "From your reset + link.", New password empty with checklist "At least 8 characters" unmet, Confirm new password + empty with checklist "Passwords match" unmet, submit "Change password". +2. **Too short state** — from state 1, set New password and Confirm new password both to "lime"; + submit; assert `POST /v1/auth/password-reset/confirm` was never called; assert "At least 8 + characters" shows its error state and "Passwords match" shows its met state. +3. **Mismatch state** — from state 1, set New password to "limewash2026", type "limewash2025" + into Confirm new password and blur; assert the error "The passwords don't match." and the + "Passwords match" checklist error state; submit; assert the call was never made and the same + error is still shown. +4. **Submitting state** — from state 1, set both password fields to "limewash2026"; mock the call + with an unresolved promise; submit; assert both fields are disabled and the submit button + reads "Changing password…" with a loading indicator. +5. **Success state** — from state 1, set both password fields to "limewash2026"; mock the call + resolving 200; submit; assert the router's current path is "/login?reason=password-changed" + (the rendering of that page is covered by `login_screen.qa.md`, not re-asserted here beyond + the path/query). +6. **Invalid or expired state** — from state 1, set both password fields to "limewash2026"; mock + the call resolving 403 with the `apperr.PasswordResetFailedErrorMessage` body; submit; assert + the form is no longer rendered; assert a ResultPanel (error, link-2-off, "This reset link has + expired", the text above) with actions "Request a new link" (primary) and "Back to log in" + (secondary); click each in separate sub-cases and assert the router's current path is + /register/reset/request and /login respectively. +7. **Server or network error state** — two sub-cases (500 response, rejected fetch) from state 1, + with both password fields set to "limewash2026"; submit; assert an error alert with icon + "i-lucide-circle-x", title "Couldn't reach RenewCMS", description "Check your connection and + try again." (the same copy `login_screen.qa.md` and `register_screen.qa.md` already assert + for their own server/network cases); assert the form is still rendered (no ResultPanel); + assert both password fields still read "limewash2026" and are re-enabled; assert the submit + button reads "Change password" and is no longer loading. +8. **Missing parameters state** — three sub-cases, one per Examples row (missing "c", missing + "email", missing both); mount at the row's path; assert no request was made; assert a + ResultPanel (error, link-2-off, "This link is incomplete", the text above) with a single + primary action "Request a new link"; click it and assert the router's current path is + /register/reset/request. + | path | + |----------------------------------------------------------| + | /register/reset/validate?email=marta%40renew.example | + | /register/reset/validate?c=abc123 | + | /register/reset/validate | + +## Visual (manual verification — desktop and mobile, light and dark) + +Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Reset password". Run +`task visual:check -- reset-password` for each state. + +- [ ] Default state: read-only Email field styling, both checklist items in their unmet/gray + state, matches the board exactly. +- [ ] Too short state: New password's checklist item in its error/red state while Confirm new + password's stays met/green, since both fields match. +- [ ] Mismatch state: inline field error under Confirm new password plus its checklist error + state. +- [ ] Submitting state: disabled field styling and the loading spinner in the submit button. +- [ ] Invalid or expired state: ResultPanel with both actions, primary first (components.md's + safe/primary-on-top rule for stacked mobile buttons). +- [ ] Server or network error state: error alert (circle-x icon, no close button) above the + heading, matching Login's/Register's own server-error alert styling exactly, since this + screen has no board state of its own for it; fields retain their values. This state is not + covered by `task visual:check`'s per-state board comparison (no board frame exists for it); + note that gap in the run's report rather than skipping it silently. +- [ ] Missing parameters state: ResultPanel with a single primary action. +- [ ] Keyboard/focus: Tab order is New password → show/hide → Confirm new password → show/hide → + submit; the ResultPanel's title receives focus on both the Invalid-or-expired and + Missing-parameters states; the mismatch error and the server/network alert are announced + without stealing focus from the field or the heading, respectively. + +## Manual verification + +- **Real POST /v1/auth/password-reset/confirm against a running server** — Vitest only mocks + `useApi`; confirm the real 200/403 round-trip (including the actual bcrypt code check, 1-hour + expiry, and single-use clearing) against `task dev` with a live SQLite file. The endpoint's own + contract is already covered by `features/reset_password.qa.md`; this slice only re-verifies the + screen consumes it correctly. diff --git a/features/reset_request_screen.feature b/features/reset_request_screen.feature new file mode 100644 index 0000000..0b37c37 --- /dev/null +++ b/features/reset_request_screen.feature @@ -0,0 +1,85 @@ +Feature: Password reset request screen + + As a RenewCMS user who forgot their password + I want a /register/reset/request screen that emails a reset link via POST + /v1/auth/password-reset, validates the email client-side before sending, and never reveals + whether the address has an account + So that I can ask for a reset link with the same neutral confirmation regardless of whether my + account exists, and see plainly when I'm asking too often or the server failed + + Background: + Given /register/reset/request is a public route (meta.auth "public") rendered as + ResetRequestPage inside AuthShell, per docs/design/routes.md + And ResetRequestPage only calls POST /v1/auth/password-reset + And the form has a single Email field (placeholder "you@example.com") + And client-side validation runs on submit only, before any request is sent: Email must be a + well-formed email address (the same pattern RegisterPage already uses), and a failing Email + shows "Enter a full email address, like name@example.com." + And a valid submit calls POST /v1/auth/password-reset with { email } + And every response the API gives — whether the email belongs to an account or not — is the + same 200 with the same body, per features/request_password_reset.feature, so the screen + shows the same Sent state either way + And a 429 response carries a Retry-After header in seconds, surfaced by useApi and displayed + as a live countdown on the submit button, the same mechanism LoginPage's own 429 handling + uses, and the Email field is disabled for the same duration + And a 500 response, or a request that never reaches the server, is surfaced as a + server/network error; the field keeps whatever the user typed and the form re-enables + And a 400 response is not expected once client-side validation passes, since the client + checks the same required,email rule the API enforces; it is out of scope for this slice + + Scenario: Default — a fresh, empty form + When the user navigates to /register/reset/request + Then the heading is "Reset your password" + And the description is "Enter the email for your account and we'll send you a link to choose + a new password." + And the Email field shows placeholder "you@example.com" with no value + And the submit button reads "Send reset link" + And the footer reads "Back to log in" linking to /login + + Scenario: Validation error — an invalid email blocks the request + Given the user is on /register/reset/request + When the user types "marta@renew" into Email and submits + Then POST /v1/auth/password-reset is never called + And the Email field shows the error "Enter a full email address, like name@example.com." + + Scenario: Submitting — the field and button are disabled while the request is in flight + Given the user is on /register/reset/request with Email "marta@renew.example" + When the user submits and POST /v1/auth/password-reset has not yet resolved + Then the Email field is disabled + And the submit button reads "Sending…" and shows a loading state + + Scenario: Sent — any 200 shows the same neutral confirmation + Given the user is on /register/reset/request with Email "marta@renew.example" + When the user submits and POST /v1/auth/password-reset resolves 200 + Then the form is replaced by a ResultPanel with tone "success", icon "i-lucide-mail-check", + title "Check your email", text "If an account exists for that email, a password reset link + has been sent.", and note "The link expires in 1 hour." + And its single action is a secondary "Back to log in" + When the user activates "Back to log in" + Then the app navigates to /login + + Scenario: Too many attempts — the button is disabled with a live countdown + Given the user is on /register/reset/request with Email "marta@renew.example" + When the user submits and POST /v1/auth/password-reset resolves 429 with a Retry-After of 58 + seconds + Then an info alert reads "Too many requests" with description "Wait a moment before asking + for another link." + And the Email field still shows "marta@renew.example" + And the submit button reads "Try again in 0:58" and is disabled + And the Email field is disabled while the countdown runs + When 58 seconds elapse + Then the submit button re-enables and reads "Send reset link" + And the Email field is editable again + + Scenario Outline: Server or network error — the field keeps its value and re-enables + Given the user is on /register/reset/request with Email "marta@renew.example" + When the user submits and POST /v1/auth/password-reset + Then an error alert reads "Couldn't send the link" with description "Check your connection + and try again." + And the Email field still shows "marta@renew.example" and is re-enabled + And the submit button reads "Send reset link" again, no longer loading + + Examples: + | failure | + | fails with a 500 response | + | never reaches the server | diff --git a/features/reset_request_screen.qa.md b/features/reset_request_screen.qa.md new file mode 100644 index 0000000..5cb1ba9 --- /dev/null +++ b/features/reset_request_screen.qa.md @@ -0,0 +1,59 @@ +# QA: Password reset request screen + +## Automated (Vitest + Vue Test Utils, `task test:web`) + +Mock `useApi` (`POST /v1/auth/password-reset`) per case; no real backend, no httptest server. +Mount ResetRequestPage inside AuthShell with a test router. + +1. **Default state** — mount at /register/reset/request; assert heading "Reset your password", + description, Email placeholder "you@example.com" with no value, submit "Send reset link", + footer "Back to log in" linking to /login. +2. **Validation error state** — set Email to "marta@renew", submit; assert the mocked + `POST /v1/auth/password-reset` was never called; assert the error "Enter a full email + address, like name@example.com." +3. **Submitting state** — set Email "marta@renew.example"; mock the call with an unresolved + promise; submit; assert the Email field is disabled and the submit button reads "Sending…" + with a loading indicator. +4. **Sent state** — set Email "marta@renew.example"; mock the call resolving 200; submit; assert + the form is replaced by a ResultPanel (success, mail-check, "Check your email", the neutral + text, note "The link expires in 1 hour.") with a single secondary action "Back to log in"; + click it and assert the router's current path is /login. +5. **Too many attempts state** — set Email "marta@renew.example"; mock the call resolving 429 + with `Retry-After: 58`; submit; assert an info alert ("Too many requests", "Wait a moment + before asking for another link."); assert Email still reads "marta@renew.example"; assert the + submit button reads "Try again in 0:58" and is disabled; assert the Email field is disabled; + advance fake timers 58s; assert the button re-enables and reads "Send reset link" and the + field is editable again. +6. **Server or network error state** — two sub-cases (500 response, rejected fetch) with Email + "marta@renew.example"; submit; assert an error alert ("Couldn't send the link", "Check your + connection and try again."); assert Email still reads "marta@renew.example" and is re-enabled; + assert the submit button reads "Send reset link" and is no longer loading. + +## Visual (manual verification — desktop and mobile, light and dark) + +Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Forgot password". Run +`task visual:check -- forgot-password` for each state. + +- [ ] Default state: heading, description, Email field, "Send reset link" button, "Back to log + in" footer link, matches the board exactly. +- [ ] Validation error state: the inline field error renders with the same styling as + Register's/Login's own field errors. +- [ ] Submitting state: disabled field styling and the loading spinner in the submit button. +- [ ] Sent state: ResultPanel (mail-check icon) with its single secondary action, note text below + the description. +- [ ] Too many attempts state: info alert (timer icon), disabled field and disabled button with + the live "Try again in m:ss" countdown text. +- [ ] Server error state: error alert (circle-x icon, no close button), field retains its value. +- [ ] Keyboard/focus: Tab order is Email → submit; the alert (when present) is announced via + `role="status"` (info) or `role="alert"` (error) without re-announcing on re-render; the + ResultPanel's title receives focus on the Sent state. + +## Manual verification + +- **Real POST /v1/auth/password-reset against a running server** — Vitest only mocks `useApi`; + confirm the real 200/429/500 round-trip (including the actual rate limiter and Retry-After + header) against `task dev` with a live SQLite file. The endpoint's own contract is already + covered by `features/request_password_reset.qa.md`; this slice only re-verifies the screen + consumes it correctly. +- **429 countdown drift** — confirm `useRetryAfter`'s live countdown does not drift against a + real elapsed 58-second wait, since Vitest fake timers only prove the logic. diff --git a/web/src/views/ResetConfirmView.vue b/web/src/views/ResetConfirmView.vue index edc9b2b..f725cc9 100644 --- a/web/src/views/ResetConfirmView.vue +++ b/web/src/views/ResetConfirmView.vue @@ -1,9 +1,270 @@ diff --git a/web/src/views/ResetRequestView.vue b/web/src/views/ResetRequestView.vue index c87dffa..0121aa3 100644 --- a/web/src/views/ResetRequestView.vue +++ b/web/src/views/ResetRequestView.vue @@ -1,9 +1,199 @@ diff --git a/web/src/views/__tests__/ResetConfirmView.spec.ts b/web/src/views/__tests__/ResetConfirmView.spec.ts new file mode 100644 index 0000000..ddf063d --- /dev/null +++ b/web/src/views/__tests__/ResetConfirmView.spec.ts @@ -0,0 +1,326 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import uiPlugin from '@nuxt/ui/vue-plugin' +import router from '@/router' +import ResultPanel from '@/components/ResultPanel.vue' +import ResetConfirmView from '../ResetConfirmView.vue' + +function mockResponse(status: number, body: unknown = null, headers: Record = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => headers[name] ?? null }, + json: async () => body, + } as unknown as Response +} + +function routeFor(url: string, init?: RequestInit) { + const method = init?.method ?? 'GET' + return `${method} ${url}` +} + +function fetchRouter(handlers: Record Response | Promise>) { + return vi.fn((url: string, init?: RequestInit) => { + const key = routeFor(url, init) + const handler = handlers[key] + if (!handler) { + throw new Error(`Unexpected fetch call: ${key}`) + } + return handler() + }) +} + +let currentWrapper: Awaited> | null = null + +async function mountResetConfirm( + path: string, + handlers: Record Response | Promise> = {}, +) { + setActivePinia(createPinia()) + const fetchMock = fetchRouter(handlers) + global.fetch = fetchMock as unknown as typeof fetch + await router.push(path) + const wrapper = mount(ResetConfirmView, { + attachTo: document.body, + global: { + plugins: [router, uiPlugin], + }, + }) + currentWrapper = wrapper + await flushPromises() + return { wrapper, fetchMock } +} + +const DEFAULT_PATH = '/register/reset/validate?c=abc123&email=marta%40renew.example' + +function newPasswordInput(wrapper: Awaited>) { + return wrapper.find('#reset-new-password') +} + +function confirmPasswordInput(wrapper: Awaited>) { + return wrapper.find('#reset-confirm-password') +} + +async function fillPasswords( + wrapper: Awaited>, + password: string, + confirm: string, +) { + await newPasswordInput(wrapper).setValue(password) + await confirmPasswordInput(wrapper).setValue(confirm) +} + +afterEach(async () => { + currentWrapper?.unmount() + currentWrapper = null + await router.push('/login') +}) + +describe('Default — both parameters present, an untouched form', () => { + it('renders the empty form', async () => { + const { wrapper } = await mountResetConfirm(DEFAULT_PATH) + + expect(wrapper.find('h1').text()).toBe('Choose a new password') + expect(wrapper.text()).toContain( + "Pick something you haven't used here before. You'll log in with it next.", + ) + + const emailField = wrapper.find('input[placeholder="you@example.com"]') + expect((emailField.element as HTMLInputElement).value).toBe('marta@renew.example') + expect((emailField.element as HTMLInputElement).readOnly).toBe(true) + expect(wrapper.text()).toContain('From your reset link.') + + expect((newPasswordInput(wrapper).element as HTMLInputElement).value).toBe('') + expect(wrapper.text()).toContain('At least 8 characters') + + expect((confirmPasswordInput(wrapper).element as HTMLInputElement).value).toBe('') + expect(wrapper.text()).toContain('Passwords match') + + expect(wrapper.find('button[type="submit"]').text()).toBe('Change password') + }) +}) + +describe('Too short — fewer than 8 characters blocks submission', () => { + it('never calls the endpoint and marks the length rule as errored', async () => { + const confirm = vi.fn(() => mockResponse(200)) + const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { + 'POST /v1/auth/password-reset/confirm': confirm, + }) + + await fillPasswords(wrapper, 'lime', 'lime') + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(confirm).not.toHaveBeenCalled() + + const items = wrapper.findAll('li') + const lengthItem = items.find((item) => item.text().includes('At least 8 characters')) + expect(lengthItem?.text()).toContain('not met') + + const matchItem = items.find((item) => item.text().includes('Passwords match')) + expect(matchItem?.text()).toContain('met') + }) +}) + +describe('Mismatch — caught on blur and on submit', () => { + it('shows the mismatch error and blocks the request', async () => { + const confirm = vi.fn(() => mockResponse(200)) + const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { + 'POST /v1/auth/password-reset/confirm': confirm, + }) + + await newPasswordInput(wrapper).setValue('limewash2026') + await confirmPasswordInput(wrapper).setValue('limewash2025') + await confirmPasswordInput(wrapper).trigger('blur') + + expect(wrapper.text()).toContain("The passwords don't match.") + + const matchItemAfterBlur = wrapper + .findAll('li') + .find((item) => item.text().includes('Passwords match')) + expect(matchItemAfterBlur?.find('svg').classes()).toContain('text-error') + + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(confirm).not.toHaveBeenCalled() + expect(wrapper.text()).toContain("The passwords don't match.") + + const matchItemAfterSubmit = wrapper + .findAll('li') + .find((item) => item.text().includes('Passwords match')) + expect(matchItemAfterSubmit?.find('svg').classes()).toContain('text-error') + }) +}) + +describe('Submitting — fields and button are disabled while the request is in flight', () => { + it('disables the fields and shows the loading label', async () => { + let resolveConfirm: (() => void) | null = null + const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { + 'POST /v1/auth/password-reset/confirm': () => + new Promise((resolve) => { + resolveConfirm = () => resolve(mockResponse(200)) + }), + }) + + await fillPasswords(wrapper, 'limewash2026', 'limewash2026') + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect((newPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(true) + expect((confirmPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(true) + + const submitButton = wrapper.find('button[type="submit"]') + expect(submitButton.text()).toBe('Changing password…') + expect((submitButton.element as HTMLButtonElement).disabled).toBe(true) + + resolveConfirm?.() + await flushPromises() + }) +}) + +describe('Success — a 200 hands off to the log-in screen', () => { + it('navigates to /login?reason=password-changed', async () => { + const { wrapper, fetchMock } = await mountResetConfirm(DEFAULT_PATH, { + 'POST /v1/auth/password-reset/confirm': () => mockResponse(200), + }) + + await fillPasswords(wrapper, 'limewash2026', 'limewash2026') + await wrapper.find('form').trigger('submit') + + await vi.waitFor(() => + expect(router.currentRoute.value.fullPath).toBe('/login?reason=password-changed'), + ) + + expect(fetchMock).toHaveBeenCalledWith( + '/v1/auth/password-reset/confirm', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + email: 'marta@renew.example', + code: 'abc123', + password: 'limewash2026', + }), + }), + ) + }) +}) + +describe('Invalid or expired — a 403 replaces the form with a ResultPanel', () => { + it('shows the panel and navigates depending on the action', async () => { + const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { + 'POST /v1/auth/password-reset/confirm': () => mockResponse(403), + }) + + await fillPasswords(wrapper, 'limewash2026', 'limewash2026') + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(wrapper.find('form').exists()).toBe(false) + expect(wrapper.text()).toContain('This reset link has expired') + expect(wrapper.text()).toContain( + 'Reset links work once, for 1 hour. Request a new one and use it within the hour.', + ) + + const resultPanel = wrapper.findComponent(ResultPanel) + expect(resultPanel.props('tone')).toBe('error') + expect(resultPanel.props('icon')).toBe('i-lucide-link-2-off') + + const buttons = wrapper.findAll('button') + const requestNew = buttons.find((button) => button.text() === 'Request a new link') + expect(requestNew).toBeTruthy() + await requestNew?.trigger('click') + + await vi.waitFor(() => + expect(router.currentRoute.value.path).toBe('/register/reset/request'), + ) + }) + + it('navigates to /login when Back to log in is activated', async () => { + const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { + 'POST /v1/auth/password-reset/confirm': () => mockResponse(403), + }) + + await fillPasswords(wrapper, 'limewash2026', 'limewash2026') + await wrapper.find('form').trigger('submit') + await flushPromises() + + const buttons = wrapper.findAll('button') + const backToLogin = buttons.find((button) => button.text() === 'Back to log in') + expect(backToLogin).toBeTruthy() + await backToLogin?.trigger('click') + + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login')) + }) +}) + +describe('Server or network error — the form keeps its values and re-enables', () => { + it.each([ + { name: 'a 500 response', handler: () => mockResponse(500) }, + { + name: 'a network failure', + handler: () => Promise.reject(new TypeError('Failed to fetch')), + }, + ])('shows the error alert and keeps the values for $name', async ({ handler }) => { + const confirm = vi.fn(handler) + const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { + 'POST /v1/auth/password-reset/confirm': confirm, + }) + + await fillPasswords(wrapper, 'limewash2026', 'limewash2026') + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(confirm).toHaveBeenCalled() + expect(wrapper.text()).toContain("Couldn't reach RenewCMS") + expect(wrapper.text()).toContain('Check your connection and try again.') + + expect(wrapper.find('form').exists()).toBe(true) + expect((newPasswordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026') + expect((confirmPasswordInput(wrapper).element as HTMLInputElement).value).toBe( + 'limewash2026', + ) + expect((newPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(false) + expect((confirmPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(false) + + const submitButton = wrapper.find('button[type="submit"]') + expect(submitButton.text()).toBe('Change password') + expect((submitButton.element as HTMLButtonElement).disabled).toBe(false) + }) +}) + +describe('Missing parameters — either missing parameter blocks the request', () => { + it.each([ + { + path: '/register/reset/validate?email=marta%40renew.example', + }, + { path: '/register/reset/validate?c=abc123' }, + { path: '/register/reset/validate' }, + ])('renders the missing-parameters panel for $path', async ({ path }) => { + const confirm = vi.fn(() => mockResponse(200)) + const { wrapper } = await mountResetConfirm(path, { + 'POST /v1/auth/password-reset/confirm': confirm, + }) + + expect(confirm).not.toHaveBeenCalled() + expect(wrapper.find('form').exists()).toBe(false) + expect(wrapper.text()).toContain('This link is incomplete') + expect(wrapper.text()).toContain( + 'Part of the reset link is missing. Open it from your email again, or request a new one.', + ) + + const resultPanel = wrapper.findComponent(ResultPanel) + expect(resultPanel.props('tone')).toBe('error') + expect(resultPanel.props('icon')).toBe('i-lucide-link-2-off') + expect(resultPanel.props('actions')).toHaveLength(1) + + const action = wrapper.find('button') + expect(action.text()).toBe('Request a new link') + await action.trigger('click') + + await vi.waitFor(() => + expect(router.currentRoute.value.path).toBe('/register/reset/request'), + ) + }) +}) diff --git a/web/src/views/__tests__/ResetRequestView.spec.ts b/web/src/views/__tests__/ResetRequestView.spec.ts new file mode 100644 index 0000000..b7bd1eb --- /dev/null +++ b/web/src/views/__tests__/ResetRequestView.spec.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import uiPlugin from '@nuxt/ui/vue-plugin' +import router from '@/router' +import ResultPanel from '@/components/ResultPanel.vue' +import ResetRequestView from '../ResetRequestView.vue' + +function mockResponse(status: number, body: unknown = null, headers: Record = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => headers[name] ?? null }, + json: async () => body, + } as unknown as Response +} + +function routeFor(url: string, init?: RequestInit) { + const method = init?.method ?? 'GET' + return `${method} ${url}` +} + +function fetchRouter(handlers: Record Response | Promise>) { + return vi.fn((url: string, init?: RequestInit) => { + const key = routeFor(url, init) + const handler = handlers[key] + if (!handler) { + throw new Error(`Unexpected fetch call: ${key}`) + } + return handler() + }) +} + +let currentWrapper: Awaited> | null = null + +async function mountResetRequest( + handlers: Record Response | Promise> = {}, +) { + setActivePinia(createPinia()) + const fetchMock = fetchRouter(handlers) + global.fetch = fetchMock as unknown as typeof fetch + await router.push('/register/reset/request') + const wrapper = mount(ResetRequestView, { + attachTo: document.body, + global: { + plugins: [router, uiPlugin], + }, + }) + currentWrapper = wrapper + await flushPromises() + return { wrapper, fetchMock } +} + +function emailInput(wrapper: Awaited>) { + return wrapper.find('input[placeholder="you@example.com"]') +} + +async function fillAndSubmit( + wrapper: Awaited>, + email: string, +) { + await emailInput(wrapper).setValue(email) + await wrapper.find('form').trigger('submit') + await flushPromises() +} + +afterEach(async () => { + currentWrapper?.unmount() + currentWrapper = null + await router.push('/login') +}) + +describe('Default — a fresh, empty form', () => { + it('renders the empty state', async () => { + const { wrapper } = await mountResetRequest() + + expect(wrapper.find('h1').text()).toBe('Reset your password') + expect(wrapper.text()).toContain( + "Enter the email for your account and we'll send you a link to choose a new password.", + ) + + expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('') + + expect(wrapper.find('button[type="submit"]').text()).toBe('Send reset link') + + const footerLink = wrapper.find('a[href="/login"]') + expect(footerLink.exists()).toBe(true) + expect(footerLink.text()).toBe('Back to log in') + }) +}) + +describe('Validation error — an invalid email blocks the request', () => { + it('never calls the endpoint and shows the field error', async () => { + const request = vi.fn(() => mockResponse(200)) + const { wrapper } = await mountResetRequest({ + 'POST /v1/auth/password-reset': request, + }) + + await fillAndSubmit(wrapper, 'marta@renew') + + expect(request).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.') + }) +}) + +describe('Submitting — the field and button are disabled while the request is in flight', () => { + it('disables the field and shows the loading label', async () => { + let resolveRequest: (() => void) | null = null + const { wrapper } = await mountResetRequest({ + 'POST /v1/auth/password-reset': () => + new Promise((resolve) => { + resolveRequest = () => resolve(mockResponse(200)) + }), + }) + + await emailInput(wrapper).setValue('marta@renew.example') + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true) + const submitButton = wrapper.find('button[type="submit"]') + expect(submitButton.text()).toBe('Sending…') + expect((submitButton.element as HTMLButtonElement).disabled).toBe(true) + + resolveRequest?.() + await flushPromises() + }) +}) + +describe('Sent — any 200 shows the same neutral confirmation', () => { + it('replaces the form with a ResultPanel and navigates back to log in', async () => { + const { wrapper, fetchMock } = await mountResetRequest({ + 'POST /v1/auth/password-reset': () => mockResponse(200), + }) + + await fillAndSubmit(wrapper, 'marta@renew.example') + + expect(fetchMock).toHaveBeenCalledWith( + '/v1/auth/password-reset', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'marta@renew.example' }), + }), + ) + + expect(wrapper.find('form').exists()).toBe(false) + expect(wrapper.text()).toContain('Check your email') + expect(wrapper.text()).toContain( + 'If an account exists for that email, a password reset link has been sent.', + ) + expect(wrapper.text()).toContain('The link expires in 1 hour.') + + const resultPanel = wrapper.findComponent(ResultPanel) + expect(resultPanel.props('tone')).toBe('success') + expect(resultPanel.props('icon')).toBe('i-lucide-mail-check') + + const action = wrapper.find('button') + expect(action.text()).toBe('Back to log in') + await action.trigger('click') + + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login')) + }) +}) + +describe('Too many attempts — the button is disabled with a live countdown', () => { + it('counts down from the Retry-After value and re-enables at zero', async () => { + vi.useFakeTimers() + const { wrapper } = await mountResetRequest({ + 'POST /v1/auth/password-reset': () => mockResponse(429, null, { 'Retry-After': '58' }), + }) + + await fillAndSubmit(wrapper, 'marta@renew.example') + + expect(wrapper.text()).toContain('Too many requests') + expect(wrapper.text()).toContain('Wait a moment before asking for another link.') + + expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example') + + const button = wrapper.find('button[type="submit"]') + expect(button.text()).toBe('Try again in 0:58') + expect((button.element as HTMLButtonElement).disabled).toBe(true) + expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true) + + await vi.advanceTimersByTimeAsync(58000) + await flushPromises() + + expect(wrapper.find('button[type="submit"]').text()).toBe('Send reset link') + expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe( + false, + ) + expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false) + vi.useRealTimers() + }) +}) + +describe('Server or network error — the field keeps its value and re-enables', () => { + it.each([ + { name: 'a 500 response', handler: () => mockResponse(500) }, + { + name: 'a network failure', + handler: () => Promise.reject(new TypeError('Failed to fetch')), + }, + ])('shows the error alert and keeps the value for $name', async ({ handler }) => { + const request = vi.fn(handler) + const { wrapper } = await mountResetRequest({ + 'POST /v1/auth/password-reset': request, + }) + + await fillAndSubmit(wrapper, 'marta@renew.example') + + expect(request).toHaveBeenCalled() + expect(wrapper.text()).toContain("Couldn't send the link") + expect(wrapper.text()).toContain('Check your connection and try again.') + + expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example') + expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false) + + const submitButton = wrapper.find('button[type="submit"]') + expect(submitButton.text()).toBe('Send reset link') + expect((submitButton.element as HTMLButtonElement).disabled).toBe(false) + }) +}) diff --git a/web/visual/states.ts b/web/visual/states.ts index cda85c2..1147f5a 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -392,6 +392,51 @@ async function chooseArticleRowMenuItem(page: Page, label: string) { await page.getByRole('menuitem', { name: label, exact: true }).click() } +async function fillResetRequestEmail(page: Page, email: string) { + await page.getByPlaceholder('you@example.com').fill(email) +} + +function submitResetRequest(email: string) { + return async (page: Page) => { + await fillResetRequestEmail(page, email) + await page.getByRole('button', { name: 'Send reset link', exact: true }).click() + await page.mouse.move(0, 0) + } +} + +function resetRequestWith(response: ApiMock): Omit { + return { + path: '/register/reset/request', + api: { + 'POST /v1/auth/password-reset': response, + }, + } +} + +const resetConfirmPath = '/register/reset/validate?c=abc123&email=marta%40renew.example' + +async function fillResetConfirm(page: Page, password: string, confirmPassword: string) { + await page.locator('#reset-new-password').fill(password) + await page.locator('#reset-confirm-password').fill(confirmPassword) +} + +function submitResetConfirm(password: string, confirmPassword: string) { + return async (page: Page) => { + await fillResetConfirm(page, password, confirmPassword) + await page.getByRole('button', { name: 'Change password', exact: true }).click() + await page.mouse.move(0, 0) + } +} + +function resetConfirmWith(response: ApiMock): Omit { + return { + path: resetConfirmPath, + api: { + 'POST /v1/auth/password-reset/confirm': response, + }, + } +} + const integrationOrigin = 'https://cms.renew.example' async function clickBaseUrlCopy(page: Page) { @@ -1022,4 +1067,68 @@ export const visualStates: Record> = { path: '/register/validate', }, }, + 'forgot-password': { + Default: { path: '/register/reset/request' }, + 'Validation error': { + path: '/register/reset/request', + steps: submitResetRequest('marta@renew'), + }, + Submitting: { + ...resetRequestWith({ status: 200, hang: true }), + steps: submitResetRequest('marta@renew.example'), + }, + Sent: { + ...resetRequestWith({ status: 200, body: { message: 'ok' } }), + steps: submitResetRequest('marta@renew.example'), + }, + 'Too many attempts (429)': { + ...resetRequestWith({ status: 429, headers: { 'Retry-After': '58' } }), + steps: submitResetRequest('marta@renew.example'), + }, + 'Server error': { + ...resetRequestWith({ status: 500 }), + steps: submitResetRequest('marta@renew.example'), + }, + }, + 'reset-password': { + Default: { path: resetConfirmPath }, + 'Too short': { + path: resetConfirmPath, + steps: submitResetConfirm('lime', 'lime'), + }, + Mismatch: { + path: resetConfirmPath, + steps: async (page) => { + await fillResetConfirm(page, 'limewash2026', 'limewash2025') + await page.locator('#reset-confirm-password').blur() + }, + }, + Submitting: { + ...resetConfirmWith({ status: 200, hang: true }), + steps: submitResetConfirm('limewash2026', 'limewash2026'), + }, + Success: { + path: resetConfirmPath, + api: { + 'POST /v1/auth/password-reset/confirm': { status: 200, body: { message: 'ok' } }, + 'GET /v1/auth/registration': registrationClosed, + }, + steps: async (page) => { + await submitResetConfirm('limewash2026', 'limewash2026')(page) + await page.waitForURL('**/login**') + await page.getByText('Password changed').waitFor() + }, + }, + 'Invalid or expired (403)': { + ...resetConfirmWith({ status: 403 }), + steps: submitResetConfirm('limewash2026', 'limewash2026'), + }, + 'Missing parameters': { + path: '/register/reset/validate', + }, + 'Server error': { + ...resetConfirmWith({ status: 500 }), + steps: submitResetConfirm('limewash2026', 'limewash2026'), + }, + }, }