@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
@@ -7,6 +7,7 @@ import RumSessionReplayViewService from "../../../Server/Services/RumSessionRepl
7
7
  import RumSessionService from "../../../Server/Services/RumSessionService";
8
8
  import RumSessionChunkService from "../../../Server/Services/RumSessionChunkService";
9
9
  import { Statement } from "../../../Server/Utils/AnalyticsDatabase/Statement";
10
+ import SessionReplayIdentity from "../../../Server/Utils/SessionReplay/SessionReplayIdentity";
10
11
  import RumApplication from "../../../Models/DatabaseModels/RumApplication";
11
12
  import RumSessionReplayView from "../../../Models/DatabaseModels/RumSessionReplayView";
12
13
  import Label from "../../../Models/DatabaseModels/Label";
@@ -1086,6 +1087,196 @@ describe("Session replay playback API", () => {
1086
1087
  expect(query).toContain("timeout_overflow_mode = 'throw'");
1087
1088
  });
1088
1089
 
1090
+ /*
1091
+ * Watching implies listing.
1092
+ *
1093
+ * RumSession's table read ACL contains ReadRumSessionReplayPayload for a
1094
+ * reason it states outright: a role granted only the watch permission
1095
+ * could fetch payloads - the payload routes authorize on it alone - while
1096
+ * being 401'd on the manifest and the list, which is an incoherent grant
1097
+ * rather than a safer one. The list guard did not mirror it, so the
1098
+ * natural support-engineer role ("Watch Session Replays" + "Read RUM
1099
+ * Application") got a permission error on the session list and a silently
1100
+ * missing "Watch what the user saw" card on every exception page.
1101
+ */
1102
+ test("a caller with only the watch permission can still list sessions", async () => {
1103
+ const principal: {
1104
+ request: JSONObject;
1105
+ databaseProps: DatabaseCommonInteractionProps;
1106
+ } = buildPrincipal({
1107
+ projectId: projectId,
1108
+ userId: userId,
1109
+ permissions: [Permission.ReadRumSessionReplayPayload],
1110
+ });
1111
+
1112
+ mockProps(principal.databaseProps);
1113
+ mockApplication({ id: applicationAId, labelIds: [] });
1114
+
1115
+ const result: CallResult = await callRoute({
1116
+ uri: LIST_ROUTE,
1117
+ request: principal.request,
1118
+ body: { rumApplicationId: applicationAId.toString() },
1119
+ });
1120
+
1121
+ expect(result.deniedWith).toBeUndefined();
1122
+ expect(headerQuerySpy).toHaveBeenCalledTimes(1);
1123
+ });
1124
+
1125
+ test("a Viewer still cannot list sessions", async () => {
1126
+ const principal: {
1127
+ request: JSONObject;
1128
+ databaseProps: DatabaseCommonInteractionProps;
1129
+ } = buildPrincipal({
1130
+ projectId: projectId,
1131
+ userId: userId,
1132
+ permissions: [Permission.Viewer],
1133
+ });
1134
+
1135
+ mockProps(principal.databaseProps);
1136
+ mockApplication({ id: applicationAId, labelIds: [] });
1137
+
1138
+ const result: CallResult = await callRoute({
1139
+ uri: LIST_ROUTE,
1140
+ request: principal.request,
1141
+ body: { rumApplicationId: applicationAId.toString() },
1142
+ });
1143
+
1144
+ expect(result.deniedWith).toBeInstanceOf(NotAuthorizedException);
1145
+ expect(headerQuerySpy).not.toHaveBeenCalled();
1146
+ });
1147
+
1148
+ /*
1149
+ * The identity filter takes the end-user REFERENCE a person can see in
1150
+ * the list, and the server derives the digest with the same per-project
1151
+ * HMAC the ingest used. It used to take the digest itself - a value
1152
+ * displayed nowhere in the product and computed by no endpoint - so
1153
+ * every input a human could plausibly type returned nothing.
1154
+ */
1155
+ test("an end-user reference is hashed server side before it reaches the query", async () => {
1156
+ const principal: {
1157
+ request: JSONObject;
1158
+ databaseProps: DatabaseCommonInteractionProps;
1159
+ } = buildPrincipal({
1160
+ projectId: projectId,
1161
+ userId: userId,
1162
+ /* Holds the narrower identity permission - see the gate test below. */
1163
+ permissions: [Permission.ProjectOwner],
1164
+ });
1165
+
1166
+ mockProps(principal.databaseProps);
1167
+ mockApplication({ id: applicationAId, labelIds: [] });
1168
+
1169
+ await callRoute({
1170
+ uri: LIST_ROUTE,
1171
+ request: principal.request,
1172
+ body: {
1173
+ rumApplicationId: applicationAId.toString(),
1174
+ filters: { identifiedUserRef: "jane@example.com" },
1175
+ },
1176
+ });
1177
+
1178
+ const statement: Statement = headerQuerySpy.mock
1179
+ .calls[0]![0] as Statement;
1180
+
1181
+ const expectedKey: string = SessionReplayIdentity.buildUserKey({
1182
+ projectId: projectId,
1183
+ userRef: "jane@example.com",
1184
+ });
1185
+
1186
+ const bound: string = JSON.stringify(statement.query_params);
1187
+
1188
+ /* The raw reference must never reach the query. */
1189
+ expect(bound).not.toContain("jane@example.com");
1190
+ expect(bound).toContain(expectedKey);
1191
+ });
1192
+
1193
+ /*
1194
+ * The identity FILTER is gated by the same ACL as the identity COLUMN.
1195
+ *
1196
+ * Without this, a caller deliberately denied the label could still ask
1197
+ * "does jane@example.com have sessions here" and read every other field
1198
+ * of the answer - a dictionary attack that de-anonymises the list one
1199
+ * candidate at a time, and hands back identifiedUserKey as a stable
1200
+ * pseudonym to join against the route filter. It only became reachable
1201
+ * once the ingest started populating the column; before that the
1202
+ * predicate matched nothing whoever asked.
1203
+ */
1204
+ test("a caller without the identity permission cannot filter by end user", async () => {
1205
+ const principal: {
1206
+ request: JSONObject;
1207
+ databaseProps: DatabaseCommonInteractionProps;
1208
+ } = buildPrincipal({
1209
+ projectId: projectId,
1210
+ userId: userId,
1211
+ /* Can list, deliberately excluded from SESSION_REPLAY_IDENTITY_PERMISSIONS. */
1212
+ permissions: [Permission.TelemetryAdmin],
1213
+ });
1214
+
1215
+ mockProps(principal.databaseProps);
1216
+ mockApplication({ id: applicationAId, labelIds: [] });
1217
+
1218
+ await callRoute({
1219
+ uri: LIST_ROUTE,
1220
+ request: principal.request,
1221
+ body: {
1222
+ rumApplicationId: applicationAId.toString(),
1223
+ filters: { identifiedUserRef: "jane@example.com" },
1224
+ },
1225
+ });
1226
+
1227
+ const statement: Statement = headerQuerySpy.mock
1228
+ .calls[0]![0] as Statement;
1229
+
1230
+ const expectedKey: string = SessionReplayIdentity.buildUserKey({
1231
+ projectId: projectId,
1232
+ userRef: "jane@example.com",
1233
+ });
1234
+
1235
+ /* Neither the reference nor its digest may reach the query. */
1236
+ const bound: string = JSON.stringify(statement.query_params);
1237
+
1238
+ expect(bound).not.toContain("jane@example.com");
1239
+ expect(bound).not.toContain(expectedKey);
1240
+
1241
+ /*
1242
+ * aggIdentifiedUserKey is always a SELECT alias; what must be absent
1243
+ * is the HAVING predicate over it.
1244
+ */
1245
+ expect(statement.query).not.toContain("aggIdentifiedUserKey =");
1246
+ });
1247
+
1248
+ /*
1249
+ * A filter the server cannot honour must be refused, not dropped. The
1250
+ * silent-drop shape returns the WHOLE project's sessions with a 200 and
1251
+ * gives the caller no way to tell that the person they asked about was
1252
+ * not the one being answered about.
1253
+ */
1254
+ test("an unusable end-user reference is refused, not silently ignored", async () => {
1255
+ const principal: {
1256
+ request: JSONObject;
1257
+ databaseProps: DatabaseCommonInteractionProps;
1258
+ } = buildPrincipal({
1259
+ projectId: projectId,
1260
+ userId: userId,
1261
+ permissions: [Permission.ProjectOwner],
1262
+ });
1263
+
1264
+ mockProps(principal.databaseProps);
1265
+ mockApplication({ id: applicationAId, labelIds: [] });
1266
+
1267
+ const result: CallResult = await callRoute({
1268
+ uri: LIST_ROUTE,
1269
+ request: principal.request,
1270
+ body: {
1271
+ rumApplicationId: applicationAId.toString(),
1272
+ filters: { identifiedUserRef: "z".repeat(4096) },
1273
+ },
1274
+ });
1275
+
1276
+ expect(result.deniedWith).toBeInstanceOf(BadDataException);
1277
+ expect(headerQuerySpy).not.toHaveBeenCalled();
1278
+ });
1279
+
1089
1280
  test("omits identifiedUserLabel for a caller without the narrower identity permission", async () => {
1090
1281
  const principal: {
1091
1282
  request: JSONObject;
@@ -1263,6 +1454,47 @@ describe("Session replay playback API", () => {
1263
1454
  "(aggRageClickCount + aggDeadClickCount + aggErrorClickCount + aggRefreshRageCount) > 0",
1264
1455
  );
1265
1456
  });
1457
+
1458
+ /*
1459
+ * `false` means "sessions with NO frustration signals" and must be
1460
+ * honoured, not dropped. The route admits any boolean and hasError /
1461
+ * isFinalized beside it both honour false, so accepting the value and
1462
+ * ignoring it returned the whole unfiltered list with a 200 and no
1463
+ * indication why. The Dashboard never sends it, so this branch has no
1464
+ * producer and this is its only cover.
1465
+ */
1466
+ test("hasFrustration false selects the clean sessions rather than being dropped", async () => {
1467
+ const principal: {
1468
+ request: JSONObject;
1469
+ databaseProps: DatabaseCommonInteractionProps;
1470
+ } = buildPrincipal({
1471
+ projectId: projectId,
1472
+ userId: userId,
1473
+ permissions: [Permission.ProjectAdmin],
1474
+ });
1475
+
1476
+ mockProps(principal.databaseProps);
1477
+ mockApplication({ id: applicationAId, labelIds: [] });
1478
+
1479
+ await callRoute({
1480
+ uri: LIST_ROUTE,
1481
+ request: principal.request,
1482
+ body: {
1483
+ rumApplicationId: applicationAId.toString(),
1484
+ filters: { hasFrustration: false },
1485
+ },
1486
+ });
1487
+
1488
+ const statement: Statement = headerQuerySpy.mock
1489
+ .calls[0]![0] as Statement;
1490
+
1491
+ expect(statement.query).toContain(
1492
+ "(aggRageClickCount + aggDeadClickCount + aggErrorClickCount + aggRefreshRageCount) = 0",
1493
+ );
1494
+ expect(statement.query).not.toContain(
1495
+ "(aggRageClickCount + aggDeadClickCount + aggErrorClickCount + aggRefreshRageCount) > 0",
1496
+ );
1497
+ });
1266
1498
  });
1267
1499
 
1268
1500
  describe("manifest", () => {
@@ -142,6 +142,7 @@ describe("URL.fromString keeps the authority separate from the rest", () => {
142
142
 
143
143
  expect(url.email.toString()).toBe("support@oneuptime.com");
144
144
  expect(url.getQueryParam("subject")).toBe("Hello");
145
+ expect(url.toString()).toBe("mailto:support@oneuptime.com?subject=Hello");
145
146
  });
146
147
 
147
148
  test("a webhook URL with basic-auth credentials still round-trips", () => {
@@ -0,0 +1,439 @@
1
+ import Email from "../../../Types/Email";
2
+ import Hostname from "../../../Types/API/Hostname";
3
+ import Protocol from "../../../Types/API/Protocol";
4
+ import Route from "../../../Types/API/Route";
5
+ import URL from "../../../Types/API/URL";
6
+
7
+ /*
8
+ * toString() never emitted the query string for a mailto: URL, so every
9
+ * prefilled field of a mail link was silently dropped:
10
+ * "mailto:a@b.com?subject=Hello&body=Hi" went back out as "mailto:a@b.com"
11
+ * and the link opened on an empty draft.
12
+ *
13
+ * The parse was never the problem — `params` held { subject, body } all along.
14
+ * The suffix was appended INSIDE the route-handling branch, and mailto skips
15
+ * that branch on purpose (it has no authority to trim and no path to append),
16
+ * so the query went with the route. mailto now returns early with the suffix
17
+ * attached, the way the opaque tel:/sms: branch above it already did.
18
+ */
19
+ describe("mailto query string — the prefilled fields survive toString", () => {
20
+ test("a subject on its own reaches the output", () => {
21
+ const url: URL = URL.fromString("mailto:a@b.com?subject=Hello");
22
+
23
+ expect(url.getQueryParam("subject")).toBe("Hello");
24
+ expect(url.toString()).toBe("mailto:a@b.com?subject=Hello");
25
+ });
26
+
27
+ test("a subject and a body both reach the output", () => {
28
+ const url: URL = URL.fromString("mailto:a@b.com?subject=Hello&body=Hi");
29
+
30
+ expect(url.params).toEqual({ subject: "Hello", body: "Hi" });
31
+ expect(url.toString()).toBe("mailto:a@b.com?subject=Hello&body=Hi");
32
+ });
33
+
34
+ test("the parse was always right — only the output was lossy", () => {
35
+ /*
36
+ * Pins the exact shape of the bug rather than just its fix: params were
37
+ * correct before the change and are asserted separately from toString, so
38
+ * a regression that breaks only one of the two still shows which one.
39
+ */
40
+ const url: URL = URL.fromString("mailto:a@b.com?subject=Hello&body=Hi");
41
+
42
+ expect(url.params).toEqual({ subject: "Hello", body: "Hi" });
43
+ expect(url.toString()).not.toBe("mailto:a@b.com");
44
+ expect(url.toString()).toContain("subject=Hello");
45
+ expect(url.toString()).toContain("body=Hi");
46
+ });
47
+
48
+ test("cc and bcc survive alongside subject and body", () => {
49
+ const url: URL = URL.fromString(
50
+ "mailto:a@b.com?cc=c@d.com&bcc=e@f.com&subject=Hello&body=Hi",
51
+ );
52
+
53
+ expect(url.params).toEqual({
54
+ cc: "c@d.com",
55
+ bcc: "e@f.com",
56
+ subject: "Hello",
57
+ body: "Hi",
58
+ });
59
+ expect(url.toString()).toBe(
60
+ "mailto:a@b.com?cc=c@d.com&bcc=e@f.com&subject=Hello&body=Hi",
61
+ );
62
+ });
63
+
64
+ test("a mailto with no query gains no stray '?'", () => {
65
+ const url: URL = URL.fromString("mailto:support@oneuptime.com");
66
+
67
+ expect(url.toString()).toBe("mailto:support@oneuptime.com");
68
+ expect(url.toString()).not.toContain("?");
69
+ });
70
+ });
71
+
72
+ describe("mailto query string — a value that contains '='", () => {
73
+ test("an '=' inside a subject is not truncated", () => {
74
+ const url: URL = URL.fromString("mailto:a@b.com?subject=a=b");
75
+
76
+ expect(url.getQueryParam("subject")).toBe("a=b");
77
+ expect(url.toString()).toBe("mailto:a@b.com?subject=a=b");
78
+ });
79
+
80
+ test("an '=' laden body keeps its whole expression", () => {
81
+ const url: URL = URL.fromString(
82
+ "mailto:ops@example.com?subject=Alert&body=filter: status = open AND team = ops",
83
+ );
84
+
85
+ expect(url.getQueryParam("body")).toBe(
86
+ "filter: status = open AND team = ops",
87
+ );
88
+ expect(url.toString()).toBe(
89
+ "mailto:ops@example.com?subject=Alert&body=filter: status = open AND team = ops",
90
+ );
91
+ });
92
+
93
+ test("only the first '=' separates, however many follow", () => {
94
+ const url: URL = URL.fromString("mailto:a@b.com?body=x=y=z");
95
+
96
+ expect(url.params).toEqual({ body: "x=y=z" });
97
+ expect(url.toString()).toBe("mailto:a@b.com?body=x=y=z");
98
+ });
99
+
100
+ test("'==' padding does not swallow the params that follow it", () => {
101
+ const url: URL = URL.fromString(
102
+ "mailto:a@b.com?ref=YWJjZGVmZ2g==&subject=Hello",
103
+ );
104
+
105
+ expect(url.params).toEqual({ ref: "YWJjZGVmZ2g==", subject: "Hello" });
106
+ expect(url.toString()).toBe(
107
+ "mailto:a@b.com?ref=YWJjZGVmZ2g==&subject=Hello",
108
+ );
109
+ });
110
+ });
111
+
112
+ describe("mailto query string — round trip is lossless", () => {
113
+ const roundTripCases: Array<string> = [
114
+ "mailto:a@b.com",
115
+ "mailto:a@b.com?subject=Hello",
116
+ "mailto:a@b.com?subject=Hello&body=Hi",
117
+ "mailto:a@b.com?body=Hi&subject=Hello",
118
+ "mailto:a@b.com?cc=c@d.com&bcc=e@f.com&subject=Hello&body=Hi",
119
+ "mailto:a@b.com?subject=a=b",
120
+ "mailto:a@b.com?body=x=y=z",
121
+ "mailto:a@b.com?ref=YWJjZGVmZ2g==&subject=Hello",
122
+ "mailto:a@b.com?subject=Hello%20World&body=Line%0A2",
123
+ "mailto:a@b.com?subject=café ✓",
124
+ "mailto:a@b.com?body=see?tab=1",
125
+ "mailto:a@b.com?body=/a/b",
126
+ "mailto:a@b.com?flag",
127
+ "mailto:a+tag@b.com?subject=Hello",
128
+ "mailto:first.last@sub.example.co.uk?subject=Hello",
129
+ "mailto:support@oneuptime.com?subject=OneUptime%20Microsoft%20Teams%20Bot",
130
+ "mailto:security@oneuptime.com?subject=Security%20documentation%20request",
131
+ ];
132
+
133
+ test.each(roundTripCases)(
134
+ "URL.fromString(%s).toString() returns it unchanged",
135
+ (original: string) => {
136
+ expect(URL.fromString(original).toString()).toBe(original);
137
+ },
138
+ );
139
+
140
+ test.each(roundTripCases)(
141
+ "a second parse of %s is stable",
142
+ (original: string) => {
143
+ const once: string = URL.fromString(original).toString();
144
+
145
+ expect(URL.fromString(once).toString()).toBe(once);
146
+ },
147
+ );
148
+
149
+ test("fromURL round trips a mailto with a query", () => {
150
+ const url: URL = URL.fromString("mailto:a@b.com?subject=Hello&body=Hi");
151
+
152
+ expect(URL.fromURL(url).toString()).toBe(
153
+ "mailto:a@b.com?subject=Hello&body=Hi",
154
+ );
155
+ });
156
+ });
157
+
158
+ describe("mailto query string — degenerate segments", () => {
159
+ test("a trailing '?' produces no params and no '?'", () => {
160
+ const url: URL = URL.fromString("mailto:a@b.com?");
161
+
162
+ expect(url.params).toEqual({});
163
+ expect(url.toString()).toBe("mailto:a@b.com");
164
+ });
165
+
166
+ test("a value-less param is kept and re-emits bare", () => {
167
+ const url: URL = URL.fromString("mailto:a@b.com?flag");
168
+
169
+ expect(url.params).toEqual({ flag: "" });
170
+ expect(url.toString()).toBe("mailto:a@b.com?flag");
171
+ expect(url.toString()).not.toContain("flag=");
172
+ });
173
+
174
+ test("an explicitly empty subject re-emits bare", () => {
175
+ const url: URL = URL.fromString("mailto:a@b.com?subject=");
176
+
177
+ expect(url.params).toEqual({ subject: "" });
178
+ expect(url.toString()).toBe("mailto:a@b.com?subject");
179
+ });
180
+
181
+ test("a later duplicate key wins", () => {
182
+ const url: URL = URL.fromString("mailto:a@b.com?subject=one&subject=two");
183
+
184
+ expect(url.getQueryParam("subject")).toBe("two");
185
+ expect(url.toString()).toBe("mailto:a@b.com?subject=two");
186
+ });
187
+
188
+ test("a '?' inside a body is not truncated", () => {
189
+ /*
190
+ * The address is taken from everything before the FIRST "?", and the query
191
+ * is everything after it — so a body that carries its own "?" survives.
192
+ */
193
+ const url: URL = URL.fromString("mailto:a@b.com?body=see?tab=1");
194
+
195
+ expect(url.getQueryParam("body")).toBe("see?tab=1");
196
+ expect(url.toString()).toBe("mailto:a@b.com?body=see?tab=1");
197
+ });
198
+
199
+ test("a '/' inside a body does not fabricate a route", () => {
200
+ /*
201
+ * Unlike a path-less https URL, whose authority is split off at the first
202
+ * "/" before the query is separated, a mailto never reaches that code:
203
+ * fromString takes its address from url.split("?")[0].
204
+ */
205
+ const url: URL = URL.fromString("mailto:a@b.com?body=/a/b");
206
+
207
+ expect(url.getQueryParam("body")).toBe("/a/b");
208
+ expect(url.route.toString()).toBe("");
209
+ expect(url.toString()).toBe("mailto:a@b.com?body=/a/b");
210
+ });
211
+
212
+ test("a unicode subject is passed through untouched", () => {
213
+ const url: URL = URL.fromString("mailto:a@b.com?subject=café ✓");
214
+
215
+ expect(url.getQueryParam("subject")).toBe("café ✓");
216
+ expect(url.toString()).toBe("mailto:a@b.com?subject=café ✓");
217
+ });
218
+
219
+ test("a percent-encoded subject and body are passed through verbatim", () => {
220
+ const url: URL = URL.fromString(
221
+ "mailto:a@b.com?subject=Hello%20World&body=Line%0A2",
222
+ );
223
+
224
+ expect(url.params).toEqual({
225
+ subject: "Hello%20World",
226
+ body: "Line%0A2",
227
+ });
228
+ expect(url.toString()).toBe(
229
+ "mailto:a@b.com?subject=Hello%20World&body=Line%0A2",
230
+ );
231
+ });
232
+
233
+ test("a pair with no key is dropped", () => {
234
+ const url: URL = URL.fromString("mailto:a@b.com?=novalue&subject=Hello");
235
+
236
+ expect(url.params).toEqual({ subject: "Hello" });
237
+ expect(url.toString()).toBe("mailto:a@b.com?subject=Hello");
238
+ });
239
+ });
240
+
241
+ describe("mailto query string — the address is still an Email", () => {
242
+ test("the email is readable with a query present", () => {
243
+ const url: URL = URL.fromString("mailto:support@oneuptime.com?subject=Hi");
244
+
245
+ expect(url.email.toString()).toBe("support@oneuptime.com");
246
+ expect(url.protocol).toBe(Protocol.MAIL);
247
+ expect(url.toString()).toBe("mailto:support@oneuptime.com?subject=Hi");
248
+ });
249
+
250
+ test("a plus-addressed recipient keeps its tag", () => {
251
+ const url: URL = URL.fromString("mailto:a+tag@b.com?subject=Hello");
252
+
253
+ expect(url.email.toString()).toBe("a+tag@b.com");
254
+ expect(url.toString()).toBe("mailto:a+tag@b.com?subject=Hello");
255
+ });
256
+
257
+ test("an upper-case scheme is normalised and keeps its query", () => {
258
+ const url: URL = URL.fromString("MAILTO:a@b.com?subject=Hello");
259
+
260
+ expect(url.email.toString()).toBe("a@b.com");
261
+ expect(url.toString()).toBe("mailto:a@b.com?subject=Hello");
262
+ });
263
+
264
+ test("a mailto payload that is not an email still keeps its query", () => {
265
+ /*
266
+ * "notanemail" fails Email.isValid, so the constructor stores it as a
267
+ * hostname and `email` is never set. The guard has to catch this case on
268
+ * the "mailto:" prefix rather than on `email`, or the query would be lost
269
+ * again for exactly these values.
270
+ */
271
+ const url: URL = URL.fromString("mailto:notanemail?subject=Hello");
272
+
273
+ expect(url.email).toBeUndefined();
274
+ expect(url.hostname.toString()).toBe("notanemail");
275
+ expect(url.toString()).toBe("mailto:notanemail?subject=Hello");
276
+ });
277
+ });
278
+
279
+ describe("mailto query string — constructed rather than parsed", () => {
280
+ test("the constructor takes a query alongside an Email", () => {
281
+ const url: URL = new URL(
282
+ Protocol.MAIL,
283
+ new Email("a@b.com"),
284
+ undefined,
285
+ "subject=Hello&body=Hi",
286
+ );
287
+
288
+ expect(url.params).toEqual({ subject: "Hello", body: "Hi" });
289
+ expect(url.toString()).toBe("mailto:a@b.com?subject=Hello&body=Hi");
290
+ });
291
+
292
+ test("the constructor takes a query alongside a string address", () => {
293
+ const url: URL = new URL(
294
+ Protocol.MAIL,
295
+ "a@b.com",
296
+ undefined,
297
+ "subject=Hello",
298
+ );
299
+
300
+ expect(url.email.toString()).toBe("a@b.com");
301
+ expect(url.toString()).toBe("mailto:a@b.com?subject=Hello");
302
+ });
303
+
304
+ test("addQueryParam prefills a mailto that had no query", () => {
305
+ const url: URL = URL.fromString("mailto:a@b.com").addQueryParam(
306
+ "subject",
307
+ "Hello",
308
+ );
309
+
310
+ expect(url.toString()).toBe("mailto:a@b.com?subject=Hello");
311
+ });
312
+
313
+ test("addQueryParam with encode still round trips", () => {
314
+ const url: URL = URL.fromString("mailto:a@b.com").addQueryParam(
315
+ "subject",
316
+ "Hello World",
317
+ true,
318
+ );
319
+
320
+ expect(url.getQueryParam("subject")).toBe("Hello%20World");
321
+ expect(URL.fromString(url.toString()).getQueryParam("subject")).toBe(
322
+ "Hello%20World",
323
+ );
324
+ });
325
+
326
+ test("addQueryParams merges a body into an existing subject", () => {
327
+ const url: URL = URL.fromString(
328
+ "mailto:a@b.com?subject=Hello",
329
+ ).addQueryParams({ body: "Hi" });
330
+
331
+ expect(url.params).toEqual({ subject: "Hello", body: "Hi" });
332
+ expect(url.toString()).toBe("mailto:a@b.com?subject=Hello&body=Hi");
333
+ });
334
+
335
+ test("removeQueryString strips the prefilled fields back off", () => {
336
+ const url: URL = URL.fromString(
337
+ "mailto:a@b.com?subject=Hello&body=Hi",
338
+ ).removeQueryString();
339
+
340
+ expect(url.params).toEqual({});
341
+ expect(url.toString()).toBe("mailto:a@b.com");
342
+ });
343
+ });
344
+
345
+ describe("mailto query string — crossing the wire", () => {
346
+ test("toJSON carries the query", () => {
347
+ const url: URL = URL.fromString("mailto:a@b.com?subject=Hello&body=Hi");
348
+
349
+ expect(url.toJSON()).toEqual({
350
+ _type: "URL",
351
+ value: "mailto:a@b.com?subject=Hello&body=Hi",
352
+ });
353
+ });
354
+
355
+ test("fromJSON restores a mailto with its query intact", () => {
356
+ const url: URL = URL.fromJSON(
357
+ URL.fromString("mailto:a@b.com?subject=Hello&body=Hi").toJSON(),
358
+ );
359
+
360
+ expect(url.getQueryParam("subject")).toBe("Hello");
361
+ expect(url.toString()).toBe("mailto:a@b.com?subject=Hello&body=Hi");
362
+ });
363
+
364
+ test("a mailto with a query is not treated as malformed", () => {
365
+ const url: URL = URL.fromStringLenient("mailto:a@b.com?subject=Hello");
366
+
367
+ expect(url.isMalformed()).toBe(false);
368
+ expect(url.toString()).toBe("mailto:a@b.com?subject=Hello");
369
+ });
370
+ });
371
+
372
+ /*
373
+ * The fix returns early for mailto instead of widening the branch that builds
374
+ * the route, so the branch itself moved. These pin the shapes that go through
375
+ * it — every one of them held before the change and must still hold.
376
+ */
377
+ describe("mailto query string — every other scheme is unaffected", () => {
378
+ const untouchedCases: Array<string> = [
379
+ "https://example.com/a/b?x=1",
380
+ "https://example.com/path",
381
+ "http://localhost:5000/api/test?a=1",
382
+ "wss://example.com/socket?a=1",
383
+ "mongodb://example.com/db?a=1",
384
+ "https://user:token@hooks.example.com/x",
385
+ "https://example.com/p?flag",
386
+ "https://example.com/p?a=b=c=d",
387
+ "tel:+15555550123?ref=a=b",
388
+ "sms:+15555550123?body=x=1&y=2",
389
+ ];
390
+
391
+ test.each(untouchedCases)("%s still round trips", (original: string) => {
392
+ expect(URL.fromString(original).toString()).toBe(original);
393
+ });
394
+
395
+ test("a path-less https URL still gains its trailing slash", () => {
396
+ expect(URL.fromString("https://example.com").toString()).toBe(
397
+ "https://example.com/",
398
+ );
399
+ });
400
+
401
+ test("a route given with a leading slash is not doubled", () => {
402
+ const url: URL = new URL(
403
+ Protocol.HTTPS,
404
+ new Hostname("example.com"),
405
+ new Route("/a/b"),
406
+ "x=1",
407
+ );
408
+
409
+ expect(url.toString()).toBe("https://example.com/a/b?x=1");
410
+ });
411
+
412
+ test("a route given without a leading slash still gets one", () => {
413
+ const url: URL = new URL(
414
+ Protocol.HTTPS,
415
+ new Hostname("example.com"),
416
+ new Route("a/b"),
417
+ "x=1",
418
+ );
419
+
420
+ expect(url.toString()).toBe("https://example.com/a/b?x=1");
421
+ });
422
+
423
+ test("an empty route still renders as a bare slash", () => {
424
+ const url: URL = new URL(
425
+ Protocol.HTTPS,
426
+ new Hostname("example.com"),
427
+ new Route(""),
428
+ "x=1",
429
+ );
430
+
431
+ expect(url.toString()).toBe("https://example.com/?x=1");
432
+ });
433
+
434
+ test("a malformed URL is still echoed back raw", () => {
435
+ expect(URL.fromStringLenient("https://tel:1234567890/").toString()).toBe(
436
+ "https://tel:1234567890/",
437
+ );
438
+ });
439
+ });