feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
1 parent
bb191c389a
commit
10a66cd979
123 files changed
+10789
-76
No files matched your search
@@ -0,0 +1,268 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSessionStore, type Session } from '@/stores/session'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import EmailConfirmView from '../EmailConfirmView.vue'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
})
|
||||
|
||||
const CONFIRM_PATH = '/v1/auth/email/confirm'
|
||||
const LOGIN_PATH = '/v1/auth/login'
|
||||
const NEW = 'marta.alves@atelier-renew.example'
|
||||
const LOGGED_IN: Session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
displayName: '',
|
||||
}
|
||||
const SERVER_COPY = 'Something went wrong on our side. Nothing was changed. Try again in a moment.'
|
||||
|
||||
type Handler = (init?: RequestInit) => Promise<unknown>
|
||||
|
||||
let mounted: VueWrapper | null = null
|
||||
|
||||
async function mountLanding(
|
||||
path: string,
|
||||
handlers: Record<string, Handler> = {},
|
||||
session: Session | null = null,
|
||||
) {
|
||||
setActivePinia(createPinia())
|
||||
if (session) {
|
||||
const store = useSessionStore()
|
||||
store.session = { ...session }
|
||||
store.status = 'ready'
|
||||
}
|
||||
const request = vi.fn((requestPath: string, init?: RequestInit) => {
|
||||
const handler = handlers[`${init?.method ?? 'GET'} ${requestPath}`]
|
||||
if (!handler) {
|
||||
throw new Error(`Unexpected request: ${init?.method ?? 'GET'} ${requestPath}`)
|
||||
}
|
||||
return handler(init)
|
||||
})
|
||||
const getMe = vi.fn(() =>
|
||||
Promise.resolve({ verified: true, username: 'marta', email: NEW, displayName: '' }),
|
||||
)
|
||||
vi.mocked(useApi).mockReturnValue({ request, getMe } as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(path)
|
||||
mounted = mount(EmailConfirmView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper: mounted, request, getMe }
|
||||
}
|
||||
|
||||
function confirmCalls(request: ReturnType<typeof vi.fn>) {
|
||||
return request.mock.calls.filter(([path]) => path === CONFIRM_PATH)
|
||||
}
|
||||
|
||||
function button(wrapper: VueWrapper, label: string) {
|
||||
return wrapper.findAll('button').find((candidate) => candidate.text() === label)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
mounted?.unmount()
|
||||
mounted = null
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Confirming: the code is sent on load', () => {
|
||||
it('shows the spinner copy and sends the code once', async () => {
|
||||
const { wrapper, request } = await mountLanding('/account/email/confirm?c=abc123', {
|
||||
[`POST ${CONFIRM_PATH}`]: () => new Promise(() => {}),
|
||||
})
|
||||
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Confirming your new email…')
|
||||
expect(confirmCalls(request)).toHaveLength(1)
|
||||
const [, init] = confirmCalls(request)[0]!
|
||||
expect(JSON.parse((init as RequestInit).body as string)).toEqual({ code: 'abc123' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Success: 200 replaces the session email, toasts and goes to the profile', () => {
|
||||
it('shows the new address, raises the toast, updates the session and navigates after 800 ms', async () => {
|
||||
const { wrapper } = await mountLanding(
|
||||
'/account/email/confirm?c=abc123',
|
||||
{ [`POST ${CONFIRM_PATH}`]: () => Promise.resolve({ email: NEW }) },
|
||||
LOGGED_IN,
|
||||
)
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('Your email address has changed')
|
||||
expect(wrapper.text()).toContain(
|
||||
`Your account now uses ${NEW}. Use it the next time you log in.`,
|
||||
)
|
||||
expect(button(wrapper, 'Go to profile')).toBeDefined()
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Email address changed',
|
||||
description: `Your account now uses ${NEW}.`,
|
||||
})
|
||||
expect(useSessionStore().session?.email).toBe(NEW)
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/profile'), {
|
||||
timeout: 2000,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invalid or expired (403)', () => {
|
||||
it('shows the dead-link copy and the profile action', async () => {
|
||||
const { wrapper } = await mountLanding(
|
||||
'/account/email/confirm?c=abc123',
|
||||
{
|
||||
[`POST ${CONFIRM_PATH}`]: () =>
|
||||
Promise.reject(new ApiError(403, undefined, 'invalid_email_change_code')),
|
||||
},
|
||||
LOGGED_IN,
|
||||
)
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe("This link doesn't work")
|
||||
expect(wrapper.text()).toContain(
|
||||
'It may have expired (links last 2 hours), been cancelled or already been used.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Request a new link from your profile.')
|
||||
|
||||
await button(wrapper, 'Go to profile')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Address unavailable (409)', () => {
|
||||
it('shows one message whether the address is taken or rejected', async () => {
|
||||
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
|
||||
[`POST ${CONFIRM_PATH}`]: () =>
|
||||
Promise.reject(new ApiError(409, undefined, 'email_unavailable')),
|
||||
})
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe("We couldn't change your email address")
|
||||
expect(wrapper.text()).toContain(
|
||||
"This address can't be used for your account. Go back to your profile and try a different one.",
|
||||
)
|
||||
expect(button(wrapper, 'Go to profile')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('No session (401): the inline log-in', () => {
|
||||
it('shows the log-in form with the confirm copy', async () => {
|
||||
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
|
||||
[`POST ${CONFIRM_PATH}`]: () => Promise.reject(new ApiError(401)),
|
||||
})
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('Log in to confirm your new email')
|
||||
expect(wrapper.text()).toContain(
|
||||
"You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.",
|
||||
)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and confirm')
|
||||
})
|
||||
|
||||
it('logs in, then sends the confirmation once with the same code and reaches Success', async () => {
|
||||
const confirm = vi.fn()
|
||||
confirm.mockRejectedValueOnce(new ApiError(401))
|
||||
confirm.mockResolvedValueOnce({ email: NEW })
|
||||
const { wrapper, request } = await mountLanding('/account/email/confirm?c=abc123', {
|
||||
[`POST ${CONFIRM_PATH}`]: confirm,
|
||||
[`POST ${LOGIN_PATH}`]: () => Promise.resolve({}),
|
||||
})
|
||||
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirm).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('h1').text()).toBe('Your email address has changed')
|
||||
const bodies = confirmCalls(request).map(([, init]) => (init as RequestInit).body)
|
||||
expect(bodies).toEqual([
|
||||
JSON.stringify({ code: 'abc123' }),
|
||||
JSON.stringify({ code: 'abc123' }),
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/account/email/confirm')
|
||||
const [, loginInit] = request.mock.calls.find(([path]) => path === LOGIN_PATH)!
|
||||
expect((loginInit as RequestInit).body).not.toContain('abc123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Missing code', () => {
|
||||
it('sends no request and offers Go to log in', async () => {
|
||||
const { wrapper, request } = await mountLanding('/account/email/confirm')
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('This link is incomplete')
|
||||
expect(wrapper.text()).toContain(
|
||||
'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.',
|
||||
)
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
|
||||
await button(wrapper, 'Go to log in')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Server error', () => {
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])('after %s offers Try again, which sends the POST again', async (_label, failure) => {
|
||||
const confirm = vi.fn()
|
||||
confirm.mockImplementationOnce(failure)
|
||||
confirm.mockResolvedValueOnce({ email: NEW })
|
||||
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
|
||||
[`POST ${CONFIRM_PATH}`]: confirm,
|
||||
})
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe("Couldn't confirm the change")
|
||||
expect(wrapper.text()).toContain(SERVER_COPY)
|
||||
|
||||
await button(wrapper, 'Try again')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirm).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('h1').text()).toBe('Your email address has changed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The code never stays in the URL', () => {
|
||||
it('removes ?c= from the address while still confirming with it, and Try again reuses it', async () => {
|
||||
const confirm = vi.fn()
|
||||
confirm.mockRejectedValueOnce(new ApiError(500))
|
||||
confirm.mockResolvedValueOnce({ email: NEW })
|
||||
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
|
||||
[`POST ${CONFIRM_PATH}`]: confirm,
|
||||
})
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/account/email/confirm')
|
||||
expect(router.currentRoute.value.query).toEqual({})
|
||||
|
||||
await button(wrapper, 'Try again')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const bodies = confirm.mock.calls.map(([init]) => (init as RequestInit).body)
|
||||
expect(bodies).toEqual([
|
||||
JSON.stringify({ code: 'abc123' }),
|
||||
JSON.stringify({ code: 'abc123' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user