// Desktop D3 write actions: every write the window offers is a dry run unless the host's confirm token for exactly // that method, project and arguments comes with it (single use, short-lived); owner actions (rollout share, phased // release, sending one approved reply) additionally answer the command's own confirmation, and only with such a token. import { afterEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { mint } from '../cli/lib/confirm-token'; import { confirm } from '../cli/lib/ctx'; import { ERR } from '../cli/lib/desktop-protocol'; import { parseDraft } from '../cli/lib/reviews'; import { setReplyText } from '../cli/lib/project-files'; import { childEnv, type RunOptions, type Running } from '../cli/lib/runner'; import { TOOLS } from '../cli/mcp-tools'; import { describeMethods, methodName, OWNER_ALLOWED_ARGS, OWNER_FORBIDDEN_ARGS, OWNER_REAL_ONLY, OWNER_SUBMIT, OWNER_TOOLS, Session } from '../cli/serve'; import { toArgv } from '../cli/mcp-tools'; type J = Record; const DEMO = join(import.meta.dir, '..', 'examples', 'demo-game'); const SECRET_HEX = Array.from({ length: 32 }, (_, i) => (i + 7).toString(16).padStart(2, '0')).join(''); const SECRET = Buffer.from(SECRET_HEX, 'hex'); async function session(now = () => Date.now()) { const calls: { argv: string[]; o: RunOptions }[] = []; const run = (argv: string[], o: RunOptions): Running => { calls.push({ argv, o }); return { result: Promise.resolve({ code: 0, out: 'done', cancelled: false }), cancel: () => {} }; }; const s = new Session(() => {}, run, now); let id = 0; const call = async (method: string, params: unknown = {}) => (await s.handle({ jsonrpc: '2.0', id: ++id, method, params }))! as J; await call('session.hello', { secret: SECRET_HEX }); return { calls, call }; } const err = (r: J) => r.error as { code: number; data?: J }; /** The writes the D3 screens offer, with arguments as the window sends them. */ const D3_WRITES: [string, J][] = [ ['play.listing', {}], ['apple.listing', { version: '2.4.0' }], ['play.images', { only: 'en-US' }], ['apple.screenshots', {}], ['apple.previews', {}], ['play.products', {}], ['apple.iap', {}], ['play.subscriptions', {}], ['apple.subscriptions', {}], ['apple.testflight_distribute', { build: '52', groups: 'Studio team' }], ['apple.testflight_what_to_test', { build: '52', text: 'New combo meter' }], ['reviews.reply', { file: 'play-r-p1.md', text: 'Thanks! The fix is in 2.4.0.' }], ['reviews.approve', { file: 'play-r-p1.md' }], ['shots.render', {}], ['shots.video', {}], ['shots.gallery', {}], ['icons', {}], ['owner.play_rollout', { action: 'percent', percent: 50 }], ['owner.play_rollout', { action: 'halt' }], ['owner.ios_phased', { action: 'pause', version: '2.3.0' }], ['owner.ios_phased', { action: 'resume' }], ['owner.reply_send', { file: 'play-r-p1.md' }], ['owner.testflight_testers', { groups: 'Friends and family' }], // full CLI coverage: price sheet, Data safety, app info, PPO, analytics ['owner.prices_import', {}], ['owner.prices_import', { csv: '/tmp/sheets/prices autumn.csv', store: 'play' }], ['prices.export', {}], ['play.data_safety', {}], ['apple.appinfo', {}], ['apple.review_details', {}], ['apple.encryption', {}], ['apple.ppo_create', { variants: 'b', traffic: 30 }], ['apple.ppo_upload', { variant: 'b', experiment: 'storebound b' }], ['owner.ppo_start', { experiment: 'storebound b' }], ['owner.ppo_stop', {}], ['analytics.snapshot', {}], ['analytics.setup', {}], // submit for review, resubmit, IAPs, the Play production release, price changes of existing products (owner actions) ['owner.apple_submit', { build: '52' }], ['owner.apple_submit', { build: '52', version: '2.4.0', resolve_iaps: true }], ['owner.apple_resubmit', { build: '53' }], ['owner.apple_resubmit', { build: '53', resolve_iaps: true }], ['owner.apple_iap_submit', { with_version: true }], ['owner.apple_iap_submit', { only: 'coins_100' }], ['owner.play_production', { version_code: '24' }], ['owner.play_production', { version_code: '24', status: 'inProgress', percent: 10 }], ['owner.play_production', { version_code: '24', status: 'draft' }], ['owner.prices_play_products', {}], ['owner.prices_play_subscriptions', {}], ['owner.prices_apple_subscriptions', { only: 'pro_monthly' }], ['apple.testflight_groups', {}], // the editors: save into the app folder (local files), then the store writes they apply ['project.edit', { ops: JSON.stringify([{ kind: 'listing', store: 'apple', lang: 'en-US', field: 'subtitle', value: 'Lists that stay tidy' }]) }], ['apple.testflight_info', {}], ['apple.iap_review', {}], ['apple.cpp_create', {}], ['apple.events_create', {}], ['play.listing_pull', {}], ['apple.listing_pull', {}], ]; describe('D3 writes: token binding for every method the window offers', () => { for (const [method, args] of D3_WRITES) { test(`${method} ${JSON.stringify(args)}`, async () => { let now = 1_000_000; const s = await session(() => now); const p = { project: DEMO, args }; // dry run by default, and without a token const dry = await s.call(method, p); expect(err(dry)).toBeUndefined(); expect(s.calls.at(-1)!.argv).toContain('--dry'); expect(s.calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBeUndefined(); expect(err(await s.call(method, { ...p, dryRun: false })).code).toBe(ERR.confirmRequired); // a token for other arguments, another method or another project is refused const other = method === 'play.listing' ? 'play.images' : 'play.listing'; expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(SECRET, other, p, { now }) })).code).toBe(ERR.confirmInvalid); expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(SECRET, method, { project: '/elsewhere', args }, { now }) })).code).toBe(ERR.confirmInvalid); expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(SECRET, method, { project: DEMO, args: { ...args, x: 1 } }, { now }) })).code).toBe(ERR.confirmInvalid); expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(Buffer.alloc(32, 1), method, p, { now }) })).code).toBe(ERR.confirmInvalid); // the right token runs it for real, once const n = s.calls.length; const token = mint(SECRET, method, p, { now }); const real = await s.call(method, { ...p, dryRun: false, confirmToken: token, credentials: { PLAY_SERVICE_ACCOUNT: '{}' } }); expect(err(real)).toBeUndefined(); expect((real.result as J).dryRun).toBe(false); const c = s.calls.at(-1)!; expect(s.calls.length).toBe(n + 1); expect(c.argv).not.toContain('--dry'); expect(c.o.env?.PLAY_SERVICE_ACCOUNT).toBe('{}'); expect(c.o.env?.STOREBOUND_OWNER_CONFIRMED).toBe(method.startsWith('owner.') ? '1' : undefined); expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: token })).data).toEqual({ reason: 'replayed' }); // and expires const late = mint(SECRET, method, p, { now, ttlMs: 1000 }); now += 1000; expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: late })).data).toEqual({ reason: 'expired' }); }); } }); describe('owner actions', () => { test('exactly these, desktop only, kind owner, never a submit / release request / cancel / external TestFlight flag', () => { expect(OWNER_TOOLS.map(t => methodName(t.name)).sort()).toEqual(['owner.apple_iap_submit', 'owner.apple_resubmit', 'owner.apple_submit', 'owner.ios_phased', 'owner.play_production', 'owner.play_rollout', 'owner.ppo_start', 'owner.ppo_stop', 'owner.prices_apple_subscriptions', 'owner.prices_import', 'owner.prices_play_products', 'owner.prices_play_subscriptions', 'owner.reply_send', 'owner.testflight_testers']); for (const n of OWNER_SUBMIT) expect(OWNER_TOOLS.some(t => t.name === n)).toBe(true); for (const t of OWNER_TOOLS) expect(TOOLS.some(x => x.name === t.name)).toBe(false); const ds = describeMethods().filter(m => m.method.startsWith('owner.')); for (const d of ds) expect(d.kind).toBe('owner'); const samples: J[] = [ { action: 'percent', percent: 5 }, { action: 'halt' }, { action: 'resume', percent: 10 }, { action: 'complete', track: 'beta' }, { action: 'on' }, { action: 'pause', version: '1.2.3' }, { action: 'resume' }, { action: 'complete' }, { file: 'apple-123.md' }, {}, { groups: 'Studio team,Friends' }, { build: '7' }, { build: '7', resolve_iaps: true }, { with_version: true }, { version_code: '9', status: 'inProgress', percent: 5 }, { only: 'coins_100' }, ]; for (const t of OWNER_TOOLS) for (const s of samples) for (const dry of [true, false]) { let argv: string[]; try { argv = toArgv(t, { ...s, dry_run: dry }).argv; } catch { continue; } // a forbidden flag only in the one owner action it belongs to, and the owner-only ones never in a dry run const allowed = OWNER_ALLOWED_ARGS[t.name] ?? []; for (const a of argv) if (!allowed.includes(a)) expect(OWNER_FORBIDDEN_ARGS).not.toContain(a); if (dry) for (const a of OWNER_REAL_ONLY) expect(argv).not.toContain(a); } // every allowed flag belongs to exactly the actions named, and the submit ones are OWNER_SUBMIT expect(Object.keys(OWNER_ALLOWED_ARGS).filter(n => OWNER_ALLOWED_ARGS[n]!.some(a => ['--yes', 'submit', '--production'].includes(a))).sort()).toEqual(['owner_apple_resubmit', 'owner_apple_submit', 'owner_play_production']); }); test('argv: rollout share in percent -> fraction, halt / resume / complete, track; phased actions; one reply', () => { const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!; expect(toArgv(t('owner_play_rollout'), { action: 'percent', percent: 20, dry_run: false }).argv).toEqual(['play', 'rollout', '--fraction', '0.2', '--track', 'production']); expect(toArgv(t('owner_play_rollout'), { action: 'halt' }).argv).toEqual(['play', 'rollout', '--halt', '--track', 'production', '--dry']); expect(toArgv(t('owner_play_rollout'), { action: 'resume', percent: 50, dry_run: false }).argv).toEqual(['play', 'rollout', '--resume', '--fraction', '0.5', '--track', 'production']); expect(toArgv(t('owner_play_rollout'), { action: 'complete', percent: 50, track: 'beta', dry_run: false }).argv).toEqual(['play', 'rollout', '--complete', '--track', 'beta']); expect(toArgv(t('owner_ios_phased'), { action: 'resume', version: '2.3.0', dry_run: false }).argv).toEqual(['apple', 'release', '--phased', 'resume', '--version', '2.3.0']); expect(toArgv(t('owner_reply_send'), { file: 'play-r1.md', dry_run: false }).argv).toEqual(['reviews', 'send', '--file', 'play-r1.md']); expect(toArgv(t('owner_testflight_testers'), { groups: 'Studio team', dry_run: false }).argv).toEqual(['apple', 'testflight', 'testers', '--group', 'Studio team']); expect(toArgv(t('owner_testflight_testers'), {}).argv).toEqual(['apple', 'testflight', 'testers', '--dry']); }); test('bad arguments never reach a command', async () => { const s = await session(); for (const [m, args] of [['owner.play_rollout', { action: 'manual' }], ['owner.play_rollout', { action: 'percent', percent: 100 }], ['owner.play_rollout', { action: 'percent', percent: 0.5 }], ['owner.ios_phased', { action: 'release' }], ['owner.ios_phased', { action: 'on', version: '--manual-release' }], ['owner.reply_send', { file: '../../etc/passwd' }], ['owner.reply_send', {}], ['reviews.reply', { file: 'play-1.md', text: '--yes' }], ['owner.prices_import', { csv: '--yes.csv' }], ['owner.prices_import', { csv: 'prices.txt' }], ['owner.prices_import', { only: 'a,--yes' }], ['owner.ppo_stop', { experiment: '--yes' }], ['project.render', { file: '../../etc/passwd.png' }], ['project.render', { file: '/etc/x.png' }], ['project.render', { file: 'en/ios69/01.txt' }], ['reviews.reply', { file: 'play-1.md', text: 'x'.repeat(6001) }]] as [string, J][]) expect(err(await s.call(m, { project: DEMO, args })).code).toBe(ERR.invalidParams); expect(s.calls.length).toBe(0); }); }); describe('submit for review from the desktop: owner actions only, build by number', () => { const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!; test('argv: dry run first; --yes / --allow-price-changes only in the real run', () => { expect(toArgv(t('owner_apple_submit'), { build: '52' }).argv).toEqual(['apple', 'submit', '--build', '52', '--dry']); expect(toArgv(t('owner_apple_submit'), { build: '52', version: '2.4.0', dry_run: false }).argv).toEqual(['apple', 'submit', '--build', '52', '--version', '2.4.0', '--yes']); expect(toArgv(t('owner_apple_resubmit'), { build: '53', resolve_iaps: true }).argv).toEqual(['apple', 'resubmit', '--build', '53', '--resolve-iaps', '--dry']); expect(toArgv(t('owner_apple_resubmit'), { build: '53', dry_run: false }).argv).toEqual(['apple', 'resubmit', '--build', '53', '--yes']); expect(toArgv(t('owner_apple_iap_submit'), { with_version: true, dry_run: false }).argv).toEqual(['apple', 'iap-submit', '--with-version']); expect(toArgv(t('owner_play_production'), { version_code: '24', status: 'inProgress', percent: 10, dry_run: false }).argv).toEqual(['play', 'release', '--version-code', '24', '--production', 'inProgress', '--fraction', '0.1']); expect(toArgv(t('owner_play_production'), { version_code: '24' }).argv).toEqual(['play', 'release', '--version-code', '24', '--production', 'completed', '--dry']); expect(toArgv(t('owner_prices_play_subscriptions'), { dry_run: false }).argv).toEqual(['play', 'subscriptions', '--allow-price-changes']); expect(toArgv(t('owner_prices_play_products'), { only: 'coins_100', dry_run: false }).argv).toEqual(['play', 'products', '--only', 'coins_100', '--allow-price-changes']); expect(toArgv(t('owner_prices_apple_subscriptions'), {}).argv).toEqual(['apple', 'subscriptions', '--dry']); }); test('builds are numbers, never "latest"; bad values never reach a command', async () => { const s = await session(); for (const [m, args] of [['owner.apple_submit', {}], ['owner.apple_submit', { build: 'latest' }], ['owner.apple_submit', { build: '--yes' }], ['owner.apple_resubmit', { build: '7', version: 'x' }], ['owner.play_production', {}], ['owner.play_production', { version_code: 'latest' }], ['owner.play_production', { version_code: '3', status: 'halted' }], ['owner.play_production', { version_code: '3', name: '--yes' }], ['owner.prices_play_products', { only: '--yes' }], ['owner.prices_apple_subscriptions', { target: 'apple_iap' }], ['owner.apple_iap_submit', { only: 'a,--yes' }]] as [string, J][]) expect(err(await s.call(m, { project: DEMO, args })).code).toBe(ERR.invalidParams); expect(s.calls.length).toBe(0); }); test('the real run carries --yes and the owner flag only with the host token; without it, a dry run or refused', async () => { let now = 2_000_000; const s = await session(() => now); const p = { project: DEMO, args: { build: '52' } }; await s.call('owner.apple_submit', p); expect(s.calls.at(-1)!.argv).toEqual(['apple', 'submit', '--build', '52', '--dry', '--json']); expect(s.calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBeUndefined(); expect(err(await s.call('owner.apple_submit', { ...p, dryRun: false })).code).toBe(ERR.confirmRequired); await s.call('owner.apple_submit', { ...p, dryRun: false, confirmToken: mint(SECRET, 'owner.apple_submit', p, { now }) }); expect(s.calls.at(-1)!.argv).toEqual(['apple', 'submit', '--build', '52', '--yes', '--json']); expect(s.calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBe('1'); // a write method is never a way to submit: the MCP-shaped tools have no submit, and the iap plan stays --dry expect(err(await s.call('apple.iap_review_plan', { project: DEMO, dryRun: false }))).toBeUndefined(); expect(s.calls.at(-1)!.argv).toContain('--dry'); }); }); describe('full CLI coverage: new methods', () => { test('argv of the price sheet, PPO start / stop and the offline reads', async () => { const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!; // the price sheet's real run says --allow-price-changes itself (the owner pressed the dialog); its dry run never expect(toArgv(t('owner_prices_import'), { csv: '/tmp/p.csv', dry_run: false }).argv).toEqual(['prices', 'import', '--csv', '/tmp/p.csv', '--allow-price-changes']); expect(toArgv(t('owner_prices_import'), { csv: '/tmp/p.csv' }).argv).toEqual(['prices', 'import', '--csv', '/tmp/p.csv', '--dry']); expect(toArgv(t('owner_ppo_start'), { experiment: 'storebound b' }).argv).toEqual(['apple', 'ppo', 'start', '--experiment', 'storebound b', '--dry']); expect(toArgv(t('owner_ppo_stop'), { dry_run: false }).argv).toEqual(['apple', 'ppo', 'stop']); const s = await session(); for (const [m, args, argv] of [ ['project.declarations', {}, ['inspect', '--declarations', '--json']], ['project.listing_text', {}, ['inspect', '--listing-text', '--json']], ['project.renders', { lang: 'en' }, ['shots', 'gallery', '--lang', 'en', '--json']], ['project.render', { file: 'b/en/ios69/01.png' }, ['inspect', '--render', 'b/en/ios69/01.png', '--json']], ['prices.current', { with_automatic: true }, ['prices', 'export', '--with-automatic', '--json']], ['prices.diff', { csv: '/tmp/p.csv' }, ['prices', 'import', '--csv', '/tmp/p.csv', '--dry', '--json']], ] as [string, J, string[]][]) { expect(err(await s.call(m, { project: DEMO, args }))).toBeUndefined(); expect(s.calls.at(-1)!.argv).toEqual(argv); } // the fastlane import runs only on a folder without a config, and never through write_with_confirm's project list expect(err(await s.call('import.fastlane', { project: DEMO, args: {} })).code).toBe(ERR.invalidParams); const empty = mkdtempSync(join(tmpdir(), 'sb-fl-')); expect(err(await s.call('import.fastlane', { project: empty, args: {} }))).toBeUndefined(); expect(s.calls.at(-1)!.argv).toEqual(['import', 'fastlane', '--dry']); }); }); describe('the owner flag', () => { const saved = { ...process.env }; afterEach(() => { for (const k of ['STOREBOUND_SERVE', 'STOREBOUND_OWNER_CONFIRMED']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }); const ctx = { loaded: {} as never, args: { has: (n: string) => n === 'yes', get: () => undefined, str: (_: string, d: string) => d, num: (_: string, d: number) => d, list: () => [], many: () => [], _: [] } }; test('under serve: no by default, even with --yes; yes only with the owner flag', async () => { process.env.STOREBOUND_SERVE = '1'; delete process.env.STOREBOUND_OWNER_CONFIRMED; expect(await confirm(ctx, 'Resume?')).toBe(false); process.env.STOREBOUND_OWNER_CONFIRMED = '1'; expect(await confirm(ctx, 'Resume?')).toBe(true); }); test('a child never inherits it; it comes only per call', () => { process.env.STOREBOUND_OWNER_CONFIRMED = '1'; expect(childEnv({}, true).STOREBOUND_OWNER_CONFIRMED).toBeUndefined(); expect(childEnv({ STOREBOUND_OWNER_CONFIRMED: '1' }, true).STOREBOUND_OWNER_CONFIRMED).toBe('1'); }); }); describe('reply drafts: write the reply', () => { const draft = ['---', 'store: play', 'review_id: r-p1', 'rating: 2', 'approved: true', 'sent: null', '---', '# ★★☆☆☆ Crashes', '', '> Game closes at a 20x combo.', '', '## Reply', '', '', '', 'Old text', ''].join('\n'); test('replaces the reply, keeps the review, resets the approval', () => { const next = setReplyText(draft, ' Sorry! Fixed in 2.4.0.\r\nThanks for the level number. '); const d = parseDraft('/x/play-r-p1.md', next); expect(d.reply).toBe('Sorry! Fixed in 2.4.0.\nThanks for the level number.'); expect(d.approved).toBe(false); expect(next).toContain('> Game closes at a 20x combo.'); expect(next).not.toContain('Old text'); expect(parseDraft('/x', setReplyText(next, 'Again ')).reply).toBe('Again'); }); test('sent drafts and other files are not edited', () => { expect(() => setReplyText(draft.replace('sent: null', 'sent: 2026-10-01T00:00:00Z'), 'x')).toThrow(); expect(() => setReplyText('# not a draft', 'x')).toThrow(); }); test('CLI: reviews reply writes one draft, dry run first', async () => { const dir = mkdtempSync(join(tmpdir(), 'sb-reply-')); const cfg = readFileSync(join(DEMO, 'storebound.config.ts'), 'utf8'); writeFileSync(join(dir, 'storebound.config.ts'), cfg); const replies = join(dir, '.storebound', 'replies'); await Bun.$`mkdir -p ${replies}`.quiet(); writeFileSync(join(replies, 'play-r-p1.md'), draft); const cli = join(import.meta.dir, '..', 'cli', 'index.ts'); const dry = await Bun.$`bun ${cli} reviews reply play-r-p1.md --text ${'New reply'} --dry`.cwd(dir).quiet().nothrow(); expect(dry.exitCode).toBe(0); expect(readFileSync(join(replies, 'play-r-p1.md'), 'utf8')).toBe(draft); const real = await Bun.$`bun ${cli} reviews reply play-r-p1.md --text ${'New reply'}`.cwd(dir).quiet().nothrow(); expect(real.exitCode).toBe(0); expect(parseDraft('x', readFileSync(join(replies, 'play-r-p1.md'), 'utf8'))).toMatchObject({ reply: 'New reply', approved: false }); const outside = await Bun.$`bun ${cli} reviews reply ../../x.md --text y`.cwd(dir).quiet().nothrow(); expect(outside.exitCode).not.toBe(0); }); });