storebound/tests/game-report.test.ts

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');
});
});