storebound/tests/google-errors.test.ts

76 lines
5.1 KiB
TypeScript

// Google API errors as typed findings (pitfall 57): the live 403 SERVICE_DISABLED body of the Play Developer Reporting
// API, a missing app permission, a revoked key and a quota; the one plain line the CLI prints; and the desktop
// runner's outcome of exit codes (scan / check / validate exit 1 with findings is a result, not an error).
import { describe, expect, test } from 'bun:test';
import { EXAMPLE_CLOUD_PROJECT, healthApiDisabled, SCAN_FAILED, SCAN_FAILED_LINE, serviceDisabledMessage } from '../apps/desktop/src/dev/error-fixtures';
import { errorLine, outcomeOf } from '../cli/lib/desktop-protocol';
import { findingText, parseGoogleError, plainError, storeReadError } from '../cli/lib/google-errors';
import { healthFindings, healthTable } from '../cli/lib/health';
const URL = `https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=${EXAMPLE_CLOUD_PROJECT}`;
const LINE = `Crash and ANR data needs the Google Play Developer Reporting API turned on in Google Cloud project ${EXAMPLE_CLOUD_PROJECT}.`;
describe('403 SERVICE_DISABLED (live shape)', () => {
const msg = serviceDisabledMessage('com.example.demo');
test('parses to api-disabled with the service, project and a console URL the desktop may open', () => {
expect(parseGoogleError(msg)).toEqual({ kind: 'api-disabled', service: 'playdeveloperreporting.googleapis.com', serviceTitle: 'Google Play Developer Reporting API', projectId: EXAMPLE_CLOUD_PROJECT, activationUrl: URL });
});
test('the CLI line: one line with the URL, no JSON', () => {
const e = storeReadError(msg, { app: 'com.example.demo' });
expect(e.error).toBe(`${LINE} Turn it on: ${URL}`);
expect(e.error).not.toContain('{');
expect(e.detail).toBe(msg);
expect(findingText(e.finding!)).toBe(LINE);
});
test('the plain line parses back (cached snapshots and portfolio store errors carry only the line)', () => {
expect(parseGoogleError(storeReadError(msg).error)).toEqual(parseGoogleError(msg));
});
test('a body cut at 800 characters (errorBody) still parses', () => {
expect(parseGoogleError(msg.slice(0, 400))?.kind).toBe('api-disabled');
});
test('health: the table and findings say it in words', () => {
const h = healthApiDisabled('com.example.demo');
expect(healthTable(h)).not.toContain('{"error"');
const f = healthFindings(h);
expect(f[0]!.msg).toContain(`Reporting API is off in the service account's Google Cloud project ${EXAMPLE_CLOUD_PROJECT}`);
expect(f[0]!.fix).toContain(URL);
});
});
describe('other Google errors', () => {
test('403 PERMISSION_DENIED without SERVICE_DISABLED: no access to that app, with the Users and permissions step', () => {
const m = 'GET /androidpublisher/v3/applications/com.example.somegame/edits -> 403 {"error":{"code":403,"message":"The caller does not have permission","status":"PERMISSION_DENIED"}}';
const f = parseGoogleError(m);
expect(f).toMatchObject({ kind: 'no-app-access', app: 'com.example.somegame' });
expect(findingText(f!)).toContain('no access to com.example.somegame in Play Console');
expect(findingText(f!)).toContain('Users and permissions');
});
test('invalid_grant and 401: the key is revoked or expired', () => {
expect(parseGoogleError('Google token: invalid_grant: Invalid JWT Signature.')?.kind).toBe('auth-revoked');
expect(parseGoogleError('GET /v1beta1/apps:search -> 401 {"error":{"code":401,"status":"UNAUTHENTICATED"}}')?.kind).toBe('auth-revoked');
});
test('429 / RESOURCE_EXHAUSTED: quota', () => {
const f = parseGoogleError('POST /v1beta1/apps/com.example.a/anrRateMetricSet:query -> 429 {"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[{"metadata":{"service":"playdeveloperreporting.googleapis.com"}}]}}');
expect(f).toEqual({ kind: 'quota', serviceTitle: 'Google Play Developer Reporting API' });
});
test('an unknown error keeps its message, never the JSON', () => {
const p = plainError('GET /x -> 500 {"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}');
expect(p).toBe('GET /x -> 500 Internal error encountered.');
});
});
describe('desktop runner: exit codes', () => {
test('scan / check / validate / health exit 1 with the result printed are findings; without it, failed', () => {
for (const m of ['scan', 'check', 'validate', 'health']) expect(outcomeOf(m, 1, true)).toBe('findings');
expect(outcomeOf('scan', 1, false)).toBe('failed');
expect(outcomeOf('play_status', 1, true)).toBe('failed');
expect(outcomeOf('owner.apple.submit', 3, false)).toBe('declined');
expect(outcomeOf('scan', 0, true)).toBe('ok');
});
test('the error line is the command\'s own: "error: ..." of stderr, else the first message before the usage', () => {
expect(SCAN_FAILED.error).toBe(SCAN_FAILED_LINE);
expect(errorLine('unknown option for storebound scan: --x\n\nusage: storebound scan [--json]\n --json print JSON')).toBe('unknown option for storebound scan: --x');
expect(errorLine('', '12:00:00 [scan] reading\n12:00:01 [scan] the folder has no app')).toBe('the folder has no app');
expect(errorLine('', '')).toBe('');
});
});