SPB Git

Repository Public

unit.test.ts 9.4 KB 176 lines typescript

1import { describe, expect, it } from 'vitest';2import { seal, unseal, safeEqual } from './crypto.js';3import { hashPassword, hasArgon2, needsRehash, verifyPassword } from './password.js';4import { base32Decode, base32Encode, hotp, otpauthUrl, totpAt, totpCounter, verifyTotp } from './totp.js';5import { validateRel } from '../paths.js';6import { loadConfig } from '../config.js';7import { buildCsp, inlineScriptHashes } from '../static.js';8import { globToRegExp } from '../modules/tasks/artifacts.js';9import { buildClaudeArgs } from '../modules/sessions/index.js';10import { SESSION_TRANSITIONS, canTransition, canTaskTransition } from '@cr/shared';1112const cost = { argonMemoryKiB: 1024, argonPasses: 2, scryptLogN: 10 };1314describe('TOTP (RFC 6238 / RFC 4226)', () => {15  const secret = Buffer.from('12345678901234567890', 'ascii');16  // RFC 6238 Appendix B, SHA-1 rows (8 digits there; the low 6 digits are the 6-digit code).17  const vectors: [number, string][] = [18    [59, '94287082'], [1111111109, '07081804'], [1111111111, '14050471'],19    [1234567890, '89005924'], [2000000000, '69279037'], [20000000000, '65353130'],20  ];21  it.each(vectors)('t=%d s', (t, code8) => {22    expect(totpAt(secret, t * 1000, 8)).toBe(code8);23    expect(totpAt(secret, t * 1000)).toBe(code8.slice(2));24  });25  it('RFC 4226 HOTP vectors', () => {26    const expected = ['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489'];27    expected.forEach((code, i) => expect(hotp(secret, i)).toBe(code));28  });29  it('accepts ±1 step, rejects ±2, and refuses replays', () => {30    const now = 1_700_000_000_000;31    const c = totpCounter(now);32    expect(verifyTotp(secret, hotp(secret, c), now, null)).toBe(c);33    expect(verifyTotp(secret, hotp(secret, c - 1), now, null)).toBe(c - 1);34    expect(verifyTotp(secret, hotp(secret, c + 1), now, null)).toBe(c + 1);35    expect(verifyTotp(secret, hotp(secret, c - 2), now, null)).toBeNull();36    expect(verifyTotp(secret, hotp(secret, c + 2), now, null)).toBeNull();37    expect(verifyTotp(secret, hotp(secret, c), now, c)).toBeNull(); // same code again38    expect(verifyTotp(secret, hotp(secret, c - 1), now, c)).toBeNull(); // older code after a newer one39    expect(verifyTotp(secret, 'abcdef', now, null)).toBeNull();40  });41  it('base32 round-trips and matches RFC 4648', () => {42    expect(base32Encode(Buffer.from('foobar'))).toBe('MZXW6YTBOI');43    expect(base32Decode('MZXW6YTBOI').toString()).toBe('foobar');44    expect(base32Encode(secret)).toBe('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ');45    expect(otpauthUrl('ABC', 'admin')).toMatch(/^otpauth:\/\/totp\/Claude%20Remote:admin\?secret=ABC&issuer=Claude\+Remote/);46  });47});4849describe('password hashing', () => {50  it('hashes, verifies and reports parameters', async () => {51    const h = await hashPassword('a long enough password', cost);52    expect(h.startsWith(hasArgon2 ? '$argon2id$v=19$m=1024,t=2,p=1$' : '$scrypt$ln=10,r=8,p=1$')).toBe(true);53    expect(await verifyPassword('a long enough password', h)).toBe(true);54    expect(await verifyPassword('a long enough passwore', h)).toBe(false);55    expect(needsRehash(h, cost)).toBe(false);56    expect(needsRehash(h, { ...cost, argonMemoryKiB: 2048, scryptLogN: 11 })).toBe(true);57  });58  it('scrypt fallback verifies and malformed hashes never throw', async () => {59    const h = await hashPassword('another password 123', cost, true);60    expect(h.startsWith('$scrypt$ln=10,r=8,p=1$')).toBe(true);61    expect(await verifyPassword('another password 123', h)).toBe(true);62    expect(await verifyPassword('nope', h)).toBe(false);63    for (const bad of ['', 'plain', '$argon2id$v=19$m=x$a$b', '$scrypt$ln=99,r=8,p=1$aa$bb', '$md5$x$y$z']) {64      expect(await verifyPassword('x', bad)).toBe(false);65    }66  });67});6869describe('crypto helpers', () => {70  const key = Buffer.alloc(32, 7);71  it('seals with AES-256-GCM bound to its AAD', () => {72    const sealed = seal(key, 'JBSWY3DPEHPK3PXP', 'totp:u1');73    expect(sealed).not.toContain('JBSWY3DPEHPK3PXP');74    expect(unseal(key, sealed, 'totp:u1')).toBe('JBSWY3DPEHPK3PXP');75    expect(() => unseal(key, sealed, 'totp:u2')).toThrow();76    expect(() => unseal(Buffer.alloc(32, 8), sealed, 'totp:u1')).toThrow();77    const parts = sealed.split('.');78    parts[3] = `${parts[3]?.slice(0, -2)}AA`;79    expect(() => unseal(key, parts.join('.'), 'totp:u1')).toThrow();80  });81  it('safeEqual', () => {82    expect(safeEqual('abc', 'abc')).toBe(true);83    expect(safeEqual('abc', 'abd')).toBe(false);84    expect(safeEqual('abc', 'abcd')).toBe(false);85  });86});8788describe('path validation', () => {89  it.each(['../x', 'a/../../b', '/etc/passwd', 'a\0b', '..', 'a/..', '~/x', '\\\\srv\\x', 'C:\\x'])('rejects %j', (p) => {90    expect(() => validateRel(p)).toThrow(/invalid path/);91  });92  it('normalises clean paths', () => {93    expect(validateRel('a//b/./c/')).toBe('a/b/c');94    expect(validateRel('')).toBe('');95    expect(validateRel('..a/b..')).toBe('..a/b..');96    expect(() => validateRel('', { allowEmpty: false })).toThrow();97  });98});99100describe('configuration', () => {101  const prod = {102    NODE_ENV: 'production', CR_PUBLIC_ORIGIN: 'https://claude.example.org', DATABASE_URL: 'postgres://x/y',103    CR_RUNTIME_TOKEN: 'x'.repeat(40), CR_SECRET_KEY: 'ab'.repeat(32), CR_DATA_DIR: '/tmp/cr-none', CR_WORKSPACE_ROOTS: '/tmp/ws',104  };105  it('accepts a complete production configuration with secure cookies', () => {106    const c = loadConfig(prod);107    expect(c.cookieName).toBe('__Host-cr_sid');108    expect(c.secureCookies).toBe(true);109    expect(c.rpId).toBe('claude.example.org');110    expect(c.trustedProxies).toEqual(['127.0.0.1', '::1', '10.67.0.1']);111  });112  it.each([113    ['CR_SECRET_KEY', ''], ['CR_SECRET_KEY', 'abcd'], ['CR_RUNTIME_TOKEN', ''], ['DATABASE_URL', ''],114    ['CR_PUBLIC_ORIGIN', 'http://claude.example.org'], ['CR_PUBLIC_ORIGIN', 'https://claude.example.org/path'],115    ['CR_TRUSTED_PROXIES', 'not-an-ip'], ['CR_TRUSTED_PROXIES', '10.0.0.0/99'],116  ])('refuses production with %s=%j', (k, v) => {117    expect(() => loadConfig({ ...prod, [k]: v })).toThrow(/Invalid configuration/);118  });119  it('never downgrades cookies in production, and only for http://localhost otherwise', () => {120    expect(loadConfig({ ...prod, NODE_ENV: 'development', CR_PUBLIC_ORIGIN: 'http://localhost:8620' }).cookieName).toBe('cr_sid');121    expect(loadConfig({ ...prod, NODE_ENV: 'development', CR_PUBLIC_ORIGIN: 'http://example.org' }).cookieName).toBe('__Host-cr_sid');122    expect(() => loadConfig({ ...prod, CR_PUBLIC_ORIGIN: 'http://localhost:8620' })).toThrow();123  });124});125126describe('CSP', () => {127  const c = loadConfig({ NODE_ENV: 'test', CR_PUBLIC_ORIGIN: 'https://claude.example.org', CR_SECRET_KEY: 'ab'.repeat(32), CR_DATA_DIR: '/tmp/cr-none', CR_WORKSPACE_ROOTS: '/tmp/ws' });128  it('hashes inline scripts only', () => {129    const html = '<script src="/a.js"></script><script>alert(1)</script><script type="application/json">{"a":1}</script><script id="x">self.x=1</script>';130    const hashes = inlineScriptHashes(html);131    expect(hashes).toHaveLength(2);132    expect(hashes[0]).toBe("'sha256-bhHHL3z2vDgxUt0W3dWQOrprscmda2Y5pLsLg4GF+pI='"); // sha256("alert(1)")133  });134  it('never allows unsafe-inline scripts', () => {135    const csp = buildCsp(c, ["'sha256-abc='"]);136    const script = csp.split('; ').find((d) => d.startsWith('script-src')) ?? '';137    expect(script).toBe("script-src 'self' 'wasm-unsafe-eval' 'sha256-abc='");138    expect(script).not.toContain('unsafe-inline');139    expect(csp).toContain("frame-ancestors 'none'");140    expect(csp).toContain("connect-src 'self' wss://claude.example.org");141    expect(csp).toContain("object-src 'none'");142    // Same-origin PDF preview only: no third-party frame source, plugins stay forbidden.143    expect(csp.split('; ').find((d) => d.startsWith('frame-src'))).toBe("frame-src 'self' blob:");144    expect(csp.split('; ').filter((d) => d.startsWith('frame-') || d.startsWith('object-'))).toEqual(["frame-src 'self' blob:", "frame-ancestors 'none'", "object-src 'none'"]);145  });146});147148describe('small pure helpers', () => {149  it('globToRegExp', () => {150    expect(globToRegExp('*.txt').test('a.txt')).toBe(true);151    expect(globToRegExp('*.txt').test('d/a.txt')).toBe(false);152    expect(globToRegExp('out/**/*.csv').test('out/x/y/a.csv')).toBe(true);153    expect(globToRegExp('out/**/*.csv').test('out/a.csv')).toBe(true);154    expect(globToRegExp('a.b').test('axb')).toBe(false);155  });156  it('claude args come only from validated fields', () => {157    expect(buildClaudeArgs('claude', {})).toEqual([]);158    expect(buildClaudeArgs('claude', { continueLast: true })).toEqual(['--continue']);159    expect(buildClaudeArgs('claude', { resumeConversationId: 'abcd-1234-ef' })).toEqual(['--resume', 'abcd-1234-ef']);160    for (const bad of ['--dangerously-skip-permissions', 'short', 'a b c d e f g h', 'abcdefgh;rm', '-rf-abcdefgh x']) {161      expect(() => buildClaudeArgs('claude', { resumeConversationId: bad })).toThrow();162    }163    expect(() => buildClaudeArgs('claude', { resumeConversationId: 'abcd-1234-ef', continueLast: true })).toThrow();164    expect(() => buildClaudeArgs('shell', { continueLast: true })).toThrow();165  });166  it('state tables', () => {167    expect(canTransition('CREATED', 'STARTING')).toBe(true);168    expect(canTransition('STOPPED', 'RUNNING')).toBe(false);169    expect(canTransition('STARTING', 'RECOVERABLE')).toBe(false);170    expect(canTransition('RECOVERABLE', 'STARTING')).toBe(true);171    expect(SESSION_TRANSITIONS.FAILED).toEqual([]);172    expect(canTaskTransition('QUEUED', 'SUCCEEDED')).toBe(false);173    expect(canTaskTransition('RUNNING', 'LOST')).toBe(true);174  });175});176
Files main
Menu

Theme