339 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|