import type { APIRequestContext, Page, Route, TestInfo } from '@playwright/test'; import path from 'node:path'; import { expect, test } from '../scripts/managedTest.ts'; // Account credentials and refresh responses must not enter retained failure artifacts. test.use({ trace: 'off', video: 'off', screenshot: 'off' }); const accountPath = '/home/user/account'; const passwordSetPath = '/home/user/password-set'; const passwordResetPath = '/home/user/password-reset'; const syntheticQueryToken = 'e2e-public-query-token'; const syntheticResetQueryToken = 'e2e-reset-public-query-token-001'; const accountApiPath = '/api/home/user/account/current'; const passportCurrentApiPath = '/api/home/user/passport/current'; const passportLoginApiPath = '/api/home/user/passport/login'; const passportRegisterApiPath = '/api/home/user/passport/register'; const passportActivateCurrentApiPath = '/api/home/user/passportTest/activateCurrent'; const passportRemoveCurrentFixtureApiPath = '/api/home/user/passportTest/removeCurrentFixture'; const accountProfileApiPath = '/api/home/user/account/profile'; const imageUploadApiPath = '/api/image/upload'; const avatarFixturePath = path.resolve( import.meta.dirname, '../../zova/src/suite/a-home/modules/home-base/assets/img/avatar_user.png', ); function waitForApiResponse(page: Page, method: string, path: string) { return page.waitForResponse(response => { const url = new URL(response.url()); return ( response.request().method() === method && url.pathname === path && !response.request().headers()['x-vona-openapi-schema'] ); }); } interface RegisteredAccount { id: number | string; username: string; password: string; accessToken: string; roles: Array<{ id: number | string; name: string }>; } interface PassportJwt { passport: { user: { id: RegisteredAccount['id'] }; roles: RegisteredAccount['roles'] }; jwt: { accessToken: string }; } async function createCaptcha(request: APIRequestContext): Promise<{ id: string; token: string }> { const response = await request.post('/api/captcha/create', { data: { scene: 'captcha-simple:simple' }, }); expect(response.ok()).toBeTruthy(); const captcha = (await response.json()).data; expect(captcha?.id).toEqual(expect.any(String)); expect(captcha?.token).toEqual(expect.any(String)); return { id: captcha.id, token: captcha.token }; } async function loginAsAccount( request: APIRequestContext, username: string, password: string, ): Promise { const captcha = await createCaptcha(request); const response = await request.post(passportLoginApiPath, { data: { username, password, captcha }, }); expect(response.ok()).toBeTruthy(); const login = (await response.json()).data as PassportJwt; return { id: login.passport.user.id, username, password, accessToken: login.jwt.accessToken, roles: login.passport.roles, }; } async function registerAccountUser( request: APIRequestContext, testInfo: TestInfo, onRegistered: (account: RegisteredAccount) => void, ): Promise { const suffix = `${testInfo.workerIndex}-${testInfo.parallelIndex ?? testInfo.retry}-${crypto.randomUUID()}`; const username = `e2e-fixture-home-account-${suffix}`; const password = 'account-e2e-password'; const captcha = await createCaptcha(request); const baseURL = testInfo.project.use.baseURL; if (!baseURL) throw new Error('account E2E base URL is unavailable'); const consumerUrl = new URL('/home/user/activation', baseURL).toString(); const registerResponse = await request.post(passportRegisterApiPath, { data: { username, email: `${username}@example.test`, password, passwordConfirm: password, consumerUrl, captcha, }, }); expect(registerResponse.ok()).toBeTruthy(); const registration = (await registerResponse.json()).data as PassportJwt; const account: RegisteredAccount = { id: registration.passport.user.id, username, password, accessToken: registration.jwt.accessToken, roles: registration.passport.roles, }; onRegistered(account); expect(account.id).toEqual(expect.anything()); expect(account.accessToken).toEqual(expect.any(String)); const activateResponse = await request.post(passportActivateCurrentApiPath, { headers: { Authorization: `Bearer ${account.accessToken}` }, }); expect(activateResponse.ok()).toBeTruthy(); const activatedAccount = await loginAsAccount(request, username, password); expect(String(activatedAccount.id)).toBe(String(account.id)); return activatedAccount; } async function removeAccountFixture(request: APIRequestContext, account: RegisteredAccount) { const currentAccount = await loginAsAccount(request, account.username, account.password); if (String(currentAccount.id) !== String(account.id)) { throw new Error(`Fixture login resolved a different account: ${account.username}`); } const response = await request.delete(passportRemoveCurrentFixtureApiPath, { headers: { Authorization: `Bearer ${currentAccount.accessToken}` }, }); expect(response.ok()).toBeTruthy(); } function collectPageErrors(page: Page) { const errors: Error[] = []; page.on('pageerror', error => { errors.push(error); }); return errors; } function collectConsoleErrors(page: Page, ignored: RegExp[] = []) { const errors: string[] = []; page.on('console', message => { const text = message.text(); if ( (message.type() === 'error' || /hydration mismatch/i.test(text)) && !ignored.some(pattern => pattern.test(text)) ) { errors.push(text); } }); return errors; } async function loginAccountFromPage(page: Page, account: RegisteredAccount, returnTo: string) { await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); await page.getByPlaceholder('Your Username').fill(account.username); await page.getByPlaceholder('Your Password').fill(account.password); await expect(page.getByPlaceholder('Please input captcha')).not.toHaveValue(''); const loginResponse = waitForApiResponse(page, 'POST', passportLoginApiPath); const accountResponse = waitForApiResponse(page, 'GET', accountApiPath); await page.getByRole('button', { name: 'Login', exact: true }).click(); expect((await loginResponse).ok()).toBeTruthy(); expect((await accountResponse).ok()).toBeTruthy(); await expect(page).toHaveURL(returnTo); await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible(); } test( 'ATP-ACCOUNT-SSR-01: anonymous Account session SSR redirects to login', { tag: ['@account', '@web', '@ssr'] }, async ({ page, request }) => { const response = await request.get(accountPath, { maxRedirects: 0 }); expect(response.status()).toBeGreaterThanOrEqual(300); expect(response.status()).toBeLessThan(400); const redirectUrl = new URL(response.headers().location!, response.url()); expect(redirectUrl.pathname).toBe('/login'); expect(redirectUrl.searchParams.get('returnTo')).toBe(accountPath); const pageErrors = collectPageErrors(page); const consoleErrors = collectConsoleErrors(page); const documentResponse = await page.goto(accountPath, { waitUntil: 'load' }); expect(documentResponse?.ok()).toBeTruthy(); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); await expect(page).toHaveURL(/\/login(?:\?|$)/); expect(new URL(page.url()).searchParams.get('returnTo')).toBe(accountPath); await expect(page.getByRole('heading', { name: 'Account Settings' })).toHaveCount(0); expect(pageErrors).toEqual([]); expect(consoleErrors).toEqual([]); }, ); test( 'ATP-HUA-REG-01: Login registration uses the Passport contract and defers site admission until activation', { tag: ['@account', '@web', '@flow'] }, async ({ page, request }, testInfo) => { const suffix = `${testInfo.workerIndex}-${testInfo.parallelIndex ?? testInfo.retry}-${crypto.randomUUID()}`; const username = `e2e-fixture-home-account-register-${suffix}`; const password = 'account-e2e-password'; let account: RegisteredAccount | undefined; try { await page.goto(accountPath, { waitUntil: 'load' }); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); await expect(page).toHaveURL(/\/login(?:\?|$)/); expect(new URL(page.url()).searchParams.get('returnTo')).toBe(accountPath); await page.getByRole('button', { name: 'Create account', exact: true }).click(); await expect(page).toHaveURL( new RegExp(`/home/login/register\\?returnTo=${encodeURIComponent(accountPath)}`), ); await expect(page.getByRole('heading', { name: 'Create account' })).toBeVisible(); await page.getByRole('group', { name: 'User Name *' }).getByRole('textbox').fill(username); await page .getByRole('group', { name: 'Email *' }) .getByRole('textbox') .fill(`${username}@example.test`); await page.locator('input[name="password"]').fill(password); await page.locator('input[name="passwordConfirm"]').fill(password); await expect(page.getByPlaceholder('Please input captcha')).not.toHaveValue(''); const registerResponse = waitForApiResponse(page, 'POST', passportRegisterApiPath); await page.getByRole('button', { name: 'Create account', exact: true }).click(); const registrationResponse = await registerResponse; expect(registrationResponse.ok()).toBeTruthy(); const registration = (await registrationResponse.json()).data as PassportJwt; account = { id: registration.passport.user.id, username, password, accessToken: registration.jwt.accessToken, roles: registration.passport.roles, }; expect(account.id).toEqual(expect.anything()); expect(account.accessToken).toEqual(expect.any(String)); await expect(page).toHaveURL( new RegExp(`/home/login/register\\?returnTo=${encodeURIComponent(accountPath)}`), ); await expect( page.getByText('Check your email to activate your account before signing in.', { exact: true, }), ).toBeVisible(); await page.getByRole('button', { name: 'Back to login', exact: true }).click(); await expect(page).toHaveURL(/\/login(?:\?|$)/); expect(new URL(page.url()).searchParams.get('returnTo')).toBe(accountPath); } finally { if (account) await removeAccountFixture(request, account); } }, ); test( 'ATP-HUA-RST-01: reset request remains generic after valid CAPTCHA', { tag: ['@account', '@web', '@flow'] }, async ({ page }) => { await page.goto('/login', { waitUntil: 'load' }); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); await page.getByRole('button', { name: 'Forgot password?', exact: true }).click(); await expect(page).toHaveURL('/home/login/password-reset'); await expect(page.getByRole('heading', { name: 'Reset password' })).toBeVisible(); await page.getByPlaceholder('Your email address').fill('unknown-reset@example.test'); const captcha = page.getByPlaceholder('Please input captcha'); await expect(captcha).toHaveValue(/\S+/); const requestResponse = waitForApiResponse( page, 'POST', '/api/home/user/account/password-reset/request', ); await page.getByRole('button', { name: 'Reset password', exact: true }).click(); const response = await requestResponse; expect(response.ok()).toBeTruthy(); await expect( page.getByText( 'If an eligible account matches that address, a reset link will be sent shortly.', { exact: true, }, ), ).toBeVisible(); }, ); test( 'ATP-HUA-RST-03: reset query token is scrubbed and invalid token stays nondiagnostic', { tag: ['@account', '@web', '@ssr'] }, async ({ page, request }) => { const tokenUrl = `${passwordResetPath}?token=${syntheticResetQueryToken}`; const response = await request.get(tokenUrl); expect(response.ok()).toBeTruthy(); expect(response.headers()['cache-control']).toBe('no-cache, no-store, must-revalidate'); const html = await response.text(); expect(html.toLowerCase()).not.toContain('data-zova-hydrated'); expect(html).not.toContain('New password'); expect(html).not.toContain('Confirm new password'); expect(html).not.toContain(syntheticResetQueryToken); const pageErrors = collectPageErrors(page); const consoleErrors = collectConsoleErrors(page, [/server responded with a status of 401/i]); const documentResponse = await page.goto(tokenUrl, { waitUntil: 'load' }); expect(documentResponse?.ok()).toBeTruthy(); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); await expect(page).toHaveURL(passwordResetPath); await expect(page.getByRole('heading', { name: 'Reset password' })).toBeVisible(); await expect( page.getByRole('group', { name: 'Password *', exact: true }).getByRole('textbox'), ).toBeVisible(); await expect( page.getByRole('group', { name: 'Confirm Password *', exact: true }).getByRole('textbox'), ).toBeVisible(); await expect .poll(() => page.evaluate(token => { return { pathname: window.location.pathname, search: window.location.search, hash: window.location.hash, local: Object.entries(localStorage).filter(([key, value]) => `${key}:${value}`.includes(token), ), session: Object.entries(sessionStorage).filter(([key, value]) => `${key}:${value}`.includes(token), ), }; }, syntheticResetQueryToken), ) .toEqual({ pathname: passwordResetPath, search: '', hash: '', local: [], session: [] }); await page .getByRole('group', { name: 'Password *', exact: true }) .getByRole('textbox') .fill('reset-e2e-password'); await page .getByRole('group', { name: 'Confirm Password *', exact: true }) .getByRole('textbox') .fill('reset-e2e-password'); const consumeResponse = waitForApiResponse( page, 'POST', '/api/home/user/account/password-reset/consume', ); await page.getByRole('button', { name: 'Reset password', exact: true }).click(); expect((await consumeResponse).status()).toBeGreaterThanOrEqual(400); expect(pageErrors).toEqual([]); expect(consoleErrors).toEqual([]); }, ); test( 'ATP-ACCOUNT-PUBLIC-01: password-set query token is scrubbed before public interaction', { tag: ['@account', '@web', '@ssr'] }, async ({ page, request }) => { const tokenUrl = `${passwordSetPath}?token=${syntheticQueryToken}`; const response = await request.get(tokenUrl); expect(response.ok()).toBeTruthy(); expect(response.headers()['cache-control']).toBe('no-cache, no-store, must-revalidate'); const html = await response.text(); expect(html.toLowerCase()).not.toContain('data-zova-hydrated'); expect(html).not.toContain('New password'); expect(html).not.toContain('Confirm new password'); expect(html).not.toContain(syntheticQueryToken); const pageErrors = collectPageErrors(page); const consoleErrors = collectConsoleErrors(page); const documentResponse = await page.goto(tokenUrl, { waitUntil: 'load' }); expect(documentResponse?.ok()).toBeTruthy(); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); await expect(page).toHaveURL(passwordSetPath); await expect(page.getByRole('heading', { name: 'Set password' })).toBeVisible(); await expect( page.getByRole('group', { name: 'Password *', exact: true }).getByRole('textbox'), ).toBeVisible(); await expect( page.getByRole('group', { name: 'Confirm Password *', exact: true }).getByRole('textbox'), ).toBeVisible(); await expect(page.getByRole('alert')).toHaveCount(0); await expect .poll(() => page.evaluate(token => { return { pathname: window.location.pathname, search: window.location.search, hash: window.location.hash, local: Object.entries(localStorage).filter(([key, value]) => `${key}:${value}`.includes(token), ), session: Object.entries(sessionStorage).filter(([key, value]) => `${key}:${value}`.includes(token), ), }; }, syntheticQueryToken), ) .toEqual({ pathname: passwordSetPath, search: '', hash: '', local: [], session: [] }); expect(pageErrors).toEqual([]); expect(consoleErrors).toEqual([]); }, ); test( 'ATP-ACCOUNT-WEB-01: display name refreshes the Web menu without changing the login username', { tag: ['@account', '@web', '@flow'] }, async ({ page, request }, testInfo) => { let account: RegisteredAccount | undefined; try { account = await registerAccountUser(request, testInfo, registered => { account = registered; }); expect(account.roles.map(role => role.name)).toContain('registeredUser'); const pageErrors = collectPageErrors(page); const consoleErrors = collectConsoleErrors(page); await page.goto('/login?returnTo=%2Fhome%2Fuser%2Faccount', { waitUntil: 'load' }); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); await page.getByPlaceholder('Your Username').fill(account.username); await page.getByPlaceholder('Your Password').fill(account.password); await expect(page.getByPlaceholder('Please input captcha')).not.toHaveValue(''); const loginResponse = waitForApiResponse(page, 'POST', passportLoginApiPath); const accountResponse = waitForApiResponse(page, 'GET', accountApiPath); await page.getByRole('button', { name: 'Login', exact: true }).click(); expect((await loginResponse).ok()).toBeTruthy(); const accountResponseBody = await accountResponse; expect(accountResponseBody.ok()).toBeTruthy(); expect((await accountResponseBody.json()).data.avatar).toBeNull(); await expect(page).toHaveURL(accountPath); await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible(); const avatarPreview = page.getByAltText('Choose avatar', { exact: true }); await expect(avatarPreview).toBeVisible(); await expect .poll(() => avatarPreview.evaluate(image => ({ complete: (image as HTMLImageElement).complete, naturalWidth: (image as HTMLImageElement).naturalWidth, })), ) .toEqual({ complete: true, naturalWidth: expect.any(Number) }); await expect .poll(() => avatarPreview.evaluate(image => (image as HTMLImageElement).naturalWidth)) .toBeGreaterThan(0); const username = page.getByRole('textbox', { name: 'User name', exact: true }); await expect(username).toHaveValue(account.username); await expect(username).toHaveAttribute('readonly', ''); const displayName = page .getByRole('group', { name: 'Display name', exact: true }) .getByRole('textbox'); await expect(displayName).toHaveValue(''); const profileName = `e2e-fixture-home-account-profile-${testInfo.workerIndex}-${crypto.randomUUID()}`; await displayName.fill(` ${profileName} `); const timezone = page .getByRole('group', { name: 'Time zone', exact: true }) .getByRole('textbox'); const inputWidths = await Promise.all( [username, displayName, timezone].map(input => input.evaluate(element => element.getBoundingClientRect().width), ), ); expect(inputWidths[0]).toBeGreaterThan(0); for (const width of inputWidths.slice(1)) { expect(width).toBeCloseTo(inputWidths[0], 0); } const browserTimezone = await page.evaluate( () => Intl.DateTimeFormat().resolvedOptions().timeZone, ); await expect(timezone).toHaveAttribute('placeholder', browserTimezone); await expect(timezone).toHaveValue(''); await timezone.fill('UTC'); // The password form is independently schema-rendered; profile submission must not mutate it. const passwordDraftSnapshot = await page .locator('input[type="password"]') .evaluateAll(inputs => inputs.map(input => (input as HTMLInputElement).value)); const profileResponse = waitForApiResponse(page, 'PATCH', accountProfileApiPath); const refreshedPassportResponse = waitForApiResponse(page, 'GET', passportCurrentApiPath); await page.getByRole('button', { name: 'Save profile', exact: true }).click(); const savedProfileResponse = await profileResponse; expect(savedProfileResponse.ok()).toBeTruthy(); expect((await savedProfileResponse.json()).data).toMatchObject({ name: account.username, displayName: profileName, }); expect(savedProfileResponse.request().postDataJSON()).not.toHaveProperty('name'); const savedPassportResponse = await refreshedPassportResponse; expect(savedPassportResponse.ok()).toBeTruthy(); expect((await savedPassportResponse.json()).data.user).toMatchObject({ name: account.username, displayName: profileName, }); await expect(username).toHaveValue(account.username); await expect(displayName).toHaveValue(profileName); await expect(page.getByText('Profile saved.', { exact: true })).toBeVisible(); await expect .poll(() => page .locator('input[type="password"]') .evaluateAll(inputs => inputs.map(input => (input as HTMLInputElement).value)), ) .toEqual(passwordDraftSnapshot); await page.goto('/', { waitUntil: 'load' }); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); const userMenu = page .locator('details') .filter({ has: page.getByText('Logout', { exact: true }) }); await expect(userMenu).toHaveCount(1); await expect(userMenu.locator('summary')).toContainText(profileName); await userMenu.locator('summary').click(); const actions = userMenu.locator('ul > li > a, ul > li > button'); await expect(actions).toHaveText(['Account Settings', 'Logout']); await userMenu.getByText('Account Settings', { exact: true }).click(); await expect(page).toHaveURL(accountPath); await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible(); await expect(userMenu).not.toHaveAttribute('open', ''); await expect(username).toHaveValue(account.username); await expect(displayName).toHaveValue(profileName); const originalLogin = await loginAsAccount(request, account.username, account.password); expect(String(originalLogin.id)).toBe(String(account.id)); await displayName.fill(' '); const clearedProfileResponse = waitForApiResponse(page, 'PATCH', accountProfileApiPath); const clearedPassportResponse = waitForApiResponse(page, 'GET', passportCurrentApiPath); await page.getByRole('button', { name: 'Save profile', exact: true }).click(); const clearedProfile = await clearedProfileResponse; expect(clearedProfile.ok()).toBeTruthy(); expect(clearedProfile.request().postDataJSON()).toMatchObject({ displayName: null }); const clearedProfileBody = (await clearedProfile.json()).data; expect(clearedProfileBody.name).toBe(account.username); expect(clearedProfileBody.displayName).toBeFalsy(); expect((await clearedPassportResponse).ok()).toBeTruthy(); await expect(displayName).toHaveValue(''); await expect(username).toHaveValue(account.username); await expect(userMenu.locator('summary')).toContainText(account.username); await expect(userMenu.locator('summary')).not.toContainText(profileName); expect(pageErrors).toEqual([]); expect(consoleErrors).toEqual([]); } finally { if (account) await removeAccountFixture(request, account); } }, ); test( 'ATP-ACCOUNT-AVATAR-01: avatar crop defers file upload until crop approval', { tag: ['@account', '@web', '@flow'] }, async ({ page, request }, testInfo) => { let account: RegisteredAccount | undefined; try { account = await registerAccountUser(request, testInfo, registered => { account = registered; }); expect(account.roles.map(role => role.name)).toContain('registeredUser'); const pageErrors = collectPageErrors(page); const consoleErrors = collectConsoleErrors(page); await page.goto('/login?returnTo=%2Fhome%2Fuser%2Faccount', { waitUntil: 'load' }); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); await page.getByPlaceholder('Your Username').fill(account.username); await page.getByPlaceholder('Your Password').fill(account.password); await expect(page.getByPlaceholder('Please input captcha')).not.toHaveValue(''); const loginResponse = waitForApiResponse(page, 'POST', passportLoginApiPath); const accountResponse = waitForApiResponse(page, 'GET', accountApiPath); await page.getByRole('button', { name: 'Login', exact: true }).click(); expect((await loginResponse).ok()).toBeTruthy(); expect((await accountResponse).ok()).toBeTruthy(); await expect(page).toHaveURL(accountPath); const fileInput = page.locator('input[type="file"]').first(); let uploadCount = 0; const uploadListener = (response: import('@playwright/test').Response) => { if (new URL(response.url()).pathname === imageUploadApiPath) uploadCount++; }; page.on('response', uploadListener); await fileInput.setInputFiles(avatarFixturePath); await expect(page.getByRole('button', { name: 'Apply crop', exact: true })).toBeVisible(); expect(uploadCount).toBe(0); await page.getByRole('button', { name: 'Cancel', exact: true }).click(); await expect(page.getByRole('button', { name: 'Apply crop', exact: true })).toHaveCount(0); expect(uploadCount).toBe(0); await fileInput.setInputFiles(avatarFixturePath); await expect(page.getByRole('button', { name: 'Apply crop', exact: true })).toBeVisible(); await page.waitForTimeout(1_000); await page.getByRole('button', { name: 'Apply crop', exact: true }).click(); await expect.poll(() => uploadCount, { timeout: 10_000 }).toBeGreaterThan(0); await expect(page.getByText('Avatar is ready to save.', { exact: true })).toBeVisible(); page.off('response', uploadListener); const profileResponse = page.waitForResponse(response => { const url = new URL(response.url()); return response.request().method() === 'PATCH' && url.pathname === accountProfileApiPath; }); await page.getByRole('button', { name: 'Save profile', exact: true }).click(); const profile = await profileResponse; expect(profile.ok()).toBeTruthy(); const profileBody = (await profile.json()).data; expect(profileBody.avatar).toEqual(expect.any(String)); const avatarResponse = await request.get(profileBody.avatar); expect(avatarResponse.ok()).toBeTruthy(); expect(avatarResponse.headers()['content-type']).toMatch(/^image\//); expect((await avatarResponse.body()).length).toBeGreaterThan(0); expect(pageErrors).toEqual([]); expect(consoleErrors).toEqual([]); } finally { if (account) await removeAccountFixture(request, account); } }, ); test( 'ATP-ACCOUNT-SSR-02: signed-in Account session SSR hydrates without mismatch', { tag: ['@account', '@web', '@ssr'] }, async ({ page, request }, testInfo) => { let account: RegisteredAccount | undefined; try { account = await registerAccountUser(request, testInfo, registered => { account = registered; }); expect(account.roles.map(role => role.name)).toContain('registeredUser'); const pageErrors = collectPageErrors(page); const consoleErrors = collectConsoleErrors(page); await page.goto('/login?returnTo=%2Fhome%2Fuser%2Faccount', { waitUntil: 'load' }); await loginAccountFromPage(page, account, accountPath); pageErrors.length = 0; consoleErrors.length = 0; const documentResponse = await page.reload({ waitUntil: 'load' }); expect(documentResponse?.ok()).toBeTruthy(); expect(documentResponse?.url()).toContain(accountPath); const html = await documentResponse!.text(); expect(html).toContain('Account Settings'); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible(); expect(pageErrors).toEqual([]); expect(consoleErrors).toEqual([]); } finally { if (account) await removeAccountFixture(request, account); } }, ); for (const flavor of ['web', 'admin'] as const) { test( `ATP-ACCOUNT-LOGOUT-${flavor.toUpperCase()}-01: rejected logout shows an error and preserves authentication`, { tag: ['@account', `@${flavor}`, '@flow'] }, async ({ page, request }, testInfo) => { let account: RegisteredAccount | undefined; const logoutRoute = (url: URL) => url.pathname === '/api/home/user/passport/logout'; const errorMessage = 'This operation is forbidden in demonstration mode'; let rejectedRequests = 0; const rejectLogout = async (route: Route) => { if (route.request().method() !== 'POST') return route.continue(); rejectedRequests++; await route.fulfill({ status: 403, contentType: 'application/json', body: JSON.stringify({ code: 'demo-demonstration:1001', message: errorMessage }), }); }; try { if (flavor === 'web') { account = await registerAccountUser(request, testInfo, registered => { account = registered; }); } const pageErrors = collectPageErrors(page); const consoleErrors = collectConsoleErrors(page, [ /server responded with a status of 403/i, ]); const loginPath = flavor === 'admin' ? '/admin/login' : '/login'; await page.goto(loginPath, { waitUntil: 'load' }); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', flavor); await page.getByPlaceholder('Your Username').fill(account?.username ?? 'admin'); await page.getByPlaceholder('Your Password').fill(account?.password ?? '123456'); await expect(page.getByPlaceholder('Please input captcha')).not.toHaveValue(''); const loginResponse = waitForApiResponse(page, 'POST', passportLoginApiPath); await page.getByRole('button', { name: 'Login', exact: true }).click(); expect((await loginResponse).ok()).toBeTruthy(); await expect(page).not.toHaveURL(new RegExp(`${loginPath}(?:\\?|$)`)); const signedInUrl = page.url(); const token = (await page.context().cookies()).find( cookie => cookie.name === 'token', )?.value; expect(token).toBeTruthy(); const userMenu = page .locator('details') .filter({ has: page.getByText('Logout', { exact: true }) }); await expect(userMenu).toHaveCount(1); const logout = userMenu.getByText('Logout', { exact: true }); await page.route(logoutRoute, rejectLogout); for (let attempt = 1; attempt <= 2; attempt++) { await userMenu.locator('summary').click(); await logout.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toHaveCount(1); await expect(dialog).toBeVisible(); await expect(dialog).toContainText(errorMessage); await expect(userMenu).not.toHaveAttribute('open', ''); await expect(logout).not.toHaveAttribute('aria-busy', 'true'); expect(rejectedRequests).toBe(attempt); expect(page.url()).toBe(signedInUrl); expect( (await page.context().cookies()).find(cookie => cookie.name === 'token')?.value, ).toBe(token); await dialog.getByRole('button', { name: 'Close', exact: true }).click(); await expect(dialog).toHaveCount(0); } await userMenu.locator('summary').click(); const accountResponse = waitForApiResponse(page, 'GET', accountApiPath); await userMenu.getByText('Account Settings', { exact: true }).click(); expect((await accountResponse).ok()).toBeTruthy(); await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible(); await page.unroute(logoutRoute, rejectLogout); if (flavor === 'web') { await userMenu.locator('summary').click(); const logoutResponse = waitForApiResponse(page, 'POST', '/api/home/user/passport/logout'); await logout.click(); expect((await logoutResponse).ok()).toBeTruthy(); await expect(page).toHaveURL(/\/login(?:\?|$)/); await expect(page.getByText('Logout', { exact: true })).toHaveCount(0); expect( (await page.context().cookies()).find(cookie => cookie.name === 'token')?.value, ).toBeFalsy(); await page.goto(accountPath, { waitUntil: 'load' }); await expect(page).toHaveURL(/\/login(?:\?|$)/); } expect(pageErrors).toEqual([]); expect(consoleErrors).toEqual([]); } finally { if (!page.isClosed()) await page.unroute(logoutRoute, rejectLogout); if (account) await removeAccountFixture(request, account); } }, ); } test( 'ATP-ACCOUNT-ADMIN-01: Account Settings precedes Logout and reaches the shared page', { tag: ['@account', '@admin'] }, async ({ page }) => { await page.goto('/admin/', { waitUntil: 'load' }); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin'); if (page.url().includes('/admin/login')) { const usernameInput = page.getByPlaceholder('Your Username'); const passwordInput = page.getByPlaceholder('Your Password'); await usernameInput.fill('admin'); await passwordInput.fill('123456'); await expect(usernameInput).toHaveValue('admin'); await expect(passwordInput).toHaveValue('123456'); await expect(page.getByPlaceholder('Please input captcha')).not.toHaveValue(''); const loginResponse = waitForApiResponse(page, 'POST', passportLoginApiPath); await page.getByRole('button', { name: 'Login', exact: true }).click(); expect((await loginResponse).ok()).toBeTruthy(); await expect(page).not.toHaveURL(/\/admin\/login(?:\?|$)/); } await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin'); const userMenu = page .locator('details') .filter({ has: page.getByText('Logout', { exact: true }) }); await expect(userMenu).toHaveCount(1); const summary = userMenu.locator('summary'); await summary.click(); const actions = userMenu.locator('ul > li > a, ul > li > button'); await expect(actions).toHaveText(['Account Settings', 'Logout']); const accountResponse = page.waitForResponse(response => { const url = new URL(response.url()); return response.request().method() === 'GET' && url.pathname === accountApiPath; }); await userMenu.getByText('Account Settings', { exact: true }).click(); expect((await accountResponse).ok()).toBeTruthy(); await expect(page).toHaveURL(/\/home\/user\/account(?:\?|$)/); await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible(); await expect(userMenu).not.toHaveAttribute('open', ''); }, ); test.describe('Account errorExpired recovery', () => { for (const scenario of ['missing refresh credentials', 'refresh 401', 'real refresh'] as const) { test( `errorExpired: ${scenario}`, { tag: ['@account', '@web', '@flow'] }, async ({ page, request }, testInfo) => { let account: RegisteredAccount | undefined; const missingRefresh = scenario === 'missing refresh credentials'; const realRefresh = scenario === 'real refresh'; const returnTo = `${accountPath}?recovery=${scenario.replaceAll(' ', '-')}`; const refreshPath = '/api/home/user/passport/refreshAuthToken'; const pageErrors = collectPageErrors(page); const consoleErrors = collectConsoleErrors(page, [ /server responded with a status of 401/i, ]); let refreshRequests = 0; const origin = new URL(testInfo.project.use.baseURL!).origin; page.on('request', outgoing => { const url = new URL(outgoing.url()); if ( url.origin === origin && url.pathname === refreshPath && outgoing.method() === 'POST' && !outgoing.headers()['x-vona-openapi-schema'] ) { refreshRequests++; } }); try { account = await registerAccountUser(request, testInfo, registered => { account = registered; }); await page.goto(`/login?${new URLSearchParams({ returnTo })}`, { waitUntil: 'load', }); await loginAccountFromPage(page, account, returnTo); expect(refreshRequests).toBe(0); expect( await page.evaluate(missing => { const jwt = localStorage.getItem('jwt'); const expires = localStorage.getItem('expireTime'); if ( !jwt || !JSON.parse(jwt)?.refreshToken || !expires || typeof JSON.parse(expires) !== 'number' || localStorage.getItem('passport') === null ) { return false; } if (missing) { localStorage.removeItem('jwt'); } else { // Retain genuine credentials; expire only the browser's persisted deadline. localStorage.setItem('expireTime', JSON.stringify(Date.now() - 60_000)); } return true; }, missingRefresh), ).toBe(true); expect((await page.context().cookies()).some(cookie => cookie.name === 'token')).toBe( true, ); if (!missingRefresh && !realRefresh) { await page.route( url => url.origin === origin && url.pathname === refreshPath, async route => { if ( route.request().method() !== 'POST' || route.request().headers()['x-vona-openapi-schema'] ) { await route.fallback(); return; } await route.fulfill({ status: 401, contentType: 'application/json', headers: { 'cache-control': 'no-store' }, body: JSON.stringify({ code: 401, message: 'Unauthorized' }), }); }, ); } const refreshResponse = missingRefresh ? undefined : waitForApiResponse(page, 'POST', refreshPath); await page.goto(`/home/base/errorExpired?${new URLSearchParams({ returnTo })}`, { waitUntil: 'load', }); if (refreshResponse) { expect((await refreshResponse).status()).toBe(realRefresh ? 200 : 401); } if (realRefresh) { await expect(page).toHaveURL(returnTo); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible(); expect( await page.evaluate(() => { const expires = localStorage.getItem('expireTime'); return !!expires && JSON.parse(expires) > Date.now(); }), ).toBe(true); expect((await page.context().cookies()).some(cookie => cookie.name === 'token')).toBe( true, ); expect(refreshRequests).toBe(1); } else { const expectRecoveryLogin = async () => { await expect(page.getByRole('heading', { name: 'Login', exact: true })).toBeVisible(); const url = new URL(page.url()); expect(url.pathname).toBe('/login'); expect(url.searchParams.get('returnTo')).toBe(returnTo); await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web'); }; await expectRecoveryLogin(); await expect .poll(async () => { return (await page.context().cookies()).some(cookie => cookie.name === 'token'); }) .toBe(false); expect( await page.evaluate(() => { return ['jwt', 'passport', 'expireTime'].every( key => localStorage.getItem(key) === null, ); }), ).toBe(true); expect(refreshRequests).toBe(missingRefresh ? 0 : 1); await page.reload({ waitUntil: 'load' }); await expectRecoveryLogin(); expect(refreshRequests).toBe(missingRefresh ? 0 : 1); await loginAccountFromPage(page, account, returnTo); expect(refreshRequests).toBe(missingRefresh ? 0 : 1); } expect(pageErrors).toEqual([]); expect(consoleErrors).toEqual([]); } finally { if (account) await removeAccountFixture(request, account); } }, ); } });