import { describe, it, expect } from 'vitest'; import { authenticateRequest, authorizeNodeMethod } from './auth.js'; import type { IncomingMessage } from 'http'; function mockRequest(headers: Record = {}): IncomingMessage { return { headers } as unknown as IncomingMessage; } function mockRequestWithUrl(url: string, headers: Record = {}): IncomingMessage { return { url, headers } as unknown as IncomingMessage; } describe('authenticateRequest', () => { describe('no auth configured', () => { it('allows all connections', () => { const result = authenticateRequest(mockRequest(), {}); expect(result.authenticated).toBe(true); expect(result.identity).toBe('anonymous'); }); }); describe('token auth', () => { const config = { token: 'secret-token-123' }; it('accepts valid Bearer token', () => { const result = authenticateRequest( mockRequest({ authorization: 'Bearer secret-token-123' }), config, ); expect(result.authenticated).toBe(true); expect(result.identity).toBe('token-user'); }); it('rejects missing Authorization header', () => { const result = authenticateRequest(mockRequest(), config); expect(result.authenticated).toBe(false); expect(result.error).toContain('Authorization required'); }); it('rejects invalid token', () => { const result = authenticateRequest( mockRequest({ authorization: 'Bearer wrong-token' }), config, ); expect(result.authenticated).toBe(false); expect(result.error).toContain('Invalid token'); }); it('rejects non-Bearer format', () => { const result = authenticateRequest( mockRequest({ authorization: 'Basic dXNlcjpwYXNz' }), config, ); expect(result.authenticated).toBe(false); expect(result.error).toContain('Invalid Authorization format'); }); it('uses Tailscale identity when both token and tailscale are configured', () => { const result = authenticateRequest( mockRequest({ authorization: 'Bearer secret-token-123', 'tailscale-user-login': 'will@example.com', }), { token: 'secret-token-123', tailscaleIdentity: true }, ); expect(result.authenticated).toBe(true); expect(result.identity).toBe('will@example.com'); }); }); describe('tailscale identity', () => { const config = { tailscaleIdentity: true }; it('extracts identity from Tailscale-User-Login header', () => { const result = authenticateRequest( mockRequest({ 'tailscale-user-login': 'will@example.com' }), config, ); expect(result.authenticated).toBe(true); expect(result.identity).toBe('will@example.com'); }); it('allows connections without Tailscale header (local access)', () => { const result = authenticateRequest(mockRequest(), config); expect(result.authenticated).toBe(true); expect(result.identity).toBe('anonymous'); }); }); describe('query parameter token', () => { const config = { token: 'secret-token-123' }; it('accepts valid token in query parameter', () => { const result = authenticateRequest( mockRequestWithUrl('/?token=secret-token-123'), config, ); expect(result.authenticated).toBe(true); expect(result.identity).toBe('token-user'); }); it('rejects invalid token in query parameter', () => { const result = authenticateRequest( mockRequestWithUrl('/?token=wrong'), config, ); expect(result.authenticated).toBe(false); expect(result.error).toContain('Invalid token'); }); it('prefers header over query parameter', () => { const result = authenticateRequest( mockRequestWithUrl('/?token=wrong', { authorization: 'Bearer secret-token-123' }), config, ); expect(result.authenticated).toBe(true); expect(result.identity).toBe('token-user'); }); it('rejects when neither header nor query parameter provided', () => { const result = authenticateRequest( mockRequestWithUrl('/'), config, ); expect(result.authenticated).toBe(false); expect(result.error).toContain('Authorization required'); }); }); }); describe('authorizeNodeMethod', () => { it('allows non-node methods', () => { const result = authorizeNodeMethod({ enabled: false, method: 'system.health' }); expect(result.authenticated).toBe(true); }); it('blocks node methods when node RPC is disabled', () => { const result = authorizeNodeMethod({ enabled: false, method: 'node.capabilities.get' }); expect(result.authenticated).toBe(false); expect(result.error).toContain('disabled'); }); it('allows node.register without prior registration', () => { const result = authorizeNodeMethod({ enabled: true, method: 'node.register' }); expect(result.authenticated).toBe(true); }); it('requires role for scoped node methods', () => { const result = authorizeNodeMethod({ enabled: true, method: 'node.capabilities.get' }); expect(result.authenticated).toBe(false); expect(result.error).toContain('not registered'); }); it('enforces allowed role list and method scopes', () => { const deniedRole = authorizeNodeMethod({ enabled: true, method: 'node.capabilities.get', nodeRole: 'observer', allowedRoles: ['companion'], }); expect(deniedRole.authenticated).toBe(false); const deniedMethod = authorizeNodeMethod({ enabled: true, method: 'node.admin.reset', nodeRole: 'companion', allowedRoles: ['companion'], roleScopes: { companion: ['node.capabilities.get'] }, }); expect(deniedMethod.authenticated).toBe(false); const allowed = authorizeNodeMethod({ enabled: true, method: 'node.capabilities.get', nodeRole: 'companion', allowedRoles: ['companion'], roleScopes: { companion: ['node.capabilities.get'] }, }); expect(allowed.authenticated).toBe(true); const allowedLocation = authorizeNodeMethod({ enabled: true, method: 'node.location.set', nodeRole: 'companion', allowedRoles: ['companion'], roleScopes: { companion: ['node.capabilities.get', 'node.location.set'] }, }); expect(allowedLocation.authenticated).toBe(true); const deniedStatus = authorizeNodeMethod({ enabled: true, method: 'node.status.set', nodeRole: 'observer', allowedRoles: ['companion', 'observer'], roleScopes: { companion: ['node.capabilities.get', 'node.status.set'], observer: ['node.capabilities.get'], }, }); expect(deniedStatus.authenticated).toBe(false); const allowedPush = authorizeNodeMethod({ enabled: true, method: 'node.push_token.set', nodeRole: 'companion', allowedRoles: ['companion'], roleScopes: { companion: ['node.capabilities.get', 'node.push_token.set'], }, }); expect(allowedPush.authenticated).toBe(true); }); });