/** * The legal pages' data, tested where a mistake would be invisible. PLAN.md §9, phase 6. * * --------------------------------------------------------------------------------------- * WHAT IS ACTUALLY AT RISK HERE * --------------------------------------------------------------------------------------- * A privacy policy is prose, and prose is not testable. What IS testable is the small set * of structural promises the page and the Play declaration both rest on, every one of * which fails silently: * * - a row with no retention line renders a card with an empty "How long" — which reads * as "we keep this forever" or "we keep nothing", depending on the reader; * - an `app`-scoped row with no Play mapping means a question on the console form gets * answered from memory, which is the exact failure D33 exists to prevent; * - a row that claims we collect or share something contradicts the premise the whole * page rests on, and would be a real disclosure defect rather than a typo; * - the consent sentence and /terms stating different minimum ages, which is the kind of * inconsistency a reviewer finds and a developer never does. * * None of that shows up in a build, a type check or a link check: the page renders * beautifully with an empty cell and a wrong number in it. * * The generated Play document is checked by `scripts/playDataSafety.mjs --check` rather * than here — a generator's output is a build artefact, and comparing it in two places * means fixing it in two places. */ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { assertScopeNonEmpty, collected, collectedIn, playRows, } from '../src/data/collection.mjs'; import { legal } from '../src/data/legal.mjs'; import { CONSENT_TEXT, CONSENT_VERSION, requirements } from '../src/data/beta.mjs'; const SCOPES = ['site', 'app', 'deployment']; describe('the collection inventory', () => { it('has a row in every scope /privacy renders', () => { for (const scope of SCOPES) { assert.doesNotThrow(() => assertScopeNonEmpty(scope), `scope "${scope}" is empty`); } }); it('uses only the three scopes the page knows how to render', () => { for (const row of collected) { assert.ok(SCOPES.includes(row.scope), `${row.id} has unknown scope "${row.scope}"`); } }); it('gives every row a unique id', () => { const ids = collected.map((row) => row.id); assert.equal(new Set(ids).size, ids.length, 'duplicate id in collection.mjs'); }); it('gives every row a retention summary and a source', () => { for (const row of collected) { assert.ok(row.retention?.summary?.trim(), `${row.id} has no retention summary`); assert.ok(row.source?.trim(), `${row.id} does not name the file it was read from`); assert.ok(row.title?.trim() && row.body?.trim(), `${row.id} is missing prose`); } }); }); describe('the Play Data Safety mapping', () => { it('maps every app-scoped row', () => { assert.doesNotThrow(() => playRows()); assert.equal(playRows().length, collectedIn('app').length); }); it('answers every mapped question completely', () => { for (const row of playRows()) { const { category, type, answer, because, collected: isCollected, shared } = row.play; assert.ok(category?.trim() && type?.trim(), `${row.id} has no console category/type`); assert.ok(answer?.trim() && because?.trim(), `${row.id} has no answer or reasoning`); assert.equal(typeof isCollected, 'boolean', `${row.id}.play.collected is not a boolean`); assert.equal(typeof shared, 'boolean', `${row.id}.play.shared is not a boolean`); } }); /* * The one assertion here that is about the product rather than the shape of the data. * * "We operate no server the app talks to" is the premise of /privacy section 2, of the * generated declaration, and of the argument for why the app needs no account with us. * If that ever stops being true — a telemetry endpoint, a crash reporter, a hosted * directory of deployments — the honest change is a `collected: true` row AND a rewrite * of the page's second section. This test makes the first impossible without noticing * the second, by failing with the reason rather than the diff. */ it('holds the premise the whole section rests on', () => { for (const row of playRows()) { assert.equal( row.play.collected, false, `${row.id} says we collect it. If that is now true, /privacy section 2's premise — ` + 'that we operate no server the app talks to — has changed, and the page has to ' + 'change with it rather than gaining a row that contradicts its own lede.' ); assert.equal(row.play.shared, false, `${row.id} says we share it — see above.`); } }); }); describe('the minimum age', () => { it('is stated in the consent sentence the row records', () => { assert.match( CONSENT_TEXT, new RegExp(`\\b${legal.minimumAge}\\b`), 'the consent text does not state the minimum age' ); }); it('is stated in the eligibility list', () => { const ages = requirements.filter((entry) => new RegExp(`\\b${legal.minimumAge}\\b`).test(entry.title) ); assert.equal(ages.length, 1, 'the beta requirements should name the age exactly once'); }); /* * Changing the wording without changing the label would leave two different sentences * sharing one version in the exported CSV, which is the only thing that label is for. */ it('was accompanied by a consent version bump', () => { assert.notEqual( CONSENT_VERSION, '2026-08-24', 'the age clause changed CONSENT_TEXT; CONSENT_VERSION must not still be the label ' + 'the pre-age wording was written under' ); }); }); describe('the published contact route', () => { /* * D13 in test form. `checkFacts.mjs` enforces this over the whole of `src/` and * `scripts/`, and it needs a network token to run — so it is skipped by anybody working * offline, on the two files most likely to want to type an address into. */ it('is not baked into the legal data', () => { const text = JSON.stringify({ collected, legal }); assert.doesNotMatch( text, /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i, 'an email address is hard-coded in the legal data — read brand.contactEmail instead' ); }); });