play data-safety: PSL_SUPPORT_DATA_DELETION_BY_USER as Play's single choice (partialDeletion true | false | 'auto90'), drop the removed PSL_DATA_COLLECTION_USER_REQUEST_DELETE (deletionRequest deprecated, check notes it); ship Google's empty form (cli/data/play-data-safety-form.csv, exported 2026-10-12), take every Answer requirement from it and report pairs not in it (formPairIssues) in check, declarations and play data-safety. Pitfall 21
This commit is contained in:
parent
dfc28c15f8
commit
b5b8f2da9d
35 changed files with 1824 additions and 859 deletions
|
|
@ -4,7 +4,8 @@ import { join } from 'node:path';
|
|||
import { parseArgs } from '../cli/lib/args';
|
||||
import { loadConfig, validateConfig } from '../cli/lib/config';
|
||||
import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy';
|
||||
import { DATA_SAFETY_RULES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from '../cli/lib/data-safety';
|
||||
import { DATA_SAFETY_RULES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows, formPairIssues, formRow, playForm, PLAY_FORM_EXPORTED } from '../cli/lib/data-safety';
|
||||
import type { DataSafety } from '../cli/lib/config';
|
||||
import { parseCsv } from '../cli/lib/csv';
|
||||
import { ALPHA3, appleTerritory, fromMoney, nearestPrices, playRegionConfigs, samePrice, toMoney } from '../cli/lib/money';
|
||||
import { maskExpr } from '../cli/icons';
|
||||
|
|
@ -27,11 +28,11 @@ describe('config', () => {
|
|||
languages: [{ id: 'en' }, { id: 'en' }],
|
||||
play: { packageName: 'notapackage' },
|
||||
products: [{ id: 'bad id!', type: 'gift', usd: 0, listings: { de: { title: '', description: 'x' } }, overrides: [{ region: 'TUR', currency: 'tl', price: 1 }] }],
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, data: { DEVICE: { shared: true, collect: [] } } },
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, partialDeletion: 'later' as never, data: { DEVICE: { shared: true, collect: [] } } },
|
||||
});
|
||||
const text = e.join('\n');
|
||||
for (const s of ['privacyPolicyUrl', 'contactEmail', 'duplicate "en"', 'packageName', 'products[0].id', 'products[0].type', 'products[0].usd',
|
||||
'listings.de: not in languages', 'region "TUR"', 'currency "tl"', 'dataSafety.data.DEVICE', 'sharing purposes']) expect(text).toContain(s);
|
||||
'listings.de: not in languages', 'region "TUR"', 'currency "tl"', 'dataSafety.data.DEVICE', 'sharing purposes', "dataSafety.partialDeletion: true | false | 'auto90'"]) expect(text).toContain(s);
|
||||
});
|
||||
test('empty config', () => { expect(validateConfig(null)).toEqual(['config must be an object']); });
|
||||
});
|
||||
|
|
@ -69,14 +70,16 @@ describe('copy parsing', () => {
|
|||
});
|
||||
|
||||
describe('data safety csv', () => {
|
||||
const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: false,
|
||||
const ds = { collectsData: true, encryptedInTransit: true,
|
||||
data: { PSL_DEVICE_ID: { shared: true, collect: ['analytics' as const, 'advertising' as const], share: ['advertising' as const] },
|
||||
PSL_OTHER_PERSONAL: { shared: false, optional: true, collect: ['functionality' as const] } } };
|
||||
const rows = dataSafetyRows(ds);
|
||||
const find = (q: string, r: string) => rows.find(x => x[0] === q && x[1] === r);
|
||||
test('header + overview + account creation', () => {
|
||||
expect(rows[0]![0]).toBe('Question ID (machine readable)');
|
||||
expect(find('PSL_DATA_COLLECTION_USER_REQUEST_DELETE', '')![2]).toBe('FALSE');
|
||||
// Play removed this question from the form; writing it is a 400
|
||||
expect(rows.some(r => r[0] === 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBe(false);
|
||||
expect(dataSafetyRows({ ...ds, deletionRequest: true }).some(r => r[0] === 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBe(false);
|
||||
expect(find('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', 'PSL_ACM_NONE')![2]).toBe('TRUE');
|
||||
});
|
||||
test('types and purposes', () => {
|
||||
|
|
@ -101,18 +104,31 @@ describe('data safety csv', () => {
|
|||
});
|
||||
|
||||
describe('data safety: conditional required rows', () => {
|
||||
const acc = { ...{ collectsData: true, encryptedInTransit: true, deletionRequest: true }, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'],
|
||||
const acc = { ...{ collectsData: true, encryptedInTransit: true }, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'],
|
||||
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account' as const] } } };
|
||||
const url = 'https://example.com/delete-account';
|
||||
const get = (rows: string[][], q: string) => rows.find(r => r[0] === q);
|
||||
test('accounts + username: the delete-account URL row carries accounts.deletionUrl', () => {
|
||||
const rows = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url });
|
||||
expect(get(rows, 'PSL_ACCOUNT_DELETION_URL')![2]).toBe(url);
|
||||
expect(get(rows, 'PSL_SUPPORT_DATA_DELETION_BY_USER')![2]).toBe('FALSE');
|
||||
// a SINGLE_CHOICE: one row per option, TRUE on the picked one (a value with no Response ID was Play's
|
||||
// "Line 7: Response ID missing")
|
||||
expect(rows.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER').map(r => r.slice(0, 4).join(','))).toEqual([
|
||||
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,,SINGLE_CHOICE',
|
||||
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,TRUE,SINGLE_CHOICE',
|
||||
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO_AUTO_DELETED,,SINGLE_CHOICE',
|
||||
]);
|
||||
expect(dataSafetyCsv({ ...acc, partialDeletion: false }, { deletionUrl: url })).toContain('\nPSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,TRUE,');
|
||||
expect(get(rows, 'PSL_DATA_DELETION_URL')).toBeUndefined();
|
||||
expect(dataSafetyIssues(rows)).toEqual([]);
|
||||
const yes = dataSafetyRows({ ...acc, partialDeletion: true }, { deletionUrl: url });
|
||||
expect(get(yes, 'PSL_DATA_DELETION_URL')![2]).toBe(url);
|
||||
expect(yes.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER' && r[2] === 'TRUE').map(r => r[1])).toEqual(['DATA_DELETION_YES']);
|
||||
expect(dataSafetyIssues(yes)).toEqual([]);
|
||||
const auto = dataSafetyRows({ ...acc, partialDeletion: 'auto90' }, { deletionUrl: url });
|
||||
expect(auto.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER' && r[2] === 'TRUE').map(r => r[1])).toEqual(['DATA_DELETION_NO_AUTO_DELETED']);
|
||||
expect(get(auto, 'PSL_DATA_DELETION_URL')).toBeUndefined();
|
||||
expect(dataSafetyIssues(auto)).toEqual([]);
|
||||
expect(get(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: 'https://example.com/data' }, { deletionUrl: url }), 'PSL_DATA_DELETION_URL')![2]).toBe('https://example.com/data');
|
||||
});
|
||||
test('no accounts: no deletion rows, no issues', () => {
|
||||
|
|
@ -142,11 +158,57 @@ describe('data safety: conditional required rows', () => {
|
|||
test('the template passes the rules and only uses known question ids', () => {
|
||||
const rows = parseCsv(readFileSync(join(import.meta.dir, '..', 'templates', 'data-safety.csv'), 'utf8'));
|
||||
expect(dataSafetyIssues(rows)).toEqual([]);
|
||||
// an example with answers, not the empty form, and every pair is in Play's current form
|
||||
expect(rows.some(r => r[2] === 'TRUE')).toBe(true);
|
||||
expect(formPairIssues(rows)).toEqual([]);
|
||||
for (const r of rows.slice(1)) expect(r[3]).toBe(formRow(r[0]!, r[1]!)!.requirement);
|
||||
const known = new Set(DATA_SAFETY_RULES.map(r => r.question));
|
||||
for (const r of rows.slice(1)) expect(known.has(r[0]!) || /^PSL_DATA_TYPES_|^PSL_DATA_USAGE_RESPONSES:PSL_[A-Z_]+:/.test(r[0]!)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("data safety: pairs from Play's exported form", () => {
|
||||
const url = 'https://example.com/delete-account';
|
||||
const all = (u: Partial<DataSafety['data'][string]> = {}) => Object.fromEntries(['PSL_NAME', 'PSL_EMAIL', 'PSL_PURCHASE_HISTORY', 'PSL_APPROX_LOCATION', 'PSL_PRECISE_LOCATION',
|
||||
'PSL_CRASH_LOGS', 'PSL_USER_INTERACTION', 'PSL_DEVICE_ID', 'PSL_PHOTOS', 'PSL_HEALTH'].map(t => [t, { shared: true, collect: ['analytics', 'functionality'], share: ['advertising'], ...u }])) as DataSafety['data'];
|
||||
const base: DataSafety = { collectsData: true, encryptedInTransit: true, data: {} };
|
||||
const accounts = { ...base, accountCreation: ['PSL_ACM_USER_ID_PASSWORD', 'PSL_ACM_OAUTH'], data: { PSL_EMAIL: { shared: false, collect: ['account' as const] } } };
|
||||
const cases: [string, DataSafety][] = [
|
||||
['no data', { collectsData: false, encryptedInTransit: false, data: {} }],
|
||||
['accounts, partialDeletion true', { ...accounts, partialDeletion: true }],
|
||||
['accounts, partialDeletion false', { ...accounts, partialDeletion: false }],
|
||||
["accounts, partialDeletion 'auto90'", { ...accounts, partialDeletion: 'auto90' }],
|
||||
['many types shared and collected', { ...base, data: all() }],
|
||||
['many types, optional and ephemeral', { ...base, data: all({ shared: false, optional: true, ephemeral: true }) }],
|
||||
['extra rows', { ...base, data: all(), extra: [{ question: 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY', value: 'TRUE' }, { question: 'PSL_INDEPENDENTLY_VALIDATED', value: 'FALSE' },
|
||||
{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', value: 'TRUE' }, { question: 'PSL_OUTSIDE_APP_ACCOUNT_TYPES', response: 'PSL_LOGIN_THROUGH_EMPLOYMENT_OR_ENTERPRISE_ACCOUNT', value: 'TRUE' }] }],
|
||||
];
|
||||
test(`the form (exported ${PLAY_FORM_EXPORTED}) is Google's empty export`, () => {
|
||||
const f = [...playForm().values()];
|
||||
expect(f.length).toBe(782);
|
||||
expect(formRow('PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBeUndefined();
|
||||
expect(formRow('PSL_SUPPORT_DATA_DELETION_BY_USER', 'DATA_DELETION_NO')!.requirement).toBe('SINGLE_CHOICE');
|
||||
expect(formRow('PSL_SUPPORT_DATA_DELETION_BY_USER')).toBeUndefined();
|
||||
});
|
||||
for (const [name, ds] of cases) test(`${name}: every pair is in the form with its Answer requirement`, () => {
|
||||
const rows = dataSafetyRows(ds, { deletionUrl: url });
|
||||
expect(formPairIssues(rows)).toEqual([]);
|
||||
for (const r of rows.slice(1)) expect([r[0], r[1], r[3]]).toEqual([r[0], r[1], formRow(r[0]!, r[1]!)!.requirement]);
|
||||
});
|
||||
test('extra rows take the requirement from the form, not a guess', () => {
|
||||
const rows = dataSafetyRows({ ...base, extra: [{ question: 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY', value: 'TRUE' }, { question: 'PSL_OUTSIDE_APP_ACCOUNT_TYPES', response: 'PSL_OUTSIDE_APP_ACCOUNT_TYPE_OTHER', value: 'TRUE', label: 'mine' }] });
|
||||
expect(rows.find(r => r[0] === 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY')!.slice(2, 4)).toEqual(['TRUE', 'OPTIONAL']);
|
||||
expect(rows.find(r => r[0] === 'PSL_OUTSIDE_APP_ACCOUNT_TYPES')!.slice(2)).toEqual(['TRUE', 'MULTIPLE_CHOICE', 'mine']);
|
||||
});
|
||||
test('an unknown pair is reported by dataSafetyIssues (check, declarations, play data-safety --dry)', () => {
|
||||
const rows = dataSafetyRows({ ...base, extra: [{ question: 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE', value: 'TRUE' }, { question: 'PSL_SUPPORT_DATA_DELETION_BY_USER', value: 'FALSE' }] });
|
||||
const msgs = dataSafetyIssues(rows).map(i => i.msg);
|
||||
expect(msgs).toContain(`PSL_DATA_COLLECTION_USER_REQUEST_DELETE, is not in Play's current Data safety form (cli/data/play-data-safety-form.csv, exported ${PLAY_FORM_EXPORTED}); Play answers 400`);
|
||||
expect(msgs.some(m => m.startsWith("PSL_SUPPORT_DATA_DELETION_BY_USER, is not in Play's current"))).toBe(true);
|
||||
expect(formPairIssues([['PSL_DATA_TYPES_PERSONAL', 'PSL_NOPE', 'TRUE', 'MULTIPLE_CHOICE', '']]).map(i => i.question)).toEqual(['PSL_DATA_TYPES_PERSONAL']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prices', () => {
|
||||
test('toMoney without float noise', () => {
|
||||
expect(toMoney('USD', 2.99)).toEqual({ currencyCode: 'USD', units: '2', nanos: 990000000 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue