desktop: a Fix button on scan and validate findings storebound can fix itself. Findings carry action { method, args } (cli/lib/fix-actions.ts); privacy-labels runs scan.write (scan --write-only, the dialog shows the config diff), Play listing gaps play.listing, a missing App Store localization or copyright apple.listing (editable version only), the App Review contact apple.review_details; each a dry run, the native dialog, then the confirmed run. --write-only is forbidden for MCP tools; tests pin the mapping, the round trip on a fixture and that no fix reaches a submit, release or publish
This commit is contained in:
parent
bd83d036ba
commit
992f5bcc0e
18 changed files with 257 additions and 37 deletions
100
tests/fix-actions.test.ts
Normal file
100
tests/fix-actions.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// 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 { FIX_METHODS, scanFixAction } from '../cli/lib/fix-actions';
|
||||
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('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);
|
||||
});
|
||||
|
|
@ -6,7 +6,7 @@ import { canonical, mac, MAX_TTL_MS, mint, parseSecret, TokenGuard } from '../cl
|
|||
import { ERR } from '../cli/lib/desktop-protocol';
|
||||
import { childEnv, pickCallEnv, type RunOptions, type Running } from '../cli/lib/runner';
|
||||
import { FORBIDDEN_ARGS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
|
||||
import { describeMethods, DESKTOP_ONLY, DESKTOP_TOOLS, FORBIDDEN_METHOD_WORDS, JSON_TOOLS, methodName, OWNER_SUBMIT, OWNER_TOOLS, Session } from '../cli/serve';
|
||||
import { describeMethods, DESKTOP_LOCAL_WRITES, DESKTOP_ONLY, DESKTOP_TOOLS, FORBIDDEN_METHOD_WORDS, JSON_TOOLS, methodName, OWNER_SUBMIT, OWNER_TOOLS, Session } from '../cli/serve';
|
||||
import { buildOne, hostTarget, leaks } from '../tools/build-sidecars';
|
||||
|
||||
type J = Record<string, unknown>;
|
||||
|
|
@ -113,7 +113,8 @@ describe('serve --stdio: methods', () => {
|
|||
}
|
||||
expect(TOOLS.some(t => OWNER_SUBMIT.has(t.name) || t.name.startsWith('owner_'))).toBe(false);
|
||||
// every desktop tool still goes through the MCP rules: no forbidden flag can come out of any of them
|
||||
for (const t of DESKTOP_TOOLS) expect(t.command.some(c => FORBIDDEN_ARGS.includes(c))).toBe(false);
|
||||
for (const t of DESKTOP_TOOLS) expect(t.command.some(c => FORBIDDEN_ARGS.includes(c) && !DESKTOP_LOCAL_WRITES[t.name]?.includes(c))).toBe(false);
|
||||
expect(Object.keys(DESKTOP_LOCAL_WRITES)).toEqual(['scan_write']);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
test('typed descriptors for every tool; json flags only on read tools', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue