Skip to content
10 changes: 6 additions & 4 deletions lib/mock/mock-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down
15 changes: 14 additions & 1 deletion lib/mock/mock-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -430,5 +442,6 @@ module.exports = {
buildAndValidateMockOptions,
getHeaderByName,
buildHeadersFromArray,
normalizeSearchParams
normalizeSearchParams,
normalizeOrigin
}
182 changes: 182 additions & 0 deletions test/mock-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
134 changes: 133 additions & 1 deletion test/mock-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -268,3 +269,134 @@ 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(4)

t.assert.strictEqual(normalizeOrigin('http://Example.com:8080'), 'http://example.com:8080')
// Note: url.origin omits default ports (443 for HTTPS, 80 for HTTP)
t.assert.strictEqual(normalizeOrigin('https://Api.Example.com:443'), 'https://api.example.com')
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 handle default ports correctly', (t) => {
t.plan(2)

// Default ports should be omitted from origin
t.assert.strictEqual(normalizeOrigin('http://Example.com:80'), 'http://example.com')
t.assert.strictEqual(normalizeOrigin('https://Example.com:443'), 'https://example.com')
})

test('should remove trailing slash when ignoreTrailingSlash is true', (t) => {
t.plan(4)

t.assert.strictEqual(normalizeOrigin('http://example.com/'), 'http://example.com')
t.assert.strictEqual(normalizeOrigin('https://example.com/'), 'https://example.com')
t.assert.strictEqual(normalizeOrigin(new URL('http://example.com/')), 'http://example.com')
t.assert.strictEqual(normalizeOrigin('http://example.com'), 'http://example.com')
})

test('should not remove trailing slash when ignoreTrailingSlash is false', (t) => {
t.plan(2)

t.assert.strictEqual(normalizeOrigin('http://example.com/'), 'http://example.com')
t.assert.strictEqual(normalizeOrigin('http://example.com'), 'http://example.com')
})

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')
})
})