fix(notifications): preserve action state and accessible recovery

This commit is contained in:
cesnimda
2026-10-16 23:14:33 +02:00
parent 9dfea1226f
commit 40cfe04df5
22 changed files with 5963 additions and 66 deletions
@@ -0,0 +1,237 @@
"""Insert one inert queued task into the existing disposable native-test database.
Stdin accepts only dataRoot, ownerId and ownerEmail. No notification, worker,
provider or product endpoint is created. --self-test uses its own temporary DBs.
"""
from __future__ import annotations
import json
import re
import sqlite3
import sys
import tempfile
import uuid
from contextlib import closing, contextmanager
from datetime import datetime, timezone
from pathlib import Path
class SeedDenied(ValueError):
pass
SCHEMA = {
"Id": ("TEXT", 1, 1), "OwnerUserId": ("TEXT", 1, 0),
"TaskType": ("TEXT", 1, 0), "IdempotencyKey": ("TEXT", 1, 0),
"Status": ("TEXT", 1, 0), "Priority": ("INTEGER", 1, 0),
"EntitlementDecision": ("TEXT", 1, 0), "PrivacyPolicy": ("TEXT", 1, 0),
"SubjectType": ("TEXT", 0, 0), "SubjectId": ("TEXT", 0, 0),
"Provider": ("TEXT", 0, 0), "Model": ("TEXT", 0, 0),
"AttemptCount": ("INTEGER", 1, 0), "MaxAttempts": ("INTEGER", 1, 0),
"CreatedAtUtc": ("TEXT", 1, 0), "AvailableAtUtc": ("TEXT", 1, 0),
"StartedAtUtc": ("TEXT", 0, 0), "CompletedAtUtc": ("TEXT", 0, 0),
"DeadlineAtUtc": ("TEXT", 0, 0), "CancellationRequestedAtUtc": ("TEXT", 0, 0),
"LeaseToken": ("TEXT", 0, 0), "LeaseExpiresAtUtc": ("TEXT", 0, 0),
"LastHeartbeatAtUtc": ("TEXT", 0, 0), "ProgressStage": ("TEXT", 0, 0),
"ProgressPercent": ("INTEGER", 0, 0), "FailureCategory": ("TEXT", 0, 0),
"FailureMessage": ("TEXT", 0, 0), "ResultReference": ("TEXT", 0, 0),
}
def insert_only_authorizer():
inserts = 0
def authorize(action, target, column, database, trigger):
nonlocal inserts
if action == sqlite3.SQLITE_INSERT:
inserts += 1
return sqlite3.SQLITE_OK if target == "UserOperations" and inserts == 1 and trigger is None else sqlite3.SQLITE_DENY
if action in {sqlite3.SQLITE_READ, sqlite3.SQLITE_SELECT, sqlite3.SQLITE_TRANSACTION, sqlite3.SQLITE_FUNCTION}:
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_PRAGMA and target == "table_info":
return sqlite3.SQLITE_OK
return sqlite3.SQLITE_DENY
return authorize
def seed_operation(payload: object) -> str:
if not isinstance(payload, dict) or set(payload) != {"dataRoot", "ownerId", "ownerEmail"}:
raise SeedDenied("Exactly dataRoot, ownerId and ownerEmail are required")
if any(not isinstance(value, str) or not value.strip() for value in payload.values()):
raise SeedDenied("All inputs must be nonempty strings")
requested = Path(payload["dataRoot"])
if not requested.is_absolute():
raise SeedDenied("dataRoot must be absolute")
try:
root, temporary = requested.resolve(strict=True), Path(tempfile.gettempdir()).resolve(strict=True)
except OSError as error:
raise SeedDenied("The isolated dataRoot must already exist") from error
if (root != requested or root.parent != temporary or not root.is_dir()
or requested.is_symlink() or requested.is_junction()
or not re.fullmatch(r"jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*", root.name)):
raise SeedDenied("Only an unlinked jobtracker-e2e-* direct child of system temp is allowed")
database = root / "jobtracker.db"
if (not database.is_file() or database.is_symlink() or database.is_junction()
or database.resolve(strict=True) != database or database.stat().st_nlink != 1):
raise SeedDenied("The unlinked existing direct jobtracker.db file is required")
# EF's SQLite Guid converter writes uppercase canonical text.
operation_id = str(uuid.uuid4()).upper()
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
with closing(sqlite3.connect(database.as_uri() + "?mode=rw", uri=True, timeout=5)) as connection, connection:
connection.execute("PRAGMA foreign_keys=ON")
connection.set_authorizer(insert_only_authorizer())
connection.execute("BEGIN IMMEDIATE")
actual = {row[1]: (row[2].upper(), row[3], row[5]) for row in connection.execute('PRAGMA table_info("UserOperations")')}
if actual != SCHEMA:
raise SeedDenied("UserOperations must match its existing 28-column SQLite migration")
users = {row[1]: row[2].upper() for row in connection.execute('PRAGMA table_info("AspNetUsers")')}
if users.get("Id") != "TEXT" or users.get("Email") != "TEXT":
raise SeedDenied("Existing AspNetUsers Id/Email schema is required")
owner = connection.execute('SELECT "Id", "Email" FROM "AspNetUsers" WHERE "Id" = ?', (payload["ownerId"],)).fetchone()
if owner != (payload["ownerId"], payload["ownerEmail"]):
raise SeedDenied("Owner ID/email must exactly match the existing real fixture account")
if connection.execute("SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='UserOperations' LIMIT 1").fetchone():
raise SeedDenied("UserOperations triggers are outside this insert-only fixture contract")
connection.execute(
'INSERT INTO "UserOperations" ("Id", "OwnerUserId", "TaskType", "IdempotencyKey", "Status", "Priority", "EntitlementDecision", "PrivacyPolicy", "AttemptCount", "MaxAttempts", "CreatedAtUtc", "AvailableAtUtc") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(operation_id, payload["ownerId"], "native-offline-fixture", uuid.uuid4().hex,
"queued", 0, "native_fixture", "local_only", 0, 3, timestamp, timestamp),
)
return operation_id.lower()
@contextmanager
def temporary_fixture():
with tempfile.TemporaryDirectory(prefix="jobtracker-e2e-operation-selftest-") as directory:
root = Path(directory).resolve()
try:
yield root
finally:
assert root.parent == Path(tempfile.gettempdir()).resolve()
assert root.name.startswith("jobtracker-e2e-operation-selftest-") and not root.is_symlink() and not root.is_junction()
def self_test() -> dict:
passed = []
owner, email = "owner' OR 1=1 --", "quote'@example.test"
def fixture(root, variant=None):
database = root / "jobtracker.db"
columns = []
for name, (kind, required, primary) in SCHEMA.items():
if variant == "missing_column" and name == "ResultReference":
continue
if variant == "wrong_type" and name == "Status":
kind = "INTEGER"
if variant == "wrong_nullability" and name == "Status":
required = 0
columns.append(f'"{name}" {kind}' + (" NOT NULL" if required else "") + (" PRIMARY KEY" if primary else ""))
if variant == "extra_column":
columns.append('"Unexpected" TEXT')
with closing(sqlite3.connect(database)) as connection, connection:
connection.execute('CREATE TABLE "UserOperations" (' + ",".join(columns) + ")")
connection.execute('CREATE TABLE "AspNetUsers" ("Id" TEXT PRIMARY KEY, "Email" TEXT, "Canonical" TEXT)')
connection.execute('INSERT INTO "AspNetUsers" VALUES (?, ?, ?)', (owner, email, "Canonical source — æøå"))
connection.execute('CREATE TABLE "UserNotifications" ("Id" TEXT PRIMARY KEY, "Title" TEXT)')
connection.execute('INSERT INTO "UserNotifications" VALUES (?, ?)', ("existing-notice", "Preserve notice"))
return database
def snapshot(database):
with closing(sqlite3.connect(database)) as connection:
return {table: connection.execute(f'SELECT * FROM "{table}" ORDER BY rowid').fetchall()
for table in ("AspNetUsers", "UserOperations", "UserNotifications", "sqlite_master")}
def payload(root, **changes):
return {"dataRoot": str(root), "ownerId": owner, "ownerEmail": email, **changes}
def deny(name, value):
try:
seed_operation(value)
except SeedDenied:
passed.append(name)
else:
raise AssertionError(name + " was accepted")
with temporary_fixture() as root:
deny("missing_database_does_not_create", payload(root))
assert not (root / "jobtracker.db").exists()
database = fixture(root)
before = snapshot(database)
for name, value in (("unknown_key", payload(root, taskType="live")), ("missing_key", {"dataRoot": str(root)}),
("relative_root", payload(root, dataRoot=".")), ("wrong_owner", payload(root, ownerId="other")),
("wrong_email", payload(root, ownerEmail="other@example.test")), ("exact_email_case", payload(root, ownerEmail=email.upper())),
("empty_owner", payload(root, ownerId="")), ("non_object", [])):
deny(name, value)
assert snapshot(database) == before
passed.append("denied_inputs_preserve_rows_and_schema")
identifier = seed_operation(payload(root))
after = snapshot(database)
assert len(after["UserOperations"]) == 1
assert all(after[key] == value for key, value in before.items() if key != "UserOperations")
with closing(sqlite3.connect(database)) as connection:
connection.row_factory = sqlite3.Row
row = dict(connection.execute('SELECT * FROM "UserOperations"').fetchone())
assert row["Id"] == identifier.upper() and str(uuid.UUID(row["Id"])).upper() == row["Id"]
assert row["OwnerUserId"] == owner and row["TaskType"] == "native-offline-fixture"
assert row["Status"] == "queued" and row["EntitlementDecision"] == "native_fixture" and row["PrivacyPolicy"] == "local_only"
assert row["AttemptCount"] == 0 and row["MaxAttempts"] == 3 and row["CreatedAtUtc"] == row["AvailableAtUtc"]
assert all(row[key] is None for key, (_, required, _) in SCHEMA.items() if not required)
passed.extend(("parameterized_exact_insert", "ef_uppercase_guid", "no_subject_provider_model_lease_result", "existing_rows_and_schema_unchanged"))
for statement in ('UPDATE "AspNetUsers" SET "Canonical" = \'lost\'', 'DELETE FROM "UserOperations"',
'INSERT INTO "UserNotifications" VALUES (\'new\', \'wrong\')', 'CREATE TABLE "Forbidden" ("Id" INTEGER)',
"ATTACH DATABASE ':memory:' AS forbidden", "PRAGMA writable_schema=ON"):
with closing(sqlite3.connect(database)) as connection:
connection.set_authorizer(insert_only_authorizer())
try:
connection.execute(statement)
except sqlite3.DatabaseError:
passed.append("authorizer_denies_" + statement.split()[0].lower())
else:
raise AssertionError("Forbidden SQL was accepted: " + statement)
assert snapshot(database) == after
passed.append("denied_sql_preserves_all_rows_and_schema")
(root / "fixture-hardlink.db").hardlink_to(database)
deny("hardlinked_database", payload(root))
assert snapshot(database) == after
for variant in ("missing_column", "extra_column", "wrong_type", "wrong_nullability"):
with temporary_fixture() as root:
database = fixture(root, variant)
before = snapshot(database)
deny(variant, payload(root))
assert snapshot(database) == before
with temporary_fixture() as root:
database = fixture(root)
with closing(sqlite3.connect(database)) as connection, connection:
connection.execute('CREATE TRIGGER "unexpected_write" AFTER INSERT ON "UserOperations" BEGIN UPDATE "AspNetUsers" SET "Canonical" = \'lost\'; END')
before = snapshot(database)
deny("trigger_denied_before_insert", payload(root))
assert snapshot(database) == before
with temporary_fixture() as root:
nested = root / "jobtracker-e2e-nested"
nested.mkdir()
database = fixture(nested)
before = snapshot(database)
deny("nested_root", payload(nested))
deny("wrong_prefix", payload(root, dataRoot=str(root.parent)))
assert snapshot(database) == before
return {"selfTest": "PASS", "checks": len(passed), "passed": passed, "databaseScope": "own direct temporary SQLite fixtures only"}
def main():
try:
if sys.argv[1:] == ["--self-test"]:
print(json.dumps(self_test()))
elif sys.argv[1:]:
raise SeedDenied("Only stdin JSON or --self-test is supported")
else:
print(json.dumps({"operationId": seed_operation(json.load(sys.stdin))}))
return 0
except (SeedDenied, json.JSONDecodeError, sqlite3.Error, OSError) as error:
print(json.dumps({"error": str(error)}), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,109 @@
import AxeBuilder from '@axe-core/playwright';
import { spawnSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import type { UserNotification } from '../src/notifications';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { id: string; email: string; headers: Record<string, string> };
type Operation = { id: string; taskType: string; status: string; subjectType: string | null; createdAtUtc: string; startedAtUtc: string | null; completedAtUtc: string | null; deadlineAtUtc: string | null; cancellationRequestedAtUtc: string | null; progressStage: string | null; progressPercent: number | null; failureCategory: string | null; canCancel: boolean; canRetry: boolean };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type RequestRecord = { method: string; path: string; query: string };
type Session = { owner: Owner; administrator: Owner; admin: APIRequestContext; requests: RequestRecord[] };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const boundary = 'Actual Program, local Identity cookies/CSRF, free owner accounts and isolated temporary SQLite. A guarded test-only helper inserts exactly one inert queued native-offline-fixture task; no registered producer/handler, AI entitlement, subject, provider/model, lease, usage or result is created. Actual cancel/retry/read/dismiss endpoints own every recorded persistent transition. Actual terminal notices have operationId and linkPath=null: no result/Open capability is inferred. Explicitly labelled controlled GET projections qualify only long-text and internal-link presentation. Controlled precommit503/held GET responses qualify UI recovery/race handling, not uncertain delivery. Every external request is aborted. Only existing HTTPS GET stylesheet requests to fonts.googleapis.com/css2 are classified separately as blocked font resources; every occurrence is retained, no font is fetched, and other external/provider/model requests remain an ordinary failing assertion. Font-resource counts are separate from forbidden provider/model counts; these captures do not qualify production fonts. No production/main/deployment, worker activation, Docker/model qualification, provider grant/send/sync, renderer or Phase8 action. Finite local Chromium/axe evidence does not establish human screen-reader, physical touch/software keyboard, actual zoom, live MariaDB, real worker execution, production font/photo or production readiness.';
function copy(key: string, language: Sample['language'] = 'en', params: Record<string, string | number> = {}) { return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`)); }
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
function waitResponse(page: Page, method: string, suffix: string) { return page.waitForResponse(response => response.request().method() === method && new URL(response.url()).pathname === `/api${suffix}`); }
function count(session: Session, method: string, suffix: string) { return session.requests.filter(request => request.method === method && request.path === `/api${suffix}`); }
function panel(page: Page, language: Sample['language'] = 'en') { return page.getByRole('dialog', { name: copy('notificationsPanel', language), exact: true }); }
function bell(page: Page, language: Sample['language'] = 'en') { return page.locator('header').getByRole('button', { name: copy('notifications', language), exact: true }); }
async function keyboard(page: Page, control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
function utcFields(value: Record<string, unknown>) { for (const [key, date] of Object.entries(value)) if (key.endsWith('AtUtc') && date !== null && date !== undefined) expect(date, key).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/); }
async function operation(api: APIRequestContext, id: string) { const value = await json<Operation>(await api.get(`${apiUrl}/operations/${id}`)); utcFields(value); expect(value.id).toBe(id); expect(Object.keys(value).sort()).toEqual(['id', 'taskType', 'status', 'subjectType', 'createdAtUtc', 'startedAtUtc', 'completedAtUtc', 'deadlineAtUtc', 'cancellationRequestedAtUtc', 'progressStage', 'progressPercent', 'failureCategory', 'canCancel', 'canRetry'].sort()); return value; }
async function notices(api: APIRequestContext) { const values = await json<UserNotification[]>(await api.get(`${apiUrl}/notifications?limit=100`)); for (const value of values) utcFields(value); return values; }
async function login(api: APIRequestContext, email: string): Promise<Owner> { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`)); const token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; }
async function account(admin: APIRequestContext, owner: Owner) { const email = `p708-native-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200); return email; }
const test = base.extend<{ session: Session }>({
session: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String)); const admin = await playwright.request.newContext(); const requests: RequestRecord[] = []; const blocked: string[] = []; const blockedKnownFontStylesheets: { url: string; resourceType: string }[] = [];
page.on('request', request => { const url = new URL(request.url()); if (/^\/api\/(?:operations|notifications|auth\/logout)(?:\/|$)/.test(url.pathname)) requests.push({ method: request.method(), path: url.pathname, query: url.search }); });
await context.route('**/*', route => { const request = route.request(); const url = new URL(request.url()); if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { blockedKnownFontStylesheets.push({ url: url.href, resourceType: request.resourceType() }); return route.abort(); } if (/^\/api\/(?:ai|email\/send|gmail)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { blocked.push(url.origin + url.pathname); return route.abort(); } return route.continue(); });
try { const administrator = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await account(admin, administrator)); expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false); await provide({ owner, administrator, admin, requests }); expect(blocked).toEqual([]); }
finally { await info.attach('notifications-native-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalOrModelDeliveryRequests: blocked, blockedKnownFontStylesheets, fontQualification: 'All recorded font stylesheet requests were aborted without external access. Production font acceptance remains pending; no font-resource count is included in provider/model request counts.' }), contentType: 'application/json' }); await admin.dispose(); }
},
});
function seedQueued(info: TestInfo, owner: Owner) {
const metadataRoot = info.config.metadata.isolatedDataRoot;
if (typeof metadataRoot !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Operation seeding requires explicit isolated non-reused dataRoot');
expect(path.isAbsolute(metadataRoot)).toBe(true); const dataRoot = realpathSync(metadataRoot); expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir())); expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/);
const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 });
expect(result.error, 'guarded helper must start').toBeUndefined(); expect(result.status, result.stderr).toBe(0); const value = JSON.parse(result.stdout) as { operationId: string }; expect(Object.keys(value)).toEqual(['operationId']); expect(value.operationId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); return value.operationId;
}
async function cancel(api: APIRequestContext, owner: Owner, id: string) { const value = await json<Operation>(await api.post(`${apiUrl}/operations/${id}/cancel`, { headers: owner.headers })); utcFields(value); expect(value).toMatchObject({ id, status: 'cancelled', canCancel: false, canRetry: true }); const notice = (await notices(api)).find(item => item.operationId === id)!; expect(notice).toBeDefined(); expect(notice).toMatchObject({ operationId: id, kind: 'operation_cancelled', linkPath: null, readAtUtc: null }); expect(notice.createdAtUtc).toBe(value.completedAtUtc); return { value, notice }; }
async function terminal(page: Page, owner: Owner, info: TestInfo) { const id = seedQueued(info, owner); const queued = await operation(page.request, id); expect(queued).toMatchObject({ taskType: 'native-offline-fixture', status: 'queued', subjectType: null, canCancel: true, canRetry: false }); const listed = await json<Operation[]>(await page.request.get(`${apiUrl}/operations?limit=100`)); expect(listed.find(item => item.id === id)).toEqual(queued); return { id, queued, ...await cancel(page.request, owner, id) }; }
async function enter(page: Page, owner: Owner, sample: Sample = desktop, target = '/documents?source=phase7-notifications#preserved') {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(({ language, theme }) => { if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language); if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(target);
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') })); if (stored.language !== sample.language || stored.theme !== sample.theme) { await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload(); }
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme); await expect(bell(page, sample.language)).toBeVisible();
}
async function open(page: Page, language: Sample['language'] = 'en') { await keyboard(page, bell(page, language)); await expect(panel(page, language)).toBeVisible(); await expect(panel(page, language).getByRole('heading', { level: 2, name: copy('notifications', language), exact: true })).toBeVisible(); }
async function close(page: Page, language: Sample['language'] = 'en') { await page.keyboard.press('Escape'); await expect(panel(page, language)).toHaveCount(0); await expect(bell(page, language)).toBeFocused(); }
async function capture(page: Page, info: TestInfo, sample: Sample, state: string, projection: string | null = null) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); }); const dialog = panel(page, sample.language);
const geometry = await dialog.evaluate(element => { const rect = element.getBoundingClientRect(); const region = element.querySelector('[role="region"]'); return { viewport: { width: innerWidth, height: innerHeight }, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), dialog: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }, content: region ? { scrollHeight: region.scrollHeight, clientHeight: region.clientHeight, scrollTop: region.scrollTop } : null, focusInside: element.contains(document.activeElement) }; });
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p708-notifications-${sample.language}-${sample.theme}-${sample.width}-${sample.height}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, boundary, controlledProjection: projection, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(), axe: { scope: 'All default-enabled rules plus target-size; no exclusions', testEngine: axe.testEngine, testEnvironment: axe.testEnvironment, toolOptions: axe.toolOptions, violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualifications: ['Finite Chromium states only; human AT, physical devices, software keyboard and real zoom remain pending.', 'Controlled projections are UI contract fixtures, not current producer output.', 'Full-page captures may move scroll; strict action geometry is measured before capture and after explicit restoration.'] }), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); expect(geometry.rootOverflow).toBe(0); expect(geometry.dialog.left).toBeGreaterThanOrEqual(0); expect(geometry.dialog.right).toBeLessThanOrEqual(sample.width); expect(geometry.dialog.top).toBeGreaterThanOrEqual(0); expect(geometry.dialog.bottom).toBeLessThanOrEqual(sample.height); expect(geometry.dialog.scrollWidth).toBeLessThanOrEqual(geometry.dialog.clientWidth); expect(axe.violations).toEqual([]);
}
test('[P7-08 notifications] actual cancelled notice persists read/dismiss independently of task and retry keeps the same task identity', async ({ page, session }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); await enter(page, session.owner); await expect(bell(page)).toHaveAccessibleName(copy('notifications')); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 1 })); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); await expect(panel(page).getByRole('button', { name: copy('notificationsOpen'), exact: true })).toHaveCount(0);
const read = waitResponse(page, 'POST', `/notifications/${seeded.notice.id}/read`); await keyboard(page, panel(page).getByRole('button', { name: copy('notificationsMarkRead'), exact: true })); expect((await read).status()).toBe(204); await expect(panel(page).getByRole('button', { name: copy('notificationsMarkRead'), exact: true })).toHaveCount(0); const readBack = (await notices(page.request)).find(item => item.id === seeded.notice.id)!; expect(readBack.readAtUtc).toMatch(/Z$/); expect((await page.request.post(`${apiUrl}/notifications/${seeded.notice.id}/read`, { headers: session.owner.headers })).status()).toBe(204); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(readBack); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); await capture(page, info, desktop, 'actual-read-task-unchanged');
await close(page); await page.reload(); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 })); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); const dismissed = waitResponse(page, 'DELETE', `/notifications/${seeded.notice.id}`); await keyboard(page, panel(page).getByRole('button', { name: copy('notificationsDismiss'), exact: true })); expect((await dismissed).status()).toBe(204); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toBeVisible(); expect(await notices(page.request)).toEqual([]); expect((await page.request.delete(`${apiUrl}/notifications/${seeded.notice.id}`, { headers: session.owner.headers })).status()).toBe(204); expect(await operation(page.request, seeded.id)).toEqual(seeded.value);
const retried = await json<Operation>(await page.request.post(`${apiUrl}/operations/${seeded.id}/retry`, { headers: session.owner.headers })); utcFields(retried); expect(retried).toEqual(seeded.queued); expect((await page.request.post(`${apiUrl}/notifications/${seeded.notice.id}/read`, { headers: session.owner.headers })).status()).toBe(404); const again = await cancel(page.request, session.owner, seeded.id); expect(again.notice.id).not.toBe(seeded.notice.id); expect(again.notice.operationId).toBe(seeded.id); expect(await operation(page.request, seeded.id)).toEqual(again.value); expect(session.requests.filter(value => value.path.startsWith('/api/operations/'))).toEqual([]);
await info.attach('actual-operation-notice-lifecycle', { body: JSON.stringify({ queued: seeded.queued, cancelled: seeded.value, notice: seeded.notice, readBack, retry: retried, secondNotice: again.notice, qualification: 'Only timestamps/status of actual API snapshots are claimed; dismissedAtUtc is not exposed in NotificationDto. Retry deletes the old notice, and no worker executes this fixture.' }), contentType: 'application/json' });
});
test('[P7-08 notifications] actual owner/anonymous denials and sign-out prevent cached notice exposure across accounts', async ({ page, session, playwright }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); const otherEmail = await account(session.admin, session.administrator); const otherApi = await playwright.request.newContext(); const anonymous = await playwright.request.newContext();
try { const other = await login(otherApi, otherEmail); expect(other.id).not.toBe(session.owner.id); expect(await notices(otherApi)).toEqual([]); expect(await json<{ count: number }>(await otherApi.get(`${apiUrl}/notifications/unread-count`))).toEqual({ count: 0 }); expect((await otherApi.get(`${apiUrl}/operations/${seeded.id}`)).status()).toBe(404); for (const action of ['cancel', 'retry']) expect((await otherApi.post(`${apiUrl}/operations/${seeded.id}/${action}`, { headers: other.headers })).status()).toBe(404); expect((await otherApi.post(`${apiUrl}/notifications/${seeded.notice.id}/read`, { headers: other.headers })).status()).toBe(404); expect((await otherApi.delete(`${apiUrl}/notifications/${seeded.notice.id}`, { headers: other.headers })).status()).toBe(404); for (const endpoint of ['/operations', `/operations/${seeded.id}`, '/notifications', '/notifications/unread-count']) expect((await anonymous.get(`${apiUrl}${endpoint}`)).status()).toBe(401); expect((await anonymous.post(`${apiUrl}/notifications/${seeded.notice.id}/read`)).status()).toBe(401); expect((await anonymous.delete(`${apiUrl}/notifications/${seeded.notice.id}`)).status()).toBe(401);
await enter(page, session.owner); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); await close(page); await keyboard(page, page.locator('header').getByRole('button', { name: copy('user'), exact: true })); const logout = waitResponse(page, 'POST', '/auth/logout'); await keyboard(page, page.getByRole('menuitem', { name: copy('signOut'), exact: true })); expect((await logout).status()).toBe(204); await expect(page).toHaveURL(/\/login(?:\?|$)/); await expect(page.getByText(seeded.notice.title, { exact: true })).toHaveCount(0); expect((await page.request.get(`${apiUrl}/notifications`)).status()).toBe(401);
const signedInOther = await login(page.request, otherEmail); await enter(page, signedInOther); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 })); await open(page); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toBeVisible(); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toHaveCount(0); expect(await notices(otherApi)).toEqual([]); expect(await operation(otherApi, seedQueued(info, other))).toMatchObject({ status: 'queued', taskType: 'native-offline-fixture' }); await capture(page, info, desktop, 'actual-second-owner-empty');
} finally { await otherApi.dispose(); await anonymous.dispose(); }
});
test('[P7-08 notifications] failed count stays unknown and GET Retry/close/reopen rejects a held stale response without false empty state', async ({ page, session }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); let counts = 0; let loads = 0; const gate = hold(); const started = hold(); await page.route(`${apiUrl}/notifications/unread-count`, async route => { counts += 1; if (counts === 1) return route.fulfill({ status: 503, json: {} }); return route.fulfill({ response: await route.fetch() }); });
await page.route(`${apiUrl}/notifications?limit=10`, async route => { loads += 1; if (loads === 1) return route.fulfill({ status: 503, json: {} }); if (loads === 2) { started.release(); await gate.pending; return route.fulfill({ status: 200, json: [] }); } return route.fulfill({ response: await route.fetch() }); });
try { await enter(page, session.owner); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCountUnavailable')); await open(page); const failed = panel(page).getByRole('alert').filter({ hasText: copy('notificationsLoadFailed') }); await expect(failed).toBeVisible(); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toHaveCount(0); await capture(page, info, desktop, 'controlled-get503-unknown-count'); await keyboard(page, failed.getByRole('button', { name: copy('retry'), exact: true })); await started.pending; await expect(panel(page).getByRole('region', { name: copy('notificationsRecent'), exact: true })).toHaveAttribute('aria-busy', 'true'); await close(page); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); gate.release(); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toHaveCount(0); await expect(panel(page).getByRole('alert')).toHaveCount(0); expect(loads).toBe(3); expect(session.requests.filter(value => value.method !== 'GET')).toEqual([]); await close(page); await page.reload(); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 1 })); expect(await operation(page.request, seeded.id)).toEqual(seeded.value);
} finally { gate.release(); }
});
test('[P7-08 notifications] failed mutation serializes activation and read-only Retry recovers in each locale without replay or task mutation', async ({ page, session }, info) => {
test.setTimeout(180_000); const seeded = await terminal(page, session.owner, info); let attempts = 0; let gate = hold(); let started = hold(); await page.route(`${apiUrl}/notifications/${seeded.notice.id}/read`, async route => { attempts += 1; started.release(); await gate.pending; return route.fulfill({ status: 503, json: {} }); });
try { for (const language of ['en', 'nb'] as const) { gate = hold(); started = hold(); const sample = { ...desktop, language, theme: language === 'nb' ? 'dark' as const : 'light' as const }; await enter(page, session.owner, sample); await open(page, language); const mark = panel(page, language).getByRole('button', { name: copy('notificationsMarkRead', language), exact: true }); await mark.focus(); await mark.evaluate(node => { (node as HTMLButtonElement).click(); (node as HTMLButtonElement).click(); }); await started.pending; await expect(mark).toBeDisabled(); await expect(panel(page, language).getByRole('button', { name: copy('notificationsDismiss', language), exact: true })).toBeDisabled(); expect(attempts).toBe(language === 'en' ? 1 : 2); gate.release(); const failed = panel(page, language).getByRole('alert').filter({ hasText: copy('notificationsActionFailed', language) }); await expect(failed).toBeVisible(); await expect(mark).toBeFocused(); const beforeReads = count(session, 'GET', '/notifications').length; const retryRead = waitResponse(page, 'GET', '/notifications'); await keyboard(page, failed.getByRole('button', { name: copy('retry', language), exact: true })); expect((await retryRead).status()).toBe(200); await expect(failed).toHaveCount(0); await expect(panel(page, language).getByRole('button', { name: copy('notificationsMarkRead', language), exact: true })).toBeVisible(); expect(count(session, 'GET', '/notifications')).toHaveLength(beforeReads + 1); expect(attempts).toBe(language === 'en' ? 1 : 2); expect(await panel(page, language).evaluate(element => element.contains(document.activeElement))).toBe(true); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(seeded.notice); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); await capture(page, info, sample, 'controlled-action503-get-only-recovery'); await close(page, language); }
expect(count(session, 'POST', `/notifications/${seeded.notice.id}/read`)).toHaveLength(2); expect(session.requests.filter(value => value.method === 'DELETE' || value.path.startsWith('/api/operations/'))).toEqual([]);
} finally { gate.release(); }
});
test('[P7-08 notifications] labelled internal-link GET projection respects existing dirty-workspace Cancel before explicit navigation', async ({ page, session }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: session.owner.headers, data: { name: 'Notification context company — æøå' } }), 201); const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: session.owner.headers, data: { companyId: company.id, jobTitle: 'Notification dirty-context role', status: 'Saved' } }), 201); const original = { text: 'Persisted letter before notification navigation — æøå', source: 'manual' }; await json(await page.request.put(`${apiUrl}/jobapplications/${job.id}/cover-letter`, { headers: session.owner.headers, data: original }));
await page.route(`${apiUrl}/notifications?limit=10`, async route => { const response = await route.fetch(); const values = await response.json() as UserNotification[]; expect(values.find(item => item.id === seeded.notice.id)?.linkPath).toBeNull(); return route.fulfill({ response, json: values.map(item => item.id === seeded.notice.id ? { ...item, linkPath: '/documents' } : item) }); });
const source = `/jobs/${job.id}?section=application&sub=letter`; await enter(page, session.owner, desktop, source); const field = page.getByRole('textbox', { name: copy('jobDetailsCoverLetter'), exact: true }); await expect(field).toHaveValue(original.text); const dirty = 'Unsaved reviewed letter survives notification navigation Cancel — æøå'; await field.fill(dirty); await open(page); const read = waitResponse(page, 'POST', `/notifications/${seeded.notice.id}/read`); await keyboard(page, panel(page).getByRole('button', { name: copy('notificationsOpen'), exact: true })); expect((await read).status()).toBe(204); const confirmation = page.getByRole('dialog', { name: copy('workspaceUnsavedTitle'), exact: true }); await expect(confirmation).toBeVisible(); await keyboard(page, confirmation.getByRole('button', { name: copy('workspaceKeepEditing'), exact: true })); await expect(confirmation).toHaveCount(0); await expect(field).toHaveValue(dirty); expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe(source); const stored = await json<{ text: string }>(await page.request.get(`${apiUrl}/jobapplications/${job.id}/cover-letter`)); expect(stored.text).toBe(original.text); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)?.linkPath).toBeNull();
await open(page); await capture(page, info, desktop, 'controlled-internal-link-dirty-cancel', 'Only linkPath=/documents is projected on an actual owned notice; actual producer API is asserted linkPath=null before and after. Existing unsaved-workspace guard is exercised.'); await close(page); expect(count(session, 'POST', `/notifications/${seeded.notice.id}/read`)).toHaveLength(1); expect(session.requests.filter(value => value.method !== 'GET' && !value.path.endsWith(`/${seeded.notice.id}/read`))).toEqual([]);
});
test('[P7-08 notifications] eight finite bilingual theme widths retain dialog focus scroll/footer and complete axe vectors with labelled long metadata', async ({ page, session }, info) => {
test.setTimeout(300_000); const seeded = await terminal(page, session.owner, info); const longTitle = 'Langt varsel om samfunnskritisk plattformutvikling og internasjonale samarbeidspartnere Æ Ø Å'; const longMessage = ('Kontrollert lang tekst for å prøve linjebryting, lesbarhet og vertikal rulling på små skjermer. LangUbruttReferanseForGrensekontroll0123456789ÆØÅ. ').repeat(24); const projection = 'Only title/message of one actual owned terminal notice are replaced by controlled long UI contract text. Its identity, ownership, UTC timestamps, operationId and absent link stay actual; this does not claim a long-message producer.';
await page.route(`${apiUrl}/notifications?limit=10`, async route => { const response = await route.fetch(); const values = await response.json() as UserNotification[]; expect(values.find(item => item.id === seeded.notice.id)?.linkPath).toBeNull(); return route.fulfill({ response, json: values.map(item => item.id === seeded.notice.id ? { ...item, title: longTitle, message: longMessage } : item) }); });
const samples: Sample[] = [{ language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 }, { language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 768, height: 1024 }, { language: 'en', theme: 'dark', width: 1024, height: 768 }, { language: 'nb', theme: 'light', width: 1280, height: 320 }, { language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 }];
for (const sample of samples) { await enter(page, session.owner, sample); await expect(bell(page, sample.language)).toHaveAccessibleDescription(copy('notificationsUnreadCount', sample.language, { count: 1 })); await open(page, sample.language); const dialog = panel(page, sample.language); await expect(dialog.getByText(longTitle, { exact: true })).toBeVisible(); const region = dialog.getByRole('region', { name: copy('notificationsRecent', sample.language), exact: true }); expect(await region.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true); const closeControl = dialog.getByRole('button', { name: copy('close', sample.language), exact: true }); await closeControl.focus(); await page.keyboard.press('Shift+Tab'); expect(await dialog.evaluate(element => element.contains(document.activeElement))).toBe(true); const footer = dialog.getByRole('button', { name: copy('notificationsViewAll', sample.language), exact: true }); await footer.focus(); await footer.scrollIntoViewIfNeeded(); await expect(footer).toBeFocused(); const before = await footer.boundingBox(); expect(before).not.toBeNull(); expect(before!.y).toBeGreaterThanOrEqual(0); expect(before!.y + before!.height).toBeLessThanOrEqual(sample.height); await capture(page, info, sample, 'controlled-long-text-keyboard', projection); await footer.scrollIntoViewIfNeeded(); await expect(footer).toBeFocused(); const after = await footer.boundingBox(); expect(after).not.toBeNull(); expect(after!.y).toBeGreaterThanOrEqual(0); expect(after!.y + after!.height).toBeLessThanOrEqual(sample.height); await info.attach(`p708-notifications-${sample.width}-footer-bounds`, { body: JSON.stringify({ sample, beforeCapture: before, afterScrollRestoration: after, controlledProjection: projection }), contentType: 'application/json' }); if ([320, 1280].includes(sample.width)) await info.attach(`p708-notifications-${sample.width}-focused-viewport`, { body: await page.screenshot({ fullPage: false, animations: 'disabled' }), contentType: 'image/png' }); await close(page, sample.language); }
expect(session.requests.filter(value => value.method !== 'GET')).toEqual([]); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(seeded.notice);
});