Files
runicgateway.com/test/legal.test.mjs
wtclaude 094c606238
Some checks failed
PR checks / checks (pull_request) Failing after -31s
feat(legal): /delete-account/, the page Google Play is given (D85, D86)
Step 5 of docs/website/ACCOUNT_DELETION.md (§4.5). The page explains how
to delete an account on a community's site and in the app, what goes,
what stays and for how long, and who to ask when you cannot sign in. It
cannot delete anything, because we hold no account.

- The facts are one deployment row in collection.mjs (deletion.goes,
  .stays with a keep each, .staff), in the website's own words. /privacy
  section 3 renders it as prose, the page renders its lists, and
  PLAY_DATA_SAFETY.md prints them under the console's deletion questions.
- The app rows that a deletion touches (session, trust token, push,
  inbox snapshot) say so. The Data Safety notes now answer "yes" to
  account deletion and name the URL. lastUpdated is 2026-10-30.
- D85: linked from the footer's legal bar, /privacy sections 2 and 3,
  and /app/'s "Two ways to get it".
- D86: someone who cannot sign in is sent to their community's operator,
  never to us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 01:25:48 -05:00

222 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'
);
});
});