86 lines
6.1 KiB
TypeScript
86 lines
6.1 KiB
TypeScript
// 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');
|
|
});
|
|
});
|