|
1 | 1 | import { describe, expect, test, vi } from 'vitest'; |
2 | 2 |
|
3 | | -import { createPathMatcher, MalformedURLError, normalizePath } from '../pathMatcher'; |
| 3 | +import { createPathMatcher, isMalformedURLError, MalformedURLError, normalizePath } from '../pathMatcher'; |
4 | 4 |
|
5 | 5 | vi.mock('../pathToRegexp', () => ({ |
6 | 6 | pathToRegexp: (pattern: string) => new RegExp(`^${pattern.replace('(.*)', '.*')}$`), |
@@ -100,6 +100,47 @@ describe('createPathMatcher', () => { |
100 | 100 | expect(() => matcher('/api/%zz/users')).toThrow(MalformedURLError); |
101 | 101 | expect(() => matcher('/%')).toThrow(MalformedURLError); |
102 | 102 | }); |
| 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 | + }); |
103 | 144 | }); |
104 | 145 |
|
105 | 146 | describe('double-slash normalization', () => { |
@@ -182,3 +223,42 @@ describe('normalizePath', () => { |
182 | 223 | }); |
183 | 224 | }); |
184 | 225 | }); |
| 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