// 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); });