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

@ -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 });