162 lines
5.6 KiB
TypeScript
162 lines
5.6 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import * as net from 'net';
|
|
import { EventEmitter } from 'events';
|
|
import { getSocketDir, safeWrite } from './daemon.js';
|
|
|
|
/**
|
|
* HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks.
|
|
* This pattern detects HTTP method prefixes that browsers must send when using fetch().
|
|
*/
|
|
const HTTP_REQUEST_PATTERN = /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i;
|
|
|
|
describe('HTTP request detection (security)', () => {
|
|
it('should detect POST requests from fetch()', () => {
|
|
const httpRequest = 'POST / HTTP/1.1\r\nHost: 127.0.0.1:51234\r\n';
|
|
expect(HTTP_REQUEST_PATTERN.test(httpRequest.trimStart())).toBe(true);
|
|
});
|
|
|
|
it('should detect GET requests', () => {
|
|
expect(HTTP_REQUEST_PATTERN.test('GET / HTTP/1.1')).toBe(true);
|
|
});
|
|
|
|
it('should detect OPTIONS preflight requests', () => {
|
|
expect(HTTP_REQUEST_PATTERN.test('OPTIONS / HTTP/1.1')).toBe(true);
|
|
});
|
|
|
|
it('should NOT detect valid JSON commands', () => {
|
|
const jsonCommand = '{"id":"1","action":"navigate","url":"https://example.com"}';
|
|
expect(HTTP_REQUEST_PATTERN.test(jsonCommand.trimStart())).toBe(false);
|
|
});
|
|
|
|
it('should NOT detect JSON with leading whitespace', () => {
|
|
const jsonCommand = ' {"id":"1","action":"click","selector":"button"}';
|
|
expect(HTTP_REQUEST_PATTERN.test(jsonCommand.trimStart())).toBe(false);
|
|
});
|
|
|
|
it('should be case insensitive for HTTP methods', () => {
|
|
expect(HTTP_REQUEST_PATTERN.test('post / HTTP/1.1')).toBe(true);
|
|
expect(HTTP_REQUEST_PATTERN.test('Post / HTTP/1.1')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getSocketDir', () => {
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
// Clear relevant env vars before each test
|
|
delete process.env.AGENT_BROWSER_SOCKET_DIR;
|
|
delete process.env.XDG_RUNTIME_DIR;
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore original env
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
describe('AGENT_BROWSER_SOCKET_DIR', () => {
|
|
it('should use custom path when set', () => {
|
|
process.env.AGENT_BROWSER_SOCKET_DIR = '/custom/socket/path';
|
|
expect(getSocketDir()).toBe('/custom/socket/path');
|
|
});
|
|
|
|
it('should ignore empty string', () => {
|
|
process.env.AGENT_BROWSER_SOCKET_DIR = '';
|
|
const result = getSocketDir();
|
|
expect(result).toContain('.agent-browser');
|
|
});
|
|
|
|
it('should take priority over XDG_RUNTIME_DIR', () => {
|
|
process.env.AGENT_BROWSER_SOCKET_DIR = '/custom/path';
|
|
process.env.XDG_RUNTIME_DIR = '/run/user/1000';
|
|
expect(getSocketDir()).toBe('/custom/path');
|
|
});
|
|
});
|
|
|
|
describe('XDG_RUNTIME_DIR', () => {
|
|
it('should use when AGENT_BROWSER_SOCKET_DIR is not set', () => {
|
|
process.env.XDG_RUNTIME_DIR = '/run/user/1000';
|
|
expect(getSocketDir()).toBe('/run/user/1000/agent-browser');
|
|
});
|
|
|
|
it('should ignore empty string', () => {
|
|
process.env.AGENT_BROWSER_SOCKET_DIR = '';
|
|
process.env.XDG_RUNTIME_DIR = '';
|
|
const result = getSocketDir();
|
|
expect(result).toContain('.agent-browser');
|
|
});
|
|
});
|
|
|
|
describe('fallback', () => {
|
|
it('should use home directory when env vars are not set', () => {
|
|
const result = getSocketDir();
|
|
const expected = path.join(os.homedir(), '.agent-browser');
|
|
expect(result).toBe(expected);
|
|
});
|
|
});
|
|
});
|
|
|
|
function createMockSocket(opts: { destroyed?: boolean; writeReturns?: boolean } = {}) {
|
|
const emitter = new EventEmitter();
|
|
const socket = Object.assign(emitter, {
|
|
destroyed: opts.destroyed ?? false,
|
|
write: vi.fn().mockReturnValue(opts.writeReturns ?? true),
|
|
removeListener: emitter.removeListener.bind(emitter),
|
|
});
|
|
return socket as unknown as net.Socket;
|
|
}
|
|
|
|
describe('safeWrite', () => {
|
|
it('should resolve immediately when socket.write returns true', async () => {
|
|
const socket = createMockSocket({ writeReturns: true });
|
|
await safeWrite(socket, 'hello\n');
|
|
expect(socket.write).toHaveBeenCalledWith('hello\n');
|
|
});
|
|
|
|
it('should resolve immediately when socket is already destroyed', async () => {
|
|
const socket = createMockSocket({ destroyed: true });
|
|
await safeWrite(socket, 'hello\n');
|
|
expect(socket.write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should wait for drain event when socket.write returns false', async () => {
|
|
const socket = createMockSocket({ writeReturns: false });
|
|
const promise = safeWrite(socket, 'big payload');
|
|
|
|
// Simulate drain after a tick
|
|
setTimeout(() => socket.emit('drain'), 0);
|
|
await promise;
|
|
|
|
expect(socket.write).toHaveBeenCalledWith('big payload');
|
|
});
|
|
|
|
it('should reject on socket error while waiting for drain', async () => {
|
|
const socket = createMockSocket({ writeReturns: false });
|
|
const promise = safeWrite(socket, 'data');
|
|
|
|
setTimeout(() => socket.emit('error', new Error('connection reset')), 0);
|
|
await expect(promise).rejects.toThrow('connection reset');
|
|
});
|
|
|
|
it('should resolve on socket close while waiting for drain', async () => {
|
|
const socket = createMockSocket({ writeReturns: false });
|
|
const promise = safeWrite(socket, 'data');
|
|
|
|
setTimeout(() => socket.emit('close'), 0);
|
|
await promise;
|
|
});
|
|
|
|
it('should clean up listeners after drain resolves', async () => {
|
|
const socket = createMockSocket({ writeReturns: false });
|
|
const promise = safeWrite(socket, 'data');
|
|
|
|
setTimeout(() => socket.emit('drain'), 0);
|
|
await promise;
|
|
|
|
expect(socket.listenerCount('drain')).toBe(0);
|
|
expect(socket.listenerCount('error')).toBe(0);
|
|
expect(socket.listenerCount('close')).toBe(0);
|
|
});
|
|
});
|