fix(register): onSubmit now calls await session.retry() before going to /register/pending
This commit is contained in:
1 parent
160c0a49ce
commit
fe254112a2
4 files changed
+169
-88
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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,9 +239,7 @@ 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 }) => {
|
||||
])('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,
|
||||
@@ -296,48 +265,35 @@ describe('Server or network error — the form is kept and re-enabled', () => {
|
||||
'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,
|
||||
)
|
||||
},
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user