257 lines
18 KiB
TypeScript
257 lines
18 KiB
TypeScript
// storebound apps list / check (D3: many studios, many store accounts): parsing of recorded App Store Connect and
|
|
// Play Reporting API responses, account health classification, the CLI over a mocked fetch, and the serve methods
|
|
// (apps.list, account.check, store-only apps through a generated config).
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { generateKeyPairSync } from 'node:crypto';
|
|
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { checkAccount, listApps } from '../cli/apps';
|
|
import { ERR } from '../cli/lib/desktop-protocol';
|
|
import type { RunOptions, Running } from '../cli/lib/runner';
|
|
import { appleCheck, iconUrl, listAppleApps, listPlayApps, playCheck } from '../cli/lib/store-apps';
|
|
import { checkVirtual, describeMethods, Session, STORE_KEY_RE, VIRTUAL_READS, virtualConfig, virtualDir } from '../cli/serve';
|
|
import { validateConfig } from '../cli/lib/config';
|
|
|
|
type J = Record<string, unknown>;
|
|
|
|
// ---- recorded responses (shapes as the APIs return them; ids and names are placeholders) ----
|
|
const ASC_APPS_1 = {
|
|
data: [
|
|
{ type: 'apps', id: '6400000101', attributes: { name: 'Example Puzzle', bundleId: 'com.example.puzzle', sku: 'PUZZLE1', primaryLocale: 'en-US' } },
|
|
{ type: 'apps', id: '6400000102', attributes: { name: 'Example Notes', bundleId: 'com.example.notes', sku: 'NOTES1', primaryLocale: 'en-GB' } },
|
|
],
|
|
links: { self: 'https://api.appstoreconnect.apple.com/v1/apps?limit=200', next: 'https://api.appstoreconnect.apple.com/v1/apps?cursor=Mg&limit=200' },
|
|
meta: { paging: { total: 3, limit: 200 } },
|
|
};
|
|
const ASC_APPS_2 = { data: [{ type: 'apps', id: '6400000103', attributes: { name: 'A Mac Tool', bundleId: 'com.example.mactool', sku: 'MAC1', primaryLocale: 'en-US' } }], links: { self: '' } };
|
|
const ASC_VERSIONS: Record<string, J> = {
|
|
'6400000101': { data: [
|
|
{ type: 'appStoreVersions', id: 'v1', attributes: { platform: 'IOS', versionString: '2.4.0', appStoreState: 'WAITING_FOR_REVIEW', appVersionState: 'WAITING_FOR_REVIEW' } },
|
|
{ type: 'appStoreVersions', id: 'v0', attributes: { platform: 'IOS', versionString: '2.3.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION' } },
|
|
] },
|
|
'6400000102': { data: [] },
|
|
'6400000103': { data: [{ type: 'appStoreVersions', id: 'v9', attributes: { platform: 'MAC_OS', versionString: '1.0', appStoreState: 'PREPARE_FOR_SUBMISSION' } }] },
|
|
};
|
|
const ASC_BUILD = { data: [{ type: 'builds', id: 'b1', attributes: { version: '52', iconAssetToken: { templateUrl: 'https://is1-ssl.mzstatic.com/image/thumb/Purple/v4/ab/cd/AppIcon.png/{w}x{h}bb.{f}', width: 1024, height: 1024 } } }] };
|
|
const PLAY_SEARCH_1 = { apps: [{ name: 'apps/com.example.puzzle', packageName: 'com.example.puzzle', displayName: 'Example Puzzle' }, { name: 'apps/com.example.runner', packageName: 'com.example.runner', displayName: 'Runner' }], nextPageToken: 'tok2' };
|
|
const PLAY_SEARCH_2 = { apps: [{ name: 'apps/com.example.ledger', packageName: 'com.example.ledger', displayName: '' }] };
|
|
|
|
function ascGet(calls: string[]) {
|
|
return async (path: string): Promise<J> => {
|
|
calls.push(path);
|
|
if (path.startsWith('/v1/apps?fields')) return ASC_APPS_1;
|
|
if (path.includes('cursor=Mg')) return ASC_APPS_2;
|
|
const v = /^\/v1\/apps\/(\d+)\/appStoreVersions/.exec(path);
|
|
if (v) return ASC_VERSIONS[v[1]!]!;
|
|
if (path.startsWith('/v1/builds?filter[app]=6400000101')) return ASC_BUILD;
|
|
if (path.startsWith('/v1/builds')) return { data: [] };
|
|
throw new Error(`GET ${path} -> 404 not recorded`);
|
|
};
|
|
}
|
|
|
|
describe('apps list: App Store Connect', () => {
|
|
test('pages through /v1/apps, platforms and newest version per app, icon as a data URL', async () => {
|
|
const calls: string[] = [];
|
|
const icons: string[] = [];
|
|
const r = await listAppleApps(ascGet(calls), { icons: true, bytes: async url => { icons.push(url); return { type: 'image/png', data: new Uint8Array([137, 80, 78, 71]) }; } });
|
|
expect(r.apps.map(a => a.name)).toEqual(['A Mac Tool', 'Example Notes', 'Example Puzzle']);
|
|
const p = r.apps.find(a => a.bundleId === 'com.example.puzzle')!;
|
|
expect(p).toMatchObject({ store: 'ios', id: '6400000101', platforms: ['IOS'], latest: { version: '2.4.0', state: 'WAITING_FOR_REVIEW', platform: 'IOS' }, sku: 'PUZZLE1' });
|
|
expect(p.icon).toBe('data:image/png;base64,iVBORw==');
|
|
expect(icons).toEqual(['https://is1-ssl.mzstatic.com/image/thumb/Purple/v4/ab/cd/AppIcon.png/128x128bb.png']);
|
|
expect(r.apps.find(a => a.id === '6400000103')!.platforms).toEqual(['MAC_OS']);
|
|
expect(r.apps.find(a => a.id === '6400000102')!).toMatchObject({ platforms: [], latest: null, icon: null });
|
|
expect(calls.filter(c => c.startsWith('/v1/builds')).length).toBe(3);
|
|
expect(r.warnings).toEqual([]);
|
|
});
|
|
test('no icons unless asked; a failing version read is a warning, not a failure', async () => {
|
|
const calls: string[] = [];
|
|
const get = ascGet(calls);
|
|
const r = await listAppleApps(async p => { if (p.includes('6400000102/appStoreVersions')) throw new Error(`GET ${p} -> 500 boom`); return get(p); });
|
|
expect(calls.some(c => c.startsWith('/v1/builds'))).toBe(false);
|
|
expect(r.apps.every(a => a.icon === null)).toBe(true);
|
|
expect(r.warnings).toEqual([expect.stringContaining('com.example.notes: versions not read')]);
|
|
});
|
|
test('icon templates only from Apple\'s CDN', async () => {
|
|
const r = await listAppleApps(async p => {
|
|
if (p.startsWith('/v1/apps?fields')) return { data: [ASC_APPS_1.data[0]] };
|
|
if (p.includes('appStoreVersions')) return { data: [] };
|
|
return { data: [{ attributes: { iconAssetToken: { templateUrl: 'https://evil.example.com/{w}x{h}bb.{f}' } } }] };
|
|
}, { icons: true, bytes: async () => { throw new Error('must not fetch'); } });
|
|
expect(r.apps[0]!.icon).toBeNull();
|
|
expect(iconUrl('https://a.mzstatic.com/x/{w}x{h}{c}.{f}', 64)).toBe('https://a.mzstatic.com/x/64x64bb.png');
|
|
});
|
|
});
|
|
|
|
describe('apps list: Google Play', () => {
|
|
test('apps:search pages, display name falls back to the package', async () => {
|
|
const calls: string[] = [];
|
|
const r = await listPlayApps(async p => { calls.push(p); return p.includes('pageToken=tok2') ? PLAY_SEARCH_2 : PLAY_SEARCH_1; });
|
|
expect(calls).toEqual(['/apps:search?pageSize=1000', '/apps:search?pageSize=1000&pageToken=tok2']);
|
|
expect(r.apps.map(a => [a.name, a.id, a.store])).toEqual([['com.example.ledger', 'com.example.ledger', 'android'], ['Example Puzzle', 'com.example.puzzle', 'android'], ['Runner', 'com.example.runner', 'android']]);
|
|
});
|
|
});
|
|
|
|
describe('account health', () => {
|
|
test('App Store Connect: ok, no apps, revoked (401), permission (403), unreachable', () => {
|
|
expect(appleCheck({ apps: 3 })).toMatchObject({ status: 'ok', apps: 3, fix: null });
|
|
expect(appleCheck({ apps: 0 }).status).toBe('no-apps');
|
|
const revoked = appleCheck({ error: new Error('GET /v1/apps -> 401 {"errors":[{"status":"401","code":"NOT_AUTHORIZED"}]}') });
|
|
expect(revoked.status).toBe('revoked');
|
|
expect(revoked.fix).toContain('Integrations');
|
|
expect(appleCheck({ error: new Error('GET /v1/apps -> 403 {"errors":[{"code":"FORBIDDEN_ERROR"}]}') }).status).toBe('permission');
|
|
expect(appleCheck({ error: new Error('fetch failed') }).status).toBe('error');
|
|
});
|
|
test('Play: ok, not invited (0 apps), API off, revoked key, missing permission; the hint names the service account', () => {
|
|
const who = 'play-bot@example.com';
|
|
expect(playCheck(who, { apps: 2 })).toMatchObject({ status: 'ok', apps: 2, principal: who });
|
|
const none = playCheck(who, { apps: 0 });
|
|
expect(none.status).toBe('permission');
|
|
expect(none.fix).toContain(`Invite new users: add ${who}`);
|
|
expect(none.fix).toContain('several developer accounts');
|
|
expect(playCheck(who, { error: new Error('GET /v1beta1/apps:search -> 403 {"error":{"code":403,"status":"PERMISSION_DENIED","details":[{"reason":"SERVICE_DISABLED"}],"message":"Google Play Developer Reporting API has not been used in project 123 before or it is disabled."}}') }).status).toBe('api-disabled');
|
|
expect(playCheck(who, { error: new Error('Google token: Invalid JWT Signature.') }).status).toBe('revoked');
|
|
expect(playCheck(who, { error: new Error('Google token: invalid_grant') }).status).toBe('revoked');
|
|
expect(playCheck(who, { error: new Error('GET /apps:search -> 403 {"error":{"status":"PERMISSION_DENIED","message":"The caller does not have permission"}}') }).status).toBe('permission');
|
|
});
|
|
});
|
|
|
|
// ---- the CLI over a mocked fetch (real JWT signing with throwaway keys) ----
|
|
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]; } });
|
|
|
|
function mockFetch(routes: (url: string, init?: RequestInit) => Response | null): string[] {
|
|
const seen: string[] = [];
|
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = String(input instanceof Request ? input.url : input);
|
|
seen.push(`${init?.method ?? 'GET'} ${url}`);
|
|
const r = routes(url, init);
|
|
if (!r) return new Response('{"error":"not recorded"}', { status: 404 });
|
|
return r;
|
|
}) as typeof fetch;
|
|
return seen;
|
|
}
|
|
const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
|
|
|
|
describe('storebound apps (CLI functions, mocked HTTP)', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'sb-apps-'));
|
|
test('apple: list with icons, check', async () => {
|
|
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();
|
|
const get = ascGet([]);
|
|
const seen = mockFetch(url => {
|
|
if (url.startsWith('https://is1-ssl.mzstatic.com/')) return new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } });
|
|
if (url.startsWith('https://api.appstoreconnect.apple.com')) return null;
|
|
return null;
|
|
});
|
|
// route ASC through the recorded getter
|
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = String(input);
|
|
seen.push(url);
|
|
const auth = new Headers(init?.headers).get('Authorization') ?? '';
|
|
if (url.startsWith('https://api.appstoreconnect')) expect(auth).toMatch(/^Bearer [\w-]+\.[\w-]+\.[\w-]+$/);
|
|
if (url.startsWith('https://is1-ssl.mzstatic.com/')) return new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } });
|
|
try { return json(await get(url.replace('https://api.appstoreconnect.apple.com', ''))); } catch { return json({ errors: [{ status: '404' }] }, 404); }
|
|
}) as typeof fetch;
|
|
const r = await listApps('apple', root, true);
|
|
expect(r.store).toBe('ios');
|
|
expect(r.apps.length).toBe(3);
|
|
expect(r.apps.find(a => a.id === '6400000101')!.icon).toBe('data:image/png;base64,AQID');
|
|
expect(await checkAccount('apple', root)).toMatchObject({ status: 'ok', apps: 3 }); // meta.paging.total of the team
|
|
globalThis.fetch = (async () => json({ errors: [{ status: '401', code: 'NOT_AUTHORIZED' }] }, 401)) as unknown as typeof fetch;
|
|
expect((await checkAccount('apple', root)).status).toBe('revoked');
|
|
});
|
|
test('play: token exchange, apps:search, check; no apps = not invited', async () => {
|
|
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' });
|
|
let apps: unknown[] = PLAY_SEARCH_2.apps;
|
|
const seen = mockFetch(url => {
|
|
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 json({ apps });
|
|
return null;
|
|
});
|
|
const r = await listApps('play', root, false);
|
|
expect(r.apps.map(a => a.id)).toEqual(['com.example.ledger']);
|
|
expect(seen.some(s => s.startsWith('POST https://oauth2.googleapis.com/token'))).toBe(true);
|
|
expect(await checkAccount('play', root)).toMatchObject({ status: 'ok', principal: 'sb@example.com' });
|
|
apps = [];
|
|
expect((await checkAccount('play', root)).status).toBe('permission');
|
|
});
|
|
});
|
|
|
|
// ---- serve: apps.list, account.check, store-only apps ----
|
|
const SECRET_HEX = Array.from({ length: 32 }, (_, i) => i.toString(16).padStart(2, '0')).join('');
|
|
async function session(reply: (argv: string[], o: RunOptions) => { code?: number; out?: string; data?: unknown } = () => ({})) {
|
|
const calls: { argv: string[]; o: RunOptions }[] = [];
|
|
const run = (argv: string[], o: RunOptions): Running => { calls.push({ argv, o }); const r = reply(argv, o); return { result: Promise.resolve({ code: r.code ?? 0, out: r.out ?? '', ...(r.data === undefined ? {} : { data: r.data }), cancelled: false }), cancel: () => {} }; };
|
|
const s = new Session(() => {}, run);
|
|
let id = 0;
|
|
const call = async (method: string, params: unknown = {}) => (await s.handle({ jsonrpc: '2.0', id: ++id, method, params }))! as J;
|
|
await call('session.hello', { secret: SECRET_HEX });
|
|
return { calls, call };
|
|
}
|
|
const PLAY = { PLAY_SERVICE_ACCOUNT: JSON.stringify({ client_email: 'x@example.com', private_key: '-----BEGIN PRIVATE KEY-----' }) };
|
|
const APPLE = { ASC_KEY_ID: 'ABCDE12345', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: '-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----' };
|
|
|
|
describe('serve: account methods', () => {
|
|
test('described as kind "account", network reads', () => {
|
|
const m = describeMethods().filter(x => x.kind === 'account').map(x => x.method).sort();
|
|
expect(m).toEqual(['account.check', 'apps.list']);
|
|
});
|
|
test('apps.list: one account, its store from the credentials, icons on request, in an empty folder', async () => {
|
|
const s = await session(() => ({ data: { store: 'android', apps: [], warnings: [], at: 'x' } }));
|
|
const r = await s.call('apps.list', { credentials: PLAY, args: { icons: true } });
|
|
expect(r.result).toEqual({ store: 'android', apps: [], warnings: [], at: 'x' });
|
|
expect(s.calls[0]!.argv).toEqual(['apps', 'list', '--store', 'play', '--icons', '--json']);
|
|
// exactly this account: no credential profile of the machine mixes in
|
|
expect(s.calls[0]!.o.env).toEqual({ ...PLAY, STOREBOUND_PROFILE: '' });
|
|
expect(existsSync(join(s.calls[0]!.o.cwd, '.storebound'))).toBe(false);
|
|
await s.call('account.check', { credentials: APPLE });
|
|
expect(s.calls[1]!.argv).toEqual(['apps', 'check', '--store', 'apple', '--json']);
|
|
});
|
|
test('apps.list: refuses no account, two accounts, unknown args', async () => {
|
|
const s = await session();
|
|
for (const p of [{ credentials: {} }, { credentials: { ...PLAY, ...APPLE } }, { credentials: PLAY, args: { only: 'x' } }, { credentials: PLAY, project: '/tmp' }, { credentials: APPLE, args: { icons: 'yes' } }])
|
|
expect((await s.call('apps.list', p)).error).toMatchObject({ code: ERR.invalidParams });
|
|
expect((await s.call('account.check', { credentials: PLAY, args: { icons: true } })).error).toMatchObject({ code: ERR.invalidParams });
|
|
expect(s.calls.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('serve: store-only apps (virtual)', () => {
|
|
const V = { name: 'Example Puzzle', apple: { bundleId: 'com.example.puzzle', appId: '6400000101' }, play: { packageName: 'com.example.puzzle' } };
|
|
test('generated config validates and only carries store ids and placeholders', () => {
|
|
expect(validateConfig(virtualConfig(V))).toEqual([]);
|
|
expect(validateConfig(virtualConfig({ name: 'Only Play', play: { packageName: 'com.example.runner' } }))).toEqual([]);
|
|
const dir = virtualDir(V);
|
|
expect(JSON.parse(readFileSync(join(dir, 'storebound.config.json'), 'utf8')).apple).toEqual({ bundleId: 'com.example.puzzle', appId: '6400000101' });
|
|
expect(virtualDir(V)).toBe(dir);
|
|
for (const bad of [{}, { name: '' }, { name: 'x' }, { name: 'x', apple: { bundleId: '../etc' } }, { name: 'x', play: { packageName: 'nope' } }, { name: 'x', apple: { bundleId: 'com.a.b', appId: '12a' } }, { name: 'x', play: { packageName: 'com.a.b' }, path: '/etc' }])
|
|
expect(() => checkVirtual(bad)).toThrow();
|
|
});
|
|
test('store reads run in the generated folder; writes and offline project reads are refused', async () => {
|
|
const s = await session(() => ({ data: { ok: true } }));
|
|
const r = await s.call('reviews.list', { virtual: V, args: { days: 30 }, credentials: APPLE });
|
|
expect(r.error).toBeUndefined();
|
|
expect(existsSync(join(s.calls[0]!.o.cwd, 'storebound.config.json'))).toBe(true);
|
|
expect([...VIRTUAL_READS].sort()).toEqual(['apple_ppo_status', 'apple_status', 'apple_testflight_status', 'art_live', 'art_thumb', 'health', 'health_issue', 'listing_live', 'play_status', 'reviews_list']);
|
|
for (const m of ['play.listing', 'catalog.diff', 'project.listings', 'check', 'reviews.approve'])
|
|
expect((await s.call(m, { virtual: V, args: m === 'reviews.approve' ? { file: 'apple-1.md' } : {} })).error).toMatchObject({ code: ERR.invalidParams });
|
|
expect((await s.call('health', { virtual: V, project: '/tmp' })).error).toMatchObject({ code: ERR.invalidParams });
|
|
});
|
|
test('portfolio.status takes store-only entries by key: inspected, no config check, keyed back', async () => {
|
|
const s = await session(argv => (argv[0] === 'inspect' ? { data: { dir: '/x', config: '/x/c', name: 'Example Puzzle', languages: ['en'], play: { packageName: 'com.example.puzzle' }, apple: { bundleId: 'com.example.puzzle', appId: '6400000101' }, platforms: ['ios', 'android'], products: 0, productIds: [], subscriptions: 0, subscriptionIds: [], catalog: { ios: [], android: [] }, shots: false, testflightGroups: 0 } } : { data: {} }));
|
|
const key = 'store:ios:a1b2:6400000101:com.example.puzzle;android:c3d4:com.example.puzzle';
|
|
expect(STORE_KEY_RE.test(key)).toBe(true);
|
|
const r = await s.call('portfolio.status', { projects: [{ key, virtual: V }] });
|
|
const app = (r.result as { apps: J[] }).apps[0]!;
|
|
expect(app).toMatchObject({ project: key, virtual: true, check: { ok: true, problems: [], warnings: [] } });
|
|
expect(s.calls.map(c => c.argv[0])).toEqual(['inspect']);
|
|
const bad = await s.call('portfolio.status', { projects: [{ key: 'store:../x y', virtual: V }] });
|
|
expect(((bad.result as { apps: J[] }).apps[0]!.errors as string[])[0]).toContain('store target key');
|
|
});
|
|
});
|