@nextrush/router 3.0.7 → 4.0.0-beta.1

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.
Files changed (51) hide show
  1. package/README.md +250 -492
  2. package/dist/index.d.ts +227 -178
  3. package/dist/index.js +1075 -657
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -6
  6. package/src/__tests__/allowed-methods.test.ts +144 -0
  7. package/src/__tests__/audit-fixes.test.ts +59 -0
  8. package/src/__tests__/canonical-path-security.test.ts +198 -0
  9. package/src/__tests__/canonicalize-memo.test.ts +108 -0
  10. package/src/__tests__/dispatch-deasync.test.ts +292 -0
  11. package/src/__tests__/find-node-differential.test.ts +220 -0
  12. package/src/__tests__/fixtures/match-golden.json +556 -0
  13. package/src/__tests__/head-auto-registration.test.ts +197 -0
  14. package/src/__tests__/helpers/differential-corpus.ts +314 -0
  15. package/src/__tests__/match-differential.test.ts +46 -0
  16. package/src/__tests__/match-hotpath-guard.test.ts +71 -0
  17. package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
  18. package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
  19. package/src/__tests__/match-prenormalized.test.ts +95 -0
  20. package/src/__tests__/match-safety.test.ts +223 -0
  21. package/src/__tests__/match-single-alloc.test.ts +82 -0
  22. package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
  23. package/src/__tests__/middleware-pipeline.test.ts +306 -0
  24. package/src/__tests__/param-decoding.test.ts +78 -0
  25. package/src/__tests__/public-surface.test.ts +72 -0
  26. package/src/__tests__/registration-max-depth.test.ts +56 -0
  27. package/src/__tests__/route-metadata.test.ts +172 -0
  28. package/src/__tests__/router-audit.test.ts +315 -0
  29. package/src/__tests__/router-edge-cases.test.ts +2 -7
  30. package/src/__tests__/router.test.ts +148 -7
  31. package/src/__tests__/static-map-reset.test.ts +48 -0
  32. package/src/__tests__/walk-pool-sizing.test.ts +72 -0
  33. package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
  34. package/src/canonicalize.ts +137 -0
  35. package/src/composition.ts +79 -0
  36. package/src/constants.ts +43 -0
  37. package/src/dispatch.ts +145 -0
  38. package/src/find-node.ts +125 -0
  39. package/src/group-router.ts +208 -0
  40. package/src/index.ts +14 -5
  41. package/src/match-route.ts +241 -0
  42. package/src/matching.ts +246 -0
  43. package/src/middleware-adapter.ts +59 -0
  44. package/src/redirect.ts +97 -0
  45. package/src/registration.ts +343 -0
  46. package/src/route-metadata.ts +68 -0
  47. package/src/router.ts +219 -872
  48. package/src/segment-trie.ts +227 -0
  49. package/src/state.ts +53 -0
  50. package/src/walk-pool.ts +208 -0
  51. package/src/radix-tree.ts +0 -184
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @nextrush/router - allowedMethods() middleware characterization
3
+ *
4
+ * `allowedMethods()` (public) wraps `findAllowedMethods()` -> `findNode()`
5
+ * (both private, matching-engine cluster). Before the T014 split moves this
6
+ * cluster into `matching.ts`, this file characterizes its CURRENT end-to-end
7
+ * behavior through the only legitimate entry point — the public middleware —
8
+ * closing a real coverage gap: previously only `typeof middleware ===
9
+ * 'function'` was asserted, and `findNode`'s static/param/wildcard branches
10
+ * were never exercised via any public code path.
11
+ */
12
+
13
+ import type { Context } from '@nextrush/types';
14
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
15
+ import { createRouter, Router } from '../router';
16
+
17
+ function createMockContext(overrides: Partial<Context> = {}): Context {
18
+ return {
19
+ method: 'GET',
20
+ path: '/',
21
+ params: {},
22
+ query: {},
23
+ body: undefined,
24
+ headers: {},
25
+ status: 404,
26
+ state: {},
27
+ json: vi.fn(),
28
+ send: vi.fn(),
29
+ html: vi.fn(),
30
+ redirect: vi.fn(),
31
+ set: vi.fn(),
32
+ get: vi.fn(),
33
+ next: vi.fn(),
34
+ raw: {
35
+ req: {} as never,
36
+ res: {} as never,
37
+ },
38
+ ...overrides,
39
+ } as Context;
40
+ }
41
+
42
+ describe('allowedMethods() middleware — end-to-end characterization', () => {
43
+ let router: Router;
44
+
45
+ beforeEach(() => {
46
+ router = createRouter();
47
+ });
48
+
49
+ it('sets the Allow header and 200 status on an OPTIONS request to a known static path', async () => {
50
+ router.get('/r', vi.fn());
51
+ router.post('/r', vi.fn());
52
+
53
+ const ctx = createMockContext({ method: 'OPTIONS', path: '/r', status: 404 });
54
+ const middleware = router.allowedMethods();
55
+ await middleware(ctx, async () => {});
56
+
57
+ expect(ctx.status).toBe(200);
58
+ expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('GET'));
59
+ expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('POST'));
60
+ expect(ctx.body).toBe('');
61
+ });
62
+
63
+ it('sets 405 and Allow header when the method is not registered for a known path', async () => {
64
+ router.get('/r', vi.fn());
65
+ router.post('/r', vi.fn());
66
+
67
+ const ctx = createMockContext({ method: 'DELETE', path: '/r', status: 404 });
68
+ const middleware = router.allowedMethods();
69
+ await middleware(ctx, async () => {});
70
+
71
+ expect(ctx.status).toBe(405);
72
+ expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('GET'));
73
+ });
74
+
75
+ it('leaves status untouched when the path is completely unknown (findNode returns null)', async () => {
76
+ router.get('/known', vi.fn());
77
+
78
+ const ctx = createMockContext({ method: 'GET', path: '/totally-unknown', status: 404 });
79
+ const middleware = router.allowedMethods();
80
+ await middleware(ctx, async () => {});
81
+
82
+ // No node found for the path at all -> findAllowedMethods returns [] ->
83
+ // early return, status stays 404 (whatever routes() already set).
84
+ expect(ctx.status).toBe(404);
85
+ expect(ctx.set).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('resolves a param route via findNode (paramChild branch)', async () => {
89
+ router.get('/users/:id', vi.fn());
90
+
91
+ const ctx = createMockContext({ method: 'OPTIONS', path: '/users/42', status: 404 });
92
+ const middleware = router.allowedMethods();
93
+ await middleware(ctx, async () => {});
94
+
95
+ expect(ctx.status).toBe(200);
96
+ expect(ctx.set).toHaveBeenCalledWith('Allow', 'GET, HEAD');
97
+ });
98
+
99
+ it('resolves a wildcard route via findNode (wildcardChild branch)', async () => {
100
+ router.get('/files/*', vi.fn());
101
+
102
+ const ctx = createMockContext({ method: 'OPTIONS', path: '/files/a/b/c', status: 404 });
103
+ const middleware = router.allowedMethods();
104
+ await middleware(ctx, async () => {});
105
+
106
+ expect(ctx.status).toBe(200);
107
+ expect(ctx.set).toHaveBeenCalledWith('Allow', 'GET, HEAD');
108
+ });
109
+
110
+ it('resolves a static-child branch deeper than one segment (findNode recursion)', async () => {
111
+ router.get('/api/v1/users', vi.fn());
112
+ router.post('/api/v1/users', vi.fn());
113
+
114
+ const ctx = createMockContext({ method: 'OPTIONS', path: '/api/v1/users', status: 404 });
115
+ const middleware = router.allowedMethods();
116
+ await middleware(ctx, async () => {});
117
+
118
+ expect(ctx.status).toBe(200);
119
+ expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('GET'));
120
+ expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('POST'));
121
+ });
122
+
123
+ it('does not act when ctx.status is not 404 (route already matched successfully)', async () => {
124
+ router.get('/r', vi.fn());
125
+
126
+ const ctx = createMockContext({ method: 'GET', path: '/r', status: 200 });
127
+ const middleware = router.allowedMethods();
128
+ await middleware(ctx, async () => {});
129
+
130
+ expect(ctx.set).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it('reports allowed methods for a path via the public allowedMethods() middleware (replaces prior direct-private-call test)', async () => {
134
+ router.get('/r', vi.fn());
135
+ router.post('/r', vi.fn());
136
+
137
+ const ctx = createMockContext({ method: 'OPTIONS', path: '/r', status: 404 });
138
+ await router.allowedMethods()(ctx, async () => {});
139
+
140
+ expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('GET'));
141
+ expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('POST'));
142
+ expect(ctx.set).not.toHaveBeenCalledWith('Allow', expect.stringContaining('DELETE'));
143
+ });
144
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @nextrush/router - Audit Remediation Tests
3
+ *
4
+ * RT-1 (reset clears the introspection registry), RT-5 (param-name conflict
5
+ * throws at registration), RT-7 (routes() is idempotent — no double-seal).
6
+ */
7
+
8
+ import type { Context } from '@nextrush/types';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { createRouter } from '../router';
11
+
12
+ describe('RT-1: reset() clears the introspection registry', () => {
13
+ it('getRoutes() is empty after reset()', () => {
14
+ const router = createRouter();
15
+ router.get('/users', vi.fn());
16
+ router.get('/users/:id', vi.fn());
17
+ expect(router.getRoutes().length).toBeGreaterThan(0);
18
+
19
+ router.reset();
20
+ expect(router.getRoutes()).toHaveLength(0);
21
+ });
22
+ });
23
+
24
+ describe('RT-5: param-name conflict throws at registration', () => {
25
+ it('throws when the same position uses two different param names', () => {
26
+ const router = createRouter();
27
+ router.get('/users/:id', vi.fn());
28
+ expect(() => router.get('/users/:userId/posts', vi.fn())).toThrow(/param/i);
29
+ });
30
+
31
+ it('does not throw when the param name is consistent', () => {
32
+ const router = createRouter();
33
+ router.get('/users/:id', vi.fn());
34
+ expect(() => router.get('/users/:id/posts', vi.fn())).not.toThrow();
35
+ });
36
+ });
37
+
38
+ describe('RT-7: routes() is idempotent w.r.t. router middleware sealing', () => {
39
+ it('runs router-level middleware exactly once even if routes() is called twice', async () => {
40
+ const router = createRouter();
41
+ const calls = { n: 0 };
42
+ router.use(async (_ctx: Context, next) => {
43
+ calls.n++;
44
+ if (next) await next();
45
+ });
46
+ router.get('/x', (ctx: Context) => {
47
+ ctx.body = 'ok';
48
+ });
49
+
50
+ router.routes();
51
+ router.routes(); // second call must not re-seal
52
+
53
+ const match = router.match('GET', '/x');
54
+ expect(match).not.toBeNull();
55
+ const ctx = { method: 'GET', path: '/x', params: {}, body: undefined } as unknown as Context;
56
+ await match!.executor!(ctx);
57
+ expect(calls.n).toBe(1);
58
+ });
59
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * @nextrush/router - SEC-02 / SEC-09 canonical-path security regression suite
3
+ *
4
+ * RED tests (tasks 3.1-3.3) for RFC-029: the router folds case and collapses
5
+ * structure for its own lookup, but never publishes that decision, and never
6
+ * rejects a dot segment. A path-based policy comparing raw `ctx.path` (the
7
+ * idiomatic prefix-guard pattern shown in this framework's own README) sees a
8
+ * different string than the one the router matched on.
9
+ *
10
+ * @see docs/RFC/request-data/029-canonical-request-path.md
11
+ */
12
+
13
+ import type { Context, RouteHandler } from '@nextrush/types';
14
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
15
+ import { createRouter, Router } from '../router';
16
+
17
+ function createMockContext(overrides: Partial<Context> = {}): Context {
18
+ return {
19
+ method: 'GET',
20
+ path: '/',
21
+ params: {},
22
+ query: {},
23
+ body: undefined,
24
+ headers: {},
25
+ status: 200,
26
+ state: {},
27
+ json: vi.fn(),
28
+ send: vi.fn(),
29
+ html: vi.fn(),
30
+ redirect: vi.fn(),
31
+ set: vi.fn(),
32
+ get: vi.fn(),
33
+ next: vi.fn(),
34
+ raw: {
35
+ req: {} as never,
36
+ res: {} as never,
37
+ },
38
+ ...overrides,
39
+ } as Context;
40
+ }
41
+
42
+ describe('SEC-02: case-fold path-prefix authorization bypass (P1)', () => {
43
+ let router: Router;
44
+ let handler: RouteHandler;
45
+
46
+ beforeEach(() => {
47
+ router = createRouter();
48
+ handler = vi.fn();
49
+ router.get('/admin/users', handler);
50
+ });
51
+
52
+ it('3.1: dispatches a mixed-case request while a naive raw-ctx.path prefix guard never fires', () => {
53
+ // The exact PoC from report/security-review.md SEC-02: a policy comparing
54
+ // ctx.path.startsWith('/admin') against the RAW request target, guarding a
55
+ // route the router matches after folding.
56
+ const rawPath = '/ADMIN/users';
57
+ const guardWouldFire = rawPath.startsWith('/admin');
58
+
59
+ const match = router.match('GET', rawPath);
60
+
61
+ expect(guardWouldFire).toBe(false); // the guard never runs
62
+ expect(match).not.toBeNull(); // the router dispatches anyway
63
+ expect(match?.handler).toBe(handler);
64
+ });
65
+
66
+ it('3.1: a trailing-slash variant also dispatches while failing a naive equality guard', () => {
67
+ const rawPath = '/admin/users/';
68
+ const guardWouldFire = rawPath === '/admin/users';
69
+
70
+ const match = router.match('GET', rawPath);
71
+
72
+ expect(guardWouldFire).toBe(false);
73
+ expect(match).not.toBeNull();
74
+ });
75
+
76
+ it('3.1: a repeated-slash variant also dispatches while failing a naive equality guard', () => {
77
+ const rawPath = '//admin//users';
78
+ const guardWouldFire = rawPath === '/admin/users';
79
+
80
+ const match = router.match('GET', rawPath);
81
+
82
+ expect(guardWouldFire).toBe(false);
83
+ expect(match).not.toBeNull();
84
+ });
85
+ });
86
+
87
+ describe('SEC-09: no dot-segment normalization (P2)', () => {
88
+ let router: Router;
89
+
90
+ beforeEach(() => {
91
+ router = createRouter();
92
+ router.get('/api/webhooks/handler', vi.fn());
93
+ router.get('/admin', vi.fn());
94
+ router.get('/users/:id', vi.fn());
95
+ router.get('/files/*', vi.fn());
96
+ });
97
+
98
+ it.each([
99
+ ['/api/webhooks/../admin', 'literal dot segment'],
100
+ ['/api/./users', 'single-dot segment'],
101
+ ['/../..', 'leading dot segments past root'],
102
+ ])(
103
+ '3.2: %s (%s) is never rejected — it either 404s or resolves past its own directory, both unsafe for a canonical-path contract',
104
+ (path) => {
105
+ // TODAY: the router has no dot-segment concept at all — a dot segment is
106
+ // just an opaque path component. This test documents the RED state: no
107
+ // 400 is ever produced, because nothing in matching.ts/match-route.ts
108
+ // inspects segments for `.`/`..`. Once RFC-029 P0 lands, dot-segment
109
+ // paths must be rejected with 400 before reaching match() at all — this
110
+ // test's premise (the router matches or 404s, never rejects) must flip.
111
+ const match = router.match('GET', path);
112
+
113
+ // Current behavior: no route happens to be registered at the resolved
114
+ // literal segments, so this 404s (null) today — proving the absence of
115
+ // any dot-segment handling, not proving safety. A registered route at
116
+ // the resolved target would dispatch silently instead.
117
+ expect(match).toBeNull();
118
+ }
119
+ );
120
+
121
+ it('3.2: a percent-encoded double-dot segment is likewise passed through as an opaque literal segment', () => {
122
+ // %2e%2e is never decoded by matchRoute for structural purposes — only
123
+ // param VALUES are percent-decoded (decodeParam), and only after a param
124
+ // slot in the trie already matched. A dot segment used for traversal
125
+ // purposes is a static path segment, so it is compared literally as
126
+ // `%2e%2e`, which matches nothing today.
127
+ const match = router.match('GET', '/api/%2e%2e/admin');
128
+ expect(match).toBeNull();
129
+ });
130
+
131
+ it('3.2: a two-character segment starting with a dot but not a double-dot is not flagged as a traversal segment', () => {
132
+ router.get('/files/.x', vi.fn());
133
+ const match = router.match('GET', '/files/.x');
134
+ expect(match).not.toBeNull();
135
+ });
136
+
137
+ it('3.2: a dot as filename content (not a traversal segment) is accepted and dispatches normally', () => {
138
+ router.get('/files/archive.tar.gz', vi.fn());
139
+ const match = router.match('GET', '/files/archive.tar.gz');
140
+ expect(match).not.toBeNull();
141
+ });
142
+ });
143
+
144
+ describe('3.3: published-path contract (ctx.path / ctx.originalPath)', () => {
145
+ it('3.3: the router publishes the canonical path onto ctx.path and preserves the raw target on ctx.originalPath', async () => {
146
+ const router = createRouter();
147
+ let seenPath = '';
148
+ let seenOriginal: string | undefined;
149
+ router.get('/admin/users', (ctx) => {
150
+ seenPath = ctx.path;
151
+ seenOriginal = ctx.originalPath;
152
+ });
153
+
154
+ const ctx = createMockContext({ path: '/ADMIN/users?x=1' });
155
+ const middleware = router.routes();
156
+ await middleware(ctx, async () => {});
157
+
158
+ expect(seenPath).toBe('/admin/users');
159
+ expect(seenOriginal).toBe('/ADMIN/users?x=1');
160
+ expect(seenPath).not.toContain('?');
161
+ });
162
+
163
+ it('3.3: both ctx.path and ctx.originalPath are populated on a 404 (no route matched)', async () => {
164
+ const router = createRouter();
165
+ router.get('/users', () => undefined);
166
+
167
+ const ctx = createMockContext({ path: '/USERS/999' });
168
+ const middleware = router.routes();
169
+ await middleware(ctx, async () => {});
170
+
171
+ expect(ctx.status).toBe(404);
172
+ expect(ctx.path).toBe('/users/999');
173
+ expect((ctx as unknown as { originalPath?: string }).originalPath).toBe('/USERS/999');
174
+ });
175
+
176
+ it('a mocked ctx not yet touched by router dispatch has no originalPath — optional field, absent by default', () => {
177
+ const ctx = createMockContext({ path: '/ADMIN/users' });
178
+ expect((ctx as unknown as { originalPath?: string }).originalPath).toBeUndefined();
179
+ });
180
+
181
+ it('3.3: a dot-segment request rejected by dispatch sets 400 and still populates ctx.originalPath', async () => {
182
+ const router = createRouter();
183
+ router.get('/admin', vi.fn());
184
+
185
+ const ctx = createMockContext({ path: '/../admin' });
186
+ const middleware = router.routes();
187
+ let nextCalled = false;
188
+ await middleware(ctx, async () => {
189
+ nextCalled = true;
190
+ });
191
+
192
+ expect(ctx.status).toBe(400);
193
+ expect((ctx as unknown as { originalPath?: string }).originalPath).toBe('/../admin');
194
+ // A dot-segment rejection stops the chain outright — it never falls
195
+ // through to a 404/allowedMethods handler the way a plain miss does.
196
+ expect(nextCalled).toBe(false);
197
+ });
198
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @nextrush/router - Canonicalization memo correctness
3
+ *
4
+ * `canonicalizePath` memoizes its most recent result, and `matchesMountPrefix`
5
+ * memoizes the canonical form of its (registration-fixed) prefix. Both exist to
6
+ * remove the O(mounts) canonicalization cost a request paid falling through
7
+ * prefix mounts — measured at ~557 ns per mounted router before, ~190 ns after.
8
+ *
9
+ * A memo is only sound if it can never return an answer that differs from
10
+ * recomputing. The hazard is options: `caseSensitive` and `strict` change the
11
+ * result for the same input string, and two routers with different options can
12
+ * be mounted in the same application. These tests pin that.
13
+ *
14
+ * @see reports/investigations/post-audit-invariant-erosion-review.md F-1
15
+ */
16
+
17
+ import { describe, expect, it } from 'vitest';
18
+ import { canonicalizePath } from '../canonicalize';
19
+ import { createRouter } from '../router';
20
+
21
+ describe('canonicalizePath memo cannot leak across option combinations', () => {
22
+ it('returns the folded path for caseSensitive=false and the raw path for true', () => {
23
+ // Same input, alternating options — a memo keyed only on the string would
24
+ // return the first answer for both.
25
+ for (let i = 0; i < 3; i++) {
26
+ expect(canonicalizePath('/Users/List', false, false).path).toBe('/users/list');
27
+ expect(canonicalizePath('/Users/List', true, false).path).toBe('/Users/List');
28
+ }
29
+ });
30
+
31
+ it('respects strict for the same input string', () => {
32
+ for (let i = 0; i < 3; i++) {
33
+ expect(canonicalizePath('/users/', false, false).path).toBe('/users');
34
+ expect(canonicalizePath('/users/', false, true).path).toBe('/users/');
35
+ }
36
+ });
37
+
38
+ it('still rejects a dot segment after a non-rejected call for another path', () => {
39
+ expect(canonicalizePath('/safe/path', false, false).rejected).toBe(false);
40
+ expect(canonicalizePath('/a/../b', false, false).rejected).toBe(true);
41
+ expect(canonicalizePath('/safe/path', false, false).rejected).toBe(false);
42
+ expect(canonicalizePath('/a/./b', false, false).rejected).toBe(true);
43
+ });
44
+
45
+ it('is stable under repetition and equal to a first-call result', () => {
46
+ const first = canonicalizePath('/A//b/', false, false);
47
+ canonicalizePath('/something/else', true, true);
48
+ const again = canonicalizePath('/A//b/', false, false);
49
+ expect(again.path).toBe(first.path);
50
+ expect(again.rejected).toBe(first.rejected);
51
+ expect(again.path).toBe('/a/b');
52
+ });
53
+
54
+ it('strips the query string consistently whether memoized or not', () => {
55
+ expect(canonicalizePath('/users?a=1', false, false).path).toBe('/users');
56
+ expect(canonicalizePath('/users?a=1', false, false).path).toBe('/users');
57
+ expect(canonicalizePath('/users?b=2', false, false).path).toBe('/users');
58
+ });
59
+ });
60
+
61
+ describe('matchesMountPrefix prefix memo', () => {
62
+ it('returns the correct remainder repeatedly for one prefix', () => {
63
+ const router = createRouter();
64
+ for (let i = 0; i < 3; i++) {
65
+ expect(router.matchesMountPrefix('/api/v1/users/42', '/api/v1')).toBe('/users/42');
66
+ }
67
+ });
68
+
69
+ it('handles a router tested against two different prefixes (memo miss path)', () => {
70
+ const router = createRouter();
71
+ expect(router.matchesMountPrefix('/api/users', '/api')).toBe('/users');
72
+ expect(router.matchesMountPrefix('/admin/users', '/admin')).toBe('/users');
73
+ expect(router.matchesMountPrefix('/api/users', '/api')).toBe('/users');
74
+ // A prefix that does not match must still miss after the memo warmed.
75
+ expect(router.matchesMountPrefix('/api/users', '/admin')).toBeUndefined();
76
+ });
77
+
78
+ it('folds the prefix per the router own caseSensitive option', () => {
79
+ const insensitive = createRouter();
80
+ expect(insensitive.matchesMountPrefix('/ADMIN/Users', '/admin')).toBe('/users');
81
+
82
+ const sensitive = createRouter({ caseSensitive: true });
83
+ expect(sensitive.matchesMountPrefix('/ADMIN/Users', '/admin')).toBeUndefined();
84
+ expect(sensitive.matchesMountPrefix('/admin/Users', '/admin')).toBe('/Users');
85
+ });
86
+
87
+ it('enforces the segment boundary rather than a bare string prefix', () => {
88
+ const router = createRouter();
89
+ expect(router.matchesMountPrefix('/apifoo/bar', '/api')).toBeUndefined();
90
+ expect(router.matchesMountPrefix('/api/bar', '/api')).toBe('/bar');
91
+ expect(router.matchesMountPrefix('/api', '/api')).toBe('/');
92
+ });
93
+
94
+ it('rejects a dot-segment path', () => {
95
+ const router = createRouter();
96
+ expect(router.matchesMountPrefix('/api/../secret', '/api')).toBeUndefined();
97
+ });
98
+
99
+ it('gives two routers with different options independent answers', () => {
100
+ const a = createRouter({ caseSensitive: false });
101
+ const b = createRouter({ caseSensitive: true });
102
+ // Interleaved, so a shared memo keyed only on the string would cross over.
103
+ for (let i = 0; i < 3; i++) {
104
+ expect(a.matchesMountPrefix('/API/x', '/api')).toBe('/x');
105
+ expect(b.matchesMountPrefix('/API/x', '/api')).toBeUndefined();
106
+ }
107
+ });
108
+ });