play data-safety: PSL_SUPPORT_DATA_DELETION_BY_USER is the general 'can users request deletion?' question; a delete-account URL answers yes (with PSL_DATA_DELETION_URL), dataSafety.dataDeletion for apps without one; check flags a no next to a delete-account URL

This commit is contained in:
CRTkafa 2026-10-12 09:46:58 +08:00
parent e01a27d7b7
commit dbd9f2973c
15 changed files with 95 additions and 66 deletions

View file

@ -144,7 +144,7 @@ describe('check: appPrivacy.types against the iOS project', () => {
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, // deletionRequest: deprecated, a note
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;
@ -155,8 +155,7 @@ describe('check: appPrivacy.types against the iOS project', () => {
const problems = res.problems.join('\n');
expect(problems).toContain('data safety: missing: PSL_ACCOUNT_DELETION_URL');
expect(problems).toContain('set accounts.deletionUrl');
expect(problems).not.toContain('PSL_SUPPORT_DATA_DELETION_BY_USER');
expect(res.warnings.join('\n')).toContain('dataSafety.deletionRequest is no longer used: Play removed that question');
expect(problems).not.toContain('missing: PSL_SUPPORT_DATA_DELETION_BY_USER'); // deletionRequest: true answers it
} finally { rmSync(dir, { recursive: true, force: true }); }
});

View file

@ -360,7 +360,8 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri
await capture(() => playDataSafety(q.ctx([])));
expect(s.play.dataSafety.at(-1)).toContain('PSL_ACCOUNT_DELETION_URL,,https://example.com/delete-account,');
// a single choice: the picked option carries TRUE (a bare FALSE was Play's 400 "Line 7: Response ID missing")
expect(s.play.dataSafety.at(-1)).toContain('\nPSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,TRUE,');
// a delete-account page answers the general deletion question with yes
expect(s.play.dataSafety.at(-1)).toContain('\nPSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,TRUE,');
expect(s.play.dataSafety.at(-1)).not.toContain('PSL_SUPPORT_DATA_DELETION_BY_USER,,');
expect(s.play.dataSafety.at(-1)).not.toContain('PSL_DATA_COLLECTION_USER_REQUEST_DELETE');
});

View file

@ -108,28 +108,32 @@ describe('data safety: conditional required rows', () => {
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', () => {
test('accounts + username: the delete-account URL row carries accounts.deletionUrl, and the deletion question is yes', () => {
// PSL_SUPPORT_DATA_DELETION_BY_USER is the general "Do you provide a way for users to request that their data is
// deleted?": a delete-account page is such a way. Answering NO next to a delete-account URL contradicts it.
const rows = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url });
expect(get(rows, 'PSL_ACCOUNT_DELETION_URL')![2]).toBe(url);
// 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_YES,TRUE,SINGLE_CHOICE',
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,,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();
// the "Delete data URL" a yes needs: the delete-account page (it deletes the data too) unless a data-only page is set
expect(get(rows, 'PSL_DATA_DELETION_URL')![2]).toBe(url);
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');
// no delete-account URL: the explicit answer, deletionRequest or the old partialDeletion values decide
const picked = (ds: object) => dataSafetyRows({ ...acc, ...ds }).filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER' && r[2] === 'TRUE').map(r => r[1]);
expect(picked({ dataDeletion: 'auto90' })).toEqual(['DATA_DELETION_NO_AUTO_DELETED']);
expect(picked({ partialDeletion: 'auto90' })).toEqual(['DATA_DELETION_NO_AUTO_DELETED']);
expect(picked({ deletionRequest: true })).toEqual(['DATA_DELETION_YES']);
expect(picked({ deletionRequest: false })).toEqual(['DATA_DELETION_NO']);
expect(picked({})).toEqual([]);
// a delete-account URL with anything but yes is a problem before Play sees it
const no = dataSafetyRows({ ...acc, dataDeletion: 'no' }, { deletionUrl: url });
expect(dataSafetyIssues(no).map(i => i.msg).join('\n')).toContain('must be DATA_DELETION_YES (is DATA_DELETION_NO): PSL_SUPPORT_DATA_DELETION_BY_USER (PSL_ACCOUNT_DELETION_URL is set');
});
test('no accounts: no deletion rows, no issues', () => {
const rows = dataSafetyRows({ ...acc, accountCreation: ['PSL_ACM_NONE'] }, { deletionUrl: url });
@ -141,11 +145,11 @@ describe('data safety: conditional required rows', () => {
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(msgs[1]).toContain('dataSafety.dataDeletion');
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 }] });
const rows = dataSafetyRows({ ...acc, dataDeletion: 'yes', partialDeletionUrl: url, 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 });