storebound/tests/report.test.ts

150 lines
10 KiB
TypeScript

// storebound report: the shared renderer (grouping, order, links, escaping, masking) and the command (file written,
// --no-open, --md, --json, validate skipped without credentials, no fixture secret in any output).
import { describe, expect, test } from 'bun:test';
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildReport, maskSecrets, renderHtml, renderMarkdown, summaryText } from '../cli/lib/report';
import { shouldOpen } from '../cli/report';
const ROOT = join(import.meta.dir, '..');
const PEM = '-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgFIXTURE000000000000\n-----END PRIVATE KEY-----';
const SECRETS = { keyId: 'K2X9Q7W4Z1', issuer: '00000000-0000-0000-0000-000000000000', demoUser: 'reviewer.fixture@mail.example.com', demoPass: 'Fixture-Pass-9931', contact: 'owner.fixture@mail.example.com' };
const fixture = () => buildReport({
app: { name: 'Fixture <App>', version: '1.2.0', build: '42' }, stores: ['apple', 'play'], storeRead: { apple: true, play: 'no Play service account' },
generatedAt: '2026-10-18T10:00:00Z', storebound: '0.0.0-test', secrets: [SECRETS.demoPass],
scan: {
findings: [
{ rule: 'att', guideline: '5.1.2', store: 'apple', severity: 'medium', title: 'Tracking without ATT', evidence: [{ file: 'ios/App/Info.plist', line: 12 }], fix: 'Add the ATT prompt', skill: 'privacy-manifest' },
{ rule: 'demo-account', guideline: '2.1', store: 'apple', severity: 'blocker', title: 'No demo account <script>alert(1)</script>', evidence: [], fix: `Use ${SECRETS.demoUser} / ${SECRETS.demoPass}`, url: 'https://appstoreconnect.apple.com/apps/123/distribution/reviewsubmissions' },
{ rule: 'privacy-labels', guideline: 'Data safety', store: 'play', severity: 'high', title: 'Labels miss DEVICE_ID', evidence: [{ file: 'android/app/build.gradle', line: 30 }], fix: 'Declare it', url: 'javascript:alert(1)' },
{ rule: 'privacy-labels', guideline: 'Data safety', store: 'apple', severity: 'high', title: 'Labels miss DEVICE_ID', evidence: [{ file: 'android/app/build.gradle', line: 30 }], fix: 'Declare it' },
{ rule: 'accepted-one', guideline: 'x', store: 'play', severity: 'blocker', title: 'Accepted by the owner', evidence: [], fix: 'none', accepted: 'fine' },
{ rule: 'obfuscator', guideline: '2.3.1', store: 'play', severity: 'low', title: 'Obfuscator found', evidence: [], fix: 'Mention it' },
],
declarations: { items: [{ area: 'dataSafety', what: 'encryptedInTransit', value: '?', status: 'ask' }, { area: 'dataSafety', what: 'PSL_NAME', value: 'yes', status: 'detected' }] },
},
check: { ok: false, problems: [`key ${SECRETS.keyId} issuer ${SECRETS.issuer} in ${SECRETS.contact}`], warnings: ['play listing has no section for de-DE'], hints: [], items: [{ what: 'appstore listing', detail: '1 locales' }] },
validate: { blockers: 1, findings: [{ store: 'apple', level: 'blocker', code: 'build', msg: 'No build attached', fix: 'storebound apple upload' }, { store: 'apple', level: 'ok', code: 'name', msg: 'Name set' }] },
});
describe('report renderer', () => {
test('groups by store, merges both stores, orders by severity, drops accepted findings', () => {
const r = fixture();
const both = r.findings.filter(f => f.store === 'both');
expect(both.map(f => f.title)).toContain('Labels miss DEVICE_ID');
expect(r.findings.some(f => f.title === 'Accepted by the owner')).toBe(false);
const apple = r.findings.filter(f => f.store === 'apple').map(f => f.level);
expect(apple).toEqual([...apple].sort((a, b) => ['blocker', 'warn', 'info', 'ok'].indexOf(a) - ['blocker', 'warn', 'info', 'ok'].indexOf(b)));
expect(r.findings[0]!.level).toBe('blocker');
expect(r.summary.apple.blockers).toBe(2);
expect(r.summary.play.warnings).toBe(1);
expect(r.questions.map(q => q.text)).toContain('encryptedInTransit: ?');
expect(summaryText(r)).toContain('App Store: 2 blockers');
expect(summaryText(r)).toContain('Google Play: Not checked (no credentials)');
});
test('html: both store columns, console links, evidence, no script, escaped text, unsafe urls dropped', () => {
const html = renderHtml(fixture());
expect(html).toContain('<h2>App Store</h2>');
expect(html).toContain('<h2>Google Play</h2>');
expect(html).toContain('href="https://appstoreconnect.apple.com/apps/123/distribution/reviewsubmissions"');
expect(html).toContain('Open in App Store Connect');
expect(html).toContain('<code>ios/App/Info.plist:12</code>');
expect(html).toContain('5.1.2');
expect(html).toContain('Questions only you can answer');
expect(html).toContain('prefers-color-scheme:dark');
expect(html).toContain('@media print');
expect(html).not.toContain('<script');
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(html).toContain('Fixture &lt;App&gt;');
expect(html).not.toContain('javascript:');
expect(html).not.toMatch(/(src|href)="(?!https:\/\/|data:image)/);
expect(html).toContain('storebound 0.0.0-test');
});
test('no fixture secret reaches html, markdown or json', () => {
const r = fixture();
for (const out of [renderHtml(r), renderMarkdown(r), JSON.stringify(r)]) for (const s of Object.values(SECRETS)) expect(out).not.toContain(s);
});
test('maskSecrets: keys, tokens, emails; example.com and plain words kept', () => {
const m = maskSecrets(`${PEM} eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJmaXh0dXJlIn0.c2lnbmF0dXJlZml4dHVyZQ ${SECRETS.keyId} ${SECRETS.issuer} a@mail.example.com support@example.com ageRating NSPrivacyAccessedAPICategoryUserDefaults ITMS-91053`);
expect(m).not.toContain('BEGIN PRIVATE KEY');
expect(m).not.toContain('eyJhbGci');
expect(m).not.toContain(SECRETS.keyId);
expect(m).not.toContain(SECRETS.issuer);
expect(m).not.toContain('a@mail.example.com');
expect(m).toContain('support@example.com');
expect(m).toContain('NSPrivacyAccessedAPICategoryUserDefaults');
expect(m).toContain('ITMS-91053');
});
test('markdown: summary per store, findings tables, checklist', () => {
const md = renderMarkdown(fixture());
expect(md).toContain('| App Store | 2 blockers |');
expect(md).toContain('## Google Play');
expect(md).toContain('[open](https://appstoreconnect.apple.com/apps/123/distribution/reviewsubmissions)');
expect(md).toContain('- [ ] **Google Play:** encryptedInTransit: ?');
});
test('shouldOpen: never with --no-open, --json, without a terminal, in CI or under serve', () => {
const args = (...f: string[]) => ({ has: (k: string) => f.includes(k) });
expect(shouldOpen(args(), {}, true)).toBe(true);
expect(shouldOpen(args('no-open'), {}, true)).toBe(false);
expect(shouldOpen(args('json'), {}, true)).toBe(false);
expect(shouldOpen(args(), {}, false)).toBe(false);
expect(shouldOpen(args(), { CI: 'true' }, true)).toBe(false);
expect(shouldOpen(args(), { STOREBOUND_SERVE: '1' }, true)).toBe(false);
});
});
describe('storebound report command', () => {
const tmp = mkdtempSync(join(tmpdir(), 'sb-report-'));
const dir = join(tmp, 'app');
cpSync(join(ROOT, 'tests/fixtures/scan/clean'), dir, { recursive: true });
mkdirSync(join(dir, '.storebound'), { recursive: true });
const env = { ...process.env, HOME: join(tmp, 'home'), STOREBOUND_CREDENTIALS_DIR: join(tmp, 'home', 'creds'), CI: '', ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', REVIEW_DEMO_USER: '', REVIEW_DEMO_PASSWORD: '' };
const cli = (args: string[], e: Record<string, string | undefined> = env) => Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'report', ...args], { cwd: dir, stdout: 'pipe', stderr: 'pipe', env: e });
test('writes the html, skips validate without credentials and says why, --no-open', () => {
const r = cli(['--no-open']);
expect(r.exitCode).toBe(0);
const files = readdirSync(join(dir, '.storebound/reports'));
expect(files.some(f => /^readiness-\d{4}-\d\d-\d\d\.html$/.test(f))).toBe(true);
const html = readFileSync(join(dir, '.storebound/reports', files.find(f => f.endsWith('.html'))!), 'utf8');
expect(html).toContain('Clean Notes');
expect(html).toContain('Not checked (no credentials)');
const err = r.stderr.toString() + r.stdout.toString();
expect(err).toContain('no App Store Connect API key');
expect(err).toContain('no service account');
expect(err).not.toMatch(/opened|xdg-open/);
});
test('--json prints the data with the file path; --md writes markdown to --out', () => {
const j = cli(['--json', '--offline']);
const data = JSON.parse(j.stdout.toString()) as { file: string; path: string; summary: Record<string, unknown>; sources: { validate: { ran: boolean; note?: string } } };
expect(existsSync(data.file)).toBe(true);
expect(data.path).toMatch(/^\.storebound\/reports\/readiness-.*\.html$/);
expect(Object.keys(data.summary)).toEqual(expect.arrayContaining(['apple', 'play']));
expect(data.sources.validate).toEqual({ ran: false, note: '--offline' });
const out = join(tmp, 'r.md');
expect(cli(['--md', '--no-open', '--offline', '--out', out]).exitCode).toBe(0);
expect(readFileSync(out, 'utf8')).toContain('# Clean Notes: store readiness');
});
test('no secret of the project credentials in the html or json', () => {
writeFileSync(join(dir, '.storebound/credentials.json'), JSON.stringify({
ASC_KEY_ID: SECRETS.keyId, ASC_ISSUER_ID: SECRETS.issuer, ASC_P8: PEM,
reviewDemoAccount: { username: SECRETS.demoUser, password: SECRETS.demoPass },
reviewContact: { firstName: 'Fixture', lastName: 'Owner', phone: '+10000000000', email: SECRETS.contact },
}));
const out = join(tmp, 'secret.html');
const r = cli(['--no-open', '--offline', '--out', out]);
expect(r.exitCode).toBe(0);
const j = cli(['--json', '--offline', '--out', join(tmp, 'secret2.html')]).stdout.toString();
for (const text of [readFileSync(out, 'utf8'), j, r.stdout.toString() + r.stderr.toString()])
for (const s of [...Object.values(SECRETS), 'BEGIN PRIVATE KEY', '+10000000000']) expect(text).not.toContain(s);
});
});