From fe254112a23c733dbf9a666a63d55c0de6e3fff4 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Tue, 13 Oct 2026 02:49:43 +0200 Subject: [PATCH] fix(register): onSubmit now calls await session.retry() before going to /register/pending --- .../register_redirects_to_pending.feature | 39 +++ features/register_redirects_to_pending.qa.md | 27 ++ web/src/views/RegisterView.vue | 3 + web/src/views/__tests__/RegisterView.spec.ts | 234 +++++++++--------- 4 files changed, 192 insertions(+), 111 deletions(-) create mode 100644 features/register_redirects_to_pending.feature create mode 100644 features/register_redirects_to_pending.qa.md diff --git a/features/register_redirects_to_pending.feature b/features/register_redirects_to_pending.feature new file mode 100644 index 0000000..4388708 --- /dev/null +++ b/features/register_redirects_to_pending.feature @@ -0,0 +1,39 @@ +Feature: Registering lands on the Check your email screen + As a visitor who has just created the owner account + I want to see the "Check your email" screen + So that I know to open the verification link, instead of being sent to log in with "Your session expired" + + Background: + Given /register is a "public" route and /register/pending is a "session" route, per docs/design/routes.md + And POST /v1/auth/register is public and, on 200, sets the HttpOnly jwt cookie of the new unverified account + And the session store caches the result of GET /v1/auth/me, and a cached 401 is stored as session null with status "ready" + And RegisterView, on a 200 from POST /v1/auth/register, writes the submitted email to sessionStorage "renewcms:pendingEmail", awaits session.retry(), then navigates to /register/pending + + Scenario: Register after the session store cached an anonymous 401 + Given the visitor opened "/" first, GET /v1/auth/me answered 401, and the session store holds null with status "ready" + And the visitor is on /register + When the visitor submits username "marta", email "marta@renew.example" and a password of 8 or more characters + And POST /v1/auth/register answers 200 + And the following GET /v1/auth/me answers 200 with verified false and email "marta@renew.example" + Then the session store holds an unverified account + And the app is on /register/pending, not /login + And no "Your session expired" alert is shown + And a ResultPanel titled "Check your email" reads "We sent a verification link to marta@renew.example. It expires in 2 hours." + And its actions are "Log out" and "Cancel registration" + + Scenario: Register as the first page of the visit + Given the session store is idle and the visitor opened /register directly + When the visitor submits the form and POST /v1/auth/register answers 200 + Then the app is on /register/pending with the "Check your email" panel for "marta@renew.example" + + Scenario Outline: Register failures never navigate + Given the visitor is on /register with a valid form + When POST /v1/auth/register answers + Then the app stays on /register + And + And GET /v1/auth/me is not called + + Examples: + | status | visible | + | 403 | the "This site already has an owner" ResultPanel is shown | + | 500 | the error alert "Couldn't create the account" is shown | diff --git a/features/register_redirects_to_pending.qa.md b/features/register_redirects_to_pending.qa.md new file mode 100644 index 0000000..d68192b --- /dev/null +++ b/features/register_redirects_to_pending.qa.md @@ -0,0 +1,27 @@ +# QA: Registering lands on the Check your email screen + +## Automated (Vitest + Vue Test Utils, `task test:web`) + +Mock `fetch` per case. Use a real router (`@/router`), a fresh Pinia and AuthShell with the Nuxt UI +plugin, as in RegisterView.spec.ts. No Go test: the API already sets the cookie on register. + +1. **Stale anonymous session** (regression) — call `useSessionStore().check()` with `GET /v1/auth/me` + mocked to 401 so the store holds null / "ready". Mount RegisterView at /register, fill valid + values, submit. Mock `POST /v1/auth/register` to 200 and the next `GET /v1/auth/me` to 200 + `{ verified: false, email: "marta@renew.example" }`. Assert after `flushPromises()` that the + current path is `/register/pending`, `/login` was never visited (no `reason=expired`), and + `sessionStorage["renewcms:pendingEmail"]` is "marta@renew.example". Must fail on the current code. +2. **Fresh store** — store idle, `/me` mocked to 200 unverified; assert the path is /register/pending. +3. **Failures** — 403 shows the owner-exists panel, 500 shows the error alert; in both the path stays + /register and `/me` is never fetched. +4. Update the existing test at RegisterView.spec.ts:328-370 so its fetch router also handles + `GET /v1/auth/me`. + +## Visual + +No new states. `task visual:check -- register` and `-- pending` must stay green. + +## Manual + +On a fresh install open `/`, go to Create the owner account, register, and confirm the "Check your +email" panel appears. Real SMTP delivery of the verification mail is manual. diff --git a/web/src/views/RegisterView.vue b/web/src/views/RegisterView.vue index 3843039..f15c413 100644 --- a/web/src/views/RegisterView.vue +++ b/web/src/views/RegisterView.vue @@ -7,11 +7,13 @@ import AuthShell from '@/components/AuthShell.vue' import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue' import ResultPanel from '@/components/ResultPanel.vue' import { ApiError, useApi } from '@/composables/useApi' +import { useSessionStore } from '@/stores/session' const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const router = useRouter() const api = useApi() +const session = useSessionStore() const username = ref('') const email = ref('') @@ -84,6 +86,7 @@ async function onSubmit() { }), }) sessionStorage.setItem('renewcms:pendingEmail', email.value) + await session.retry() await router.push('/register/pending') } catch (error) { if (error instanceof ApiError && error.status === 403) { diff --git a/web/src/views/__tests__/RegisterView.spec.ts b/web/src/views/__tests__/RegisterView.spec.ts index ed9a290..3611794 100644 --- a/web/src/views/__tests__/RegisterView.spec.ts +++ b/web/src/views/__tests__/RegisterView.spec.ts @@ -4,13 +4,10 @@ import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import router from '@/router' import ResultPanel from '@/components/ResultPanel.vue' +import { useSessionStore } from '@/stores/session' import RegisterView from '../RegisterView.vue' -function mockResponse( - status: number, - body: unknown = null, - headers: Record = {}, -) { +function mockResponse(status: number, body: unknown = null, headers: Record = {}) { return { ok: status >= 200 && status < 300, status, @@ -24,9 +21,7 @@ function routeFor(url: string, init?: RequestInit) { return `${method} ${url}` } -function fetchRouter( - handlers: Record Response | Promise>, -) { +function fetchRouter(handlers: Record Response | Promise>) { return vi.fn((url: string, init?: RequestInit) => { const key = routeFor(url, init) const handler = handlers[key] @@ -41,8 +36,12 @@ let currentWrapper: Awaited> | null = null async function mountRegister( handlers: Record Response | Promise> = {}, + prepareSession?: (session: ReturnType) => void, ) { setActivePinia(createPinia()) + if (prepareSession) { + prepareSession(useSessionStore()) + } const fetchMock = fetchRouter(handlers) global.fetch = fetchMock as unknown as typeof fetch await router.push('/register') @@ -69,9 +68,7 @@ function passwordInput(wrapper: Awaited>) { return wrapper.find('input[autocomplete="new-password"]') } -async function fillValidForm( - wrapper: Awaited>, -) { +async function fillValidForm(wrapper: Awaited>) { await usernameInput(wrapper).setValue('marta') await emailInput(wrapper).setValue('marta@renew.example') await passwordInput(wrapper).setValue('limewash2026') @@ -92,21 +89,15 @@ describe('Default — a fresh, empty form', () => { "This creates the single admin account for this site. Once it's verified, registration closes.", ) - expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe( - '', - ) + expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('') expect(wrapper.text()).toContain('0 / 20') expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('') - expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe( - '', - ) + expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('') expect(wrapper.text()).toContain('At least 8 characters') - expect(wrapper.find('button[type="submit"]').text()).toBe( - 'Create account', - ) + expect(wrapper.find('button[type="submit"]').text()).toBe('Create account') expect(wrapper.text()).toContain('Already set up?') const footerLink = wrapper.find('a[href="/login"]') expect(footerLink.exists()).toBe(true) @@ -132,18 +123,14 @@ describe('Validation errors — checked on blur and on submit', () => { expect(wrapper.text()).toContain('2 / 20') expect(wrapper.text()).toContain('Use 3 to 20 characters.') - expect(wrapper.text()).toContain( - 'Enter a full email address, like name@example.com.', - ) + expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.') await wrapper.find('form').trigger('submit') await flushPromises() expect(register).not.toHaveBeenCalled() expect(wrapper.text()).toContain('Use 3 to 20 characters.') - expect(wrapper.text()).toContain( - 'Enter a full email address, like name@example.com.', - ) + expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.') }) }) @@ -156,11 +143,9 @@ describe('Filled, valid — every rule is met', () => { const toggle = wrapper.find('button[aria-label="Show password"]') await toggle.trigger('click') - expect( - wrapper - .find('button[aria-label="Hide password"]') - .attributes('aria-pressed'), - ).toBe('true') + expect(wrapper.find('button[aria-label="Hide password"]').attributes('aria-pressed')).toBe( + 'true', + ) expect(wrapper.text()).toContain('5 / 20') expect(wrapper.text()).not.toContain('Use 3 to 20 characters.') @@ -168,17 +153,11 @@ describe('Filled, valid — every rule is met', () => { "Letters and numbers, 3 to 20 characters. You'll use it to log in.", ) - expect(wrapper.text()).not.toContain( - 'Enter a full email address, like name@example.com.', - ) + expect(wrapper.text()).not.toContain('Enter a full email address, like name@example.com.') expect(wrapper.text()).toContain("We'll send a verification link here.") - expect((passwordInput(wrapper).element as HTMLInputElement).type).toBe( - 'text', - ) - expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe( - 'limewash2026', - ) + expect((passwordInput(wrapper).element as HTMLInputElement).type).toBe('text') + expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026') const submitButton = wrapper.find('button[type="submit"]') expect(submitButton.text()).toBe('Create account') @@ -200,15 +179,9 @@ describe('Submitting — fields and button are disabled while the request is in await wrapper.find('form').trigger('submit') await flushPromises() - expect( - (usernameInput(wrapper).element as HTMLInputElement).disabled, - ).toBe(true) - expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe( - true, - ) - expect( - (passwordInput(wrapper).element as HTMLInputElement).disabled, - ).toBe(true) + expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(true) + expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true) + expect((passwordInput(wrapper).element as HTMLInputElement).disabled).toBe(true) const submitButton = wrapper.find('button[type="submit"]') expect(submitButton.text()).toBe('Creating account…') @@ -255,9 +228,7 @@ describe('Registration closed — the form is replaced by a ResultPanel', () => expect(action.text()).toBe('Go to log in') await action.trigger('click') - await vi.waitFor(() => - expect(router.currentRoute.value.path).toBe('/login'), - ) + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login')) }) }) @@ -268,76 +239,61 @@ describe('Server or network error — the form is kept and re-enabled', () => { 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 register = vi.fn(handler) - const { wrapper, fetchMock } = await mountRegister({ - 'POST /v1/auth/register': register, - }) + ])('shows the error alert and keeps the values for $name', async ({ handler }) => { + const register = vi.fn(handler) + const { wrapper, fetchMock } = await mountRegister({ + 'POST /v1/auth/register': register, + }) - await fillValidForm(wrapper) - await wrapper.find('form').trigger('submit') - await flushPromises() + await fillValidForm(wrapper) + await wrapper.find('form').trigger('submit') + await flushPromises() - expect(fetchMock).toHaveBeenCalledWith( - '/v1/auth/register', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - username: 'marta', - email: 'marta@renew.example', - password: 'limewash2026', - }), + expect(fetchMock).toHaveBeenCalledWith( + '/v1/auth/register', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + username: 'marta', + email: 'marta@renew.example', + password: 'limewash2026', }), - ) - expect(wrapper.text()).toContain("Couldn't create the account") - expect(wrapper.text()).toContain( - 'Something went wrong on the server. Try again in a moment.', - ) + }), + ) + expect(wrapper.text()).toContain("Couldn't create the account") + expect(wrapper.text()).toContain( + 'Something went wrong on the server. Try again in a moment.', + ) - expect( - (usernameInput(wrapper).element as HTMLInputElement).value, - ).toBe('marta') - expect( - (emailInput(wrapper).element as HTMLInputElement).value, - ).toBe('marta@renew.example') - expect( - (passwordInput(wrapper).element as HTMLInputElement).value, - ).toBe('limewash2026') + expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('marta') + expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example') + expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026') - expect( - (usernameInput(wrapper).element as HTMLInputElement).disabled, - ).toBe(false) - expect( - (emailInput(wrapper).element as HTMLInputElement).disabled, - ).toBe(false) - expect( - (passwordInput(wrapper).element as HTMLInputElement).disabled, - ).toBe(false) + expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(false) + expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false) + expect((passwordInput(wrapper).element as HTMLInputElement).disabled).toBe(false) - const submitButton = wrapper.find('button[type="submit"]') - expect(submitButton.text()).toBe('Create account') - expect((submitButton.element as HTMLButtonElement).disabled).toBe( - false, - ) - }, - ) + const submitButton = wrapper.find('button[type="submit"]') + expect(submitButton.text()).toBe('Create account') + expect((submitButton.element as HTMLButtonElement).disabled).toBe(false) + }) }) describe('A successful registration goes straight to the pending page', () => { it('navigates directly to /register/pending on submit resolution', async () => { const { wrapper, fetchMock } = await mountRegister({ 'POST /v1/auth/register': () => mockResponse(200), - 'GET /v1/auth/me': () => mockResponse(200, { is_verified: false }), + 'GET /v1/auth/me': () => + mockResponse(200, { + is_verified: false, + email: 'marta@renew.example', + }), }) await fillValidForm(wrapper) await wrapper.find('form').trigger('submit') - await vi.waitFor(() => - expect(router.currentRoute.value.path).toBe('/register/pending'), - ) + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending')) expect(fetchMock).toHaveBeenCalledWith( '/v1/auth/register', @@ -356,18 +312,74 @@ describe('A successful registration goes straight to the pending page', () => { sessionStorage.removeItem('renewcms:pendingEmail') const { wrapper } = await mountRegister({ 'POST /v1/auth/register': () => mockResponse(200), - 'GET /v1/auth/me': () => mockResponse(200, { is_verified: false }), + 'GET /v1/auth/me': () => + mockResponse(200, { + is_verified: false, + email: 'marta@renew.example', + }), }) await fillValidForm(wrapper) await wrapper.find('form').trigger('submit') - await vi.waitFor(() => - expect(router.currentRoute.value.path).toBe('/register/pending'), - ) + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending')) - expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe( - 'marta@renew.example', + expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe('marta@renew.example') + }) + + it('stays on /register/pending after the session store cached an anonymous 401', async () => { + const me = vi.fn(() => + mockResponse(200, { + is_verified: false, + email: 'marta@renew.example', + }), ) + const { wrapper } = await mountRegister( + { + 'POST /v1/auth/register': () => mockResponse(200), + 'GET /v1/auth/me': me, + }, + (session) => session.clear(), + ) + const session = useSessionStore() + expect(session.session).toBeNull() + expect(session.status).toBe('ready') + + await fillValidForm(wrapper) + await wrapper.find('form').trigger('submit') + await flushPromises() + + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending')) + await flushPromises() + + expect(router.currentRoute.value.path).toBe('/register/pending') + expect(router.currentRoute.value.query.reason).toBeUndefined() + expect(session.session?.verified).toBe(false) + expect(session.session?.email).toBe('marta@renew.example') + expect(me).toHaveBeenCalled() + }) +}) + +describe('A failed registration never navigates', () => { + it.each([ + { status: 403, text: 'This site already has an owner' }, + { status: 500, text: "Couldn't create the account" }, + ])('stays on /register and does not fetch /me for a $status', async ({ status, text }) => { + const me = vi.fn(() => mockResponse(200, { is_verified: false })) + const { wrapper } = await mountRegister( + { + 'POST /v1/auth/register': () => mockResponse(status), + 'GET /v1/auth/me': me, + }, + (session) => session.clear(), + ) + + await fillValidForm(wrapper) + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(router.currentRoute.value.path).toBe('/register') + expect(wrapper.text()).toContain(text) + expect(me).not.toHaveBeenCalled() }) })