236 lines
16 KiB
TypeScript
236 lines
16 KiB
TypeScript
// Shapes the real App Store Connect and Google APIs answered during a read-only run against live accounts, where they
|
|
// differ from what the first fixtures assumed. Every id, name, email and project number here is a placeholder.
|
|
// - Google and App Store Connect pretty-print error bodies (multi-line JSON): the reason (SERVICE_DISABLED) sits on a
|
|
// later line, tables broke and serve's last-line error became a JSON fragment.
|
|
// - appStoreVersions come grouped by platform, not newest first.
|
|
// - Play Money leaves out zero fields: 0.99 USD is { currencyCode, nanos } without units.
|
|
// - App Store Connect answers 404 for diagnostics / power metrics of a build or app with no field data yet.
|
|
// - Age rating fields an accepted submission left null (gracRatingClassificationNumber, socialMedia*).
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { generateKeyPairSync } from 'node:crypto';
|
|
import { mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { checkAccount, listApps, run as appsRun } from '../cli/apps';
|
|
import { collectAppleHealth } from '../cli/health';
|
|
import { parseArgs } from '../cli/lib/args';
|
|
import { healthFindings } from '../cli/lib/health';
|
|
import { errorBody } from '../cli/lib/http';
|
|
import { fromMoney } from '../cli/lib/money';
|
|
import { appleCheck, latestVersion, listAppleApps, playCheck, pool } from '../cli/lib/store-apps';
|
|
import { type AppleSnapshot, appleRules } from '../cli/lib/validate-rules';
|
|
|
|
type J = Record<string, unknown>;
|
|
|
|
// Google's 403 for an API that is off in the service account's Cloud project, as sent (pretty-printed).
|
|
const SERVICE_DISABLED_BODY = `{
|
|
"error": {
|
|
"code": 403,
|
|
"message": "Google Play Developer Reporting API has not been used in project 123456789000 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=123456789000 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.",
|
|
"status": "PERMISSION_DENIED",
|
|
"details": [
|
|
{
|
|
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
|
"reason": "SERVICE_DISABLED",
|
|
"domain": "googleapis.com",
|
|
"metadata": {
|
|
"service": "playdeveloperreporting.googleapis.com",
|
|
"consumer": "projects/123456789000",
|
|
"containerInfo": "123456789000",
|
|
"activationUrl": "https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=123456789000",
|
|
"serviceTitle": "Google Play Developer Reporting API"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
`;
|
|
// App Store Connect's 404 for diagnostics of a build without field data, as sent (pretty-printed, space before colon).
|
|
const ASC_NOT_FOUND_BODY = `{
|
|
"errors" : [ {
|
|
"id" : "00000000-0000-0000-0000-000000000000",
|
|
"status" : "404",
|
|
"code" : "NOT_FOUND",
|
|
"title" : "The specified resource does not exist",
|
|
"detail" : "There is no resource of type '/v1/builds/{id}/diagnosticSignatures' with id 'build-1'"
|
|
} ]
|
|
}`;
|
|
// appStoreVersions of an iOS + macOS app: grouped by platform, macOS first, not sorted by date.
|
|
const VERSIONS_BY_PLATFORM = { data: [
|
|
{ type: 'appStoreVersions', id: 'v-mac-2', attributes: { platform: 'MAC_OS', versionString: '0.8.1', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-28T09:36:56-07:00' } },
|
|
{ type: 'appStoreVersions', id: 'v-mac-1', attributes: { platform: 'MAC_OS', versionString: '0.7.1', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-14T13:26:21-07:00' } },
|
|
{ type: 'appStoreVersions', id: 'v-ios-3', attributes: { platform: 'IOS', versionString: '0.8.1', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-30T07:37:04-07:00' } },
|
|
{ type: 'appStoreVersions', id: 'v-ios-2', attributes: { platform: 'IOS', versionString: '0.8.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-28T09:36:52-07:00' } },
|
|
{ type: 'appStoreVersions', id: 'v-ios-1', attributes: { platform: 'IOS', versionString: '0.7.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-06T16:22:51-07:00' } },
|
|
], meta: { paging: { total: 5, limit: 200 } } };
|
|
|
|
const realFetch = globalThis.fetch;
|
|
const saved = { ...process.env };
|
|
afterEach(() => { globalThis.fetch = realFetch; for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'PLAY_SERVICE_ACCOUNT']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } });
|
|
const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
|
|
const raw = (text: string, status: number) => new Response(text, { status, headers: { 'content-type': 'application/json' } });
|
|
function playKey(): void {
|
|
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
|
|
}
|
|
function ascKey(): void {
|
|
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
process.env.ASC_KEY_ID = 'ABCDE12345';
|
|
process.env.ASC_ISSUER_ID = '00000000-0000-0000-0000-000000000000';
|
|
process.env.ASC_P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
}
|
|
|
|
describe('error bodies', () => {
|
|
test('pretty-printed JSON becomes one line; HTML is named; plain text collapses', () => {
|
|
const one = errorBody(SERVICE_DISABLED_BODY);
|
|
expect(one).not.toContain('\n');
|
|
expect(one).toContain('"reason":"SERVICE_DISABLED"');
|
|
expect(errorBody(ASC_NOT_FOUND_BODY)).toStartWith('{"errors":[{"id":');
|
|
expect(errorBody('<html><body>404</body></html>')).toBe('(HTML error page)');
|
|
expect(errorBody('upstream\n timed out')).toBe('upstream timed out');
|
|
expect(errorBody('x'.repeat(50), 10)).toBe('xxxxxxxxxx…');
|
|
});
|
|
});
|
|
|
|
describe('Play account check with the real 403', () => {
|
|
test('SERVICE_DISABLED on a later line of the body is api-disabled, not a permission problem', () => {
|
|
// the error as the getter built it before the fix: the raw, multi-line body after the status
|
|
const before = new Error(`GET /apps:search?pageSize=1000 -> 403 ${SERVICE_DISABLED_BODY}`);
|
|
expect(playCheck('sb@example.com', { error: before }).status).toBe('api-disabled');
|
|
const after = new Error(`GET /apps:search?pageSize=1000 -> 403 ${errorBody(SERVICE_DISABLED_BODY, 800)}`);
|
|
expect(playCheck('sb@example.com', { error: after }).status).toBe('api-disabled');
|
|
});
|
|
test('the CLI: check says api-disabled with the project\'s link; list does not fail: the finding and the local apps (pitfall 46)', async () => {
|
|
playKey();
|
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
|
const url = String(input instanceof Request ? input.url : input);
|
|
if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'ya29.test', expires_in: 3600 });
|
|
if (url.startsWith('https://playdeveloperreporting.googleapis.com/v1beta1/apps:search')) return raw(SERVICE_DISABLED_BODY, 403);
|
|
return json({ error: 'not recorded' }, 404);
|
|
}) as typeof fetch;
|
|
const root = mkdtempSync(join(tmpdir(), 'sb-real-'));
|
|
const c = await checkAccount('play', root);
|
|
expect(c).toMatchObject({ status: 'api-disabled', principal: 'sb@example.com', docs: 'https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000' });
|
|
const savedReg = process.env.STOREBOUND_REGISTRY;
|
|
process.env.STOREBOUND_REGISTRY = join(root, 'projects.json');
|
|
const cwd = process.cwd();
|
|
process.chdir(root);
|
|
try {
|
|
const r = await listApps('play', root, false);
|
|
expect(r.apps).toEqual([]);
|
|
expect(r.warnings[0]).not.toContain('\n');
|
|
expect(r.warnings[0]).toContain('Reporting API is off');
|
|
expect(r.warnings[0]).toContain('Fix: Enable "Google Play Developer Reporting API" for that project: https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000');
|
|
let failed = false;
|
|
try { await appsRun(parseArgs(['list', '--store', 'play', '--json'], ['json', 'icons'])); } catch { failed = true; }
|
|
expect(failed).toBe(false);
|
|
} finally {
|
|
process.chdir(cwd);
|
|
if (savedReg === undefined) delete process.env.STOREBOUND_REGISTRY; else process.env.STOREBOUND_REGISTRY = savedReg;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('App Store Connect shapes', () => {
|
|
test('versions grouped by platform: platforms from every page item, newest iOS version by createdDate', async () => {
|
|
expect(latestVersion(VERSIONS_BY_PLATFORM.data)).toEqual({ version: '0.8.1', state: 'READY_FOR_DISTRIBUTION', platform: 'IOS' });
|
|
// only macOS: the newest of any platform
|
|
expect(latestVersion(VERSIONS_BY_PLATFORM.data.slice(0, 2).reverse())).toMatchObject({ version: '0.8.1', platform: 'MAC_OS' });
|
|
expect(latestVersion([])).toBeNull();
|
|
const calls: string[] = [];
|
|
const r = await listAppleApps(async p => {
|
|
calls.push(p);
|
|
if (p.startsWith('/v1/apps?')) return { data: [{ type: 'apps', id: '6400000201', attributes: { name: 'Example Aquarium', bundleId: 'com.example.aquarium', sku: 'AQ1', primaryLocale: 'en-US' } }], links: {} };
|
|
return VERSIONS_BY_PLATFORM;
|
|
});
|
|
expect(r.apps[0]).toMatchObject({ platforms: ['MAC_OS', 'IOS'], latest: { version: '0.8.1', platform: 'IOS' } });
|
|
expect(calls[1]).toContain('appStoreVersions?limit=200&');
|
|
});
|
|
test('per-app reads run a few at a time and keep the order of apps and warnings', async () => {
|
|
let inFlight = 0, most = 0;
|
|
const out = await pool([1, 2, 3, 4, 5, 6, 7, 8, 9], 4, async n => { inFlight++; most = Math.max(most, inFlight); await Bun.sleep(10 - n); inFlight--; return n * 2; });
|
|
expect(out).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18]);
|
|
expect(most).toBe(4);
|
|
const ids = Array.from({ length: 6 }, (_, i) => String(6400000300 + i));
|
|
const r = await listAppleApps(async p => {
|
|
if (p.startsWith('/v1/apps?')) return { data: ids.map(id => ({ type: 'apps', id, attributes: { name: `App ${id.slice(-1)}`, bundleId: `com.example.a${id.slice(-1)}` } })), links: {} };
|
|
await Bun.sleep(Number(p.split('/')[3]!.slice(-1)) % 3);
|
|
throw new Error(`GET ${p} -> 500 ${errorBody(ASC_NOT_FOUND_BODY)}`);
|
|
});
|
|
expect(r.warnings.map(w => w.split(':')[0])).toEqual(ids.map(id => `com.example.a${id.slice(-1)}`));
|
|
expect(r.warnings[0]).toContain('There is no resource of type');
|
|
});
|
|
test('account check counts the team\'s apps from meta.paging.total, one app read', async () => {
|
|
ascKey();
|
|
const seen: string[] = [];
|
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
|
seen.push(String(input));
|
|
return json({ data: [{ type: 'apps', id: '6400000201', attributes: { bundleId: 'com.example.aquarium' } }], links: {}, meta: { paging: { total: 5, limit: 1 } } });
|
|
}) as typeof fetch;
|
|
expect(await checkAccount('apple', mkdtempSync(join(tmpdir(), 'sb-real-')))).toMatchObject({ status: 'ok', apps: 5 });
|
|
expect(seen).toEqual(['https://api.appstoreconnect.apple.com/v1/apps?fields[apps]=bundleId&limit=1']);
|
|
expect(appleCheck({ error: new Error(`GET /v1/apps -> 403 ${errorBody('{\n "errors" : [ { "status" : "403", "code" : "FORBIDDEN_ERROR" } ]\n}')}`) }).status).toBe('permission');
|
|
});
|
|
});
|
|
|
|
describe('health with no field data yet', () => {
|
|
test('404 for power metrics and diagnostic signatures means none, not an unreadable store', async () => {
|
|
ascKey();
|
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
|
const url = String(input).replace('https://api.appstoreconnect.apple.com', '');
|
|
if (url.startsWith('/v1/apps/6400000401/perfPowerMetrics')) return raw(ASC_NOT_FOUND_BODY, 404);
|
|
if (url.startsWith('/v1/builds?filter[app]=6400000401')) return json({ data: [{ type: 'builds', id: 'build-1', attributes: { version: '11' } }, { type: 'builds', id: 'build-2', attributes: { version: '10' } }], links: {} });
|
|
if (url === '/v1/builds/build-1/diagnosticSignatures?limit=10') return raw(ASC_NOT_FOUND_BODY, 404);
|
|
if (url === '/v1/builds/build-2/diagnosticSignatures?limit=10') return json({ data: [{ type: 'diagnosticSignatures', id: 's1', attributes: { diagnosticType: 'HANGS', signature: 'main thread', weight: 0.4 } }] });
|
|
if (url.startsWith('/v1/apps/6400000401/betaFeedbackCrashSubmissions')) return json({ data: [], links: {} });
|
|
return raw(ASC_NOT_FOUND_BODY.replace('404', '500'), 418);
|
|
}) as typeof fetch;
|
|
const h = await collectAppleHealth(mkdtempSync(join(tmpdir(), 'sb-real-')), 'com.example.cutter', '6400000401', 7);
|
|
expect(h.metrics).toEqual([]);
|
|
expect(h.signatures).toEqual([{ build: '10', type: 'HANGS', signature: 'main thread', weight: 0.4, id: 's1' }]);
|
|
});
|
|
test('other errors still make the store unreadable', async () => {
|
|
ascKey();
|
|
globalThis.fetch = (async () => raw('{\n "errors" : [ { "status" : "403", "code" : "FORBIDDEN_ERROR" } ]\n}', 403)) as unknown as typeof fetch;
|
|
await expect(collectAppleHealth(mkdtempSync(join(tmpdir(), 'sb-real-')), 'com.example.cutter', '6400000401', 7)).rejects.toThrow(/-> 403 \{"errors"/);
|
|
});
|
|
test('Play vitals behind a disabled Reporting API: says so and how to turn it on', () => {
|
|
const f = healthFindings({ date: '2026-10-10', play: { error: `GET /v1beta1/apps/com.example.cutter/crashRateMetricSet -> 403 ${errorBody(SERVICE_DISABLED_BODY, 800)}` } });
|
|
expect(f).toEqual([expect.objectContaining({ store: 'play', level: 'warn', code: 'vitals', msg: expect.stringContaining('Reporting API is off') })]);
|
|
expect(f[0]!.fix).toContain('enable "Google Play Developer Reporting API"');
|
|
expect(f[0]!.msg).not.toContain('\n');
|
|
});
|
|
});
|
|
|
|
describe('Play money without units', () => {
|
|
test('0.99 comes back as nanos only; whole prices without nanos', () => {
|
|
expect(fromMoney({ currencyCode: 'USD', nanos: 990000000 })).toBeCloseTo(0.99, 9);
|
|
expect(fromMoney({ currencyCode: 'USD', units: '3' })).toBe(3);
|
|
expect(fromMoney({ currencyCode: 'USD', units: '2', nanos: 990000000 })).toBeCloseTo(2.99, 9);
|
|
});
|
|
});
|
|
|
|
describe('validate: age rating fields accepted submissions leave null', () => {
|
|
const snap = (ageRating: J): AppleSnapshot => ({
|
|
version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' },
|
|
build: { number: '1', processingState: 'VALID', usesNonExemptEncryption: false, declarationState: null },
|
|
expectedLocales: ['en-US'], locales: [{ locale: 'en-US', subtitle: 's', description: 'd', keywords: 'k', screenshots: { APP_IPHONE_67: 5 } }],
|
|
requiredDisplayTypes: ['APP_IPHONE_67'],
|
|
reviewDetail: { contactEmail: 'review@example.com', contactPhone: '+1 555 0100', contactFirstName: 'A', contactLastName: 'B', notes: 'n' },
|
|
ageRating, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 },
|
|
});
|
|
// the declaration of an app in review (values as returned; 30 fields in 2026)
|
|
const IN_REVIEW: J = { advertising: true, gambling: false, kidsAgeBand: null, lootBox: false, messagingAndChat: false, socialMedia: null, socialMediaAgeRestricted: null,
|
|
userGeneratedContent: false, ageRatingOverride: 'NONE', ageRatingOverrideV2: 'NONE', koreaAgeRatingOverride: 'NONE', gracRatingClassificationNumber: null, developerAgeRatingInfoUrl: null };
|
|
test('no blocker; socialMedia unanswered is a warning', () => {
|
|
const f = appleRules(snap(IN_REVIEW)).filter(x => x.code === 'age-rating');
|
|
expect(f.map(x => x.level).sort()).toEqual(['ok', 'warn']);
|
|
const w = f.find(x => x.level === 'warn')!;
|
|
expect(w.msg).toContain('socialMedia');
|
|
expect(w.msg).not.toContain('socialMediaAgeRestricted');
|
|
});
|
|
test('socialMediaAgeRestricted is asked once socialMedia is yes; other nulls still block', () => {
|
|
expect(appleRules(snap({ ...IN_REVIEW, socialMedia: true })).find(x => x.code === 'age-rating')).toMatchObject({ level: 'blocker', msg: 'age rating unanswered: socialMediaAgeRestricted' });
|
|
expect(appleRules(snap({ ...IN_REVIEW, socialMedia: false, lootBox: null })).filter(x => x.code === 'age-rating').map(x => `${x.level}:${x.msg}`)).toEqual(['blocker:age rating unanswered: lootBox']);
|
|
});
|
|
});
|