80 lines
5.8 KiB
TypeScript
80 lines
5.8 KiB
TypeScript
// 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']);
|
|
});
|
|
});
|