Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/logs/log-aggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,135 @@ describe('log-aggregator', () => {
});
});

describe('blocked domain aggregation', () => {
it('should correctly aggregate multiple blocked domains', () => {
const entries: ParsedLogEntry[] = [
createLogEntry({ domain: 'evil.com', isAllowed: false, decision: 'TCP_DENIED:HIER_NONE', statusCode: 403 }),
createLogEntry({ domain: 'malware.io', isAllowed: false, decision: 'TCP_DENIED:HIER_NONE', statusCode: 403 }),
createLogEntry({ domain: 'evil.com', isAllowed: false, decision: 'TCP_DENIED:HIER_NONE', statusCode: 403 }),
];

const stats = aggregateLogs(entries);

expect(stats.totalRequests).toBe(3);
expect(stats.allowedRequests).toBe(0);
expect(stats.deniedRequests).toBe(3);
expect(stats.uniqueDomains).toBe(2);
expect(stats.byDomain.get('evil.com')).toEqual({
domain: 'evil.com',
allowed: 0,
denied: 2,
total: 2,
});
expect(stats.byDomain.get('malware.io')).toEqual({
domain: 'malware.io',
allowed: 0,
denied: 1,
total: 1,
});
});

it('should correctly aggregate mixed allowed and denied for same domain', () => {
const entries: ParsedLogEntry[] = [
createLogEntry({ domain: 'github.com', isAllowed: true }),
createLogEntry({ domain: 'github.com', isAllowed: false, decision: 'TCP_DENIED:HIER_NONE', statusCode: 403 }),
createLogEntry({ domain: 'github.com', isAllowed: true }),
];

const stats = aggregateLogs(entries);

expect(stats.byDomain.get('github.com')).toEqual({
domain: 'github.com',
allowed: 2,
denied: 1,
total: 3,
});
});

it('should handle only denied entries with no allowed entries', () => {
const entries: ParsedLogEntry[] = [
createLogEntry({ domain: 'blocked1.com', isAllowed: false }),
createLogEntry({ domain: 'blocked2.com', isAllowed: false }),
];

const stats = aggregateLogs(entries);

expect(stats.totalRequests).toBe(2);
expect(stats.allowedRequests).toBe(0);
expect(stats.deniedRequests).toBe(2);
expect(stats.uniqueDomains).toBe(2);
});
});

describe('loadAndAggregate', () => {
it('should correctly detect blocked domains from real log lines', async () => {
const mockLogContent = [
'1761074374.646 172.30.0.20:39748 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"',
'1761074375.123 172.30.0.20:39749 evil.com:443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE evil.com:443 "curl/7.81.0"',
'1761074376.456 172.30.0.20:39750 malware.io:443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE malware.io:443 "python-requests/2.28"',
'1761074377.789 172.30.0.20:39751 npmjs.org:443 104.16.0.0:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT npmjs.org:443 "-"',
'1761074378.012 172.30.0.20:39752 evil.com:443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE evil.com:443 "curl/7.81.0"',
].join('\n');

mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue(mockLogContent);

const source: LogSource = {
type: 'preserved',
path: '/tmp/squid-logs-blocked-test',
};

const stats = await loadAndAggregate(source);

expect(stats.totalRequests).toBe(5);
expect(stats.allowedRequests).toBe(2);
expect(stats.deniedRequests).toBe(3);
expect(stats.uniqueDomains).toBe(4);

// Verify blocked domains are correctly identified
const evilStats = stats.byDomain.get('evil.com');
expect(evilStats).toBeDefined();
expect(evilStats!.denied).toBe(2);
expect(evilStats!.allowed).toBe(0);

const malwareStats = stats.byDomain.get('malware.io');
expect(malwareStats).toBeDefined();
expect(malwareStats!.denied).toBe(1);
expect(malwareStats!.allowed).toBe(0);

// Verify allowed domains
const githubStats = stats.byDomain.get('api.github.com');
expect(githubStats).toBeDefined();
expect(githubStats!.allowed).toBe(1);
expect(githubStats!.denied).toBe(0);
});

it('should detect blocked HTTP domains from real log lines', async () => {
const mockLogContent = [
'1761074374.646 172.30.0.20:39748 example.com:80 93.184.216.34:80 1.1 GET 200 TCP_MISS:HIER_DIRECT http://example.com/ "-"',
'1761074375.123 172.30.0.20:39749 blocked.com:80 -:- 1.1 GET 403 TCP_DENIED:HIER_NONE http://blocked.com/exfil "-"',
].join('\n');

mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue(mockLogContent);

const source: LogSource = {
type: 'preserved',
path: '/tmp/squid-logs-http-blocked',
};

const stats = await loadAndAggregate(source);

expect(stats.totalRequests).toBe(2);
expect(stats.allowedRequests).toBe(1);
expect(stats.deniedRequests).toBe(1);

const blockedStats = stats.byDomain.get('blocked.com');
expect(blockedStats).toBeDefined();
expect(blockedStats!.denied).toBe(1);
expect(blockedStats!.allowed).toBe(0);
});

it('should load and aggregate logs in one call', async () => {
const mockLogContent = [
'1761074374.646 172.30.0.20:39748 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"',
Expand Down
94 changes: 94 additions & 0 deletions src/logs/log-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,51 @@ describe('log-parser', () => {
expect(result!.domain).toBe('api.github.com');
});

it('should parse a denied HTTP (GET) log line', () => {
const line =
'1760994429.358 172.30.0.20:36274 evil.com:80 -:- 1.1 GET 403 TCP_DENIED:HIER_NONE http://evil.com/ "curl/7.81.0"';
const result = parseLogLine(line);

expect(result).not.toBeNull();
expect(result!.domain).toBe('evil.com');
expect(result!.isAllowed).toBe(false);
expect(result!.isHttps).toBe(false);
expect(result!.method).toBe('GET');
expect(result!.statusCode).toBe(403);
expect(result!.decision).toBe('TCP_DENIED:HIER_NONE');
});

it('should mark TCP_HIT as allowed (cached response)', () => {
const line =
'1761074374.646 172.30.0.20:39748 example.com:80 93.184.216.34:80 1.1 GET 200 TCP_HIT:HIER_NONE http://example.com/ "-"';
const result = parseLogLine(line);

expect(result).not.toBeNull();
// TCP_HIT is neither TCP_TUNNEL nor TCP_MISS, so isAllowed should be false
// This documents current behavior: only TCP_TUNNEL and TCP_MISS are considered allowed
expect(result!.isAllowed).toBe(false);
});
Comment on lines +100 to +109
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name contradicts the asserted behavior: it says "should mark TCP_HIT as allowed" but the test (correctly, per current isAllowed logic) expects isAllowed to be false. Please rename the test to reflect the actual expectation so failures are easier to interpret.

Copilot uses AI. Check for mistakes.

it('should mark TCP_REFRESH_MODIFIED as denied (not in allowed list)', () => {
const line =
'1761074374.646 172.30.0.20:39748 example.com:80 93.184.216.34:80 1.1 GET 200 TCP_REFRESH_MODIFIED:HIER_DIRECT http://example.com/ "-"';
const result = parseLogLine(line);

expect(result).not.toBeNull();
// TCP_REFRESH_MODIFIED is not TCP_TUNNEL or TCP_MISS
expect(result!.isAllowed).toBe(false);
});

it('should mark NONE_NONE as denied (connection failure entries)', () => {
const line =
'1761074374.646 172.30.0.20:39748 -:0 -:- 1.1 NONE 0 NONE_NONE:HIER_NONE error:transaction-end-before-headers "-"';
const result = parseLogLine(line);

expect(result).not.toBeNull();
expect(result!.isAllowed).toBe(false);
expect(result!.decision).toBe('NONE_NONE:HIER_NONE');
});

it('should correctly identify HTTPS requests via CONNECT method', () => {
const httpsLine =
'1761074374.646 172.30.0.20:39748 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"';
Expand All @@ -97,6 +142,55 @@ describe('log-parser', () => {
});
});

describe('blocked domain detection', () => {
it('should detect blocked HTTPS domain with correct domain extraction', () => {
const line =
'1760994429.358 172.30.0.20:36274 malware.example.com:443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE malware.example.com:443 "python-requests/2.28"';
const result = parseLogLine(line);

expect(result).not.toBeNull();
expect(result!.domain).toBe('malware.example.com');
expect(result!.isAllowed).toBe(false);
expect(result!.isHttps).toBe(true);
});

it('should detect blocked HTTP domain with correct domain extraction', () => {
const line =
'1760994429.358 172.30.0.20:36274 exfiltration.io:80 -:- 1.1 GET 403 TCP_DENIED:HIER_NONE http://exfiltration.io/data "wget/1.21"';
const result = parseLogLine(line);

expect(result).not.toBeNull();
expect(result!.domain).toBe('exfiltration.io');
expect(result!.isAllowed).toBe(false);
expect(result!.isHttps).toBe(false);
});

it('should detect blocked domain on non-standard port', () => {
const line =
'1760994429.358 172.30.0.20:36274 api.blocked.com:8443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE api.blocked.com:8443 "curl/7.81.0"';
const result = parseLogLine(line);

expect(result).not.toBeNull();
expect(result!.domain).toBe('api.blocked.com');
expect(result!.isAllowed).toBe(false);
});

it('should distinguish allowed and denied requests for the same domain format', () => {
const allowedLine =
'1761074374.646 172.30.0.20:39748 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"';
const deniedLine =
'1761074375.123 172.30.0.20:39749 api.github.com:443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE api.github.com:443 "-"';

const allowed = parseLogLine(allowedLine);
const denied = parseLogLine(deniedLine);

expect(allowed!.domain).toBe('api.github.com');
expect(denied!.domain).toBe('api.github.com');
expect(allowed!.isAllowed).toBe(true);
expect(denied!.isAllowed).toBe(false);
});
});

describe('extractDomain', () => {
it('should extract domain from CONNECT URL with port', () => {
expect(extractDomain('api.github.com:443', 'host', 'CONNECT')).toBe('api.github.com');
Expand Down
101 changes: 101 additions & 0 deletions src/logs/log-streamer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ import { LogFormatter } from './log-formatter';
import { LogSource } from '../types';
import execa from 'execa';
import { Readable } from 'stream';
import { trackPidForPortSync, isPidTrackingAvailable } from '../pid-tracker';

// Mock external dependencies
jest.mock('execa');
jest.mock('fs');
jest.mock('../pid-tracker', () => ({
trackPidForPortSync: jest.fn().mockReturnValue({ pid: -1, cmdline: '', comm: '', inode: 0 }),
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock trackPidForPortSync return value uses inode: 0 (number), but the real PidTrackResult.inode type is an optional string. Using the wrong type in the module mock can mask type/serialization issues in PID enrichment; return a string inode (e.g. '0'/'56789') or undefined to match production behavior.

This issue also appears on line 340 of the same file.

Suggested change
trackPidForPortSync: jest.fn().mockReturnValue({ pid: -1, cmdline: '', comm: '', inode: 0 }),
trackPidForPortSync: jest.fn().mockReturnValue({ pid: -1, cmdline: '', comm: '', inode: '0' }),

Copilot uses AI. Check for mistakes.
isPidTrackingAvailable: jest.fn().mockReturnValue(true),
}));
jest.mock('../logger', () => ({
logger: {
debug: jest.fn(),
Expand Down Expand Up @@ -317,4 +322,100 @@ describe('log-streamer', () => {
expect(stdoutWriteSpy).toHaveBeenCalledWith('raw log line\n');
});
});

describe('streamLogs - withPid enrichment', () => {
it('should enrich parsed entries with PID info when withPid is true', async () => {
const source: LogSource = {
type: 'preserved',
path: '/tmp/squid-logs',
};
const formatter = new LogFormatter({ format: 'json' });

const logLine =
'1761074374.646 172.30.0.20:39748 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"';

mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue(logLine);

(trackPidForPortSync as jest.Mock).mockReturnValue({
pid: 1234,
cmdline: 'curl https://api.github.com',
comm: 'curl',
inode: 56789,
});

await streamLogs({
follow: false,
source,
formatter,
parse: true,
withPid: true,
});
Comment on lines +347 to +353
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These withPid tests exercise PID enrichment while follow: false on a preserved file source. In the CLI, --with-pid is explicitly disabled unless -f/--follow is set (and the option docs say "real-time only"), so this test is asserting behavior that is effectively unreachable and contradicts the intended contract. Consider switching the test to follow: true (tailing) or otherwise aligning it with the documented/CLI behavior.

Copilot uses AI. Check for mistakes.

expect(trackPidForPortSync).toHaveBeenCalledWith(39748);
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = JSON.parse(stdoutWriteSpy.mock.calls[0][0]);
expect(output.pid).toBe(1234);
expect(output.comm).toBe('curl');
});

it('should not enrich when PID lookup returns -1', async () => {
const source: LogSource = {
type: 'preserved',
path: '/tmp/squid-logs',
};
const formatter = new LogFormatter({ format: 'json' });

const logLine =
'1761074374.646 172.30.0.20:39748 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"';

mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue(logLine);

(trackPidForPortSync as jest.Mock).mockReturnValue({
pid: -1,
cmdline: '',
comm: '',
inode: 0,
});

await streamLogs({
follow: false,
source,
formatter,
parse: true,
withPid: true,
});

expect(stdoutWriteSpy).toHaveBeenCalled();
const output = JSON.parse(stdoutWriteSpy.mock.calls[0][0]);
expect(output.pid).toBeUndefined();
});

it('should warn when PID tracking is not available', async () => {
const source: LogSource = {
type: 'preserved',
path: '/tmp/squid-logs',
};
const formatter = new LogFormatter({ format: 'raw' });

mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue('raw line');

(isPidTrackingAvailable as jest.Mock).mockReturnValue(false);

await streamLogs({
follow: false,
source,
formatter,
parse: false,
withPid: true,
});

const { logger } = jest.requireMock('../logger') as { logger: { warn: jest.Mock } };
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('PID tracking not available')
);
});
});
});
Loading