fix(core): tell host lists apart when one repeats a host (#18305)

This commit is contained in:
Denis Scarabelli authored and GitHub committed 2026-10-27 20:02:32 +01:00
1 parent a415cff0cf
commit 6ce26bc666
6 files changed
+111 -9

No files matched your search

@@ -4,6 +4,7 @@ import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import request from 'supertest'; import request from 'supertest';
import { DuplicateModule } from '../src/duplicate/duplicate.module.js'; import { DuplicateModule } from '../src/duplicate/duplicate.module.js';
import { HostListModule } from '../src/host-list/host-list.module.js';
import { MultiUserModule } from '../src/multi-user/multi-user.module.js'; import { MultiUserModule } from '../src/multi-user/multi-user.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js'; import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
@@ -138,4 +139,23 @@ describe('Route conflict policy (Node)', () => {
.expect(200, { from: 'A' }); .expect(200, { from: 'A' });
}); });
}); });
describe('host-list fixture', () => {
it.each([{ duplicate: 'off' }, { duplicate: 'error' }] as const)(
'keeps a route whose host list only partly matches a repeated one (%j)',
async routeConflictPolicy => {
app = await buildNodeApp(HostListModule, { routeConflictPolicy });
await expect(listenOnLoopback(app)).resolves.toBeDefined();
await request(app.getHttpServer())
.get('/users/me')
.set('Host', 'a.example.com')
.expect(200, { from: 'repeated' });
await request(app.getHttpServer())
.get('/users/me')
.set('Host', 'b.example.com')
.expect(200, { from: 'two-hosts' });
},
);
});
}); });
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { RepeatedHostController } from './repeated-host.controller.js';
import { TwoHostsController } from './two-hosts.controller.js';
@Module({
controllers: [RepeatedHostController, TwoHostsController],
})
export class HostListModule {}
@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller({ host: ['a.example.com', 'a.example.com'] })
export class RepeatedHostController {
@Get('users/me')
me() {
return { from: 'repeated' };
}
}
@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller({ host: ['a.example.com', 'b.example.com'] })
export class TwoHostsController {
@Get('users/me')
me() {
return { from: 'two-hosts' };
}
}
@@ -358,16 +358,20 @@ export class RouteConflictDetector {
const leftHosts = Array.isArray(leftHost) ? leftHost : [leftHost]; const leftHosts = Array.isArray(leftHost) ? leftHost : [leftHost];
const rightHosts = Array.isArray(rightHost) ? rightHost : [rightHost]; const rightHosts = Array.isArray(rightHost) ? rightHost : [rightHost];
if (leftHosts.length !== rightHosts.length) return false;
// Order-insensitive set comparison: ['a', 'b'] and ['b', 'a'] // Set comparison, in both directions: ['a', 'b'] and ['b', 'a'] allow
// describe the same allowed-host set, so they are identical for // the same hosts, and so do ['a', 'a'] and ['a'], while ['a', 'a'] and
// duplicate-classification purposes. // ['a', 'b'] do not, although they have the same length.
return leftHosts.every(leftValue => const isSubset = (
rightHosts.some(rightValue => hosts: Array<string | RegExp>,
RouteConflictDetector.hostValuesAreIdentical(leftValue, rightValue), otherHosts: Array<string | RegExp>,
), ) =>
); hosts.every(value =>
otherHosts.some(otherValue =>
RouteConflictDetector.hostValuesAreIdentical(value, otherValue),
),
);
return isSubset(leftHosts, rightHosts) && isSubset(rightHosts, leftHosts);
} }
private static hostValuesAreIdentical( private static hostValuesAreIdentical(
@@ -379,6 +379,58 @@ describe('RouteConflictDetector', () => {
expect(conflicts[0].kind).toBe('duplicate'); expect(conflicts[0].kind).toBe('duplicate');
}); });
it.each([
[
['api.example.com', 'api.example.com'],
['api.example.com', 'admin.example.com'],
],
[
['api.example.com', 'admin.example.com'],
['api.example.com', 'api.example.com'],
],
])(
'should classify host arrays %j and %j as a shadow, not a duplicate',
(earlierHosts, laterHosts) => {
const earlierRoute = makeResolvedRoute({
path: '/users/me',
host: earlierHosts,
});
const laterRoute = makeResolvedRoute({
path: '/users/me',
host: laterHosts,
methodName: 'meAgain',
});
const conflicts = RouteConflictDetector.detect(
[earlierRoute, laterRoute],
undefined,
);
expect(conflicts).toHaveLength(1);
expect(conflicts[0].kind).toBe('shadow');
},
);
it('should classify a host array with a repeated value as a duplicate of the same hosts listed once', () => {
const earlierRoute = makeResolvedRoute({
path: '/users/me',
host: ['api.example.com', 'api.example.com'],
});
const laterRoute = makeResolvedRoute({
path: '/users/me',
host: ['api.example.com'],
methodName: 'meAgain',
});
const conflicts = RouteConflictDetector.detect(
[earlierRoute, laterRoute],
undefined,
);
expect(conflicts).toHaveLength(1);
expect(conflicts[0].kind).toBe('duplicate');
});
it('should compare across multiple routes only once per unique pair', () => { it('should compare across multiple routes only once per unique pair', () => {
const firstRoute = makeResolvedRoute({ path: '/users/me' }); const firstRoute = makeResolvedRoute({ path: '/users/me' });
const secondRoute = makeResolvedRoute({ path: '/users/me' }); const secondRoute = makeResolvedRoute({ path: '/users/me' });