feat(settings): complete privacy usage and data recovery presentation
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type TestInfo } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
@@ -40,7 +42,7 @@ const test = base.extend<{ session: Session }>({
|
||||
if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { fonts.push(url.href); return route.abort(); }
|
||||
const mailbox = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname);
|
||||
const statusRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/status$/.test(url.pathname);
|
||||
const ai = /^\/api\/ai(?:\/|$)/.test(url.pathname); const accountAiRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname);
|
||||
const ai = /^\/api\/ai(?:\/|$)/.test(url.pathname); const accountAiRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname) || request.method() === 'PUT' && url.pathname === '/api/ai/settings';
|
||||
if (mailbox && !statusRead || ai && !accountAiRead || /\/api\/(?:email\/send|billing\/(?:checkout|portal)|account-lifecycle\/delete|backup\/restore)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); }
|
||||
return route.continue();
|
||||
});
|
||||
@@ -70,13 +72,120 @@ async function disconnected(session: Session, api: APIRequestContext) { for (con
|
||||
async function capture(page: Page, info: TestInfo, name: string) {
|
||||
const geometry = await page.getByRole('main').evaluate(main => {
|
||||
const bounds = main.getBoundingClientRect(); const targets = Array.from(main.querySelectorAll('h1,h2,h3,p,button,input,[role="combobox"],[role="status"],.MuiFormControlLabel-root'));
|
||||
return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: targets.filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden').map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) };
|
||||
// MUI Switch uses a transparent 300%-wide native input; its painted label/control remains in the checked targets.
|
||||
return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: targets.filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden' && !(node instanceof HTMLInputElement && getComputedStyle(node).opacity === '0')).map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) };
|
||||
});
|
||||
await info.attach(`${name}-reflow`, { body: JSON.stringify(geometry), contentType: 'application/json' }); expect(geometry.rootOverflow).toBeLessThanOrEqual(1); expect(geometry.outside).toEqual([]);
|
||||
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
const axe = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete, qualification: 'All enabled axe rules; raw incomplete contexts retained for root triage, not human screen-reader acceptance.' }), contentType: 'application/json' }); expect(axe.violations).toEqual([]);
|
||||
}
|
||||
|
||||
test('[P7-09 privacy S04 E09] read-only recovery, retained failed draft and actual privacy persistence stay account scoped', async ({ page, playwright, session }, info) => {
|
||||
const original = await json<{ enabled: boolean; externalProcessingAllowed: boolean }>(await call(session.requests, page.request, 'GET', '/ai/settings'));
|
||||
let failedRead = false;
|
||||
await page.route(`${apiUrl}/ai/settings`, route => {
|
||||
if (route.request().method() === 'GET' && !failedRead) { failedRead = true; return route.fulfill({ status: 503, json: { title: 'Controlled unavailable read' } }); }
|
||||
return route.continue();
|
||||
});
|
||||
await start(page, '/settings', 390);
|
||||
const privacy = page.getByRole('region', { name: copy('settingsAiPrivacyTitle'), exact: true });
|
||||
await expect(privacy.getByRole('alert')).toContainText(copy('settingsAiPrivacyUnavailable'));
|
||||
await keyboard(privacy.getByRole('button', { name: copy('retry'), exact: true }));
|
||||
const enabled = privacy.getByRole('switch', { name: copy('settingsAiEnabled'), exact: true });
|
||||
await expect(enabled).toBeChecked({ checked: original.enabled });
|
||||
await expect(privacy.getByRole('group', { name: copy('settingsAiPrivacyTitle'), exact: true })).toBeFocused();
|
||||
expect(browserRequests(session, '/ai/settings', 'PUT')).toHaveLength(0);
|
||||
await toggle(enabled);
|
||||
await page.route(`${apiUrl}/ai/settings`, route => route.request().method() === 'PUT' ? route.fulfill({ status: 503, json: { title: 'Controlled unsaved write' } }) : route.continue());
|
||||
await keyboard(privacy.getByRole('button', { name: copy('settingsAiSave'), exact: true }));
|
||||
await expect(privacy.getByRole('alert')).toContainText(copy('settingsAiSaveFailed'));
|
||||
await expect(enabled).toBeChecked({ checked: !original.enabled });
|
||||
expect(await json(await call(session.requests, page.request, 'GET', '/ai/settings'))).toMatchObject(original);
|
||||
await capture(page, info, 'privacy-controlled-save-failure-en-light-390');
|
||||
await page.unroute(`${apiUrl}/ai/settings`);
|
||||
const saved = response(page, 'PUT', '/ai/settings'); await keyboard(privacy.getByRole('button', { name: copy('settingsAiSave'), exact: true })); expect((await saved).status()).toBe(200);
|
||||
await expect(privacy.getByText(copy('assetsUnsavedChanges'), { exact: true })).toHaveCount(0);
|
||||
expect(await json(await call(session.requests, page.request, 'GET', '/ai/settings'))).toMatchObject({ enabled: !original.enabled, externalProcessingAllowed: original.externalProcessingAllowed });
|
||||
await page.reload(); await expect(page.getByRole('switch', { name: copy('settingsAiEnabled'), exact: true })).toBeChecked({ checked: !original.enabled });
|
||||
const foreign = await playwright.request.newContext();
|
||||
try { const email=await createOwner(session.requests, session.admin, session.adminHeaders); const other=await login(session.requests, foreign, email); expect(other.id).not.toBe(session.owner.id); expect(await json(await call(session.requests, foreign, 'GET', '/ai/settings'))).toMatchObject(original); }
|
||||
finally { await foreign.dispose(); }
|
||||
});
|
||||
|
||||
test('[P7-09 privacy S04] bilingual theme widths retain dirty controls and explicit local discard without model calls', async ({ page, session }, info) => {
|
||||
test.setTimeout(240_000);
|
||||
const samples = [{ width: 320, language: 'nb', mode: 'dark' }, { width: 360, language: 'en', mode: 'light' }, { width: 390, language: 'en', mode: 'dark' }, { width: 768, language: 'nb', mode: 'light' }, { width: 1024, language: 'en', mode: 'dark' }, { width: 1280, language: 'nb', mode: 'light' }, { width: 1440, language: 'en', mode: 'light' }, { width: 1920, language: 'nb', mode: 'dark' }] as const;
|
||||
for (const sample of samples) {
|
||||
expect((await call(session.requests, page.request, 'PUT', '/auth/preferences/language', session.owner.headers, { language: sample.language === 'nb' ? 'nb-NO' : 'en' })).status()).toBe(204);
|
||||
await page.addInitScript(value => { localStorage.setItem('uiLanguage', value.language); localStorage.setItem('jobtracker.themeMode', value.mode); }, sample);
|
||||
await start(page, '/settings', sample.width);
|
||||
const privacy=page.getByRole('region', { name: copy('settingsAiPrivacyTitle', sample.language), exact: true });
|
||||
const enabled=privacy.getByRole('switch', { name: copy('settingsAiEnabled', sample.language), exact: true }); await expect(enabled).toBeChecked(); await toggle(enabled);
|
||||
await expect(privacy.getByRole('status')).toHaveText(copy('assetsUnsavedChanges', sample.language));
|
||||
await capture(page, info, `privacy-dirty-${sample.width}-${sample.language}-${sample.mode}`);
|
||||
await keyboard(privacy.getByRole('button', { name: copy('assetsDiscardChanges', sample.language), exact: true })); await expect(enabled).toBeChecked();
|
||||
const bookmarklet=page.getByRole('textbox', { name: copy('settingsQuickCaptureManual', sample.language), exact: true });
|
||||
await bookmarklet.focus(); await expect(bookmarklet).toHaveAttribute('readonly', '');
|
||||
const code=await bookmarklet.inputValue(); expect(code).toContain("http://localhost:3300/?add="); expect(code).toContain('encodeURIComponent(location.href)');
|
||||
expect(await bookmarklet.evaluate(node => { const input=node as HTMLInputElement; return [input.selectionStart, input.selectionEnd]; })).toEqual([0, code.length]);
|
||||
await choose(page, copy('settingsTabBackup', sample.language), sample.language);
|
||||
const backup=page.getByRole('region', { name: copy('backupTitle', sample.language), exact: true }); await expect(backup.getByText(copy('accountDeleteDisabled', sample.language), { exact: true })).toBeVisible();
|
||||
await expect(backup.getByRole('button', { name: copy('accountDeleteButton', sample.language), exact: true })).toHaveCount(0);
|
||||
await capture(page, info, `backup-disabled-${sample.width}-${sample.language}-${sample.mode}`);
|
||||
}
|
||||
expect(browserRequests(session, '/ai/settings', 'PUT')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[P7-09 usage S04] independent usage and billing GET failures recover without provider actions or invented disabled state', async ({ page, session }, info) => {
|
||||
let usageFailed=false; let billingFailed=false;
|
||||
await page.route(`${apiUrl}/ai/usage`, route => { if (!usageFailed) { usageFailed=true; return route.fulfill({ status: 503, json: { title: 'Controlled usage read failure' } }); } return route.continue(); });
|
||||
await page.route(`${apiUrl}/billing/status`, route => { if (!billingFailed) { billingFailed=true; return route.fulfill({ status: 503, json: { title: 'Controlled billing read failure' } }); } return route.continue(); });
|
||||
await start(page, '/settings', 390);
|
||||
const usage=page.getByRole('region', { name: copy('settingsUsageTitle'), exact: true });
|
||||
await expect(usage.getByRole('heading', { name: copy('settingsUsageTitle'), exact: true })).toBeVisible();
|
||||
await expect(usage.getByRole('alert')).toHaveCount(2); await expect(usage.getByText(copy('settingsBillingNotConfigured'), { exact: true })).toHaveCount(0);
|
||||
const usageError=usage.getByRole('alert').filter({ hasText: copy('settingsUsageUnavailable') });
|
||||
await keyboard(usageError.getByRole('button', { name: copy('retry'), exact: true }));
|
||||
await expect(usage.getByText(copy('settingsUsageNoAi'), { exact: true })).toBeVisible();
|
||||
await expect(usage.getByRole('group', { name: copy('settingsUsageTitle'), exact: true })).toBeFocused();
|
||||
expect(browserRequests(session, '/billing/status', 'GET')).toHaveLength(1);
|
||||
const billingError=usage.getByRole('alert').filter({ hasText: copy('settingsBillingUnavailable') });
|
||||
await keyboard(billingError.getByRole('button', { name: copy('retry'), exact: true }));
|
||||
await expect(usage.getByText(copy('settingsBillingNotConfigured'), { exact: true })).toBeVisible();
|
||||
expect(browserRequests(session, '/ai/usage', 'GET')).toHaveLength(2);
|
||||
await expect(usage.getByRole('button', { name: copy('settingsBillingUpgrade'), exact: true })).toHaveCount(0);
|
||||
await capture(page, info, 'usage-actual-free-independent-read-recovery-en-light-390');
|
||||
expect(session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET')).toEqual([]);
|
||||
});
|
||||
|
||||
test('[P7-09 backup S05 J10] unknown lifecycle recovers read-only, actual protected ZIP download retains response bytes and disposal rejects late download', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); let failed=false;
|
||||
await page.route(`${apiUrl}/account-lifecycle/status`, route => { if (!failed) { failed=true; return route.fulfill({ status: 503, json: { title: 'Controlled lifecycle read failure' } }); } return route.continue(); });
|
||||
await start(page, '/settings', 390); await choose(page, copy('settingsTabBackup'));
|
||||
const backup=page.getByRole('region', { name: copy('backupTitle'), exact: true });
|
||||
const unavailable=backup.getByRole('alert').filter({ hasText: copy('accountLifecycleUnavailable') }); await expect(unavailable).toBeVisible();
|
||||
await expect(backup.getByText(copy('accountDeleteDisabled'), { exact: true })).toHaveCount(0);
|
||||
await keyboard(unavailable.getByRole('button', { name: copy('retry'), exact: true }));
|
||||
await expect(backup.getByText(copy('accountDeleteDisabled'), { exact: true })).toBeVisible();
|
||||
await expect(backup.getByRole('group', { name: copy('backupTitle'), exact: true })).toBeFocused();
|
||||
expect(session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET')).toEqual([]);
|
||||
const downloadEvent=page.waitForEvent('download'); const exportResponse=response(page, 'POST', '/export/account');
|
||||
await keyboard(backup.getByRole('button', { name: copy('accountExportDownload'), exact: true }));
|
||||
const result=await exportResponse; expect(result.status()).toBe(200); const downloaded=await downloadEvent; expect(downloaded.suggestedFilename()).toMatch(/\.zip$/);
|
||||
const savedPath=await downloaded.path(); expect(savedPath).not.toBeNull(); const bytes=await readFile(savedPath!); expect(bytes.equals(await result.body())).toBe(true); expect(bytes.readUInt32LE(0)).toBe(0x04034b50); expect(bytes.includes(Buffer.from([0x50, 0x4b, 0x05, 0x06]))).toBe(true);
|
||||
await info.attach('actual-account-zip-byte-identity', { body: JSON.stringify({ source: 'Actual protected isolated account export response and browser download', byteLength: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), restoreVerified: false }), contentType: 'application/json' });
|
||||
await capture(page, info, 'backup-actual-readonly-lifecycle-and-protected-export-en-light-390');
|
||||
await page.getByRole('alert').filter({ hasText: copy('accountExportDownloaded') }).getByRole('button').click();
|
||||
const held=hold(); const received=hold();
|
||||
await page.route(`${apiUrl}/export/account`, async route => { const accepted=await route.fetch(); received.release(); await held.pending; await route.fulfill({ response: accepted }); });
|
||||
const lateDownloads: string[]=[]; page.on('download', value => lateDownloads.push(value.suggestedFilename()));
|
||||
await keyboard(backup.getByRole('button', { name: copy('accountExportDownload'), exact: true })); await received.pending;
|
||||
await choose(page, copy('settingsTabNotifications')); const lateResponse=response(page, 'POST', '/export/account'); held.release(); expect((await lateResponse).status()).toBe(200);
|
||||
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
||||
await expect(page.getByRole('alert').filter({ hasText: copy('accountExportDownloaded') })).toHaveCount(0);
|
||||
expect(lateDownloads).toEqual([]); expect(browserRequests(session, '/account-lifecycle/delete', 'POST')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[P7-09 S03 E09] notification and Rules saves stay independent, persist after reload and ignore forged owners across actual account disposal', async ({ page, playwright, session }, info) => {
|
||||
test.setTimeout(150_000); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: true }); await start(page);
|
||||
const initialRules = response(page, 'GET', '/rules'); await choose(page, copy('settingsTabFollowUps')); expect((await initialRules).status()).toBe(200); const rule = page.getByRole('spinbutton', { name: copy('rulesAppliedFollowUpDays'), exact: true }); await expect(rule).toHaveValue('14'); await rule.fill('6');
|
||||
|
||||
Reference in New Issue
Block a user