storebound/tests/fix-actions.test.ts

111 lines
8 KiB
TypeScript

// 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<typeof appleRules>[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<typeof appleRules>[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<typeof playRules>[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<string, unknown>;
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);
});