apple testflight distribute: a build's groups read per group (GET /v1/betaGroups/{id}/relationships/builds) instead of GET /v1/betaGroups?filter[builds]=, which App Store Connect refuses with 400 although the spec declares it; internal all-builds groups reported as already available and never sent the build; a re-run adds nothing; configured groups missing on App Store Connect point at groups sync, unknown ones list what exists; APPLE_LIVE_REJECTED (refused by storebound api too), tests/asc-query-guard.test.ts checks every App Store Connect GET with a query in cli/ against the spec's declared parameters, and the fake App Store Connect answers 400 to undeclared or live-refused parameters (pitfall 42)
This commit is contained in:
parent
f741c15829
commit
bba46b5c7f
13 changed files with 354 additions and 14 deletions
163
tests/asc-query-guard.test.ts
Normal file
163
tests/asc-query-guard.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// 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]"');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue