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 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) {
+123 -111
View File
@@ -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<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -24,9 +21,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -41,8 +36,12 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountRegister(
handlers: Record<string, () => Response | Promise<Response>> = {},
prepareSession?: (session: ReturnType<typeof useSessionStore>) => 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<ReturnType<typeof mountRegister>>) {
return wrapper.find('input[autocomplete="new-password"]')
}
async function fillValidForm(
wrapper: Awaited<ReturnType<typeof mountRegister>>,
) {
async function fillValidForm(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
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()
})
})