play data-safety: write the conditional required rows (PSL_ACCOUNT_DELETION_URL from accounts.deletionUrl, PSL_SUPPORT_DATA_DELETION_BY_USER from dataSafety.partialDeletion, PSL_DATA_DELETION_URL); a rule table checks every conditional row in check, declarations and --dry before Play answers 400

This commit is contained in:
CRTkafa 2026-10-12 08:25:14 +08:00
parent c9a7f095c4
commit 97ab76e9c8
14 changed files with 295 additions and 24 deletions

View file

@ -139,6 +139,24 @@ describe('check: appPrivacy.types against the iOS project', () => {
expect(msgs(r)).toContain('appPrivacy.types.GAMEPLAY_CONTENT is collected, but App/PrivacyInfo.xcprivacy has no NSPrivacyCollectedDataTypeGameplayContent');
expect(appPrivacyMismatches({ DEVICE_ID: { collected: true, tracking: true } }, { attString: true, manifest: m(true, ['NSPrivacyCollectedDataTypeDeviceID']) })).toEqual([]);
});
test('storebound check: a missing accounts.deletionUrl is a Data safety problem (Play 400 otherwise)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'storebound-ds-'));
try {
writeFileSync(join(dir, '.gitignore'), '.storebound/\n');
const c = { app: base.app, languages: base.languages, accounts: { enabled: true, signUp: ['username'] },
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false,
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account'] } } } } as ShipConfig;
const out: string[] = [];
const orig = console.log;
console.log = (s: string) => { out.push(s); };
try { await check({ loaded: { config: c, root: dir, path: join(dir, 'storebound.config.ts'), resolve: p => join(dir, p) }, args: parseArgs(['--json']) }); }
finally { console.log = orig; process.exitCode = 0; }
const problems = (JSON.parse(out.at(-1)!).problems as string[]).join('\n');
expect(problems).toContain('data safety: missing: PSL_ACCOUNT_DELETION_URL');
expect(problems).toContain('set accounts.deletionUrl');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('storebound check reports the mismatches (errors are problems)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'storebound-ap-'));
try {

View file

@ -347,6 +347,21 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri
expect(s.play.dataSafety).toHaveLength(3);
});
test('play data-safety: a missing conditional row stops --dry and the real run before any POST', async () => {
// STAR DASH: accounts with a username, no deletion URL in the CSV -> Play 400 "Response missing for PSL_ACCOUNT_DELETION_URL"
const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false,
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account'] } } };
const p = project({ ...CONFIG, accounts: { enabled: true, signUp: ['username'] }, dataSafety: ds as never });
const before = s.calls.length;
await expect(capture(() => playDataSafety(p.ctx(['--dry'])))).rejects.toThrow('missing: PSL_ACCOUNT_DELETION_URL (account creation is not PSL_ACM_NONE; set accounts.deletionUrl');
await expect(capture(() => playDataSafety(p.ctx([])))).rejects.toThrow('PSL_ACCOUNT_DELETION_URL');
expect(writes(s.calls.slice(before))).toEqual([]);
const q = project({ ...CONFIG, accounts: { enabled: true, signUp: ['username'], deletionUrl: 'https://example.com/delete-account' }, dataSafety: ds as never });
await capture(() => playDataSafety(q.ctx([])));
expect(s.play.dataSafety.at(-1)).toContain('PSL_ACCOUNT_DELETION_URL,,https://example.com/delete-account,');
expect(s.play.dataSafety.at(-1)).toContain('PSL_SUPPORT_DATA_DELETION_BY_USER,,FALSE,');
});
test('apple promoted: created and ordered once', async () => {
s.asc.put({ type: 'inAppPurchases', id: 'iap-pro', attributes: { productId: 'com.example.notes.pro', state: 'APPROVED' }, rels: { app: APP } });
s.asc.put({ type: 'inAppPurchases', id: 'iap-tip', attributes: { productId: 'com.example.notes.tip', state: 'APPROVED' }, rels: { app: APP } });

View file

@ -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 { dataSafetyCsv, dataSafetyRows } from '../cli/lib/data-safety';
import { DATA_SAFETY_RULES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from '../cli/lib/data-safety';
import { parseCsv } from '../cli/lib/csv';
import { ALPHA3, appleTerritory, fromMoney, nearestPrices, playRegionConfigs, samePrice, toMoney } from '../cli/lib/money';
import { maskExpr } from '../cli/icons';
import { ffmpegQ } from '../cli/shots/jpeg';
@ -99,6 +100,53 @@ 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'],
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');
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(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', () => {
const rows = dataSafetyRows({ ...acc, accountCreation: ['PSL_ACM_NONE'] }, { deletionUrl: url });
for (const q of ['PSL_ACCOUNT_DELETION_URL', 'PSL_SUPPORT_DATA_DELETION_BY_USER', 'PSL_DATA_DELETION_URL']) expect(get(rows, q)).toBeUndefined();
expect(dataSafetyIssues(rows)).toEqual([]);
expect(get(dataSafetyRows({ ...acc, accountCreation: undefined }), 'PSL_ACCOUNT_DELETION_URL')).toBeUndefined();
});
test('missing deletionUrl / partialDeletion: named with the config field to set', () => {
const msgs = dataSafetyIssues(dataSafetyRows(acc)).map(i => i.msg);
expect(msgs[0]).toBe('missing: PSL_ACCOUNT_DELETION_URL (account creation is not PSL_ACM_NONE; set accounts.deletionUrl, a public https page to request account + data deletion (account-deletion skill))');
expect(msgs[1]).toContain('missing: PSL_SUPPORT_DATA_DELETION_BY_USER');
expect(msgs[1]).toContain('dataSafety.partialDeletion');
expect(dataSafetyIssues(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: undefined }, {})).map(i => i.question)).toEqual(['PSL_ACCOUNT_DELETION_URL', 'PSL_DATA_DELETION_URL']);
});
test('extra replaces a generated question; refused and per-type rows', () => {
const rows = dataSafetyRows({ ...acc, partialDeletion: false, extra: [{ question: 'PSL_ACCOUNT_DELETION_URL', value: url }] });
expect(rows.filter(r => r[0] === 'PSL_ACCOUNT_DELETION_URL')).toHaveLength(1);
expect(dataSafetyIssues(rows)).toEqual([]);
const outside = dataSafetyRows({ ...acc, partialDeletion: false, extra: [{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', value: 'FALSE' }] }, { deletionUrl: url });
expect(dataSafetyIssues(outside).map(i => i.msg.split(' (')[0])).toEqual(['not allowed: PSL_HAS_OUTSIDE_APP_ACCOUNTS']);
// a type selected but its purpose rows emptied (e.g. a hand-edited export)
const cut = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url })
.map(r => r[0] === 'PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE' ? [r[0], r[1]!, '', r[3]!, r[4]!] : r);
expect(dataSafetyIssues(cut).map(i => i.question)).toEqual(['PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE']);
});
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([]);
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('prices', () => {
test('toMoney without float noise', () => {
expect(toMoney('USD', 2.99)).toEqual({ currencyCode: 'USD', units: '2', nanos: 990000000 });