// Google access through a credential broker (cli/lib/broker.ts, playToken in cli/lib/google-auth.ts), against a local // Bun.serve fake broker: token minting, caching, single flight and expiry, fallback to the local key, the package's // Play permissions, the missing-grant guardrail (preflight and Google's own 403), key file permissions, and that no // key or token shows up in any output. import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import { generateKeyPairSync } from 'node:crypto'; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type BrokerConfig, brokerConfig, brokerKey, brokerPlayInfo, BrokerRefused, brokerToken, checkBrokerUrl, clearBrokerCache, playGaps, playPreflight, redact } from '../cli/lib/broker'; import { playCredentials, playToken } from '../cli/lib/google-auth'; import { accessError, GoogleAccessError } from '../cli/lib/google-access'; import { playClient } from '../cli/play/client'; import { doctor, playLine } from '../cli/doctor'; const PKG = 'com.example.game'; const ACCOUNT = 'publisher@sb-test.example.com'; const API_KEY = 'brokerkey_0123456789abcdef'; const ENV = ['PLAY_SERVICE_ACCOUNT', 'PLAY_SERVICE_ACCOUNT_EMAIL', 'STOREBOUND_BROKER', 'STOREBOUND_BROKER_URL', 'STOREBOUND_BROKER_KEY_FILE', 'STOREBOUND_BROKER_CONFIG', 'STOREBOUND_CREDENTIALS_DIR', 'STOREBOUND_PROFILE', 'STOREBOUND_BROKER_TIMEOUT_MS'] as const; const saved = Object.fromEntries(ENV.map(k => [k, process.env[k]])); interface Seen { method: string; path: string; auth: string | null; body: unknown } let seen: Seen[] = []; let mints = 0; /** What the fake broker answers; tests change it. */ let route: (s: Seen) => Response | Promise; const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } }); const nowS = () => Math.floor(Date.now() / 1000); const defaultRoute = (s: Seen): Response => { if (s.path === '/api/google/token') { mints++; return json({ accessToken: `ya29.fake-${mints}`, expiresAt: nowS() + 3600, clientEmail: ACCOUNT, scopes: ['androidpublisher'] }); } if (s.path === `/api/google/play/${PKG}/storebound`) return json(INFO); return json({ error: 'err_not_found' }, 404); }; const INFO = { packageName: PKG, developerId: '5550000000000000001', account: ACCOUNT, accountPermissions: { developer: [], app: ['CAN_MANAGE_PUBLIC_LISTING', 'CAN_MANAGE_ORDERS'] }, grantsCheckedAt: 1761390000, projects: [{ projectId: 'sb-test-project', projectNumber: '100000000001', enabledApis: ['androidpublisher.googleapis.com'], billing: 'linked' }], sdkConfigs: [], }; let server: ReturnType; let tmp: string, root: string, keyFile: string; const realFetch = globalThis.fetch; beforeAll(() => { server = Bun.serve({ port: 0, async fetch(req) { const u = new URL(req.url); const text = await req.text(); const s: Seen = { method: req.method, path: decodeURIComponent(u.pathname), auth: req.headers.get('authorization'), body: text ? JSON.parse(text) : null }; seen.push(s); return route(s); }, }); }); afterAll(() => { server.stop(true); }); function writeBroker(o: Record = {}): void { writeFileSync(join(tmp, 'broker.json'), JSON.stringify({ url: `http://127.0.0.1:${server.port}`, apiKeyFile: keyFile, account: ACCOUNT, ...o })); } function localKey(): void { const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: ACCOUNT, private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' }); } /** fetch double for Google only (the local key's token exchange, the Play API); the fake broker is reached for real. */ function fakeGoogle(api: (url: string, init?: RequestInit) => Response = () => json({})): string[] { const calls: string[] = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input instanceof Request ? input.url : input); if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'local-file-token', expires_in: 3600 }); if (/googleapis\.com/.test(url)) { calls.push(`${init?.method ?? 'GET'} ${url}`); return api(url, init); } return realFetch(input as Request, init); }) as typeof fetch; return calls; } beforeEach(() => { for (const k of ENV) delete process.env[k]; tmp = mkdtempSync(join(tmpdir(), 'sb-broker-')); root = join(tmp, 'project'); mkdirSync(root, { recursive: true }); keyFile = join(tmp, 'broker.key'); writeFileSync(keyFile, API_KEY + '\n', { mode: 0o600 }); process.env.STOREBOUND_BROKER_CONFIG = join(tmp, 'broker.json'); process.env.STOREBOUND_CREDENTIALS_DIR = join(tmp, 'credentials'); process.env.STOREBOUND_PROFILE = ''; seen = []; mints = 0; route = defaultRoute; clearBrokerCache(); }); afterEach(() => { globalThis.fetch = realFetch; rmSync(tmp, { recursive: true, force: true }); for (const k of ENV) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }); describe('config', () => { test('broker.json, env overrides, https only, per-profile opt-out', () => { expect(brokerConfig(root)).toBeNull(); writeBroker(); const c = brokerConfig(root)!; expect(c.url).toBe(`http://127.0.0.1:${server.port}`); expect(c.keyFile).toBe(keyFile); expect(c.account).toBe(ACCOUNT); // PLAY_SERVICE_ACCOUNT_EMAIL (here a broker credential id) wins over broker.json process.env.PLAY_SERVICE_ACCOUNT_EMAIL = 'cred-play-1'; expect(brokerConfig(root)!.account).toBe('cred-play-1'); delete process.env.PLAY_SERVICE_ACCOUNT_EMAIL; process.env.STOREBOUND_BROKER_URL = 'https://broker.example.com/'; expect(brokerConfig(root)!.url).toBe('https://broker.example.com'); expect(brokerConfig(root)!.from).toBe('env'); process.env.STOREBOUND_BROKER = 'off'; expect(brokerConfig(root)).toBeNull(); delete process.env.STOREBOUND_BROKER; expect(() => checkBrokerUrl('http://broker.example.com')).toThrow(/https only/); // a profile that keeps its own key const creds = join(tmp, 'credentials'); mkdirSync(creds, { recursive: true }); writeFileSync(join(creds, 'p1.json'), JSON.stringify({ broker: false })); process.env.STOREBOUND_PROFILE = 'p1'; expect(brokerConfig(root)).toBeNull(); }); test('a broken broker.json is an error, not a silent skip', () => { writeFileSync(join(tmp, 'broker.json'), '{ not json'); expect(() => brokerConfig(root)).toThrow(/broker\.json/); }); }); describe('key file permissions', () => { test('group / world readable refused on POSIX, accepted on Windows; the key is never in the message', () => { writeBroker(); const cfg = brokerConfig(root)!; if (process.platform !== 'win32') { chmodSync(keyFile, 0o644); expect(() => brokerKey(cfg)).toThrow(/chmod 600/); chmodSync(keyFile, 0o600); expect(brokerKey(cfg)).toBe(API_KEY); } else { // Windows reports 0o666 for every writable file: the POSIX rule is checked by naming the platform let msg = ''; try { brokerKey(cfg, 'linux'); } catch (e) { msg = (e as Error).message; } expect(msg).toMatch(/readable by group or others .*chmod 600/); expect(msg).not.toContain(API_KEY); expect(brokerKey(cfg, 'win32')).toBe(API_KEY); } rmSync(keyFile); expect(() => brokerKey(cfg)).toThrow(/not found/); }); }); describe('token minting', () => { test('one mint, cached until 5 minutes before expiry, scoped request with the bearer key', async () => { writeBroker(); const t = playToken(root); expect(await t()).toBe('ya29.fake-1'); expect(await t()).toBe('ya29.fake-1'); expect(mints).toBe(1); expect(t.source()).toBe('broker'); expect(t.principal()).toBe(ACCOUNT); const mint = seen.find(s => s.path === '/api/google/token')!; expect(mint.method).toBe('POST'); expect(mint.auth).toBe(`Bearer ${API_KEY}`); expect(mint.body).toEqual({ account: ACCOUNT, scopes: ['androidpublisher'] }); }); test('parallel callers share one request (single flight)', async () => { writeBroker(); route = async s => { await Bun.sleep(50); return defaultRoute(s); }; const t = playToken(root); const all = await Promise.all(Array.from({ length: 6 }, () => t())); expect(new Set(all)).toEqual(new Set(['ya29.fake-1'])); expect(mints).toBe(1); }); test('a token close to expiry is minted again', async () => { writeBroker(); const cfg = brokerConfig(root)!; let clock = Date.now(); expect(await brokerToken(cfg, ACCOUNT, 'androidpublisher', () => clock)).toBe('ya29.fake-1'); clock += 3000_000; // 50 min later: 10 min left, still cached expect(await brokerToken(cfg, ACCOUNT, 'androidpublisher', () => clock)).toBe('ya29.fake-1'); clock += 400_000; // under 5 min left expect(await brokerToken(cfg, ACCOUNT, 'androidpublisher', () => clock)).toBe('ya29.fake-2'); expect(mints).toBe(2); }); test('the account comes from the broker for the package when nothing names it', async () => { writeBroker({ account: undefined }); const t = playToken(root, undefined, { packageName: PKG }); expect(await t()).toBe('ya29.fake-1'); expect((seen.find(s => s.path === '/api/google/token')!.body as { account: string }).account).toBe(ACCOUNT); }); test('Reporting has no broker scope: the local key serves it, and without one the error says so', () => { writeBroker(); expect(() => playToken(root, 'https://www.googleapis.com/auth/playdeveloperreporting')).toThrow(/hands out no such scope/); }); }); describe('fallback to the local key', () => { test('broker not answering: the local key, one warning, source "file"', async () => { localKey(); fakeGoogle(); writeFileSync(join(tmp, 'broker.json'), JSON.stringify({ url: 'http://127.0.0.1:9', apiKeyFile: keyFile })); process.env.STOREBOUND_BROKER_TIMEOUT_MS = '2000'; const warnings: string[] = []; const t = playToken(root, undefined, { warn: m => warnings.push(m) }); expect(await t()).toBe('local-file-token'); expect(await t()).toBe('local-file-token'); expect(t.source()).toBe('file'); expect(warnings.length).toBe(1); expect(warnings[0]).toMatch(/did not answer.*local service account key/); }); test('broker 503: the local key', async () => { localKey(); fakeGoogle(); writeBroker(); route = () => json({ error: 'down' }, 503); const t = playToken(root, undefined, { warn: () => {} }); expect(await t()).toBe('local-file-token'); }); test('broker refusing the API key (401): no fallback, no retry, the key lacks google:token', async () => { localKey(); fakeGoogle(); writeBroker(); route = () => json({ error: 'err_scope', echo: `Bearer ${API_KEY}` }, 401); const t = playToken(root, undefined, { warn: () => {} }); const e = await t().then(() => null, x => x as Error); expect(e).toBeInstanceOf(BrokerRefused); expect(e!.message).toMatch(/lacks the google:token scope/); expect(e!.message).not.toContain(API_KEY); expect(seen.filter(s => s.path === '/api/google/token').length).toBe(1); }); test('no broker: the local key as before; neither: the PLAY_SERVICE_ACCOUNT error', async () => { expect(() => playToken(root)).toThrow(/PLAY_SERVICE_ACCOUNT is not set/); localKey(); fakeGoogle(); const t = playToken(root); expect(await t()).toBe('local-file-token'); expect(t.source()).toBe('file'); expect(seen.length).toBe(0); }); }); describe('Play permissions from the broker', () => { test('grants and APIs read; gaps name the permission, the API and their links', async () => { writeBroker(); const r = await brokerPlayInfo(brokerConfig(root)!, PKG); expect(r.kind).toBe('ok'); if (r.kind !== 'ok') return; const gaps = playGaps(r.info, ['upload', 'listing', 'games']); expect(gaps.map(g => g.name)).toEqual(['CAN_MANAGE_TRACK_APKS', 'gamesconfiguration.googleapis.com']); expect(gaps[0]!.url).toBe('https://play.google.com/console/u/0/developers/5550000000000000001/users-and-permissions'); expect(gaps[0]!.fix).toMatch(/google_play_grant/); expect(gaps[1]!.url).toBe('https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=sb-test-project'); // read once per run and package await brokerPlayInfo(brokerConfig(root)!, PKG); expect(seen.filter(s => s.path.endsWith('/storebound')).length).toBe(1); }); test('404: register the package, not "no access"', async () => { writeBroker(); const r = await brokerPlayInfo(brokerConfig(root)!, 'com.example.other'); expect(r.kind).toBe('not-registered'); if (r.kind === 'not-registered') expect(r.message).toMatch(/register the package .*not a missing Play access/); }); test('preflight: a write stops before any Play call with the exact grant; a dry run only warns', async () => { writeBroker(); const google = fakeGoogle(); const e = await playPreflight('play upload', root, PKG, { dry: false, warn: () => {} }).catch(x => x as Error); expect(e).toBeInstanceOf(Error); expect((e as Error).message).toMatch(/stopped before any call to Google Play/); expect((e as Error).message).toMatch(/CAN_MANAGE_TRACK_APKS for com\.example\.game/); const warned: string[] = []; expect((await playPreflight('play upload', root, PKG, { dry: true, warn: m => warned.push(m) })).length).toBe(1); expect(warned[0]).toMatch(/CAN_MANAGE_TRACK_APKS/); // granted: nothing; a command without needs: nothing expect(await playPreflight('play listing', root, PKG, { dry: false, warn: () => {} })).toEqual([]); expect(await playPreflight('play status', root, PKG, { dry: false, warn: () => {} })).toEqual([]); expect(google).toEqual([]); }); }); describe('Google 403 guardrail', () => { const DISABLED = `GET /edits -> 403 {"error":{"code":403,"message":"Google Play Android Developer API has not been used in project 100000000001 before or it is disabled.","status":"PERMISSION_DENIED","details":[{"reason":"SERVICE_DISABLED","metadata":{"service":"androidpublisher.googleapis.com","consumer":"projects/sb-test-project"}}]}}`; test('SERVICE_DISABLED: the API and its enable link, never retried', async () => { const e = await accessError(DISABLED, { app: PKG }); expect(e).toBeInstanceOf(GoogleAccessError); expect(e!.noRetry).toBe(true); expect(e!.message).toContain('https://console.cloud.google.com/apis/library/androidpublisher.googleapis.com?project=sb-test-project'); }); test('PERMISSION_DENIED through the Play client with broker tokens: one call, the grants the broker lists', async () => { writeBroker(); const google = fakeGoogle(() => json({ error: { code: 403, message: 'The caller does not have permission', status: 'PERMISSION_DENIED' } }, 403)); const play = await playClient(root, PKG); const e = await play.call('POST', `${play.API}/edits`, {}).then(() => null, x => x as Error); expect(e).toBeInstanceOf(GoogleAccessError); expect(google.length).toBe(1); expect(e!.message).toMatch(/no access to com\.example\.game in Play Console/); expect(e!.message).toMatch(/credential broker lists CAN_MANAGE_ORDERS, CAN_MANAGE_PUBLIC_LISTING/); expect(play.credentialSource()).toBe('broker'); }); }); describe('no secret in output', () => { test('redact removes tokens, bearer values and the key', () => { expect(redact(`Authorization: Bearer ${API_KEY} token ya29.a0AfH6SM-x_y.z`, API_KEY)).toBe('Authorization: Bearer *** token ya29.***'); }); test('doctor and the credential summary carry the source, never the key or a token', async () => { writeBroker(); await playToken(root)(); const p = playCredentials(root); expect(p.source).toBe('broker'); const d = doctor(root); const out = JSON.stringify(d) + playLine(d.play); expect(d.play.source).toBe('broker'); expect(playLine(d.play)).toMatch(/tokens from the credential broker/); expect(out).not.toContain(API_KEY); expect(out).not.toContain('ya29.'); expect(out).not.toContain(ACCOUNT); // masked }); test('a broker error body echoing the key or a token is redacted', async () => { writeBroker(); route = () => new Response(`upstream failed for Bearer ${API_KEY} ya29.secret-token`, { status: 502 }); const e = await brokerToken(brokerConfig(root)! as BrokerConfig, ACCOUNT, 'androidpublisher').then(() => null, x => x as Error); expect(e!.message).not.toContain(API_KEY); expect(e!.message).not.toContain('ya29.secret-token'); }); });