diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 3ab14949f35..61449e077ea 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -22,7 +22,7 @@ const { } = require('./mock-symbols') const MockClient = require('./mock-client') const MockPool = require('./mock-pool') -const { matchValue, normalizeSearchParams, buildAndValidateMockOptions } = require('./mock-utils') +const { matchValue, normalizeSearchParams, buildAndValidateMockOptions, normalizeOrigin } = require('./mock-utils') const { InvalidArgumentError, UndiciError } = require('../core/errors') const Dispatcher = require('../dispatcher/dispatcher') const PendingInterceptorsFormatter = require('./pending-interceptors-formatter') @@ -56,9 +56,9 @@ class MockAgent extends Dispatcher { } get (origin) { - const originKey = this[kIgnoreTrailingSlash] - ? origin.replace(/\/$/, '') - : origin + // Normalize origin to handle URL objects and case-insensitive hostnames + const normalizedOrigin = normalizeOrigin(origin) + const originKey = this[kIgnoreTrailingSlash] ? normalizedOrigin.replace(/\/$/, '') : normalizedOrigin let dispatcher = this[kMockAgentGet](originKey) @@ -70,6 +70,8 @@ class MockAgent extends Dispatcher { } dispatch (opts, handler) { + opts.origin = normalizeOrigin(opts.origin) + // Call MockAgent.get to perform additional setup before dispatching as normal this.get(opts.origin) diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 3b6d5b741bc..e1e3f040643 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -396,6 +396,18 @@ function checkNetConnect (netConnect, origin) { return false } +function normalizeOrigin (origin) { + if (typeof origin !== 'string' && !(origin instanceof URL)) { + return origin + } + + if (origin instanceof URL) { + return origin.origin + } + + return origin.toLowerCase() +} + function buildAndValidateMockOptions (opts) { const { agent, ...mockOptions } = opts @@ -430,5 +442,6 @@ module.exports = { buildAndValidateMockOptions, getHeaderByName, buildHeadersFromArray, - normalizeSearchParams + normalizeSearchParams, + normalizeOrigin } diff --git a/test/mock-agent.js b/test/mock-agent.js index f0b20e909e6..a0df6413a4e 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -2934,3 +2934,185 @@ test('MockAgent - should not accept non-standard search parameters when acceptNo const textResponse = await getResponse(body) t.assert.strictEqual(textResponse, '(non-intercepted) response from server') }) + +// https://github.com/nodejs/undici/issues/4703 +describe('MockAgent - case-insensitive origin matching', () => { + test('should match origins with different hostname case', async (t) => { + t.plan(2) + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const url1 = 'http://myEndpoint' + const url2 = 'http://myendpoint' // Different case + + const mockPool = mockAgent.get(url1) + mockPool + .intercept({ + path: '/test', + method: 'GET' + }) + .reply(200, { success: true }, { + headers: { 'content-type': 'application/json' } + }) + + const { statusCode, body } = await mockAgent.request({ + origin: url2, // Different case should still match + method: 'GET', + path: '/test' + }) + + t.assert.strictEqual(statusCode, 200) + const jsonResponse = JSON.parse(await getResponse(body)) + t.assert.deepStrictEqual(jsonResponse, { success: true }) + }) + + test('should match URL object origin with string origin', async (t) => { + t.plan(2) + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const url = 'http://myEndpoint' + + const mockPool = mockAgent.get(url) + mockPool + .intercept({ + path: '/path', + method: 'GET' + }) + .reply(200, { key: 'value' }, { + headers: { 'content-type': 'application/json' } + }) + + const { statusCode, body } = await mockAgent.request({ + origin: new URL(url), // URL object should match string origin + method: 'GET', + path: '/path' + }) + + t.assert.strictEqual(statusCode, 200) + const jsonResponse = JSON.parse(await getResponse(body)) + t.assert.deepStrictEqual(jsonResponse, { key: 'value' }) + }) + + test('should match URL object with different hostname case', async (t) => { + t.plan(2) + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const url1 = 'http://Example.com' + const url2 = new URL('http://example.com') // Different case + + const mockPool = mockAgent.get(url1) + mockPool + .intercept({ + path: '/test', + method: 'GET' + }) + .reply(200, { success: true }, { + headers: { 'content-type': 'application/json' } + }) + + const { statusCode, body } = await mockAgent.request({ + origin: url2, // URL object with different case should match + method: 'GET', + path: '/test' + }) + + t.assert.strictEqual(statusCode, 200) + const jsonResponse = JSON.parse(await getResponse(body)) + t.assert.deepStrictEqual(jsonResponse, { success: true }) + }) + + test('should handle mixed case scenarios correctly', async (t) => { + t.plan(2) + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const url1 = 'http://MyEndpoint.com' + const url2 = 'http://myendpoint.com' // All lowercase + + const mockPool = mockAgent.get(url1) + mockPool + .intercept({ + path: '/api', + method: 'GET' + }) + .reply(200, { data: 'test' }, { + headers: { 'content-type': 'application/json' } + }) + + const { statusCode, body } = await mockAgent.request({ + origin: url2, + method: 'GET', + path: '/api' + }) + + t.assert.strictEqual(statusCode, 200) + const jsonResponse = JSON.parse(await getResponse(body)) + t.assert.deepStrictEqual(jsonResponse, { data: 'test' }) + }) + + test('should preserve port numbers when normalizing', async (t) => { + t.plan(2) + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const url1 = 'http://Example.com:8080' + const url2 = 'http://example.com:8080' // Different case, same port + + const mockPool = mockAgent.get(url1) + mockPool + .intercept({ + path: '/test', + method: 'GET' + }) + .reply(200, { port: 8080 }, { + headers: { 'content-type': 'application/json' } + }) + + const { statusCode, body } = await mockAgent.request({ + origin: url2, + method: 'GET', + path: '/test' + }) + + t.assert.strictEqual(statusCode, 200) + const jsonResponse = JSON.parse(await getResponse(body)) + t.assert.deepStrictEqual(jsonResponse, { port: 8080 }) + }) + + test('should handle https origins with case differences', async (t) => { + t.plan(2) + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const url1 = 'https://Api.Example.com' + const url2 = new URL('https://api.example.com') // Different case + + const mockPool = mockAgent.get(url1) + mockPool + .intercept({ + path: '/data', + method: 'GET' + }) + .reply(200, { secure: true }, { + headers: { 'content-type': 'application/json' } + }) + + const { statusCode, body } = await mockAgent.request({ + origin: url2, + method: 'GET', + path: '/data' + }) + + t.assert.strictEqual(statusCode, 200) + const jsonResponse = JSON.parse(await getResponse(body)) + t.assert.deepStrictEqual(jsonResponse, { secure: true }) + }) +}) diff --git a/test/mock-utils.js b/test/mock-utils.js index 3b00242d8b7..873ac84014a 100644 --- a/test/mock-utils.js +++ b/test/mock-utils.js @@ -9,7 +9,8 @@ const { getStatusText, getHeaderByName, buildHeadersFromArray, - normalizeSearchParams + normalizeSearchParams, + normalizeOrigin } = require('../lib/mock/mock-utils') test('deleteMockDispatch - should do nothing if not able to find mock dispatch', (t) => { @@ -268,3 +269,108 @@ describe('normalizeQueryParams', () => { t.assert.deepStrictEqual(normalizeSearchParams("a='1,2,3'").toString(), `a=${encodedSingleQuote}${encodeURIComponent('1,2,3')}${encodedSingleQuote}`) }) }) + +describe('normalizeOrigin', () => { + test('should normalize hostname to lowercase for string origins', (t) => { + t.plan(4) + + t.assert.strictEqual(normalizeOrigin('http://Example.com'), 'http://example.com') + t.assert.strictEqual(normalizeOrigin('http://EXAMPLE.COM'), 'http://example.com') + t.assert.strictEqual(normalizeOrigin('https://Api.Example.com'), 'https://api.example.com') + t.assert.strictEqual(normalizeOrigin('http://MyEndpoint'), 'http://myendpoint') + }) + + test('should normalize hostname to lowercase for URL objects', (t) => { + t.plan(4) + + t.assert.strictEqual(normalizeOrigin(new URL('http://Example.com')), 'http://example.com') + t.assert.strictEqual(normalizeOrigin(new URL('http://EXAMPLE.COM')), 'http://example.com') + t.assert.strictEqual(normalizeOrigin(new URL('https://Api.Example.com')), 'https://api.example.com') + t.assert.strictEqual(normalizeOrigin(new URL('http://MyEndpoint')), 'http://myendpoint') + }) + + test('should preserve port numbers', (t) => { + t.plan(3) + + t.assert.strictEqual(normalizeOrigin('http://Example.com:8080'), 'http://example.com:8080') + t.assert.strictEqual(normalizeOrigin(new URL('http://Example.com:3000')), 'http://example.com:3000') + t.assert.strictEqual(normalizeOrigin(new URL('https://Test.com:8443')), 'https://test.com:8443') + }) + + test('should return RegExp matchers as-is', (t) => { + t.plan(1) + + const regex = /http:\/\/example\.com/ + t.assert.strictEqual(normalizeOrigin(regex), regex) + }) + + test('should return function matchers as-is', (t) => { + t.plan(1) + + const fn = (origin) => origin === 'http://example.com' + t.assert.strictEqual(normalizeOrigin(fn), fn) + }) + + test('should return other non-string, non-URL types as-is', (t) => { + t.plan(4) + + const obj = { origin: 'http://example.com' } + const num = 123 + const bool = true + const nullValue = null + + t.assert.strictEqual(normalizeOrigin(obj), obj) + t.assert.strictEqual(normalizeOrigin(num), num) + t.assert.strictEqual(normalizeOrigin(bool), bool) + t.assert.strictEqual(normalizeOrigin(nullValue), nullValue) + }) + + test('should handle invalid URLs gracefully', (t) => { + t.plan(2) + + // Invalid URL strings should be returned as-is + t.assert.strictEqual(normalizeOrigin('not-a-url'), 'not-a-url') + t.assert.strictEqual(normalizeOrigin('://invalid'), '://invalid') + }) + + test('should handle IPv4 addresses', (t) => { + t.plan(2) + + t.assert.strictEqual(normalizeOrigin('http://192.168.1.1'), 'http://192.168.1.1') + t.assert.strictEqual(normalizeOrigin('http://127.0.0.1:3000'), 'http://127.0.0.1:3000') + }) + + test('should handle IPv6 addresses', (t) => { + t.plan(2) + + t.assert.strictEqual(normalizeOrigin('http://[::1]'), 'http://[::1]') + t.assert.strictEqual(normalizeOrigin('http://[2001:db8::1]:8080'), 'http://[2001:db8::1]:8080') + }) + + test('should handle localhost with different cases', (t) => { + t.plan(3) + + t.assert.strictEqual(normalizeOrigin('http://LocalHost'), 'http://localhost') + t.assert.strictEqual(normalizeOrigin('http://LOCALHOST:3000'), 'http://localhost:3000') + t.assert.strictEqual(normalizeOrigin(new URL('http://LocalHost')), 'http://localhost') + }) + + test('should handle subdomains with mixed case', (t) => { + t.plan(3) + + t.assert.strictEqual(normalizeOrigin('http://Api.Example.Com'), 'http://api.example.com') + t.assert.strictEqual(normalizeOrigin('https://WWW.Example.COM'), 'https://www.example.com') + t.assert.strictEqual(normalizeOrigin(new URL('http://Sub.Domain.Example.Com')), 'http://sub.domain.example.com') + }) + + test('should handle paths in URL objects (should only normalize origin part)', (t) => { + t.plan(2) + + // URL objects with paths should still only return the origin + const url1 = new URL('http://Example.com/path/to/resource') + t.assert.strictEqual(normalizeOrigin(url1), 'http://example.com') + + const url2 = new URL('https://Api.Example.com:8080/api/v1') + t.assert.strictEqual(normalizeOrigin(url2), 'https://api.example.com:8080') + }) +})