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
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -56,6 +56,23 @@ once per card.
|
|||
on page 1), then the same dry diff and `--write`.
|
||||
- Pitfalls 38-41.
|
||||
|
||||
TestFlight distribute from real use (2026-10-13, pitfall 42):
|
||||
|
||||
- Fix (`apple testflight distribute`): it failed with 400 right after printing the build state, because App Store
|
||||
Connect refuses `GET /v1/betaGroups?filter[builds]=` although Apple's spec declares the filter (and the spec has no
|
||||
GET of a build's groups). A build's groups are now read per target group with
|
||||
`GET /v1/betaGroups/{id}/relationships/builds`. An internal group with all builds is reported as "already
|
||||
available to <group> (internal, all builds)" and never sent the build (App Store Connect refuses that); internal
|
||||
groups without all builds and external groups get it through the relationship; a re-run adds nothing.
|
||||
- Fix (`apple testflight distribute`, `testers`): a group in `apple.testflight.groups` that is not on App Store
|
||||
Connect yet says so and names `storebound apple testflight groups sync`; a group in neither lists the groups that
|
||||
exist (was `unknown group "Team" ()`).
|
||||
- Guard: `tests/asc-query-guard.test.ts` walks every App Store Connect GET with a query that `cli/` builds (template
|
||||
literals and the URLSearchParams they interpolate) and fails offline on a path that is no declared GET, a query
|
||||
parameter the operation does not declare in `cli/api/apple.gen.ts`, or one in the new `APPLE_LIVE_REJECTED` list
|
||||
(declared, refused live). `storebound api apple` refuses those parameters too. The fake App Store Connect in the
|
||||
tests answers 400 PARAMETER_ERROR.ILLEGAL to such parameters, as the live API does.
|
||||
|
||||
## 0.7.1 · 2026-10-13
|
||||
|
||||
- Fix: README links and images on npmjs.com resolved against npmjs.com and gave 404 (`docs/README.md`, the logo).
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ export interface Op {
|
|||
shapes: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* App Store Connect query parameters the spec declares but the live API answers 400 to, per operation id, with what to
|
||||
* use instead. `storebound api` refuses them, and tests/asc-query-guard.test.ts fails when storebound's own code uses one.
|
||||
*/
|
||||
export const APPLE_LIVE_REJECTED: Record<string, Record<string, string>> = {
|
||||
// seen live 2026-10-13 (apple testflight distribute): GET /v1/betaGroups?filter[app]=..&filter[builds]=.. -> 400
|
||||
betaGroups_getCollection: { 'filter[builds]': 'seen live: 400; read the builds of each group with GET /v1/betaGroups/{id}/relationships/builds' },
|
||||
};
|
||||
|
||||
/** Query parameters every Google API accepts on top of a method's own. */
|
||||
export const GOOGLE_GLOBAL = ['fields', 'prettyPrint', 'quotaUser'];
|
||||
|
||||
|
|
@ -136,6 +145,8 @@ export function buildRequest(op: Op, params: Record<string, ParamValue | undefin
|
|||
const names = op.query.map(x => x.name);
|
||||
throw new Error(`${op.id}: unknown parameter "${k}"${names.length ? ` (takes: ${names.slice(0, 30).join(', ')}${names.length > 30 ? ', …' : ''})` : ' (takes no query parameters)'}`);
|
||||
}
|
||||
const refused = op.store === 'apple' ? APPLE_LIVE_REJECTED[op.id]?.[k] : undefined;
|
||||
if (refused) throw new Error(`${op.id}: App Store Connect refuses "${k}" although the spec declares it (${refused})`);
|
||||
const values = Array.isArray(v) ? v.map(str) : [str(v as string)];
|
||||
if (q?.repeated) for (const x of values) parts.push(`${encName(k)}=${encVal(x)}`);
|
||||
else parts.push(`${encName(k)}=${encVal(values.join(','))}`);
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'betaGroups_betaTesters_getToManyRelated': 'apple/testflight',
|
||||
'betaGroups_builds_createToManyRelationship': 'lib/testflight',
|
||||
'betaGroups_builds_getToManyRelated': 'apple/testflight',
|
||||
'betaGroups_builds_getToManyRelationship': 'apple/testflight',
|
||||
'betaGroups_createInstance': 'lib/testflight',
|
||||
'betaGroups_getCollection': 'apple/testflight',
|
||||
'betaGroups_getInstance': 'apple/testflight',
|
||||
'betaTesterInvitations_createInstance': 'lib/testflight',
|
||||
'betaTesters_createInstance': 'lib/testflight',
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import { textTable } from '../lib/table';
|
|||
import { BUILD_WAIT_EXIT, type BuildSeen, expireCandidates, planExpire, validHint, waitDelay, type WaitOutcome, waitOutcome } from '../lib/build-ops';
|
||||
import { parseDuration } from '../lib/release';
|
||||
import {
|
||||
type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, type Group, groupFromApi, inviteReq, maskEmail,
|
||||
parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, whatToTestFromListing,
|
||||
type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, getsAllBuilds, type Group, groupFromApi, inviteReq, maskEmail,
|
||||
parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, unknownGroupMessage, whatToTestFromListing,
|
||||
} from '../lib/testflight';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
import { describeReq, execute } from './jsonapi';
|
||||
|
|
@ -178,7 +178,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const resp = await c.all(`/v1/betaTesters?filter[apps]=${appId}&include=betaGroups&limit=200`);
|
||||
for (const t of resp) existing.set(String(t.attributes.email ?? '').toLowerCase(), { id: t.id, groupIds: (t.relationships?.betaGroups?.data ?? []).map((g: J) => g.id), state: t.attributes.state });
|
||||
const plan = planTesters(gs, existing, want, { remove: a.has('remove'), only });
|
||||
if (plan.unknownGroups.length) throw new Error(`unknown group(s): ${plan.unknownGroups.join(', ')} (storebound apple testflight groups sync creates the configured ones)`);
|
||||
if (plan.unknownGroups.length) throw new Error(unknownGroupMessage(plan.unknownGroups, gs, tf?.groups ?? []));
|
||||
const internal = gs.filter(g => g.internal && want.some(t => t.groups.includes(g.name)));
|
||||
if (internal.length) warn(TAG, `internal groups (${internal.map(g => g.name).join(', ')}) only take App Store Connect users of your team (Users and Access)`);
|
||||
log(TAG, `${want.length} testers in the CSV: ${plan.summary.join(', ')}`);
|
||||
|
|
@ -239,14 +239,25 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const names = a.list('group');
|
||||
if (!names.length) throw new Error('--group <name>[,<name>]: which beta groups get the build');
|
||||
const gs = await groups(c, appId);
|
||||
const target = names.map(n => gs.find(g => g.name === n) ?? (() => { throw new Error(`unknown group "${n}" (${gs.map(g => g.name).join(', ')})`); })());
|
||||
const missing = names.filter(n => !gs.some(g => g.name === n));
|
||||
if (missing.length) throw new Error(unknownGroupMessage(missing, gs, tf?.groups ?? []));
|
||||
const target = names.map(n => gs.find(g => g.name === n)!);
|
||||
const external = target.filter(g => !g.internal);
|
||||
if (a.has('internal-only') && external.length) throw new Error(`external group(s) ${external.map(g => g.name).join(', ')}: distribute to external testers from the terminal (asks first)`);
|
||||
const b = await findBuild(c, appId, number, a.get('version'));
|
||||
const problems = betaReadiness(b.attributes, b.detail);
|
||||
if (problems.length) throw new Error(`build ${number}: ${problems.join('; ')}`);
|
||||
log(TAG, `build ${number} (${b.marketing ?? '?'}): internal ${b.detail?.internalBuildState ?? '?'}, external ${b.detail?.externalBuildState ?? '?'}`);
|
||||
const inGroups = (await c.all(`/v1/betaGroups?filter[app]=${appId}&filter[builds]=${b.id}&limit=200`)).map((g: J) => g.id);
|
||||
// a build's groups, read per group: GET /v1/betaGroups?filter[builds]= is refused live (400) and the spec has no
|
||||
// GET /v1/builds/{id}/betaGroups; internal all-builds groups have every build and take none by hand
|
||||
const inGroups: string[] = [];
|
||||
for (const g of target) {
|
||||
if (getsAllBuilds(g)) { log(TAG, `build ${number}: already available to ${g.name} (internal, all builds)`); continue; }
|
||||
if ((await c.all(`/v1/betaGroups/${g.id}/relationships/builds?limit=200`)).some((x: J) => x.id === b.id)) {
|
||||
inGroups.push(g.id);
|
||||
log(TAG, `build ${number}: already in ${g.name} (${g.internal ? 'internal' : 'external'})`);
|
||||
}
|
||||
}
|
||||
const reqs = planDistribute(b.id, target, inGroups);
|
||||
const review = a.has('submit-review') && external.length ? (await c.maybe(`/v1/builds/${b.id}/betaAppReviewSubmission`))?.data ?? null : null;
|
||||
const wantsReview = a.has('submit-review') && external.length > 0 && !review && !['BETA_APPROVED', 'IN_BETA_REVIEW', 'WAITING_FOR_BETA_REVIEW', 'IN_BETA_TESTING'].includes(b.detail?.externalBuildState);
|
||||
|
|
@ -264,7 +275,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (await confirm(ctx, `Submit build ${number} to Beta App Review now? Apple reviews it for external testing (Test Information must be complete: storebound apple testflight info).`))
|
||||
await execute(c.asc, [betaReviewReq(b.id)], { dry: false, tag: TAG });
|
||||
}
|
||||
done(TAG, `build ${number}: ${reqs.length ? `added to ${reqs.length} group(s)` : 'already in every group'}`);
|
||||
const added = target.filter(g => reqs.some(r => r.path.includes(`/betaGroups/${g.id}/`))).map(g => g.name);
|
||||
done(TAG, `build ${number}: ${added.length ? `added to ${added.join(', ')}` : `already available to ${target.map(g => g.name).join(', ')}`}`);
|
||||
return;
|
||||
}
|
||||
if (sub === 'wait') {
|
||||
|
|
|
|||
|
|
@ -179,12 +179,37 @@ export function planTesters(groups: Group[], existing: Map<string, { id: string;
|
|||
export const inviteReq = (appId: string, testerId: string, label: string): Req => ({ method: 'POST', path: '/v1/betaTesterInvitations', note: `invite ${label}`,
|
||||
body: { data: { type: 'betaTesterInvitations', relationships: { app: one('apps', appId), betaTester: one('betaTesters', testerId) } } } });
|
||||
|
||||
/** POST /v1/betaGroups/{id}/relationships/builds for every group that does not have the build yet. */
|
||||
/** An internal group with hasAccessToAllBuilds gets every build by itself; App Store Connect refuses adding one by hand. */
|
||||
export const getsAllBuilds = (g: Group): boolean => g.internal && g.hasAccessToAllBuilds === true;
|
||||
|
||||
/**
|
||||
* POST /v1/betaGroups/{id}/relationships/builds for every group that does not have the build yet. Internal groups with
|
||||
* all builds are skipped (they have it already). `buildGroupIds`: the groups that list the build, read per group with
|
||||
* GET /v1/betaGroups/{id}/relationships/builds (App Store Connect answers 400 to GET /v1/betaGroups?filter[builds]=,
|
||||
* and the spec has no GET of a build's groups).
|
||||
*/
|
||||
export function planDistribute(buildId: string, groups: Group[], buildGroupIds: string[]): Req[] {
|
||||
return groups.filter(g => !buildGroupIds.includes(g.id) && !(g.internal && g.hasAccessToAllBuilds)).map(g => ({ method: 'POST', path: `/v1/betaGroups/${g.id}/relationships/builds`,
|
||||
return groups.filter(g => !buildGroupIds.includes(g.id) && !getsAllBuilds(g)).map(g => ({ method: 'POST', path: `/v1/betaGroups/${g.id}/relationships/builds`,
|
||||
body: many('builds', [buildId]), note: `build -> ${g.internal ? 'internal' : 'external'} group "${g.name}"` }));
|
||||
}
|
||||
|
||||
/** Why group names given to distribute / testers are not on App Store Connect: configured but not created yet (with the
|
||||
* command that creates them), or unknown (with the groups that do exist). Pure. */
|
||||
export function unknownGroupMessage(names: string[], existing: Group[], configured: TfGroupSpec[]): string {
|
||||
const sync = '`storebound apple testflight groups sync`';
|
||||
const quote = (ns: string[]) => ns.map(n => `"${n}"`).join(', ');
|
||||
const inConfig = names.filter(n => configured.some(g => g.name === n));
|
||||
const other = names.filter(n => !inConfig.includes(n));
|
||||
const lines: string[] = [];
|
||||
if (inConfig.length) lines.push(inConfig.length > 1
|
||||
? `groups ${quote(inConfig)} are in the config but not on App Store Connect yet: create them with ${sync}`
|
||||
: `group ${quote(inConfig)} is in the config but not on App Store Connect yet: create it with ${sync}`);
|
||||
if (other.length) lines.push(`unknown group${other.length > 1 ? 's' : ''} ${quote(other)}: ${existing.length
|
||||
? `App Store Connect has ${quote(existing.map(g => g.name))}`
|
||||
: `App Store Connect has no beta groups yet (add them to apple.testflight.groups, then ${sync})`}`);
|
||||
return lines.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* "What to Test" texts for TestFlight per App Store locale from the App Store listing file: a `### whatToTest` block
|
||||
* when a locale section has one, else its `### whatsNew` block (what `storebound whats-new` drafts). Pure.
|
||||
|
|
|
|||
|
|
@ -567,10 +567,10 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| `betaGroups_builds_createToManyRelationship` | POST | `/v1/betaGroups/{id}/relationships/builds` | write (dry by default) | lib/testflight |
|
||||
| `betaGroups_builds_deleteToManyRelationship` | DELETE | `/v1/betaGroups/{id}/relationships/builds` | owner only (typed confirm) | |
|
||||
| `betaGroups_builds_getToManyRelated` | GET | `/v1/betaGroups/{id}/builds` | read | apple/testflight |
|
||||
| `betaGroups_builds_getToManyRelationship` | GET | `/v1/betaGroups/{id}/relationships/builds` | read | |
|
||||
| `betaGroups_builds_getToManyRelationship` | GET | `/v1/betaGroups/{id}/relationships/builds` | read | apple/testflight |
|
||||
| `betaGroups_createInstance` | POST | `/v1/betaGroups` | write (dry by default) | lib/testflight |
|
||||
| `betaGroups_deleteInstance` | DELETE | `/v1/betaGroups/{id}` | owner only (typed confirm) | |
|
||||
| `betaGroups_getCollection` | GET | `/v1/betaGroups` | read | apple/testflight |
|
||||
| `betaGroups_getCollection` | GET | `/v1/betaGroups` | read | |
|
||||
| `betaGroups_getInstance` | GET | `/v1/betaGroups/{id}` | read | apple/testflight |
|
||||
| `betaGroups_publicLinkUsages_getMetrics` | GET | `/v1/betaGroups/{id}/metrics/publicLinkUsages` | read | |
|
||||
| `betaGroups_updateInstance` | PATCH | `/v1/betaGroups/{id}` | write (dry by default) | |
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 42 | 2026-10 | `apple testflight distribute --build 1 --group Team --yes` failed with 400 on `GET /v1/betaGroups?filter[builds]=`: Apple's spec declares the filter, App Store Connect refuses it, and the spec has no GET of a build's groups. Before the groups existed it said `unknown group "Team" ()`. | Distribute reads each target group's builds (`GET /v1/betaGroups/{id}/relationships/builds`), reports internal all-builds groups as "already available" and never sends them the build; `unknownGroupMessage` points at `groups sync` for configured groups. `APPLE_LIVE_REJECTED` (`cli/api/client.ts`, also refused by `storebound api`); `tests/asc-query-guard.test.ts` checks every App Store Connect GET with a query in `cli/` against the spec's declared parameters and that list; the fake App Store Connect answers 400 to them (`tests/idempotency.test.ts`). |
|
||||
| 41 | 2026-10 | Every Play App content card ended in an owner-only Save, so the owner came back once per card (privacy policy, ads, app access, advertising ID, government, financial, health, target audience, content rating, data safety). Saving an App content declaration is not public: it goes to Google only with the next "Send changes for review" in Publishing overview (verified 2026-10-13). | Card Saves are the agent's (`declaration-save`): `declarationSaveAllowed` (exactly Save / Kaydet, a button on an /app-content/<card> page) and `flowAllowed` for content rating and target audience, while `publishingControl` refuses "Send changes for review", "Submit N changes for review", Submit, Publish, "Restart review" everywhere; the plans' `buttons.final` is "Send changes for review"; `console fill` presses the declaration's Save; `console plan play-app-content --all` walks every card and ends with one owner list (`cli/lib/console-forms.ts`, `cli/lib/console-plan.ts`, `cli/lib/console-play-plans.ts`, `cli/console/fill.ts`; `tests/console-play-plans.test.ts`, `tests/console-flow.test.ts`). |
|
||||
| 40 | 2026-10 | Finding a new app's Play Console app id (`play.appId`) took several steps: the app list shows 10 apps per page and the new app was not on page 1. The id was in the URL Create app redirects to (/developers/<devId>/app/<appId>/app-dashboard) all along. | `console verify play-app-record --url <url>` reads the id from the redirect (or the capture JSON's url), shows the `play.appId` change as a dry diff and `--write` upserts it (`upsertNestedString`); `storebound play app-id` finds it by package name across captured app list pages (search box or pages) as an agent step (`cli/lib/play-app-id.ts`, `cli/play/app-id.ts`; `tests/play-app-id.test.ts`, a modeled 3-page list). |
|
||||
| 39 | 2026-10 | Play IARC questionnaire: page 1's Next did nothing until the IARC Terms of Use box was ticked, an owner-only agreement, so the agent stopped mid-run and waited for the owner. | The box is in the plan's ONE owner batch before the run with its exact place (`ownerBatch`, printed first; `play-app-content` and `play-content-rating`); the step says not to press Next again and again; `console fill` waits for the tick ("agreement needed on …", `blocksNext`) instead of pressing a Next that does nothing (`tests/console-play-plans.test.ts`, `tests/console-flow.test.ts`). |
|
||||
|
|
|
|||
|
|
@ -549,3 +549,18 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
`storebound console plan play-app-content --all` walks every card and ends with that one owner list.
|
||||
- **Guard:** `declarationSaveAllowed`, `publishingControl`, `flowAllowed` (`cli/lib/console-forms.ts`), the plans'
|
||||
`buttons.final` "Send changes for review", `console fill`'s declaration Save; `tests/console-play-plans.test.ts`.
|
||||
|
||||
## 42. A filter the spec declares, refused live (TestFlight distribute)
|
||||
- **Symptom:** `apple testflight distribute --build 1 --group Team --yes` printed the build state, then failed:
|
||||
`GET /v1/betaGroups?filter[app]=…&filter[builds]=… -> 400`. Before the groups existed it said `unknown group
|
||||
"Team" ()`.
|
||||
- **Cause:** Apple's OpenAPI spec declares `filter[builds]` on `GET /v1/betaGroups`, but App Store Connect answers 400;
|
||||
the spec has no GET of a build's groups. An internal group with all builds already has every build and refuses
|
||||
one added by hand.
|
||||
- **Do:** read a build's groups per group (`GET /v1/betaGroups/{id}/relationships/builds`); skip internal all-builds
|
||||
groups ("already available"). A configured group missing on App Store Connect: `storebound apple testflight groups
|
||||
sync` first. Never use a query parameter that is not declared for the operation (`storebound api list`), and add
|
||||
one refused live to `APPLE_LIVE_REJECTED` (`cli/api/client.ts`).
|
||||
- **Guard:** `tests/asc-query-guard.test.ts` (every App Store Connect GET with a query in `cli/` against the spec and
|
||||
`APPLE_LIVE_REJECTED`), the fake App Store Connect's 400 for such parameters, `unknownGroupMessage`;
|
||||
`tests/idempotency.test.ts` (distribute), `tests/testflight.test.ts`.
|
||||
|
|
|
|||
|
|
@ -60,7 +60,12 @@ beta app localization before a Beta App Review submission, and fields cannot be
|
|||
|
||||
## 5. Distribute
|
||||
- Internal: `storebound apple testflight distribute --build N --group Team --dry`, then for real (MCP:
|
||||
`apple_testflight_distribute`, internal groups only). Groups with `allBuilds: true` get every build automatically.
|
||||
`apple_testflight_distribute`, internal groups only). Groups with `allBuilds: true` get every build automatically:
|
||||
distribute reports "already available to <group> (internal, all builds)" and sends nothing (App Store Connect
|
||||
refuses adding a build to such a group). Internal groups without all builds get the build like external ones.
|
||||
A re-run adds nothing (it reads each group's builds first).
|
||||
- A group in `apple.testflight.groups` that is not on App Store Connect yet: run `storebound apple testflight groups
|
||||
sync` first (distribute and testers say so); a name in neither lists the groups that exist.
|
||||
- External: same command with an external group; it asks first. External testers only get the build after **Beta App
|
||||
Review**: add `--submit-review` (asks again) or submit in App Store Connect. The first build of a version is
|
||||
reviewed; later builds of the same version are often approved quickly. Beta review states: `WAITING_FOR_REVIEW`,
|
||||
|
|
|
|||
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]"');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,53 @@ describe('apple testflight groups: bare lists, sync creates', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('apple testflight distribute (real use 2026-10-13)', () => {
|
||||
const TF: ShipConfig = { ...CONFIG, apple: { ...CONFIG.apple!, testflight: { groups: [{ name: 'Team', internal: true, allBuilds: true }, { name: 'Friends' }, { name: 'Devs', internal: true }] } as never } };
|
||||
const groupsAndBuild = () => {
|
||||
s.asc.put({ type: 'betaGroups', id: 'grp-team', attributes: { name: 'Team', isInternalGroup: true, hasAccessToAllBuilds: true }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'betaGroups', id: 'grp-friends', attributes: { name: 'Friends', isInternalGroup: false }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'betaGroups', id: 'grp-devs', attributes: { name: 'Devs', isInternalGroup: true, hasAccessToAllBuilds: false }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '1', processingState: 'VALID', usesNonExemptEncryption: false, expired: false }, rels: { app: APP } });
|
||||
};
|
||||
|
||||
test('live shape: GET /v1/betaGroups?filter[builds]= is a 400 (the old lookup fails here as it did live)', async () => {
|
||||
const r = s.asc.handle('GET', `/v1/betaGroups?filter[app]=${APP}&filter[builds]=b-1&limit=200`, undefined);
|
||||
expect(r.status).toBe(400);
|
||||
expect(JSON.stringify(await r.json())).toContain('PARAMETER_ERROR.ILLEGAL');
|
||||
});
|
||||
|
||||
test('an internal all-builds group: reported as already available, nothing added', async () => {
|
||||
groupsAndBuild();
|
||||
const p = project(TF);
|
||||
const r = await twice(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Team', '--yes'])));
|
||||
expect(r.first).toEqual([]);
|
||||
expect(r.second).toEqual([]);
|
||||
const out = await capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Team', '--yes'])));
|
||||
expect(out.out).toContain('already available to Team (internal, all builds)');
|
||||
expect(s.calls.some(c => /filter\[builds\]/.test(decodeURIComponent(c.path)))).toBe(false);
|
||||
});
|
||||
|
||||
test('an internal group without all builds and an external group: added once, the re-run adds nothing', async () => {
|
||||
groupsAndBuild();
|
||||
const p = project(TF);
|
||||
const r = await twice(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Team,Devs,Friends', '--yes'])));
|
||||
expect(r.first).toEqual(['POST /v1/betaGroups/grp-devs/relationships/builds', 'POST /v1/betaGroups/grp-friends/relationships/builds']);
|
||||
expect(r.second).toEqual([]);
|
||||
const out = await capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Devs', '--yes'])));
|
||||
expect(out.out).toContain('already in Devs (internal)');
|
||||
});
|
||||
|
||||
test('a group in the config but not on App Store Connect points at groups sync; an unknown one lists what exists', async () => {
|
||||
const p = project(TF);
|
||||
s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '1', processingState: 'VALID' }, rels: { app: APP } });
|
||||
await expect(capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Team', '--yes']))))
|
||||
.rejects.toThrow('group "Team" is in the config but not on App Store Connect yet: create it with `storebound apple testflight groups sync`');
|
||||
s.asc.put({ type: 'betaGroups', id: 'grp-friends', attributes: { name: 'Friends', isInternalGroup: false }, rels: { app: APP } });
|
||||
await expect(capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Nope', '--yes']))))
|
||||
.rejects.toThrow('unknown group "Nope": App Store Connect has "Friends"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('App Store Connect: a second run writes nothing', () => {
|
||||
test('apple iap: product, localizations, price schedule, availability once', async () => {
|
||||
const p = project(CONFIG);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { parseCsv, parseCsvRecords, toCsv } from '../cli/lib/csv';
|
|||
import { validateConfig } from '../cli/lib/config';
|
||||
import {
|
||||
betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, type Group, groupFromApi, inviteReq, maskEmail, parseTesters, pickBuild,
|
||||
planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, validateTestflight, whatToTestFromListing,
|
||||
planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, unknownGroupMessage, validateTestflight, whatToTestFromListing,
|
||||
} from '../cli/lib/testflight';
|
||||
import { TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
|
||||
|
||||
|
|
@ -103,6 +103,13 @@ describe('testflight builds', () => {
|
|||
expect(betaReadiness({ processingState: 'VALID', expired: true }, null)[0]).toContain('expired');
|
||||
expect(betaReadiness({ processingState: 'VALID', usesNonExemptEncryption: null }, { internalBuildState: 'MISSING_EXPORT_COMPLIANCE' })[0]).toContain('export compliance');
|
||||
});
|
||||
test('unknown groups: configured ones point at groups sync, others list what exists', () => {
|
||||
const cfg = [{ name: 'Team', internal: true }, { name: 'QA' }];
|
||||
expect(unknownGroupMessage(['Team'], [], cfg)).toBe('group "Team" is in the config but not on App Store Connect yet: create it with `storebound apple testflight groups sync`');
|
||||
expect(unknownGroupMessage(['Team', 'QA'], [], cfg)).toContain('groups "Team", "QA" are in the config');
|
||||
expect(unknownGroupMessage(['Nope'], G, cfg)).toBe('unknown group "Nope": App Store Connect has "Team", "Everyone inside", "Friends"');
|
||||
expect(unknownGroupMessage(['Nope'], [], cfg)).toContain('no beta groups yet');
|
||||
});
|
||||
test('distribute skips groups that have it and internal all-builds groups', () => {
|
||||
expect(planDistribute('b1', G, ['g-ext']).map(r => r.path)).toEqual(['/v1/betaGroups/g-int/relationships/builds']);
|
||||
expect(planDistribute('b1', G, [])[0]!.body).toEqual({ data: [{ type: 'builds', id: 'b1' }] });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue