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:
parent
c9a7f095c4
commit
97ab76e9c8
14 changed files with 295 additions and 24 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue