// App Review 2.3.1 / Play Deceptive behavior: remote config, feature flags and remote paywalls must be told to review. // scan warns (Apple and Play) unless the review notes mention them; storebound never writes the note itself. Minify // alone is never a finding; a code obfuscator is a low note. import { afterAll, describe, expect, test } from 'bun:test'; import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ShipConfig } from '../cli/lib/config'; import { runScan } from '../cli/lib/scan'; import { detectProject } from '../cli/lib/scan/project'; import { remoteFeatureFindings } from '../cli/lib/scan/rules'; const dirs: string[] = []; afterAll(() => { for (const d of dirs) rmSync(d, { recursive: true, force: true }); }); /** A tiny app: Podfile.lock pods, Gradle dependencies and own sources. */ function app(o: { pods?: string[]; gradle?: string[]; src?: string; minify?: boolean }): string { const d = mkdtempSync(join(tmpdir(), 'sb-remote-')); dirs.push(d); if (o.pods) writeFileSync(join(d, 'Podfile.lock'), `PODS:\n${o.pods.map(p => ` - ${p} (11.0.0)`).join('\n')}\n\nDEPENDENCIES:\n${o.pods.map(p => ` - ${p}`).join('\n')}\n\nCOCOAPODS: 1.15.2\n`); mkdirSync(join(d, 'android/app'), { recursive: true }); writeFileSync(join(d, 'android/app/build.gradle.kts'), [ 'plugins { id("com.android.application") }', 'android {', ' namespace = "com.example.app"', ' compileSdk = 36', ' defaultConfig { applicationId = "com.example.app"; minSdk = 24; targetSdk = 36 }', ` buildTypes { release { isMinifyEnabled = ${o.minify ? 'true' : 'false'}; proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } }`, '}', 'dependencies {', ...(o.gradle ?? []).map(g => ` implementation("${g}")`), '}', ''].join('\n')); if (o.src) { mkdirSync(join(d, 'src'), { recursive: true }); writeFileSync(join(d, 'src/App.swift'), o.src); } return d; } const cfg = (notes?: string, instructions?: string) => ({ apple: { info: { reviewNotes: notes } }, play: { packageName: 'com.example.app' }, reviewAccess: instructions ? { signInRequired: false, instructions } : undefined }) as unknown as ShipConfig; const both = { apple: true, play: true }; const rules = (dir: string, c: ShipConfig | null) => remoteFeatureFindings(detectProject(dir), c, both); describe('remote features (2.3.1)', () => { test('Firebase Remote Config without a review note: an Apple and a Play finding with deep links', () => { const d = app({ pods: ['FirebaseRemoteConfig'], gradle: ['com.google.firebase:firebase-config:22.0.0'] }); const f = rules(d, cfg('Single-player game. No account needed.')); expect(f.map(x => `${x.store} ${x.rule} ${x.guideline} ${x.severity}`).sort()).toEqual(['apple remote-features 2.3.1 medium', 'play remote-features Play: Deceptive behavior medium']); const apple = f.find(x => x.store === 'apple')!; expect(apple.title).toContain('Firebase Remote Config'); expect(apple.fix).toContain('soft -> hard paywall'); expect(apple.url).toStartWith('https://appstoreconnect.apple.com/'); expect(f.find(x => x.store === 'play')!.fix).toContain('App access'); expect(apple.evidence.map(e => e.file)).toContain('Podfile.lock'); }); test('the same app with a note that mentions remote config: no finding', () => { const d = app({ pods: ['FirebaseRemoteConfig'], gradle: ['com.google.firebase:firebase-config:22.0.0'] }); expect(rules(d, cfg('Shop prices and the paywall design come from Firebase Remote Config and can change remotely.'))).toEqual([]); expect(rules(d, cfg(undefined, 'No sign-in. Feature flags: the daily event can be switched on remotely.'))).toEqual([]); }); test('Superwall: a remote paywall finding', () => { const d = app({ pods: ['SuperwallKit'] }); const f = rules(d, cfg('A photo app.')); expect(f.filter(x => x.rule === 'remote-features').map(x => x.store).sort()).toEqual(['apple', 'play']); expect(f[0]!.title).toStartWith('remote paywalls (Superwall)'); }); test('RevenueCat paywall presented from code, Firebase web Remote Config from code, PostHog only with flag calls', () => { expect(rules(app({ src: 'func show() {\n let v = PaywallView(offering: o)\n}\n' }), cfg('x'))[0]?.title).toContain('RevenueCat Paywalls'); expect(rules(app({ src: 'const rc = getRemoteConfig(app);\n' }), cfg('x'))[0]?.title).toContain('Firebase Remote Config'); expect(rules(app({ pods: ['PostHog'] }), cfg('x'))).toEqual([]); expect(rules(app({ pods: ['PostHog'], src: 'if posthog.isFeatureEnabled("new-shop") { open() }\n' }), cfg('x'))[0]?.title).toContain('PostHog feature flags'); }); test('no such SDK: no finding; minify alone: no finding; an obfuscator: a low note, never an error', () => { expect(rules(app({ pods: ['GoogleSignIn'], gradle: ['androidx.core:core-ktx:1.13.1'] }), null)).toEqual([]); expect(rules(app({ gradle: ['androidx.core:core-ktx:1.13.1'], minify: true }), cfg('x'))).toEqual([]); const o = rules(app({ pods: ['iXGuard'] }), cfg('x')); expect(o.map(x => `${x.rule} ${x.severity}`)).toEqual(['obfuscation low', 'obfuscation low']); }); test('scan and validate carry it: the clean fixture plus Firebase Remote Config', async () => { const d = mkdtempSync(join(tmpdir(), 'sb-remote-clean-')); dirs.push(d); cpSync(join(import.meta.dir, 'fixtures/scan/clean'), d, { recursive: true }); const lock = join(d, 'Podfile.lock'); writeFileSync(lock, readFileSync(lock, 'utf8').replace(' - GoogleSignIn (7.1.0)', ' - GoogleSignIn (7.1.0)\n - FirebaseRemoteConfig (11.0.0)')); const f = (await runScan(d)).report.findings.filter(x => x.rule === 'remote-features'); expect(f.map(x => x.store).sort()).toEqual(['apple', 'play']); }); });