diff --git a/CHANGELOG.md b/CHANGELOG.md index 93aba44..757ef35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased +- `storebound watch` has an `ingest` sink (`--notify ingest`, `ingest` in ~/.storebound/watch.json): a JSON POST per event to a push ingest endpoint with a Bearer token read from a file at send time (refused when group or world readable), 8 KiB body cap, 60 sends a minute, one retry on 5xx; `watch test` covers it. - Ship-step contract for release pipelines (docs/ship-step.md): `play build | upload | release`, `apple build | upload | submit | resubmit` exit 0 ok, 1 failure, 2 usage, 3 needs the owner (with `error.code` and the console deep link), 4 store refused, 7 nothing to do; `--json` ends with exactly one result line on stdout, `--result-file` writes it atomically (also on failure), `--progress json` gives one JSON line per phase on stderr. - `--env-file ` loads KEY=VALUE secrets before the credentials resolve (POSIX: mode 600 or stricter) and every known secret value is scrubbed from stdout, stderr and the result file; `--version-from version.json` takes the version and build ({version, build}, {versionName, versionCode}, {marketing, build}). - Re-runs never upload twice: `play upload` with a versionCode Play has and `apple upload` with a build number App Store Connect has end with exit 7 and what is there; `play release` with the same release and `apple submit` of an already submitted build too. diff --git a/cli/commands.ts b/cli/commands.ts index 3655643..ddce923 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -11,7 +11,7 @@ export const PLAIN: Record Promise, string]> = { 'import': [() => import('./import'), 'fastlane [--dir fastlane] [--profile app|game|subscription-app] [--dry]: Appfile, deliver + supply metadata and screenshots -> config, store/*.md, shots frames (read-only on fastlane, never overwrites)'], 'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'], 'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'], - 'watch': [() => import('./watch'), 'store events for every app on this machine: rejected, approved, live, rollout halted, crash threshold, low reviews; loops every --every 15m or --once; sinks --notify os|ntfy|telegram|webhook; test sends a test event; --dry, --dir , --json (read-only on the stores)'], + 'watch': [() => import('./watch'), 'store events for every app on this machine: rejected, approved, live, rollout halted, crash threshold, low reviews; loops every --every 15m or --once; sinks --notify os|ntfy|telegram|webhook|ingest; test sends a test event; --dry, --dir , --json (read-only on the stores)'], 'status': [() => import('./status'), '[--app ] [--json]: per platform the live and next version, build, track, review state and rollout %, without uploading (read-only; read by ship pipelines, docs/ship-step.md)'], 'events': [() => import('./events'), '[--since 24h] [--app ] [--kind rejected] [--json]: the events watch recorded in ~/.storebound/events.jsonl (read-only)'], 'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 864cd5a..9b96343 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -1696,7 +1696,7 @@ "storebound validate [--apple] [--play] [--json] [--src store/renders] [--no-local] [--no-health] [--no-scan]" ], "watch": [ - "storebound watch [--dir ] [--every 15m] [--notify os,ntfy,telegram,webhook] [--once] [--dry] [--json]", + "storebound watch [--dir ] [--every 15m] [--notify os,ntfy,telegram,webhook,ingest] [--once] [--dry] [--json]", "storebound watch test [--notify ...] [--dry] sends one test event to every configured sink" ], "web": [ diff --git a/cli/lib/watch-sinks.ts b/cli/lib/watch-sinks.ts index 83c5c69..5bbdfec 100644 --- a/cli/lib/watch-sinks.ts +++ b/cli/lib/watch-sinks.ts @@ -1,29 +1,33 @@ -// Where `storebound watch` sends events: OS notification, ntfy, Telegram, a webhook. All optional, configured from the +// Where `storebound watch` sends events: OS notification, ntfy, Telegram, a webhook, a push ingest endpoint. All optional, configured from the // environment or ~/.storebound/watch.json (never printed; logs name the sink, not its URL, topic or token): // STOREBOUND_NTFY_URL (default https://ntfy.sh) + STOREBOUND_NTFY_TOPIC [+ STOREBOUND_NTFY_TOKEN] // STOREBOUND_TELEGRAM_TOKEN + STOREBOUND_TELEGRAM_CHAT // STOREBOUND_WEBHOOK_URL -// watch.json: { "notify": ["os"], "ntfy": { "url", "topic", "token" }, "telegram": { "token", "chat" }, "webhook": { "url" } } +// STOREBOUND_INGEST_URL + STOREBOUND_INGEST_TOKEN_FILE [+ STOREBOUND_INGEST_CATEGORY] +// watch.json: { "notify": ["os"], "ntfy": { "url", "topic", "token" }, "telegram": { "token", "chat" }, "webhook": { "url" }, +// "ingest": { "url", "tokenFile", "category" } } // Builders are pure (tests check them); `send` takes the fetch and the OS runner as parameters. import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { readFileSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; import { type EventKind, watchConfigFile, type WatchEvent } from './watch'; -export type SinkName = 'os' | 'ntfy' | 'telegram' | 'webhook'; -export const SINK_NAMES: readonly SinkName[] = ['os', 'ntfy', 'telegram', 'webhook']; +export type SinkName = 'os' | 'ntfy' | 'telegram' | 'webhook' | 'ingest'; +export const SINK_NAMES: readonly SinkName[] = ['os', 'ntfy', 'telegram', 'webhook', 'ingest']; export interface SinkConfig { os?: true; ntfy?: { url: string; topic: string; token?: string }; telegram?: { token: string; chat: string }; webhook?: { url: string }; + ingest?: { url: string; tokenFile: string; category: string }; } type Env = Record; /** The sinks that are configured (os only when the file lists it). `only` limits and, for os, enables. */ export function sinkConfig(env: Env = process.env, file: string | null = watchConfigFile(env), only?: SinkName[]): SinkConfig { - let f: { notify?: string[]; ntfy?: Record; telegram?: Record; webhook?: Record } = {}; + let f: { notify?: string[]; ntfy?: Record; telegram?: Record; webhook?: Record; ingest?: Record } = {}; if (file) { try { f = JSON.parse(readFileSync(file, 'utf8')); } catch { /* no file */ } } const out: SinkConfig = {}; const topic = env.STOREBOUND_NTFY_TOPIC || f.ntfy?.topic; @@ -32,6 +36,8 @@ export function sinkConfig(env: Env = process.env, file: string | null = watchCo if (tg.token && tg.chat) out.telegram = { token: tg.token, chat: tg.chat }; const hook = env.STOREBOUND_WEBHOOK_URL || f.webhook?.url; if (hook) out.webhook = { url: hook }; + const iUrl = env.STOREBOUND_INGEST_URL || f.ingest?.url, iFile = env.STOREBOUND_INGEST_TOKEN_FILE || f.ingest?.tokenFile; + if (iUrl && iFile) out.ingest = { url: iUrl, tokenFile: iFile, category: env.STOREBOUND_INGEST_CATEGORY || f.ingest?.category || 'storebound' }; if (Array.isArray(f.notify) && f.notify.includes('os')) out.os = true; if (only) { const picked: SinkConfig = {}; @@ -96,7 +102,7 @@ export function osCommand(platform: string, title: string, message: string): { c return null; } -export type FetchLike = (url: string, init: { method: string; headers: Record; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number }>; +export type FetchLike = (url: string, init: { method: string; headers: Record; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; text?: () => Promise }>; export type OsRunner = (c: { cmd: string; args: string[]; env: Record }) => boolean; export const runOs: OsRunner = c => { @@ -118,6 +124,88 @@ export async function post(fetchFn: FetchLike, r: HttpRequest, o: { timeoutMs?: return last; } +// ---- ingest: one JSON POST per event to a push ingest endpoint (Bearer token read from a file at send time) ---- +export const INGEST_EVENTS: Partial> = { + rejected: 'review-rejected', approved: 'review-approved', live: 'live', rollout_halted: 'rollout-halted', + crash_threshold: 'crash-threshold', review_low: 'low-reviews', review_needs_reply: 'review-reply', +}; +export const INGEST_MAX_BODY = 8 * 1024; +export const INGEST_DETAIL_MAX = 300; +export const INGEST_PER_MINUTE = 60; +export const slugOf = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app'; + +function ingestDetail(e: WatchEvent): string { + if (e.kind === 'review_low' || e.kind === 'review_needs_reply') return e.to ?? e.detail; + if (e.kind === 'crash_threshold' && e.store === 'play') { + const m = /([\d.]+)\s*%/.exec(e.to ?? ''); + if (m) return `crash-free ${(100 - Number(m[1])).toFixed(1)}%`; + } + return e.detail; +} + +/** The exact JSON body the endpoint accepts (no other fields: unknown ones are a 400); null for an event with no mapping. */ +export function ingestBody(e: WatchEvent, c: { category: string }): Record | null { + const event = INGEST_EVENTS[e.kind]; + if (!event) return null; + const app = slugOf(e.app); + const body: Record = { category: c.category, event, app, platform: e.store === 'apple' ? 'ios' : 'android' }; + if (e.version) body.version = e.version; + body.detail = ingestDetail(e).replace(/\s+/g, ' ').trim().slice(0, INGEST_DETAIL_MAX); + body.url = e.url; + body.dedupeKey = `${app}:${event}:${e.version ?? ''}${e.ref ? `:${e.ref}` : ''}`; + // keep the whole body within 8 KiB (only detail is free text) + while (Buffer.byteLength(JSON.stringify(body)) > INGEST_MAX_BODY && body.detail!.length > 0) body.detail = body.detail!.slice(0, Math.max(0, body.detail!.length - 100)); + return body; +} + +const expandHome = (p: string): string => (p === '~' || /^~[\\/]/.test(p) ? homedir() + p.slice(1) : p); + +/** The token from its file (trimmed), or an error text that names the file, never the token. */ +export function readIngestToken(file: string): { token: string } | { error: string } { + const path = expandHome(file); + try { + const st = statSync(path); + if (process.platform !== 'win32' && (st.mode & 0o077)) return { error: `token file ${file} is readable by group or others, refusing to use it (chmod 600 ${file})` }; + const token = readFileSync(path, 'utf8').trim(); + if (!token) return { error: `token file ${file} is empty` }; + return { token }; + } catch { return { error: `token file ${file} cannot be read` }; } +} + +let sentAt: number[] = []; +/** Client-side limit: INGEST_PER_MINUTE sends in any 60 s window; true = allowed (and counted). */ +export function ingestAllow(now = Date.now()): boolean { + sentAt = sentAt.filter(t => now - t < 60_000); + if (sentAt.length >= INGEST_PER_MINUTE) return false; + sentAt.push(now); + return true; +} +export const resetIngestLimit = (): void => { sentAt = []; }; + +/** 202 = sent. 5xx and network errors are retried once; 4xx never (status and the server's message are the error). Resolves to an error text or null. */ +export async function sendIngest(e: WatchEvent, c: NonNullable, d: { fetch: FetchLike; timeoutMs?: number; retryMs?: number }): Promise { + const body = ingestBody(e, c); + if (!body) return null; + const t = readIngestToken(c.tokenFile); + if ('error' in t) return `not sent, ${t.error}`; + if (!ingestAllow()) return `not sent, rate limit of ${INGEST_PER_MINUTE}/min reached (event dropped)`; + const scrub = (s: string): string => s.split(t.token).join('***').replace(/\s+/g, ' ').slice(0, 200); + const init = { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t.token}` }, body: JSON.stringify(body) }; + let last = ''; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt) await new Promise(res => setTimeout(res, d.retryMs ?? 1500)); + try { + const res = await d.fetch(c.url, { ...init, signal: AbortSignal.timeout(d.timeoutMs ?? 10_000) }); + if (res.status === 202) return null; + let msg = ''; + try { msg = scrub(await res.text?.() ?? ''); } catch { /* no body */ } + last = `HTTP ${res.status}${msg ? `: ${msg}` : ''}`; + if (res.status < 500) return last; + } catch (err) { last = (err as Error).name === 'TimeoutError' ? 'timed out' : 'network error'; } + } + return last; +} + export interface SendReport { sink: SinkName; ok: boolean; error?: string } /** Sends one event to every sink in the config. A failing sink is a report row, never an exception. */ @@ -133,5 +221,6 @@ export async function sendEvent(e: WatchEvent, c: SinkConfig, d: { fetch?: Fetch if (c.ntfy) await http('ntfy', ntfyRequest(e, c.ntfy)); if (c.telegram) await http('telegram', telegramRequest(e, c.telegram)); if (c.webhook) await http('webhook', webhookRequest(e, c.webhook)); + if (c.ingest && INGEST_EVENTS[e.kind]) { const err = await sendIngest(e, c.ingest, { fetch: fetchFn, timeoutMs: d.timeoutMs, retryMs: d.retryMs }); out.push(err ? { sink: 'ingest', ok: false, error: err } : { sink: 'ingest', ok: true }); } return out; } diff --git a/cli/lib/watch.ts b/cli/lib/watch.ts index b037b37..8a11079 100644 --- a/cli/lib/watch.ts +++ b/cli/lib/watch.ts @@ -25,6 +25,8 @@ export interface WatchEvent { detail: string; /** The absolute console deep link for the app (App Store Connect / Play Console). */ url: string; + /** Review events only: the store's review id (dedupe key of the ingest sink). */ + ref?: string; } /** One version on one store, reduced to what the diff needs. */ @@ -189,8 +191,8 @@ export function diffApp(prev: AppSnap | undefined, next: AppSnap, at: string): W if (!prev) return []; const out: WatchEvent[] = []; const ids = { ...prev.ids, ...next.ids }; - const ev = (store: WatchStore, kind: EventKind, version: string | null, from: string | null, to: string | null, detail: string): void => { - out.push({ at, app: next.name, store, kind, version, from, to, detail, url: eventUrl(kind, store, ids) }); + const ev = (store: WatchStore, kind: EventKind, version: string | null, from: string | null, to: string | null, detail: string, ref?: string): void => { + out.push({ at, app: next.name, store, kind, version, from, to, detail, url: eventUrl(kind, store, ids), ...(ref ? { ref } : {}) }); }; for (const store of ['apple', 'play'] as const) { @@ -225,8 +227,8 @@ export function diffApp(prev: AppSnap | undefined, next: AppSnap, at: string): W const seen = new Set(p.map(r => r.id)); for (const r of n) { if (seen.has(r.id)) continue; - if (r.rating >= 1 && r.rating <= LOW_STARS) ev(store, 'review_low', null, null, `${r.rating} stars`, `${r.rating}-star review${r.replied ? '' : ', no reply yet'}: ${snippet(r.text)}`); - else if (!r.replied) ev(store, 'review_needs_reply', null, null, `${r.rating} stars`, `${r.rating}-star review without a reply: ${snippet(r.text)}`); + if (r.rating >= 1 && r.rating <= LOW_STARS) ev(store, 'review_low', null, null, `${r.rating} stars`, `${r.rating}-star review${r.replied ? '' : ', no reply yet'}: ${snippet(r.text)}`, r.id); + else if (!r.replied) ev(store, 'review_needs_reply', null, null, `${r.rating} stars`, `${r.rating}-star review without a reply: ${snippet(r.text)}`, r.id); } } return out; diff --git a/cli/watch.ts b/cli/watch.ts index 50b8f5a..4dda790 100644 --- a/cli/watch.ts +++ b/cli/watch.ts @@ -1,5 +1,5 @@ // `storebound watch`: store events for every app on this machine, for people and agents. -// storebound watch [--dir ] [--every 15m] [--notify os,ntfy,telegram,webhook] [--once] [--dry] [--json] +// storebound watch [--dir ] [--every 15m] [--notify os,ntfy,telegram,webhook,ingest] [--once] [--dry] [--json] // storebound watch test [--notify ...] [--dry] sends one test event to every configured sink // Apps: the machine's app folder list (~/.storebound/projects.json: folders and store-only apps, the same list as the // desktop app) or --dir for one folder. Each pass reads release state (like `release status`), crash / ANR health @@ -29,7 +29,7 @@ import { import { configuredSinks, parseNotify, sendEvent, sinkConfig, type SinkConfig } from './lib/watch-sinks'; const TAG = 'watch'; -const USAGE = 'usage: storebound watch [--dir ] [--every 15m] [--notify os,ntfy,telegram,webhook] [--once] [--dry] [--json] | watch test [--notify ...] [--dry]'; +const USAGE = 'usage: storebound watch [--dir ] [--every 15m] [--notify os,ntfy,telegram,webhook,ingest] [--once] [--dry] [--json] | watch test [--notify ...] [--dry]'; export const HEALTH_EVERY_MS = 60 * 60_000; const MIN_EVERY_MS = 5 * 60_000; const MAX_BACKOFF_MS = 4 * 60 * 60_000; @@ -150,7 +150,7 @@ function emit(e: WatchEvent, args: Args): void { async function testCmd(args: Args): Promise { const c = chosenSinks(args); const names = configuredSinks(c); - if (!names.length) { warn(TAG, 'no sink configured: set STOREBOUND_NTFY_TOPIC, STOREBOUND_TELEGRAM_TOKEN + STOREBOUND_TELEGRAM_CHAT, STOREBOUND_WEBHOOK_URL, or pass --notify os (docs/watch.md)'); process.exitCode = 1; return; } + if (!names.length) { warn(TAG, 'no sink configured: set STOREBOUND_NTFY_TOPIC, STOREBOUND_TELEGRAM_TOKEN + STOREBOUND_TELEGRAM_CHAT, STOREBOUND_WEBHOOK_URL, STOREBOUND_INGEST_URL + STOREBOUND_INGEST_TOKEN_FILE, or pass --notify os (docs/watch.md)'); process.exitCode = 1; return; } const e = testEvent(); emit(e, args); if (args.has('dry')) { done(TAG, `dry run: would send the test event to ${names.join(', ')}`); return; } diff --git a/docs/watch.md b/docs/watch.md index 813ca7d..a0b7005 100644 --- a/docs/watch.md +++ b/docs/watch.md @@ -46,12 +46,16 @@ All optional. Configure with environment variables or `~/.storebound/watch.json` | ntfy | `STOREBOUND_NTFY_TOPIC` (required), `STOREBOUND_NTFY_URL` (default `https://ntfy.sh`), `STOREBOUND_NTFY_TOKEN` (optional). Title, Tags and Click headers; Priority high for `rejected` and `rollout_halted` | | Telegram | `STOREBOUND_TELEGRAM_TOKEN` and `STOREBOUND_TELEGRAM_CHAT` | | webhook | `STOREBOUND_WEBHOOK_URL` (JSON POST of the event) | +| ingest | `watch.json` `ingest` (or `STOREBOUND_INGEST_URL`, `STOREBOUND_INGEST_TOKEN_FILE`, `STOREBOUND_INGEST_CATEGORY`): JSON POST with `Authorization: Bearer `; the token is read from `tokenFile` at send time (`~` expanded, trimmed, never logged; on POSIX a group- or world-readable file is refused). Body: `category`, `event`, `app` (slug), `platform` (`ios`/`android`), `version`, `detail` (max 300 chars), `url`, `dedupeKey`, at most 8 KiB. 202 = sent; 5xx and network errors retry once, 4xx do not (status and server message are logged). At most 60 sends a minute, the rest are dropped with a warning | | OS notification | `--notify os`, or `"notify": ["os"]` in watch.json (Windows toast, macOS osascript, Linux notify-send; skipped quietly when unavailable) | ```json -{ "notify": ["os"], "ntfy": { "topic": "..." }, "telegram": { "token": "...", "chat": "..." }, "webhook": { "url": "..." } } +{ "notify": ["os"], "ntfy": { "topic": "..." }, "telegram": { "token": "...", "chat": "..." }, "webhook": { "url": "..." }, + "ingest": { "url": "https://push.example.com/api/notifications/ingest", "tokenFile": "~/.ingest-token", "category": "storebound" } } ``` +Ingest events: `review-rejected`, `review-approved`, `live`, `rollout-halted`, `crash-threshold`, `low-reviews`, `review-reply`. Several sinks can be active at once (ntfy stays a fine fallback). + Network calls time out after 10 s and are retried once. A failing sink is logged (by name) and never stops the watch. ## On a server with PM2 diff --git a/tests/watch-ingest.test.ts b/tests/watch-ingest.test.ts new file mode 100644 index 0000000..abbf530 --- /dev/null +++ b/tests/watch-ingest.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { testEvent, type WatchEvent } from '../cli/lib/watch'; +import { ingestBody, INGEST_EVENTS, resetIngestLimit, sendEvent, sinkConfig, slugOf, type FetchLike } from '../cli/lib/watch-sinks'; + +const TOKEN = 'sekret-token-123'; +const dir = mkdtempSync(join(tmpdir(), 'sb-ingest-')); +const tokenFile = join(dir, 'token'); +writeFileSync(tokenFile, ` ${TOKEN}\n`); +chmodSync(tokenFile, 0o600); + +const ev = (o: Partial = {}): WatchEvent => ({ ...testEvent('2026-10-22T10:00:00.000Z'), app: 'Demo App!', version: '1.2.0', kind: 'rejected', detail: 'App Store 1.2.0: review ended in REJECTED', url: 'https://appstoreconnect.apple.com/apps/1', ...o }); + +let status = 202, replyText = 'ok'; +const hits: { headers: Headers; body: string }[] = []; +const server = Bun.serve({ port: 0, async fetch(req) { hits.push({ headers: req.headers, body: await req.text() }); return new Response(replyText, { status }); } }); +afterAll(() => server.stop(true)); +beforeEach(() => { hits.length = 0; status = 202; replyText = 'ok'; resetIngestLimit(); }); +const cfg = (o: Record = {}) => ({ ingest: { url: `http://localhost:${server.port}/api/notifications/ingest`, tokenFile, category: 'storebound', ...o } }); +const logs = (rep: unknown): string => JSON.stringify(rep); + +describe('watch ingest sink', () => { + test('config: watch.json and defaults; needs url and tokenFile', () => { + const f = join(dir, 'watch.json'); + writeFileSync(f, JSON.stringify({ ingest: { url: 'https://push.example.com/api/notifications/ingest', tokenFile: '~/.ingest-token' } })); + expect(sinkConfig({}, f).ingest).toEqual({ url: 'https://push.example.com/api/notifications/ingest', tokenFile: '~/.ingest-token', category: 'storebound' }); + writeFileSync(f, JSON.stringify({ ingest: { url: 'https://push.example.com/x' } })); + expect(sinkConfig({}, f).ingest).toBeUndefined(); + expect(sinkConfig({}, f, ['ingest'])).toEqual({}); + }); + test('headers and the exact body fields', async () => { + const rep = await sendEvent(ev(), cfg(), { retryMs: 1 }); + expect(rep).toEqual([{ sink: 'ingest', ok: true }]); + expect(hits).toHaveLength(1); + expect(hits[0]!.headers.get('authorization')).toBe(`Bearer ${TOKEN}`); + expect(hits[0]!.headers.get('content-type')).toBe('application/json'); + expect(JSON.parse(hits[0]!.body)).toEqual({ + category: 'storebound', event: 'review-rejected', app: 'demo-app', platform: 'ios', version: '1.2.0', + detail: 'App Store 1.2.0: review ended in REJECTED', url: 'https://appstoreconnect.apple.com/apps/1', dedupeKey: 'demo-app:review-rejected:1.2.0', + }); + }); + test('custom category; version omitted when null; crash-free detail', async () => { + await sendEvent(ev({ kind: 'crash_threshold', store: 'play', version: null, to: '2.90 %', detail: 'x' }), cfg({ category: 'apps' }), { retryMs: 1 }); + expect(JSON.parse(hits[0]!.body)).toEqual({ category: 'apps', event: 'crash-threshold', app: 'demo-app', platform: 'android', detail: 'crash-free 97.1%', url: 'https://appstoreconnect.apple.com/apps/1', dedupeKey: 'demo-app:crash-threshold:' }); + }); + test('mapping of every event kind; review events add the review id to the dedupe key', () => { + expect(INGEST_EVENTS).toEqual({ rejected: 'review-rejected', approved: 'review-approved', live: 'live', rollout_halted: 'rollout-halted', crash_threshold: 'crash-threshold', review_low: 'low-reviews', review_needs_reply: 'review-reply' }); + const low = ingestBody(ev({ kind: 'review_low', version: null, to: '2 stars', detail: '2-star review: meh', ref: 'r-9' }), { category: 'c' })!; + expect(low).toMatchObject({ event: 'low-reviews', detail: '2 stars', dedupeKey: 'demo-app:low-reviews::r-9' }); + expect(ingestBody(ev({ kind: 'review_needs_reply', ref: 'r-1', to: '4 stars' }), { category: 'c' })).toMatchObject({ event: 'review-reply', dedupeKey: 'demo-app:review-reply:1.2.0:r-1' }); + expect(ingestBody(ev({ kind: 'bogus' as never }), { category: 'c' })).toBeNull(); + }); + test('an unmapped event is skipped: nothing sent, no report row', async () => { + expect(await sendEvent(ev({ kind: 'bogus' as never }), cfg(), { retryMs: 1 })).toEqual([]); + expect(hits).toHaveLength(0); + }); + test('slug', () => { + expect(slugOf('My Cool_App 2!')).toBe('my-cool-app-2'); + expect(slugOf('Café')).toBe('caf'); + }); + test('detail is cut to 300 chars and the body stays under 8 KiB', () => { + const b = ingestBody(ev({ detail: 'x'.repeat(5000), url: 'https://example.com/' + 'u'.repeat(7600) }), { category: 'c' })!; + expect(b.detail!.length).toBeLessThanOrEqual(300); + expect(Buffer.byteLength(JSON.stringify(b))).toBeLessThanOrEqual(8192); + expect(ingestBody(ev({ detail: 'y'.repeat(900) }), { category: 'c' })!.detail).toHaveLength(300); + }); + test('4xx: no retry; status and server message reported, token not', async () => { + status = 400; replyText = `unknown field; token ${TOKEN}`; + const rep = await sendEvent(ev(), cfg(), { retryMs: 1 }); + expect(hits).toHaveLength(1); + expect(rep[0]).toMatchObject({ sink: 'ingest', ok: false }); + expect(rep[0]!.error).toContain('HTTP 400: unknown field'); + expect(logs(rep)).not.toContain(TOKEN); + }); + test('5xx: retried once, then reported', async () => { + status = 503; + const rep = await sendEvent(ev(), cfg(), { retryMs: 1 }); + expect(hits).toHaveLength(2); + expect(rep[0]).toMatchObject({ ok: false }); + expect(rep[0]!.error).toContain('HTTP 503'); + }); + test('a network error is retried once, then succeeds', async () => { + let n = 0; + const flaky: FetchLike = async () => { n++; if (n === 1) throw new Error('down'); return { ok: true, status: 202 }; }; + expect(await sendEvent(ev(), cfg(), { fetch: flaky, retryMs: 1 })).toEqual([{ sink: 'ingest', ok: true }]); + expect(n).toBe(2); + }); + test('missing or empty token file: warning naming the file, nothing sent, no token', async () => { + const missing = join(dir, 'nope'); + const rep = await sendEvent(ev(), cfg({ tokenFile: missing }), { retryMs: 1 }); + expect(hits).toHaveLength(0); + expect(rep[0]).toMatchObject({ sink: 'ingest', ok: false }); + expect(rep[0]!.error).toContain(missing); + const empty = join(dir, 'empty'); writeFileSync(empty, '\n'); chmodSync(empty, 0o600); + expect((await sendEvent(ev(), cfg({ tokenFile: empty }), { retryMs: 1 }))[0]!.error).toContain('empty'); + expect(hits).toHaveLength(0); + }); + test.skipIf(process.platform === 'win32')('a group- or world-readable token file is refused', async () => { + const open = join(dir, 'open'); writeFileSync(open, TOKEN); chmodSync(open, 0o644); + const rep = await sendEvent(ev(), cfg({ tokenFile: open }), { retryMs: 1 }); + expect(hits).toHaveLength(0); + expect(rep[0]!.error).toContain(open); + expect(logs(rep)).not.toContain(TOKEN); + }); + test('client-side rate limit: 60 a minute, the rest dropped with a warning', async () => { + const ok: FetchLike = async () => ({ ok: true, status: 202 }); + let dropped = 0, sent = 0; + for (let i = 0; i < 62; i++) { + const r = (await sendEvent(ev(), cfg(), { fetch: ok, retryMs: 1 }))[0]!; + if (r.ok) sent++; else { dropped++; expect(r.error).toContain('rate limit'); } + } + expect([sent, dropped]).toEqual([60, 2]); + }); + test('the test event reaches the ingest sink and the token never appears in the report', async () => { + const rep = await sendEvent(testEvent(), cfg(), { retryMs: 1 }); + expect(rep).toEqual([{ sink: 'ingest', ok: true }]); + expect(JSON.parse(hits[0]!.body).event).toBe('live'); + expect(logs(rep)).not.toContain(TOKEN); + }); +}); diff --git a/tests/watch.test.ts b/tests/watch.test.ts index e1f9eea..34e5bfb 100644 --- a/tests/watch.test.ts +++ b/tests/watch.test.ts @@ -86,7 +86,7 @@ describe('watch diff', () => { ...diffApp(snap({ reviews: { play: [] } }), snap({ reviews: { play: [{ id: 'x', rating: 1, replied: false, date: AT, text: 't' }, { id: 'y', rating: 5, replied: false, date: AT, text: 't' }] } }), AT), ]; expect([...new Set(all.map(e => e.kind))].sort()).toEqual(['approved', 'crash_threshold', 'live', 'rejected', 'review_low', 'review_needs_reply', 'rollout_halted']); - for (const e of all) { expect(Object.keys(e).sort()).toEqual(['app', 'at', 'detail', 'from', 'kind', 'store', 'to', 'url', 'version']); expect(e.url).toMatch(/^https:\/\//); } + for (const e of all) { expect(Object.keys(e).filter(k => k !== 'ref').sort()).toEqual(['app', 'at', 'detail', 'from', 'kind', 'store', 'to', 'url', 'version']); expect('ref' in e).toBe(e.kind.startsWith('review_')); expect(e.url).toMatch(/^https:\/\//); } }); });