// Offline guard for App Store Connect GETs (pitfall: a filter App Store Connect refuses fails only at run time, with a // 400 in the middle of a release). Walks every App Store Connect path with a query string that storebound's code builds // (template literals "/v1/...?...", including the URLSearchParams objects they interpolate), resolves it to the // operation in cli/api/apple.gen.ts (generated from Apple's OpenAPI spec) and checks every query parameter against the // operation's declared parameters and against APPLE_LIVE_REJECTED (declared, but refused live). import { describe, expect, test } from 'bun:test'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import { APPLE_LIVE_REJECTED, buildRequest, getOp, resolveOp } from '../cli/api/client'; const ROOT = join(import.meta.dir, '..'); /** Expressions that name a resource type in a path (`/v1/apps/${appId}/${kind}?...`), per file: every combination the * callers pass, so each one is checked as its own GET. A new caller with another type adds a row here. */ const SEGMENT_VALUES: Record[]> = { 'cli/triage.ts': [{ kind: 'betaFeedbackCrashSubmissions' }, { kind: 'betaFeedbackScreenshotSubmissions' }], 'cli/apple/asc.ts': [{ 'o.set.type': 'appScreenshotSets', asset: 'appScreenshots' }, { 'o.set.type': 'appPreviewSets', asset: 'appPreviews' }], 'cli/apple/screenshots.ts': [{ 'parent.type': 'appStoreVersionLocalizations' }, { 'parent.type': 'appCustomProductPageLocalizations' }, { 'parent.type': 'appStoreVersionExperimentTreatmentLocalizations' }], }; function files(dir: string): string[] { return readdirSync(dir).flatMap(n => { const p = join(dir, n); if (statSync(p).isDirectory()) return files(p); return /\.ts$/.test(n) && !/\.gen\.ts$/.test(n) ? [p] : []; }); } /** Index just after the string / template literal that opens at `i`. */ function literalEnd(s: string, i: number): number { const q = s[i]!; for (let j = i + 1; j < s.length; j++) { const c = s[j]; if (c === '\\') { j++; continue; } if (c === q) return j + 1; if (q === '`' && c === '$' && s[j + 1] === '{') j = exprEnd(s, j + 2) - 1; } return s.length; } /** Index just after the `}` that closes an expression starting at `i`. */ function exprEnd(s: string, i: number): number { let depth = 1; for (let j = i; j < s.length; j++) { const c = s[j]!; if (c === '\'' || c === '"' || c === '`') { j = literalEnd(s, j) - 1; continue; } if (c === '{') depth++; else if (c === '}' && --depth === 0) return j + 1; } return s.length; } /** Top-level keys of an object literal's body ("'filter[app]': appId, include: 'x'"). */ function objectKeys(body: string): string[] { const parts: string[] = []; let depth = 0, from = 0; for (let j = 0; j < body.length; j++) { const c = body[j]!; if (c === '\'' || c === '"' || c === '`') { j = literalEnd(body, j) - 1; continue; } if ('([{'.includes(c)) depth++; else if (')]}'.includes(c)) depth--; else if (c === ',' && depth === 0) { parts.push(body.slice(from, j)); from = j + 1; } } parts.push(body.slice(from)); return parts.map(p => /^\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_$][\w$]*))\s*:/.exec(p)).filter(m => !!m).map(m => m![1] ?? m![2] ?? m![3]!); } /** Keys of `new URLSearchParams({...})` starting at `i` (index of "new"). */ function paramsObjectKeys(s: string, i: number): string[] { const open = s.indexOf('{', i); return objectKeys(s.slice(open + 1, exprEnd(s, open + 1) - 1)); } interface Site { file: string; line: number; path: string; keys: string[] } function sites(): Site[] { const out: Site[] = []; for (const f of files(join(ROOT, 'cli'))) { const src = readFileSync(f, 'utf8'); const rel = relative(ROOT, f); for (const m of src.matchAll(/[`'"](?=\/v[1-3]\/[^`'"\s]*\?)/g)) { const start = m.index!; const lit = src.slice(start + 1, literalEnd(src, start) - 1); const qAt = lit.indexOf('?'); // the path: every ${...} is one segment (an id) const rawPath = lit.slice(0, qAt); const query = lit.slice(qAt + 1); const keys = [...query.matchAll(/(?:^|[&?])([A-Za-z]\w*(?:\[[\w.$]+\])?)=/g)].map(x => x[1]!); // fields[${kind}] style keys keep their ${...} for the segment values below keys.push(...[...query.matchAll(/(?:^|[&?])([A-Za-z]\w*\[\$\{\w+\}\])=/g)].map(x => x[1]!)); // the whole query from a URLSearchParams: inline, or a variable defined before (plus its .set / .append calls) const whole = /^\$\{(.*)\}$/s.exec(query); if (whole) { const expr = whole[1]!.trim(); if (expr.startsWith('new URLSearchParams(')) keys.push(...paramsObjectKeys(lit, lit.indexOf('new URLSearchParams(', qAt))); else { const v = /^(\w+)(?:\.toString\(\))?$/.exec(expr)?.[1]; if (!v) throw new Error(`${rel}: cannot read the query of ${lit}`); const def = [...src.slice(0, start).matchAll(new RegExp(`(?:const|let)\\s+${v}\\s*=\\s*`, 'g'))].at(-1); if (!def) throw new Error(`${rel}: no definition of "${v}" before ${lit}`); const at = def.index! + def[0].length; if (/^new URLSearchParams\(\s*\{/.test(src.slice(at))) keys.push(...paramsObjectKeys(src, at)); else if (/^new URLSearchParams\(\s*\)/.test(src.slice(at))) { /* filled by .set / .append below */ } else if (/^['"`]/.test(src.slice(at))) keys.push(...[...src.slice(at + 1, literalEnd(src, at) - 1).matchAll(/(?:^|&)([A-Za-z]\w*(?:\[[\w.]+\])?)=/g)].map(x => x[1]!)); else throw new Error(`${rel}: cannot read the query "${v}" of ${lit}`); const between = src.slice(def.index!, start); keys.push(...[...between.matchAll(new RegExp(`\\b${v}\\.(?:set|append)\\(\\s*'([^']+)'`, 'g'))].map(x => x[1]!)); } } const line = src.slice(0, start).split('\n').length; const combos = (SEGMENT_VALUES[rel] ?? []).filter(c => Object.keys(c).some(n => rawPath.includes(`\${${n}}`))); for (const sub of combos.length ? combos : [{}]) { const fill = (t: string) => Object.entries(sub).reduce((acc, [n, v]) => acc.split(`\${${n}}`).join(v), t); const path = fill(rawPath).replace(/\$\{[^}]*\}/g, 'X'); out.push({ file: rel, line, path, keys: [...new Set(keys.map(fill))] }); } } } return out; } describe('App Store Connect GETs built by storebound', () => { const all = sites(); test('the walk finds the GETs (a broken scanner must not pass by finding nothing)', () => { expect(all.length).toBeGreaterThan(60); expect(all.some(s => s.file === 'cli/apple/testflight.ts' && s.path === '/v1/builds' && s.keys.includes('filter[preReleaseVersion.platform]'))).toBe(true); expect(all.some(s => s.file === 'cli/lib/reports.ts' && s.path === '/v1/financeReports' && s.keys.includes('filter[regionCode]'))).toBe(true); }); test('every path is a declared GET and every query parameter is declared for it (and not refused live)', () => { const problems: string[] = []; for (const s of all) { let op; try { op = resolveOp('apple', 'GET', s.path).op; } catch (e) { problems.push(`${s.file}:${s.line} ${s.path}: ${(e as Error).message}`); continue; } const declared = new Set(op.query.map(q => q.name)); for (const k of s.keys) { if (!declared.has(k)) problems.push(`${s.file}:${s.line} GET ${s.path} (${op.id}): "${k}" is not a parameter of the operation`); else if (APPLE_LIVE_REJECTED[op.id]?.[k]) problems.push(`${s.file}:${s.line} GET ${s.path} (${op.id}): "${k}" is refused live: ${APPLE_LIVE_REJECTED[op.id]![k]}`); } } expect(problems).toEqual([]); }); test('the old distribute lookup would have been caught', () => { const op = resolveOp('apple', 'GET', '/v1/betaGroups').op; expect(op.id).toBe('betaGroups_getCollection'); expect(APPLE_LIVE_REJECTED[op.id]?.['filter[builds]']).toBeTruthy(); }); test('every live-refused parameter names a declared one (the list stays honest when the spec moves)', () => { for (const [id, params] of Object.entries(APPLE_LIVE_REJECTED)) { const op = getOp('apple', id); expect(op).toBeDefined(); for (const k of Object.keys(params)) expect(op!.query.map(q => q.name)).toContain(k); } }); test('storebound api refuses a live-refused parameter', () => { expect(() => buildRequest(getOp('apple', 'betaGroups_getCollection')!, { 'filter[builds]': 'b1' })).toThrow('refuses "filter[builds]"'); }); });