// storebound scan: project detection, every rejection-risk rule on the fixtures (true positives) and the clean // fixture (no false positives), the declarations proposal and --write, validate / MCP integration, the demo projects. import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { FORBIDDEN_ARGS, TOOLS, toArgv } from '../cli/mcp-tools'; import { JSON_TOOLS } from '../cli/serve'; import { scanAsFindings } from '../cli/validate'; import { matchBrace, toTs, upsertBlock } from '../cli/lib/scan/config-edit'; import { validateConfig } from '../cli/lib/config'; import { runScan } from '../cli/lib/scan'; import { PLAY_REFS } from '../cli/lib/scan/play-rules'; import { vaguePurpose } from '../cli/lib/scan/permissions'; import { detectProject, isBuilt, parsePlist } from '../cli/lib/scan/project'; import { SIGNALS, userFacing } from '../cli/lib/scan/rules'; import { localSwiftPackages, nativeIosPlugins, workspaceDirs } from '../cli/lib/node-packages'; import type { ScanFinding } from '../cli/lib/scan/rules'; import { matchesSdk, SDKS, versionAtLeast } from '../cli/lib/scan/sdks'; const ROOT = join(import.meta.dir, '..'); const FX = join(import.meta.dir, 'fixtures', 'scan'); const CLI = join(ROOT, 'cli', 'index.ts'); const saved = { u: process.env.REVIEW_DEMO_USER, p: process.env.REVIEW_DEMO_PASSWORD }; beforeEach(() => { delete process.env.REVIEW_DEMO_USER; delete process.env.REVIEW_DEMO_PASSWORD; }); afterAll(() => { if (saved.u !== undefined) process.env.REVIEW_DEMO_USER = saved.u; if (saved.p !== undefined) process.env.REVIEW_DEMO_PASSWORD = saved.p; }); const scan = async (name: string) => (await runScan(join(FX, name))).report; const rules = (f: ScanFinding[], store?: 'apple' | 'play') => new Set(f.filter(x => !x.accepted && (!store || x.store === store)).map(x => x.rule)); const find = (f: ScanFinding[], rule: string, store?: 'apple' | 'play') => f.find(x => x.rule === rule && (!store || x.store === store)); describe('project detection', () => { test('plist reader: values, nested dicts, top-level key lines', () => { const { value, lines } = parsePlist(readFileSync(join(FX, 'ios-native/Notes/Info.plist'), 'utf8')); expect(value.NSPhotoLibraryUsageDescription).toBe(''); expect((value.NSAppTransportSecurity as Record).NSAllowsArbitraryLoads).toBe(true); expect(lines.get('NSAppTransportSecurity')).toBe(9); expect(parsePlist('not a plist').value).toEqual({}); }); test('stacks, SDKs with versions and file:line from every ecosystem', () => { const ios = detectProject(join(FX, 'ios-native')); expect(ios.stacks).toEqual(['ios-native']); const admob = ios.sdks.find(s => s.sdk.id === 'admob')!.deps[0]!; expect([admob.file, admob.line, admob.version]).toEqual(['Podfile.lock', 5, '10.14.0']); expect(ios.sdks.find(s => s.sdk.id === 'google-signin')!.deps.map(d => d.eco).sort()).toEqual(['cocoapods', 'spm']); const android = detectProject(join(FX, 'android')); expect(android.androidInfo.targetSdk).toEqual({ value: 34, at: { file: 'app/build.gradle.kts', line: 12 } }); expect(android.sdks.map(s => s.sdk.id).sort()).toEqual(['admob', 'firebase-auth']); expect(detectProject(join(FX, 'capacitor')).stacks).toEqual(['capacitor']); expect(detectProject(join(FX, 'capacitor')).remoteStart?.url).toBe('https://shop.example.com'); expect(detectProject(join(FX, 'capacitor')).androidInfo.targetSdk?.value).toBe(35); const unity = detectProject(join(FX, 'unity')); expect(unity.stacks).toEqual(['unity']); expect(unity.sdks.map(s => s.sdk.id).sort()).toEqual(['admob', 'iap-wrapper', 'levelplay', 'unity-ads']); expect(unity.plist.get('NSCameraUsageDescription')?.at.file).toBe('ProjectSettings/ProjectSettings.asset'); const flutter = detectProject(join(FX, 'flutter')); expect(flutter.stacks).toEqual(['flutter']); expect(flutter.deps.find(d => d.name === 'firebase_auth')?.version).toBe('5.3.1'); expect(flutter.androidInfo.targetSdkFrom).toContain('Flutter'); }); test('SDK table: matchers and version compare', () => { const admob = SDKS.find(s => s.id === 'admob')!; expect(matchesSdk({ eco: 'cocoapods', name: 'Google-Mobile-Ads-SDK', version: null, file: '', line: 1 }, admob)).toBe(true); expect(matchesSdk({ eco: 'gradle', name: 'com.google.android.gms:play-services-ads-lite', version: null, file: '', line: 1 }, admob)).toBe(false); expect(matchesSdk({ eco: 'gradle', name: 'com.amplitude:analytics-android', version: null, file: '', line: 1 }, SDKS.find(s => s.id === 'amplitude')!)).toBe(true); expect(matchesSdk({ eco: 'spm', name: 'FirebaseAnalytics', version: null, file: '', line: 1 }, SDKS.find(s => s.id === 'firebase-analytics')!)).toBe(true); expect(versionAtLeast('10.14.0', '11.2.0')).toBe(false); expect(versionAtLeast('~> 11.10.0', '11.2.0')).toBe(true); expect(versionAtLeast('latest', '1.0')).toBeNull(); for (const s of SDKS) expect(s.docs).toMatch(/^https:\/\//); }); test('purpose strings: empty, vague, fine', () => { expect(vaguePurpose('')).toBe('empty'); expect(vaguePurpose('TODO')).toBe('empty'); expect(vaguePurpose('Location')).toBe('vague'); expect(vaguePurpose('Allow $(PRODUCT_NAME) to access your camera')).toBe('vague'); expect(vaguePurpose('Scan a paper note with the camera to turn it into text.')).toBeNull(); }); }); describe('rules: planted issues are found', () => { test('ios-native', async () => { const r = await scan('ios-native'); for (const id of ['demo-account', 'account-deletion', 'login-services', 'restore-purchases', 'att', 'purpose-strings', 'privacy-manifest', 'other-platforms', 'metadata-keywords', 'placeholder-text', 'deprecated-api', 'private-api', 'ats', 'ipv6', 'export-compliance']) expect(rules(r.findings)).toContain(id); expect(find(r.findings, 'demo-account')!.severity).toBe('blocker'); expect(find(r.findings, 'login-services')!.guideline).toBe('4.8'); const cam = r.findings.find(f => f.rule === 'purpose-strings' && f.title.includes('NSCameraUsageDescription'))!; expect(cam.severity).toBe('blocker'); expect(cam.evidence[0]).toMatchObject({ file: 'Notes/ScannerView.swift', line: 9 }); expect(r.findings.some(f => f.rule === 'purpose-strings' && f.title.includes('too vague'))).toBe(true); expect(find(r.findings, 'other-platforms')!.evidence[0]).toMatchObject({ file: 'store/appstore.md', line: 24 }); expect(find(r.findings, 'deprecated-api')!.evidence[0]!.file).toBe('Notes/LegacyHelp.swift'); expect(r.findings.filter(f => f.title.includes('older than')).length).toBe(3); for (const f of r.findings) { expect(f.fix.length).toBeGreaterThan(10); expect(f.skill).toBeTruthy(); expect(f.ref).toMatch(/^https:\/\//); expect(f.evidence.length).toBeGreaterThan(0); } }); test('android', async () => { const r = await scan('android'); for (const id of ['play-deletion-url', 'play-target-sdk', 'play-package-name', 'play-fgs', 'play-exact-alarm', 'play-query-all-packages', 'play-sms-call-log', 'privacy-labels']) expect(rules(r.findings, 'play')).toContain(id); expect(find(r.findings, 'play-target-sdk')!.evidence[0]).toMatchObject({ file: 'app/build.gradle.kts', line: 12 }); expect(find(r.findings, 'play-sms-call-log')!.evidence[0]).toMatchObject({ file: 'app/src/main/AndroidManifest.xml', line: 5 }); expect(rules(r.findings, 'apple').size).toBe(0); expect(rules(r.findings)).not.toContain('play-app-access'); expect(rules(r.findings)).not.toContain('account-deletion'); }); test('capacitor (no config: project files only)', async () => { const r = await scan('capacitor'); expect(r.config).toBeNull(); for (const id of ['min-functionality', 'purpose-strings', 'play-target-sdk', 'privacy-manifest', 'att']) expect(rules(r.findings)).toContain(id); expect(find(r.findings, 'min-functionality')!.evidence[0]).toMatchObject({ file: 'capacitor.config.json', line: 6 }); expect(rules(r.findings)).not.toContain('export-compliance'); }); test('unity', async () => { const r = await scan('unity'); for (const id of ['purpose-strings', 'play-target-sdk', 'restore-purchases', 'kids-ads', 'review-prompt', 'privacy-manifest']) expect(rules(r.findings)).toContain(id); expect(find(r.findings, 'kids-ads', 'apple')!.guideline).toBe('1.3'); expect(find(r.findings, 'kids-ads', 'play')).toBeTruthy(); expect(r.findings.find(f => f.rule === 'review-prompt' && f.severity === 'high')).toBeTruthy(); expect(rules(r.findings)).not.toContain('kids-terms'); }); test('flutter', async () => { const r = await scan('flutter'); for (const id of ['account-deletion', 'play-deletion-url', 'login-services', 'restore-purchases', 'subscription-info', 'att', 'play-fgs', 'play-target-sdk']) expect(rules(r.findings)).toContain(id); expect(find(r.findings, 'att')!.severity).toBe('blocker'); expect(r.findings.find(f => f.rule === 'subscription-info' && f.severity === 'blocker')!.title).toContain('Terms of Use'); expect(r.findings.find(f => f.rule === 'play-fgs' && f.title.includes('FOREGROUND_SERVICE_LOCATION'))).toBeTruthy(); expect(find(r.findings, 'play-target-sdk')!.severity).toBe('low'); }); test('teaser: exactly the five classic rejections', async () => { const r = await scan('teaser'); expect([...rules(r.findings)].sort()).toEqual(['account-deletion', 'att', 'demo-account', 'login-services', 'restore-purchases']); expect(r.findings.map(f => f.guideline)).toEqual(['2.1(a)', '5.1.1(v)', '4.8', '3.1.1', '5.1.2(i)']); }); }); /** A temp copy of a fixture, changed by `edit` (file -> new content from old), for the variants of one fixture. */ async function scanVariant(name: string, edit: Record string>) { const dir = mkdtempSync(join(tmpdir(), 'sb-scan-play-')); try { cpSync(join(FX, name), dir, { recursive: true }); for (const [f, fn] of Object.entries(edit)) writeFileSync(join(dir, f), fn(existsSync(join(dir, f)) ? readFileSync(join(dir, f), 'utf8') : '')); return (await runScan(dir)).report; } finally { rmSync(dir, { recursive: true, force: true }); } } describe('Google Play rules', () => { test('detection: merged manifest, components, accessibility config, strings, native toolchain', () => { const a = detectProject(join(FX, 'play-android')); expect(a.androidInfo.mergedManifest).toBe('app/build/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml'); expect(a.androidInfo.permissions.find(x => x.name.endsWith('USE_FULL_SCREEN_INTENT'))!.at.file).toBe(a.androidInfo.mergedManifest!); expect(a.androidInfo.permissions.find(x => x.name.endsWith('ACCESS_BACKGROUND_LOCATION'))!.at).toEqual({ file: 'app/src/main/AndroidManifest.xml', line: 5 }); expect(a.androidInfo.components.filter(c => c.kind === 'service').map(c => c.name)).toEqual(['.TrailAssistService', '.OfflineMapVpnService']); expect(a.androidInfo.components.find(c => c.categories.includes('android.intent.category.LAUNCHER'))!.name).toBe('.MainActivity'); expect(a.androidInfo.accessibilityConfigs).toEqual([{ at: { file: 'app/src/main/res/xml/accessibility_service_config.xml', line: 2 }, isTool: false }]); expect(a.androidInfo.strings.map(s => s.path)).toEqual(['app/src/main/res/values/strings.xml']); expect(a.native.agp).toEqual({ value: '8.7.3', at: { file: 'settings.gradle.kts', line: 3 } }); const n = detectProject(join(FX, 'play-native')); expect(n.native.libs).toEqual(['android/app/src/main/jniLibs/arm64-v8a/libvendorfx.so']); expect(n.native.ndkVersion).toEqual({ value: '25.1.8937393', at: { file: 'android/app/build.gradle', line: 10 } }); expect(n.native.agp!.value).toBe('8.1.0'); expect(n.native.nativeBuild).toEqual({ file: 'android/app/build.gradle', line: 16 }); expect(n.androidInfo.extractNativeLibs).toEqual({ value: false, at: { file: 'android/app/src/main/AndroidManifest.xml', line: 3 } }); expect(n.androidInfo.mergedManifest).toBeNull(); expect(detectProject(join(FX, 'play-families')).native.unityVersion).toEqual({ value: '2022.3.20f1', at: { file: 'ProjectSettings/ProjectVersion.txt', line: 1 } }); }); test('sensitive permissions, prominent disclosure, privacy policy, ads at launch, payments, testing (play-android)', async () => { const r = await scan('play-android'); for (const id of ['play-background-location', 'play-photo-video', 'play-all-files-access', 'play-accessibility', 'play-install-packages', 'play-health-connect', 'play-full-screen-intent', 'play-vpn', 'play-prominent-disclosure', 'play-privacy-policy', 'play-ad-id', 'play-ads-launch', 'play-payments', 'play-testing-requirement', 'privacy-labels']) expect(rules(r.findings, 'play')).toContain(id); expect(rules(r.findings, 'apple').size).toBe(0); expect(find(r.findings, 'play-full-screen-intent')!.evidence[0]!.file).toContain('build/intermediates/merged_manifest'); expect(find(r.findings, 'play-accessibility')!.severity).toBe('high'); expect(r.findings.filter(f => f.rule === 'play-prominent-disclosure').map(f => f.severity)).toEqual(['high', 'medium']); expect(find(r.findings, 'play-ads-launch')!.evidence[0]).toMatchObject({ file: 'app/src/main/java/org/example/trailmate/MainActivity.kt', line: 15 }); expect(find(r.findings, 'play-ad-id')!.severity).toBe('medium'); expect(find(r.findings, 'play-testing-requirement')).toMatchObject({ severity: 'medium', title: expect.stringContaining('12 opted-in testers for 14 days') }); expect(r.findings.find(f => f.rule === 'privacy-labels' && f.title.includes('narrower'))!.title).toContain('PSL_DEVICE_ID'); expect(rules(r.findings)).not.toContain('play-16kb'); for (const f of r.findings) { expect(f.ref).toMatch(/^https:\/\/(support\.google\.com|developer\.android\.com)\//); expect(f.evidence.length).toBeGreaterThan(0); expect(f.fix.length).toBeGreaterThan(20); } }); test('variants: isAccessibilityTool, a disclosure string, organization account, billing program', async () => { const r = await scanVariant('play-android', { 'app/src/main/res/xml/accessibility_service_config.xml': s => s.replace('android:canRetrieveWindowContent', 'android:isAccessibilityTool="true"\n android:canRetrieveWindowContent'), 'app/src/main/res/values/strings.xml': s => s.replace('', ' Trail Mate collects location data to record your hike even when the app is closed or not in use.\n'), 'storebound.config.ts': s => s.replace("accountType: 'personal', accountCreated: '2025-03-01'", "accountType: 'organization', billingPrograms: ['user-choice-billing']"), }); expect(find(r.findings, 'play-accessibility')!.severity).toBe('medium'); expect(rules(r.findings)).not.toContain('play-prominent-disclosure'); expect(rules(r.findings)).not.toContain('play-testing-requirement'); expect(rules(r.findings)).not.toContain('play-payments'); const old = await scanVariant('play-android', { 'storebound.config.ts': s => s.replace("accountCreated: '2025-03-01'", "accountCreated: '2023-11-13'") }); expect(rules(old.findings)).not.toContain('play-testing-requirement'); }); test('16 KB page sizes: old NDK and AGP (play-native), Unity versions, prebuilt libraries', async () => { const r = await scan('play-native'); const k = r.findings.filter(f => f.rule === 'play-16kb'); expect(k.map(f => f.severity)).toEqual(['high', 'high']); expect(k[0]!.title).toContain('NDK 25.1.8937393'); expect(k[1]!.title).toContain('Android Gradle Plugin 8.1.0'); expect(k.every(f => f.title.includes('2027-02-01'))).toBe(true); const fixed = await scanVariant('play-native', { 'android/app/build.gradle': s => s.replace('25.1.8937393', '27.2.12479018').replace('path "src/main/cpp/CMakeLists.txt"', 'path "src/main/cpp/CMakeLists.txt"\n arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"'), 'android/settings.gradle': s => s.replace('"8.1.0"', '"8.7.3"'), }); const left = fixed.findings.filter(f => f.rule === 'play-16kb'); expect(left.map(f => [f.severity, f.evidence[0]!.file])).toEqual([['medium', 'android/app/src/main/jniLibs/arm64-v8a/libvendorfx.so']]); const legacy = await scanVariant('play-native', { 'android/app/build.gradle': s => s.replace('externalNativeBuild', 'packaging { jniLibs { useLegacyPackaging = true } }\n externalNativeBuild') }); expect(legacy.findings.filter(f => f.rule === 'play-16kb').map(f => f.title)).toEqual([expect.stringContaining('NDK')]); for (const [v, flagged] of [['2022.3.56f1', false], ['2021.3.47f1', true], ['6000.0.38f1', false], ['6000.0.37f1', true], ['6000.1.2f1', false], ['2023.2.20f1', true]] as const) { const u = await scanVariant('play-families', { 'ProjectSettings/ProjectVersion.txt': () => `m_EditorVersion: ${v}\n` }); expect([v, rules(u.findings).has('play-16kb')]).toEqual([v, flagged]); } }); test('Families: uncertified and old ad SDKs, AD_ID, location (play-families)', async () => { const r = await scan('play-families'); const ads = r.findings.filter(f => f.rule === 'play-families-ads'); expect(ads.map(f => f.title)).toEqual([expect.stringContaining('AdMob 18.3.0 is older than 19.0.0'), expect.stringContaining('AppLovin MAX is not a Families Self-Certified')]); expect(find(r.findings, 'play-families-ad-id')!.severity).toBe('high'); expect(find(r.findings, 'play-families-location')!.evidence[0]).toMatchObject({ file: 'Assets/Plugins/Android/AndroidManifest.xml', line: 3 }); expect(rules(r.findings)).not.toContain('kids-ads'); expect(rules(r.findings)).not.toContain('play-ad-id'); const mixed = await scanVariant('play-families', { 'storebound.config.ts': s => s.replace("['0-5', '6-8', '9-12']", "['9-12', '13-15', '16-17']") }); expect(find(mixed.findings, 'play-families-ad-id')!.severity).toBe('medium'); expect(rules(mixed.findings)).not.toContain('play-families-location'); const adult = await scanVariant('play-families', { 'storebound.config.ts': s => s.replace("['0-5', '6-8', '9-12']", "['18+']") }); expect([...rules(adult.findings)].filter(x => x.startsWith('play-families'))).toEqual([]); }); test('Play listing metadata, steering to the web, impersonation (play-listing)', async () => { const r = await scan('play-listing'); const m = r.findings.filter(f => f.rule === 'play-metadata').map(f => f.title); for (const t of ['longer than 30 characters', 'Emoji', 'Rank, performance', 'ALL CAPS', 'Other apps or brands', 'Repeated keywords ("photo"']) expect(m.some(x => x.includes(t))).toBe(true); expect(m.find(x => x.includes('Repeated'))).toContain('and a keyword list'); // function words of other languages are grammar, not stuffing (a German description used "eine" 10x) const de = await scanVariant('play-listing', { 'store/listing.md': s => s.replace(/\bphotos?\b/gi, w => `${w} eine der und eine`) }); expect(de.findings.filter(f => f.rule === 'play-metadata').map(f => f.title).join('\n')).not.toMatch(/"(eine|der|und)"/); expect(find(r.findings, 'play-payments')!.evidence[0]).toMatchObject({ file: 'store/listing.md', line: 22 }); expect(find(r.findings, 'play-impersonation')).toMatchObject({ severity: 'low', title: expect.stringContaining('"instagram"') }); const ok = await scanVariant('play-listing', { 'storebound.config.ts': s => s.replace('org.example.instagramsaver', 'org.example.snapsaver') }); expect(rules(ok.findings)).not.toContain('play-impersonation'); const soft = await scanVariant('play-listing', { 'store/listing.md': s => s.replace('🔥 #1 Best Free Photo Saver App Pro', 'Free Photo Saver') }); expect(soft.findings.filter(f => f.rule === 'play-metadata' && f.title.includes('Free Photo Saver')).map(f => f.severity)).toEqual(['low']); expect(rules(await scan('ios-native').then(x => x.findings), 'play').size).toBe(0); // scan.ignore with match: accepts the ALL CAPS check of play-metadata only, the other metadata checks stay open const caps = await scanVariant('play-listing', { 'storebound.config.ts': s => s.replace(/\n(\s*)app: \{/, "\n$1scan: { ignore: [{ rule: 'play-metadata', match: 'ALL CAPS', reason: 'brand name' }] },\n$1app: {") }); const md = caps.findings.filter(f => f.rule === 'play-metadata'); expect(md.find(f => f.title.includes('ALL CAPS'))!.accepted).toBe('brand name'); expect(md.filter(f => !f.title.includes('ALL CAPS')).every(f => !f.accepted)).toBe(true); }); test('the policy tables cite their sources', () => { for (const u of Object.values(PLAY_REFS)) expect(u).toMatch(/^https:\/\//); const fam = SDKS.filter(s => s.category === 'ads').map(s => [s.id, s.families?.certified]); expect(fam).toEqual([['admob', true], ['applovin', false], ['levelplay', true], ['unity-ads', true], ['meta-an', false]]); expect(validateConfig({ app: { name: 'X', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com/s', website: 'https://example.com', contactEmail: 'support@example.com' }, languages: [{ id: 'en' }], play: { packageName: 'org.example.x', targetAges: ['7-9'], accountType: 'person', accountCreated: '2024', billingPrograms: ['stripe'] } })) .toEqual(expect.arrayContaining([expect.stringContaining('play.targetAges'), expect.stringContaining('play.accountType'), expect.stringContaining('play.accountCreated'), expect.stringContaining('play.billingPrograms')])); }); }); describe('rules: no false positives', () => { test('clean fixture has no finding at all', async () => { process.env.REVIEW_DEMO_USER = 'demo@example.com'; process.env.REVIEW_DEMO_PASSWORD = 'example'; const r = await scan('clean'); expect(r.findings.map(f => `${f.rule}: ${f.title}`)).toEqual([]); expect(r.declarations.changed).toBe(false); expect(r.declarations.labelGaps).toEqual([]); expect(r.stacks.sort()).toEqual(['android-native', 'ios-native']); }); test('the demo account comes from the environment or .storebound/', async () => { expect(rules((await scan('clean')).findings)).toContain('demo-account'); }); test('the example projects have no open finding (the fictional ones are accepted in scan.ignore)', async () => { for (const d of ['demo-app', 'demo-game']) { const r = (await runScan(join(ROOT, 'examples', d))).report; expect(r.findings.filter(f => !f.accepted).map(f => f.title)).toEqual([]); for (const f of r.findings) expect(f.accepted!.length).toBeGreaterThan(10); } }); }); describe('declarations proposal', () => { test('detected SDK data types become additions, owner answers are kept, never removed', async () => { const d = (await scan('android')).declarations; expect(d.diff.find(x => x.type === 'PSL_DEVICE_ID')).toMatchObject({ change: 'add', after: { shared: true, collect: ['advertising', 'analytics', 'fraud'], share: ['advertising'] } }); expect(d.diff.find(x => x.type === 'PSL_EMAIL')!.change).toBe('merge'); expect(d.dataSafety!.data.PSL_EMAIL!.collect).toEqual(['account', 'functionality']); expect(d.items.find(x => x.what === 'encryptedInTransit')!.status).toBe('ask'); expect(d.items.find(x => x.what === 'PSL_DEVICE_ID')!.evidence[0]!.file).toBe('gradle/libs.versions.toml'); expect(d.changed).toBe(true); }); test('ATT and the privacy manifest', async () => { const d = (await scan('clean')).declarations; expect(d.items.find(x => x.area === 'appPrivacy' && x.what === 'tracking')).toMatchObject({ status: 'detected', value: expect.stringContaining('true') }); expect(d.items.find(x => x.area === 'privacyManifest' && x.what === 'NSPrivacyAccessedAPICategoryUserDefaults')).toMatchObject({ status: 'detected', value: 'declared: CA92.1' }); const t = (await scan('teaser')).declarations; expect(t.items.find(x => x.area === 'att')!.status).toBe('ask'); }); }); describe('config writing', () => { test('matchBrace skips strings and comments', () => { const s = "{ a: '}', b: { c: \"{\" }, // }\n d: `}` }"; expect(matchBrace(s, 0)).toBe(s.length - 1); }); test('toTs writes plain TypeScript', () => { expect(toTs({ shared: false, collect: ['a', 'b'] })).toBe("{ shared: false, collect: ['a', 'b'] }"); expect(toTs({ PSL_X: { shared: true, collect: ['x'] } }, ' ')).toBe("{\n PSL_X: { shared: true, collect: ['x'] },\n }"); }); test('upsertBlock replaces an existing block and inserts after app', () => { const src = "export default {\n app: { name: 'X' },\n dataSafety: { collectsData: false, data: {} },\n languages: [],\n};\n"; const a = upsertBlock(src, 'c.ts', 'dataSafety', { collectsData: true, data: {} }); expect(a).toBe("export default {\n app: { name: 'X' },\n dataSafety: {\n collectsData: true,\n data: {},\n },\n languages: [],\n};\n"); const b = upsertBlock(src, 'c.ts', 'appPrivacy', { tracking: true }, 'note'); expect(b).toContain(" app: { name: 'X' },\n // note\n appPrivacy: { tracking: true },\n dataSafety"); expect(JSON.parse(upsertBlock('{"app":{}}', 'c.json', 'appPrivacy', { tracking: false }))).toEqual({ app: {}, appPrivacy: { tracking: false } }); }); test('scan is read-only without --write; --write adds the detected answers and the config still loads', async () => { const dir = mkdtempSync(join(tmpdir(), 'sb-scan-')); try { cpSync(join(FX, 'android'), dir, { recursive: true }); const before = readFileSync(join(dir, 'storebound.config.ts'), 'utf8'); const run = (...a: string[]) => Bun.spawnSync(['bun', CLI, 'scan', '--dir', dir, ...a], { env: { ...process.env, REVIEW_DEMO_USER: '', REVIEW_DEMO_PASSWORD: '' } }); const r1 = run(); expect(r1.exitCode).toBe(1); expect(r1.stdout.toString()).toContain('+ dataSafety.data.PSL_DEVICE_ID'); expect(readFileSync(join(dir, 'storebound.config.ts'), 'utf8')).toBe(before); run('--write', '--dry'); expect(readFileSync(join(dir, 'storebound.config.ts'), 'utf8')).toBe(before); run('--write'); const after = readFileSync(join(dir, 'storebound.config.ts'), 'utf8'); expect(after).toContain("PSL_DEVICE_ID: { shared: true, collect: ['advertising', 'analytics', 'fraud'], share: ['advertising'] }"); expect(after).toContain("PSL_EMAIL: { shared: false, collect: ['account', 'functionality'] }"); expect(after).toContain("accountCreation: ['PSL_ACM_USER_ID_PASSWORD']"); const again = await runScan(dir); expect(again.report.configError).toBeNull(); expect(again.report.declarations.changed).toBe(false); expect(rules(again.report.findings)).not.toContain('privacy-labels'); } finally { rmSync(dir, { recursive: true, force: true }); } }); }); describe('monorepo: username accounts without email, plugins in apps/native/node_modules', () => { test('accounts mean a user id, never an email; route constants in the built bundle are not evidence', async () => { const r = await scan('monorepo'); const d = r.declarations; expect(d.diff.find(x => x.type === 'PSL_EMAIL')).toBeUndefined(); expect(d.dataSafety!.data.PSL_EMAIL).toBeUndefined(); expect(r.findings.filter(f => /Email|PSL_EMAIL/.test(f.title))).toEqual([]); const uid = d.items.find(x => x.what === 'PSL_USER_ACCOUNT')!; expect(uid.status).toBe('detected'); expect(uid.evidence[0]!.file).toBe('packages/shared/src/routes.ts'); for (const x of d.items) for (const e of x.evidence) if (x.status === 'detected') expect(e.file).not.toContain('/web/bundle/'); // the email input in the vendor chunk is built output: a question, never written expect(d.items.find(x => x.what === 'PSL_EMAIL')).toMatchObject({ status: 'ask', evidence: [{ file: 'apps/native/web/bundle/vendor-XyZ98765.js' }] }); expect(d.items.find(x => x.area === 'appPrivacy' && x.what === 'Identifiers / User ID')!.status).toBe('detected'); expect(d.items.some(x => x.area === 'appPrivacy' && x.what === 'Contact Info / Email Address')).toBe(false); expect(d.items.find(x => x.what === 'accountCreation')!.status).toBe('ask'); }); test('accounts.signUp: username -> Username and other authentication, still no email; email-password -> email', async () => { const dir = mkdtempSync(join(tmpdir(), 'sb-mono-')); try { cpSync(join(FX, 'monorepo'), dir, { recursive: true }); const cfgPath = join(dir, 'storebound.config.ts'); const base = readFileSync(cfgPath, 'utf8'); writeFileSync(cfgPath, base.replace("inApp: 'Settings > Delete account',", "inApp: 'Settings > Delete account',\n signUp: ['username'],")); const d = (await runScan(dir)).report.declarations; expect(d.accountCreationChange).toEqual({ from: ['PSL_ACM_NONE'], to: ['PSL_ACM_USER_ID_OTHER_AUTH'] }); expect(d.items.find(x => x.what === 'accountCreation')).toMatchObject({ status: 'detected', value: expect.stringContaining('Username and other authentication') }); expect(d.dataSafety!.data.PSL_EMAIL).toBeUndefined(); expect(d.dataSafety!.accountCreation).toEqual(['PSL_ACM_USER_ID_OTHER_AUTH']); Bun.spawnSync(['bun', CLI, 'scan', '--dir', dir, '--write']); const after = readFileSync(cfgPath, 'utf8'); expect(after).toContain("accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH']"); expect(after).not.toContain('PSL_EMAIL'); expect((await runScan(dir)).report.configError).toBeNull(); // a fresh folder: the config module of the first one stays cached in this process const dir2 = join(dir, 'email'); cpSync(join(FX, 'monorepo'), dir2, { recursive: true }); writeFileSync(join(dir2, 'storebound.config.ts'), base.replace("inApp: 'Settings > Delete account',", "inApp: 'Settings > Delete account',\n signUp: ['email-password'],")); const e = (await runScan(dir2)).report.declarations; expect(e.items.find(x => x.what === 'PSL_EMAIL')!.status).toBe('detected'); expect(e.accountCreationChange!.to).toEqual(['PSL_ACM_USER_ID_PASSWORD']); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('email evidence: fields, properties, validation, endpoints and SDK calls count; labels and constants do not', () => { const yes = ['', 'n.createElement("input",{type:"email"})', 'body: JSON.stringify({ email: form.email })', 'email?: string;', 'z.string().email()', 'const RE = /^[^\\s@]+@[^\\s@]+$/', "fetch('/api/auth/forgot-password')", 'auth.createUserWithEmailAndPassword(a, b)', 'Auth.auth().createUser(withEmail: e, password: p)', 'keyboardType="email-address"', 'request.requestedScopes = [.fullName, .email]']; const no = ["const ROUTES = { register: '/register', usernameCheck: '/username/check' }", "contactEmail: 'support@example.com'", "email: 'privacy@example.com'", "labels = { email: 'E-mail' }", 'register(username)', "fetch('/api/v1/session')", // server / config identities, not user data (a game apps/server/src/config.ts) ' rtdn: { audience: string | null; email: string | null; token: string | null };', ' email: process.env.RTDN_EMAIL ?? null,', ' pubsub: { email: cfg.serviceAccount },', ' smtp: { host, email: env.SMTP_USER },']; for (const s of yes) expect({ s, hit: SIGNALS.email.test(s) }).toEqual({ s, hit: true }); for (const s of no) expect({ s, hit: SIGNALS.email.test(s) }).toEqual({ s, hit: false }); // multi-line: the identity is named in the doc comment above (a game apps/server/src/iap/rtdn.ts) const rtdn = 'export interface RtdnAuthConfig {\n /** OIDC audience of the push subscription. */\n audience: string | null;\n /** Push service account email the token must carry. */\n email: string | null;\n}\n\nexport async function signUp(form: Form) {\n // the sign-up request\n const body = { email: form.email };'; const src = [{ path: 'rtdn.ts', text: rtdn }]; expect(userFacing(src, [{ file: 'rtdn.ts', line: 5 }, { file: 'rtdn.ts', line: 10 }]).map(h => h.line)).toEqual([10]); }); test('built output is low trust', () => { expect(isBuilt('apps/native/web/bundle/routes-AbC12345.js')).toBe(true); expect(isBuilt('dist/index.min.js')).toBe(true); expect(isBuilt('apps/native/web/index.js', ['apps/native/web'])).toBe(true); expect(isBuilt('packages/shared/src/routes.ts')).toBe(false); }); test('scan finds the SDK behind a plugin in apps/native/node_modules (podspec, Package.swift, android/build.gradle)', () => { const p = detectProject(join(FX, 'monorepo')); expect(p.stacks).toContain('capacitor'); expect(p.builtDirs).toEqual(['apps/native/web']); const lp = p.sdks.find(s => s.sdk.id === 'levelplay')!; expect(lp.deps.map(d => `${d.eco} ${d.file}`).sort()).toEqual([ 'cocoapods apps/native/node_modules/example-ads-plugin/ExampleAdsPlugin.podspec', 'gradle apps/native/node_modules/example-ads-plugin/android/build.gradle', 'spm apps/native/node_modules/example-ads-plugin/Package.swift', ]); }); test('privacy-manifest finds the plugins without --sdk-dirs (node_modules next to the app, local SwiftPM packages)', () => { const ios = join(FX, 'monorepo/apps/native/ios/App'); expect(nativeIosPlugins(ios).map(x => x.name).sort()).toEqual(['@example/capacitor-haptics', 'example-ads-plugin']); expect(localSwiftPackages(ios).map(x => x.split('/').pop())).toEqual(['example-ads-plugin']); expect(workspaceDirs(join(FX, 'monorepo')).map(x => x.split('/').slice(-2).join('/'))).toEqual(['apps/game', 'apps/native', 'packages/shared']); const r = Bun.spawnSync(['bun', CLI, 'privacy-manifest', '--ios', ios, '--json']); const out = r.stdout.toString(); const j = JSON.parse(out.slice(out.indexOf('{'))) as { sdks: { name: string; hasManifest: boolean }[] }; expect(j.sdks.map(s => [s.name, s.hasManifest]).sort()).toEqual([['@example/capacitor-haptics', true], ['example-ads-plugin', false]]); }); }); describe('integration', () => { test('2.2: beta / demo / test wording in the app name or the App Store text (not "demo account")', async () => { const dir = mkdtempSync(join(tmpdir(), 'storebound-scan-beta-')); try { cpSync(join(FX, 'clean'), dir, { recursive: true }); const cfg = join(dir, 'storebound.config.ts'); writeFileSync(cfg, readFileSync(cfg, 'utf8').replace("name: 'Clean Notes'", "name: 'Clean Notes Beta'")); const md = join(dir, 'store', 'appstore.md'); const text = readFileSync(md, 'utf8'); writeFileSync(md, text.replace(/(### description\n```\n)/, '$1This is a demo of the full app.\nReviewers use the demo account.\n')); const f = find((await runScan(dir)).report.findings, 'beta-wording', 'apple')!; expect(f.guideline).toBe('2.2'); expect(f.severity).toBe('high'); expect(f.evidence[0]!.text).toContain('Clean Notes Beta'); expect(f.evidence.some(e => e.file === join('store', 'appstore.md') && /demo of the full app/.test(e.text))).toBe(true); writeFileSync(cfg, readFileSync(cfg, 'utf8').replace("'Clean Notes Beta'", "'Clean Notes'")); writeFileSync(md, text.replace(/(### description\n```\n)/, '$1Reviewers use the demo account. Start a free trial of Pro.\n')); expect(find((await runScan(dir)).report.findings, 'beta-wording')).toBeUndefined(); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('JSON output, --store filter, exit code', () => { const r = Bun.spawnSync(['bun', CLI, 'scan', '--dir', join(FX, 'teaser'), '--json', '--store', 'apple'], { env: { ...process.env, REVIEW_DEMO_USER: '', REVIEW_DEMO_PASSWORD: '' } }); expect(r.exitCode).toBe(1); const out = r.stdout.toString(); const j = JSON.parse(out.slice(out.indexOf('{'))) as { counts: { blocker: number }; findings: ScanFinding[] }; expect(j.counts.blocker).toBe(2); expect(j.findings.every(f => f.store === 'apple')).toBe(true); }); test('scan.ignore marks findings as accepted and they stop blocking', async () => { const r = (await runScan(join(ROOT, 'examples', 'demo-app'))).report; expect(r.findings.find(f => f.rule === 'demo-account')!.accepted).toContain('fictional'); expect(r.counts.blocker).toBe(0); }); test('validate gets the open blockers and risks, not low or accepted ones', async () => { const r = (await scan('flutter')).findings; const v = scanAsFindings(r, { apple: true, play: false }); expect(v.every(f => f.store === 'apple' && f.code.startsWith('scan-'))).toBe(true); expect(v.filter(f => f.level === 'blocker').length).toBe(r.filter(f => f.store === 'apple' && f.severity === 'blocker').length); expect(v.some(f => f.code === 'scan-subscription-info' && f.msg.startsWith('3.1.2'))).toBe(true); expect(scanAsFindings([{ ...r[0]!, accepted: 'fine' }], { apple: true, play: true })).toEqual([]); }); test('MCP tool and desktop method are read-only and cannot write', () => { const t = TOOLS.find(x => x.name === 'scan')!; expect(t.write).toBe(false); expect(t.network).toBe(false); expect(toArgv(t, { store: 'play' }).argv).toEqual(['scan', '--store', 'play']); expect(FORBIDDEN_ARGS).toContain('--write'); for (const x of TOOLS) expect(toArgv(x, {}).argv).not.toContain('--write'); expect(JSON_TOOLS.has('scan')).toBe(true); }); }); test('placeholder text: "todo" as a word or keyword is fine, TODO / FIXME markers and lorem ipsum are not', async () => { const r = await scanVariant('clean', { 'store/appstore.md': s => s.replace(/(### keywords\n+```\n)/, '$1todo,tasks,') }); expect(r.findings.filter(f => f.rule === 'placeholder-text')).toEqual([]); const bad = await scanVariant('clean', { 'store/appstore.md': s => s.replace(/(### keywords\n+```\n)/, '$1TODO,') }); expect(bad.findings.some(f => f.rule === 'placeholder-text')).toBe(true); });