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

+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()
})
})