storebound/tests/credential-broker.test.ts

339 lines
16 KiB
TypeScript

// 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<Response>;
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<typeof Bun.serve>;
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<string, unknown> = {}): 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');
});
});