163 lines
8.4 KiB
TypeScript
163 lines
8.4 KiB
TypeScript
// 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<string, Record<string, string>[]> = {
|
|
'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]"');
|
|
});
|
|
});
|