489 lines
37 KiB
TypeScript
489 lines
37 KiB
TypeScript
// 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<string, unknown>).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, (s: string) => 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('</resources>', ' <string name="bg">Trail Mate collects location data to record your hike even when the app is closed or not in use.</string>\n</resources>'),
|
|
'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 = ['<input type="email" />', '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);
|
|
});
|