@oneuptime/common 12.0.17 → 12.0.19

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 (220) hide show
  1. package/Models/DatabaseModels/EnterpriseLicense.ts +23 -0
  2. package/Models/DatabaseModels/Index.ts +4 -2
  3. package/Models/DatabaseModels/Monitor.ts +39 -9
  4. package/Models/DatabaseModels/UserMicrosoftTeams.ts +344 -0
  5. package/Models/DatabaseModels/UserNotificationRule.ts +144 -8
  6. package/Models/DatabaseModels/UserNotificationSetting.ts +34 -0
  7. package/Models/DatabaseModels/UserOnCallLogTimeline.ts +95 -0
  8. package/Models/DatabaseModels/UserSlack.ts +341 -0
  9. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +9 -0
  10. package/Server/API/UserMicrosoftTeamsAPI.ts +127 -0
  11. package/Server/API/UserSlackAPI.ts +125 -0
  12. package/Server/EnvironmentConfig.ts +12 -107
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1788700000000-RemoveMarketingConversionUploadState.ts +33 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1788800000000-DropMarketingConversionAddEnterpriseLicenseEmail.ts +51 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +117 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +197 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  18. package/Server/Infrastructure/Queue.ts +7 -0
  19. package/Server/Services/BillingService.ts +8 -0
  20. package/Server/Services/EnterpriseLicenseService.ts +60 -0
  21. package/Server/Services/Index.ts +6 -2
  22. package/Server/Services/LlmLogService.ts +25 -9
  23. package/Server/Services/OnCallReadinessService.ts +86 -2
  24. package/Server/Services/ProjectService.ts +67 -0
  25. package/Server/Services/UserMicrosoftTeamsService.ts +208 -0
  26. package/Server/Services/UserNotificationMethodAdminService.ts +167 -5
  27. package/Server/Services/UserNotificationRuleAdminService.ts +40 -6
  28. package/Server/Services/UserNotificationRuleService.ts +589 -11
  29. package/Server/Services/UserNotificationSettingService.ts +138 -0
  30. package/Server/Services/UserService.ts +28 -0
  31. package/Server/Services/UserSlackService.ts +218 -0
  32. package/Server/Services/WorkspaceProjectAuthTokenService.ts +67 -1
  33. package/Server/Services/WorkspaceUserAuthTokenService.ts +73 -0
  34. package/Server/Services/WorkspaceUserNotificationService.ts +190 -0
  35. package/Server/Utils/Marketing/MarketingEventUtil.ts +145 -0
  36. package/Server/Utils/Marketing/MarketingEventWebhook.ts +114 -0
  37. package/Server/Utils/Monitor/MonitorLogUtil.ts +14 -1
  38. package/Server/Utils/Monitor/MonitorPayloadRedaction.ts +321 -0
  39. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +191 -4
  40. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +22 -14
  41. package/Tests/App/Dashboard/AdminUserNotificationMethodsPage.test.tsx +22 -11
  42. package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +37 -1
  43. package/Tests/App/Dashboard/OnCallRulesTable.test.tsx +18 -10
  44. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +17 -0
  45. package/Tests/Models/DatabaseModels/DomainRoleTierCoverage.test.ts +27 -1
  46. package/Tests/Models/DatabaseModels/MonitorSecretKeyColumnAccessControl.test.ts +214 -0
  47. package/Tests/Server/API/OnCallReadinessAPI.test.ts +145 -26
  48. package/Tests/Server/API/UserNotificationMethodAdminAPI.test.ts +2 -2
  49. package/Tests/Server/Services/AIServiceDailyBudget.test.ts +11 -1
  50. package/Tests/Server/Services/AdminRuleEditGuards.test.ts +22 -8
  51. package/Tests/Server/Services/BillingService.test.ts +20 -0
  52. package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +18 -2
  53. package/Tests/Server/Services/EpisodeRuleSeverityRepair.test.ts +5 -1
  54. package/Tests/Server/Services/LlmLogServiceTokenAggregateParams.test.ts +640 -0
  55. package/Tests/Server/Services/NotificationChannelEventCoverage.test.ts +246 -22
  56. package/Tests/Server/Services/NotificationDeletionImpact.test.ts +136 -8
  57. package/Tests/Server/Services/OnCallNotificationFallback.test.ts +191 -8
  58. package/Tests/Server/Services/OnCallReadinessService.test.ts +343 -18
  59. package/Tests/Server/Services/SeverityRuleBackfill.test.ts +5 -1
  60. package/Tests/Server/Services/UserMicrosoftTeamsService.test.ts +343 -0
  61. package/Tests/Server/Services/UserNotificationMethodAdminService.test.ts +209 -8
  62. package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +22 -6
  63. package/Tests/Server/Services/UserNotificationRuleDefaultCreation.test.ts +12 -6
  64. package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +37 -3
  65. package/Tests/Server/Services/UserNotificationRuleWorkspaceDelivery.test.ts +669 -0
  66. package/Tests/Server/Services/UserNotificationSettingWorkspaceChannels.test.ts +410 -0
  67. package/Tests/Server/Services/UserSlackService.test.ts +407 -0
  68. package/Tests/Server/Services/WorkspaceProjectAuthTokenDisconnectCascade.test.ts +224 -0
  69. package/Tests/Server/Services/WorkspaceUserAuthTokenNotificationMethodCascade.test.ts +189 -0
  70. package/Tests/Server/Services/WorkspaceUserNotificationService.test.ts +496 -0
  71. package/Tests/Server/Types/Database/Permissions/AdminNotificationRuleAccess.test.ts +30 -17
  72. package/Tests/Server/Types/Database/Permissions/CreateOwnershipScoping.test.ts +8 -3
  73. package/Tests/Server/Types/Database/Permissions/OwnerOnlyColumns.test.ts +72 -3
  74. package/Tests/Server/Types/Database/Permissions/UserNotificationRuleScoping.test.ts +22 -18
  75. package/Tests/Server/Types/Database/Permissions/WorkspaceMethodStampedColumnCreate.test.ts +178 -0
  76. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +259 -0
  77. package/Tests/Server/Utils/Marketing/MarketingEventWebhook.test.ts +257 -0
  78. package/Tests/Server/Utils/Monitor/MonitorLogUtilRedaction.test.ts +300 -0
  79. package/Tests/Server/Utils/Monitor/MonitorPayloadRedaction.test.ts +381 -0
  80. package/Tests/Server/Utils/Runbook/RunbookExecutePermission.test.ts +228 -0
  81. package/Tests/Server/Utils/SessionReplay/SessionReplayErasureTombstone.test.ts +189 -0
  82. package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +205 -0
  83. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +275 -0
  84. package/Tests/Server/Utils/Telemetry/TelemetryContext.test.ts +279 -0
  85. package/Tests/Server/Utils/Workspace/MicrosoftTeamsDirectMessage.test.ts +454 -0
  86. package/Tests/Types/Kubernetes/KubernetesObjectParsers.test.ts +794 -0
  87. package/Tests/Types/Kubernetes/KubernetesRightSizing.test.ts +53 -0
  88. package/Tests/Types/Measurement/MeasurementAggregationType.test.ts +100 -0
  89. package/Tests/Types/Monitor/MonitorStepConfigHelpers.test.ts +114 -0
  90. package/Tests/Types/Workspace/WorkspaceType.test.ts +53 -0
  91. package/Tests/UI/Utils/NotificationMethodUtil.test.ts +137 -9
  92. package/Tests/UI/Utils/PermissionGate.test.ts +105 -0
  93. package/Tests/Utils/Dashboard/Components/DashboardComponentDefaults.test.ts +257 -0
  94. package/Types/Billing/PayAsYouGoPricing.ts +1 -1
  95. package/Types/Marketing/MarketingEvent.ts +97 -0
  96. package/UI/Utils/NotificationMethodUtil.ts +41 -1
  97. package/UI/Utils/PermissionGate.ts +56 -0
  98. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +24 -0
  99. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/Index.js +4 -2
  101. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/Monitor.js +39 -9
  103. package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/UserMicrosoftTeams.js +363 -0
  105. package/build/dist/Models/DatabaseModels/UserMicrosoftTeams.js.map +1 -0
  106. package/build/dist/Models/DatabaseModels/UserNotificationRule.js +145 -8
  107. package/build/dist/Models/DatabaseModels/UserNotificationRule.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/UserNotificationSetting.js +38 -0
  109. package/build/dist/Models/DatabaseModels/UserNotificationSetting.js.map +1 -1
  110. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +96 -0
  111. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
  112. package/build/dist/Models/DatabaseModels/UserSlack.js +361 -0
  113. package/build/dist/Models/DatabaseModels/UserSlack.js.map +1 -0
  114. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  115. package/build/dist/Server/API/UserMicrosoftTeamsAPI.js +82 -0
  116. package/build/dist/Server/API/UserMicrosoftTeamsAPI.js.map +1 -0
  117. package/build/dist/Server/API/UserSlackAPI.js +81 -0
  118. package/build/dist/Server/API/UserSlackAPI.js.map +1 -0
  119. package/build/dist/Server/EnvironmentConfig.js +10 -70
  120. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788700000000-RemoveMarketingConversionUploadState.js +26 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788700000000-RemoveMarketingConversionUploadState.js.map +1 -0
  123. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788800000000-DropMarketingConversionAddEnterpriseLicenseEmail.js +37 -0
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788800000000-DropMarketingConversionAddEnterpriseLicenseEmail.js.map +1 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +106 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +1 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +78 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  131. package/build/dist/Server/Infrastructure/Queue.js +7 -0
  132. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  133. package/build/dist/Server/Services/BillingService.js +6 -0
  134. package/build/dist/Server/Services/BillingService.js.map +1 -1
  135. package/build/dist/Server/Services/EnterpriseLicenseService.js +65 -0
  136. package/build/dist/Server/Services/EnterpriseLicenseService.js.map +1 -1
  137. package/build/dist/Server/Services/Index.js +6 -2
  138. package/build/dist/Server/Services/Index.js.map +1 -1
  139. package/build/dist/Server/Services/LlmLogService.js +25 -9
  140. package/build/dist/Server/Services/LlmLogService.js.map +1 -1
  141. package/build/dist/Server/Services/OnCallReadinessService.js +74 -2
  142. package/build/dist/Server/Services/OnCallReadinessService.js.map +1 -1
  143. package/build/dist/Server/Services/ProjectService.js +56 -0
  144. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  145. package/build/dist/Server/Services/UserMicrosoftTeamsService.js +183 -0
  146. package/build/dist/Server/Services/UserMicrosoftTeamsService.js.map +1 -0
  147. package/build/dist/Server/Services/UserNotificationMethodAdminService.js +138 -2
  148. package/build/dist/Server/Services/UserNotificationMethodAdminService.js.map +1 -1
  149. package/build/dist/Server/Services/UserNotificationRuleAdminService.js +32 -4
  150. package/build/dist/Server/Services/UserNotificationRuleAdminService.js.map +1 -1
  151. package/build/dist/Server/Services/UserNotificationRuleService.js +499 -67
  152. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  153. package/build/dist/Server/Services/UserNotificationSettingService.js +118 -0
  154. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  155. package/build/dist/Server/Services/UserService.js +26 -1
  156. package/build/dist/Server/Services/UserService.js.map +1 -1
  157. package/build/dist/Server/Services/UserSlackService.js +195 -0
  158. package/build/dist/Server/Services/UserSlackService.js.map +1 -0
  159. package/build/dist/Server/Services/WorkspaceProjectAuthTokenService.js +64 -1
  160. package/build/dist/Server/Services/WorkspaceProjectAuthTokenService.js.map +1 -1
  161. package/build/dist/Server/Services/WorkspaceUserAuthTokenService.js +70 -0
  162. package/build/dist/Server/Services/WorkspaceUserAuthTokenService.js.map +1 -1
  163. package/build/dist/Server/Services/WorkspaceUserNotificationService.js +129 -0
  164. package/build/dist/Server/Services/WorkspaceUserNotificationService.js.map +1 -0
  165. package/build/dist/Server/Utils/Marketing/MarketingEventUtil.js +94 -0
  166. package/build/dist/Server/Utils/Marketing/MarketingEventUtil.js.map +1 -0
  167. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +93 -0
  168. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -0
  169. package/build/dist/Server/Utils/Monitor/MonitorLogUtil.js +12 -1
  170. package/build/dist/Server/Utils/Monitor/MonitorLogUtil.js.map +1 -1
  171. package/build/dist/Server/Utils/Monitor/MonitorPayloadRedaction.js +233 -0
  172. package/build/dist/Server/Utils/Monitor/MonitorPayloadRedaction.js.map +1 -0
  173. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +141 -4
  174. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  175. package/build/dist/Types/Billing/PayAsYouGoPricing.js +1 -1
  176. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -1
  177. package/build/dist/Types/Marketing/MarketingEvent.js +43 -0
  178. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -0
  179. package/build/dist/UI/Utils/NotificationMethodUtil.js +33 -1
  180. package/build/dist/UI/Utils/NotificationMethodUtil.js.map +1 -1
  181. package/build/dist/UI/Utils/PermissionGate.js +32 -0
  182. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  183. package/package.json +1 -1
  184. package/Models/DatabaseModels/MarketingConversion.ts +0 -410
  185. package/Server/Services/MarketingConversionService.ts +0 -10
  186. package/Server/Utils/Marketing/ConversionUploadProvider.ts +0 -215
  187. package/Server/Utils/Marketing/ConversionUploadProviders.ts +0 -22
  188. package/Server/Utils/Marketing/Providers/GoogleAds.ts +0 -333
  189. package/Server/Utils/Marketing/Providers/LinkedIn.ts +0 -185
  190. package/Server/Utils/Marketing/Providers/Meta.ts +0 -182
  191. package/Server/Utils/Marketing/Providers/MicrosoftAds.ts +0 -223
  192. package/Server/Utils/Marketing/Providers/Reddit.ts +0 -208
  193. package/Tests/Server/Utils/Marketing/AdUploadableConversionTypes.test.ts +0 -315
  194. package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +0 -300
  195. package/Tests/Server/Utils/Marketing/GoogleAds.test.ts +0 -592
  196. package/Tests/Server/Utils/Marketing/LinkedIn.test.ts +0 -282
  197. package/Tests/Server/Utils/Marketing/Meta.test.ts +0 -304
  198. package/Tests/Server/Utils/Marketing/MicrosoftAds.test.ts +0 -263
  199. package/Tests/Server/Utils/Marketing/Reddit.test.ts +0 -259
  200. package/Types/Marketing/MarketingConversion.ts +0 -53
  201. package/build/dist/Models/DatabaseModels/MarketingConversion.js +0 -445
  202. package/build/dist/Models/DatabaseModels/MarketingConversion.js.map +0 -1
  203. package/build/dist/Server/Services/MarketingConversionService.js +0 -9
  204. package/build/dist/Server/Services/MarketingConversionService.js.map +0 -1
  205. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js +0 -93
  206. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js.map +0 -1
  207. package/build/dist/Server/Utils/Marketing/ConversionUploadProviders.js +0 -20
  208. package/build/dist/Server/Utils/Marketing/ConversionUploadProviders.js.map +0 -1
  209. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js +0 -221
  210. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js.map +0 -1
  211. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js +0 -137
  212. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js.map +0 -1
  213. package/build/dist/Server/Utils/Marketing/Providers/Meta.js +0 -133
  214. package/build/dist/Server/Utils/Marketing/Providers/Meta.js.map +0 -1
  215. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js +0 -143
  216. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js.map +0 -1
  217. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js +0 -145
  218. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js.map +0 -1
  219. package/build/dist/Types/Marketing/MarketingConversion.js +0 -52
  220. package/build/dist/Types/Marketing/MarketingConversion.js.map +0 -1
@@ -0,0 +1,259 @@
1
+ import { beforeEach, describe, expect, test } from "@jest/globals";
2
+
3
+ let mockConfigured: boolean = true;
4
+ let mockMisconfigured: boolean = false;
5
+
6
+ jest.mock("../../../../Server/Utils/Marketing/MarketingEventWebhook", () => {
7
+ return {
8
+ __esModule: true,
9
+ default: {
10
+ isConfigured: (): boolean => {
11
+ return mockConfigured;
12
+ },
13
+ isMisconfigured: (): boolean => {
14
+ return mockMisconfigured;
15
+ },
16
+ },
17
+ };
18
+ });
19
+
20
+ jest.mock("../../../../Server/Utils/Logger", () => {
21
+ return {
22
+ __esModule: true,
23
+ default: { debug: jest.fn(), error: jest.fn(), warn: jest.fn() },
24
+ };
25
+ });
26
+
27
+ const mockAddJob: jest.Mock = jest.fn();
28
+
29
+ jest.mock("../../../../Server/Infrastructure/Queue", () => {
30
+ return {
31
+ __esModule: true,
32
+ QueueName: { MarketingEvent: "MarketingEvent" },
33
+ default: {
34
+ addJob: (...args: Array<unknown>): unknown => {
35
+ return mockAddJob(...args);
36
+ },
37
+ },
38
+ };
39
+ });
40
+
41
+ import MarketingEventUtil from "../../../../Server/Utils/Marketing/MarketingEventUtil";
42
+ import Attribution from "../../../../Server/Utils/Attribution";
43
+ import {
44
+ MarketingEvent,
45
+ MarketingEventType,
46
+ } from "../../../../Types/Marketing/MarketingEvent";
47
+
48
+ describe("MarketingEventUtil", () => {
49
+ beforeEach(() => {
50
+ jest.clearAllMocks();
51
+ mockConfigured = true;
52
+ mockMisconfigured = false;
53
+ mockAddJob.mockResolvedValue({} as never);
54
+ });
55
+
56
+ describe("buildEvent", () => {
57
+ test("stamps the schema version so a receiver can branch on it", () => {
58
+ const event: MarketingEvent = MarketingEventUtil.buildEvent({
59
+ eventType: MarketingEventType.SignUp,
60
+ eventId: "sign_up:u1",
61
+ occurredAt: new Date("2026-08-24T10:00:00.000Z"),
62
+ });
63
+
64
+ expect(event.schemaVersion).toBe(1);
65
+ expect(event.eventType).toBe("sign_up");
66
+ expect(event.eventId).toBe("sign_up:u1");
67
+ expect(event.occurredAt).toBe("2026-08-24T10:00:00.000Z");
68
+ });
69
+
70
+ /*
71
+ * Both identity forms travel together: the address for a direct CRM join
72
+ * and the digest for anywhere that wants one. The digest must match what
73
+ * every other part of OneUptime produces, or the join it exists for fails.
74
+ */
75
+ test("carries the address and a matching SHA-256", () => {
76
+ const event: MarketingEvent = MarketingEventUtil.buildEvent({
77
+ eventType: MarketingEventType.MeetingBooked,
78
+ eventId: "meeting_booked:b1",
79
+ occurredAt: new Date(),
80
+ email: "Buyer@Example.com",
81
+ });
82
+
83
+ expect(event.email).toBe("Buyer@Example.com");
84
+ expect(event.emailHash).toBe(Attribution.hashEmail("Buyer@Example.com"));
85
+ // Normalisation is case-insensitive, so a differently cased address joins.
86
+ expect(event.emailHash).toBe(Attribution.hashEmail("buyer@example.com"));
87
+ });
88
+
89
+ test("omits both identity fields when there is no email", () => {
90
+ const event: MarketingEvent = MarketingEventUtil.buildEvent({
91
+ eventType: MarketingEventType.EnterpriseLicenseIssued,
92
+ eventId: "enterprise_license_issued:l1",
93
+ occurredAt: new Date(),
94
+ });
95
+
96
+ expect(event.email).toBeUndefined();
97
+ expect(event.emailHash).toBeUndefined();
98
+ });
99
+
100
+ test("copies every attribution column off the source row", () => {
101
+ const event: MarketingEvent = MarketingEventUtil.buildEvent({
102
+ eventType: MarketingEventType.SignUp,
103
+ eventId: "sign_up:u1",
104
+ occurredAt: new Date(),
105
+ attributionSource: {
106
+ utmSource: "google",
107
+ utmMedium: "cpc",
108
+ utmCampaign: "brand",
109
+ utmTerm: "oneuptime",
110
+ utmContent: "hero-b",
111
+ utmUrl: "https://oneuptime.com/?gclid=x",
112
+ clickIds: { gclid: "x" },
113
+ firstTouchAttribution: { utmSource: "linkedin" },
114
+ },
115
+ });
116
+
117
+ expect(event.attribution).toEqual({
118
+ utmSource: "google",
119
+ utmMedium: "cpc",
120
+ utmCampaign: "brand",
121
+ utmTerm: "oneuptime",
122
+ utmContent: "hero-b",
123
+ utmUrl: "https://oneuptime.com/?gclid=x",
124
+ clickIds: { gclid: "x" },
125
+ firstTouch: { utmSource: "linkedin" },
126
+ });
127
+ });
128
+
129
+ /*
130
+ * A conversion carrying no attribution is still a conversion. The two
131
+ * container fields default to empty objects rather than undefined so a
132
+ * receiver can always read .clickIds without a guard.
133
+ */
134
+ test("always gives attribution containers, even with no source", () => {
135
+ const event: MarketingEvent = MarketingEventUtil.buildEvent({
136
+ eventType: MarketingEventType.SignUp,
137
+ eventId: "sign_up:u1",
138
+ occurredAt: new Date(),
139
+ });
140
+
141
+ expect(event.attribution.clickIds).toEqual({});
142
+ expect(event.attribution.firstTouch).toEqual({});
143
+ expect(event.data).toEqual({});
144
+ });
145
+ });
146
+
147
+ describe("emit", () => {
148
+ test("queues the event under its own id with retries", async () => {
149
+ const event: MarketingEvent = MarketingEventUtil.buildEvent({
150
+ eventType: MarketingEventType.SignUp,
151
+ eventId: "sign_up:u1",
152
+ occurredAt: new Date(),
153
+ });
154
+
155
+ await MarketingEventUtil.emit(event);
156
+
157
+ expect(mockAddJob).toHaveBeenCalledTimes(1);
158
+
159
+ const call: Array<unknown> = mockAddJob.mock.calls[0] as Array<unknown>;
160
+
161
+ expect(call[0]).toBe("MarketingEvent");
162
+ expect(call[1]).toBe("sign_up:u1");
163
+ expect(call[2]).toBe("sign_up");
164
+ expect(call[3]).toEqual(event);
165
+ expect(call[4]).toEqual(
166
+ expect.objectContaining({ attempts: 5, backoffDelayInMs: 30_000 }),
167
+ );
168
+ });
169
+
170
+ test("does nothing when no endpoint is configured", async () => {
171
+ mockConfigured = false;
172
+
173
+ await MarketingEventUtil.emit(
174
+ MarketingEventUtil.buildEvent({
175
+ eventType: MarketingEventType.SignUp,
176
+ eventId: "sign_up:u1",
177
+ occurredAt: new Date(),
178
+ }),
179
+ );
180
+
181
+ expect(mockAddJob).not.toHaveBeenCalled();
182
+ });
183
+
184
+ /*
185
+ * A URL with no secret must not silently behave like "not configured" —
186
+ * the operator meant to send these somewhere and needs to be told why
187
+ * nothing arrived.
188
+ */
189
+ test("refuses to queue when a url is set without a secret", async () => {
190
+ mockConfigured = false;
191
+ mockMisconfigured = true;
192
+
193
+ await MarketingEventUtil.emit(
194
+ MarketingEventUtil.buildEvent({
195
+ eventType: MarketingEventType.SignUp,
196
+ eventId: "sign_up:u1",
197
+ occurredAt: new Date(),
198
+ }),
199
+ );
200
+
201
+ expect(mockAddJob).not.toHaveBeenCalled();
202
+ });
203
+
204
+ test("swallows a queue failure rather than failing its caller", async () => {
205
+ mockAddJob.mockRejectedValue(new Error("redis down") as never);
206
+
207
+ await expect(
208
+ MarketingEventUtil.emit(
209
+ MarketingEventUtil.buildEvent({
210
+ eventType: MarketingEventType.SignUp,
211
+ eventId: "sign_up:u1",
212
+ occurredAt: new Date(),
213
+ }),
214
+ ),
215
+ ).resolves.toBeUndefined();
216
+ });
217
+ });
218
+
219
+ /*
220
+ * Every caller is a commercial transaction that has already succeeded — a
221
+ * user created, a plan changed, a booking verified. Emitting must never be
222
+ * able to turn one of those into a failure, on either the synchronous or the
223
+ * asynchronous path.
224
+ */
225
+ describe("emitInBackground", () => {
226
+ test("never throws when the queue rejects", async () => {
227
+ mockAddJob.mockRejectedValue(new Error("redis down") as never);
228
+
229
+ expect(() => {
230
+ MarketingEventUtil.emitInBackground(
231
+ MarketingEventUtil.buildEvent({
232
+ eventType: MarketingEventType.SignUp,
233
+ eventId: "sign_up:u1",
234
+ occurredAt: new Date(),
235
+ }),
236
+ );
237
+ }).not.toThrow();
238
+
239
+ // Let the swallowed rejection settle so it cannot surface as unhandled.
240
+ await Promise.resolve();
241
+ });
242
+
243
+ test("never throws when the queue throws synchronously", () => {
244
+ mockAddJob.mockImplementation((): never => {
245
+ throw new Error("boom");
246
+ });
247
+
248
+ expect(() => {
249
+ MarketingEventUtil.emitInBackground(
250
+ MarketingEventUtil.buildEvent({
251
+ eventType: MarketingEventType.SignUp,
252
+ eventId: "sign_up:u1",
253
+ occurredAt: new Date(),
254
+ }),
255
+ );
256
+ }).not.toThrow();
257
+ });
258
+ });
259
+ });
@@ -0,0 +1,257 @@
1
+ import crypto from "crypto";
2
+ import { beforeEach, describe, expect, test } from "@jest/globals";
3
+
4
+ let mockUrl: string = "";
5
+ let mockSecret: string = "";
6
+
7
+ jest.mock("../../../../Server/EnvironmentConfig", () => {
8
+ return {
9
+ __esModule: true,
10
+ get MarketingWebhookUrl(): string {
11
+ return mockUrl;
12
+ },
13
+ get MarketingWebhookSecret(): string {
14
+ return mockSecret;
15
+ },
16
+ };
17
+ });
18
+
19
+ jest.mock("../../../../Server/Utils/Logger", () => {
20
+ return {
21
+ __esModule: true,
22
+ default: { debug: jest.fn(), error: jest.fn(), warn: jest.fn() },
23
+ };
24
+ });
25
+
26
+ const mockPost: jest.Mock = jest.fn();
27
+
28
+ jest.mock("axios", () => {
29
+ class MockAxiosError extends Error {
30
+ public response?: { status: number; data: unknown } | undefined;
31
+ }
32
+
33
+ return {
34
+ __esModule: true,
35
+ default: {
36
+ post: (...args: Array<unknown>): unknown => {
37
+ return mockPost(...args);
38
+ },
39
+ },
40
+ AxiosError: MockAxiosError,
41
+ };
42
+ });
43
+
44
+ import MarketingEventWebhook from "../../../../Server/Utils/Marketing/MarketingEventWebhook";
45
+ import {
46
+ MarketingEvent,
47
+ MarketingEventType,
48
+ } from "../../../../Types/Marketing/MarketingEvent";
49
+
50
+ const URL: string = "https://receiver.example.com/oneuptime";
51
+ const SECRET: string = "test-secret-not-for-production";
52
+
53
+ type MakeEventFunction = () => MarketingEvent;
54
+
55
+ const makeEvent: MakeEventFunction = (): MarketingEvent => {
56
+ return {
57
+ schemaVersion: 1,
58
+ eventId: "sign_up:user-1",
59
+ eventType: MarketingEventType.SignUp,
60
+ occurredAt: "2026-08-24T10:00:00.000Z",
61
+ email: "buyer@example.com",
62
+ emailHash: "hash",
63
+ attribution: { clickIds: {}, firstTouch: {} },
64
+ data: {},
65
+ };
66
+ };
67
+
68
+ type PostCallFunction = () => {
69
+ url: string;
70
+ body: string;
71
+ config: { headers: Record<string, string> };
72
+ };
73
+
74
+ const postCall: PostCallFunction = (): {
75
+ url: string;
76
+ body: string;
77
+ config: { headers: Record<string, string> };
78
+ } => {
79
+ const call: Array<unknown> = mockPost.mock.calls[0] as Array<unknown>;
80
+
81
+ return {
82
+ url: call[0] as string,
83
+ body: call[1] as string,
84
+ config: call[2] as { headers: Record<string, string> },
85
+ };
86
+ };
87
+
88
+ describe("MarketingEventWebhook", () => {
89
+ beforeEach(() => {
90
+ jest.clearAllMocks();
91
+ mockUrl = URL;
92
+ mockSecret = SECRET;
93
+ mockPost.mockResolvedValue({ status: 200 } as never);
94
+ });
95
+
96
+ describe("configuration", () => {
97
+ test("needs both a url and a secret", () => {
98
+ expect(MarketingEventWebhook.isConfigured()).toBe(true);
99
+
100
+ mockSecret = "";
101
+ expect(MarketingEventWebhook.isConfigured()).toBe(false);
102
+
103
+ mockSecret = SECRET;
104
+ mockUrl = "";
105
+ expect(MarketingEventWebhook.isConfigured()).toBe(false);
106
+ });
107
+
108
+ /*
109
+ * A URL with no secret is the dangerous shape: it looks configured, and
110
+ * sending to it would put email addresses on the wire with nothing for the
111
+ * receiver to authenticate. It has to be distinguishable from "not set up",
112
+ * so a caller can say so out loud instead of silently dropping events.
113
+ */
114
+ test("reports a url without a secret as misconfigured, not unconfigured", () => {
115
+ mockSecret = "";
116
+
117
+ expect(MarketingEventWebhook.isMisconfigured()).toBe(true);
118
+ expect(MarketingEventWebhook.isConfigured()).toBe(false);
119
+ });
120
+
121
+ test("is not misconfigured when nothing is set at all", () => {
122
+ mockUrl = "";
123
+ mockSecret = "";
124
+
125
+ expect(MarketingEventWebhook.isMisconfigured()).toBe(false);
126
+ });
127
+
128
+ test("sends nothing when unconfigured", async () => {
129
+ mockUrl = "";
130
+
131
+ await MarketingEventWebhook.deliver(makeEvent());
132
+
133
+ expect(mockPost).not.toHaveBeenCalled();
134
+ });
135
+ });
136
+
137
+ describe("signing", () => {
138
+ test("signs the exact bytes it sends", async () => {
139
+ await MarketingEventWebhook.deliver(makeEvent());
140
+
141
+ const { body, config } = postCall();
142
+
143
+ const expected: string = crypto
144
+ .createHmac("sha256", SECRET)
145
+ .update(body, "utf8")
146
+ .digest("hex");
147
+
148
+ expect(config.headers["x-oneuptime-signature-256"]).toBe(expected);
149
+ });
150
+
151
+ /*
152
+ * The body has to go out as the pre-serialised string. If it were handed
153
+ * over as an object, the HTTP client would serialise it again and any
154
+ * difference in key order or spacing would make the digest unverifiable —
155
+ * the single most likely way for this integration to break silently.
156
+ */
157
+ test("sends the body as a string, not an object", async () => {
158
+ await MarketingEventWebhook.deliver(makeEvent());
159
+
160
+ const { body, config } = postCall();
161
+
162
+ expect(typeof body).toBe("string");
163
+ expect(JSON.parse(body)).toEqual(makeEvent());
164
+ expect(config.headers["content-type"]).toBe("application/json");
165
+ });
166
+
167
+ test("a digest computed over re-serialised JSON does not match", async () => {
168
+ await MarketingEventWebhook.deliver(makeEvent());
169
+
170
+ const { body, config } = postCall();
171
+
172
+ // Same data, different bytes — reordered and re-spaced.
173
+ const reSerialised: string = JSON.stringify(JSON.parse(body), null, 2);
174
+
175
+ const naive: string = crypto
176
+ .createHmac("sha256", SECRET)
177
+ .update(reSerialised, "utf8")
178
+ .digest("hex");
179
+
180
+ expect(naive).not.toBe(config.headers["x-oneuptime-signature-256"]);
181
+ });
182
+
183
+ test("different secrets produce different signatures", () => {
184
+ const body: string = JSON.stringify(makeEvent());
185
+
186
+ const first: string = MarketingEventWebhook.sign(body);
187
+
188
+ mockSecret = "a-different-secret";
189
+
190
+ expect(MarketingEventWebhook.sign(body)).not.toBe(first);
191
+ });
192
+
193
+ test("routes on id and type headers as well as the body", async () => {
194
+ await MarketingEventWebhook.deliver(makeEvent());
195
+
196
+ const { url, config } = postCall();
197
+
198
+ expect(url).toBe(URL);
199
+ expect(config.headers["x-oneuptime-event-id"]).toBe("sign_up:user-1");
200
+ expect(config.headers["x-oneuptime-event-type"]).toBe("sign_up");
201
+ });
202
+ });
203
+
204
+ describe("failure handling", () => {
205
+ /*
206
+ * Nothing stores these events, so the queue's retry is the only backstop.
207
+ * A delivery that fails has to throw — swallowing it loses the conversion
208
+ * with no row anywhere to reconcile against later.
209
+ */
210
+ test("throws on a transport failure so the queue retries", async () => {
211
+ mockPost.mockRejectedValue(new Error("ECONNREFUSED") as never);
212
+
213
+ await expect(MarketingEventWebhook.deliver(makeEvent())).rejects.toThrow(
214
+ /ECONNREFUSED/,
215
+ );
216
+ });
217
+
218
+ test("names the event in the failure so a log line is actionable", async () => {
219
+ mockPost.mockRejectedValue(new Error("boom") as never);
220
+
221
+ await expect(MarketingEventWebhook.deliver(makeEvent())).rejects.toThrow(
222
+ /sign_up:user-1/,
223
+ );
224
+ });
225
+
226
+ test("resolves quietly on a 2xx", async () => {
227
+ mockPost.mockResolvedValue({ status: 202 } as never);
228
+
229
+ await expect(
230
+ MarketingEventWebhook.deliver(makeEvent()),
231
+ ).resolves.toBeUndefined();
232
+ });
233
+
234
+ /*
235
+ * Non-2xx must reach the catch rather than resolving, which is what the
236
+ * explicit validateStatus is for — the default would resolve on a 4xx and
237
+ * the event would be reported as delivered when it was rejected.
238
+ */
239
+ test("treats only 2xx as success", async () => {
240
+ const { config } = await MarketingEventWebhook.deliver(makeEvent()).then(
241
+ () => {
242
+ return postCall();
243
+ },
244
+ );
245
+
246
+ const validateStatus: (status: number) => boolean = (
247
+ config as unknown as { validateStatus: (status: number) => boolean }
248
+ ).validateStatus;
249
+
250
+ expect(validateStatus(200)).toBe(true);
251
+ expect(validateStatus(299)).toBe(true);
252
+ expect(validateStatus(400)).toBe(false);
253
+ expect(validateStatus(500)).toBe(false);
254
+ expect(validateStatus(302)).toBe(false);
255
+ });
256
+ });
257
+ });