From e1e4e05237137c5d9c19d530372cd13090e20740 Mon Sep 17 00:00:00 2001 From: CRT Date: Tue, 20 Oct 2026 12:41:43 +0700 Subject: [PATCH] shots capture: signed-in web apps (shots.capture.auth request with ${cred:}/${env:} references or storageState), shots login saves a demo sign-in to .storebound/auth.json (0600), expired-session warning, secrets and cookies masked --- CHANGELOG.md | 7 + cli/commands.ts | 3 +- cli/data/flags.json | 10 ++ cli/lib/config.ts | 6 + cli/mcp-tools.ts | 2 +- cli/shots/auth.ts | 179 ++++++++++++++++++++++++++ cli/shots/capture-web.ts | 20 ++- cli/shots/capture.ts | 9 +- cli/shots/devices.ts | 2 + cli/shots/login.ts | 72 +++++++++++ docs/commands.md | 3 +- docs/store-art.md | 6 +- skills/store-screenshots/SKILL.md | 27 +++- tests/browser/capture-auth.browser.ts | 122 ++++++++++++++++++ tests/capture-auth.test.ts | 86 +++++++++++++ 15 files changed, 545 insertions(+), 9 deletions(-) create mode 100644 cli/shots/auth.ts create mode 100644 cli/shots/login.ts create mode 100644 tests/browser/capture-auth.browser.ts create mode 100644 tests/capture-auth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index de9d320..8873538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- `shots capture --source web` can sign in first: `shots.capture.auth` is a sign-in request (run once per browser + context, cookies kept; body and headers use `${cred:NAME}` from `.storebound/credentials.json` or `${env:NAME}`, + literal secrets are refused, a failed status stops with the status only) or a `storageState` file. `storebound shots + login` opens a fresh visible browser for a demo sign-in by hand and saves `.storebound/auth.json` (0600, counts + only). A shot that lands on a sign-in page warns "session expired? run storebound shots login again". Secrets and + cookie values are masked in logs and the capture report. + ## 0.8.16 ยท 2026-10-20 - `apps folders add []` adds the folder (it listed and added nothing); a template config is added with a note, diff --git a/cli/commands.ts b/cli/commands.ts index 17cf3b3..8014f55 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -92,7 +92,8 @@ export const COMMANDS: Record Promise, string]> = { 'apple ppo': [() => import('./apple/ppo'), 'Product Page Optimization: status | create --variant b | upload --variant b | start | stop (asks first)'], 'apple testflight': [() => import('./apple/testflight'), 'TestFlight: status | groups | testers (CSV in .storebound/) | what-to-test --build N | info | distribute --build N --group g [--submit-review] | wait --build N | expire --keep N|--older-than D (asks)'], 'icons': [() => import('./icons'), 'App Store / Play / web / adaptive / legacy launcher icons from one artwork'], - 'shots capture': [() => import('./shots/capture'), 'raw frames: --source web (playwright) | ios-sim (simctl) | android-emu (adb) | folder; --dry'], + 'shots capture': [() => import('./shots/capture'), 'raw frames: --source web (playwright; signed-in apps: shots.capture.auth) | ios-sim (simctl) | android-emu (adb) | folder; --dry'], + 'shots login': [() => import('./shots/login'), 'opens a visible browser (fresh profile) at shots.capture.baseUrl or --url; you sign in to a demo account by hand, Enter saves .storebound/auth.json (0600) for shots.capture.auth.storageState; --timeout '], 'shots fonts': [() => import('./shots/fonts'), 'download font subsets for the copy (CJK / Arabic / Cyrillic ...)'], 'shots render': [() => import('./shots/render'), 'store screenshots + feature graphic in every size and language'], 'shots jpeg': [() => import('./shots/jpeg'), 'JPEG q92 copies of the Play renders (4x faster uploads)'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 3787556..37834d6 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -806,6 +806,12 @@ "variant", "variants" ], + "shots login": [ + "headless", + "out", + "timeout", + "url" + ], "shots record": [ "concurrency", "device", @@ -983,6 +989,7 @@ "godot", "group", "halt", + "headless", "home", "hook", "icon", @@ -1479,6 +1486,9 @@ "shots jpeg": [ "storebound shots jpeg [--q 92] [--lang en,tr] [--variant b | --variants]" ], + "shots login": [ + "storebound shots login [--url ] [--out .storebound/auth.json] [--timeout 900]" + ], "shots record": [ "storebound shots record [--lang en,tr] [--only intro,combo] [--source web|ios-sim|android-emu|folder] [--dry] [--keep-booted]" ], diff --git a/cli/lib/config.ts b/cli/lib/config.ts index b7a0287..445e508 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -200,6 +200,8 @@ export interface ShotSpec { extras?: Record; } +export type { CaptureAuth } from '../shots/auth'; +import type { CaptureAuth } from '../shots/auth'; export interface ShotsCapture { /** Where frames come from. Default "web". */ source?: 'web' | 'ios-sim' | 'android-emu' | 'folder'; @@ -254,6 +256,10 @@ export interface ShotsCapture { /** Web: files or folders of the app build (e.g. "apps/game/src" or "apps/game/dist") hashed into the capture cache, * so a code change re-captures. Default: the HTML at baseUrl and the scripts it loads. */ build?: string | string[]; + /** Web: sign in before the shots (apps whose key screens are behind a login; use a demo account on a local or demo + * backend with fabricated data). { request: { url, method, headers, body } } with "${cred:NAME}" / "${env:NAME}" + * references (never literal secrets), or { storageState: ".storebound/auth.json" } from `storebound shots login`. */ + auth?: CaptureAuth; shots: Record; } diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index 9a40eda..d637967 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -222,7 +222,7 @@ export const TOOLS: ToolDef[] = [ props: { action: { type: 'string', enum: ['on', 'pause'] }, version: { type: 'string' }, platform }, required: ['action'], args: i => ['--phased', String(i.action), ...opt('version', i.version), ...opt('platform', i.platform)] }, { name: 'shots_capture', title: 'Capture raw frames', command: ['shots', 'capture'], write: true, network: false, idempotent: true, - description: 'Raw app or game frames per language into shots.frames from a web build (Playwright), the iOS Simulator (simctl), an Android emulator / device (adb) or a folder check. Local files only; a simulator it boots is shut down again, an emulator is never booted. dry_run lists the plan and the simctl / adb commands.', + description: 'Raw app or game frames per language into shots.frames from a web build (Playwright), the iOS Simulator (simctl), an Android emulator / device (adb) or a folder check. Web apps behind a sign-in: shots.capture.auth (a sign-in request with ${cred:NAME} / ${env:NAME} references, or a storageState from the terminal-only `storebound shots login`, where the owner signs in to a demo account by hand); secrets and cookies are masked. Local files only; a simulator it boots is shut down again, an emulator is never booted. dry_run lists the plan and the simctl / adb commands.', props: { lang, only: { type: 'string', description: 'Comma-separated shot names' }, device: { type: 'string', enum: ['phone', 'tablet'] }, source, concurrency: { type: 'integer', minimum: 1, maximum: 32, description: 'Web: parallel browser contexts (default from the CPU count)' }, mode: { type: 'string', enum: ['changed', 'only-missing', 'force'], default: 'changed', description: 'Web: skip frames with unchanged inputs (default), only missing ones, or capture all' } }, diff --git a/cli/shots/auth.ts b/cli/shots/auth.ts new file mode 100644 index 0000000..ca8bd5f --- /dev/null +++ b/cli/shots/auth.ts @@ -0,0 +1,179 @@ +// Signed-in web capture (shots.capture.auth). Two ways in, applied to every browser context before its first shot: +// request { request: { url, method?, headers?, body? } }: one HTTP call per context through the context's own +// request API, so the cookies it sets stay in that context. Secrets are references only: +// "${cred:NAME}" (.storebound/credentials.json: reviewDemoAccount.username / .password, the same with +// REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD from the environment, or captureAuth.NAME) or "${env:NAME}". +// A password-like key with a literal value is refused. A non-2xx/3xx status stops the capture with the +// status, never the values. +// storageState { storageState: ".storebound/auth.json" }: Playwright storage state (cookies + localStorage) saved by +// `storebound shots login`, loaded into every context. It must live in .storebound/ (gitignored). +// Resolved values are collected so every log line, error and report text can be masked (maskSecrets). +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve as resolvePath } from 'node:path'; +import { reviewDemoAccount } from '../lib/credentials'; + +export interface AuthRequest { + /** Absolute URL, or a path after shots.capture.baseUrl (e.g. "/api/login"). */ + url: string; + /** Default POST. */ + method?: string; + headers?: Record; + /** An object is sent as JSON, a string as is. Secrets as "${cred:NAME}" / "${env:NAME}" only. */ + body?: Record | string; +} +export interface CaptureAuth { + request?: AuthRequest; + /** Playwright storage state file in .storebound/ (from `storebound shots login`). */ + storageState?: string; + /** Pattern (regex source) of the login page URL; a shot that lands there warns. Default /login|signin|sign-in|auth/. */ + loginUrl?: string; +} + +export const DEFAULT_STATE = '.storebound/auth.json'; +const REF = /\$\{(cred|env):([A-Za-z0-9_.-]+)\}/g; +const SECRET_KEY = /pass(word)?|pwd|secret|token|api[-_]?key|authorization|cookie|session|otp|pin$|credential/i; + +/** Problems with shots.capture.auth (pure): shape, a gitignored state path, and literal secrets. */ +export function validateAuth(a: CaptureAuth | undefined): string[] { + if (!a) return []; + const e: string[] = []; + if (!!a.request === !!a.storageState) e.push('shots.capture.auth: either { request: { url, method, headers, body } } or { storageState: ".storebound/auth.json" }'); + if (a.storageState && !/^\.storebound[\\/]/.test(a.storageState)) e.push('shots.capture.auth.storageState: a file in .storebound/ (gitignored), e.g. ".storebound/auth.json"'); + if (a.request) { + if (!a.request.url) e.push('shots.capture.auth.request.url: the sign-in endpoint (absolute, or a path after baseUrl)'); + for (const k of literalSecrets(a.request)) e.push(`shots.capture.auth.request.${k}: a secret is never written in the config; use "\${cred:NAME}" (.storebound/credentials.json) or "\${env:NAME}"`); + } + if (a.loginUrl) try { new RegExp(a.loginUrl); } catch { e.push('shots.capture.auth.loginUrl: a regular expression'); } + return e; +} + +/** Paths (headers.X, body.y) whose key looks like a secret but whose value is not only references. */ +export function literalSecrets(r: AuthRequest): string[] { + const out: string[] = []; + const literal = (v: unknown): boolean => typeof v === 'string' ? v.replace(REF, '').replace(/^(Bearer|Basic|Token)\s+/i, '').trim() !== '' : v !== undefined && v !== null && typeof v !== 'object'; + const walk = (o: unknown, path: string): void => { + if (!o || typeof o !== 'object') return; + for (const [k, v] of Object.entries(o as Record)) { + if (v && typeof v === 'object') walk(v, `${path}.${k}`); + else if (SECRET_KEY.test(k) && literal(v)) out.push(`${path}.${k}`); + } + }; + walk(r.headers, 'headers'); + if (typeof r.body === 'string') { + // a form or JSON string body: check its fields the same way + let parsed: unknown = null; + try { parsed = JSON.parse(r.body); } catch { if (r.body.includes('=')) parsed = Object.fromEntries(new URLSearchParams(r.body)); } + walk(parsed, 'body'); + } else walk(r.body, 'body'); + return out; +} + +/** Where references resolve: credentials.json (reviewDemoAccount, captureAuth) and the environment. */ +export function credLookup(root: string, env: Record = process.env): (kind: 'cred' | 'env', name: string) => string | undefined { + let store: Record | null = null; + const file = (): Record => { + if (store) return store; + const p = join(root, '.storebound', 'credentials.json'); + try { store = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as Record : {}; } catch { store = {}; } + return store; + }; + return (kind, name) => { + if (kind === 'env') return env[name] || undefined; + if (name === 'reviewDemoAccount.username' || name === 'reviewDemoAccount.password') { + const eu = env.REVIEW_DEMO_USER, ep = env.REVIEW_DEMO_PASSWORD; + const d = eu && ep ? { username: eu, password: ep } : reviewDemoAccount(root); + return d ? (name.endsWith('username') ? d.username : d.password) : undefined; + } + const ca = file().captureAuth as Record | undefined; + const v = ca?.[name.replace(/^captureAuth\./, '')]; + return typeof v === 'string' && v ? v : undefined; + }; +} + +/** Replaces every reference; collects the values into `secrets`. A missing one throws with its NAME only. */ +export function resolveRefs(text: string, lookup: ReturnType, secrets: Set): string { + return text.replace(REF, (_m, kind: 'cred' | 'env', name: string) => { + const v = lookup(kind, name); + if (v === undefined) throw new Error(kind === 'env' ? `shots.capture.auth: environment variable ${name} is not set` : `shots.capture.auth: \${cred:${name}} is not in .storebound/credentials.json (reviewDemoAccount { username, password } or captureAuth { "${name.replace(/^captureAuth\./, '')}": ... })`); + secrets.add(v); + return v; + }); +} + +const deepResolve = (v: unknown, f: (s: string) => string): unknown => + typeof v === 'string' ? f(v) : Array.isArray(v) ? v.map(x => deepResolve(x, f)) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deepResolve(x, f)])) : v; + +export interface ResolvedRequest { url: string; method: string; headers: Record; body?: string } +/** The sign-in request with references resolved (values only in memory, listed in `secrets`). */ +export function resolveRequest(r: AuthRequest, baseUrl: string, lookup: ReturnType, secrets: Set): ResolvedRequest { + const f = (s: string) => resolveRefs(s, lookup, secrets); + const headers = deepResolve(r.headers ?? {}, f) as Record; + let body: string | undefined; + if (typeof r.body === 'string') body = f(r.body); + else if (r.body) { body = JSON.stringify(deepResolve(r.body, f)); if (!Object.keys(headers).some(h => h.toLowerCase() === 'content-type')) headers['content-type'] = 'application/json'; } + const url = /^https?:\/\//.test(r.url) ? f(r.url) : baseUrl.replace(/\/$/, '') + f(r.url); + return { url, method: (r.method ?? 'POST').toUpperCase(), headers, ...(body !== undefined ? { body } : {}) }; +} + +/** Hides every resolved secret (and its URL-encoded form) in a text. */ +export function maskSecrets(text: string, secrets: Iterable): string { + let out = text; + for (const s of secrets) if (s.length >= 3) for (const form of new Set([s, encodeURIComponent(s)])) out = out.split(form).join('***'); + return out; +} + +/** URL for a log line: origin + path only (a query can carry a token). */ +export const safeUrl = (u: string): string => { try { const x = new URL(u); return x.origin + x.pathname; } catch { return u.split('?')[0]!; } }; + +/** A shot that wanted `want` but shows a login page. */ +export function landedOnLogin(want: string, got: string, pattern?: string): boolean { + const re = new RegExp(pattern ?? '(^|[/._-])(login|log-in|signin|sign-in|sign_in|auth)([/._?-]|$)', 'i'); + try { + const w = new URL(want), g = new URL(got); + if (w.pathname === g.pathname) return false; + return re.test(g.pathname + g.search) && !re.test(w.pathname); + } catch { return false; } +} +export const expiredHint = (url: string): string => `${safeUrl(url)} is a sign-in page: session expired? run storebound shots login again`; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface AuthPlan { + /** Extra newContext options (storageState). */ + contextOptions: Record; + /** Signs a fresh context in (request auth); a no-op for storageState. */ + prepare: (context: any) => Promise; + secrets: Set; + loginUrl?: string; +} + +/** Builds the per-context sign-in from shots.capture.auth. Throws before any browser starts when something is missing. */ +export function authPlan(a: CaptureAuth, o: { root: string; baseUrl: string; env?: Record }): AuthPlan { + const problems = validateAuth(a); + if (problems.length) throw new Error(problems.join('\n ')); + const secrets = new Set(); + if (a.storageState) { + const p = resolvePath(o.root, a.storageState); + if (!existsSync(p)) throw new Error(`shots.capture.auth.storageState: ${a.storageState} does not exist; run storebound shots login (sign in to a demo account by hand)`); + let state: { cookies?: { value?: string }[] }; + try { state = JSON.parse(readFileSync(p, 'utf8')) as typeof state; } catch { throw new Error(`shots.capture.auth.storageState: ${a.storageState} is not a Playwright storage state; run storebound shots login again`); } + for (const c of state.cookies ?? []) if (c.value && c.value.length >= 6) secrets.add(c.value); + return { contextOptions: { storageState: p }, prepare: async () => {}, secrets, ...(a.loginUrl ? { loginUrl: a.loginUrl } : {}) }; + } + const req = resolveRequest(a.request!, o.baseUrl, credLookup(o.root, o.env), secrets); + return { + contextOptions: {}, secrets, ...(a.loginUrl ? { loginUrl: a.loginUrl } : {}), + prepare: async (context: any) => { + let status = 0; + try { + const r = await context.request.fetch(req.url, { method: req.method, headers: req.headers, ...(req.body !== undefined ? { data: req.body } : {}), timeout: 30_000, maxRedirects: 0 }); + status = r.status(); + await r.dispose?.(); + } catch (e) { throw new Error(`sign-in request ${req.method} ${safeUrl(maskSecrets(req.url, secrets))} failed: ${maskSecrets((e as Error).message.split('\n')[0]!, secrets).slice(0, 200)}`); } + if (status < 200 || status >= 400) throw new Error(`sign-in request ${req.method} ${safeUrl(maskSecrets(req.url, secrets))} returned HTTP ${status} (check the demo account in .storebound/credentials.json and shots.capture.auth.request; values not shown)`); + // the session cookies it set are masked like the request values + const cookies = await context.cookies(); + for (const c of cookies) if (c.value && c.value.length >= 6) secrets.add(c.value); + }, + }; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/cli/shots/capture-web.ts b/cli/shots/capture-web.ts index 33a1dd4..516c798 100644 --- a/cli/shots/capture-web.ts +++ b/cli/shots/capture-web.ts @@ -11,6 +11,9 @@ // size, session, the copy text of its language, the app build (shots.capture.build files, else // the HTML and scripts at baseUrl), fonts.css). Unchanged frames that exist are skipped; // --only-missing only captures frames that do not exist; --force captures everything. +// auth shots.capture.auth (auth.ts): every new context is signed in before its first shot (request +// auth through the context's request API, or a storageState file); a shot that lands on a login +// page warns; resolved secrets and cookie values are masked in every log line and the report. // timing per frame: goto, waitFor, eval, delay, screenshot, total, in the progress line and in // .storebound/capture-report.json, with the slowest frames and steps at the end. import { createHash } from 'node:crypto'; @@ -19,6 +22,7 @@ import { availableParallelism, cpus } from 'node:os'; import { join } from 'node:path'; import type { ShotSpec, ShotsCapture } from '../lib/config'; import { log, warn } from '../lib/log'; +import { type AuthPlan, expiredHint, landedOnLogin, maskSecrets, safeUrl } from './auth'; import { shotsFor } from './devices'; import type { FrameDims } from './lib'; @@ -132,7 +136,10 @@ export interface CaptureReport { /* eslint-disable @typescript-eslint/no-explicit-any */ /** Captures the jobs in a browser: per-shot contexts, or one page per language + device in per-language sessions. */ -export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[], o: { concurrency: number; dims: Record }): Promise { +export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[], o: { concurrency: number; dims: Record; auth?: AuthPlan }): Promise { + const auth = o.auth; + const mask = (t: string): string => (auth ? maskSecrets(t, auth.secrets) : t); + const expired = new Set(); const started = Date.now(); const perLang = cap.session === 'per-language'; const report: CaptureReport['frames'] = []; @@ -140,7 +147,9 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[] const total = jobs.length; const newPage = async (j: WebJob): Promise<{ c: any; page: any; errs: string[] }> => { const d = o.dims[j.device]; - const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: d.dpr, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: j.lang }); + const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: d.dpr, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: j.lang, ...(auth?.contextOptions ?? {}) }); + // sign in once per context, cookies kept in it (request auth); a failed sign-in stops the whole capture + if (auth) try { await auth.prepare(c); } catch (e) { await c.close(); throw Object.assign(new Error(mask((e as Error).message)), { authFailed: true }); } const page = await c.newPage(); const errs: string[] = []; page.on('pageerror', (e: Error) => errs.push(e.message)); @@ -152,6 +161,11 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[] if (how === 'goto') await s.page.goto(j.url, { waitUntil: 'domcontentloaded', timeout: 90000 }); else await s.page.evaluate(`(p => { if (location.pathname + location.search !== p) { history.pushState(null, '', p); dispatchEvent(new PopStateEvent('popstate', { state: null })); } })(${JSON.stringify(j.path)})`); t.goto = Date.now() - t0; t0 = Date.now(); + if (auth && landedOnLogin(j.url, s.page.url(), auth.loginUrl)) { + // a sign-in page is never a store frame: warn once per login URL, fail this shot now (no waitFor timeout) + if (!expired.has(safeUrl(s.page.url()))) { expired.add(safeUrl(s.page.url())); warn('shots', `${j.lang}/${j.device}/${j.name}: ${mask(expiredHint(s.page.url()))}`); } + throw new Error(`landed on the sign-in page ${safeUrl(s.page.url())}`); + } if (j.spec.waitFor) await s.page.waitForFunction(j.spec.waitFor, null, { timeout: waitTimeout, polling: 50 }); t.waitFor = Date.now() - t0; t0 = Date.now(); for (const js of j.spec.eval ?? []) await s.page.evaluate(js); @@ -166,6 +180,8 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[] }; const record = (j: WebJob, r: { ok: boolean; reused: boolean; timing: Timing; error?: string; errs?: string[] }): void => { finished++; + if (r.error) r.error = mask(r.error); + if (r.errs) r.errs = r.errs.map(mask); report.push({ frame: j.frame, ok: r.ok, reused: r.reused, timing: r.timing, ...(r.error ? { error: r.error } : {}) }); const head = `${progressLine(finished, total, started)} ${j.lang}/${j.device}/${j.name}`; if (r.ok) log('shots', `${head}.png ${r.reused ? '(reused page) ' : ''}${timingText(r.timing)}${r.errs?.length ? ' (page errors: ' + r.errs.join(' | ').slice(0, 200) + ')' : ''}`); diff --git a/cli/shots/capture.ts b/cli/shots/capture.ts index 0b014fc..b82d0e3 100644 --- a/cli/shots/capture.ts +++ b/cli/shots/capture.ts @@ -6,7 +6,8 @@ // web headless Chromium (playwright), mobile viewport + touch at phone / tablet size. Each shot: // { path, waitFor?: JS expression that turns true, eval?: [JS], delayMs?, device? }; URL = baseUrl + path + // query, "{lang}" replaced. Parallel contexts, per-language page reuse, the input-hash cache and the -// per-frame timing (goto / waitFor / eval / delay / screenshot): capture-web.ts. +// per-frame timing (goto / waitFor / eval / delay / screenshot): capture-web.ts. Signed-in apps: +// shots.capture.auth (a sign-in request or a storageState from `storebound shots login`), auth.ts. // ios-sim xcrun simctl: boots the simulator (ios.device / ios.devices.tablet), optional install of ios.app, status // bar 9:41 / full battery / full bars, one warm-up launch after a cold boot (ios.warmupMs), then per shot: // launch ios.bundleId with -AppleLanguages (xx) -AppleLocale + ios.args + shot.args, open shot.url @@ -37,6 +38,7 @@ import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, a import { type CopyFile, copyText, type FrameDims, frameDims } from './lib'; import { captureWeb, defaultConcurrency, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web'; import { imageInfo } from './spec'; +import { authPlan, safeUrl } from './auth'; import * as rt from '../lib/rt'; type Device = 'phone' | 'tablet'; @@ -121,9 +123,12 @@ export async function run(ctx: Ctx): Promise { if (source === 'web') { if (dry) { for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only)) progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`); + if (cap.auth) log('shots', cap.auth.storageState ? `would load the signed-in state from ${cap.auth.storageState} into every context` : `would sign every context in first: ${(cap.auth.request?.method ?? 'POST').toUpperCase()} ${safeUrl(cap.auth.request?.url ?? '')} (values from .storebound/credentials.json / env, never shown)`); return done('shots', `capture planned (dry run): ${total} frames from web`); } need(cap.baseUrl, 'shots.capture.baseUrl'); + const auth = cap.auth ? authPlan(cap.auth, { root, baseUrl: cap.baseUrl! }) : undefined; + if (auth) log('shots', cap.auth!.storageState ? `signed in from ${cap.auth!.storageState}` : `signing every context in: ${(cap.auth!.request!.method ?? 'POST').toUpperCase()} ${safeUrl(cap.auth!.request!.url)}`); const concurrency = a.num('concurrency', cap.concurrency ?? defaultConcurrency('capture')); const builds = cap.build ? (Array.isArray(cap.build) ? cap.build : [cap.build]).map(p => resolve(p)) : []; const build = builds.length ? hashPaths(builds) : await hashServed(cap.baseUrl!); @@ -139,7 +144,7 @@ export async function run(ctx: Ctx): Promise { if (todo.length) { const browser = await launch(root, { headless: true, args: ['--mute-audio', '--ignore-gpu-blocklist', '--enable-webgl', '--enable-gpu'] }); let report; - try { report = await captureWeb(browser, cap, todo, { concurrency, dims }); } finally { await closeBrowser(browser); } // capped close, then kill: Chrome can hang 10-30 s in its shutdown + try { report = await captureWeb(browser, cap, todo, { concurrency, dims, ...(auth ? { auth } : {}) }); } finally { await closeBrowser(browser); } // capped close, then kill: Chrome can hang 10-30 s in its shutdown report.skipped = skipped.map(j => j.frame); writeCaptureState(root, report, cache, todo); for (const l of summaryLines(report)) log('shots', l); diff --git a/cli/shots/devices.ts b/cli/shots/devices.ts index ec2167f..3b2fab0 100644 --- a/cli/shots/devices.ts +++ b/cli/shots/devices.ts @@ -7,6 +7,7 @@ // exec-out screencap. Emulator or a USB device. // folder bring your own frames: ///.png, checked for size. import type { ShotsCapture, ShotSpec, VideoRecord } from '../lib/config'; +import { validateAuth } from './auth'; export type Source = 'web' | 'ios-sim' | 'android-emu' | 'folder'; export const SOURCES: Source[] = ['web', 'ios-sim', 'android-emu', 'folder']; @@ -29,6 +30,7 @@ export function validateCapture(c: ShotsCapture | undefined): string[] { } if (c.ios?.warmupMs !== undefined && !(Number.isInteger(c.ios.warmupMs) && c.ios.warmupMs >= 0)) e.push('shots.capture.ios.warmupMs: milliseconds (integer >= 0)'); if (c.concurrency !== undefined && !(Number.isInteger(c.concurrency) && c.concurrency >= 1 && c.concurrency <= 32)) e.push('shots.capture.concurrency: integer 1-32'); + if (c.auth) e.push(...validateAuth(c.auth)); if (c.session !== undefined && !['per-shot', 'per-language'].includes(c.session)) e.push('shots.capture.session: per-shot | per-language'); if (c.reuseTimeoutMs !== undefined && !(Number.isInteger(c.reuseTimeoutMs) && c.reuseTimeoutMs > 0)) e.push('shots.capture.reuseTimeoutMs: milliseconds (integer > 0)'); if (src === 'android-emu' && !c.android?.package) e.push('shots.capture.android.package: application id (source android-emu)'); diff --git a/cli/shots/login.ts b/cli/shots/login.ts new file mode 100644 index 0000000..841f265 --- /dev/null +++ b/cli/shots/login.ts @@ -0,0 +1,72 @@ +// storebound shots login [--url ] [--out .storebound/auth.json] [--timeout 900] +// Opens a visible browser with a fresh, empty profile at shots.capture.baseUrl (or --url) so the owner signs in to a +// DEMO account by hand (storebound never types credentials). Press Enter in the terminal, or close the window, and the +// Playwright storage state (cookies + localStorage) is written to .storebound/auth.json (0600) for +// shots.capture.auth.storageState. Only counts are printed, never cookie values. The wait has a timeout (default +// 15 min) and a heartbeat line every 30 s. +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, relative } from 'node:path'; +import type { Ctx } from '../lib/ctx'; +import { done, log, warn } from '../lib/log'; +import { DEFAULT_STATE, safeUrl } from './auth'; +import { closeBrowser, launch } from './browser'; + +export interface StorageState { cookies: { name: string; value: string; domain: string }[]; origins: { origin: string; localStorage: unknown[] }[] } + +/** What the confirmation says (counts and domains, never values). */ +export function stateSummary(s: StorageState): string { + const domains = [...new Set(s.cookies.map(c => c.domain.replace(/^\./, '')))].sort(); + return `${s.cookies.length} cookie${s.cookies.length === 1 ? '' : 's'}${domains.length ? ` (${domains.join(', ')})` : ''}, ${s.origins.length} origin${s.origins.length === 1 ? '' : 's'} with local storage`; +} + +export function writeState(file: string, s: StorageState): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(s, null, 1) + '\n', { mode: 0o600 }); + chmodSync(file, 0o600); +} + +export async function run(ctx: Ctx): Promise { + const { config, root, resolve } = ctx.loaded; + const a = ctx.args; + const url = a.get('url') ?? config.shots?.capture?.baseUrl; + if (!url || !/^https?:\/\//.test(url)) throw new Error('shots login: --url , or shots.capture.baseUrl in the config'); + const outRel = a.get('out') ?? config.shots?.capture?.auth?.storageState ?? DEFAULT_STATE; + if (!/^\.storebound[\\/]/.test(outRel)) throw new Error('shots login --out: a file in .storebound/ (gitignored), e.g. .storebound/auth.json'); + const out = resolve(outRel); + const timeoutMs = a.num('timeout', 900) * 1000; + // hidden, for tests: headless (no window) and Enter on stdin still ends it + const headless = a.has('headless'); + if (a.has('dry')) return done('shots', `would open ${safeUrl(url)} in a fresh browser profile; after you sign in to a demo account, Enter saves ${outRel} (0600)`); + + const browser = await launch(root, { headless, args: ['--mute-audio'] }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let context: any; + let last: StorageState | null = null; + try { + context = await browser.newContext({ viewport: null, serviceWorkers: 'block' }); + const page = await context.newPage(); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }).catch((e: Error) => warn('shots', `could not open ${safeUrl(url)}: ${e.message.split('\n')[0]}`)); + log('shots', `opened ${safeUrl(url)} in a fresh browser profile. Sign in to a DEMO account (fabricated data, never a real user's) by hand, then press Enter here (or close the window). Timeout ${Math.round(timeoutMs / 1000)} s.`); + const started = Date.now(); + const snapshot = async (): Promise => { try { last = await context.storageState() as StorageState; } catch { /* context gone */ } }; + const reason = await new Promise<'enter' | 'closed' | 'timeout'>(res => { + const finish = (r: 'enter' | 'closed' | 'timeout'): void => { clearInterval(beat); clearInterval(snap); clearTimeout(t); process.stdin.off('data', onData); process.stdin.pause(); res(r); }; + const onData = (b: Buffer): void => { if (b.toString().includes('\n')) finish('enter'); }; + process.stdin.on('data', onData); process.stdin.resume(); + const snap = setInterval(() => { void snapshot(); }, 2000); + const beat = setInterval(() => log('shots', `waiting for the sign-in (${Math.round((Date.now() - started) / 1000)} s of ${Math.round(timeoutMs / 1000)} s): press Enter when signed in`), 30_000); + const t = setTimeout(() => finish('timeout'), timeoutMs); + page.on('close', () => { if (!context.pages().length) finish('closed'); }); + browser.on('disconnected', () => finish('closed')); + }); + if (reason === 'timeout') throw new Error(`shots login: no Enter within ${Math.round(timeoutMs / 1000)} s; nothing saved (--timeout )`); + if (reason === 'enter') await snapshot(); + const state = last as StorageState | null; + if (!state) throw new Error('shots login: the browser closed before its sign-in state could be read; run it again and press Enter before closing the window'); + writeState(out, state); + if (!state.cookies.length && !state.origins.length) warn('shots', 'no cookies and no local storage were saved: was the sign-in finished?'); + done('shots', `saved ${relative(root, out) || out} (0600): ${stateSummary(state)}. Set shots.capture.auth = { storageState: "${outRel}" }`); + } finally { + await closeBrowser(browser); + } +} diff --git a/docs/commands.md b/docs/commands.md index 4c00820..7f51fe7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -140,7 +140,8 @@ run in the background with a log (`storebound shots render > .storebound/render. | command | kind | what | |---|---|---| -| `shots capture [--source web\|ios-sim\|android-emu\|folder] [--concurrency N] [--only-missing \| --force]` | local | raw frames from your running app or game; web: parallel contexts, optional one boot per language (`capture.session`), skips unchanged frames, timing per step | +| `shots capture [--source web\|ios-sim\|android-emu\|folder] [--concurrency N] [--only-missing \| --force]` | local | raw frames from your running app or game; web: parallel contexts, optional one boot per language (`capture.session`), skips unchanged frames, timing per step; signed-in apps: `capture.auth` (a sign-in request with `${cred:NAME}` / `${env:NAME}` references, or `storageState`) | +| `shots login [--url ] [--timeout ]` | local | opens a visible browser with a fresh profile; you sign in to a demo account by hand, Enter saves `.storebound/auth.json` (0600) for `capture.auth.storageState`; prints counts only | | `shots fonts` | network | font subsets for your copy (Noto for CJK / Arabic / Cyrillic / Devanagari ...), your own local font files, and the glyph coverage check per language | | `shots render [--variant b \| --variants] [--concurrency N]` | local | every store size and language in the game or app style, feature graphic, contact sheet, overflow report | | `shots jpeg` | local | JPEG q92 copies of the Play renders (about 4x faster uploads) | diff --git a/docs/store-art.md b/docs/store-art.md index 78430eb..df7b5ff 100644 --- a/docs/store-art.md +++ b/docs/store-art.md @@ -30,7 +30,11 @@ languages, every store size, a review gallery and an app preview video. No store freeze, autoplay) and let `shots capture` take the frames: Playwright for a web build, `simctl` for anything that runs in the iOS Simulator (Unity, Godot, Flutter, React Native, native, Capacitor), `adb` for an Android emulator or device, or bring a folder of - your own frames. The status bar is cropped off and redrawn clean (9:41, full battery). + your own frames. The status bar is cropped off and redrawn clean (9:41, full battery). Web apps behind a login sign + in first with `shots.capture.auth`: a sign-in request whose secrets come from `.storebound/credentials.json` or the + environment (`${cred:NAME}`, `${env:NAME}`; literal secrets are refused), or a session saved by `storebound shots + login`, where you sign in to a demo account by hand in a fresh browser. Use a demo backend with fabricated data; + the same frames feed the App Store and Google Play. 2. **Copy.** Per language and scene: eyebrow, two-line title, subtitle, badges. Fonts are subset from Google Fonts to exactly your characters, so Japanese, Chinese, Korean, Arabic (right to left) and Hindi just work. `shots fonts` checks every character of every language against the font's cmap (TTF / OTF / WOFF / WOFF2, read in plain diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index 4c26fdf..0de95d9 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -126,7 +126,8 @@ the icon, feature graphic and phone screenshots and prints `tablet screenshots: `baseUrl`, fonts) via `.storebound/capture-cache.json`; `--only-missing` captures only missing files, `--force` everything. Each progress line shows n/total, ETA and the time per step (goto, wait, eval, delay, shot); the end lists the slowest frames and steps, and `.storebound/capture-report.json` keeps them. Put the slow step right - (a long `waitFor` = the game reaching the scene: add a URL switch that jumps there). + (a long `waitFor` = the game reaching the scene: add a URL switch that jumps there). Screens behind a login: + `capture.auth` (see Signed-in apps). - `ios-sim` (Unity, Godot, Flutter, native, Capacitor: any Simulator build): `ios.device` (simulator name or UDID, e.g. a 6.9" iPhone), `ios.bundleId`, optional `ios.app` (.app to install), `ios.args`. Per shot: `args` (launch arguments, read them with UserDefaults / your engine) and / or `url` (deep link). Language goes in as @@ -190,6 +191,30 @@ the icon, feature graphic and phone screenshots and prints `tablet screenshots: `play images` uploads only the sets that exist (tablet / TV / Wear when rendered) and never deletes a set Play has but the renders lack: the dry run shows `keeps on Play`; `--prune` (only on the owner's word) deletes them. +## Signed-in apps + +Key screens behind a login (`source: 'web'`): capture against a **local or demo backend with fabricated data** and a +demo account. Never real users' data in store screenshots. The same frames feed both stores (App Store and Google +Play), so one sign-in setup covers both. Two ways to sign in, set in `shots.capture.auth`: + +- **Sign-in request**: `{ request: { url: '/api/login', method: 'POST', body: { email: '${cred:reviewDemoAccount.username}', + password: '${cred:reviewDemoAccount.password}' } } }`. It runs once per browser context before its first shot, and + the context keeps the cookies. `${cred:NAME}` reads `.storebound/credentials.json` (`reviewDemoAccount.username` / + `.password`, also from REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD, or any key under `captureAuth`). `${env:NAME}` + reads the environment. A password-, token- or key-like field with a literal value is refused. A status outside + 2xx/3xx stops the capture and shows the status, never the values. +- **Saved session**: `{ storageState: '.storebound/auth.json' }`, made by `storebound shots login [--url ]`. It + opens a visible browser with a fresh, empty profile. The owner signs in to the demo account by hand (signing in is + the owner's step; the agent never types it). Enter in the terminal (or closing the window) saves the state with + 0600 permissions and prints how many cookies and origins it saved. The wait times out after 15 min + (`--timeout `) and logs a heartbeat every 30 s. This one is for SSO, 2FA or captcha logins. MCP has no login + tool, because the owner signs in at a terminal. + +If a shot lands on a sign-in page anyway, capture warns ` is a sign-in page: session expired? run storebound +shots login again` and fails that shot at once. `auth.loginUrl` (a regex) names the login page when the default +(login / signin / auth in the path) does not match. Resolved values and cookie values are masked in logs and +`.storebound/capture-report.json`; the gallery only shows the renders. + ## A/B variants and store experiments `shots.variants.`: `scenes` (order / subset), `copy` (a JSON merged over the base copy, only what changes), `palettes` (per scene). `storebound shots render --variant b` (or `--variants` for all) writes diff --git a/tests/browser/capture-auth.browser.ts b/tests/browser/capture-auth.browser.ts new file mode 100644 index 0000000..d89dc84 --- /dev/null +++ b/tests/browser/capture-auth.browser.ts @@ -0,0 +1,122 @@ +// shots.capture.auth against a tiny local app: POST /api/login sets a session cookie, /app/ needs it (else a +// redirect to /login). Request auth signs every context in; a wrong password stops with the status and no values; +// storageState loads a saved session; an expired one warns; `shots login` (headless here, Enter on stdin) saves the +// state 0600 with counts only. Secrets and cookie values never reach the output or the report. Skips without Chromium. +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ShipConfig, ShotsCapture } from '../../cli/lib/config'; +import { BASE } from '../helpers/store-mock'; + +const CLI = join(import.meta.dir, '..', '..', 'cli', 'index.ts'); +const PASSWORD = 'demo-pass-7731'; +const SESSION = 'sess-value-55aa91'; + +const server = Bun.serve({ + port: 0, hostname: '127.0.0.1', + async fetch(req) { + const u = new URL(req.url); + if (u.pathname === '/api/login' && req.method === 'POST') { + const b = await req.json().catch(() => ({})) as { email?: string; password?: string }; + if (b.email !== 'demo@example.com' || b.password !== PASSWORD) return new Response('no', { status: 401 }); + return new Response('{}', { headers: { 'set-cookie': `sid=${SESSION}; Path=/; HttpOnly`, 'content-type': 'application/json' } }); + } + if (u.pathname === '/login') return new Response('loginsign in', { headers: { 'content-type': 'text/html', 'set-cookie': 'csrf=login-page-token; Path=/' } }); + if (u.pathname.startsWith('/app/')) { + if (!(req.headers.get('cookie') ?? '').includes(`sid=${SESSION}`)) return new Response(null, { status: 302, headers: { location: '/login?next=' + encodeURIComponent(u.pathname) } }); + const name = u.pathname.split('/').pop(); + return new Response(`
${name}
`, { headers: { 'content-type': 'text/html' } }); + } + return new Response('not found', { status: 404 }); + }, +}); +afterAll(() => server.stop(true)); +const base = `http://127.0.0.1:${server.port}`; + +const canLaunch = spawnSync('bun', ['-e', `const { launch } = await import(${JSON.stringify(join(import.meta.dir, '..', '..', 'cli', 'shots', 'browser.ts'))}); await launch(${JSON.stringify(join(import.meta.dir, '..', '..'))}, { headless: true }); process.exit(0);`], { timeout: 60_000 }).status === 0; +if (!canLaunch) console.warn('capture-auth browser tests skipped: no Chromium could launch (bunx playwright install chromium)'); + +function project(auth: ShotsCapture['auth'], creds: object): string { + const root = mkdtempSync(join(tmpdir(), 'sb-auth-')); + mkdirSync(join(root, '.storebound'), { recursive: true }); + mkdirSync(join(root, 'store/shots'), { recursive: true }); + writeFileSync(join(root, 'store/shots/copy.json'), '{}'); + writeFileSync(join(root, '.storebound/credentials.json'), JSON.stringify(creds)); + const shot = (n: string) => ({ path: `/app/${n}`, waitFor: `window.scene === '${n}'`, delayMs: 10 }); + const cfg: ShipConfig = { ...BASE, languages: [BASE.languages[0]!], shots: { copy: 'store/shots/copy.json', frames: 'store/shots/frames', out: 'store/renders', scenes: [{ id: 'hero', palette: 'fire', frames: [{ src: 'hero.png' }] }], phone: { w: 120, h: 200, dpr: 1 }, + capture: { source: 'web', baseUrl: base, shots: { projects: shot('projects'), settings: shot('settings') }, ...(auth ? { auth } : {}) } } }; + writeFileSync(join(root, 'storebound.config.json'), JSON.stringify(cfg)); + return root; +} +async function cli(root: string, args: string[], stdin?: (p: Bun.Subprocess<'pipe', 'pipe', 'pipe'>) => Promise): Promise<{ out: string; code: number }> { + const p = Bun.spawn(['bun', CLI, 'shots', ...args], { cwd: root, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env: { ...process.env, NO_COLOR: '1' } }); + const outP = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]); + if (stdin) await stdin(p); else p.stdin.end(); + const [[o, e], code] = await Promise.all([outP, p.exited]); + return { out: o + e, code }; +} +const demo = { reviewDemoAccount: { username: 'demo@example.com', password: PASSWORD } }; +const request = { request: { url: '/api/login', body: { email: '${cred:reviewDemoAccount.username}', password: '${cred:reviewDemoAccount.password}' } } }; +const frame = (root: string, n: string) => join(root, 'store/shots/frames', BASE.languages[0]!.id, 'phone', `${n}.png`); + +describe.skipIf(!canLaunch)('shots capture with shots.capture.auth', () => { + test.concurrent('request auth signs every context in; secrets and the cookie never reach the output or report', async () => { + const root = project(request, demo); + try { + const r = await cli(root, ['capture', '--device', 'phone', '--force', '--concurrency', '2']); + expect(r.code).toBe(0); + expect(r.out).toContain('signing every context in: POST /api/login'); + expect(existsSync(frame(root, 'projects')) && existsSync(frame(root, 'settings'))).toBe(true); + const report = readFileSync(join(root, '.storebound/capture-report.json'), 'utf8'); + for (const t of [r.out, report]) { expect(t).not.toContain(PASSWORD); expect(t).not.toContain(SESSION); } + expect(r.out).not.toContain('session expired'); + } finally { rmSync(root, { recursive: true, force: true }); } + }, 120_000); + + test.concurrent('a refused sign-in stops with the status, never the values', async () => { + const root = project(request, { reviewDemoAccount: { username: 'demo@example.com', password: 'wrong-pass-0001' } }); + try { + const r = await cli(root, ['capture', '--device', 'phone', '--force']); + expect(r.code).not.toBe(0); + expect(r.out).toContain('returned HTTP 401'); + expect(r.out).not.toContain('wrong-pass-0001'); + expect(existsSync(frame(root, 'projects'))).toBe(false); + } finally { rmSync(root, { recursive: true, force: true }); } + }, 120_000); + + test.concurrent('storageState loads a saved session; an expired one warns to run shots login again', async () => { + const state = (v: string) => JSON.stringify({ cookies: [{ name: 'sid', value: v, domain: '127.0.0.1', path: '/', expires: -1, httpOnly: true, secure: false, sameSite: 'Lax' }], origins: [] }); + const root = project({ storageState: '.storebound/auth.json' }, {}); + try { + writeFileSync(join(root, '.storebound/auth.json'), state(SESSION)); + let r = await cli(root, ['capture', '--device', 'phone', '--force']); + expect(r.code).toBe(0); + expect(r.out).toContain('signed in from .storebound/auth.json'); + expect(r.out).not.toContain(SESSION); + writeFileSync(join(root, '.storebound/auth.json'), state('sess-expired-000')); + r = await cli(root, ['capture', '--device', 'phone', '--force']); + expect(r.code).not.toBe(0); // the shots fail at once, no waitFor timeout + expect(r.out).toContain('landed on the sign-in page'); + expect(r.out).toContain(`${base}/login is a sign-in page: session expired? run storebound shots login again`); + expect(r.out.match(/session expired/g)?.length).toBe(1); // once per login URL, not per shot + } finally { rmSync(root, { recursive: true, force: true }); } + }, 120_000); + + test.concurrent('shots login saves the storage state 0600 after Enter, with counts only', async () => { + const root = project(undefined, {}); + try { + const r = await cli(root, ['login', '--url', `${base}/login`, '--headless', '--timeout', '60'], async p => { + // Enter, like the owner after signing in (read once the page is open) + p.stdin.write('\n'); await p.stdin.end(); + }); + expect(r.code).toBe(0); + const file = join(root, '.storebound/auth.json'); + expect(statSync(file).mode & 0o777).toBe(0o600); + expect(r.out).toContain('saved .storebound/auth.json (0600): 1 cookie (127.0.0.1)'); + expect(r.out).not.toContain('login-page-token'); + expect(JSON.parse(readFileSync(file, 'utf8')).cookies[0].name).toBe('csrf'); + } finally { rmSync(root, { recursive: true, force: true }); } + }, 120_000); +}); diff --git a/tests/capture-auth.test.ts b/tests/capture-auth.test.ts new file mode 100644 index 0000000..b7422f8 --- /dev/null +++ b/tests/capture-auth.test.ts @@ -0,0 +1,86 @@ +// shots.capture.auth without a browser: reference resolution (credentials.json, env), refusal of literal secrets, +// masking, the login-page check and the storage state summary of `shots login`. +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { authPlan, credLookup, landedOnLogin, literalSecrets, maskSecrets, resolveRequest, validateAuth } from '../cli/shots/auth'; +import { validateCapture } from '../cli/shots/devices'; +import { stateSummary } from '../cli/shots/login'; + +function proj(creds: object): string { + const root = mkdtempSync(join(tmpdir(), 'sb-auth-')); + mkdirSync(join(root, '.storebound')); + writeFileSync(join(root, '.storebound', 'credentials.json'), JSON.stringify(creds)); + return root; +} + +describe('capture auth references', () => { + test('cred: reviewDemoAccount and captureAuth from credentials.json, env: from the environment', () => { + const root = proj({ reviewDemoAccount: { username: 'demo@example.com', password: 'pw-from-file' }, captureAuth: { apiKey: 'key-123456' } }); + try { + const secrets = new Set(); + const r = resolveRequest({ url: '/api/login', body: { email: '${cred:reviewDemoAccount.username}', password: '${cred:reviewDemoAccount.password}' }, + headers: { 'x-api-key': '${cred:apiKey}', 'x-tenant': '${env:SB_TENANT}' } }, 'http://127.0.0.1:1/', credLookup(root, { SB_TENANT: 'demo-tenant' }), secrets); + expect(r).toEqual({ url: 'http://127.0.0.1:1/api/login', method: 'POST', headers: { 'x-api-key': 'key-123456', 'x-tenant': 'demo-tenant', 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'demo@example.com', password: 'pw-from-file' }) }); + expect([...secrets].sort()).toEqual(['demo-tenant', 'demo@example.com', 'key-123456', 'pw-from-file']); + // REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD win over the file, like apple review-details + const env = credLookup(root, { REVIEW_DEMO_USER: 'env@example.com', REVIEW_DEMO_PASSWORD: 'pw-env' }); + expect(env('cred', 'reviewDemoAccount.password')).toBe('pw-env'); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + test('a missing reference names only the reference', () => { + const root = proj({}); + try { + expect(() => resolveRequest({ url: '/l', body: { password: '${cred:reviewDemoAccount.password}' } }, 'http://x', credLookup(root, {}), new Set())).toThrow(/reviewDemoAccount\.password\} is not in \.storebound\/credentials\.json/); + expect(() => resolveRequest({ url: '/l', body: 'token=${env:NOPE_TOKEN}' }, 'http://x', credLookup(root, {}), new Set())).toThrow('environment variable NOPE_TOKEN is not set'); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +}); + +describe('literal secrets are refused', () => { + test('password-like keys with literal values, in JSON, form strings and headers', () => { + expect(literalSecrets({ url: '/l', body: { email: 'demo@example.com', password: 'hunter22' } })).toEqual(['body.password']); + expect(literalSecrets({ url: '/l', body: 'user=a&pass=hunter22' })).toEqual(['body.pass']); + expect(literalSecrets({ url: '/l', body: '{"auth":{"token":"abc"}}' })).toEqual(['body.auth.token']); + expect(literalSecrets({ url: '/l', headers: { Authorization: 'Bearer abc.def' } })).toEqual(['headers.Authorization']); + expect(literalSecrets({ url: '/l', headers: { Authorization: 'Bearer ${env:TOKEN}' }, body: { password: '${cred:reviewDemoAccount.password}' } })).toEqual([]); + const e = validateCapture({ source: 'web', baseUrl: 'http://x', shots: {}, auth: { request: { url: '/l', body: { password: 'hunter22' } } } }); + expect(e.join('\n')).toContain('shots.capture.auth.request.body.password: a secret is never written in the config'); + expect(e.join('\n')).not.toContain('hunter22'); + }); + test('shape and a gitignored storageState path', () => { + expect(validateAuth({})[0]).toContain('either'); + expect(validateAuth({ storageState: 'auth.json' })[0]).toContain('a file in .storebound/'); + expect(validateAuth({ storageState: '.storebound/auth.json' })).toEqual([]); + }); +}); + +describe('masking and the login-page check', () => { + test('values and their URL-encoded forms are masked', () => { + expect(maskSecrets('POST failed for p@ss word / p%40ss%20word', ['p@ss word'])).toBe('POST failed for *** / ***'); + }); + test('storageState: a missing file says to run shots login; cookie values become secrets', () => { + const root = proj({}); + try { + expect(() => authPlan({ storageState: '.storebound/auth.json' }, { root, baseUrl: 'http://x' })).toThrow('run storebound shots login'); + writeFileSync(join(root, '.storebound/auth.json'), JSON.stringify({ cookies: [{ name: 'sid', value: 'cookie-value-1', domain: '127.0.0.1' }], origins: [] })); + const p = authPlan({ storageState: '.storebound/auth.json' }, { root, baseUrl: 'http://x' }); + expect(p.contextOptions.storageState).toBe(join(root, '.storebound/auth.json')); + expect([...p.secrets]).toEqual(['cookie-value-1']); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + test('a shot redirected to a login page is detected, a login shot itself is not', () => { + expect(landedOnLogin('http://h/app/projects?lang=en', 'http://h/login?next=%2Fapp')).toBe(true); + expect(landedOnLogin('http://h/app/projects', 'http://h/auth/sign-in')).toBe(true); + expect(landedOnLogin('http://h/app/projects', 'http://h/app/projects')).toBe(false); + expect(landedOnLogin('http://h/login', 'http://h/login/step2')).toBe(false); + expect(landedOnLogin('http://h/app', 'http://h/welcome', 'welcome')).toBe(true); + }); + test('shots login summary shows counts and domains, never values', () => { + const s = stateSummary({ cookies: [{ name: 'sid', value: 'SECRET-VALUE', domain: '.example.com' }, { name: 'csrf', value: 'X2', domain: 'example.com' }], origins: [{ origin: 'https://example.com', localStorage: [] }] }); + expect(s).toBe('2 cookies (example.com), 1 origin with local storage'); + expect(s).not.toContain('SECRET'); + }); +});