storebound/tests/template-answers.test.ts

115 lines
8.7 KiB
TypeScript

// Template compliance answers never reach a store: `storebound init` marks them unanswered, check blocks, play
// data-safety (also --dry), console plan, apple review-details / appinfo refuse; onboard replaces them with what the
// code proves (no advertising rows without an ad SDK, a real account answer with auth code) and the template identity
// with the store's. Plus the data-safety dry run's changed-rows diff.
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { parseArgs } from '../cli/lib/args';
import { loadConfig, type ShipConfig } from '../cli/lib/config';
import { dataSafetyDiff, dataSafetyRows } from '../cli/lib/data-safety';
import { resetDeclarations } from '../cli/lib/onboard-declarations';
import { removeKeyPath } from '../cli/lib/scan/config-edit';
import { detectProject } from '../cli/lib/scan/project';
import { templateAreas, templateRefusal } from '../cli/lib/template-answers';
import { run as init } from '../cli/init';
import { run as check } from '../cli/check';
import { changeLines, run as dataSafety } from '../cli/play/data-safety';
import { run as consolePlan } from '../cli/console/plan';
import { run as reviewDetails } from '../cli/apple/review-details';
import { capture } from './helpers/store-mock';
const tmp = () => mkdtempSync(join(tmpdir(), 'sb-tpl-'));
async function initProject(profile: string) {
const root = tmp();
await capture(() => init(parseArgs(['--dir', root, '--profile', profile])));
const loaded = await loadConfig(join(root, 'storebound.config.ts'));
return { root, loaded, ctx: (argv: string[]) => ({ loaded, args: parseArgs(argv) }) };
}
describe('template answers', () => {
test('init (every profile) marks the compliance answers unanswered and check blocks on them', async () => {
for (const profile of ['game', 'app', 'subscription-app']) {
const p = await initProject(profile);
expect(p.loaded.config.unanswered).toContain('dataSafety');
const areas = templateAreas(p.loaded.config).map(a => a.area);
expect(areas).toEqual(expect.arrayContaining(['identity', 'dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating']));
const r = await capture(() => check(p.ctx(['--json'])));
const out = JSON.parse(r.out.slice(r.out.indexOf('{\n'))) as { problems: string[] };
expect(out.problems.some(x => x.startsWith('Data safety / App Privacy still holds template answers') && x.includes('storebound scan --write'))).toBe(true);
expect(r.exit ?? process.exitCode).not.toBe(0);
}
});
test('play data-safety --dry, console plan apple-app-privacy and apple review-details refuse template answers', async () => {
const p = await initProject('game');
await expect(dataSafety(p.ctx(['--dry']))).rejects.toThrow(/refused: play data-safety --dry would send template answers.*Data safety.*Nothing was sent/);
await expect(dataSafety(p.ctx([]))).rejects.toThrow(/refused: play data-safety would send template answers/);
await expect(consolePlan(p.ctx(['apple-app-privacy']))).rejects.toThrow(/refused: console plan apple-app-privacy/);
await expect(reviewDetails(p.ctx(['--dry']))).rejects.toThrow(/refused: apple review-details.*App Review notes/);
});
test('a config from before `unanswered` is recognised by the template values; [] means answered', () => {
const legacy = { app: { name: 'DEMO BLAST', copyright: '2026 Example Studio' }, apple: { bundleId: 'x', info: { reviewNotes: 'mine', ageRating: { advertising: true } } },
dataSafety: { collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_NONE'], data: {
PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] },
PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] },
PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] } } } } as unknown as ShipConfig;
expect(templateAreas(legacy).map(a => a.area)).toEqual(['identity', 'dataSafety', 'appPrivacy', 'ageRating']);
expect(templateRefusal(legacy, ['appPrivacy'], 'x')).toContain('Data safety');
const answered = { ...legacy, app: { ...legacy.app, copyright: '2026 Someone' }, unanswered: [] } as ShipConfig;
expect(templateAreas(answered)).toEqual([]);
});
test('onboard --declarations: no ad SDK + auth code gives no advertising rows, a real account answer, no template identity', async () => {
const p = await initProject('game');
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'demo', dependencies: { firebase: '10.0.0' } }),
'src/auth.ts': "import { getAuth, signInWithEmailAndPassword, createUserWithEmailAndPassword } from 'firebase/auth';\nexport const login = (e: string, pw: string) => signInWithEmailAndPassword(getAuth(), e, pw);\nexport const signUp = (e: string, pw: string) => createUserWithEmailAndPassword(getAuth(), e, pw);\nexport const form = { email: e0 };\nconst e0 = '';\n",
};
for (const [f, t] of Object.entries(files)) { mkdirSync(dirname(join(p.root, f)), { recursive: true }); writeFileSync(join(p.root, f), t); }
const path = p.loaded.path;
const r = resetDeclarations({ config: p.loaded.config, text: readFileSync(path, 'utf8'), path, facts: detectProject(p.root),
store: { name: 'Real Name', copyright: '2026 Hidden Co', source: 'App Store Connect' }, terms: ['hidden co'] });
const out = join(p.root, 'after.config.ts'); // a new path: the first import of the config is cached
writeFileSync(out, r.text);
const after = (await loadConfig(out)).config;
const rows = Object.entries(after.dataSafety!.data);
expect(rows.some(([, u]) => u.collect.includes('advertising') || (u.share ?? []).includes('advertising'))).toBe(false);
expect(after.dataSafety!.data.PSL_APPROX_LOCATION).toBeUndefined();
expect(after.dataSafety!.accountCreation).toEqual(['PSL_ACM_USER_ID_PASSWORD']);
expect(after.accounts).toMatchObject({ enabled: true, signUp: ['email-password'] });
expect(after.app.name).toBe('Real Name');
expect(after.app.copyright).toBeUndefined(); // the store's copyright has a private term: left out
expect(r.text).not.toContain('Example Game');
expect(after.apple?.info?.reviewNotes).toBeUndefined();
expect(after.unanswered).toEqual(expect.arrayContaining(['dataSafety', 'appPrivacy', 'ageRating']));
expect(r.questions.some(q => q.startsWith('Age rating'))).toBe(true);
expect(r.questions.some(q => /encryptedInTransit/.test(q))).toBe(true);
// still refused until the owner confirms
expect(templateRefusal(after, ['dataSafety'], 'x')).not.toBeNull();
});
test('removeKeyPath drops a nested expression value and its comments', () => {
const t = "const c = {\n app: { name: 'a' },\n apple: {\n info: {\n // notes\n reviewNotes: ['a', 'b'].join('\\n\\n'),\n free: true,\n },\n },\n};\n";
expect(removeKeyPath(t, 'x.ts', ['apple', 'info', 'reviewNotes'])).toBe("const c = {\n app: { name: 'a' },\n apple: {\n info: {\n free: true,\n },\n },\n};\n");
});
test('data-safety dry run diff: old -> new per question, account answers first', () => {
const ds = { collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_NONE'], data: { PSL_DEVICE_ID: { shared: false, collect: ['analytics' as const] } } };
const before = dataSafetyRows(ds).map(r => r.join(',')).join('\n');
const next = { ...ds, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'], data: { PSL_CRASH_LOGS: { shared: false, collect: ['analytics' as const] } } };
const changes = dataSafetyDiff(before, dataSafetyRows(next, { deletionUrl: 'https://example.org/delete' }));
expect(changes.some(c => c.response === 'PSL_ACM_NONE' && c.from === 'TRUE' && c.to === '')).toBe(true);
expect(changes.some(c => c.response === 'PSL_ACM_USER_ID_PASSWORD' && c.to === 'TRUE')).toBe(true);
expect(changes.some(c => c.question.includes('PSL_DEVICE_ID') && c.from && !c.to)).toBe(true);
const lines = changeLines(changes);
expect(lines[0]).toBe(' account and deletion answers:');
expect(lines.join('\n')).toMatch(/PSL_ACM_NONE\]: TRUE -> \(empty\)/);
expect(dataSafetyDiff(null, dataSafetyRows(ds)).length).toBeGreaterThan(2);
});
});