diff --git a/integration/route-conflict/e2e/conflict-policy-node.spec.ts b/integration/route-conflict/e2e/conflict-policy-node.spec.ts index 768104853e..49072610b0 100644 --- a/integration/route-conflict/e2e/conflict-policy-node.spec.ts +++ b/integration/route-conflict/e2e/conflict-policy-node.spec.ts @@ -4,6 +4,7 @@ import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node'; import { Test } from '@nestjs/testing'; import request from 'supertest'; 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 { listenOnLoopback } from '../../_support/listen-on-loopback.js'; @@ -138,4 +139,23 @@ describe('Route conflict policy (Node)', () => { .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' }); + }, + ); + }); }); diff --git a/integration/route-conflict/src/host-list/host-list.module.ts b/integration/route-conflict/src/host-list/host-list.module.ts new file mode 100644 index 0000000000..f149f13941 --- /dev/null +++ b/integration/route-conflict/src/host-list/host-list.module.ts @@ -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 {} diff --git a/integration/route-conflict/src/host-list/repeated-host.controller.ts b/integration/route-conflict/src/host-list/repeated-host.controller.ts new file mode 100644 index 0000000000..f91e0cf7cf --- /dev/null +++ b/integration/route-conflict/src/host-list/repeated-host.controller.ts @@ -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' }; + } +} diff --git a/integration/route-conflict/src/host-list/two-hosts.controller.ts b/integration/route-conflict/src/host-list/two-hosts.controller.ts new file mode 100644 index 0000000000..4db3bd6968 --- /dev/null +++ b/integration/route-conflict/src/host-list/two-hosts.controller.ts @@ -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' }; + } +} diff --git a/packages/core/router/route-conflict-detector.ts b/packages/core/router/route-conflict-detector.ts index 47f227f8d8..a0f5331052 100644 --- a/packages/core/router/route-conflict-detector.ts +++ b/packages/core/router/route-conflict-detector.ts @@ -358,16 +358,20 @@ export class RouteConflictDetector { const leftHosts = Array.isArray(leftHost) ? leftHost : [leftHost]; const rightHosts = Array.isArray(rightHost) ? rightHost : [rightHost]; - if (leftHosts.length !== rightHosts.length) return false; - // Order-insensitive set comparison: ['a', 'b'] and ['b', 'a'] - // describe the same allowed-host set, so they are identical for - // duplicate-classification purposes. - return leftHosts.every(leftValue => - rightHosts.some(rightValue => - RouteConflictDetector.hostValuesAreIdentical(leftValue, rightValue), - ), - ); + // Set comparison, in both directions: ['a', 'b'] and ['b', 'a'] allow + // the same hosts, and so do ['a', 'a'] and ['a'], while ['a', 'a'] and + // ['a', 'b'] do not, although they have the same length. + const isSubset = ( + hosts: Array, + otherHosts: Array, + ) => + hosts.every(value => + otherHosts.some(otherValue => + RouteConflictDetector.hostValuesAreIdentical(value, otherValue), + ), + ); + return isSubset(leftHosts, rightHosts) && isSubset(rightHosts, leftHosts); } private static hostValuesAreIdentical( diff --git a/packages/core/test/router/route-conflict-detector.spec.ts b/packages/core/test/router/route-conflict-detector.spec.ts index 6e603f8108..7d6465b14b 100644 --- a/packages/core/test/router/route-conflict-detector.spec.ts +++ b/packages/core/test/router/route-conflict-detector.spec.ts @@ -379,6 +379,58 @@ describe('RouteConflictDetector', () => { 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', () => { const firstRoute = makeResolvedRoute({ path: '/users/me' }); const secondRoute = makeResolvedRoute({ path: '/users/me' });