214 lines
7.0 KiB
TypeScript
214 lines
7.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { authenticateRequest, authorizeNodeMethod } from './auth.js';
|
|
import type { IncomingMessage } from 'http';
|
|
|
|
function mockRequest(headers: Record<string, string> = {}): IncomingMessage {
|
|
return { headers } as unknown as IncomingMessage;
|
|
}
|
|
|
|
function mockRequestWithUrl(url: string, headers: Record<string, string> = {}): 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);
|
|
});
|
|
});
|