310 lines
24 KiB
TypeScript
310 lines
24 KiB
TypeScript
// A real-use report from a second game (Capacitor monorepo, first release): false positives and gaps in scan, check,
|
|
// privacy-manifest, the console answer sheets and init. The patterns are recreated with example code.
|
|
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 AppleDataTypeId, type AppleTypeAnswer, appPrivacyMismatches } from '../cli/lib/app-privacy';
|
|
import type { ShipConfig } from '../cli/lib/config';
|
|
import { validateConfig } from '../cli/lib/config';
|
|
import { consoleAnswers } from '../cli/lib/console-answers';
|
|
import { consolePlan } from '../cli/lib/console-plan';
|
|
import { advertisingIdUse } from '../cli/lib/console-play-plans';
|
|
import { credentialStatus, credentialValue, isPlaceholder } from '../cli/lib/credentials';
|
|
import { collectedFromAppPrivacy, proposeAppManifest } from '../cli/lib/privacy-manifest';
|
|
import { type ProjectSignals, projectSignals } from '../cli/lib/project-signals';
|
|
import { runScan } from '../cli/lib/scan';
|
|
import { detectProject } from '../cli/lib/scan/project';
|
|
import { applyIgnore, type ScanFinding, SIGNALS, rewardNearReviewApi, unusedIgnores } from '../cli/lib/scan/rules';
|
|
|
|
const ROOT = join(import.meta.dir, '..');
|
|
const FX = join(import.meta.dir, 'fixtures', 'scan', 'capacitor-game');
|
|
const tmp = mkdtempSync(join(tmpdir(), 'sb-game-report-'));
|
|
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
|
/** A copy of the fixture with files replaced (path -> text). */
|
|
function variant(name: string, files: Record<string, string>): string {
|
|
const d = join(tmp, name);
|
|
cpSync(FX, d, { recursive: true });
|
|
for (const [p, t] of Object.entries(files)) { mkdirSync(join(d, p, '..'), { recursive: true }); writeFileSync(join(d, p), t); }
|
|
return d;
|
|
}
|
|
const fxConfig = readFileSync(join(FX, 'storebound.config.ts'), 'utf8');
|
|
|
|
describe('review-prompt: a bare "rate" next to a reward word is not a reward for rating', () => {
|
|
test('negatives: production rates, frame rates, rate limits, exchange rates, code review', () => {
|
|
for (const t of [
|
|
'const rate = F.genRate(lv, bonus.gen(r));',
|
|
'const frameRate = 60; // bonus coins drop at 30 fps',
|
|
'if (rateLimit.hit) return bonus.coins;',
|
|
'const exchangeRate = coins / gems;',
|
|
'rate limit bonus reward',
|
|
'review the code before the bonus round ships',
|
|
'growth rate per level gives bonus coins',
|
|
]) expect({ t, hit: SIGNALS.incentiveRating.test(t) }).toEqual({ t, hit: false });
|
|
});
|
|
test('positives: rate-us phrases next to a reward, rewards for rating', () => {
|
|
for (const t of [
|
|
'Rate us 5 stars and get 100 coins',
|
|
'review the app to earn a reward',
|
|
'Get 50 gems for rating us',
|
|
'rate the game for a bonus',
|
|
'Leave a review and get 20 gems!',
|
|
'Write us a review for free spins',
|
|
]) expect({ t, hit: SIGNALS.incentiveRating.test(t) }).toEqual({ t, hit: true });
|
|
});
|
|
test('a reward granted next to the system review prompt call', () => {
|
|
const yes = { path: 'src/rate.ts', text: 'export async function askRating() {\n await InAppReview.requestReview();\n wallet.addCoins(50);\n}\n' };
|
|
expect(rewardNearReviewApi([yes]).map(h => h.line)).toEqual([2]);
|
|
const no = { path: 'src/rate.ts', text: 'export async function askRating() {\n await InAppReview.requestReview();\n}\n' + '\n'.repeat(20) + 'wallet.addCoins(50);\n' };
|
|
expect(rewardNearReviewApi([no])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('account signals: module paths are not auth code', () => {
|
|
test('negatives: import paths and module names with register / login / account', () => {
|
|
for (const t of [
|
|
"import './buildings/register';",
|
|
"import { registerAll } from './modules/register';",
|
|
"export * from './account/login';",
|
|
"const m = require('./login');",
|
|
"const Screen = lazy(() => import('./screens/register'));",
|
|
"loadModule('./buildings/register');",
|
|
"registry.register('farm', Farm);",
|
|
"router.add({ path: './buildings/signup' });",
|
|
]) expect({ t, up: SIGNALS.signUp.test(t), in: SIGNALS.signIn.test(t) }).toEqual({ t, up: false, in: false });
|
|
});
|
|
test('positives: auth endpoints, SDK calls, credential fields, guest accounts, screen strings', () => {
|
|
for (const t of [
|
|
"await fetch(`${API}/auth/register`, { method: 'POST', body });",
|
|
"fetch('/api/signup', { method: 'POST' })",
|
|
"http.post('https://api.example.com/v1/register', body)",
|
|
'<button>Sign up</button>',
|
|
'const guest = await createGuestAccount(deviceId);',
|
|
'<input autoComplete="new-password" />',
|
|
'await supabase.auth.signUp({ email, password })',
|
|
]) expect({ t, hit: SIGNALS.signUp.test(t) }).toEqual({ t, hit: true });
|
|
for (const t of ["fetch('/auth/login', { method: 'POST' })", '<input type="password" autoComplete="current-password" />', 'await signInWithEmailAndPassword(auth, e, p)'])
|
|
expect({ t, hit: SIGNALS.signIn.test(t) }).toEqual({ t, hit: true });
|
|
});
|
|
test('a whole file: the import line is skipped, the real call on a later line still counts', () => {
|
|
expect(SIGNALS.signUp.test("import './buildings/register';\nconst x = 1;\n")).toBe(false);
|
|
expect(SIGNALS.signUp.test("import './buildings/register';\nawait fetch('/api/register', { method: 'POST' });\n")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('scan on the Capacitor game fixture', () => {
|
|
test('no reward-for-rating finding from production rates; account evidence never an import path', async () => {
|
|
const r = (await runScan(FX)).report;
|
|
expect(r.findings.filter(f => f.rule === 'review-prompt')).toEqual([]);
|
|
const account = r.findings.filter(f => ['account-deletion', 'play-deletion-url', 'demo-account', 'privacy-policy-link'].includes(f.rule));
|
|
expect(account.length).toBeGreaterThan(0);
|
|
for (const f of account) for (const e of f.evidence) expect({ rule: f.rule, bad: /import|buildings\/register/.test(e.text) }).toEqual({ rule: f.rule, bad: false });
|
|
const ua = r.declarations.items.find(i => i.what === 'PSL_USER_ACCOUNT')!;
|
|
expect(ua.evidence[0]!.text).toBe('from config (accounts.enabled)');
|
|
});
|
|
test('accounts.inApp alone does not clear 5.1.1(v): medium, "claimed by config"', async () => {
|
|
const r = (await runScan(FX)).report;
|
|
const del = r.findings.find(f => f.rule === 'account-deletion')!;
|
|
expect(del).toMatchObject({ severity: 'medium', store: 'apple' });
|
|
expect(del.title).toContain('claimed by config');
|
|
expect(del.evidence[0]!.text).toBe('from config (accounts.inApp: Settings > Delete account)');
|
|
expect(del.fix).toContain('Show the code path or the screen');
|
|
});
|
|
test('deletion code clears it; no config claim and no code is a blocker; an import of a delete-account module is not code', async () => {
|
|
const withCode = variant('del-code', { 'src/settings.ts': "export async function deleteAccount(): Promise<void> {\n await fetch('/api/account', { method: 'DELETE' });\n}\n" });
|
|
expect((await runScan(withCode)).report.findings.filter(f => f.rule === 'account-deletion')).toEqual([]);
|
|
const noClaim = variant('del-none', { 'storebound.config.ts': fxConfig.replace(", inApp: 'Settings > Delete account'", '') });
|
|
expect((await runScan(noClaim)).report.findings.find(f => f.rule === 'account-deletion')).toMatchObject({ severity: 'blocker', title: 'Accounts can be created but no in-app account deletion was found' });
|
|
const imported = variant('del-import', { 'src/menu.ts': "import './screens/delete-account';\n" });
|
|
expect((await runScan(imported)).report.findings.find(f => f.rule === 'account-deletion')!.severity).toBe('medium');
|
|
});
|
|
test('a scan.ignore entry that matches nothing is reported; evidence matching accepts by the evidence text', async () => {
|
|
const r = (await runScan(FX)).report;
|
|
expect(r.notes.some(n => n.startsWith('scan.ignore[0] (rule privacy-labels, evidence "src/no-such-file.ts") matches no finding'))).toBe(true);
|
|
const by = variant('ignore-evidence', { 'storebound.config.ts': fxConfig.replace("{ rule: 'privacy-labels', evidence: 'src/no-such-file.ts', reason: 'stale entry: matches nothing' }",
|
|
"{ rule: 'account-deletion', evidence: 'accounts.inApp', reason: 'deletion is a server-driven screen, checked on a device' }") });
|
|
const r2 = (await runScan(by)).report;
|
|
expect(r2.findings.find(f => f.rule === 'account-deletion')!.accepted).toBe('deletion is a server-driven screen, checked on a device');
|
|
expect(r2.notes.filter(n => n.startsWith('scan.ignore['))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('scan.ignore: title (match) and evidence', () => {
|
|
const f = (): ScanFinding => ({ rule: 'privacy-labels', guideline: '5.1.2', store: 'apple', severity: 'medium', title: 'Data types the SDKs collect are missing', evidence: [{ file: 'src/sdk.ts', line: 7, text: 'Analytics.init(key)' }], fix: 'x', skill: 's', ref: 'https://example.com' });
|
|
test('match keeps matching the title (backward compatible), evidence matches file:line or text, both together narrow', () => {
|
|
const cases: [{ rule: string; store?: 'apple' | 'play'; match?: string; evidence?: string; reason: string }, boolean][] = [
|
|
[{ rule: 'privacy-labels', match: 'SDKS COLLECT', reason: 'r' }, true],
|
|
[{ rule: 'privacy-labels', match: 'Analytics.init', reason: 'r' }, false],
|
|
[{ rule: 'privacy-labels', evidence: 'src/sdk.ts:7', reason: 'r' }, true],
|
|
[{ rule: 'privacy-labels', evidence: 'analytics.init', reason: 'r' }, true],
|
|
[{ rule: 'privacy-labels', evidence: 'src/other.ts', reason: 'r' }, false],
|
|
[{ rule: 'privacy-labels', match: 'missing', evidence: 'src/sdk.ts', reason: 'r' }, true],
|
|
[{ rule: 'privacy-labels', match: 'missing', evidence: 'src/other.ts', reason: 'r' }, false],
|
|
[{ rule: 'privacy-labels', store: 'play', reason: 'r' }, false],
|
|
];
|
|
for (const [i, yes] of cases) {
|
|
const x = f();
|
|
applyIgnore([x], [i]);
|
|
expect({ i, accepted: !!x.accepted }).toEqual({ i, accepted: yes });
|
|
}
|
|
});
|
|
test('unused entries are named; originality entries are skipped unless that rule ran', () => {
|
|
const msgs = unusedIgnores([f()], [{ rule: 'privacy-labels', evidence: 'src/sdk.ts', reason: 'r' }, { rule: 'privacy-label', reason: 'typo' }, { rule: 'originality', reason: 'r' }]);
|
|
expect(msgs.length).toBe(1);
|
|
expect(msgs[0]).toStartWith('scan.ignore[1] (rule privacy-label) matches no finding');
|
|
});
|
|
test('the config accepts evidence and rejects an empty one', () => {
|
|
const base = { app: { name: 'A', privacyPolicyUrl: 'https://example.com/p' }, languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }] };
|
|
const errs = (ignore: unknown[]) => validateConfig({ ...base, scan: { ignore } }).filter(e => e.startsWith('scan.ignore'));
|
|
expect(errs([{ rule: 'x', evidence: 'src/a.ts:3', reason: 'r' }])).toEqual([]);
|
|
expect(errs([{ rule: 'x', evidence: ' ', reason: 'r' }]).length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('privacy manifest from appPrivacy.types', () => {
|
|
const types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {
|
|
USER_ID: { collected: true, linked: true, tracking: false, purposes: ['APP_FUNCTIONALITY'] },
|
|
DEVICE_ID: { collected: true, linked: false, tracking: true, purposes: ['APP_FUNCTIONALITY', 'THIRD_PARTY_ADVERTISING'] },
|
|
PRODUCT_INTERACTION: { collected: true, linked: false, purposes: ['ANALYTICS'] },
|
|
CRASH_DATA: { collected: true, linked: false, tracking: false, purposes: ['APP_FUNCTIONALITY'] },
|
|
GAMEPLAY_CONTENT: { collected: true, linked: true, tracking: false, purposes: ['APP_FUNCTIONALITY'] },
|
|
NAME: { collected: false },
|
|
};
|
|
test('--propose lists every collected type with linked, tracking and purposes; NSPrivacyTracking stays false without domains', () => {
|
|
const m = proposeAppManifest({ types, collected: [] }) as { NSPrivacyTracking: boolean; NSPrivacyCollectedDataTypes: Record<string, unknown>[] };
|
|
expect(m.NSPrivacyTracking).toBe(false);
|
|
expect(m.NSPrivacyCollectedDataTypes.map(e => e.NSPrivacyCollectedDataType)).toEqual(['NSPrivacyCollectedDataTypeGameplayContent', 'NSPrivacyCollectedDataTypeUserID',
|
|
'NSPrivacyCollectedDataTypeDeviceID', 'NSPrivacyCollectedDataTypeProductInteraction', 'NSPrivacyCollectedDataTypeCrashData']);
|
|
expect(m.NSPrivacyCollectedDataTypes.find(e => e.NSPrivacyCollectedDataType === 'NSPrivacyCollectedDataTypeDeviceID')).toEqual({
|
|
NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeDeviceID', NSPrivacyCollectedDataTypeLinked: false, NSPrivacyCollectedDataTypeTracking: true,
|
|
NSPrivacyCollectedDataTypePurposes: ['NSPrivacyCollectedDataTypePurposeAppFunctionality', 'NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising'] });
|
|
expect((proposeAppManifest({ types, trackingDomains: ['t.example.com'] }) as { NSPrivacyTracking: boolean }).NSPrivacyTracking).toBe(true);
|
|
// no types: the manifest's own entries stay
|
|
expect((proposeAppManifest({ collected: [{ NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeName' }] }) as { NSPrivacyCollectedDataTypes: unknown[] }).NSPrivacyCollectedDataTypes.length).toBe(1);
|
|
});
|
|
test('notes: unanswered fields and manifest entries that appPrivacy.types does not mark collected', () => {
|
|
const { notes } = collectedFromAppPrivacy(types, [{ NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeName' }]);
|
|
expect(notes).toContain('appPrivacy.types.PRODUCT_INTERACTION.tracking is not answered: wrote false');
|
|
expect(notes.some(n => n.startsWith('NSPrivacyCollectedDataTypeName was in the manifest'))).toBe(true);
|
|
});
|
|
test('check: an empty NSPrivacyCollectedDataTypes against collected types is one warning naming --propose', () => {
|
|
const out = appPrivacyMismatches(types, { attString: true, manifest: { path: 'ios/App/App/PrivacyInfo.xcprivacy', tracking: false, collectedTypes: [] }, tracking: { trackerSdks: ['Example Ads'] } });
|
|
const w = out.filter(x => x.msg.includes('empty NSPrivacyCollectedDataTypes'));
|
|
expect(w).toHaveLength(1);
|
|
expect(w[0]!.level).toBe('warn');
|
|
expect(w[0]!.msg).toContain('--propose');
|
|
expect(w[0]!.msg).toContain('USER_ID, DEVICE_ID, PRODUCT_INTERACTION, CRASH_DATA, GAMEPLAY_CONTENT');
|
|
});
|
|
test('the CLI proposes the fixture manifest from the config', () => {
|
|
const r = Bun.spawnSync(['bun', join(ROOT, 'cli', 'index.ts'), 'privacy-manifest', '--ios', 'ios/App', '--propose'], { cwd: FX, env: { ...process.env, STOREBOUND_PROFILE: '' } });
|
|
const out = r.stdout.toString();
|
|
expect(r.exitCode).toBe(0);
|
|
for (const k of ['UserID', 'DeviceID', 'GameplayContent', 'ProductInteraction', 'CrashData']) expect(out).toContain(`<string>NSPrivacyCollectedDataType${k}</string>`);
|
|
expect(out).toMatch(/<key>NSPrivacyTracking<\/key>\s*<false\/>/);
|
|
});
|
|
});
|
|
|
|
const none: ProjectSignals = { adSdks: [], attributionSdks: [], adIdSdks: [], adIdPermission: null, adIdRemoved: null, support: null, ar: null };
|
|
|
|
describe('App Privacy answer sheet: appPrivacy.types is authoritative', () => {
|
|
const cfg = async () => (await runScan(FX)).config!;
|
|
test('unlisted types are not collected (certain), never derived from dataSafety (PSL_OTHER_APP_ACTIVITY) or asked', async () => {
|
|
const a = consoleAnswers('apple-app-privacy', await cfg(), none);
|
|
const types = a.filter(x => x.id.startsWith('type:'));
|
|
expect(types.filter(x => !x.certain)).toEqual([]);
|
|
expect(a.find(x => x.id === 'type:Other Usage Data')).toMatchObject({ value: false, certain: true, source: 'appPrivacy.types (not listed: not collected)' });
|
|
expect(types.filter(x => x.value === true).map(x => x.id).sort()).toEqual(['type:Crash Data', 'type:Device ID', 'type:Gameplay Content', 'type:Product Interaction', 'type:User ID']);
|
|
// the same without project signals: authoritative does not need a scan
|
|
expect(consoleAnswers('apple-app-privacy', await cfg()).filter(x => x.id.startsWith('type:') && !x.certain)).toEqual([]);
|
|
});
|
|
test('without appPrivacy.types: tracking false and no ad SDK default the ad, support and AR types to not collected, unless a signal says otherwise', async () => {
|
|
const c = { ...(await cfg()), appPrivacy: { tracking: false } } as ShipConfig;
|
|
const val = (s: ProjectSignals | undefined, t: string) => consoleAnswers('apple-app-privacy', c, s).find(x => x.id === `type:${t}`)!;
|
|
for (const t of ['Advertising Data', 'Customer Support', 'Environment Scanning', 'Hands', 'Head']) expect(val(none, t)).toMatchObject({ value: false, certain: true });
|
|
expect(val(undefined, 'Advertising Data').certain).toBe(false);
|
|
expect(val({ ...none, ar: 'src/ar.swift:3' }, 'Hands').certain).toBe(false);
|
|
expect(val({ ...none, ar: 'src/ar.swift:3' }, 'Advertising Data').value).toBe(false);
|
|
expect(val({ ...none, support: 'package.json:9' }, 'Customer Support').certain).toBe(false);
|
|
expect(val({ ...none, adSdks: ['Google AdMob'] }, 'Advertising Data').certain).toBe(false);
|
|
expect(consoleAnswers('apple-app-privacy', { ...c, appPrivacy: { tracking: true } } as ShipConfig, none).find(x => x.id === 'type:Advertising Data')!.certain).toBe(false);
|
|
// dataSafety still answers the types it maps (Other Usage Data from PSL_OTHER_APP_ACTIVITY) when there are no types
|
|
expect(val(none, 'Other Usage Data')).toMatchObject({ value: true, certain: true });
|
|
});
|
|
});
|
|
|
|
describe('Play "Does your app use advertising ID?" from real signals', () => {
|
|
const c = { app: { name: 'Example Farm', profile: 'game' }, play: { packageName: 'org.example.farmgame' }, languages: [],
|
|
dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { shared: false, collect: ['functionality'] } } } } as unknown as ShipConfig;
|
|
test('a Device ID in Data safety alone is No (certain) after a scan', () => {
|
|
expect(advertisingIdUse(c, none)).toMatchObject({ v: false });
|
|
const p = consolePlan('play-advertising-id', c, { signals: none });
|
|
expect(p.answers.find(a => a.id === 'uses')).toMatchObject({ value: false, status: 'certain', label: 'No' });
|
|
expect(p.answers.filter(a => a.id.startsWith('purpose'))).toEqual([]);
|
|
});
|
|
test('ad / attribution SDKs, the AD_ID permission, app-ads.txt or the flag say Yes; disagreeing signals ask', () => {
|
|
expect(advertisingIdUse(c, { ...none, adSdks: ['Google AdMob'] }).v).toBe(true);
|
|
expect(advertisingIdUse(c, { ...none, attributionSdks: ['Adjust'] }).v).toBe(true);
|
|
expect(advertisingIdUse(c, { ...none, adIdPermission: 'android/app/src/main/AndroidManifest.xml:3' }).v).toBe(true);
|
|
expect(advertisingIdUse({ ...c, console: { appContent: { advertisingId: true } } } as ShipConfig, none).v).toBe(true);
|
|
expect(advertisingIdUse({ ...c, console: { appContent: { advertisingId: false } } } as ShipConfig, none).v).toBe(false);
|
|
const clash = advertisingIdUse({ ...c, console: { appContent: { advertisingId: false } } } as ShipConfig, { ...none, adSdks: ['Google AdMob'] });
|
|
expect(clash.v).toBeNull();
|
|
expect(clash.note).toContain('disagree');
|
|
expect(advertisingIdUse(c, { ...none, adSdks: ['Google AdMob'], adIdRemoved: 'android/app/src/main/AndroidManifest.xml:4' }).v).toBeNull();
|
|
expect(consolePlan('play-advertising-id', { ...c, console: { appContent: { advertisingId: false } } } as ShipConfig, { signals: { ...none, adSdks: ['Google AdMob'] } }).answers.find(a => a.id === 'uses')!.status).toBe('ask-owner');
|
|
// not scanned: ask, as before
|
|
expect(advertisingIdUse(c, undefined).v).toBeNull();
|
|
});
|
|
test('project signals read the AD_ID permission and its removal from the manifests', () => {
|
|
expect(projectSignals(detectProject(FX))).toMatchObject({ adSdks: [], adIdPermission: null, adIdRemoved: null, ar: null, support: null });
|
|
const withPerm = variant('ad-id', { 'android/app/src/main/AndroidManifest.xml': '<?xml version="1.0" encoding="utf-8"?>\n<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n <uses-permission android:name="com.google.android.gms.permission.AD_ID" />\n</manifest>\n' });
|
|
expect(projectSignals(detectProject(withPerm)).adIdPermission).toBe('android/app/src/main/AndroidManifest.xml:3');
|
|
const removed = variant('ad-id-removed', { 'android/app/src/main/AndroidManifest.xml': '<?xml version="1.0" encoding="utf-8"?>\n<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">\n <uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />\n</manifest>\n' });
|
|
expect(projectSignals(detectProject(removed)).adIdRemoved).toBe('android/app/src/main/AndroidManifest.xml:3');
|
|
});
|
|
});
|
|
|
|
describe('credentials: template placeholders are missing, never credentials', () => {
|
|
test('the template has only empty values', () => {
|
|
const t = JSON.parse(readFileSync(join(ROOT, 'templates', 'credentials.example.json'), 'utf8')) as Record<string, unknown>;
|
|
const strings = (v: unknown): string[] => typeof v === 'string' ? [v] : v && typeof v === 'object' ? Object.values(v).flatMap(strings) : [];
|
|
expect(strings(t).filter(Boolean)).toEqual([]);
|
|
});
|
|
test('placeholder-looking values', () => {
|
|
for (const v of ['YOUR_KEY_ID', 'your-key', 'sk_your_revenuecat_v2_secret_key', '/absolute/path/to/upload.p12', '/absolute/path/to/AuthKey_<KEY_ID>.p8', '<KEY_ID>', 'xxx', 'XXXX-XXXX',
|
|
'SEARCHADS.00000000-0000-0000-0000-000000000000', 'example', 'key-example', 'review-contact@example.com'])
|
|
expect({ v, p: isPlaceholder(v) }).toEqual({ v, p: true });
|
|
for (const v of ['ABCDE12345', '00000000-0000-0000-0000-000000000000', 'sk_test_placeholder_secret_value', '/Users/me/keys/upload.p12', 'upload', '-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----', '{"client_email":"x@example.com"}', ''])
|
|
expect({ v, p: isPlaceholder(v) }).toEqual({ v, p: false });
|
|
expect(isPlaceholder('demo@example.com', { example: false })).toBe(false);
|
|
});
|
|
test('the 0.7.1 template in .storebound/credentials.json counts as missing', () => {
|
|
const d = join(tmp, 'creds');
|
|
mkdirSync(join(d, '.storebound'), { recursive: true });
|
|
writeFileSync(join(d, '.storebound', 'credentials.json'), JSON.stringify({
|
|
PLAY_SERVICE_ACCOUNT: '/absolute/path/to/play-service-account.json', ASC_KEY_ID: 'YOUR_KEY_ID', ASC_P8: '/absolute/path/to/AuthKey_<KEY_ID>.p8',
|
|
REVENUECAT_SECRET_KEY: 'sk_your_revenuecat_v2_secret_key', ANDROID_KEYSTORE: '/absolute/path/to/upload.p12', ANDROID_KEY_ALIAS: 'upload', ASC_VENDOR_NUMBER: '',
|
|
}));
|
|
const keep = Object.fromEntries(['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_P8', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEY_ALIAS', 'STOREBOUND_PROFILE', 'STOREBOUND_CREDENTIALS_DIR'].map(k => [k, process.env[k]]));
|
|
for (const k of Object.keys(keep)) delete process.env[k];
|
|
// hermetic: a machine's default credential profile (~/.storebound/credentials/default-profile) must not fill these
|
|
process.env.STOREBOUND_CREDENTIALS_DIR = join(tmp, 'no-profiles');
|
|
try {
|
|
for (const k of ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_P8', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE']) expect({ k, v: credentialValue(d, k) }).toEqual({ k, v: undefined });
|
|
expect(credentialValue(d, 'ANDROID_KEY_ALIAS')).toBe('upload');
|
|
const st = Object.fromEntries(credentialStatus(d).map(s => [s.key, s.status]));
|
|
expect(st).toMatchObject({ PLAY_SERVICE_ACCOUNT: 'placeholder', ASC_KEY_ID: 'placeholder', REVENUECAT_SECRET_KEY: 'placeholder', ANDROID_KEY_ALIAS: 'set', ASC_VENDOR_NUMBER: 'missing', ASC_ISSUER_ID: 'missing' });
|
|
} finally { delete process.env.STOREBOUND_CREDENTIALS_DIR; for (const [k, v] of Object.entries(keep)) if (v !== undefined) process.env[k] = v; }
|
|
});
|
|
test('check warns about placeholders, the empty manifest list and an unused scan.ignore entry', () => {
|
|
const d = variant('check', { '.storebound/credentials.json': JSON.stringify({ ASC_KEY_ID: 'YOUR_KEY_ID', REVENUECAT_SECRET_KEY: 'sk_your_revenuecat_v2_secret_key' }) });
|
|
const env = { ...process.env };
|
|
for (const k of ['ASC_KEY_ID', 'REVENUECAT_SECRET_KEY', 'STOREBOUND_PROFILE']) delete env[k];
|
|
env.STOREBOUND_CREDENTIALS_DIR = join(tmp, 'no-profiles');
|
|
const r = Bun.spawnSync(['bun', join(ROOT, 'cli', 'index.ts'), 'check'], { cwd: d, env });
|
|
const err = r.stderr.toString() + r.stdout.toString();
|
|
expect(err).toContain('credentials: ASC_KEY_ID, REVENUECAT_SECRET_KEY still hold template placeholders');
|
|
expect(err).toContain('has an empty NSPrivacyCollectedDataTypes');
|
|
expect(err).toContain('scan.ignore[0] (rule privacy-labels');
|
|
});
|
|
});
|