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
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue