115 lines
8.7 KiB
TypeScript
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);
|
|
});
|
|
});
|