@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.
- package/Models/AnalyticsModels/RumSessionChunk.ts +52 -0
- package/Models/DatabaseModels/CloudResource.ts +17 -1
- package/Models/DatabaseModels/RumApplication.ts +35 -2
- package/Server/API/TelemetryAPI.ts +104 -15
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +12 -5
- package/Server/Utils/SessionReplay/SessionReplayIdentity.ts +102 -0
- package/Server/Utils/SessionReplay/SessionReplayReadService.ts +11 -2
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +81 -0
- package/Tests/Server/API/SessionReplayAPI.test.ts +232 -0
- package/Tests/Types/API/HostnameAuthorityParsing.test.ts +1 -0
- package/Tests/Types/API/URLMailtoQueryString.test.ts +439 -0
- package/Tests/Types/API/URLQueryString.test.ts +33 -24
- package/Tests/Types/API/URLRouteQuerySeparation.test.ts +491 -0
- package/Tests/UI/Components/LogsHistogramDragTooltip.test.tsx +393 -0
- package/Tests/UI/Components/LogsViewerHistogramZoom.test.tsx +248 -0
- package/Tests/UI/Components/TelemetryHistogramDragTooltip.test.tsx +205 -0
- package/Tests/UI/Components/UseHistogramZoom.test.tsx +262 -0
- package/Types/API/URL.ts +55 -18
- package/Types/Rum/SessionReplay.ts +14 -0
- package/UI/Components/Charts/Utils/useHistogramZoom.ts +101 -0
- package/UI/Components/LogsViewer/LogsViewer.tsx +19 -3
- package/UI/Components/LogsViewer/components/LogsHistogram.tsx +52 -1
- package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +18 -2
- package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +52 -1
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js +48 -0
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CloudResource.js +19 -2
- package/build/dist/Models/DatabaseModels/CloudResource.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +37 -3
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +67 -14
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js +59 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js.map +1 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js +9 -2
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js.map +1 -1
- package/build/dist/Types/API/URL.js +48 -17
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js +47 -0
- package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js.map +1 -0
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +15 -3
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +33 -5
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +14 -2
- package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +33 -5
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
- 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
|
+
});
|