178 lines
4.8 KiB
TypeScript
178 lines
4.8 KiB
TypeScript
import {
|
|
describe,
|
|
test,
|
|
expect,
|
|
beforeEach,
|
|
afterAll,
|
|
mock,
|
|
spyOn,
|
|
} from 'bun:test'
|
|
|
|
// Mock config before importing modules that depend on it
|
|
const mockConfig = {
|
|
port: 3000,
|
|
host: '0.0.0.0',
|
|
apiKeys: ['test-key-1', 'test-key-2'],
|
|
baseUrl: '',
|
|
pollTimeout: 8,
|
|
heartbeatInterval: 20,
|
|
jwtExpiresIn: 3600,
|
|
disconnectTimeout: 300,
|
|
webCorsOrigins: [],
|
|
wsIdleTimeout: 30,
|
|
wsKeepaliveInterval: 20,
|
|
}
|
|
|
|
mock.module('../config', () => ({
|
|
config: mockConfig,
|
|
getBaseUrl: () => 'http://localhost:3000',
|
|
}))
|
|
|
|
import { validateApiKey, hashApiKey } from '../auth/api-key'
|
|
import { generateWorkerJwt, verifyWorkerJwt } from '../auth/jwt'
|
|
import { issueToken, resolveToken } from '../auth/token'
|
|
import { storeReset, storeCreateUser } from '../store'
|
|
|
|
// ---------- api-key ----------
|
|
|
|
describe('validateApiKey', () => {
|
|
test('validates a configured API key', () => {
|
|
expect(validateApiKey('test-key-1')).toBe(true)
|
|
expect(validateApiKey('test-key-2')).toBe(true)
|
|
})
|
|
|
|
test('rejects unknown key', () => {
|
|
expect(validateApiKey('unknown-key')).toBe(false)
|
|
})
|
|
|
|
test('rejects undefined', () => {
|
|
expect(validateApiKey(undefined)).toBe(false)
|
|
})
|
|
|
|
test('rejects empty string', () => {
|
|
expect(validateApiKey('')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('hashApiKey', () => {
|
|
test('produces consistent SHA-256 hex', () => {
|
|
const hash = hashApiKey('my-key')
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
expect(hashApiKey('my-key')).toBe(hash)
|
|
})
|
|
|
|
test('different keys produce different hashes', () => {
|
|
expect(hashApiKey('key-a')).not.toBe(hashApiKey('key-b'))
|
|
})
|
|
})
|
|
|
|
// ---------- jwt ----------
|
|
|
|
describe('JWT', () => {
|
|
// JWT reads process.env.RCS_API_KEYS directly (not via config)
|
|
const originalKeys = process.env.RCS_API_KEYS
|
|
|
|
beforeEach(() => {
|
|
process.env.RCS_API_KEYS = 'jwt-test-secret'
|
|
})
|
|
|
|
afterAll(() => {
|
|
process.env.RCS_API_KEYS = originalKeys
|
|
})
|
|
|
|
describe('generateWorkerJwt', () => {
|
|
test('produces a three-part base64url token', () => {
|
|
const token = generateWorkerJwt('ses_123', 3600)
|
|
const parts = token.split('.')
|
|
expect(parts).toHaveLength(3)
|
|
for (const part of parts) {
|
|
expect(part).toMatch(/^[A-Za-z0-9_-]+$/)
|
|
}
|
|
})
|
|
|
|
test('contains correct header', () => {
|
|
const token = generateWorkerJwt('ses_123', 3600)
|
|
const header = JSON.parse(
|
|
atob(token.split('.')[0].replace(/-/g, '+').replace(/_/g, '/')),
|
|
)
|
|
expect(header.alg).toBe('HS256')
|
|
expect(header.typ).toBe('JWT')
|
|
})
|
|
|
|
test('throws when no API key configured', () => {
|
|
delete process.env.RCS_API_KEYS
|
|
expect(() => generateWorkerJwt('ses_123', 3600)).toThrow(
|
|
'No API key configured',
|
|
)
|
|
process.env.RCS_API_KEYS = 'jwt-test-secret'
|
|
})
|
|
})
|
|
|
|
describe('verifyWorkerJwt', () => {
|
|
test('verifies a valid token', () => {
|
|
const token = generateWorkerJwt('ses_abc', 3600)
|
|
const payload = verifyWorkerJwt(token)
|
|
expect(payload).not.toBeNull()
|
|
expect(payload!.session_id).toBe('ses_abc')
|
|
expect(payload!.role).toBe('worker')
|
|
expect(payload!.iat).toBeGreaterThan(0)
|
|
expect(payload!.exp).toBeGreaterThan(payload!.iat)
|
|
})
|
|
|
|
test('returns null for expired token', () => {
|
|
const token = generateWorkerJwt('ses_old', -10)
|
|
expect(verifyWorkerJwt(token)).toBeNull()
|
|
})
|
|
|
|
test('returns null for malformed token (not 3 parts)', () => {
|
|
expect(verifyWorkerJwt('a.b')).toBeNull()
|
|
expect(verifyWorkerJwt('just-a-string')).toBeNull()
|
|
})
|
|
|
|
test('returns null for tampered signature', () => {
|
|
const token = generateWorkerJwt('ses_123', 3600)
|
|
const parts = token.split('.')
|
|
const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}xxxx`
|
|
expect(verifyWorkerJwt(tampered)).toBeNull()
|
|
})
|
|
|
|
test('returns null for wrong signing key', () => {
|
|
const token = generateWorkerJwt('ses_123', 3600)
|
|
process.env.RCS_API_KEYS = 'wrong-key'
|
|
expect(verifyWorkerJwt(token)).toBeNull()
|
|
process.env.RCS_API_KEYS = 'jwt-test-secret'
|
|
})
|
|
})
|
|
})
|
|
|
|
// ---------- token ----------
|
|
|
|
describe('issueToken / resolveToken', () => {
|
|
beforeEach(() => {
|
|
storeReset()
|
|
})
|
|
|
|
test('issues and resolves a token', () => {
|
|
storeCreateUser('alice')
|
|
const { token, expires_in } = issueToken('alice')
|
|
expect(token).toMatch(/^rct_\d+_[0-9a-f]+$/)
|
|
expect(expires_in).toBe(86400)
|
|
expect(resolveToken(token)).toBe('alice')
|
|
})
|
|
|
|
test('returns null for unknown token', () => {
|
|
expect(resolveToken('nonexistent')).toBeNull()
|
|
})
|
|
|
|
test('returns null for undefined token', () => {
|
|
expect(resolveToken(undefined)).toBeNull()
|
|
})
|
|
|
|
test('tokens are unique', () => {
|
|
storeCreateUser('alice')
|
|
const t1 = issueToken('alice').token
|
|
const t2 = issueToken('alice').token
|
|
expect(t1).not.toBe(t2)
|
|
})
|
|
})
|