Skip to content

Commit f489240

Browse files
committed
feat: Add href validation to calculateHref
- Explicit test to disallow full HREF's with things like protocol, host and port. - Also improved unit testing.
1 parent ff812b5 commit f489240

File tree

2 files changed

+211
-119
lines changed

2 files changed

+211
-119
lines changed

src/lib/core/calculateHref.test.ts

Lines changed: 200 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
22
import { calculateHref, type CalculateHrefOptions } from "./calculateHref.js";
33
import { init, location } from "$lib/index.js";
4+
import { ROUTING_UNIVERSES, ALL_HASHES } from "../../testing/test-utils.js";
45

56
describe("calculateHref", () => {
67
describe("(...paths) Overload", () => {
@@ -24,151 +25,231 @@ describe("calculateHref", () => {
2425
inputPaths: ["path#hash", "anotherPath#hash2"],
2526
expectedHref: "path/anotherPath#hash",
2627
}
27-
])("Should combine paths $inputPaths as $expectedHref .", ({ inputPaths, expectedHref }) => {
28-
// Act.
28+
])("Should combine paths $inputPaths as $expectedHref", ({ inputPaths, expectedHref }) => {
29+
// Act
2930
const href = calculateHref(...inputPaths);
3031

31-
// Assert.
32+
// Assert
3233
expect(href).toBe(expectedHref);
3334
});
3435
});
35-
describe("(options, ...paths) Overload", () => {
36+
37+
// Test across all routing universes for comprehensive coverage
38+
ROUTING_UNIVERSES.forEach((universe) => {
39+
describe(`(options, ...paths) Overload - ${universe.text}`, () => {
40+
let cleanup: Function;
41+
42+
beforeAll(() => {
43+
cleanup = init({
44+
implicitMode: universe.implicitMode,
45+
hashMode: universe.hashMode
46+
});
47+
});
48+
49+
afterAll(() => {
50+
cleanup();
51+
});
52+
53+
const basePath = "/base/path";
54+
const baseHash = universe.hashMode === 'multi'
55+
? "#p1=path/one;p2=path/two"
56+
: "#base/hash";
57+
58+
beforeEach(() => {
59+
location.url.href = `https://example.com${basePath}${baseHash}`;
60+
});
61+
62+
describe("Basic navigation", () => {
63+
test.each([
64+
{
65+
opts: { hash: universe.hash, preserveHash: false },
66+
url: '/sample/path',
67+
expectedHref: (() => {
68+
if (universe.hash === ALL_HASHES.path) return '/sample/path';
69+
if (universe.hash === ALL_HASHES.single) return '#/sample/path';
70+
if (universe.hash === ALL_HASHES.implicit) {
71+
return universe.implicitMode === 'path' ? '/sample/path' : '#/sample/path';
72+
}
73+
// Multi-hash routing - preserves existing paths and adds/updates the specified hash
74+
if (typeof universe.hash === 'string') {
75+
// This will preserve existing paths and update/add the new one
76+
return `#${universe.hash}=/sample/path;p2=path/two`;
77+
}
78+
return '/sample/path';
79+
})(),
80+
text: "create correct href without preserving hash",
81+
},
82+
{
83+
opts: { hash: universe.hash, preserveHash: true },
84+
url: '/sample/path',
85+
expectedHref: (() => {
86+
if (universe.hash === ALL_HASHES.path) return `/sample/path${baseHash}`;
87+
if (universe.hash === ALL_HASHES.single) return '#/sample/path';
88+
if (universe.hash === ALL_HASHES.implicit) {
89+
return universe.implicitMode === 'path' ? `/sample/path${baseHash}` : '#/sample/path';
90+
}
91+
// Multi-hash routing - preserveHash doesn't apply to hash routing
92+
if (typeof universe.hash === 'string') {
93+
return `#${universe.hash}=/sample/path;p2=path/two`;
94+
}
95+
return `/sample/path${baseHash}`;
96+
})(),
97+
text: "handle hash preservation correctly",
98+
},
99+
])("Should $text in ${universe.text}", ({ opts, url, expectedHref }) => {
100+
// Act
101+
const href = calculateHref(opts, url);
102+
103+
// Assert
104+
expect(href).toBe(expectedHref);
105+
});
106+
});
107+
108+
describe("Query string preservation", () => {
109+
test.each([
110+
{ preserveQuery: true, text: 'preserve' },
111+
{ preserveQuery: false, text: 'not preserve' },
112+
])("Should $text the query string in ${universe.text}", ({ preserveQuery }) => {
113+
// Arrange
114+
const newPath = "/sample/path";
115+
const query = "a=b&c=d";
116+
location.url.search = `?${query}`;
117+
118+
const expectedHref = (() => {
119+
const baseHref = (() => {
120+
if (universe.hash === ALL_HASHES.path) return newPath;
121+
if (universe.hash === ALL_HASHES.single) return `#${newPath}`;
122+
if (universe.hash === ALL_HASHES.implicit) {
123+
return universe.implicitMode === 'path' ? newPath : `#${newPath}`;
124+
}
125+
// Multi-hash routing
126+
if (typeof universe.hash === 'string') {
127+
return `#${universe.hash}=${newPath};p2=path/two`;
128+
}
129+
return newPath;
130+
})();
131+
132+
if (!preserveQuery) return baseHref;
133+
134+
// Add query string
135+
if (baseHref.startsWith('#')) {
136+
return `?${query}${baseHref}`;
137+
} else {
138+
return `${baseHref}?${query}`;
139+
}
140+
})();
141+
142+
// Act
143+
const href = calculateHref({ hash: universe.hash, preserveQuery }, newPath);
144+
145+
// Assert
146+
expect(href).toBe(expectedHref);
147+
});
148+
});
149+
150+
if (universe.hashMode === 'multi') {
151+
describe("Multi-hash routing behavior", () => {
152+
test("Should preserve all existing paths when adding a new path", () => {
153+
// Arrange
154+
const newPath = "/sample/path";
155+
const newHashId = 'new';
156+
157+
// Act
158+
const href = calculateHref({ hash: newHashId }, newPath);
159+
160+
// Assert
161+
expect(href).toBe(`${baseHash};${newHashId}=${newPath}`);
162+
});
163+
164+
test("Should preserve all existing paths when updating an existing path", () => {
165+
// Arrange
166+
const newPath = "/sample/path";
167+
const existingHashId = 'p1';
168+
const expected = baseHash.replace(/(p1=).+;/i, `$1${newPath};`);
169+
170+
// Act
171+
const href = calculateHref({ hash: existingHashId }, newPath);
172+
173+
// Assert
174+
expect(href).toEqual(expected);
175+
});
176+
});
177+
}
178+
179+
if (universe.hash === ALL_HASHES.implicit) {
180+
describe("Implicit hash resolution", () => {
181+
test("Should resolve implicit hash according to implicitMode", () => {
182+
// Arrange
183+
const newPath = "/sample/path";
184+
const expectedHref = universe.implicitMode === 'path' ? newPath : `#${newPath}`;
185+
186+
// Act
187+
const href = calculateHref({ hash: universe.hash }, newPath);
188+
189+
// Assert
190+
expect(href).toBe(expectedHref);
191+
});
192+
});
193+
}
194+
});
195+
});
196+
197+
describe("HREF Validation", () => {
36198
let cleanup: Function;
199+
37200
beforeAll(() => {
38201
cleanup = init();
39202
});
203+
40204
afterAll(() => {
41205
cleanup();
42206
});
43-
const basePath = "/base/path";
44-
const baseHash = "#base/hash";
45-
beforeEach(() => {
46-
location.url.href = `https://example.com${basePath}${baseHash}`;
47-
});
48-
test.each<{
49-
opts: CalculateHrefOptions;
50-
url: string;
51-
expectedHref: string;
52-
text: string;
53-
textMode: string;
54-
}>([
55-
{
56-
opts: { hash: false, preserveHash: false },
57-
url: '/sample/path',
58-
expectedHref: '/sample/path',
59-
text: "not preserve hash",
60-
textMode: "path routing",
61-
},
62-
{
63-
opts: { hash: false, preserveHash: true },
64-
url: '/sample/path',
65-
expectedHref: `/sample/path${baseHash}`,
66-
text: "preserve hash",
67-
textMode: "path routing",
68-
},
69-
{
70-
opts: { hash: true },
71-
url: '/sample/path',
72-
expectedHref: '#/sample/path',
73-
text: "ignore hash",
74-
textMode: "hash routing",
75-
},
76-
])("Should $text from the URL for $textMode .", ({ opts, url, expectedHref }) => {
77-
// Arrange.
78-
79-
// Act.
80-
const href = calculateHref(opts, url);
81207

82-
// Assert.
83-
expect(href).toBe(expectedHref);
208+
test("Should reject HREF with http protocol", () => {
209+
expect(() => calculateHref("http://example.com/path"))
210+
.toThrow('HREF cannot contain protocol, host, or port. Received: "http://example.com/path"');
84211
});
85-
test("Should create a hash HREF when the 'hash' property is set to true.", () => {
86-
// Arrange.
87-
const newPath = "/sample/path";
88-
const hash = true;
89212

90-
// Act.
91-
const href = calculateHref({ hash }, newPath);
92-
93-
// Assert.
94-
expect(href).toBe(`#${newPath}`);
213+
test("Should reject HREF with https protocol", () => {
214+
expect(() => calculateHref("https://example.com/path"))
215+
.toThrow('HREF cannot contain protocol, host, or port. Received: "https://example.com/path"');
95216
});
96-
test.each([
97-
{
98-
hash: false,
99-
preserveQuery: true,
100-
text: 'preserve',
101-
textMode: 'path routing',
102-
},
103-
{
104-
hash: false,
105-
preserveQuery: false,
106-
text: 'not preserve',
107-
textMode: 'path routing',
108-
},
109-
{
110-
hash: true,
111-
preserveQuery: true,
112-
text: 'preserve',
113-
textMode: 'hash routing',
114-
},
115-
{
116-
hash: true,
117-
preserveQuery: false,
118-
text: 'not preserve',
119-
textMode: 'hash routing',
120-
},
121-
])("Should $text the query string when 'preserveQuery' is $preserveQuery under the $textMode mode.", ({ hash, preserveQuery }) => {
122-
// Arrange.
123-
const newPath = "/sample/path";
124-
const query = "a=b&c=d";
125-
location.url.search = query;
126-
const expected = hash ?
127-
(preserveQuery ? `?${query}#${newPath}` : `#${newPath}`) :
128-
(preserveQuery ? `${newPath}?${query}` : newPath);
129-
130-
// Act.
131-
const href = calculateHref({ hash, preserveQuery }, newPath);
132217

133-
// Assert.
134-
expect(href).toBe(expected);
218+
test("Should reject HREF with ftp protocol", () => {
219+
expect(() => calculateHref("ftp://example.com/path"))
220+
.toThrow('HREF cannot contain protocol, host, or port. Received: "ftp://example.com/path"');
135221
});
136-
});
137-
describe("(options, ...paths) Overload - Multi Hash Routing", () => {
138-
let cleanup: Function;
139-
beforeAll(() => {
140-
cleanup = init({ hashMode: 'multi' });
222+
223+
test("Should reject HREF with protocol-relative URL", () => {
224+
expect(() => calculateHref("//example.com/path"))
225+
.toThrow('HREF cannot contain protocol, host, or port. Received: "//example.com/path"');
141226
});
142-
afterAll(() => {
143-
cleanup();
227+
228+
test("Should reject HREF with custom protocol", () => {
229+
expect(() => calculateHref("custom-protocol://example.com/path"))
230+
.toThrow('HREF cannot contain protocol, host, or port. Received: "custom-protocol://example.com/path"');
144231
});
145-
const basePath = "/base/path";
146-
const baseHash = "#p1=path/one;p2=path/two";
147-
beforeEach(() => {
148-
location.url.href = `https://example.com${basePath}${baseHash}`;
232+
233+
test("Should reject HREF when passed in options overload", () => {
234+
expect(() => calculateHref({}, "https://example.com/path"))
235+
.toThrow('HREF cannot contain protocol, host, or port. Received: "https://example.com/path"');
149236
});
150-
test("Should preserve all existing paths in the URL's hash when adding a new path.", () => {
151-
// Arrange.
152-
const newPath = "/sample/path";
153-
const hash = 'new';
154237

155-
// Act.
156-
const href = calculateHref({ hash }, newPath);
238+
test("Should reject HREF among multiple valid paths", () => {
239+
expect(() => calculateHref("/valid/path", "https://example.com/invalid", "/another/valid"))
240+
.toThrow('HREF cannot contain protocol, host, or port. Received: "https://example.com/invalid"');
241+
});
157242

158-
// Assert.
159-
expect(href).toBe(`${baseHash};${hash}=${newPath}`);
243+
test("Should allow valid relative paths", () => {
244+
expect(() => calculateHref("/path", "relative/path", "../other/path")).not.toThrow();
160245
});
161-
test("Should preserve all existing paths in the URL's hash when updating an existing path.", () => {
162-
// Arrange.
163-
const newPath = "/sample/path";
164-
const hash = 'p1';
165-
const expected = baseHash.replace(/(p1=).+;/i, `$1${newPath};`);
166246

167-
// Act.
168-
const href = calculateHref({ hash }, newPath);
247+
test("Should allow valid paths with query and hash", () => {
248+
expect(() => calculateHref("/path?query=value", "relative/path#hash")).not.toThrow();
249+
});
169250

170-
// Assert.
171-
expect(href).toEqual(expected);
251+
test("Should allow paths that start with protocol-like strings but are not URLs", () => {
252+
expect(() => calculateHref("/http-endpoint", "/https-folder")).not.toThrow();
172253
});
173254
});
174255
});

src/lib/core/calculateHref.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@ export function calculateHref(...allArgs: (CalculateHrefOptions | string | undef
7272
preserveHash = false
7373
} = options;
7474
const allHrefs = allArgs as (string | undefined)[];
75+
76+
// Validate that no HREF contains protocol, host, or port
77+
for (const href of allHrefs) {
78+
if (href && typeof href === 'string') {
79+
// Check for absolute URL patterns (protocol://host or //host)
80+
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(href)) {
81+
throw new Error(`HREF cannot contain protocol, host, or port. Received: "${href}"`);
82+
}
83+
}
84+
}
85+
7586
const dissected = dissectHrefs(...allHrefs);
7687
if (hash !== false && dissected.hashes.some(h => !!h.length)) {
7788
throw new Error("Specifying hashes in HREF's is only allowed for path routing.");

0 commit comments

Comments
 (0)