fix(register): onSubmit now calls await session.retry() before going to /register/pending

This commit is contained in:
Florian Sylvain committed 2026-10-13 02:49:43 +02:00
1 parent 160c0a49ce
commit fe254112a2
4 files changed
+192 -111

No files matched your search

@@ -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 <status>
Then the app stays on /register
And <visible>
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 |
@@ -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.
+3
View File
@@ -7,11 +7,13 @@ import AuthShell from '@/components/AuthShell.vue'
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue' import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
import ResultPanel from '@/components/ResultPanel.vue' import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi' import { ApiError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const router = useRouter() const router = useRouter()
const api = useApi() const api = useApi()
const session = useSessionStore()
const username = ref('') const username = ref('')
const email = ref('') const email = ref('')
@@ -84,6 +86,7 @@ async function onSubmit() {
}), }),
}) })
sessionStorage.setItem('renewcms:pendingEmail', email.value) sessionStorage.setItem('renewcms:pendingEmail', email.value)
await session.retry()
await router.push('/register/pending') await router.push('/register/pending')
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.status === 403) { if (error instanceof ApiError && error.status === 403) {
+123 -111
View File
@@ -4,13 +4,10 @@ import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin' import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router' import router from '@/router'
import ResultPanel from '@/components/ResultPanel.vue' import ResultPanel from '@/components/ResultPanel.vue'
import { useSessionStore } from '@/stores/session'
import RegisterView from '../RegisterView.vue' import RegisterView from '../RegisterView.vue'
function mockResponse( function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return { return {
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
status, status,
@@ -24,9 +21,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}` return `${method} ${url}`
} }
function fetchRouter( function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => { return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init) const key = routeFor(url, init)
const handler = handlers[key] const handler = handlers[key]
@@ -41,8 +36,12 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountRegister( async function mountRegister(
handlers: Record<string, () => Response | Promise<Response>> = {}, handlers: Record<string, () => Response | Promise<Response>> = {},
prepareSession?: (session: ReturnType<typeof useSessionStore>) => void,
) { ) {
setActivePinia(createPinia()) setActivePinia(createPinia())
if (prepareSession) {
prepareSession(useSessionStore())
}
const fetchMock = fetchRouter(handlers) const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch global.fetch = fetchMock as unknown as typeof fetch
await router.push('/register') await router.push('/register')
@@ -69,9 +68,7 @@ function passwordInput(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
return wrapper.find('input[autocomplete="new-password"]') return wrapper.find('input[autocomplete="new-password"]')
} }
async function fillValidForm( async function fillValidForm(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
wrapper: Awaited<ReturnType<typeof mountRegister>>,
) {
await usernameInput(wrapper).setValue('marta') await usernameInput(wrapper).setValue('marta')
await emailInput(wrapper).setValue('marta@renew.example') await emailInput(wrapper).setValue('marta@renew.example')
await passwordInput(wrapper).setValue('limewash2026') 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.", "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(wrapper.text()).toContain('0 / 20')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('') 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.text()).toContain('At least 8 characters')
expect(wrapper.find('button[type="submit"]').text()).toBe( expect(wrapper.find('button[type="submit"]').text()).toBe('Create account')
'Create account',
)
expect(wrapper.text()).toContain('Already set up?') expect(wrapper.text()).toContain('Already set up?')
const footerLink = wrapper.find('a[href="/login"]') const footerLink = wrapper.find('a[href="/login"]')
expect(footerLink.exists()).toBe(true) 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('2 / 20')
expect(wrapper.text()).toContain('Use 3 to 20 characters.') expect(wrapper.text()).toContain('Use 3 to 20 characters.')
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.')
'Enter a full email address, like name@example.com.',
)
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
expect(register).not.toHaveBeenCalled() expect(register).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Use 3 to 20 characters.') expect(wrapper.text()).toContain('Use 3 to 20 characters.')
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.')
'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"]') const toggle = wrapper.find('button[aria-label="Show password"]')
await toggle.trigger('click') await toggle.trigger('click')
expect( expect(wrapper.find('button[aria-label="Hide password"]').attributes('aria-pressed')).toBe(
wrapper 'true',
.find('button[aria-label="Hide password"]') )
.attributes('aria-pressed'),
).toBe('true')
expect(wrapper.text()).toContain('5 / 20') expect(wrapper.text()).toContain('5 / 20')
expect(wrapper.text()).not.toContain('Use 3 to 20 characters.') 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.", "Letters and numbers, 3 to 20 characters. You'll use it to log in.",
) )
expect(wrapper.text()).not.toContain( expect(wrapper.text()).not.toContain('Enter a full email address, like name@example.com.')
'Enter a full email address, like name@example.com.',
)
expect(wrapper.text()).toContain("We'll send a verification link here.") expect(wrapper.text()).toContain("We'll send a verification link here.")
expect((passwordInput(wrapper).element as HTMLInputElement).type).toBe( expect((passwordInput(wrapper).element as HTMLInputElement).type).toBe('text')
'text', expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
)
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe(
'limewash2026',
)
const submitButton = wrapper.find('button[type="submit"]') const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account') 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 wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
expect( expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
(usernameInput(wrapper).element as HTMLInputElement).disabled, expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
).toBe(true) expect((passwordInput(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"]') const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Creating account…') 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') expect(action.text()).toBe('Go to log in')
await action.trigger('click') await action.trigger('click')
await vi.waitFor(() => await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
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', name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')), handler: () => Promise.reject(new TypeError('Failed to fetch')),
}, },
])( ])('shows the error alert and keeps the values for $name', async ({ handler }) => {
'shows the error alert and keeps the values for $name', const register = vi.fn(handler)
async ({ handler }) => { const { wrapper, fetchMock } = await mountRegister({
const register = vi.fn(handler) 'POST /v1/auth/register': register,
const { wrapper, fetchMock } = await mountRegister({ })
'POST /v1/auth/register': register,
})
await fillValidForm(wrapper) await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register', '/v1/auth/register',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
username: 'marta', username: 'marta',
email: 'marta@renew.example', email: 'marta@renew.example',
password: 'limewash2026', password: 'limewash2026',
}),
}), }),
) }),
expect(wrapper.text()).toContain("Couldn't create the account") )
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain("Couldn't create the account")
'Something went wrong on the server. Try again in a moment.', expect(wrapper.text()).toContain(
) 'Something went wrong on the server. Try again in a moment.',
)
expect( expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('marta')
(usernameInput(wrapper).element as HTMLInputElement).value, expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example')
).toBe('marta') expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect(
(emailInput(wrapper).element as HTMLInputElement).value,
).toBe('marta@renew.example')
expect(
(passwordInput(wrapper).element as HTMLInputElement).value,
).toBe('limewash2026')
expect( expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
(usernameInput(wrapper).element as HTMLInputElement).disabled, expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
).toBe(false) expect((passwordInput(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"]') const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account') expect(submitButton.text()).toBe('Create account')
expect((submitButton.element as HTMLButtonElement).disabled).toBe( expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
false, })
)
},
)
}) })
describe('A successful registration goes straight to the pending page', () => { describe('A successful registration goes straight to the pending page', () => {
it('navigates directly to /register/pending on submit resolution', async () => { it('navigates directly to /register/pending on submit resolution', async () => {
const { wrapper, fetchMock } = await mountRegister({ const { wrapper, fetchMock } = await mountRegister({
'POST /v1/auth/register': () => mockResponse(200), '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 fillValidForm(wrapper)
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await vi.waitFor(() => await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register', '/v1/auth/register',
@@ -356,18 +312,74 @@ describe('A successful registration goes straight to the pending page', () => {
sessionStorage.removeItem('renewcms:pendingEmail') sessionStorage.removeItem('renewcms:pendingEmail')
const { wrapper } = await mountRegister({ const { wrapper } = await mountRegister({
'POST /v1/auth/register': () => mockResponse(200), '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 fillValidForm(wrapper)
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await vi.waitFor(() => await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe( expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe('marta@renew.example')
'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()
}) })
}) })