// 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) expect(a.get('type:Customer Support')).toMatchObject({ value: null, certain: false }); // 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: null, certain: false }); 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[]) => ` NSPrivacyTracking<${tracking}/> NSPrivacyCollectedDataTypes${types.map(t => `NSPrivacyCollectedDataType${t}`).join('')} `; 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('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 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'), 'CFBundleNameExample'); 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'); expect(problems).toContain('NSPrivacyTracking false'); expect(r.items.some((i: { what: string }) => i.what === 'app privacy')).toBe(true); } finally { rmSync(dir, { recursive: true, force: true }); } }); });