scan: remote config, feature flags and remote paywalls not told to review (App Review 2.3.1, Play Deceptive behavior): remote-features finding for both stores, medium, with deep links, unless the review notes mention them; check prints it as a warning, validate carries it; warn only, the templates get a commented example line; code obfuscators a low note, minify alone nothing; store-pitfalls 74
This commit is contained in:
parent
9ba97b7e29
commit
61cb3338e8
13 changed files with 258 additions and 6 deletions
80
tests/remote-features.test.ts
Normal file
80
tests/remote-features.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// 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']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue