Skip to content

Commit a6a721d

Browse files
authored
test(shared): pin createPathMatcher contract surface (#8416)
1 parent 9b57986 commit a6a721d

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/shared/src/__tests__/pathMatcher.spec.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test, vi } from 'vitest';
22

3-
import { createPathMatcher, MalformedURLError, normalizePath } from '../pathMatcher';
3+
import { createPathMatcher, isMalformedURLError, MalformedURLError, normalizePath } from '../pathMatcher';
44

55
vi.mock('../pathToRegexp', () => ({
66
pathToRegexp: (pattern: string) => new RegExp(`^${pattern.replace('(.*)', '.*')}$`),
@@ -100,6 +100,47 @@ describe('createPathMatcher', () => {
100100
expect(() => matcher('/api/%zz/users')).toThrow(MalformedURLError);
101101
expect(() => matcher('/%')).toThrow(MalformedURLError);
102102
});
103+
104+
test('does not resolve dot-segments — `..` is treated as literal text', () => {
105+
// Pinning current behavior: createPathMatcher does not perform RFC 3986
106+
// §5.2.4 dot-segment removal. Callers are responsible for passing a
107+
// pathname that has already had `..` resolved (frameworks built on the
108+
// WHATWG URL parser do this automatically). If anyone later teaches
109+
// normalizePath to resolve `..`, that's a behavior change that should
110+
// be deliberate and update this test.
111+
const matcher = createPathMatcher('/api/admin(.*)');
112+
expect(matcher('/public/%2E%2E/api/admin')).toBe(false);
113+
expect(matcher('/public/../api/admin')).toBe(false);
114+
});
115+
116+
test('decodes exactly once — does not collapse double-percent encoding', () => {
117+
// Pinning current behavior: normalizePath calls decodeURI a single
118+
// time. `%2561dmin` decodes to `%61dmin` (literal `%` + `61dmin`),
119+
// not `admin`. A two-pass decode would change matching semantics for
120+
// any pattern containing literal `%` and is intentionally not done.
121+
const matcher = createPathMatcher('/api/admin(.*)');
122+
expect(matcher('/api/%2561dmin/users')).toBe(false);
123+
expect(normalizePath('/api/%2561dmin')).toBe('/api/%61dmin');
124+
});
125+
126+
test('decodes UTF-8 multi-byte sequences', () => {
127+
// Decoded codepoint must round-trip cleanly through the matcher.
128+
const matcher = createPathMatcher('/api/admin(.*)');
129+
expect(matcher('/api/admin/%E6%97%A5%E6%9C%AC')).toBe(true); // 日本
130+
expect(matcher('/api/admin/%F0%9F%92%A9')).toBe(true); // 💩 (surrogate pair)
131+
expect(normalizePath('/api/%E6%97%A5')).toBe('/api/日');
132+
});
133+
134+
test('decodes backslash to a literal backslash, not a slash', () => {
135+
// %5C is not in decodeURI's reservedURISet and not a path delimiter,
136+
// so it decodes to `\` and stays as one character. Some servers
137+
// (notably IIS) historically aliased `\` to `/`; that aliasing is the
138+
// upstream router's job, not the matcher's, and the WHATWG URL parser
139+
// handles it before pathname is ever seen here.
140+
expect(normalizePath('/api/admin%5Cfoo')).toBe('/api/admin\\foo');
141+
const matcher = createPathMatcher('/api/admin(.*)');
142+
expect(matcher('/api/admin%5Cfoo')).toBe(true);
143+
});
103144
});
104145

105146
describe('double-slash normalization', () => {
@@ -182,3 +223,42 @@ describe('normalizePath', () => {
182223
});
183224
});
184225
});
226+
227+
describe('MalformedURLError', () => {
228+
// Public contract: callers like clerkMiddleware fail closed on this exception
229+
// class. The shape (name, statusCode, instanceof Error) and the cross-bundle
230+
// detection helper are part of that contract — pin them so they can't drift
231+
// silently across releases.
232+
233+
test('has the documented public shape', () => {
234+
const err = new MalformedURLError('/foo');
235+
expect(err).toBeInstanceOf(Error);
236+
expect(err.name).toBe('MalformedURLError');
237+
expect(err.statusCode).toBe(400);
238+
expect(err.message).toContain('/foo');
239+
});
240+
241+
test('preserves the cause when one is provided', () => {
242+
const cause = new URIError('boom');
243+
const err = new MalformedURLError('/foo', cause);
244+
expect(err.cause).toBe(cause);
245+
});
246+
247+
test('isMalformedURLError detects instances by name (not by class identity)', () => {
248+
// The string-based check exists so callers in other bundles can detect
249+
// MalformedURLError thrown by a different copy of @clerk/shared. Pin
250+
// both halves: the positive case and the negative cases.
251+
expect(isMalformedURLError(new MalformedURLError('/x'))).toBe(true);
252+
253+
const lookalike = new Error('not us');
254+
lookalike.name = 'MalformedURLError';
255+
expect(isMalformedURLError(lookalike)).toBe(true);
256+
257+
expect(isMalformedURLError(new Error('plain'))).toBe(false);
258+
expect(isMalformedURLError(new URIError('uri'))).toBe(false);
259+
expect(isMalformedURLError(undefined)).toBe(false);
260+
expect(isMalformedURLError(null)).toBe(false);
261+
expect(isMalformedURLError('MalformedURLError')).toBe(false);
262+
expect(isMalformedURLError({ name: 'MalformedURLError' })).toBe(false);
263+
});
264+
});

0 commit comments

Comments
 (0)