storebound/tests/privacy-tracking.test.ts

224 lines
16 KiB
TypeScript

// ITMS-91064 (pitfall 29): a privacy manifest's NSPrivacyTracking and NSPrivacyTrackingDomains must agree. A real
// build was refused as Invalid Binary one minute after the upload for NSPrivacyTracking true with an empty
// NSPrivacyTrackingDomains in the app's own manifest (the tracking came from an ad SDK, whose manifest declared it).
// Covers the rule, the SDK-declared tracking in check, the proposed manifest, the built IPA (binary manifests) and
// `apple upload` stopping before any network call. Placeholder data only.
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { run as check } from '../cli/check';
import { appPrivacyMismatches, trackingDeclaredIssues } from '../cli/lib/app-privacy';
import { parseArgs } from '../cli/lib/args';
import type { ShipConfig } from '../cli/lib/config';
import { toXmlPlist, type PlistValue } from '../cli/lib/plist';
import { bundleTrackingIssues, ipaManifests, manifestFromBytes, projectSdkManifests, sdkOfBundlePath } from '../cli/lib/privacy-files';
import { evaluatePrivacyManifest, itms91064, parseManifest, proposeAppManifest, trackingIssue, trackingProblem } from '../cli/lib/privacy-manifest';
import { detectProject } from '../cli/lib/scan/project';
import { scanRules } from '../cli/lib/scan/rules';
import { makeZip } from './build-fixtures';
const ROOT = join(import.meta.dir, '..');
const FIX = join(import.meta.dir, 'fixtures', 'build');
const tmp = mkdtempSync(join(tmpdir(), 'sb-privacy-'));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
const touch = (base: string, rel: string, text: string | Buffer = ''): void => { mkdirSync(join(base, rel, '..'), { recursive: true }); writeFileSync(join(base, rel), text); };
const MANIFEST = (tracking: boolean, domains: string[] = [], types: string[] = []) => `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>NSPrivacyTracking</key><${tracking}/>
<key>NSPrivacyTrackingDomains</key><array>${domains.map(d => `<string>${d}</string>`).join('')}</array>
<key>NSPrivacyCollectedDataTypes</key><array>${types.map(t => `<dict><key>NSPrivacyCollectedDataType</key><string>${t}</string></dict>`).join('')}</array>
</dict></plist>`;
const ITMS = 'ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "PrivacyInfo.xcprivacy". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn\'t empty. Keys and values in your app\'s privacy manifests must be valid.';
describe('rule: NSPrivacyTracking and NSPrivacyTrackingDomains agree', () => {
test('true + empty list -> error with the exact ITMS-91064 text, never "set true"', () => {
const m = parseManifest('App/PrivacyInfo.xcprivacy', MANIFEST(true));
expect(trackingProblem(m)).toBe('true-without-domains');
const msg = trackingIssue(m)!;
expect(msg).toContain(ITMS);
expect(msg).toContain('set NSPrivacyTracking false');
expect(itms91064('PrivacyInfo.xcprivacy')).toBe(ITMS);
});
test('false + domains -> error; missing + domains -> error', () => {
expect(trackingProblem(parseManifest('p', MANIFEST(false, ['t.example.com'])))).toBe('domains-without-true');
expect(trackingIssue(parseManifest('p', MANIFEST(false, ['t.example.com'])))).toContain('NSPrivacyTrackingDomains lists t.example.com but NSPrivacyTracking is false');
expect(trackingProblem({ tracking: null, trackingDomains: ['t.example.com'] })).toBe('domains-without-true');
});
test('true + domains, false + empty -> ok', () => {
expect(trackingIssue(parseManifest('p', MANIFEST(true, ['t.example.com'])))).toBeNull();
expect(trackingIssue(parseManifest('p', MANIFEST(false)))).toBeNull();
expect(trackingProblem({ tracking: null, trackingDomains: [] })).toBeNull();
});
test('an SDK manifest: fix is updating the SDK', () => {
expect(trackingIssue(parseManifest('Pods/AdSdk/PrivacyInfo.xcprivacy', MANIFEST(true)), 'AdSdk')).toContain('this is AdSdk\'s own manifest: update AdSdk');
});
});
describe('privacy-manifest scan', () => {
const app = (t: boolean, d: string[] = []) => parseManifest('App/PrivacyInfo.xcprivacy', MANIFEST(t, d));
const ads = { name: 'AdSdk', path: 'Pods/AdSdk', hasManifest: true, manifests: [parseManifest('Pods/AdSdk/PrivacyInfo.xcprivacy', MANIFEST(true, ['ads.example.com']))] };
test('app true + empty -> blocker with ITMS-91064', () => {
const f = evaluatePrivacyManifest({ appManifest: app(true), sourceHits: new Map(), sdks: [], asksTracking: true });
expect(f[0]!.level).toBe('blocker');
expect(f[0]!.msg).toContain(ITMS);
});
test('SDK manifest declares tracking, app false -> info, no blocker', () => {
const f = evaluatePrivacyManifest({ appManifest: app(false), sourceHits: new Map(), sdks: [ads], asksTracking: true });
expect(f.filter(x => x.level === 'blocker' || x.level === 'warn')).toEqual([]);
expect(f.find(x => x.level === 'info')!.msg).toContain('tracking declared by the AdSdk manifest');
});
test('ATT string, nothing declares tracking -> warn that never says to set the app to true without domains', () => {
const f = evaluatePrivacyManifest({ appManifest: app(false), sourceHits: new Map(), sdks: [], asksTracking: true });
const w = f.find(x => x.level === 'warn')!;
expect(w.msg).toContain('no privacy manifest found here declares NSPrivacyTracking true');
expect(w.msg).toContain('Do not set the app\'s manifest to true without NSPrivacyTrackingDomains');
});
test('an SDK manifest with false + domains -> blocker', () => {
const bad = { ...ads, manifests: [parseManifest('Pods/AdSdk/PrivacyInfo.xcprivacy', MANIFEST(false, ['ads.example.com']))] };
const f = evaluatePrivacyManifest({ appManifest: app(false), sourceHits: new Map(), sdks: [bad], asksTracking: false });
expect(f[0]!.level).toBe('blocker');
expect(f[0]!.msg).toContain('update AdSdk');
});
});
describe('App Privacy tracking vs manifests (check)', () => {
const m = { path: 'App/PrivacyInfo.xcprivacy', tracking: false, collectedTypes: ['NSPrivacyCollectedDataTypeDeviceID'] };
const types = { DEVICE_ID: { collected: true, tracking: true }, COARSE_LOCATION: { collected: true, tracking: true }, ADVERTISING_DATA: { collected: true, tracking: true } } as const;
test('SDK manifest declares tracking, app manifest false -> info, no error', () => {
const r = appPrivacyMismatches(types, { attString: true, manifest: m, tracking: { sdkManifests: ['IronSource'] } });
expect(r.filter(x => x.level === 'error')).toEqual([]);
expect(r.find(x => x.level === 'info')!.msg).toContain('tracking declared by the IronSource manifest');
});
test('known tracking SDK in the dependencies -> info', () => {
const r = appPrivacyMismatches(types, { attString: true, manifest: m, tracking: { trackerSdks: ['Unity LevelPlay (ironSource)'] } });
expect(r.filter(x => x.level === 'error')).toEqual([]);
expect(r.find(x => x.level === 'info')!.msg).toContain('tracking comes from Unity LevelPlay (ironSource)');
});
test('App Privacy tracking with no manifest declaring it -> warning, not an error', () => {
const r = appPrivacyMismatches(types, { attString: true, manifest: m });
expect(r.filter(x => x.level === 'error')).toEqual([]);
expect(r.find(x => x.level === 'warn' && x.msg.includes('no privacy manifest declares NSPrivacyTracking true'))).toBeDefined();
expect(trackingDeclaredIssues('x', { path: 'p', tracking: true }, {})).toEqual([]);
});
const runCheck = async (dir: string, c: 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; }
return JSON.parse(out.at(-1)!) as { problems: string[]; warnings: string[]; hints: string[] };
};
const project = (name: string, appManifest: string) => {
const dir = join(tmp, name);
touch(dir, 'ios/App.xcodeproj/project.pbxproj', '// placeholder\n');
touch(dir, 'ios/Info.plist', '<?xml version="1.0"?><plist version="1.0"><dict><key>NSUserTrackingUsageDescription</key><string>Your data is used to show ads that fit your interests.</string></dict></plist>');
touch(dir, 'ios/PrivacyInfo.xcprivacy', appManifest);
touch(dir, '.gitignore', '.storebound/\n');
return dir;
};
const cfg = { app: { name: 'Example 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' }, appPrivacy: { tracking: true, types } } as unknown as ShipConfig;
test('storebound check: app false + a Pods SDK manifest that declares tracking -> no problem, a hint', async () => {
const dir = project('sdk-tracks', MANIFEST(false, [], ['NSPrivacyCollectedDataTypeDeviceID', 'NSPrivacyCollectedDataTypeCoarseLocation', 'NSPrivacyCollectedDataTypeAdvertisingData']));
touch(dir, 'ios/Pods/IronSourceSDK/PrivacyInfo.xcprivacy', MANIFEST(true, ['ads.example.com']));
expect(projectSdkManifests(dir).map(s => s.sdk)).toEqual(['IronSourceSDK']);
const r = await runCheck(dir, cfg);
expect(r.problems.join('\n')).not.toContain('NSPrivacyTracking');
expect(r.hints.join('\n')).toContain('tracking declared by the IronSourceSDK manifest');
});
test('storebound check: app manifest true + empty list -> problem with ITMS-91064', async () => {
const dir = project('app-true-empty', MANIFEST(true, [], ['NSPrivacyCollectedDataTypeDeviceID', 'NSPrivacyCollectedDataTypeCoarseLocation', 'NSPrivacyCollectedDataTypeAdvertisingData']));
const r = await runCheck(dir, cfg);
expect(r.problems.join('\n')).toContain(ITMS);
});
test('storebound check: the last built IPA with a bad framework manifest -> problem', async () => {
const dir = project('built-ipa', MANIFEST(false));
touch(dir, 'build/ios/export/Example.ipa', makeZip([
{ name: 'Payload/Example.app/Info.plist', data: readFileSync(join(FIX, 'Info.plist')) },
{ name: 'Payload/Example.app/Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy', data: readFileSync(join(FIX, 'PrivacyInfo-tracking-no-domains.bplist')) },
]));
const r = await runCheck(dir, cfg);
expect(r.problems.join('\n')).toContain('build/ios/export/Example.ipa');
expect(r.problems.join('\n')).toContain('Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy');
});
test('scan: ITMS-91064 is a blocker', () => {
const dir = project('scan', MANIFEST(true));
const f = scanRules({ facts: detectProject(dir), config: null, configFile: null, listings: {}, demoAccount: false });
const hit = f.find(x => x.guideline === 'Privacy manifest (ITMS-91064)')!;
expect(hit.severity).toBe('blocker');
expect(hit.fix).toContain(ITMS);
});
});
describe('proposed app manifest', () => {
test('tracking without app-level domains -> NSPrivacyTracking false; with domains -> true', () => {
const none = proposeAppManifest({ used: ['NSPrivacyAccessedAPICategoryUserDefaults'] });
expect(none.NSPrivacyTracking).toBe(false);
expect(none.NSPrivacyTrackingDomains).toEqual([]);
expect(none.NSPrivacyAccessedAPITypes).toEqual([{ NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', NSPrivacyAccessedAPITypeReasons: ['CA92.1'] }]);
const some = proposeAppManifest({ trackingDomains: ['t.example.com', ' '], apis: { NSPrivacyAccessedAPICategoryUserDefaults: ['1C8F.1'] }, used: ['NSPrivacyAccessedAPICategoryUserDefaults'] });
expect(some.NSPrivacyTracking).toBe(true);
expect(some.NSPrivacyTrackingDomains).toEqual(['t.example.com']);
expect(some.NSPrivacyAccessedAPITypes).toEqual([{ NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', NSPrivacyAccessedAPITypeReasons: ['1C8F.1'] }]);
for (const v of [none, some]) expect(trackingIssue(parseManifest('p', toXmlPlist(v as PlistValue)))).toBeNull();
});
test('privacy-manifest --propose writes false without domains (config field appPrivacy.trackingDomains)', () => {
const dir = join(tmp, 'propose');
touch(dir, 'ios/App/App/PrivacyInfo.xcprivacy', MANIFEST(true));
touch(dir, 'ios/App/App/Save.swift', 'UserDefaults.standard.set(1, forKey: "x")');
const cli = (args: string[]) => Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'privacy-manifest', '--ios', 'ios/App', ...args], { cwd: dir, stdout: 'pipe', stderr: 'pipe' });
let r = cli(['--propose']);
let m = parseManifest('p', r.stdout.toString());
expect(m.tracking).toBe(false);
expect(m.apis).toEqual({ NSPrivacyAccessedAPICategoryUserDefaults: ['CA92.1'] });
r = cli(['--propose', '--tracking-domains', 't.example.com']);
m = parseManifest('p', r.stdout.toString());
expect(m.tracking).toBe(true);
expect(m.trackingDomains).toEqual(['t.example.com']);
r = cli([]);
expect(r.exitCode).toBe(1);
expect(r.stdout.toString()).toContain('ITMS-91064');
});
});
describe('built app (IPA)', () => {
const ipa = (fw: Buffer | string) => makeZip([
{ name: 'Payload/Example.app/Info.plist', data: readFileSync(join(FIX, 'Info.plist')) },
{ name: 'Payload/Example.app/PrivacyInfo.xcprivacy', data: MANIFEST(false) },
{ name: 'Payload/Example.app/Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy', data: fw },
{ name: 'Payload/Example.app/Frameworks/Ok.framework/Res.bundle/PrivacyInfo.xcprivacy', data: MANIFEST(true, ['ok.example.com']) },
]);
test('binary manifests are read', () => {
const m = manifestFromBytes('x', readFileSync(join(FIX, 'PrivacyInfo-tracking-no-domains.bplist')));
expect(m.tracking).toBe(true);
expect(m.trackingDomains).toEqual([]);
expect(m.apis).toEqual({ NSPrivacyAccessedAPICategoryUserDefaults: ['CA92.1'] });
});
test('every manifest in the IPA: app root, frameworks, bundles', () => {
const list = ipaManifests(ipa(readFileSync(join(FIX, 'PrivacyInfo-tracking-no-domains.bplist'))));
expect(list.map(x => [x.inApp, x.sdk])).toEqual([['PrivacyInfo.xcprivacy', null], ['Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy', 'AdSdk'], ['Frameworks/Ok.framework/Res.bundle/PrivacyInfo.xcprivacy', 'Res']]);
const issues = bundleTrackingIssues(list);
expect(issues.length).toBe(1);
expect(issues[0]).toContain('"Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy"');
expect(sdkOfBundlePath('PrivacyInfo.xcprivacy')).toBeNull();
});
test('apple upload stops before any network call', () => {
const dir = join(tmp, 'upload');
touch(dir, 'storebound.config.ts', `export default { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' },
languages: [{ id: 'en', apple: 'en-US' }], apple: { bundleId: 'com.example.game' } };\n`);
touch(dir, 'app.ipa', ipa(readFileSync(join(FIX, 'PrivacyInfo-tracking-no-domains.bplist'))));
// a credential that would work offline up to the first request: the check must stop before ascClient
const r = Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'apple', 'upload', 'app.ipa', '--dry'], { cwd: dir, stdout: 'pipe', stderr: 'pipe',
env: { ...process.env, ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', HOME: join(tmp, 'home') } });
const err = r.stderr.toString() + r.stdout.toString();
expect(r.exitCode).toBe(1);
expect(err).toContain('not uploaded, Apple would refuse it as Invalid Binary');
expect(err).toContain('ITMS-91064');
expect(err).toContain('Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy');
expect(err).not.toMatch(/ASC_KEY_ID|credentials|api key/i);
});
});