@auxiora/browser 1.0.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,185 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { BrowserManager } from '../src/browser-manager.js';
3
-
4
- // --- Mock factories ---
5
-
6
- function createMockPage(overrides: Record<string, any> = {}) {
7
- return {
8
- isClosed: vi.fn().mockReturnValue(false),
9
- close: vi.fn().mockResolvedValue(undefined),
10
- goto: vi.fn().mockResolvedValue(undefined),
11
- title: vi.fn().mockResolvedValue('Test Page'),
12
- url: vi.fn().mockReturnValue('https://example.com'),
13
- content: vi.fn().mockResolvedValue('<html><body><h1>Hello</h1></body></html>'),
14
- click: vi.fn().mockResolvedValue(undefined),
15
- fill: vi.fn().mockResolvedValue(undefined),
16
- screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-png')),
17
- $: vi.fn().mockResolvedValue(null),
18
- $$eval: vi.fn().mockResolvedValue([]),
19
- evaluate: vi.fn().mockResolvedValue({}),
20
- waitForSelector: vi.fn().mockResolvedValue(undefined),
21
- setViewportSize: vi.fn().mockResolvedValue(undefined),
22
- setDefaultNavigationTimeout: vi.fn(),
23
- setDefaultTimeout: vi.fn(),
24
- keyboard: {
25
- press: vi.fn().mockResolvedValue(undefined),
26
- },
27
- ...overrides,
28
- };
29
- }
30
-
31
- function createMockContext(overrides: Record<string, any> = {}) {
32
- const mockPage = createMockPage();
33
- return {
34
- newPage: vi.fn().mockResolvedValue(mockPage),
35
- close: vi.fn().mockResolvedValue(undefined),
36
- _mockPage: mockPage,
37
- ...overrides,
38
- };
39
- }
40
-
41
- function createMockBrowser(overrides: Record<string, any> = {}) {
42
- const mockContext = createMockContext();
43
- return {
44
- isConnected: vi.fn().mockReturnValue(true),
45
- newContext: vi.fn().mockResolvedValue(mockContext),
46
- close: vi.fn().mockResolvedValue(undefined),
47
- _mockContext: mockContext,
48
- ...overrides,
49
- };
50
- }
51
-
52
- // --- Tests ---
53
-
54
- describe('BrowserManager Actions', () => {
55
- let mockBrowser: ReturnType<typeof createMockBrowser>;
56
- let mockPage: ReturnType<typeof createMockPage>;
57
- let browserFactory: ReturnType<typeof vi.fn>;
58
- let manager: BrowserManager;
59
-
60
- beforeEach(async () => {
61
- mockBrowser = createMockBrowser();
62
- mockPage = mockBrowser._mockContext._mockPage;
63
- browserFactory = vi.fn().mockResolvedValue(mockBrowser);
64
- manager = new BrowserManager({
65
- browserFactory,
66
- config: { screenshotDir: '' } as any,
67
- });
68
- });
69
-
70
- describe('navigate', () => {
71
- it('should return page info after navigation', async () => {
72
- const result = await manager.navigate('s1', 'https://example.com');
73
- expect(result.url).toBe('https://example.com');
74
- expect(result.title).toBe('Test Page');
75
- expect(result.content).toBeDefined();
76
- expect(mockPage.goto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'domcontentloaded' });
77
- });
78
-
79
- it('should reject blocked URLs', async () => {
80
- await expect(
81
- manager.navigate('s1', 'https://evil.com', )
82
- ).resolves.toBeDefined(); // not blocked by default
83
-
84
- const blockedManager = new BrowserManager({
85
- browserFactory,
86
- config: { blockedUrls: ['evil.com'], screenshotDir: '' } as any,
87
- });
88
- await expect(
89
- blockedManager.navigate('s1', 'https://evil.com')
90
- ).rejects.toThrow('blocked');
91
- });
92
-
93
- it('should reject private IPs', async () => {
94
- await expect(
95
- manager.navigate('s1', 'http://127.0.0.1')
96
- ).rejects.toThrow('private');
97
- });
98
- });
99
-
100
- describe('click', () => {
101
- it('should call page.click with selector and timeout', async () => {
102
- await manager.click('s1', '#btn');
103
- expect(mockPage.click).toHaveBeenCalledWith('#btn', expect.objectContaining({ timeout: expect.any(Number) }));
104
- });
105
- });
106
-
107
- describe('type', () => {
108
- it('should fill input with text', async () => {
109
- await manager.type('s1', '#input', 'hello');
110
- expect(mockPage.fill).toHaveBeenCalledWith('#input', 'hello');
111
- expect(mockPage.keyboard.press).not.toHaveBeenCalled();
112
- });
113
-
114
- it('should press Enter when requested', async () => {
115
- await manager.type('s1', '#input', 'hello', true);
116
- expect(mockPage.fill).toHaveBeenCalledWith('#input', 'hello');
117
- expect(mockPage.keyboard.press).toHaveBeenCalledWith('Enter');
118
- });
119
- });
120
-
121
- describe('extract', () => {
122
- it('should return elements by selector', async () => {
123
- const mockElements = [
124
- { text: 'Hello', tagName: 'h1', attributes: { class: 'title' } },
125
- ];
126
- mockPage.$$eval.mockResolvedValue(mockElements);
127
-
128
- const result = await manager.extract('s1', 'h1');
129
- expect(result.selector).toBe('h1');
130
- expect(result.elements).toEqual(mockElements);
131
- });
132
- });
133
-
134
- describe('wait', () => {
135
- it('should wait for selector', async () => {
136
- await manager.wait('s1', '.loaded');
137
- expect(mockPage.waitForSelector).toHaveBeenCalledWith('.loaded', expect.objectContaining({ timeout: expect.any(Number) }));
138
- });
139
-
140
- it('should wait for fixed delay', async () => {
141
- const start = Date.now();
142
- await manager.wait('s1', 50);
143
- const elapsed = Date.now() - start;
144
- expect(elapsed).toBeGreaterThanOrEqual(40); // allow small timing margin
145
- });
146
- });
147
-
148
- describe('runScript', () => {
149
- it('should return JSON result', async () => {
150
- mockPage.evaluate.mockResolvedValue({ answer: 42 });
151
- const result = await manager.runScript('s1', 'document.title');
152
- expect(result).toBe('{"answer":42}');
153
- });
154
-
155
- it('should reject oversized results', async () => {
156
- const bigObj = { data: 'x'.repeat(200_000) };
157
- mockPage.evaluate.mockResolvedValue(bigObj);
158
- await expect(manager.runScript('s1', 'big()')).rejects.toThrow('Result too large');
159
- });
160
- });
161
-
162
- describe('screenshot', () => {
163
- it('should capture full-page screenshot as base64', async () => {
164
- const fakeBuffer = Buffer.from('png-data');
165
- mockPage.screenshot.mockResolvedValue(fakeBuffer);
166
-
167
- const result = await manager.screenshot('s1');
168
- expect(result.base64).toBe(fakeBuffer.toString('base64'));
169
- expect(mockPage.screenshot).toHaveBeenCalledWith(expect.objectContaining({ fullPage: true, type: 'png' }));
170
- });
171
- });
172
-
173
- describe('browse', () => {
174
- it('should return mutation message for write tasks', async () => {
175
- const result = await manager.browse('s1', 'Click the login button');
176
- expect(result.result).toContain('interactions');
177
- expect(result.steps).toEqual([]);
178
- });
179
-
180
- it('should handle read tasks', async () => {
181
- const result = await manager.browse('s1', 'Find the pricing information');
182
- expect(result.result).toContain('Browse task queued');
183
- });
184
- });
185
- });
@@ -1,142 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { BrowserManager } from '../src/browser-manager.js';
3
-
4
- // --- Mock factories ---
5
-
6
- function createMockPage(overrides: Record<string, any> = {}) {
7
- return {
8
- isClosed: vi.fn().mockReturnValue(false),
9
- close: vi.fn().mockResolvedValue(undefined),
10
- goto: vi.fn().mockResolvedValue(undefined),
11
- title: vi.fn().mockResolvedValue('Test Page'),
12
- url: vi.fn().mockReturnValue('https://example.com'),
13
- content: vi.fn().mockResolvedValue('<html><body><h1>Hello</h1></body></html>'),
14
- click: vi.fn().mockResolvedValue(undefined),
15
- fill: vi.fn().mockResolvedValue(undefined),
16
- screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-png')),
17
- $: vi.fn().mockResolvedValue(null),
18
- $$eval: vi.fn().mockResolvedValue([]),
19
- evaluate: vi.fn().mockResolvedValue({}),
20
- waitForSelector: vi.fn().mockResolvedValue(undefined),
21
- setViewportSize: vi.fn().mockResolvedValue(undefined),
22
- setDefaultNavigationTimeout: vi.fn(),
23
- setDefaultTimeout: vi.fn(),
24
- keyboard: {
25
- press: vi.fn().mockResolvedValue(undefined),
26
- },
27
- ...overrides,
28
- };
29
- }
30
-
31
- function createMockContext(overrides: Record<string, any> = {}) {
32
- const mockPage = createMockPage();
33
- return {
34
- newPage: vi.fn().mockResolvedValue(mockPage),
35
- close: vi.fn().mockResolvedValue(undefined),
36
- _mockPage: mockPage,
37
- ...overrides,
38
- };
39
- }
40
-
41
- function createMockBrowser(overrides: Record<string, any> = {}) {
42
- const mockContext = createMockContext();
43
- return {
44
- isConnected: vi.fn().mockReturnValue(true),
45
- newContext: vi.fn().mockResolvedValue(mockContext),
46
- close: vi.fn().mockResolvedValue(undefined),
47
- _mockContext: mockContext,
48
- ...overrides,
49
- };
50
- }
51
-
52
- // --- Tests ---
53
-
54
- describe('BrowserManager', () => {
55
- let mockBrowser: ReturnType<typeof createMockBrowser>;
56
- let browserFactory: ReturnType<typeof vi.fn>;
57
- let manager: BrowserManager;
58
-
59
- beforeEach(() => {
60
- mockBrowser = createMockBrowser();
61
- browserFactory = vi.fn().mockResolvedValue(mockBrowser);
62
- manager = new BrowserManager({
63
- browserFactory,
64
- config: { screenshotDir: '' } as any,
65
- });
66
- });
67
-
68
- describe('launch and shutdown', () => {
69
- it('should launch browser on first use', async () => {
70
- await manager.launch();
71
- expect(browserFactory).toHaveBeenCalledTimes(1);
72
- expect(mockBrowser.newContext).toHaveBeenCalledTimes(1);
73
- });
74
-
75
- it('should not re-launch if already running', async () => {
76
- await manager.launch();
77
- await manager.launch();
78
- expect(browserFactory).toHaveBeenCalledTimes(1);
79
- });
80
-
81
- it('should close all pages and browser on shutdown', async () => {
82
- await manager.launch();
83
- const page = await manager.getPage('session-1');
84
- await manager.shutdown();
85
-
86
- expect(page.close).toHaveBeenCalled();
87
- expect(mockBrowser.close).toHaveBeenCalled();
88
- expect(manager.getPageCount()).toBe(0);
89
- });
90
- });
91
-
92
- describe('page lifecycle', () => {
93
- it('should create new page for new session', async () => {
94
- const page = await manager.getPage('session-1');
95
- expect(page).toBeDefined();
96
- expect(page.setViewportSize).toHaveBeenCalled();
97
- expect(manager.getPageCount()).toBe(1);
98
- });
99
-
100
- it('should reuse existing page for same session', async () => {
101
- const page1 = await manager.getPage('session-1');
102
- const page2 = await manager.getPage('session-1');
103
- expect(page1).toBe(page2);
104
- expect(manager.getPageCount()).toBe(1);
105
- });
106
-
107
- it('should close a specific page', async () => {
108
- const page = await manager.getPage('session-1');
109
- await manager.closePage('session-1');
110
- expect(page.close).toHaveBeenCalled();
111
- expect(manager.getPageCount()).toBe(0);
112
- });
113
-
114
- it('should enforce max concurrent pages', async () => {
115
- const limitedManager = new BrowserManager({
116
- browserFactory,
117
- config: { maxConcurrentPages: 2, screenshotDir: '' } as any,
118
- });
119
-
120
- await limitedManager.getPage('s1');
121
- await limitedManager.getPage('s2');
122
- await expect(limitedManager.getPage('s3')).rejects.toThrow('Max concurrent pages');
123
- });
124
- });
125
-
126
- describe('crash recovery', () => {
127
- it('should re-launch browser if disconnected', async () => {
128
- await manager.getPage('session-1');
129
- expect(browserFactory).toHaveBeenCalledTimes(1);
130
-
131
- // Simulate browser crash
132
- mockBrowser.isConnected.mockReturnValue(false);
133
-
134
- // Create a new mock browser for re-launch
135
- const newMockBrowser = createMockBrowser();
136
- browserFactory.mockResolvedValue(newMockBrowser);
137
-
138
- await manager.getPage('session-2');
139
- expect(browserFactory).toHaveBeenCalledTimes(2);
140
- });
141
- });
142
- });
@@ -1,141 +0,0 @@
1
- import { describe, it, expect, afterEach, vi } from 'vitest';
2
- import { BrowserManager } from '../src/browser-manager.js';
3
- import { DEFAULT_BROWSER_CONFIG } from '../src/types.js';
4
- import { validateUrl } from '../src/url-validator.js';
5
-
6
- function createMockPage(overrides: any = {}) {
7
- return {
8
- goto: vi.fn().mockResolvedValue(undefined),
9
- title: vi.fn().mockResolvedValue(overrides.title || 'Test Page'),
10
- content: vi.fn().mockResolvedValue(
11
- overrides.html || '<html><body><h1>Test</h1><p>Content here</p></body></html>'
12
- ),
13
- url: vi.fn().mockReturnValue(overrides.url || 'https://example.com'),
14
- click: vi.fn().mockResolvedValue(undefined),
15
- fill: vi.fn().mockResolvedValue(undefined),
16
- keyboard: { press: vi.fn().mockResolvedValue(undefined) },
17
- screenshot: vi.fn().mockResolvedValue(Buffer.from('screenshot-data')),
18
- $: vi.fn().mockResolvedValue(null),
19
- $$eval: vi.fn().mockResolvedValue(
20
- overrides.elements || [
21
- { text: 'Story 1', tagName: 'a', attributes: { href: '/story/1' } },
22
- { text: 'Story 2', tagName: 'a', attributes: { href: '/story/2' } },
23
- ]
24
- ),
25
- waitForSelector: vi.fn().mockResolvedValue(undefined),
26
- evaluate: vi.fn().mockResolvedValue(overrides.evalResult || null),
27
- close: vi.fn().mockResolvedValue(undefined),
28
- isClosed: vi.fn().mockReturnValue(false),
29
- setViewportSize: vi.fn().mockResolvedValue(undefined),
30
- setDefaultNavigationTimeout: vi.fn(),
31
- setDefaultTimeout: vi.fn(),
32
- };
33
- }
34
-
35
- function createMockBrowser(page: any) {
36
- const context = {
37
- newPage: vi.fn().mockResolvedValue(page),
38
- close: vi.fn().mockResolvedValue(undefined),
39
- };
40
- return {
41
- newContext: vi.fn().mockResolvedValue(context),
42
- isConnected: vi.fn().mockReturnValue(true),
43
- close: vi.fn().mockResolvedValue(undefined),
44
- contexts: vi.fn().mockReturnValue([context]),
45
- };
46
- }
47
-
48
- describe('Browser integration', () => {
49
- let manager: BrowserManager;
50
-
51
- afterEach(async () => {
52
- if (manager) await manager.shutdown();
53
- });
54
-
55
- it('should navigate then extract then screenshot flow', async () => {
56
- const mockPage = createMockPage({
57
- title: 'Hacker News',
58
- url: 'https://news.ycombinator.com',
59
- });
60
- const mockBrowser = createMockBrowser(mockPage);
61
-
62
- manager = new BrowserManager({
63
- config: { ...DEFAULT_BROWSER_CONFIG, screenshotDir: '' },
64
- browserFactory: vi.fn().mockResolvedValue(mockBrowser),
65
- });
66
-
67
- // Navigate
68
- const navResult = await manager.navigate('s1', 'https://news.ycombinator.com');
69
- expect(navResult.title).toBe('Hacker News');
70
- expect(navResult.url).toBe('https://news.ycombinator.com');
71
-
72
- // Extract
73
- const extractResult = await manager.extract('s1', '.storylink');
74
- expect(extractResult.elements).toHaveLength(2);
75
- expect(extractResult.elements[0].text).toBe('Story 1');
76
-
77
- // Screenshot
78
- const screenshotResult = await manager.screenshot('s1');
79
- expect(screenshotResult.base64).toBeDefined();
80
- expect(screenshotResult.base64.length).toBeGreaterThan(0);
81
- });
82
-
83
- it('should isolate multi-session pages', async () => {
84
- const page1 = createMockPage({ title: 'Page 1', url: 'https://site1.com' });
85
- const page2 = createMockPage({ title: 'Page 2', url: 'https://site2.com' });
86
-
87
- let callCount = 0;
88
- const context = {
89
- newPage: vi.fn().mockImplementation(async () => {
90
- callCount++;
91
- return callCount === 1 ? page1 : page2;
92
- }),
93
- close: vi.fn().mockResolvedValue(undefined),
94
- };
95
-
96
- const mockBrowser = {
97
- newContext: vi.fn().mockResolvedValue(context),
98
- isConnected: vi.fn().mockReturnValue(true),
99
- close: vi.fn().mockResolvedValue(undefined),
100
- contexts: vi.fn().mockReturnValue([context]),
101
- };
102
-
103
- manager = new BrowserManager({
104
- config: DEFAULT_BROWSER_CONFIG,
105
- browserFactory: vi.fn().mockResolvedValue(mockBrowser),
106
- });
107
-
108
- const nav1 = await manager.navigate('session-a', 'https://site1.com');
109
- const nav2 = await manager.navigate('session-b', 'https://site2.com');
110
-
111
- expect(nav1.title).toBe('Page 1');
112
- expect(nav2.title).toBe('Page 2');
113
- expect(manager.getPageCount()).toBe(2);
114
- });
115
-
116
- it('should enforce URL validation throughout the stack', () => {
117
- expect(validateUrl('file:///etc/passwd')).not.toBeNull();
118
- expect(validateUrl('javascript:alert(1)')).not.toBeNull();
119
- expect(validateUrl('http://127.0.0.1')).not.toBeNull();
120
- expect(validateUrl('http://10.0.0.1:8080')).not.toBeNull();
121
-
122
- expect(validateUrl('https://example.com')).toBeNull();
123
- expect(validateUrl('https://news.ycombinator.com')).toBeNull();
124
- });
125
-
126
- it('should handle browse mutation detection', async () => {
127
- const mockPage = createMockPage();
128
- const mockBrowser = createMockBrowser(mockPage);
129
-
130
- manager = new BrowserManager({
131
- config: DEFAULT_BROWSER_CONFIG,
132
- browserFactory: vi.fn().mockResolvedValue(mockBrowser),
133
- });
134
-
135
- const mutationResult = await manager.browse('s1', 'click the buy button');
136
- expect(mutationResult.result).toContain('primitive browser tools');
137
-
138
- const readResult = await manager.browse('s1', 'get the price of BTC');
139
- expect(readResult.result).toBeDefined();
140
- });
141
- });
@@ -1,72 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import type { BrowserConfig, PageInfo, BrowseStep } from '../src/types.js';
3
- import { DEFAULT_BROWSER_CONFIG, BLOCKED_PROTOCOLS } from '../src/types.js';
4
- import { isPrivateIP, parseIPv4ToNumber, isNumericHostname, normalizeIPv6 } from '../src/url-validator.js';
5
-
6
- describe('Browser types', () => {
7
- it('should provide sensible default config', () => {
8
- expect(DEFAULT_BROWSER_CONFIG.headless).toBe(true);
9
- expect(DEFAULT_BROWSER_CONFIG.viewport).toEqual({ width: 1280, height: 720 });
10
- expect(DEFAULT_BROWSER_CONFIG.navigationTimeout).toBe(30_000);
11
- expect(DEFAULT_BROWSER_CONFIG.actionTimeout).toBe(10_000);
12
- expect(DEFAULT_BROWSER_CONFIG.maxConcurrentPages).toBe(10);
13
- expect(DEFAULT_BROWSER_CONFIG.screenshotDir).toBe('screenshots');
14
- });
15
-
16
- it('should block dangerous protocols', () => {
17
- expect(BLOCKED_PROTOCOLS).toContain('file:');
18
- expect(BLOCKED_PROTOCOLS).toContain('javascript:');
19
- expect(BLOCKED_PROTOCOLS).not.toContain('https:');
20
- });
21
-
22
- it('should detect private IPv4 addresses numerically', () => {
23
- expect(isPrivateIP('127.0.0.1')).toBe(true);
24
- expect(isPrivateIP('10.0.0.1')).toBe(true);
25
- expect(isPrivateIP('192.168.1.1')).toBe(true);
26
- expect(isPrivateIP('169.254.169.254')).toBe(true);
27
- expect(isPrivateIP('172.16.0.1')).toBe(true);
28
- expect(isPrivateIP('172.31.255.255')).toBe(true);
29
- expect(isPrivateIP('0.0.0.0')).toBe(true);
30
- // Public IPs should not be private
31
- expect(isPrivateIP('8.8.8.8')).toBe(false);
32
- expect(isPrivateIP('172.32.0.1')).toBe(false);
33
- expect(isPrivateIP('1.1.1.1')).toBe(false);
34
- });
35
-
36
- it('should detect private IPv6 addresses', () => {
37
- expect(isPrivateIP('::1')).toBe(true);
38
- expect(isPrivateIP('::ffff:127.0.0.1')).toBe(true);
39
- expect(isPrivateIP('::ffff:10.0.0.1')).toBe(true);
40
- expect(isPrivateIP('fe80::1')).toBe(true);
41
- expect(isPrivateIP('fd00::1')).toBe(true);
42
- });
43
-
44
- it('should detect numeric hostname encodings', () => {
45
- expect(isNumericHostname('2130706433')).toBe(true);
46
- expect(isNumericHostname('0x7f000001')).toBe(true);
47
- expect(isNumericHostname('0177.0.0.1')).toBe(true);
48
- expect(isNumericHostname('example.com')).toBe(false);
49
- expect(isNumericHostname('127.0.0.1')).toBe(false); // standard dotted-decimal is not "numeric encoding"
50
- });
51
-
52
- it('should have correct TypeScript types (compile check)', () => {
53
- const config: BrowserConfig = {
54
- ...DEFAULT_BROWSER_CONFIG,
55
- headless: false,
56
- };
57
- expect(config.headless).toBe(false);
58
-
59
- const pageInfo: PageInfo = {
60
- url: 'https://example.com',
61
- title: 'Example',
62
- };
63
- expect(pageInfo.url).toBe('https://example.com');
64
-
65
- const step: BrowseStep = {
66
- action: 'navigate',
67
- params: { url: 'https://example.com' },
68
- result: 'Navigated to Example',
69
- };
70
- expect(step.action).toBe('navigate');
71
- });
72
- });
@@ -1,151 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { validateUrl } from '../src/url-validator.js';
3
-
4
- describe('URL Validator', () => {
5
- describe('valid URLs', () => {
6
- it('should allow https URLs', () => {
7
- expect(validateUrl('https://example.com')).toBeNull();
8
- });
9
-
10
- it('should allow http URLs', () => {
11
- expect(validateUrl('http://example.com')).toBeNull();
12
- });
13
-
14
- it('should allow URLs with paths', () => {
15
- expect(validateUrl('https://example.com/path/to/page')).toBeNull();
16
- });
17
-
18
- it('should allow URLs with query params', () => {
19
- expect(validateUrl('https://example.com?q=search&page=1')).toBeNull();
20
- });
21
- });
22
-
23
- describe('blocked protocols', () => {
24
- it('should block file:// protocol', () => {
25
- const error = validateUrl('file:///etc/passwd');
26
- expect(error).toContain('protocol');
27
- });
28
-
29
- it('should block javascript: protocol', () => {
30
- const error = validateUrl('javascript:alert(1)');
31
- expect(error).toContain('protocol');
32
- });
33
-
34
- it('should block data: protocol', () => {
35
- const error = validateUrl('data:text/html,<script>alert(1)</script>');
36
- expect(error).toContain('protocol');
37
- });
38
- });
39
-
40
- describe('private IP blocking', () => {
41
- it('should block localhost', () => {
42
- const error = validateUrl('http://localhost:3000');
43
- expect(error).toContain('private');
44
- });
45
-
46
- it('should block 127.0.0.1', () => {
47
- const error = validateUrl('http://127.0.0.1');
48
- expect(error).toContain('private');
49
- });
50
-
51
- it('should block 10.x.x.x', () => {
52
- const error = validateUrl('http://10.0.0.1');
53
- expect(error).toContain('private');
54
- });
55
-
56
- it('should block 192.168.x.x', () => {
57
- const error = validateUrl('http://192.168.1.1');
58
- expect(error).toContain('private');
59
- });
60
-
61
- it('should block 169.254.x.x (link-local)', () => {
62
- const error = validateUrl('http://169.254.169.254');
63
- expect(error).toContain('private');
64
- });
65
-
66
- it('should block 172.16-31.x.x', () => {
67
- expect(validateUrl('http://172.16.0.1')).toContain('private');
68
- expect(validateUrl('http://172.31.255.255')).toContain('private');
69
- });
70
-
71
- it('should allow 172.32.x.x (not private)', () => {
72
- expect(validateUrl('http://172.32.0.1')).toBeNull();
73
- });
74
-
75
- it('should block 0.0.0.0', () => {
76
- const error = validateUrl('http://0.0.0.0');
77
- expect(error).toContain('private');
78
- });
79
- });
80
-
81
- describe('SSRF bypass prevention', () => {
82
- it('should block decimal IP encoding (2130706433 = 127.0.0.1)', () => {
83
- // Node URL constructor normalizes to 127.0.0.1, caught by private IP check
84
- const error = validateUrl('http://2130706433');
85
- expect(error).toBeTruthy();
86
- expect(error).toContain('private');
87
- });
88
-
89
- it('should block hex IP encoding (0x7f000001 = 127.0.0.1)', () => {
90
- // Node URL constructor normalizes to 127.0.0.1, caught by private IP check
91
- const error = validateUrl('http://0x7f000001');
92
- expect(error).toBeTruthy();
93
- expect(error).toContain('private');
94
- });
95
-
96
- it('should block octal IP encoding (0177.0.0.1 = 127.0.0.1)', () => {
97
- // Node URL constructor normalizes to 127.0.0.1, caught by private IP check
98
- const error = validateUrl('http://0177.0.0.1');
99
- expect(error).toBeTruthy();
100
- expect(error).toContain('private');
101
- });
102
-
103
- it('should block IPv6 loopback (::1)', () => {
104
- const error = validateUrl('http://[::1]');
105
- expect(error).toContain('private');
106
- });
107
-
108
- it('should block IPv6-mapped 127.0.0.1 (::ffff:127.0.0.1)', () => {
109
- const error = validateUrl('http://[::ffff:127.0.0.1]');
110
- expect(error).toContain('private');
111
- });
112
-
113
- it('should block IPv6-mapped 10.0.0.1 (::ffff:10.0.0.1)', () => {
114
- const error = validateUrl('http://[::ffff:10.0.0.1]');
115
- expect(error).toContain('private');
116
- });
117
-
118
- it('should block subdomain of localhost', () => {
119
- const error = validateUrl('http://foo.localhost:3000');
120
- expect(error).toContain('private');
121
- });
122
- });
123
-
124
- describe('invalid URLs', () => {
125
- it('should reject empty string', () => {
126
- const error = validateUrl('');
127
- expect(error).toBeTruthy();
128
- });
129
-
130
- it('should reject malformed URLs', () => {
131
- const error = validateUrl('not a url');
132
- expect(error).toBeTruthy();
133
- });
134
- });
135
-
136
- describe('allowlist/blocklist', () => {
137
- it('should allow private IPs when in allowlist', () => {
138
- const error = validateUrl('http://localhost:3000', {
139
- allowedUrls: ['localhost'],
140
- });
141
- expect(error).toBeNull();
142
- });
143
-
144
- it('should block URLs in blocklist', () => {
145
- const error = validateUrl('https://evil.com/page', {
146
- blockedUrls: ['evil.com'],
147
- });
148
- expect(error).toContain('blocked');
149
- });
150
- });
151
- });