// Desktop full CLI coverage, CLI side: store declarations as data (ask the owner, never guess), Data safety changes // since the last upload, renders as data: URLs only from inside the renders folder. import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ShipConfig } from '../cli/lib/config'; import { dataSafetyCsv } from '../cli/lib/data-safety'; import { applePurpose, dataSafetyChanges, declarations } from '../cli/lib/declarations'; import { RENDER_FILE_RE, renderImage } from '../cli/lib/project-files'; const base = (o: Partial = {}): ShipConfig => ({ app: { name: 'Demo', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com/s', website: 'https://example.com', contactEmail: 'a@example.com', profile: 'app' }, languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }], play: { packageName: 'com.example.demo' }, apple: { bundleId: 'com.example.demo' }, ...o, } as ShipConfig); const none = { demoUser: null, contactEmail: null, uploaded: null }; describe('declarations', () => { test('an empty config asks the owner everything it cannot prove', () => { const d = declarations(base(), none); expect(d.ask.map(a => a.id).sort()).toEqual(['ai-age', 'ai-categories', 'ai-rights', 'ap-unknown', 'ds-missing', 'enc-unset', 'rv-contact', 'rv-signin']); expect(d.encryption).toBeNull(); expect(d.dataSafety).toBeNull(); }); test('Data safety answers become App Privacy suggestions; linked / tracking stay questions', () => { const cfg = base({ dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { shared: true, collect: ['analytics'], share: ['advertising'] }, PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] } } }, apple: { bundleId: 'com.example.demo', usesNonExemptEncryption: false, info: { categories: { primary: 'UTILITIES' }, ageRating: {}, contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT' } }, reviewAccess: { signInRequired: false }, }); const d = declarations(cfg, { ...none, contactEmail: 'a***@example.com' }); expect(d.appPrivacy!.items.find(x => x.apple === 'Device ID')).toEqual({ apple: 'Device ID', category: 'Identifiers', from: ['PSL_DEVICE_ID'], purposes: ['Analytics', 'Third-Party Advertising'], thirdParty: true }); expect(d.ask.map(a => a.id)).toEqual(['ap-linked']); expect(d.encryption).toBe(false); expect(d.review.contact).toBe('a***@example.com'); // ads in an app come from an ad SDK: Third-Party Advertising only; Developer's Advertising is the owner's answer expect(applePurpose('advertising')).toBe('Third-Party Advertising'); expect(applePurpose('communications')).toBe('App Functionality'); }); test('changes since the last upload compare answers, not labels', () => { const ds = { collectsData: true, encryptedInTransit: false, data: { PSL_CRASH_LOGS: { shared: false, collect: ['analytics' as const] } } }; const before = dataSafetyCsv(ds); expect(dataSafetyChanges(before, before)).toEqual([]); const after = dataSafetyCsv({ ...ds, encryptedInTransit: true }); expect(dataSafetyChanges(before, after)).toEqual([{ question: 'PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', response: '', from: 'FALSE', to: 'TRUE' }]); const d = declarations(base({ dataSafety: { ...ds, encryptedInTransit: true } }), { ...none, uploaded: { csv: before, at: '2026-10-01T00:00:00Z' } }); expect(d.dataSafety!.uploaded!.changes.length).toBe(1); }); }); describe('renders as data: URLs', () => { test('only image paths inside the renders folder', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-renders-')); mkdirSync(join(dir, 'en', 'ios69'), { recursive: true }); writeFileSync(join(dir, 'en', 'ios69', '01.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); writeFileSync(join(dir, 'secret.txt'), 'x'); expect(renderImage(dir, 'en/ios69/01.png').dataUrl).toStartWith('data:image/png;base64,'); for (const bad of ['../x.png', 'en/../../x.png', '/etc/x.png', 'secret.txt', 'en/./01.png', '-x.png']) { if (bad !== '-x.png') expect(RENDER_FILE_RE.test(bad), bad).toBe(false); expect(renderImage(dir, bad).dataUrl, bad).toBeNull(); } }); });