@oneuptime/common 12.0.19 → 12.0.21

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 (124) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Server/API/EnterpriseLicenseAPI.ts +94 -19
  4. package/Server/EnvironmentConfig.ts +15 -5
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
  7. package/Server/Services/ChangeEventService.ts +11 -0
  8. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  9. package/Server/Services/Index.ts +2 -0
  10. package/Server/Services/ProjectService.ts +104 -0
  11. package/Server/Utils/Attribution.ts +11 -11
  12. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  13. package/Server/Utils/Response.ts +2 -2
  14. package/Server/Utils/StartServer.ts +2 -2
  15. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  16. package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
  17. package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
  18. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
  19. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
  20. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  21. package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
  22. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  23. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  24. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  25. package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
  26. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  27. package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
  28. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  29. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
  30. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
  31. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  32. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  33. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  34. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  35. package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
  36. package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
  37. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  38. package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
  39. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
  40. package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
  41. package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
  42. package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
  43. package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
  44. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  45. package/Types/Analytics/RevenueEvent.ts +1 -0
  46. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  47. package/Types/Marketing/Attribution.ts +9 -15
  48. package/Types/Marketing/MarketingEvent.ts +19 -12
  49. package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
  50. package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
  51. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  52. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
  53. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
  54. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
  55. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
  56. package/UI/Components/Charts/Line/LineChart.tsx +23 -1
  57. package/UI/Components/Detail/Detail.tsx +18 -1
  58. package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
  59. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  60. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  61. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  62. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  63. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  64. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  65. package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
  66. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  67. package/build/dist/Server/EnvironmentConfig.js +14 -5
  68. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  73. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  74. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  75. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  76. package/build/dist/Server/Services/Index.js +2 -0
  77. package/build/dist/Server/Services/Index.js.map +1 -1
  78. package/build/dist/Server/Services/ProjectService.js +88 -0
  79. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  80. package/build/dist/Server/Utils/Attribution.js +6 -6
  81. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  82. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  83. package/build/dist/Server/Utils/Response.js +2 -2
  84. package/build/dist/Server/Utils/Response.js.map +1 -1
  85. package/build/dist/Server/Utils/StartServer.js +2 -2
  86. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  88. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  89. package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
  90. package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  92. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  93. package/build/dist/Types/Marketing/Attribution.js +9 -15
  94. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  95. package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
  96. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  97. package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  99. package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
  100. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  102. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
  104. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
  106. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
  108. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
  110. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
  112. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  113. package/build/dist/UI/Components/Detail/Detail.js +17 -1
  114. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  115. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
  116. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
  117. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  118. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  119. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  120. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  121. package/package.json +1 -1
  122. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  123. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -0,0 +1,763 @@
1
+ import EnterpriseLicenseAPI from "../../../Server/API/EnterpriseLicenseAPI";
2
+ import EnterpriseLicenseService from "../../../Server/Services/EnterpriseLicenseService";
3
+ import EnterpriseLicenseInstanceService from "../../../Server/Services/EnterpriseLicenseInstanceService";
4
+ import JSONWebToken from "../../../Server/Utils/JsonWebToken";
5
+ import Response from "../../../Server/Utils/Response";
6
+ import EnterpriseLicense from "../../../Models/DatabaseModels/EnterpriseLicense";
7
+ import EnterpriseLicenseInstance from "../../../Models/DatabaseModels/EnterpriseLicenseInstance";
8
+ import BadDataException from "../../../Types/Exception/BadDataException";
9
+ import ObjectID from "../../../Types/ObjectID";
10
+ import OneUptimeDate from "../../../Types/Date";
11
+ import PositiveNumber from "../../../Types/PositiveNumber";
12
+ import { JSONObject } from "../../../Types/JSON";
13
+ import EnterpriseLicenseSyncUtil, {
14
+ EnterpriseLicenseSyncResult,
15
+ } from "../../../Utils/EnterpriseLicense/EnterpriseLicenseSync";
16
+ import {
17
+ NextFunction,
18
+ OneUptimeRequest,
19
+ OneUptimeResponse,
20
+ } from "../../../Server/Utils/Express";
21
+ import { mockRouter } from "./Helpers";
22
+ import { beforeEach, afterEach, describe, expect, it } from "@jest/globals";
23
+
24
+ /*
25
+ * The license-server half of the seat-limit sync.
26
+ *
27
+ * /report-user-count is the daily call a self-hosted installation makes, and
28
+ * its response is the ONLY thing that installation can learn from without a
29
+ * human re-typing the license key. It used to answer with the usage numbers
30
+ * and the seat limit but nothing else, and the job on the other end dropped
31
+ * the seat limit on the floor — so raising a customer's limit on oneuptime.com
32
+ * never reached the installation enforcing it.
33
+ *
34
+ * These tests pin the response as a complete statement of the license terms,
35
+ * and pin the one thing that must NOT be unconditional: an expired license
36
+ * still gets a straight answer (it has to keep reporting, the expiry emails
37
+ * are built from those reports) but it does not get a fresh token.
38
+ */
39
+
40
+ /*
41
+ * PasswordHash carries a pre-existing TS diagnostic that fails any suite whose
42
+ * require graph reaches it, and DatabaseService — the base of every concrete
43
+ * service below — imports it. Replaced with a factory rather than automocked,
44
+ * because an automock still type-checks the real file.
45
+ */
46
+ jest.mock("../../../Server/Utils/PasswordHash", () => {
47
+ return {
48
+ __esModule: true,
49
+ default: {
50
+ hash: jest.fn(),
51
+ verify: jest.fn(),
52
+ generateSalt: jest.fn(),
53
+ needsUpgrade: jest.fn(),
54
+ applyPepper: jest.fn(),
55
+ },
56
+ };
57
+ });
58
+
59
+ /*
60
+ * Same story as PasswordHash: a pre-existing local-only TS diagnostic in a
61
+ * module the service graph drags in. Nothing here verifies a code.
62
+ */
63
+ jest.mock("../../../Server/Utils/VerificationCode", () => {
64
+ return {
65
+ __esModule: true,
66
+ default: {
67
+ generate: jest.fn(),
68
+ hashCode: jest.fn(),
69
+ isHashEqual: jest.fn(),
70
+ generateUnusableHash: jest.fn(),
71
+ },
72
+ };
73
+ });
74
+
75
+ jest.mock("../../../Server/Utils/Express", () => {
76
+ return {
77
+ getRouter: () => {
78
+ return mockRouter;
79
+ },
80
+ };
81
+ });
82
+
83
+ jest.mock("../../../Server/Utils/Response", () => {
84
+ return {
85
+ sendEntityArrayResponse: jest.fn().mockImplementation((...args: []) => {
86
+ return args;
87
+ }),
88
+ sendJsonObjectResponse: jest.fn().mockImplementation((...args: []) => {
89
+ return args;
90
+ }),
91
+ sendEmptySuccessResponse: jest.fn(),
92
+ sendEntityResponse: jest.fn().mockImplementation((...args: []) => {
93
+ return args;
94
+ }),
95
+ sendErrorResponse: jest.fn().mockImplementation((...args: []) => {
96
+ return args;
97
+ }),
98
+ };
99
+ });
100
+
101
+ /*
102
+ * Signed with the server's own secret in production; here it only has to be
103
+ * deterministic and countable, so the expiry gating can be asserted on whether
104
+ * it was called at all.
105
+ */
106
+ jest.mock("../../../Server/Utils/JsonWebToken", () => {
107
+ return {
108
+ __esModule: true,
109
+ default: {
110
+ signJsonPayload: jest.fn().mockReturnValue("signed.jwt.token"),
111
+ },
112
+ };
113
+ });
114
+
115
+ jest.mock("../../../Server/Services/EnterpriseLicenseService");
116
+ jest.mock("../../../Server/Services/EnterpriseLicenseInstanceService");
117
+
118
+ const REPORT_ROUTE: string = "/enterprise-license/report-user-count";
119
+ const VALIDATE_ROUTE: string = "/enterprise-license/validate";
120
+
121
+ const LICENSE_ID: ObjectID = ObjectID.generate();
122
+ const LICENSE_KEY: string = "acme-license-key";
123
+
124
+ // A hash the sanitizer accepts: 64 hex characters.
125
+ type HashFunction = (seed: string) => string;
126
+
127
+ const hashOf: HashFunction = (seed: string): string => {
128
+ return seed
129
+ .repeat(64)
130
+ .replace(/[^a-f0-9]/g, "a")
131
+ .substring(0, 64);
132
+ };
133
+
134
+ /*
135
+ * Overrides are loosely typed on purpose: several tests need to express
136
+ * "this column is not set", which exactOptionalPropertyTypes forbids on
137
+ * Partial<EnterpriseLicense>.
138
+ */
139
+ type MakeLicenseFunction = (
140
+ overrides?: Record<string, unknown>,
141
+ ) => EnterpriseLicense;
142
+
143
+ const makeLicense: MakeLicenseFunction = (
144
+ overrides?: Record<string, unknown>,
145
+ ): EnterpriseLicense => {
146
+ return {
147
+ id: LICENSE_ID,
148
+ companyName: "Acme Inc",
149
+ licenseKey: LICENSE_KEY,
150
+ expiresAt: OneUptimeDate.addRemoveDays(OneUptimeDate.getCurrentDate(), 90),
151
+ userLimit: 150,
152
+ currentUserCount: 42,
153
+ userCountUpdatedAt: OneUptimeDate.getCurrentDate(),
154
+ isEvaluationLicense: false,
155
+ ...overrides,
156
+ } as unknown as EnterpriseLicense;
157
+ };
158
+
159
+ type MakeInstanceFunction = (
160
+ overrides?: Record<string, unknown>,
161
+ ) => EnterpriseLicenseInstance;
162
+
163
+ const makeInstance: MakeInstanceFunction = (
164
+ overrides?: Record<string, unknown>,
165
+ ): EnterpriseLicenseInstance => {
166
+ return {
167
+ id: ObjectID.generate(),
168
+ instanceId: "instance-1",
169
+ host: "oneuptime.acme.internal",
170
+ userCount: 2,
171
+ userEmailHashes: [hashOf("1"), hashOf("2")],
172
+ lastReportedAt: OneUptimeDate.getCurrentDate(),
173
+ oneuptimeVersion: "12.0.19",
174
+ ...overrides,
175
+ } as unknown as EnterpriseLicenseInstance;
176
+ };
177
+
178
+ type GetResponseBodyFunction = () => JSONObject;
179
+
180
+ /*
181
+ * The third argument of the single sendJsonObjectResponse call: the literal
182
+ * body that goes on the wire.
183
+ */
184
+ const getResponseBody: GetResponseBodyFunction = (): JSONObject => {
185
+ const calls: Array<Array<unknown>> = (
186
+ Response.sendJsonObjectResponse as unknown as jest.Mock
187
+ ).mock.calls as Array<Array<unknown>>;
188
+
189
+ expect(calls).toHaveLength(1);
190
+
191
+ return calls[0]![2] as JSONObject;
192
+ };
193
+
194
+ describe("EnterpriseLicenseAPI POST /enterprise-license/report-user-count", () => {
195
+ let mockRequest: OneUptimeRequest;
196
+ let mockResponse: OneUptimeResponse;
197
+ let nextFunction: NextFunction;
198
+
199
+ type CallRouteFunction = (route?: string) => Promise<void>;
200
+
201
+ const callRoute: CallRouteFunction = async (
202
+ route: string = REPORT_ROUTE,
203
+ ): Promise<void> => {
204
+ await mockRouter
205
+ .match("post", route)
206
+ .handlerFunction(mockRequest, mockResponse, nextFunction);
207
+ };
208
+
209
+ beforeEach(() => {
210
+ jest.clearAllMocks();
211
+
212
+ new EnterpriseLicenseAPI();
213
+
214
+ EnterpriseLicenseService.findOneBy = jest
215
+ .fn()
216
+ .mockResolvedValue(makeLicense());
217
+ EnterpriseLicenseService.updateOneById = jest
218
+ .fn()
219
+ .mockResolvedValue(undefined);
220
+
221
+ EnterpriseLicenseInstanceService.findBy = jest.fn().mockResolvedValue([]);
222
+ EnterpriseLicenseInstanceService.findOneBy = jest
223
+ .fn()
224
+ .mockResolvedValue(null);
225
+ EnterpriseLicenseInstanceService.updateOneById = jest
226
+ .fn()
227
+ .mockResolvedValue(undefined);
228
+ EnterpriseLicenseInstanceService.create = jest
229
+ .fn()
230
+ .mockResolvedValue(undefined);
231
+ EnterpriseLicenseInstanceService.countBy = jest
232
+ .fn()
233
+ .mockResolvedValue(new PositiveNumber(0));
234
+
235
+ mockRequest = {
236
+ body: {
237
+ licenseKey: LICENSE_KEY,
238
+ userCount: 2,
239
+ instanceId: "instance-1",
240
+ host: "oneuptime.acme.internal",
241
+ version: "12.0.19",
242
+ userEmailHashes: [hashOf("1"), hashOf("2")],
243
+ masterAdminEmails: ["admin@acme.com"],
244
+ },
245
+ } as unknown as OneUptimeRequest;
246
+
247
+ mockResponse = {
248
+ send: jest.fn(),
249
+ json: jest.fn(),
250
+ status: jest.fn().mockReturnThis(),
251
+ } as unknown as OneUptimeResponse;
252
+
253
+ nextFunction = jest.fn();
254
+ });
255
+
256
+ afterEach(() => {
257
+ jest.restoreAllMocks();
258
+ });
259
+
260
+ describe("the license terms it reports back", () => {
261
+ it("returns the seat limit, which is the field the installation was missing", async () => {
262
+ await callRoute();
263
+
264
+ expect(getResponseBody()["userLimit"]).toBe(150);
265
+ });
266
+
267
+ it("returns null for a license with no seat limit, so the limit can be cleared", async () => {
268
+ EnterpriseLicenseService.findOneBy = jest
269
+ .fn()
270
+ .mockResolvedValue(makeLicense({ userLimit: undefined }));
271
+
272
+ await callRoute();
273
+
274
+ expect(getResponseBody()).toHaveProperty("userLimit", null);
275
+ });
276
+
277
+ it("returns the company name and the expiry, so a renewal reaches the installation", async () => {
278
+ const expiresAt: Date = new Date("2027-01-01T00:00:00.000Z");
279
+
280
+ EnterpriseLicenseService.findOneBy = jest
281
+ .fn()
282
+ .mockResolvedValue(makeLicense({ expiresAt: expiresAt }));
283
+
284
+ await callRoute();
285
+
286
+ const body: JSONObject = getResponseBody();
287
+
288
+ expect(body["companyName"]).toBe("Acme Inc");
289
+ expect(body["expiresAt"]).toBe("2027-01-01T00:00:00.000Z");
290
+ });
291
+
292
+ it("selects the term columns it reports - a trimmed select is how this broke", async () => {
293
+ await callRoute();
294
+
295
+ expect(EnterpriseLicenseService.findOneBy).toHaveBeenCalledWith(
296
+ expect.objectContaining({
297
+ query: { licenseKey: LICENSE_KEY },
298
+ select: expect.objectContaining({
299
+ companyName: true,
300
+ expiresAt: true,
301
+ licenseKey: true,
302
+ userLimit: true,
303
+ isEvaluationLicense: true,
304
+ }),
305
+ }),
306
+ );
307
+ });
308
+
309
+ it("mirrors the evaluation flag", async () => {
310
+ EnterpriseLicenseService.findOneBy = jest
311
+ .fn()
312
+ .mockResolvedValue(makeLicense({ isEvaluationLicense: true }));
313
+
314
+ await callRoute();
315
+
316
+ expect(getResponseBody()["isEvaluationLicense"]).toBe(true);
317
+ });
318
+
319
+ it("reports the usage numbers alongside the terms", async () => {
320
+ await callRoute();
321
+
322
+ const body: JSONObject = getResponseBody();
323
+
324
+ expect(body["currentUserCount"]).toBe(2);
325
+ expect(typeof body["userCountUpdatedAt"]).toBe("string");
326
+ });
327
+ });
328
+
329
+ describe("the token", () => {
330
+ it("mints one for a live license", async () => {
331
+ await callRoute();
332
+
333
+ expect(getResponseBody()["token"]).toBe("signed.jwt.token");
334
+ expect(JSONWebToken.signJsonPayload).toHaveBeenCalledTimes(1);
335
+ });
336
+
337
+ it("signs the current terms into it, not a stale copy", async () => {
338
+ await callRoute();
339
+
340
+ expect(JSONWebToken.signJsonPayload).toHaveBeenCalledWith(
341
+ expect.objectContaining({
342
+ companyName: "Acme Inc",
343
+ licenseKey: LICENSE_KEY,
344
+ userLimit: 150,
345
+ }),
346
+ expect.any(Number),
347
+ );
348
+ });
349
+
350
+ it("withholds it once the license has expired, rather than renewing it daily", async () => {
351
+ EnterpriseLicenseService.findOneBy = jest.fn().mockResolvedValue(
352
+ makeLicense({
353
+ expiresAt: OneUptimeDate.addRemoveDays(
354
+ OneUptimeDate.getCurrentDate(),
355
+ -1,
356
+ ),
357
+ }),
358
+ );
359
+
360
+ await callRoute();
361
+
362
+ expect(getResponseBody()).toHaveProperty("token", null);
363
+ expect(JSONWebToken.signJsonPayload).not.toHaveBeenCalled();
364
+ });
365
+
366
+ it("still answers an expired license in full, because it must keep reporting", async () => {
367
+ const expiredAt: Date = OneUptimeDate.addRemoveDays(
368
+ OneUptimeDate.getCurrentDate(),
369
+ -1,
370
+ );
371
+
372
+ EnterpriseLicenseService.findOneBy = jest
373
+ .fn()
374
+ .mockResolvedValue(makeLicense({ expiresAt: expiredAt }));
375
+
376
+ await callRoute();
377
+
378
+ const body: JSONObject = getResponseBody();
379
+
380
+ /*
381
+ * The expiry notification emails are built from what expired instances
382
+ * keep reporting, so this route deliberately does not reject them the
383
+ * way /validate does. Telling them the truth about the expiry is what
384
+ * makes the installation show itself as expired.
385
+ */
386
+ expect(nextFunction).not.toHaveBeenCalled();
387
+ expect(body["expiresAt"]).toBe(expiredAt.toISOString());
388
+ expect(body["userLimit"]).toBe(150);
389
+ });
390
+
391
+ it("withholds it for a license with no expiry set at all", async () => {
392
+ EnterpriseLicenseService.findOneBy = jest
393
+ .fn()
394
+ .mockResolvedValue(makeLicense({ expiresAt: undefined }));
395
+
396
+ await callRoute();
397
+
398
+ const body: JSONObject = getResponseBody();
399
+
400
+ expect(body).toHaveProperty("token", null);
401
+ expect(body).toHaveProperty("expiresAt", null);
402
+ });
403
+ });
404
+
405
+ describe("input validation", () => {
406
+ it("rejects a missing license key", async () => {
407
+ mockRequest.body["licenseKey"] = undefined;
408
+
409
+ await callRoute();
410
+
411
+ expect(nextFunction).toHaveBeenCalledWith(
412
+ new BadDataException("License key is required"),
413
+ );
414
+ expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
415
+ });
416
+
417
+ it.each([
418
+ ["a missing count", undefined],
419
+ ["a negative count", -1],
420
+ ["a fractional count", 1.5],
421
+ ["a non-numeric count", "many"],
422
+ ])("rejects %s", async (_label: string, value: unknown) => {
423
+ mockRequest.body["userCount"] = value;
424
+
425
+ await callRoute();
426
+
427
+ expect(nextFunction).toHaveBeenCalledWith(
428
+ new BadDataException("userCount must be a non-negative integer"),
429
+ );
430
+ });
431
+
432
+ it("accepts a count of zero", async () => {
433
+ mockRequest.body["userCount"] = 0;
434
+ mockRequest.body["userEmailHashes"] = [];
435
+
436
+ await callRoute();
437
+
438
+ expect(nextFunction).not.toHaveBeenCalled();
439
+ expect(getResponseBody()["currentUserCount"]).toBe(0);
440
+ });
441
+
442
+ it("rejects an unknown license key", async () => {
443
+ EnterpriseLicenseService.findOneBy = jest.fn().mockResolvedValue(null);
444
+
445
+ await callRoute();
446
+
447
+ expect(nextFunction).toHaveBeenCalledWith(
448
+ new BadDataException("License key is invalid"),
449
+ );
450
+ });
451
+
452
+ it("stays unauthenticated - instances report before anyone signs in", () => {
453
+ expect(mockRouter.match("post", REPORT_ROUTE).middlewares).toHaveLength(
454
+ 0,
455
+ );
456
+ });
457
+ });
458
+
459
+ describe("usage accounting", () => {
460
+ it("records this instance's usage against the license", async () => {
461
+ await callRoute();
462
+
463
+ expect(EnterpriseLicenseInstanceService.create).toHaveBeenCalledWith(
464
+ expect.objectContaining({
465
+ data: expect.objectContaining({
466
+ instanceId: "instance-1",
467
+ userCount: 2,
468
+ }),
469
+ }),
470
+ );
471
+ });
472
+
473
+ it("counts a user on two instances once", async () => {
474
+ const shared: string = hashOf("1");
475
+
476
+ EnterpriseLicenseInstanceService.findBy = jest.fn().mockResolvedValue([
477
+ makeInstance({
478
+ instanceId: "instance-1",
479
+ userCount: 2,
480
+ userEmailHashes: [shared, hashOf("2")],
481
+ }),
482
+ makeInstance({
483
+ instanceId: "instance-2",
484
+ userCount: 2,
485
+ userEmailHashes: [shared, hashOf("3")],
486
+ }),
487
+ ]);
488
+
489
+ await callRoute();
490
+
491
+ // Union of {1,2} and {1,3} is three seats, not four.
492
+ expect(getResponseBody()["currentUserCount"]).toBe(3);
493
+ });
494
+
495
+ it("writes the deduplicated count back onto the license row", async () => {
496
+ EnterpriseLicenseInstanceService.findBy = jest
497
+ .fn()
498
+ .mockResolvedValue([makeInstance()]);
499
+
500
+ await callRoute();
501
+
502
+ expect(EnterpriseLicenseService.updateOneById).toHaveBeenCalledWith(
503
+ expect.objectContaining({
504
+ id: LICENSE_ID,
505
+ data: expect.objectContaining({ currentUserCount: 2 }),
506
+ }),
507
+ );
508
+ });
509
+
510
+ it("never writes the seat limit back from a report", async () => {
511
+ /*
512
+ * The limit flows one way only: oneuptime.com to the installation. A
513
+ * report that could raise it would make the license meaningless.
514
+ */
515
+ await callRoute();
516
+
517
+ const updateCall: JSONObject = (
518
+ EnterpriseLicenseService.updateOneById as unknown as jest.Mock
519
+ ).mock.calls[0]![0] as JSONObject;
520
+
521
+ expect(Object.keys(updateCall["data"] as JSONObject)).toEqual([
522
+ "currentUserCount",
523
+ "userCountUpdatedAt",
524
+ ]);
525
+ });
526
+
527
+ it("leaves a decommissioned instance out of the seat count but still lists it", async () => {
528
+ EnterpriseLicenseInstanceService.findBy = jest.fn().mockResolvedValue([
529
+ makeInstance({
530
+ instanceId: "live",
531
+ userCount: 1,
532
+ userEmailHashes: [hashOf("1")],
533
+ }),
534
+ makeInstance({
535
+ instanceId: "retired",
536
+ userCount: 2,
537
+ userEmailHashes: [hashOf("2"), hashOf("3")],
538
+ lastReportedAt: OneUptimeDate.addRemoveDays(
539
+ OneUptimeDate.getCurrentDate(),
540
+ -400,
541
+ ),
542
+ }),
543
+ ]);
544
+
545
+ await callRoute();
546
+
547
+ const body: JSONObject = getResponseBody();
548
+
549
+ expect(body["currentUserCount"]).toBe(1);
550
+ expect(body["instances"]).toHaveLength(2);
551
+ });
552
+
553
+ it("counts users an instance could not send hashes for, so a huge install is not undercounted", async () => {
554
+ /*
555
+ * Hash lists are capped, so an instance may report more users than
556
+ * hashes. The overflow cannot be deduplicated against anyone else and is
557
+ * counted as-is.
558
+ */
559
+ EnterpriseLicenseInstanceService.findBy = jest.fn().mockResolvedValue([
560
+ makeInstance({
561
+ instanceId: "big",
562
+ userCount: 10,
563
+ userEmailHashes: [hashOf("1"), hashOf("2")],
564
+ }),
565
+ ]);
566
+
567
+ await callRoute();
568
+
569
+ expect(getResponseBody()["currentUserCount"]).toBe(10);
570
+ });
571
+
572
+ it("does not let a legacy report with no instance id stomp the deduplicated count", async () => {
573
+ mockRequest.body["instanceId"] = undefined;
574
+ mockRequest.body["userCount"] = 99;
575
+
576
+ EnterpriseLicenseInstanceService.findBy = jest
577
+ .fn()
578
+ .mockResolvedValue([makeInstance()]);
579
+
580
+ await callRoute();
581
+
582
+ expect(getResponseBody()["currentUserCount"]).toBe(2);
583
+ expect(EnterpriseLicenseService.updateOneById).not.toHaveBeenCalled();
584
+ });
585
+ });
586
+ });
587
+
588
+ describe("EnterpriseLicenseAPI POST /enterprise-license/validate", () => {
589
+ let mockRequest: OneUptimeRequest;
590
+ let mockResponse: OneUptimeResponse;
591
+ let nextFunction: NextFunction;
592
+
593
+ beforeEach(() => {
594
+ jest.clearAllMocks();
595
+
596
+ new EnterpriseLicenseAPI();
597
+
598
+ EnterpriseLicenseService.findOneBy = jest
599
+ .fn()
600
+ .mockResolvedValue(makeLicense());
601
+ EnterpriseLicenseInstanceService.findBy = jest.fn().mockResolvedValue([]);
602
+ EnterpriseLicenseInstanceService.findOneBy = jest
603
+ .fn()
604
+ .mockResolvedValue(null);
605
+ EnterpriseLicenseInstanceService.create = jest
606
+ .fn()
607
+ .mockResolvedValue(undefined);
608
+ EnterpriseLicenseInstanceService.countBy = jest
609
+ .fn()
610
+ .mockResolvedValue(new PositiveNumber(0));
611
+
612
+ mockRequest = {
613
+ body: {
614
+ licenseKey: LICENSE_KEY,
615
+ instanceId: "instance-1",
616
+ host: "oneuptime.acme.internal",
617
+ version: "12.0.19",
618
+ },
619
+ } as unknown as OneUptimeRequest;
620
+
621
+ mockResponse = {
622
+ send: jest.fn(),
623
+ json: jest.fn(),
624
+ status: jest.fn().mockReturnThis(),
625
+ } as unknown as OneUptimeResponse;
626
+
627
+ nextFunction = jest.fn();
628
+ });
629
+
630
+ afterEach(() => {
631
+ jest.restoreAllMocks();
632
+ });
633
+
634
+ it("still answers with the same terms after the shared refactor", async () => {
635
+ const expiresAt: Date = OneUptimeDate.addRemoveDays(
636
+ OneUptimeDate.getCurrentDate(),
637
+ 90,
638
+ );
639
+
640
+ EnterpriseLicenseService.findOneBy = jest
641
+ .fn()
642
+ .mockResolvedValue(makeLicense({ expiresAt: expiresAt }));
643
+
644
+ await mockRouter
645
+ .match("post", VALIDATE_ROUTE)
646
+ .handlerFunction(mockRequest, mockResponse, nextFunction);
647
+
648
+ expect(nextFunction).not.toHaveBeenCalled();
649
+
650
+ const body: JSONObject = getResponseBody();
651
+
652
+ expect(body["companyName"]).toBe("Acme Inc");
653
+ expect(body["licenseKey"]).toBe(LICENSE_KEY);
654
+ expect(body["userLimit"]).toBe(150);
655
+ expect(body["expiresAt"]).toBe(expiresAt.toISOString());
656
+ expect(body["token"]).toBe("signed.jwt.token");
657
+ });
658
+
659
+ it("keeps rejecting an expired license, unlike the report route", async () => {
660
+ EnterpriseLicenseService.findOneBy = jest.fn().mockResolvedValue(
661
+ makeLicense({
662
+ expiresAt: OneUptimeDate.addRemoveDays(
663
+ OneUptimeDate.getCurrentDate(),
664
+ -1,
665
+ ),
666
+ }),
667
+ );
668
+
669
+ await mockRouter
670
+ .match("post", VALIDATE_ROUTE)
671
+ .handlerFunction(mockRequest, mockResponse, nextFunction);
672
+
673
+ expect(nextFunction).toHaveBeenCalledWith(
674
+ new BadDataException("License key has expired"),
675
+ );
676
+ });
677
+ });
678
+
679
+ describe("the contract between the two halves of the sync", () => {
680
+ let nextFunction: NextFunction;
681
+
682
+ beforeEach(() => {
683
+ jest.clearAllMocks();
684
+
685
+ new EnterpriseLicenseAPI();
686
+
687
+ EnterpriseLicenseService.findOneBy = jest
688
+ .fn()
689
+ .mockResolvedValue(makeLicense());
690
+ EnterpriseLicenseService.updateOneById = jest
691
+ .fn()
692
+ .mockResolvedValue(undefined);
693
+ EnterpriseLicenseInstanceService.findBy = jest
694
+ .fn()
695
+ .mockResolvedValue([makeInstance()]);
696
+ EnterpriseLicenseInstanceService.findOneBy = jest
697
+ .fn()
698
+ .mockResolvedValue(null);
699
+ EnterpriseLicenseInstanceService.create = jest
700
+ .fn()
701
+ .mockResolvedValue(undefined);
702
+ EnterpriseLicenseInstanceService.countBy = jest
703
+ .fn()
704
+ .mockResolvedValue(new PositiveNumber(0));
705
+
706
+ nextFunction = jest.fn();
707
+ });
708
+
709
+ afterEach(() => {
710
+ jest.restoreAllMocks();
711
+ });
712
+
713
+ it("lands every license term when the real response body is fed to the real mapper", async () => {
714
+ /*
715
+ * The end of the bug, asserted end to end: the literal object the license
716
+ * server puts on the wire, through the mapper the installation runs. If
717
+ * either side renames a field, this fails and nothing else has to.
718
+ */
719
+ await mockRouter.match("post", REPORT_ROUTE).handlerFunction(
720
+ {
721
+ body: {
722
+ licenseKey: LICENSE_KEY,
723
+ userCount: 2,
724
+ instanceId: "instance-1",
725
+ host: "oneuptime.acme.internal",
726
+ version: "12.0.19",
727
+ userEmailHashes: [hashOf("1"), hashOf("2")],
728
+ masterAdminEmails: ["admin@acme.com"],
729
+ },
730
+ } as unknown as OneUptimeRequest,
731
+ {
732
+ send: jest.fn(),
733
+ json: jest.fn(),
734
+ status: jest.fn().mockReturnThis(),
735
+ } as unknown as OneUptimeResponse,
736
+ nextFunction,
737
+ );
738
+
739
+ expect(nextFunction).not.toHaveBeenCalled();
740
+
741
+ const wireBody: JSONObject = getResponseBody();
742
+
743
+ const result: EnterpriseLicenseSyncResult =
744
+ EnterpriseLicenseSyncUtil.getGlobalConfigUpdateFromLicenseResponse({
745
+ payload: wireBody,
746
+ reportedAt: OneUptimeDate.getCurrentDate(),
747
+ });
748
+
749
+ expect(result.warnings).toEqual([]);
750
+ expect(result.updateData.enterpriseLicenseUserLimit).toBe(150);
751
+ expect(result.updateData.enterpriseLicenseCurrentUserCount).toBe(2);
752
+ expect(result.updateData.enterpriseCompanyName).toBe("Acme Inc");
753
+ expect(result.updateData.enterpriseLicenseIsEvaluation).toBe(false);
754
+ expect(result.updateData.enterpriseLicenseToken).toBe("signed.jwt.token");
755
+ expect(result.updateData.enterpriseLicenseExpiresAt).toBeInstanceOf(Date);
756
+ expect(result.updateData.enterpriseLicenseInstances).toHaveLength(1);
757
+
758
+ // The installation never adopts a key handed to it by a response.
759
+ expect(Object.keys(result.updateData)).not.toContain(
760
+ "enterpriseLicenseKey",
761
+ );
762
+ });
763
+ });