All checks were successful
whatever the operator called their instance, `instance.name` was never rendered in the running app at all, and there was no favicon anywhere — `index.html` had no `<link rel="icon">` and `public/` held only fonts and theme-init.js. Where the line is drawn, and why: - **The api never decodes an image.** Cropping, scaling and the conversion to PNG happen on a canvas in the browser; the api checks the PNG signature, reads the IHDR dimensions at their fixed offsets and enforces the caps. An image library would put a decoder in front of attacker-supplied bytes AND would have to be carried through the `--network none` offline build. Reading two big-endian integers is not decoding. - **SVG is refused**, with its own error message rather than a generic "not a PNG": it can carry script, and serving it from our own origin would be a cross-site-scripting vector. An operator who tried one should learn that it is deliberate. - **The crop is driven by number inputs, not by dragging.** A drag-only cropper excludes keyboard and switch users outright; a number input is arrow-key operable and screen-reader readable without any custom aria. The resulting pixel size is stated in text, not only drawn as a frame. - **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has already resolved `data-theme` before first paint, so the correct logo is the one painted rather than the one that appears after a flash. Without a dark variant the LIGHT logo carries both themes — the operator's own asset shown unchanged beats one they did not choose (the rule #307 extends to ponds). The settings screen warns; it never blocks. - **The favicon link is static, its resource dynamic.** index.html stays a static file and the api answers with the uploaded icon or a shipped default — that route must never 404, or the browser keeps its generic icon for good. The default is generated by a script from Node's own zlib (`gen-default-favicon.mjs`), for the same offline-build reason. - Both favicon sizes are uploaded together: one source, one crop, so the tab icon and the home-screen icon can never disagree. - Branding is served WITHOUT a session, because the login screen carries it and the browser fetches the favicon before anyone signs in. The admin screen says so — an operator may not expect their logo to be public. - The metadata is not writable through the settings endpoint: it describes bytes on disk, and hand-writing it would claim an asset that is not there. `./data/branding` follows the three-step rule #303 paid for: env default + `data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the `mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and closes the hole that made #303's variant invisible: the nightly archive skips a missing directory WORDLESSLY, so the fence now demands that every `*_DIR` the backup env declares actually travels in the archive. Verified against the real defect — removing the line fails it by name. Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start so #307 is the same event with a different scope, not a second id. Verified: api suite 103 files green (a lone `public-api` ECONNRESET under local parallel load, green in isolation — the documented local flake); branding suite 12 tests against a real directory; crop arithmetic unit tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the new section (overflow 0); and the whole flow walked in the browser: upload → crop 780×180 → stored as 512×118 → logo in the sidebar linking home with the instance name as its accessible name → topbar wordmark following `instance.name` → light logo still shown under `data-theme="dark"`.
213 lines
9.4 KiB
TypeScript
213 lines
9.4 KiB
TypeScript
import AxeBuilder from '@axe-core/playwright';
|
||
import { expect, test, type Page } from '@playwright/test';
|
||
|
||
import { contextForUser } from './helpers';
|
||
|
||
/**
|
||
* A11y-Smoke-Pack (issue #171): axe-core-Scan der Kern-Oberflächen gegen
|
||
* WCAG 2.1 A/AA. Regressionsschutz für das Audit vom 21.07.2026 (Bericht im
|
||
* Workspace, Befunde A11Y-001…024) — die Screens hier waren nach den Fixes
|
||
* der Issues #162–#170 verletzungsfrei; jede neue Verletzung bricht den
|
||
* Build. Best-Practice-Regeln (axe-Tag best-practice) prüfen wir hier
|
||
* bewusst NICHT, nur normative WCAG-Kriterien.
|
||
*
|
||
* Seit issue #180 läuft jeder Scan in BEIDEN Farbschemata: emulateMedia
|
||
* setzt prefers-color-scheme, theme-init.js löst den Default „System“ zum
|
||
* konkreten data-theme auf, axe misst dann die echten Dark-Token-Farben.
|
||
*/
|
||
|
||
const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||
const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa'];
|
||
const SCHEMES = ['light', 'dark'] as const;
|
||
|
||
/** Bewusst tolerierte Regel-IDs — nur mit Begründung ergänzen. */
|
||
const ALLOWED_RULES: string[] = [];
|
||
|
||
async function expectClean(page: Page, label: string): Promise<void> {
|
||
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
|
||
const violations = results.violations.filter((v) => !ALLOWED_RULES.includes(v.id));
|
||
expect(
|
||
violations.map((v) => ({
|
||
rule: v.id,
|
||
impact: v.impact,
|
||
help: v.help,
|
||
nodes: v.nodes.slice(0, 5).map((n) => n.target),
|
||
})),
|
||
`axe-Verletzungen auf ${label}`,
|
||
).toEqual([]);
|
||
}
|
||
|
||
for (const scheme of SCHEMES) {
|
||
test.describe(`${scheme} scheme`, () => {
|
||
test(`login page passes the axe WCAG A/AA scan (${scheme})`, async ({ page }) => {
|
||
await page.emulateMedia({ colorScheme: scheme });
|
||
await page.goto('/login');
|
||
await page.waitForLoadState('networkidle');
|
||
await expectClean(page, `/login (${scheme})`);
|
||
});
|
||
|
||
test(`reading and editing a page passes the axe WCAG A/AA scan (${scheme})`, async ({
|
||
browser,
|
||
}) => {
|
||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||
const page = await context.newPage();
|
||
await page.emulateMedia({ colorScheme: scheme });
|
||
await page.goto('/p/content-fixtures/every-element');
|
||
await page.waitForLoadState('networkidle');
|
||
await expectClean(page, `Lesemodus every-element (${scheme})`);
|
||
|
||
await page.locator('.editor-page__mode-toggle').click();
|
||
await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 });
|
||
await page.waitForTimeout(500);
|
||
await expectClean(page, `Editor every-element (${scheme})`);
|
||
await context.close();
|
||
});
|
||
|
||
test(`a classified page shows the marking top+bottom and passes axe (${scheme})`, async ({
|
||
browser,
|
||
}) => {
|
||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||
const page = await context.newPage();
|
||
await page.emulateMedia({ colorScheme: scheme });
|
||
await page.goto('/p/content-fixtures/classified-note');
|
||
await page.waitForLoadState('networkidle');
|
||
// Kennzeichnung oben UND unten (issue #206, ADR 0022) — fester
|
||
// Wortlaut, nicht lokalisiert.
|
||
const banners = page.locator('.classification-banner');
|
||
await expect(banners).toHaveCount(2);
|
||
await expect(banners.first()).toContainText('VS – NUR FÜR DEN DIENSTGEBRAUCH');
|
||
await expect(banners.last()).toContainText('VS – NUR FÜR DEN DIENSTGEBRAUCH');
|
||
await expectClean(page, `Eingestufte Seite classified-note (${scheme})`);
|
||
await context.close();
|
||
});
|
||
|
||
test(`user settings pass the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
|
||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||
const page = await context.newPage();
|
||
await page.emulateMedia({ colorScheme: scheme });
|
||
await page.goto('/settings');
|
||
await page.waitForLoadState('networkidle');
|
||
await expectClean(page, `/settings (${scheme})`);
|
||
|
||
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den
|
||
// eigenen Schriften zwei Tabellen samt Scroll-Regionen). Bewusst
|
||
// KEIN eigener Test — jeder zusätzliche Login im Pack bringt die
|
||
// CI zwei Packs später ans Rate-Limit (Lehre aus #301).
|
||
await page.goto('/fonts');
|
||
await page.waitForLoadState('networkidle');
|
||
await expectClean(page, `/fonts (${scheme})`);
|
||
await context.close();
|
||
});
|
||
|
||
test(`admin area passes the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
|
||
const context = await contextForUser(browser, BASE, 'fixture-admin');
|
||
const page = await context.newPage();
|
||
await page.emulateMedia({ colorScheme: scheme });
|
||
await page.goto('/admin');
|
||
await page.waitForLoadState('networkidle');
|
||
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
|
||
await page.locator('.user-manager__table .user-row').first().waitFor();
|
||
// Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung
|
||
// liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert.
|
||
await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor();
|
||
// Dasselbe für den Branding-Abschnitt (issue #306). Der Zuschnitt ist
|
||
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
|
||
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
|
||
await expectClean(page, `/admin (${scheme})`);
|
||
await context.close();
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Reflow (WCAG 2.1 SC 1.4.10, issue #301): bei 320 px CSS-Breite — was 400 %
|
||
* Zoom auf 1280 px entspricht — darf die Seite nicht seitenweit horizontal
|
||
* scrollen. axe prüft das NICHT, das Kriterium ist nicht maschinell aus dem
|
||
* DOM ableitbar; deshalb ein eigener Zaun.
|
||
*
|
||
* Schlägt er an, nennt er die überstehenden Elemente. Ohne diese Diagnose
|
||
* weiß man nur DASS es überläuft und muss im Browser bisektieren.
|
||
*/
|
||
const NARROW = { width: 320, height: 800 };
|
||
|
||
async function expectNoHorizontalScroll(page: Page, label: string): Promise<void> {
|
||
const report = await page.evaluate(() => {
|
||
const doc = document.documentElement;
|
||
const limit = doc.clientWidth;
|
||
|
||
const describe = (el: Element): string => {
|
||
const cls =
|
||
el.className && typeof el.className === 'string'
|
||
? `.${el.className.trim().split(/\s+/).join('.')}`
|
||
: '';
|
||
return `${el.tagName.toLowerCase()}${cls}`;
|
||
};
|
||
|
||
// Every element whose own content is wider than its box. One of these is
|
||
// the source; the ones that scroll it away on purpose are marked.
|
||
const overflowing: string[] = [];
|
||
for (const el of Array.from(document.querySelectorAll('*'))) {
|
||
if (el.scrollWidth > el.clientWidth + 1 && el.clientWidth > 0) {
|
||
const overflowX = getComputedStyle(el).overflowX;
|
||
overflowing.push(
|
||
`${describe(el)} client=${el.clientWidth} scroll=${el.scrollWidth} overflow-x=${overflowX}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Content inside a scroll container may exceed the viewport — that is
|
||
* the remedy. But only when the CONTAINER fits: a scroller that is
|
||
* itself too wide still pushes the page. */
|
||
const insideFittingScroller = (el: Element): boolean => {
|
||
for (let node = el.parentElement; node && node !== doc; node = node.parentElement) {
|
||
const ox = getComputedStyle(node).overflowX;
|
||
if (ox === 'auto' || ox === 'scroll' || ox === 'hidden') {
|
||
return node.getBoundingClientRect().right <= limit + 1;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// Widest reach first, so a long tail of clipped children cannot bury the
|
||
// one box that actually pushes the page.
|
||
const past = Array.from(document.querySelectorAll('body *'))
|
||
.map((el) => ({ el, rect: el.getBoundingClientRect() }))
|
||
.filter(({ rect }) => rect.width > 0 && rect.right > limit + 1)
|
||
.sort((a, b) => b.rect.right - a.rect.right)
|
||
.map(
|
||
({ el, rect }) =>
|
||
`${describe(el)} right=${Math.round(rect.right)} w=${Math.round(rect.width)}` +
|
||
`${insideFittingScroller(el) ? ' [in fitting scroller]' : ' <-- pushes page'}`,
|
||
);
|
||
|
||
return {
|
||
overflowBy: doc.scrollWidth - limit,
|
||
viewport: `html client=${limit} scroll=${doc.scrollWidth} | body client=${document.body.clientWidth} scroll=${document.body.scrollWidth} rect=${Math.round(document.body.getBoundingClientRect().width)}`,
|
||
overflowing: overflowing.slice(0, 15),
|
||
past: past.slice(0, 40),
|
||
};
|
||
});
|
||
const diagnosis = [
|
||
`${label}: horizontaler Überlauf bei 320 px`,
|
||
report.viewport,
|
||
`eigener Inhaltsüberlauf: ${JSON.stringify(report.overflowing, null, 1)}`,
|
||
`Boxen über dem Rand: ${JSON.stringify(report.past, null, 1)}`,
|
||
].join('\n');
|
||
expect({ overflowBy: report.overflowBy }, diagnosis).toEqual({ overflowBy: 0 });
|
||
}
|
||
|
||
test.describe('reflow at 320px', () => {
|
||
test('user settings do not scroll horizontally at 320px', async ({ browser }) => {
|
||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||
const page = await context.newPage();
|
||
await page.setViewportSize(NARROW);
|
||
await page.goto('/settings');
|
||
await page.waitForLoadState('networkidle');
|
||
// Die Sitzungstabelle rendert asynchron und ist der breiteste Inhalt —
|
||
// ohne sie misst der Zaun eine halb aufgebaute Seite.
|
||
await page.locator('.table tbody tr').first().waitFor();
|
||
await expectNoHorizontalScroll(page, '/settings');
|
||
await context.close();
|
||
});
|
||
});
|