/** * 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 the app shares something contradicts the premise the whole page * rests on, and would be a real disclosure defect rather than a typo; * - a data type that is not the console's own, which the form then never asks about; * - 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, deletionRow, PLAY_DATA_TYPES, PLAY_PURPOSES, 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 { types, answer, because, collected: isCollected, shared } = row.play; assert.ok(Array.isArray(types), `${row.id}.play.types is not a list`); 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 console's own words, or nothing. A type filed under the wrong category reads fine * in the generated table and is simply not on the form, so whoever fills it in either * skips it or guesses where it went. */ it('uses only the console’s categories, types and purposes', () => { for (const row of playRows()) { for (const { category, type } of row.play.types) { assert.ok(PLAY_DATA_TYPES[category], `${row.id}: "${category}" is not a console category`); assert.ok( PLAY_DATA_TYPES[category].includes(type), `${row.id}: "${type}" is not a data type under "${category}"` ); } for (const purpose of row.play.purposes ?? []) { assert.ok(PLAY_PURPOSES.includes(purpose), `${row.id}: "${purpose}" is not a console purpose`); } } }); /* * The form asks three more questions of every collected type, and a collected row with * no type is data leaving the phone that the form never hears about. */ it('answers the follow-up questions for everything collected', () => { for (const row of playRows().filter((r) => r.play.collected)) { assert.ok(row.play.types.length, `${row.id} is collected but names no console type`); assert.equal(typeof row.play.required, 'boolean', `${row.id}.play.required is not a boolean`); assert.ok(row.play.purposes?.length, `${row.id} is collected but gives no purpose`); } }); /* * The one assertion here that is about the product rather than the shape of the data. * * What the app sends is collected in Play's sense, and declared so: it leaves the phone * for the deployment the user chose. What stays true is the premise of /privacy section 2, * that none of it reaches us and none of it is handed on. A row that says otherwise — a * telemetry endpoint, a crash reporter, a hosted directory of deployments — means that * premise has changed, and the page has to change with it rather than gaining a row that * contradicts its own lede. */ it('holds the premise the whole section rests on', () => { for (const row of playRows()) { assert.equal( row.play.shared, false, `${row.id} says the app shares it. If that is now true, /privacy section 2's ` + 'premise — that the app talks only to the deployment the user chose — has changed.' ); } }); }); /* * `/delete-account/` is the address Google Play is given (ACCOUNT_DELETION.md §4.5), and * Play asks two things of it that render fine when missing: what is deleted, and how long * what stays is kept. A `stays` entry with no `keep` is a card with no answer to the * second question. */ describe('the account-deletion row', () => { it('exists exactly once, in the deployment scope', () => { const row = deletionRow(); assert.equal(row.scope, 'deployment', 'the deletion row is about a deployment, not about us'); }); it('says what goes and what stays, with how long for each', () => { const { goes, stays, staff } = deletionRow().deletion; assert.ok(goes.length, 'the deletion row names nothing deleted'); assert.ok(stays.length, 'the deletion row names nothing kept'); for (const entry of stays) { assert.ok(entry.what?.trim(), 'a kept entry says nothing about what is kept'); assert.ok(entry.keep?.trim(), `"${entry.what}" does not say how long it is kept`); } assert.ok(staff?.trim(), 'the staff note (AD9) is missing'); }); /* * AD5 in test form: members see "[deleted player]", the wording the website and the app * render. A page that named a different placeholder would describe a product that does * not exist. */ it('names the placeholder members see', () => { const text = deletionRow().deletion.stays.map((entry) => entry.what).join(' '); assert.match(text, /“\[deleted player\]”/); }); }); 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' ); }); });