feat(settings): complete privacy usage and data recovery presentation

This commit is contained in:
cesnimda
2026-10-20 09:58:46 +02:00
parent 36fc5d54e7
commit 8af9168014
18 changed files with 1757 additions and 74 deletions
@@ -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');