// The desktop's Fix button (lib/fix-actions.ts): which findings get a machine-readable fix, the scan.write round trip // (dry run with the config diff -> confirm token -> real write) on a fixture, and that no fix reaches a submit, // release or publish. import { describe, expect, test } from 'bun:test'; import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { mint } from '../cli/lib/confirm-token'; import { AI_FIX_RULES, FIX_METHODS, SCAN_WRITE_RULES, scanFixAction } from '../cli/lib/fix-actions'; import { readFileSync as read } from 'node:fs'; import { runScan } from '../cli/lib/scan'; import { appleRules, copyrightRules, playRules } from '../cli/lib/validate-rules'; import { FORBIDDEN_ARGS, TOOLS, toArgv } from '../cli/mcp-tools'; import { describeMethods, DESKTOP_LOCAL_WRITES, FORBIDDEN_METHOD_WORDS, Session } from '../cli/serve'; import { scanAsFindings } from '../cli/validate'; const FX = join(import.meta.dir, 'fixtures', 'scan'); const SECRET_HEX = Array.from({ length: 32 }, (_, i) => i.toString(16).padStart(2, '0')).join(''); const SECRET = Buffer.from(SECRET_HEX, 'hex'); const ENV = { REVIEW_DEMO_USER: '', REVIEW_DEMO_PASSWORD: '' }; describe('finding -> fix action', () => { test('scan: privacy-labels gets scan.write while the write would change the config; nothing else does', () => { expect(scanFixAction('privacy-labels', { configPath: '/x/storebound.config.ts', declarationsChanged: true })).toEqual({ method: 'scan.write', args: {} }); expect(scanFixAction('privacy-labels', { configPath: '/x/storebound.config.ts', declarationsChanged: false })).toBeUndefined(); expect(scanFixAction('privacy-labels', { configPath: null, declarationsChanged: true })).toBeUndefined(); for (const r of ['ats', 'play-fgs', 'export-compliance', 'demo-account', 'att', 'template-answers']) expect(scanFixAction(r, { configPath: '/x/c.ts', declarationsChanged: true })).toBeUndefined(); }); test('validate: listing text and review contact map to the push commands; screenshots, builds and compliance do not', () => { const version = { versionString: '1.2.0', state: 'PREPARE_FOR_SUBMISSION', copyright: null as string | null | undefined }; const base = { version, build: null, locales: [], expectedLocales: ['en-US'], requiredDisplayTypes: [], reviewDetail: null, configCopyright: '2026 Example', ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } }; const apple = appleRules(base as unknown as Parameters[0]); const by = (code: string) => apple.filter(f => f.code === code); expect(by('locale')[0]!.action).toEqual({ method: 'apple.listing', args: { version: '1.2.0' } }); expect(by('review-details')[0]!.action).toEqual({ method: 'apple.review_details', args: {} }); expect(by('build')[0]!.action).toBeUndefined(); expect(copyrightRules({ version, configCopyright: '2026 Example' })[0]!.action).toEqual({ method: 'apple.listing', args: { version: '1.2.0' } }); expect(copyrightRules({ version, configCopyright: undefined })[0]!.action).toBeUndefined(); // a version in review is not editable: no push const inReview = appleRules({ ...base, version: { ...version, state: 'WAITING_FOR_REVIEW' } } as unknown as Parameters[0]); expect(inReview.find(f => f.code === 'locale')!.action).toBeUndefined(); const play = playRules({ defaultLanguage: 'en-US', expectedLanguages: ['en-US'], listings: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, website: null, appAds: null, appAdsLines: [], adsLikely: false, tracks: [], products: [], expectedProducts: [] } as unknown as Parameters[0]); expect(play.filter(f => f.code === 'listing').map(f => f.action)).toEqual([{ method: 'play.listing', args: {} }, { method: 'play.listing', args: {} }]); expect(play.filter(f => f.code !== 'listing').some(f => f.action)).toBe(false); }); test('no fix reaches a submit, release, publish or an owner action; every fix method is a desktop write', () => { const ms = new Map(describeMethods().map(m => [m.method, m.kind])); for (const m of FIX_METHODS) { expect(m).not.toMatch(FORBIDDEN_METHOD_WORDS); expect(m.startsWith('owner.')).toBe(false); expect(ms.get(m)).toBe('write'); } // scan --write-only is desktop-only: the MCP server (AI tools) never produces it expect(FORBIDDEN_ARGS).toContain('--write-only'); expect(DESKTOP_LOCAL_WRITES.scan_write).toEqual(['--write-only']); for (const t of TOOLS) for (const s of [{}, { dry_run: false }]) expect(toArgv(t, s).argv).not.toContain('--write-only'); }); }); describe('Fix with AI rules', () => { test('only real scan rules that need a code change; never a compliance answer or a deterministic fix', () => { const src = ['rules.ts', 'play-rules.ts'].map(f => read(join(import.meta.dir, '..', 'cli', 'lib', 'scan', f), 'utf8')).join('\n'); const ids = new Set([...src.matchAll(/rule: '([a-z0-9-]+)'/g)].map(m => m[1]!)); for (const r of AI_FIX_RULES) expect({ r, known: ids.has(r) }).toEqual({ r, known: true }); for (const r of ['export-compliance', 'demo-account', 'template-answers', 'privacy-labels', 'play-health-connect', 'metadata-keywords']) expect(AI_FIX_RULES.has(r)).toBe(false); for (const r of SCAN_WRITE_RULES) expect(AI_FIX_RULES.has(r)).toBe(false); }); }); describe('scan.write round trip', () => { test('dry run shows the config diff and writes nothing; the confirmed run writes; the finding is gone', async () => { const dir = mkdtempSync(join(tmpdir(), 'sb-fix-')); const prev = { ...process.env }; Object.assign(process.env, ENV); try { cpSync(join(FX, 'android'), dir, { recursive: true }); const cfg = join(dir, 'storebound.config.ts'); const before = readFileSync(cfg, 'utf8'); const scan = await runScan(dir); const f = scan.report.findings.find(x => x.rule === 'privacy-labels' && !x.accepted)!; expect(f.action).toEqual({ method: 'scan.write', args: {} }); // validate carries the scan's action along expect(scanAsFindings([f], { apple: true, play: true })[0]!.action).toEqual(f.action); const s = new Session(() => {}); let id = 0; const call = async (method: string, params: unknown = {}) => (await s.handle({ jsonrpc: '2.0', id: ++id, method, params }))! as Record; await call('session.hello', { secret: SECRET_HEX, client: { name: 'test', version: '1' } }); const p = { project: dir, args: f.action!.args }; const dry = (await call(f.action!.method, p)).result as { ok: boolean; dryRun: boolean; output: string }; expect(dry).toMatchObject({ ok: true, dryRun: true }); expect(dry.output).toContain('+'); expect(dry.output).toContain('PSL_DEVICE_ID'); expect(readFileSync(cfg, 'utf8')).toBe(before); // without the host's token the real run is refused expect(((await call(f.action!.method, { ...p, dryRun: false })).error as { code: number }).code).toBeDefined(); expect(readFileSync(cfg, 'utf8')).toBe(before); const real = (await call(f.action!.method, { ...p, dryRun: false, confirmToken: mint(SECRET, f.action!.method, p) })).result as { ok: boolean }; expect(real.ok).toBe(true); expect(readFileSync(cfg, 'utf8')).toContain('PSL_DEVICE_ID: { shared: true'); // a fresh process (this one has the old config module cached) const again = JSON.parse(Bun.spawnSync(['bun', join(import.meta.dir, '..', 'cli', 'index.ts'), 'scan', '--dir', dir, '--json'], { env: { ...process.env, ...ENV } }).stdout.toString()) as { declarations: { changed: boolean }; findings: { rule: string }[] }; expect(again.declarations.changed).toBe(false); expect(again.findings.some(x => x.rule === 'privacy-labels')).toBe(false); } finally { for (const k of Object.keys(ENV)) if (prev[k] === undefined) delete process.env[k]; else process.env[k] = prev[k]; rmSync(dir, { recursive: true, force: true }); } }, 60_000); });