storebound/tests/app-privacy.test.ts

186 lines
14 KiB
TypeScript

// App Privacy: the Play -> Apple purpose mapping (one purpose per Play purpose; advertising is third-party only), the
// owner's appPrivacy.types answers (certain, and they win over what dataSafety suggests), their validation, the
// scan proposal (collected + purposes only, never linked / tracking) and `check` against the iOS project.
// Placeholder data only.
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { run as check } from '../cli/check';
import { APPLE_DATA_TYPE_IDS, APPLE_PRIVACY_TYPES, appPrivacyMismatches, typeIdOfManifest, validateAppPrivacyTypes } from '../cli/lib/app-privacy';
import { parseArgs } from '../cli/lib/args';
import type { ShipConfig } from '../cli/lib/config';
import { validateExtras } from '../cli/lib/config';
import { consoleAnswers } from '../cli/lib/console-answers';
import { formDef } from '../cli/lib/console-forms';
import { applePurpose, declarations } from '../cli/lib/declarations';
import { proposeDeclarations } from '../cli/lib/scan/declare';
import { detectProject } from '../cli/lib/scan/project';
const base: ShipConfig = {
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
apple: { bundleId: 'com.example.game' },
play: { packageName: 'com.example.game' },
dataSafety: {
collectsData: true, encryptedInTransit: true,
data: {
PSL_DEVICE_ID: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] },
PSL_APPROX_LOCATION: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] },
PSL_USER_INTERACTION: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] },
PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] },
PSL_EMAIL: { shared: false, collect: ['communications', 'account'] },
},
},
} as ShipConfig;
const answers = (c: ShipConfig = base) => new Map(consoleAnswers('apple-app-privacy', c).map(a => [a.id, a]));
const TPA = 'Third-Party Advertising', DEV = 'Developer\'s Advertising or Marketing';
describe('purpose mapping (developer.apple.com/app-store/app-privacy-details/)', () => {
test('one App Privacy purpose per Play purpose; advertising is third-party only', () => {
expect(applePurpose('advertising')).toBe(TPA);
expect(applePurpose('analytics')).toBe('Analytics');
expect(applePurpose('personalization')).toBe('Product Personalization');
for (const p of ['functionality', 'fraud', 'account', 'communications'] as const) expect(applePurpose(p)).toBe('App Functionality');
});
test('ad SDK data (shared for advertising): Third-Party Advertising yes, Developer\'s Advertising no, for every type', () => {
const a = answers();
for (const t of ['Device ID', 'Coarse Location', 'Product Interaction', 'Performance Data']) {
expect(a.get(`purpose:${t}:${TPA}`)).toMatchObject({ value: true, certain: true });
expect(a.get(`purpose:${t}:${DEV}`)).toMatchObject({ value: false, certain: true });
expect(a.get(`purpose:${t}:Analytics`)?.value).toBe(true);
expect(a.get(`purpose:${t}:Product Personalization`)?.value).toBe(false);
}
// Play "developer communications" may be marketing: asked, never guessed
expect(a.get(`purpose:Email Address:${DEV}`)).toMatchObject({ value: null, certain: false });
expect(a.get('purpose:Email Address:App Functionality')?.value).toBe(true);
const d = declarations(base, { demoUser: null, contactEmail: null, uploaded: null });
expect(d.appPrivacy!.items.find(x => x.apple === 'Device ID')!.purposes).toEqual(['Analytics', TPA]);
});
});
describe('appPrivacy.types (the owner\'s answers)', () => {
test('override wins over dataSafety; types without a Play equivalent work from the block alone', () => {
const c = { ...base, appPrivacy: { types: {
DEVICE_ID: { collected: true, linked: false, tracking: true, purposes: ['THIRD_PARTY_ADVERTISING', 'DEVELOPERS_ADVERTISING'] },
ADVERTISING_DATA: { collected: true, linked: false, tracking: true, purposes: ['THIRD_PARTY_ADVERTISING'] },
GAMEPLAY_CONTENT: { collected: false },
PERFORMANCE_DATA: { linked: false },
} } } as ShipConfig;
const a = answers(c);
expect(a.get(`purpose:Device ID:${DEV}`)).toMatchObject({ value: true, certain: true, source: 'appPrivacy.types.DEVICE_ID.purposes' });
expect(a.get('purpose:Device ID:Analytics')).toMatchObject({ value: false, certain: true }); // the list replaces the derived one
expect(a.get('linked:Device ID')).toMatchObject({ value: false, certain: true });
expect(a.get('tracking:Device ID')).toMatchObject({ value: true, certain: true });
expect(a.get('type:Advertising Data')).toMatchObject({ value: true, certain: true, source: 'appPrivacy.types.ADVERTISING_DATA.collected' });
expect(a.get(`purpose:Advertising Data:${TPA}`)?.value).toBe(true);
expect(a.get('type:Gameplay Content')).toMatchObject({ value: false, certain: true });
expect(a.get('linked:Performance Data')).toMatchObject({ value: false, certain: true });
expect(a.get('tracking:Performance Data')).toMatchObject({ value: null, certain: false }); // not set: (you decide)
// appPrivacy.types is authoritative: a type it does not list is not collected (not asked, not derived)
expect(a.get('type:Customer Support')).toMatchObject({ value: false, certain: true, source: 'appPrivacy.types (not listed: not collected)' });
// the Advertising Data dialog gets a section of its own
expect(formDef('apple-app-privacy', [...a.values()]).sections.map(s => s.title)).toContain('Advertising Data: purposes');
});
test('without dataSafety: only appPrivacy.types', () => {
const c = { ...base, dataSafety: undefined, appPrivacy: { types: { HANDS: { collected: true, purposes: ['APP_FUNCTIONALITY'] } } } } as ShipConfig;
const a = answers(c);
expect(a.get('collects')).toMatchObject({ value: true, certain: true });
expect(a.get('type:Hands')?.value).toBe(true);
expect(a.get('type:Device ID')).toMatchObject({ value: false, certain: true });
expect(a.get('linked:Hands')?.certain).toBe(false);
const none = { ...base, dataSafety: undefined, appPrivacy: { types: Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }])) } } as ShipConfig;
expect([...answers(none).values()]).toEqual([expect.objectContaining({ id: 'collects', value: false, certain: true })]);
});
test('validation: ids and purposes against Apple\'s lists', () => {
expect(validateExtras({ appPrivacy: { types: { USER_ID: { collected: true, linked: true, tracking: false, purposes: ['APP_FUNCTIONALITY'] } } } })).toEqual([]);
const e = validateAppPrivacyTypes({ IDFA: { collected: true }, DEVICE_ID: { collected: 'yes', purposes: ['ADS'] }, HEAD: { collected: false, tracking: true }, HANDS: { seen: true } }).join('\n');
for (const k of ['appPrivacy.types.IDFA', 'DEVICE_ID.collected', '"ADS"', 'HEAD: collected is false', 'HANDS.seen']) expect(e).toContain(k);
expect(APPLE_DATA_TYPE_IDS).toHaveLength(35);
for (const id of APPLE_DATA_TYPE_IDS) expect(typeIdOfManifest(APPLE_PRIVACY_TYPES[id][2])).toBe(id);
});
});
describe('scan proposes collected + purposes, never linked / tracking', () => {
test('clean fixture without types: proposed from the SDKs and the code', () => {
const dir = join(import.meta.dir, 'fixtures', 'scan', 'clean');
const p = detectProject(dir);
const c = { ...base, accounts: { enabled: true }, dataSafety: undefined } as ShipConfig;
const d = proposeDeclarations(p, c);
expect(d.changed).toBe(true);
const t = d.appPrivacyTypes!;
expect(Object.keys(t).length).toBeGreaterThan(0);
for (const a of Object.values(t)) { expect(a!.collected).toBe(true); expect(a!.linked).toBeUndefined(); expect(a!.tracking).toBeUndefined(); expect(a!.purposes).not.toContain('DEVELOPERS_ADVERTISING'); }
// an owner answer is never overwritten
const owned = proposeDeclarations(p, { ...c, appPrivacy: { types: { USER_ID: { collected: true, linked: true } } } } as ShipConfig);
expect(owned.appPrivacyTypes?.USER_ID).toBeUndefined();
expect(owned.appPrivacy?.types?.USER_ID).toEqual({ collected: true, linked: true });
});
});
describe('check: appPrivacy.types against the iOS project', () => {
const MANIFEST = (tracking: boolean, types: string[]) => `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>NSPrivacyTracking</key><${tracking}/>
<key>NSPrivacyCollectedDataTypes</key><array>${types.map(t => `<dict><key>NSPrivacyCollectedDataType</key><string>${t}</string></dict>`).join('')}</array>
</dict></plist>`;
test('pure: both ways', () => {
const m = (tracking: boolean | null, collectedTypes: string[]) => ({ path: 'App/PrivacyInfo.xcprivacy', tracking, collectedTypes });
const msgs = (r: { level: string; msg: string }[]) => r.map(x => `${x.level}: ${x.msg}`).join('\n');
let r = appPrivacyMismatches({ DEVICE_ID: { collected: true, tracking: true } }, { attString: false, manifest: m(false, ['NSPrivacyCollectedDataTypeDeviceID']) });
expect(msgs(r)).toContain('error: appPrivacy.types says DEVICE_ID is used for tracking, but Info.plist has no NSUserTrackingUsageDescription');
expect(msgs(r)).toContain('NSPrivacyTracking false');
r = appPrivacyMismatches({ DEVICE_ID: { collected: true, tracking: true }, USER_ID: { collected: false } }, { attString: true, manifest: m(true, ['NSPrivacyCollectedDataTypeDeviceID', 'NSPrivacyCollectedDataTypeUserID', 'NSPrivacyCollectedDataTypeEmailAddress', 'NSPrivacyCollectedDataTypeBogus']) });
expect(msgs(r)).toContain('error: App/PrivacyInfo.xcprivacy declares NSPrivacyCollectedDataTypeUserID (User ID) as collected, but appPrivacy.types.USER_ID.collected is false');
expect(msgs(r)).toContain('warn: App/PrivacyInfo.xcprivacy declares NSPrivacyCollectedDataTypeEmailAddress (Email Address) as collected, but appPrivacy.types has no answer for EMAIL_ADDRESS');
expect(msgs(r)).toContain('unknown NSPrivacyCollectedDataType "NSPrivacyCollectedDataTypeBogus"');
r = appPrivacyMismatches({ GAMEPLAY_CONTENT: { collected: true, tracking: false } }, { attString: true, manifest: m(true, []) });
expect(msgs(r)).toContain('warn: Info.plist has NSUserTrackingUsageDescription, but appPrivacy.types marks no collected type as used for tracking');
expect(msgs(r)).toContain('has NSPrivacyTracking true, but appPrivacy.types marks no collected type');
expect(msgs(r)).toContain('App/PrivacyInfo.xcprivacy has an empty NSPrivacyCollectedDataTypes, but appPrivacy.types marks GAMEPLAY_CONTENT as collected');
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 res = JSON.parse(out.at(-1)!) as { problems: string[]; warnings: string[] };
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('missing: PSL_SUPPORT_DATA_DELETION_BY_USER'); // deletionRequest: true answers it
} 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 {
mkdirSync(join(dir, 'ios', 'App.xcodeproj'), { recursive: true });
writeFileSync(join(dir, 'ios', 'App.xcodeproj', 'project.pbxproj'), '// placeholder\n');
writeFileSync(join(dir, 'ios', 'Info.plist'), '<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleName</key><string>Example</string></dict></plist>');
writeFileSync(join(dir, 'ios', 'PrivacyInfo.xcprivacy'), MANIFEST(false, ['NSPrivacyCollectedDataTypeDeviceID']));
writeFileSync(join(dir, '.gitignore'), '.storebound/\n');
const c = { app: base.app, languages: base.languages, apple: base.apple, appPrivacy: { types: { DEVICE_ID: { collected: true, tracking: true } } } } 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 r = JSON.parse(out.at(-1)!);
const problems = (r.problems as string[]).join('\n');
expect(problems).toContain('Info.plist has no NSUserTrackingUsageDescription');
// tracking from an SDK is declared in the SDK's manifest: the app's own false is not an error (ITMS-91064, pitfall 29)
expect(problems).not.toContain('NSPrivacyTracking false');
expect((r.warnings as string[]).join('\n')).toContain('no privacy manifest declares NSPrivacyTracking true');
expect(r.items.some((i: { what: string }) => i.what === 'app privacy')).toBe(true);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
});