@oneuptime/common 12.0.26 → 12.0.28

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 (50) hide show
  1. package/Models/AnalyticsModels/RumSessionChunk.ts +52 -0
  2. package/Models/DatabaseModels/CloudResource.ts +17 -1
  3. package/Models/DatabaseModels/RumApplication.ts +35 -2
  4. package/Server/API/TelemetryAPI.ts +104 -15
  5. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +12 -5
  6. package/Server/Utils/SessionReplay/SessionReplayIdentity.ts +102 -0
  7. package/Server/Utils/SessionReplay/SessionReplayReadService.ts +11 -2
  8. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +81 -0
  9. package/Tests/Server/API/SessionReplayAPI.test.ts +232 -0
  10. package/Tests/Types/API/HostnameAuthorityParsing.test.ts +1 -0
  11. package/Tests/Types/API/URLMailtoQueryString.test.ts +439 -0
  12. package/Tests/Types/API/URLQueryString.test.ts +33 -24
  13. package/Tests/Types/API/URLRouteQuerySeparation.test.ts +491 -0
  14. package/Tests/UI/Components/LogsHistogramDragTooltip.test.tsx +393 -0
  15. package/Tests/UI/Components/LogsViewerHistogramZoom.test.tsx +248 -0
  16. package/Tests/UI/Components/TelemetryHistogramDragTooltip.test.tsx +205 -0
  17. package/Tests/UI/Components/UseHistogramZoom.test.tsx +262 -0
  18. package/Types/API/URL.ts +55 -18
  19. package/Types/Rum/SessionReplay.ts +14 -0
  20. package/UI/Components/Charts/Utils/useHistogramZoom.ts +101 -0
  21. package/UI/Components/LogsViewer/LogsViewer.tsx +19 -3
  22. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +52 -1
  23. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +18 -2
  24. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +52 -1
  25. package/build/dist/Models/AnalyticsModels/RumSessionChunk.js +48 -0
  26. package/build/dist/Models/AnalyticsModels/RumSessionChunk.js.map +1 -1
  27. package/build/dist/Models/DatabaseModels/CloudResource.js +19 -2
  28. package/build/dist/Models/DatabaseModels/CloudResource.js.map +1 -1
  29. package/build/dist/Models/DatabaseModels/RumApplication.js +37 -3
  30. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  31. package/build/dist/Server/API/TelemetryAPI.js +67 -14
  32. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  33. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  34. package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js +59 -0
  35. package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js.map +1 -0
  36. package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js +9 -2
  37. package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js.map +1 -1
  38. package/build/dist/Types/API/URL.js +48 -17
  39. package/build/dist/Types/API/URL.js.map +1 -1
  40. package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js +47 -0
  41. package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js.map +1 -0
  42. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +15 -3
  43. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  44. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +33 -5
  45. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  46. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +14 -2
  47. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  48. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +33 -5
  49. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  50. package/package.json +1 -1
@@ -235,6 +235,9 @@ describe("URL query string — round trip is lossless", () => {
235
235
  'https://example.com/p?json={"where":"a=b"}&page=2',
236
236
  "https://example.com/p?q=a%3Db",
237
237
  "http://localhost:5000/api/test?a=1&b=x=y",
238
+ "https://example.com/p?next=/a/b",
239
+ "https://example.com/?next=/a/b",
240
+ "https://example.com/?redirect=https://app.example.com/home?tab=1",
238
241
  "tel:+15555550123?ref=a=b",
239
242
  "sms:+15555550123?body=x=1&y=2",
240
243
  ];
@@ -400,12 +403,36 @@ describe("URL query string — constructed rather than parsed", () => {
400
403
  });
401
404
 
402
405
  /*
403
- * Round-tripping is lossless for the cases above, but not universally, and the
404
- * two gaps below are older than this change both predate it and neither is
405
- * reachable from the Jira/OData/signed-URL shapes it set out to fix, which all
406
- * carry a path and string keys. They are pinned so the round-trip guarantee is
407
- * not read as broader than it is; if either is fixed later, these tests are the
408
- * ones that should change.
406
+ * A "/" inside a query value used to be read as a path segment whenever the
407
+ * URL had no path of its own, because fromString split the authority off on
408
+ * the first "/" before the query was ever separated. The query is now cut off
409
+ * first, so no path is invented. URLRouteQuerySeparation.test.ts covers this
410
+ * in full; these two stay here because this is where the behaviour was pinned
411
+ * as a known limitation of the "=" fix.
412
+ */
413
+ describe("URL query string — a '/' inside a value", () => {
414
+ test("no path is fabricated when the URL has none of its own", () => {
415
+ const url: URL = URL.fromString("https://example.com?next=/a/b");
416
+
417
+ expect(url.getQueryParam("next")).toBe("/a/b");
418
+ expect(url.route.toString()).toBe("");
419
+ expect(url.toString()).toBe("https://example.com/?next=/a/b");
420
+ expect(url.toString()).not.toBe("https://example.com/a/b?next=/a/b");
421
+ });
422
+
423
+ test("and a '/' in a value is harmless once the URL has a path", () => {
424
+ const original: string = "https://example.com/p?next=/a/b";
425
+
426
+ expect(URL.fromString(original).toString()).toBe(original);
427
+ });
428
+ });
429
+
430
+ /*
431
+ * Round-tripping is lossless for the cases above, but not universally. The gap
432
+ * below is older than the "=" fix, predates it, and is not reachable from the
433
+ * Jira/OData/signed-URL shapes it set out to fix, which all carry string keys.
434
+ * It is pinned so the round-trip guarantee is not read as broader than it is;
435
+ * if it is fixed later, this test is the one that should change.
409
436
  */
410
437
  describe("URL query string — known round-trip limitations", () => {
411
438
  test("an integer-like key is hoisted to the front on re-serialization", () => {
@@ -420,24 +447,6 @@ describe("URL query string — known round-trip limitations", () => {
420
447
  expect(url.params).toEqual({ b: "1", "1": "x", a: "2" });
421
448
  expect(url.toString()).toBe("https://example.com/p?1=x&b=1&a=2");
422
449
  });
423
-
424
- test("a '/' in a value fabricates a path when the URL has no path", () => {
425
- /*
426
- * fromString splits the authority off on the first "/" before the query is
427
- * ever separated, so a "/" inside a query value is read as a path segment.
428
- * Only bites a URL with no path of its own; "…/p?next=/a/b" is fine.
429
- */
430
- const url: URL = URL.fromString("https://example.com?next=/a/b");
431
-
432
- expect(url.getQueryParam("next")).toBe("/a/b");
433
- expect(url.toString()).toBe("https://example.com/a/b?next=/a/b");
434
- });
435
-
436
- test("but a '/' in a value is harmless once the URL has a path", () => {
437
- const original: string = "https://example.com/p?next=/a/b";
438
-
439
- expect(URL.fromString(original).toString()).toBe(original);
440
- });
441
450
  });
442
451
 
443
452
  /*
@@ -0,0 +1,491 @@
1
+ import Hostname from "../../../Types/API/Hostname";
2
+ import Protocol from "../../../Types/API/Protocol";
3
+ import Route from "../../../Types/API/Route";
4
+ import URL from "../../../Types/API/URL";
5
+
6
+ /*
7
+ * fromString used to split the authority off on the FIRST "/" before the query
8
+ * was ever separated, so a "/" inside a query VALUE was read as a path segment
9
+ * on any URL that had no path of its own:
10
+ *
11
+ * URL.fromString("https://example.com?next=/a/b").toString()
12
+ * => "https://example.com/a/b?next=/a/b" <-- "/a/b" invented
13
+ *
14
+ * "https://example.com/p?next=/a/b" was fine, because the real path consumed
15
+ * the split. The bug therefore hid behind every URL that happened to have a
16
+ * path, which is most of them.
17
+ *
18
+ * It matters because Common/Server/Types/Workflow/Components/API/Utils.ts
19
+ * validates the RAW string for SSRF and then dispatches URL.fromString(url):
20
+ * a workflow author whose URL had no path and a "/" in a query value sent a
21
+ * request to a path they never wrote.
22
+ */
23
+
24
+ /*
25
+ * The path this type would put on the wire, in the shape WHATWG reports it, so
26
+ * the two parsers can be compared directly. An empty route means "no path",
27
+ * which is "/" to WHATWG. Only meaningful for URLs without a fragment: a
28
+ * fragment rides on the end of the route here (this type has no fragment
29
+ * field) and WHATWG keeps it out of pathname.
30
+ */
31
+ const pathOf: (url: URL) => string = (url: URL): string => {
32
+ const route: string = url.route.toString();
33
+
34
+ if (!route) {
35
+ return "/";
36
+ }
37
+
38
+ return route.startsWith("/") ? route : "/" + route;
39
+ };
40
+
41
+ describe("URL route/query separation — '/' in a value, no path of its own", () => {
42
+ test("no path is fabricated from the query value", () => {
43
+ const url: URL = URL.fromString("https://example.com?next=/a/b");
44
+
45
+ expect(url.route.toString()).toBe("");
46
+ expect(url.getQueryParam("next")).toBe("/a/b");
47
+ expect(url.toString()).toBe("https://example.com/?next=/a/b");
48
+ expect(url.toString()).not.toBe("https://example.com/a/b?next=/a/b");
49
+ });
50
+
51
+ test("a deeply nested value contributes no path segments", () => {
52
+ const url: URL = URL.fromString("https://example.com?next=/a/b/c/d/e");
53
+
54
+ expect(url.route.toString()).toBe("");
55
+ expect(url.getQueryParam("next")).toBe("/a/b/c/d/e");
56
+ expect(url.toString()).toBe("https://example.com/?next=/a/b/c/d/e");
57
+ });
58
+
59
+ test("an absolute URL used as a value does not become the path", () => {
60
+ const url: URL = URL.fromString(
61
+ "https://auth.example.com?redirect=https://app.example.com/home",
62
+ );
63
+
64
+ expect(url.hostname.hostname).toBe("auth.example.com");
65
+ expect(url.route.toString()).toBe("");
66
+ expect(url.getQueryParam("redirect")).toBe("https://app.example.com/home");
67
+ expect(url.toString()).toBe(
68
+ "https://auth.example.com/?redirect=https://app.example.com/home",
69
+ );
70
+ });
71
+
72
+ test("a value that is a single '/' invents nothing", () => {
73
+ const url: URL = URL.fromString("https://example.com?next=/");
74
+
75
+ expect(url.route.toString()).toBe("");
76
+ expect(url.getQueryParam("next")).toBe("/");
77
+ expect(url.toString()).toBe("https://example.com/?next=/");
78
+ });
79
+
80
+ test("params after the slash-bearing one are still parsed", () => {
81
+ const url: URL = URL.fromString("https://example.com?next=/a/b&state=xyz");
82
+
83
+ expect(url.params).toEqual({ next: "/a/b", state: "xyz" });
84
+ expect(url.route.toString()).toBe("");
85
+ expect(url.toString()).toBe("https://example.com/?next=/a/b&state=xyz");
86
+ });
87
+
88
+ test("the slash-bearing param need not be the first one", () => {
89
+ const url: URL = URL.fromString("https://example.com?state=xyz&next=/a/b");
90
+
91
+ expect(url.params).toEqual({ state: "xyz", next: "/a/b" });
92
+ expect(url.route.toString()).toBe("");
93
+ expect(url.toString()).toBe("https://example.com/?state=xyz&next=/a/b");
94
+ });
95
+
96
+ test("several values may carry slashes at once", () => {
97
+ const url: URL = URL.fromString(
98
+ "https://example.com?from=/x/y&to=/z/w&mode=copy",
99
+ );
100
+
101
+ expect(url.params).toEqual({ from: "/x/y", to: "/z/w", mode: "copy" });
102
+ expect(url.route.toString()).toBe("");
103
+ });
104
+
105
+ test("a value-less param containing '/' invents no path", () => {
106
+ const url: URL = URL.fromString("https://example.com?/a/b");
107
+
108
+ expect(url.route.toString()).toBe("");
109
+ expect(url.params).toEqual({ "/a/b": "" });
110
+ });
111
+
112
+ test("a port on the authority is kept and still gets no path", () => {
113
+ const url: URL = URL.fromString("https://example.com:8080?next=/a/b");
114
+
115
+ expect(url.hostname.hostname).toBe("example.com:8080");
116
+ expect(url.route.toString()).toBe("");
117
+ expect(url.toString()).toBe("https://example.com:8080/?next=/a/b");
118
+ });
119
+
120
+ test("an http (not https) URL is fixed the same way", () => {
121
+ const url: URL = URL.fromString("http://localhost:5000?next=/a/b");
122
+
123
+ expect(url.protocol).toBe(Protocol.HTTP);
124
+ expect(url.route.toString()).toBe("");
125
+ expect(url.toString()).toBe("http://localhost:5000/?next=/a/b");
126
+ });
127
+
128
+ test("an encoded '%2F' was never affected and still is not", () => {
129
+ const url: URL = URL.fromString("https://example.com?next=%2Fa%2Fb");
130
+
131
+ expect(url.route.toString()).toBe("");
132
+ expect(url.getQueryParam("next")).toBe("%2Fa%2Fb");
133
+ expect(url.toString()).toBe("https://example.com/?next=%2Fa%2Fb");
134
+ });
135
+ });
136
+
137
+ describe("URL route/query separation — a real path is untouched", () => {
138
+ test("a one-segment path survives a slash-bearing value", () => {
139
+ const url: URL = URL.fromString("https://example.com/p?next=/a/b");
140
+
141
+ expect(url.route.toString()).toBe("p");
142
+ expect(url.getQueryParam("next")).toBe("/a/b");
143
+ expect(url.toString()).toBe("https://example.com/p?next=/a/b");
144
+ });
145
+
146
+ test("a deep path survives a slash-bearing value", () => {
147
+ const url: URL = URL.fromString(
148
+ "https://example.com/api/v1/items?next=/a/b",
149
+ );
150
+
151
+ expect(url.route.toString()).toBe("api/v1/items");
152
+ expect(url.route.toString()).not.toContain("?");
153
+ expect(url.toString()).toBe("https://example.com/api/v1/items?next=/a/b");
154
+ });
155
+
156
+ test("a trailing slash on the path is preserved", () => {
157
+ const url: URL = URL.fromString("https://example.com/p/?next=/a/b");
158
+
159
+ expect(url.route.toString()).toBe("p/");
160
+ expect(url.toString()).toBe("https://example.com/p/?next=/a/b");
161
+ });
162
+
163
+ test("a path with no query at all is unchanged", () => {
164
+ const url: URL = URL.fromString("https://example.com/a/b/c");
165
+
166
+ expect(url.route.toString()).toBe("a/b/c");
167
+ expect(url.params).toEqual({});
168
+ expect(url.toString()).toBe("https://example.com/a/b/c");
169
+ });
170
+
171
+ test("the path stops at the first '?', whatever the value holds", () => {
172
+ const url: URL = URL.fromString(
173
+ "https://example.com/api/v1/items?next=/a/b&other=/c/d",
174
+ );
175
+
176
+ expect(url.route.toString()).toBe("api/v1/items");
177
+ });
178
+ });
179
+
180
+ describe("URL route/query separation — '/' in a value plus an embedded '?'", () => {
181
+ test("a path-less URL keeps the whole value and gains no path", () => {
182
+ const url: URL = URL.fromString("https://example.com?next=/a?b=c");
183
+
184
+ expect(url.route.toString()).toBe("");
185
+ expect(url.getQueryParam("next")).toBe("/a?b=c");
186
+ expect(url.toString()).toBe("https://example.com/?next=/a?b=c");
187
+ });
188
+
189
+ test("a redirect target carrying its own query is not turned into a path", () => {
190
+ const url: URL = URL.fromString(
191
+ "https://auth.example.com?redirect=https://app.example.com/home?tab=1",
192
+ );
193
+
194
+ expect(url.hostname.hostname).toBe("auth.example.com");
195
+ expect(url.route.toString()).toBe("");
196
+ expect(url.getQueryParam("redirect")).toBe(
197
+ "https://app.example.com/home?tab=1",
198
+ );
199
+ expect(url.toString()).toBe(
200
+ "https://auth.example.com/?redirect=https://app.example.com/home?tab=1",
201
+ );
202
+ });
203
+
204
+ test("params after the embedded '?' are still parsed on a path-less URL", () => {
205
+ const url: URL = URL.fromString("https://example.com?next=/a?b=c&state=x");
206
+
207
+ expect(url.params).toEqual({ next: "/a?b=c", state: "x" });
208
+ expect(url.route.toString()).toBe("");
209
+ });
210
+
211
+ test("the same shape with a real path keeps the path and the value", () => {
212
+ const url: URL = URL.fromString(
213
+ "https://auth.example.com/login?redirect=https://app.example.com/home?tab=1&state=x",
214
+ );
215
+
216
+ expect(url.route.toString()).toBe("login");
217
+ expect(url.getQueryParam("redirect")).toBe(
218
+ "https://app.example.com/home?tab=1",
219
+ );
220
+ expect(url.getQueryParam("state")).toBe("x");
221
+ });
222
+ });
223
+
224
+ /*
225
+ * The authority was already cut at "?" and "#" before this change, so the host
226
+ * never moved - the fabricated path was a path bug, not a host bypass. These
227
+ * pin that: the workflow SSRF check reads the host off the RAW string with the
228
+ * WHATWG parser, and both parsers agree on the host for every shape here. What
229
+ * used to diverge between them was the PATH, which is what the request line
230
+ * carried.
231
+ */
232
+ describe("URL route/query separation — the host never moved", () => {
233
+ const hostCases: Array<string> = [
234
+ "https://example.com?next=/a/b",
235
+ "https://example.com?redirect=https://evil.example.net/x",
236
+ "https://example.com?next=/a?b=c",
237
+ "https://example.com/p?next=/a/b",
238
+ "https://example.com?next=/a/b&state=x",
239
+ ];
240
+
241
+ test.each(hostCases)("the host of %s is example.com", (raw: string) => {
242
+ expect(URL.fromString(raw).hostname.hostname).toBe("example.com");
243
+ expect(new globalThis.URL(raw).host).toBe("example.com");
244
+ });
245
+
246
+ test("a value naming another host does not become the host", () => {
247
+ const url: URL = URL.fromString(
248
+ "https://example.com?redirect=https://169.254.169.254/latest/meta-data",
249
+ );
250
+
251
+ expect(url.hostname.hostname).toBe("example.com");
252
+ expect(url.toString()).toContain("https://example.com/?redirect=");
253
+ });
254
+
255
+ test.each(hostCases)(
256
+ "the path of %s now agrees with the WHATWG parser",
257
+ (raw: string) => {
258
+ expect(pathOf(URL.fromString(raw))).toBe(
259
+ new globalThis.URL(raw).pathname,
260
+ );
261
+ },
262
+ );
263
+
264
+ test("the path used to diverge from what SSRF validation saw", () => {
265
+ /*
266
+ * The exact divergence the fix closes, stated as the two parsers seeing
267
+ * the same request line: WHATWG (which the SSRF check uses) reads "/" for
268
+ * a path-less URL, and this type used to read "/a/b".
269
+ */
270
+ const raw: string = "https://example.com?next=/a/b";
271
+
272
+ expect(new globalThis.URL(raw).pathname).toBe("/");
273
+ expect(pathOf(URL.fromString(raw))).toBe("/");
274
+ expect(pathOf(URL.fromString(raw))).not.toBe("/a/b");
275
+ });
276
+
277
+ test("userinfo in the authority is still carried whole", () => {
278
+ const url: URL = URL.fromString("https://user:pw@example.com?next=/a/b");
279
+
280
+ expect(url.hostname.hostname).toBe("user:pw@example.com");
281
+ expect(url.route.toString()).toBe("");
282
+ });
283
+ });
284
+
285
+ /*
286
+ * A fragment is not a field on this type; it rides on the end of the route.
287
+ * Cutting the route at "#" as well as "?" - without carrying the fragment
288
+ * across - would have silently dropped it, so these pin that it survives.
289
+ */
290
+ describe("URL route/query separation — fragments survive", () => {
291
+ test("a fragment after a path is kept", () => {
292
+ const url: URL = URL.fromString("https://example.com/docs#section");
293
+
294
+ expect(url.route.toString()).toBe("docs#section");
295
+ expect(url.toString()).toBe("https://example.com/docs#section");
296
+ });
297
+
298
+ test("a fragment on a path-less URL is no longer dropped", () => {
299
+ const url: URL = URL.fromString("https://example.com#section");
300
+
301
+ expect(url.hostname.hostname).toBe("example.com");
302
+ expect(url.route.toString()).toBe("#section");
303
+ expect(url.toString()).toBe("https://example.com/#section");
304
+ });
305
+
306
+ test("a '/' inside a fragment no longer fabricates a path", () => {
307
+ /*
308
+ * The fragment-side twin of the query bug: "#a/b" used to split as a path
309
+ * and come back out as "https://example.com/b", losing the fragment and
310
+ * inventing a path in one step.
311
+ */
312
+ const url: URL = URL.fromString("https://example.com#a/b");
313
+
314
+ expect(url.route.toString()).toBe("#a/b");
315
+ expect(url.toString()).toBe("https://example.com/#a/b");
316
+ expect(url.toString()).not.toBe("https://example.com/b");
317
+ });
318
+
319
+ test("a fragment coexists with a slash-bearing query value", () => {
320
+ const url: URL = URL.fromString("https://example.com/p?next=/a/b#frag");
321
+
322
+ expect(url.route.toString()).toBe("p");
323
+ expect(url.getQueryParam("next")).toBe("/a/b#frag");
324
+ expect(url.toString()).toBe("https://example.com/p?next=/a/b#frag");
325
+ });
326
+
327
+ test("a fragment before the query keeps both halves", () => {
328
+ const url: URL = URL.fromString("https://example.com/p#frag?x=1");
329
+
330
+ expect(url.route.toString()).toBe("p#frag");
331
+ expect(url.getQueryParam("x")).toBe("1");
332
+ expect(url.toString()).toBe("https://example.com/p#frag?x=1");
333
+ });
334
+ });
335
+
336
+ describe("URL route/query separation — round trip is lossless", () => {
337
+ const roundTripCases: Array<string> = [
338
+ "https://example.com/?next=/a/b",
339
+ "https://example.com/?next=/a/b/c/d/e",
340
+ "https://example.com/?next=/",
341
+ "https://example.com/?next=/a/b&state=xyz",
342
+ "https://example.com/?state=xyz&next=/a/b",
343
+ "https://example.com/?next=/a?b=c",
344
+ "https://example.com/?next=/a?b=c&state=x",
345
+ "https://example.com/?redirect=https://app.example.com/home",
346
+ "https://example.com/?redirect=https://app.example.com/home?tab=1",
347
+ "https://example.com:8080/?next=/a/b",
348
+ "http://localhost:5000/?next=/a/b",
349
+ "https://example.com/p?next=/a/b",
350
+ "https://example.com/p/?next=/a/b",
351
+ "https://example.com/api/v1/items?next=/a/b",
352
+ "https://example.com/api/v1/items?next=/a/b&other=/c/d",
353
+ "https://auth.example.com/login?redirect=https://app.example.com/home?tab=1&state=x",
354
+ "https://example.com/docs#section",
355
+ "https://example.com/#section",
356
+ "https://example.com/#a/b",
357
+ "https://example.com/p?next=/a/b#frag",
358
+ "https://example.com/p#frag?x=1",
359
+ ];
360
+
361
+ test.each(roundTripCases)(
362
+ "URL.fromString(%s).toString() returns it unchanged",
363
+ (original: string) => {
364
+ expect(URL.fromString(original).toString()).toBe(original);
365
+ },
366
+ );
367
+
368
+ test.each(roundTripCases)(
369
+ "a second parse of %s is stable",
370
+ (original: string) => {
371
+ const once: string = URL.fromString(original).toString();
372
+
373
+ expect(URL.fromString(once).toString()).toBe(once);
374
+ },
375
+ );
376
+
377
+ test.each(roundTripCases)(
378
+ "fromURL re-parses %s unchanged",
379
+ (original: string) => {
380
+ expect(URL.fromURL(URL.fromString(original)).toString()).toBe(original);
381
+ },
382
+ );
383
+
384
+ /*
385
+ * An authority with no path re-serializes with the "/" RFC 3986 6.2.3 makes
386
+ * equivalent to an empty path - "https://example.com" has always come back
387
+ * as "https://example.com/", with or without a query. That normalization
388
+ * predates this change and is why the round-trip list above spells the "/"
389
+ * out; parsing either form twice is stable.
390
+ */
391
+ const normalizedCases: Array<[string, string]> = [
392
+ ["https://example.com?next=/a/b", "https://example.com/?next=/a/b"],
393
+ ["https://example.com?next=/a?b=c", "https://example.com/?next=/a?b=c"],
394
+ ["https://example.com#section", "https://example.com/#section"],
395
+ ["https://example.com", "https://example.com/"],
396
+ ["https://example.com?token=x", "https://example.com/?token=x"],
397
+ ];
398
+
399
+ test.each(normalizedCases)(
400
+ "%s re-serializes as %s and then holds still",
401
+ (original: string, normalized: string) => {
402
+ expect(URL.fromString(original).toString()).toBe(normalized);
403
+ expect(URL.fromString(normalized).toString()).toBe(normalized);
404
+ },
405
+ );
406
+ });
407
+
408
+ /*
409
+ * The workflow "API Get (JSON)" and "API Delete (JSON)" components hand the
410
+ * author's URL to URL.fromString and dispatch whatever comes back, so these
411
+ * are the strings that used to go out to the wrong path.
412
+ */
413
+ describe("URL route/query separation — the workflow API component path", () => {
414
+ const componentUrls: Array<string> = [
415
+ "https://hooks.example.com/?target=/deploy/rollback",
416
+ "https://api.example.com/?path=/v1/incidents&limit=50",
417
+ "https://example.com/?callback=https://oneuptime.com/api/webhook",
418
+ "https://api.example.com/v1/incidents?callback=/done",
419
+ ];
420
+
421
+ test.each(componentUrls)(
422
+ "a component URL is dispatched unchanged: %s",
423
+ (componentUrl: string) => {
424
+ expect(URL.fromString(componentUrl).toString()).toBe(componentUrl);
425
+ },
426
+ );
427
+
428
+ test("a path-less component URL is no longer dispatched to an invented path", () => {
429
+ const dispatched: string = URL.fromString(
430
+ "https://hooks.example.com?target=/deploy/rollback",
431
+ ).toString();
432
+
433
+ expect(dispatched).not.toBe(
434
+ "https://hooks.example.com/deploy/rollback?target=/deploy/rollback",
435
+ );
436
+ expect(dispatched).toBe(
437
+ "https://hooks.example.com/?target=/deploy/rollback",
438
+ );
439
+ });
440
+ });
441
+
442
+ describe("URL route/query separation — other entry points agree", () => {
443
+ test("the constructor produces the same URL as the parser", () => {
444
+ const constructed: URL = new URL(
445
+ Protocol.HTTPS,
446
+ new Hostname("example.com"),
447
+ new Route(""),
448
+ "next=/a/b",
449
+ );
450
+
451
+ expect(constructed.toString()).toBe("https://example.com/?next=/a/b");
452
+ expect(constructed.toString()).toBe(
453
+ URL.fromString("https://example.com?next=/a/b").toString(),
454
+ );
455
+ });
456
+
457
+ test("fromStringLenient parses the fixed shape rather than falling back", () => {
458
+ const url: URL = URL.fromStringLenient("https://example.com?next=/a/b");
459
+
460
+ expect(url.isMalformed()).toBe(false);
461
+ expect(url.route.toString()).toBe("");
462
+ expect(url.toString()).toBe("https://example.com/?next=/a/b");
463
+ });
464
+
465
+ test("removeQueryString leaves no fabricated path behind", () => {
466
+ const url: URL = URL.fromString(
467
+ "https://example.com?next=/a/b",
468
+ ).removeQueryString();
469
+
470
+ expect(url.route.toString()).toBe("");
471
+ expect(url.toString()).toBe("https://example.com/");
472
+ });
473
+
474
+ test("toJSON/fromJSON round trip a slash-bearing value", () => {
475
+ const url: URL = URL.fromString("https://example.com?next=/a/b");
476
+
477
+ expect(URL.fromJSON(url.toJSON()).toString()).toBe(
478
+ "https://example.com/?next=/a/b",
479
+ );
480
+ });
481
+
482
+ test("addQueryParam on a path-less URL adds no path", () => {
483
+ const url: URL = URL.fromString("https://example.com").addQueryParam(
484
+ "next",
485
+ "/a/b",
486
+ );
487
+
488
+ expect(url.route.toString()).toBe("");
489
+ expect(url.toString()).toBe("https://example.com/?next=/a/b");
490
+ });
491
+ });