111 lines
8 KiB
TypeScript
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);
|
|
});
|