storebound/tests/app-privacy.test.ts
CRT 8ffc83bb39 console fill: walk the whole form; the owner only reviews and presses the final button
App Privacy stopped at "now click Get Started yourself" and every page
asked the owner to click Next. The owner should not click what
storebound can safely do.

- Every button of every console form is classified (navigation,
  draft-save, final-submit) in FORM_BUTTONS (cli/lib/console-forms.ts),
  citing Apple's and Google's help pages. A scoped flow gate
  (flowAllowed) lets navigation through; the generic click gate stays
  absolute for field clicks.
- App Privacy: Get Started, Edit next to Data Types (never the Privacy
  Policy Edit), each data type / "Set Up <type>", Next, and the Save
  inside the questionnaire dialogs (a draft: Apple publishes only after
  Publish, per "Manage app privacy"). Each type's dialog may be one page
  or purposes > Next > linked > Next > tracking > Save. It ends with
  "review the summary, then press Publish yourself". Publish is never
  clicked.
- Play content rating and target audience: Next by itself. Save draft,
  Submit, the final Save, Create and Create app stay the owner's.
- An uncertain answer stops the walk on its page (outlined, "answer
  needed on ...") until the owner has set it; then it goes on.

Fixture apple-app-privacy-flow.html (first time, ?existing=1, decoys);
tests/console-flow.test.ts with the guardrail that no log line asks the
owner to click a navigation control.
2026-10-12 06:51:01 +08:00

163 lines
12 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, deletionRequest: false,
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[]) => `<?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('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 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');
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 }); }
});
});