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
This commit is contained in:
parent
3949ff1531
commit
e1e4e05237
15 changed files with 545 additions and 9 deletions
122
tests/browser/capture-auth.browser.ts
Normal file
122
tests/browser/capture-auth.browser.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// shots.capture.auth against a tiny local app: POST /api/login sets a session cookie, /app/<scene> 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('<!doctype html><title>login</title><body>sign in</body>', { 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(`<!doctype html><body style="margin:0;background:#1e66f5"><div>${name}</div><script>window.scene=${JSON.stringify(name)};console.error('debug ${PASSWORD}')</script></body>`, { 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<void>): 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);
|
||||
});
|
||||
86
tests/capture-auth.test.ts
Normal file
86
tests/capture-auth.test.ts
Normal file
|
|
@ -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<string>();
|
||||
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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue