All checks were successful
PR checks / checks (pull_request) Successful in 2m33s
Play counts data that leaves the device as collected, whoever runs the server it lands on. The generated Data Safety notes answered "not collected" because we run no server, which is true of /privacy and the wrong answer to the form (org lead, 2026-10-29, Android PLAN.md M8). An audit of the app's edge (every body, query and header in data/api/) found more leaving the phone than section 2 listed. New rows: the User-Agent, game-account links and creation, the Contact form, staff posts and moderation, notification read state and preferences, search text. The sign-in and trusted-device rows now say what is sent (the credentials, the device's make and model). - PlayMapping: a list of console types, plus required and purposes, checked against PLAY_DATA_TYPES / PLAY_PURPOSES (the first version filed "Other user-generated content" under Messages). - The generator answers per data type, lists every type not collected, and answers the form's security questions. - The premise test now asserts what is still true: nothing is shared. - legal.lastUpdated moves to 2026-10-29 with /privacy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
187 lines
7.6 KiB
JavaScript
187 lines
7.6 KiB
JavaScript
/**
|
||
* 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,
|
||
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.'
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
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'
|
||
);
|
||
});
|
||
});
|