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:
CRTkafa 2026-10-13 06:03:16 +08:00
parent f741c15829
commit bba46b5c7f
13 changed files with 354 additions and 14 deletions

View file

@ -5,6 +5,7 @@
// unclear answer; a 5xx before it; a failing upload part), and new resources can be hidden from list reads for a
// while (the stores' list lag right after a create). Nothing here reaches the network.
import { json, type Call } from './store-mock';
import { APPLE_LIVE_REJECTED, resolveOp } from '../../cli/api/client';
export type R = { type: string; id: string; attributes: Record<string, unknown>; rels: Record<string, string | string[]>; order: number; hiddenReads?: number };
export interface Fault { method: string; match: RegExp; when: 'before' | 'after'; status?: number; times?: number; code?: string; detail?: string }
@ -196,11 +197,30 @@ export class FakeAsc {
return r;
}
/**
* App Store Connect answers 400 PARAMETER_ERROR.ILLEGAL to a query parameter the operation does not take, and (seen
* live) to some the spec declares (APPLE_LIVE_REJECTED: filter[builds] on GET /v1/betaGroups). The fake does the same,
* so a command that builds such a GET fails here as it fails live.
*/
illegalQuery(u: URL): Response | null {
let op;
try { op = resolveOp('apple', 'GET', u.pathname).op; } catch { return null; }
const declared = new Set(op.query.map(q => q.name));
for (const k of new Set(u.searchParams.keys())) {
if (declared.has(k) && !APPLE_LIVE_REJECTED[op.id]?.[k]) continue;
return json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '400', code: 'PARAMETER_ERROR.ILLEGAL', title: 'A parameter has an invalid value',
detail: `'${k}' is not a valid ${/^filter\[/.test(k) ? 'filter type' : 'parameter'}`, source: { parameter: k } }] }, 400);
}
return null;
}
handle(method: string, path: string, body: unknown): Response {
const u = new URL(ASC + path);
const parts = u.pathname.split('/').filter(Boolean); // v1, type, id?, rel?
const [, type, id, rel, rel2] = parts;
if (method === 'GET') {
const bad = this.illegalQuery(u);
if (bad) return bad;
if (type === 'territories') return json({ data: this.all('territories').map(r => this.view(r)), links: {} });
if (rel === 'pricePoints') {
const t = u.searchParams.get('filter[territory]') ?? 'USA';
@ -232,7 +252,24 @@ export class FakeAsc {
return json({ data: shown.map(r => this.view(r)), included, links: {} });
}
if (method === 'POST') {
if (rel === 'relationships') { return new Response(null, { status: 204 }); }
if (rel === 'relationships') {
const self = id ? this.res.get(id) : undefined;
const items = ((body as { data?: { id: string }[] } | undefined)?.data ?? []);
// App Store Connect gives an internal all-builds group every build by itself and refuses adding one by hand
// (the exact answer is not recorded; a 409 stands for it)
if (self?.type === 'betaGroups' && rel2 === 'builds' && self.attributes.isInternalGroup === true && self.attributes.hasAccessToAllBuilds === true)
return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'internal group with access to all builds' }] }, 409);
// the link is kept: the item points back at the parent, so GET .../relationships/<rel> and the item's own
// relationships list it (a build in a beta group, a tester in a group)
if (self) for (const it of items) {
const child = this.res.get(it.id);
if (!child) continue;
const cur = child.rels[type!];
const list = cur === undefined ? [] : Array.isArray(cur) ? cur : [cur];
if (!list.includes(self.id)) child.rels[type!] = [...list, self.id];
}
return new Response(null, { status: 204 });
}
const out = this.create(body as Parameters<FakeAsc['create']>[0]);
return out instanceof Response ? out : json({ data: this.view(out) }, 201);
}