storebound/tests/store-apps.test.ts

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');
});
});