@oneuptime/common 12.0.14 → 12.0.15

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 (195) hide show
  1. package/Models/DatabaseModels/GlobalOidc.ts +20 -0
  2. package/Models/DatabaseModels/GlobalSso.ts +20 -0
  3. package/Models/DatabaseModels/MarketingConversion.ts +19 -9
  4. package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +46 -0
  5. package/Models/DatabaseModels/StatusPage.ts +7 -4
  6. package/Server/API/StatusPageAPI.ts +29 -4
  7. package/Server/EnvironmentConfig.ts +7 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.ts +19 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.ts +40 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  11. package/Server/Middleware/SlackAuthorization.ts +43 -6
  12. package/Server/Middleware/UserAuthorization.ts +236 -26
  13. package/Server/Middleware/WhatsAppAuthorization.ts +43 -3
  14. package/Server/Services/GlobalOidcProjectService.ts +126 -1
  15. package/Server/Services/GlobalOidcService.ts +132 -0
  16. package/Server/Services/GlobalSsoProjectService.ts +126 -1
  17. package/Server/Services/GlobalSsoService.ts +132 -0
  18. package/Server/Services/UserEmailService.ts +4 -4
  19. package/Server/Utils/CronTab.ts +73 -0
  20. package/Server/Utils/GlobalSsoAuthorization.ts +175 -0
  21. package/Server/Utils/Marketing/ConversionUploadProvider.ts +35 -5
  22. package/Server/Utils/Marketing/Providers/GoogleAds.ts +1 -1
  23. package/Server/Utils/Marketing/Providers/LinkedIn.ts +1 -1
  24. package/Server/Utils/Marketing/Providers/Meta.ts +1 -1
  25. package/Server/Utils/Marketing/Providers/MicrosoftAds.ts +1 -1
  26. package/Server/Utils/Marketing/Providers/Reddit.ts +1 -1
  27. package/Server/Utils/Monitor/Criteria/APIRequestCriteria.ts +48 -33
  28. package/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.ts +35 -30
  29. package/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.ts +33 -28
  30. package/Server/Utils/Monitor/Criteria/EvaluateOverTime.ts +475 -47
  31. package/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.ts +35 -30
  32. package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +34 -28
  33. package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +33 -28
  34. package/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.ts +69 -40
  35. package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +35 -29
  36. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +8 -0
  37. package/Server/Utils/Monitor/MonitorResource.ts +161 -21
  38. package/Server/Utils/StatusPageContentSecurityPolicy.ts +56 -0
  39. package/Server/Utils/StatusPageCustomizationAccess.ts +117 -0
  40. package/Tests/Server/API/StatusPageCustomizationOrigin.test.ts +348 -0
  41. package/Tests/Server/Infrastructure/Postgres/FixTotpOtpUrlAlgorithmMigration.test.ts +9 -59
  42. package/Tests/Server/Infrastructure/Postgres/SchemaMigrationsOrdering.test.ts +227 -0
  43. package/Tests/Server/Middleware/SlackAuthorization.test.ts +149 -0
  44. package/Tests/Server/Middleware/UserAuthorization.test.ts +27 -13
  45. package/Tests/Server/Middleware/UserAuthorizationGlobalSsoGovernance.test.ts +1455 -0
  46. package/Tests/Server/Middleware/UserAuthorizationSSOProvider.test.ts +91 -2
  47. package/Tests/Server/Middleware/WhatsAppAuthorization.test.ts +233 -0
  48. package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +21 -8
  49. package/Tests/Server/Services/GlobalSsoProviderAuthorization.test.ts +1242 -0
  50. package/Tests/Server/Services/MonitorResourceProbeAgreementHydration.test.ts +459 -0
  51. package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +10 -0
  52. package/Tests/Server/Types/AnalyticsDatabase/AggregateUtilBucketTimestamp.test.ts +416 -0
  53. package/Tests/Server/Types/Database/QueryHelperManyToManyAndEmptyValues.test.ts +551 -0
  54. package/Tests/Server/Types/Database/SelectUtil.test.ts +348 -0
  55. package/Tests/Server/Utils/CronTab.test.ts +116 -0
  56. package/Tests/Server/Utils/GlobalSSOToken.test.ts +581 -0
  57. package/Tests/Server/Utils/GlobalSsoAuthorization.test.ts +1401 -0
  58. package/Tests/Server/Utils/Marketing/AdUploadableConversionTypes.test.ts +254 -0
  59. package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +1 -1
  60. package/Tests/Server/Utils/Monitor/Criteria/APIRequestCriteriaEvaluateOverTime.test.ts +508 -0
  61. package/Tests/Server/Utils/Monitor/Criteria/APIRequestCriteriaPortTimings.test.ts +30 -9
  62. package/Tests/Server/Utils/Monitor/Criteria/EvaluateOverTime.test.ts +987 -0
  63. package/Tests/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.test.ts +916 -0
  64. package/Tests/Server/Utils/Monitor/Criteria/ProfileMonitorCriteria.test.ts +382 -0
  65. package/Tests/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.test.ts +1219 -0
  66. package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +86 -0
  67. package/Tests/Server/Utils/StatusPageContentSecurityPolicy.test.ts +136 -0
  68. package/Tests/Server/Utils/StatusPageCustomizationAccess.test.ts +223 -0
  69. package/Tests/Types/Monitor/MonitorStepConfigHelpers.test.ts +165 -0
  70. package/Tests/Types/NetworkDevice/NetworkDeviceLinkRuleScope.test.ts +215 -0
  71. package/Tests/UI/Components/Forms/FormSubmitAnalytics.test.tsx +206 -0
  72. package/Tests/UI/Utils/AIChatExport/ConversationMarkdown.test.ts +774 -0
  73. package/Tests/UI/Utils/AIChatExport/MarkdownBlocks.test.ts +791 -0
  74. package/Tests/UI/Utils/AIChatExport/MarkdownSafety.test.ts +544 -0
  75. package/Tests/Utils/Analytics.test.ts +187 -0
  76. package/Tests/Utils/Monitor/MonitorMetricType.test.ts +80 -0
  77. package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +1291 -0
  78. package/Tests/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.test.ts +214 -0
  79. package/Types/Marketing/MarketingConversion.ts +12 -0
  80. package/Types/Monitor/CriteriaFilter.ts +9 -0
  81. package/Types/NetworkDevice/NetworkDeviceLinkRuleScope.ts +51 -0
  82. package/UI/Components/FormModal/BasicFormModal.tsx +12 -0
  83. package/UI/Components/Forms/BasicForm.tsx +13 -1
  84. package/UI/Components/Forms/BasicModelForm.tsx +2 -1
  85. package/UI/Components/Forms/ModelForm.tsx +10 -1
  86. package/UI/Components/Forms/Utils/FormAnalyticsName.ts +39 -0
  87. package/UI/Components/ModelFormModal/ModelFormModal.tsx +6 -1
  88. package/Utils/Analytics.ts +55 -2
  89. package/Utils/Monitor/MonitorMetricType.ts +40 -0
  90. package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +587 -37
  91. package/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.ts +115 -0
  92. package/build/dist/Models/DatabaseModels/GlobalOidc.js +21 -0
  93. package/build/dist/Models/DatabaseModels/GlobalOidc.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/GlobalSso.js +21 -0
  95. package/build/dist/Models/DatabaseModels/GlobalSso.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/MarketingConversion.js +20 -10
  97. package/build/dist/Models/DatabaseModels/MarketingConversion.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +47 -0
  99. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/StatusPage.js +4 -4
  101. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  102. package/build/dist/Server/API/StatusPageAPI.js +25 -4
  103. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  104. package/build/dist/Server/EnvironmentConfig.js +6 -0
  105. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  106. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.js +12 -0
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.js.map +1 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.js +29 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.js.map +1 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  112. package/build/dist/Server/Middleware/SlackAuthorization.js +28 -4
  113. package/build/dist/Server/Middleware/SlackAuthorization.js.map +1 -1
  114. package/build/dist/Server/Middleware/UserAuthorization.js +197 -22
  115. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  116. package/build/dist/Server/Middleware/WhatsAppAuthorization.js +32 -2
  117. package/build/dist/Server/Middleware/WhatsAppAuthorization.js.map +1 -1
  118. package/build/dist/Server/Services/GlobalOidcProjectService.js +105 -0
  119. package/build/dist/Server/Services/GlobalOidcProjectService.js.map +1 -1
  120. package/build/dist/Server/Services/GlobalOidcService.js +127 -0
  121. package/build/dist/Server/Services/GlobalOidcService.js.map +1 -1
  122. package/build/dist/Server/Services/GlobalSsoProjectService.js +105 -0
  123. package/build/dist/Server/Services/GlobalSsoProjectService.js.map +1 -1
  124. package/build/dist/Server/Services/GlobalSsoService.js +127 -0
  125. package/build/dist/Server/Services/GlobalSsoService.js.map +1 -1
  126. package/build/dist/Server/Services/UserEmailService.js +4 -4
  127. package/build/dist/Server/Utils/CronTab.js +66 -0
  128. package/build/dist/Server/Utils/CronTab.js.map +1 -1
  129. package/build/dist/Server/Utils/GlobalSsoAuthorization.js +112 -0
  130. package/build/dist/Server/Utils/GlobalSsoAuthorization.js.map +1 -0
  131. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js +17 -1
  132. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js.map +1 -1
  133. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js +1 -1
  134. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js.map +1 -1
  135. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js +1 -1
  136. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js.map +1 -1
  137. package/build/dist/Server/Utils/Marketing/Providers/Meta.js +1 -1
  138. package/build/dist/Server/Utils/Marketing/Providers/Meta.js.map +1 -1
  139. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js +1 -1
  140. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js.map +1 -1
  141. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js +1 -1
  142. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js.map +1 -1
  143. package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js +37 -35
  144. package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js.map +1 -1
  145. package/build/dist/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.js +21 -25
  146. package/build/dist/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.js.map +1 -1
  147. package/build/dist/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.js +20 -22
  148. package/build/dist/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.js.map +1 -1
  149. package/build/dist/Server/Utils/Monitor/Criteria/EvaluateOverTime.js +370 -42
  150. package/build/dist/Server/Utils/Monitor/Criteria/EvaluateOverTime.js.map +1 -1
  151. package/build/dist/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.js +21 -25
  152. package/build/dist/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.js.map +1 -1
  153. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +21 -25
  154. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +21 -25
  156. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js +64 -52
  158. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js.map +1 -1
  159. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +25 -29
  160. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
  161. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +8 -0
  162. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  163. package/build/dist/Server/Utils/Monitor/MonitorResource.js +123 -23
  164. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  165. package/build/dist/Server/Utils/StatusPageContentSecurityPolicy.js +39 -0
  166. package/build/dist/Server/Utils/StatusPageContentSecurityPolicy.js.map +1 -0
  167. package/build/dist/Server/Utils/StatusPageCustomizationAccess.js +85 -0
  168. package/build/dist/Server/Utils/StatusPageCustomizationAccess.js.map +1 -0
  169. package/build/dist/Types/Marketing/MarketingConversion.js +10 -0
  170. package/build/dist/Types/Marketing/MarketingConversion.js.map +1 -1
  171. package/build/dist/Types/Monitor/CriteriaFilter.js +1 -0
  172. package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
  173. package/build/dist/Types/NetworkDevice/NetworkDeviceLinkRuleScope.js +46 -0
  174. package/build/dist/Types/NetworkDevice/NetworkDeviceLinkRuleScope.js.map +1 -0
  175. package/build/dist/UI/Components/FormModal/BasicFormModal.js +2 -1
  176. package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
  177. package/build/dist/UI/Components/Forms/BasicForm.js +10 -1
  178. package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
  179. package/build/dist/UI/Components/Forms/BasicModelForm.js +2 -1
  180. package/build/dist/UI/Components/Forms/BasicModelForm.js.map +1 -1
  181. package/build/dist/UI/Components/Forms/ModelForm.js +4 -1
  182. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  183. package/build/dist/UI/Components/Forms/Utils/FormAnalyticsName.js +33 -0
  184. package/build/dist/UI/Components/Forms/Utils/FormAnalyticsName.js.map +1 -0
  185. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js +2 -1
  186. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
  187. package/build/dist/Utils/Analytics.js +50 -2
  188. package/build/dist/Utils/Analytics.js.map +1 -1
  189. package/build/dist/Utils/Monitor/MonitorMetricType.js +37 -0
  190. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  191. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +318 -37
  192. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -1
  193. package/build/dist/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.js +87 -0
  194. package/build/dist/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.js.map +1 -0
  195. package/package.json +1 -1
@@ -0,0 +1,348 @@
1
+ import StatusPage from "../../../Models/DatabaseModels/StatusPage";
2
+ import StatusPageDomain from "../../../Models/DatabaseModels/StatusPageDomain";
3
+ import StatusPageAPI from "../../../Server/API/StatusPageAPI";
4
+ import StatusPageDomainService from "../../../Server/Services/StatusPageDomainService";
5
+ import StatusPageFooterLinkService from "../../../Server/Services/StatusPageFooterLinkService";
6
+ import StatusPageHeaderLinkService from "../../../Server/Services/StatusPageHeaderLinkService";
7
+ import StatusPageService from "../../../Server/Services/StatusPageService";
8
+ import StatusPageSsoService from "../../../Server/Services/StatusPageSsoService";
9
+ import Select from "../../../Server/Types/Database/Select";
10
+ import {
11
+ ExpressRequest,
12
+ ExpressResponse,
13
+ NextFunction,
14
+ } from "../../../Server/Utils/Express";
15
+ import Response from "../../../Server/Utils/Response";
16
+ import { JSONObject } from "../../../Types/JSON";
17
+ import ObjectID from "../../../Types/ObjectID";
18
+ import PositiveNumber from "../../../Types/PositiveNumber";
19
+ import { mockRouter } from "./Helpers";
20
+ import {
21
+ afterEach,
22
+ beforeAll,
23
+ beforeEach,
24
+ describe,
25
+ expect,
26
+ it,
27
+ jest,
28
+ } from "@jest/globals";
29
+
30
+ jest.mock("../../../Server/Utils/Express", () => {
31
+ return {
32
+ getRouter: () => {
33
+ return mockRouter;
34
+ },
35
+ };
36
+ });
37
+
38
+ jest.mock("../../../Server/Utils/Response", () => {
39
+ return {
40
+ sendEntityArrayResponse: jest.fn(),
41
+ sendJsonObjectResponse: jest.fn(),
42
+ sendEmptySuccessResponse: jest.fn(),
43
+ sendEntityResponse: jest.fn(),
44
+ sendErrorResponse: jest.fn(),
45
+ };
46
+ });
47
+
48
+ const MASTER_PAGE_ROUTE: string = "/status-page/master-page/:statusPageId";
49
+
50
+ const CUSTOM_CSS: string = "body { background: url(https://evil.example); }";
51
+ const CUSTOM_JAVASCRIPT: string =
52
+ "fetch('/api/project/get-list', { credentials: 'include' });";
53
+ const HEADER_HTML: string =
54
+ '<img src="x" onerror="window.headerExecuted = true">';
55
+ const FOOTER_HTML: string = '<svg onload="window.footerExecuted = true"></svg>';
56
+
57
+ type InvocationResult = {
58
+ payload: JSONObject;
59
+ request: ExpressRequest;
60
+ response: ExpressResponse;
61
+ next: NextFunction;
62
+ };
63
+
64
+ describe("StatusPageAPI master-page customization origin boundary", () => {
65
+ const originalHost: string | undefined = process.env["HOST"];
66
+ let statusPageId: ObjectID;
67
+ let statusPage: StatusPage;
68
+
69
+ beforeAll(() => {
70
+ mockRouter.routes.length = 0;
71
+ new StatusPageAPI();
72
+ });
73
+
74
+ beforeEach(() => {
75
+ jest.clearAllMocks();
76
+ process.env["HOST"] = "app.example.com";
77
+
78
+ statusPageId = ObjectID.generate();
79
+ statusPage = new StatusPage();
80
+ statusPage.id = statusPageId;
81
+ statusPage.pageTitle = "Customer Status";
82
+ statusPage.pageDescription = "Everything is operational.";
83
+ statusPage.customCSS = CUSTOM_CSS;
84
+ statusPage.customJavaScript = CUSTOM_JAVASCRIPT;
85
+ statusPage.headerHTML = HEADER_HTML;
86
+ statusPage.footerHTML = FOOTER_HTML;
87
+
88
+ jest.spyOn(StatusPageService, "findOneById").mockResolvedValue(statusPage);
89
+ jest
90
+ .spyOn(StatusPageSsoService, "countBy")
91
+ .mockResolvedValue(new PositiveNumber(0));
92
+ jest.spyOn(StatusPageFooterLinkService, "findBy").mockResolvedValue([]);
93
+ jest.spyOn(StatusPageHeaderLinkService, "findBy").mockResolvedValue([]);
94
+ jest.spyOn(StatusPageDomainService, "findOneBy").mockResolvedValue(null);
95
+ });
96
+
97
+ afterEach(() => {
98
+ jest.restoreAllMocks();
99
+
100
+ if (originalHost === undefined) {
101
+ delete process.env["HOST"];
102
+ } else {
103
+ process.env["HOST"] = originalHost;
104
+ }
105
+ });
106
+
107
+ const invokeMasterPage: (data: {
108
+ host?: string | undefined;
109
+ forwardedHost?: string | undefined;
110
+ origin?: string | undefined;
111
+ referer?: string | undefined;
112
+ bodyDomain?: string | undefined;
113
+ }) => Promise<InvocationResult> = async (data: {
114
+ host?: string | undefined;
115
+ forwardedHost?: string | undefined;
116
+ origin?: string | undefined;
117
+ referer?: string | undefined;
118
+ bodyDomain?: string | undefined;
119
+ }): Promise<InvocationResult> => {
120
+ const headers: Record<string, string> = {};
121
+
122
+ if (data.host !== undefined) {
123
+ headers["host"] = data.host;
124
+ }
125
+
126
+ if (data.forwardedHost !== undefined) {
127
+ headers["x-forwarded-host"] = data.forwardedHost;
128
+ }
129
+
130
+ if (data.origin !== undefined) {
131
+ headers["origin"] = data.origin;
132
+ }
133
+
134
+ if (data.referer !== undefined) {
135
+ headers["referer"] = data.referer;
136
+ }
137
+
138
+ const request: ExpressRequest = {
139
+ params: {
140
+ statusPageId: statusPageId.toString(),
141
+ },
142
+ body: data.bodyDomain ? { domain: data.bodyDomain } : {},
143
+ query: {},
144
+ cookies: {},
145
+ headers,
146
+ get: (name: string): string | undefined => {
147
+ return headers[name.toLowerCase()];
148
+ },
149
+ socket: {},
150
+ ips: [],
151
+ } as unknown as ExpressRequest;
152
+
153
+ const response: ExpressResponse = {
154
+ send: jest.fn(),
155
+ json: jest.fn(),
156
+ status: jest.fn().mockReturnThis(),
157
+ } as unknown as ExpressResponse;
158
+ const next: NextFunction = jest.fn() as unknown as NextFunction;
159
+
160
+ await mockRouter
161
+ .match("post", MASTER_PAGE_ROUTE)
162
+ .handlerFunction(request, response, next);
163
+
164
+ expect(next).not.toHaveBeenCalled();
165
+ expect(Response.sendJsonObjectResponse).toHaveBeenCalledTimes(1);
166
+
167
+ const responseCall: Array<unknown> = (
168
+ Response.sendJsonObjectResponse as jest.Mock
169
+ ).mock.calls[0] as Array<unknown>;
170
+
171
+ return {
172
+ payload: responseCall[2] as JSONObject,
173
+ request,
174
+ response,
175
+ next,
176
+ };
177
+ };
178
+
179
+ const expectCustomizationsDenied: (payload: JSONObject) => void = (
180
+ payload: JSONObject,
181
+ ): void => {
182
+ expect(payload["allowStatusPageCustomizations"]).toBe(false);
183
+
184
+ const serializedStatusPage: JSONObject = payload[
185
+ "statusPage"
186
+ ] as JSONObject;
187
+
188
+ expect(serializedStatusPage).not.toHaveProperty("customCSS");
189
+ expect(serializedStatusPage).not.toHaveProperty("customJavaScript");
190
+ expect(serializedStatusPage).not.toHaveProperty("headerHTML");
191
+ expect(serializedStatusPage).not.toHaveProperty("footerHTML");
192
+ };
193
+
194
+ it.each([
195
+ ["the configured primary host", "app.example.com"],
196
+ ["a normalized primary host", " APP.EXAMPLE.COM.:443 "],
197
+ ["localhost", "localhost:3000"],
198
+ ["the internal ingress name", "ingress"],
199
+ ["a missing Host", undefined],
200
+ ["a malformed Host", "app.example.com/path"],
201
+ ["an unknown custom host", "unknown.customer.example"],
202
+ ])(
203
+ "omits executable customization fields for %s",
204
+ async (_label: string, host: string | undefined) => {
205
+ const result: InvocationResult = await invokeMasterPage({ host });
206
+
207
+ expectCustomizationsDenied(result.payload);
208
+
209
+ const findPageCall: Array<unknown> = (
210
+ StatusPageService.findOneById as jest.Mock
211
+ ).mock.calls[0] as Array<unknown>;
212
+ const select: Select<StatusPage> = (
213
+ findPageCall[0] as { select: Select<StatusPage> }
214
+ ).select;
215
+
216
+ expect(select).not.toHaveProperty("customCSS");
217
+ expect(select).not.toHaveProperty("customJavaScript");
218
+ expect(select).not.toHaveProperty("headerHTML");
219
+ expect(select).not.toHaveProperty("footerHTML");
220
+ },
221
+ );
222
+
223
+ it("returns all customization fields for a verified domain bound to this status page", async () => {
224
+ const statusPageDomain: StatusPageDomain = new StatusPageDomain();
225
+ statusPageDomain.id = ObjectID.generate();
226
+ (StatusPageDomainService.findOneBy as jest.Mock).mockResolvedValue(
227
+ statusPageDomain as never,
228
+ );
229
+
230
+ const result: InvocationResult = await invokeMasterPage({
231
+ host: "STATUS.Customer.Example.:443",
232
+ });
233
+
234
+ expect(result.payload["allowStatusPageCustomizations"]).toBe(true);
235
+ expect(result.payload["statusPage"]).toMatchObject({
236
+ customCSS: CUSTOM_CSS,
237
+ customJavaScript: CUSTOM_JAVASCRIPT,
238
+ headerHTML: HEADER_HTML,
239
+ footerHTML: FOOTER_HTML,
240
+ });
241
+
242
+ const findPageCall: Array<unknown> = (
243
+ StatusPageService.findOneById as jest.Mock
244
+ ).mock.calls[0] as Array<unknown>;
245
+ const select: Select<StatusPage> = (
246
+ findPageCall[0] as { select: Select<StatusPage> }
247
+ ).select;
248
+
249
+ expect(select).toEqual(
250
+ expect.objectContaining({
251
+ customCSS: true,
252
+ customJavaScript: true,
253
+ headerHTML: true,
254
+ footerHTML: true,
255
+ }),
256
+ );
257
+
258
+ expect(StatusPageDomainService.findOneBy).toHaveBeenCalledWith(
259
+ expect.objectContaining({
260
+ query: {
261
+ fullDomain: "status.customer.example",
262
+ statusPageId,
263
+ isCnameVerified: true,
264
+ domain: {
265
+ isVerified: true,
266
+ },
267
+ },
268
+ }),
269
+ );
270
+ });
271
+
272
+ it("denies a domain record bound to a different status page", async () => {
273
+ const otherStatusPageId: ObjectID = ObjectID.generate();
274
+ const statusPageDomain: StatusPageDomain = new StatusPageDomain();
275
+ statusPageDomain.id = ObjectID.generate();
276
+
277
+ (StatusPageDomainService.findOneBy as jest.Mock).mockImplementation(
278
+ (args: unknown) => {
279
+ const queryStatusPageId: ObjectID = (
280
+ args as { query: { statusPageId: ObjectID } }
281
+ ).query.statusPageId;
282
+
283
+ return Promise.resolve(
284
+ queryStatusPageId.toString() === otherStatusPageId.toString()
285
+ ? statusPageDomain
286
+ : null,
287
+ );
288
+ },
289
+ );
290
+
291
+ const result: InvocationResult = await invokeMasterPage({
292
+ host: "status.customer.example",
293
+ });
294
+
295
+ expectCustomizationsDenied(result.payload);
296
+ });
297
+
298
+ it("ignores forwarded-host, origin, referer, and body attempts to opt in", async () => {
299
+ const statusPageDomain: StatusPageDomain = new StatusPageDomain();
300
+ statusPageDomain.id = ObjectID.generate();
301
+ (StatusPageDomainService.findOneBy as jest.Mock).mockResolvedValue(
302
+ statusPageDomain as never,
303
+ );
304
+
305
+ const result: InvocationResult = await invokeMasterPage({
306
+ host: "app.example.com",
307
+ forwardedHost: "status.customer.example",
308
+ origin: "https://status.customer.example",
309
+ referer: "https://status.customer.example/",
310
+ bodyDomain: "status.customer.example",
311
+ });
312
+
313
+ expectCustomizationsDenied(result.payload);
314
+ expect(StatusPageDomainService.findOneBy).not.toHaveBeenCalled();
315
+ });
316
+
317
+ it("fails closed but still serves safe master-page data when domain lookup fails", async () => {
318
+ (StatusPageDomainService.findOneBy as jest.Mock).mockRejectedValue(
319
+ new Error("database unavailable") as never,
320
+ );
321
+
322
+ const result: InvocationResult = await invokeMasterPage({
323
+ host: "status.customer.example",
324
+ });
325
+
326
+ expectCustomizationsDenied(result.payload);
327
+ expect(result.payload["statusPage"]).toMatchObject({
328
+ _id: statusPageId.toString(),
329
+ pageTitle: "Customer Status",
330
+ pageDescription: "Everything is operational.",
331
+ });
332
+ expect(result.payload["footerLinks"]).toEqual([]);
333
+ expect(result.payload["headerLinks"]).toEqual([]);
334
+ expect(result.payload["hasEnabledSSO"]).toBe(0);
335
+ });
336
+
337
+ it("explicitly redacts over-populated service results on denied origins", async () => {
338
+ const result: InvocationResult = await invokeMasterPage({
339
+ host: "app.example.com",
340
+ });
341
+
342
+ expectCustomizationsDenied(result.payload);
343
+ expect(statusPage.customCSS).toBeUndefined();
344
+ expect(statusPage.customJavaScript).toBeUndefined();
345
+ expect(statusPage.headerHTML).toBeUndefined();
346
+ expect(statusPage.footerHTML).toBeUndefined();
347
+ });
348
+ });
@@ -31,14 +31,12 @@ import { beforeEach, describe, expect, test } from "@jest/globals";
31
31
  * without a database, and what can go wrong here is the WHERE clause, not
32
32
  * Postgres' REPLACE().
33
33
  *
34
- * This file also carries the ORDERING guard, handed on from
35
- * AddNetworkDeviceReachabilityColumnsMigration.test.ts: this is the newest
36
- * registered migration, so its timestamp must sort above every other one.
37
- * `generate-postgres-migration` stamps a WALL-CLOCK timestamp, while the
38
- * recent migrations here use hand-picked round numbers that already run ahead
39
- * of it so a generated file lands BELOW several registered migrations
40
- * unless somebody renumbers it, and runs out of order without anything saying
41
- * so. Whoever adds the next migration should move this block to theirs.
34
+ * The ordering of this migration against the others is no longer asserted
35
+ * here. It used to be — as a guard hand-carried from one migration's test to
36
+ * the next, which broke the moment a migration was added without moving it.
37
+ * It now lives in SchemaMigrationsOrdering.test.ts, written against the
38
+ * registry as a whole so that it covers whichever migration is newest without
39
+ * anybody having to remember it.
42
40
  */
43
41
 
44
42
  const MIGRATION_TIMESTAMP: string = "1787700000000";
@@ -48,15 +46,6 @@ const MIGRATION_DIRECTORY: string = path.join(
48
46
  "../../../../Server/Infrastructure/Postgres/SchemaMigrations",
49
47
  );
50
48
 
51
- interface MigrationClass {
52
- name: string;
53
- }
54
-
55
- function timestampOf(migrationClass: MigrationClass): number | null {
56
- const match: RegExpMatchArray | null = migrationClass.name.match(/(\d+)$/);
57
- return match ? Number(match[1]) : null;
58
- }
59
-
60
49
  type CapturedQueryRunner = {
61
50
  runner: QueryRunner;
62
51
  queries: Array<string>;
@@ -104,46 +93,7 @@ describe("FixTotpOtpUrlAlgorithm migration", () => {
104
93
  expect(SchemaMigrations).toContain(FixTotpOtpUrlAlgorithm1787700000000);
105
94
  });
106
95
 
107
- // The guard described in the header. Hand it to the next migration.
108
- describe("ordering", () => {
109
- test("its timestamp sorts after every other registered migration", () => {
110
- const ourTimestamp: number | null = timestampOf(
111
- FixTotpOtpUrlAlgorithm1787700000000,
112
- );
113
-
114
- expect(ourTimestamp).not.toBeNull();
115
-
116
- const otherTimestamps: Array<number> = [];
117
-
118
- for (const migrationClass of SchemaMigrations) {
119
- if (migrationClass === FixTotpOtpUrlAlgorithm1787700000000) {
120
- continue;
121
- }
122
-
123
- const timestamp: number | null = timestampOf(
124
- migrationClass as MigrationClass,
125
- );
126
-
127
- if (timestamp !== null) {
128
- otherTimestamps.push(timestamp);
129
- }
130
- }
131
-
132
- /*
133
- * Math.max() of an empty list is -Infinity, which every timestamp beats.
134
- * Prove the list was actually enumerated before leaning on the maximum.
135
- */
136
- expect(otherTimestamps.length).toBeGreaterThan(100);
137
-
138
- expect(ourTimestamp).toBeGreaterThan(Math.max(...otherTimestamps));
139
- });
140
-
141
- test("it is registered last, matching that timestamp", () => {
142
- expect(SchemaMigrations[SchemaMigrations.length - 1]).toBe(
143
- FixTotpOtpUrlAlgorithm1787700000000,
144
- );
145
- });
146
-
96
+ describe("registration", () => {
147
97
  test("it is registered exactly once", () => {
148
98
  const occurrences: number = SchemaMigrations.filter(
149
99
  (migration: new () => MigrationInterface): boolean => {
@@ -157,8 +107,8 @@ describe("FixTotpOtpUrlAlgorithm migration", () => {
157
107
  /*
158
108
  * ...and the timestamp in the class name is the one on disk. A class
159
109
  * renamed without renaming its file (or two migrations landing on the
160
- * same timestamp) leaves the ordering above asserting something that is
161
- * not what actually runs.
110
+ * same timestamp) leaves the registry pointing at a different file from
111
+ * the one this test read.
162
112
  */
163
113
  test("exactly one file on disk carries its timestamp, and it is this one", () => {
164
114
  const matching: Array<string> = fs
@@ -0,0 +1,227 @@
1
+ import SchemaMigrations from "../../../../Server/Infrastructure/Postgres/SchemaMigrations/Index";
2
+ import { MigrationInterface } from "typeorm";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { describe, expect, test } from "@jest/globals";
6
+
7
+ /*
8
+ * The ordering guard for the migration registry.
9
+ *
10
+ * TypeORM runs migrations in the order this array lists them, and records each
11
+ * one by class name. Two things can go wrong when a migration is added, and
12
+ * neither one announces itself — the schema simply diverges on some
13
+ * installations and not others:
14
+ *
15
+ * 1. the new migration runs BEFORE one that is already deployed. Existing
16
+ * installations have already recorded the older migration, so the new one
17
+ * still runs last for them, while a fresh install runs it in the listed
18
+ * position. The two databases end up different.
19
+ * 2. two migrations share a timestamp, so the file a class belongs to is
20
+ * ambiguous and the pairing between name and file can silently swap.
21
+ *
22
+ * `generate-postgres-migration` stamps a WALL-CLOCK timestamp, while the
23
+ * recent migrations here use hand-picked round numbers that already run ahead
24
+ * of wall clock — so a freshly generated file lands BELOW several registered
25
+ * migrations unless somebody renumbers it. That is case 1, and it is the
26
+ * likely one.
27
+ *
28
+ * This guard is deliberately written against the registry as a whole rather
29
+ * than against whichever migration happens to be newest, so that adding a
30
+ * migration never requires editing (or hand-carrying) the guard itself.
31
+ *
32
+ * Note it does NOT assert the whole array is sorted. It is not: a handful of
33
+ * historical entries were registered out of timestamp order years ago and have
34
+ * long since run everywhere, so renumbering them now would re-run them. What
35
+ * is asserted is the part that is still live — the newest entry must sort
36
+ * above everything already registered.
37
+ */
38
+
39
+ const MIGRATION_DIRECTORY: string = path.join(
40
+ __dirname,
41
+ "../../../../Server/Infrastructure/Postgres/SchemaMigrations",
42
+ );
43
+
44
+ /*
45
+ * The very first migration predates the convention and carries no timestamp in
46
+ * its class name. It is first in the array and can never be last, so it is
47
+ * simply excluded rather than special-cased in every assertion below.
48
+ */
49
+ const UNTIMESTAMPED_MIGRATION_NAME: string = "InitialMigration";
50
+
51
+ const INITIAL_MIGRATION_TIMESTAMP: number = 1717605043663;
52
+
53
+ const TIMESTAMPED_FILE: RegExp = new RegExp("^\\d+-");
54
+
55
+ type MigrationClass = new () => MigrationInterface;
56
+
57
+ type TimestampOfFunction = (migrationClass: MigrationClass) => number | null;
58
+
59
+ const timestampOf: TimestampOfFunction = (
60
+ migrationClass: MigrationClass,
61
+ ): number | null => {
62
+ const match: RegExpMatchArray | null = migrationClass.name.match(/(\d+)$/);
63
+ return match ? Number(match[1]) : null;
64
+ };
65
+
66
+ const registered: Array<MigrationClass> =
67
+ SchemaMigrations as Array<MigrationClass>;
68
+
69
+ describe("the migration registry", () => {
70
+ /*
71
+ * Math.max() of an empty list is -Infinity and every assertion below would
72
+ * pass vacuously against one. Prove the import actually produced a registry
73
+ * before leaning on anything derived from it.
74
+ */
75
+ test("is actually populated, so nothing below passes vacuously", () => {
76
+ expect(registered.length).toBeGreaterThan(100);
77
+ });
78
+
79
+ test("names every migration after its timestamp, bar the first one", () => {
80
+ const untimestamped: Array<string> = registered
81
+ .filter((migrationClass: MigrationClass): boolean => {
82
+ return timestampOf(migrationClass) === null;
83
+ })
84
+ .map((migrationClass: MigrationClass): string => {
85
+ return migrationClass.name;
86
+ });
87
+
88
+ expect(untimestamped).toEqual([UNTIMESTAMPED_MIGRATION_NAME]);
89
+ });
90
+
91
+ describe("ordering", () => {
92
+ /*
93
+ * The guard that used to be hand-carried from one migration's test to the
94
+ * next. Whoever adds a migration adds it to the end of the array, and this
95
+ * is what says the timestamp has to match that position.
96
+ */
97
+ test("gives the last-registered migration the highest timestamp", () => {
98
+ const last: MigrationClass = registered[registered.length - 1]!;
99
+ const lastTimestamp: number | null = timestampOf(last);
100
+
101
+ expect(lastTimestamp).not.toBeNull();
102
+
103
+ const earlierTimestamps: Array<number> = registered
104
+ .slice(0, -1)
105
+ .map(timestampOf)
106
+ .filter((timestamp: number | null): timestamp is number => {
107
+ return timestamp !== null;
108
+ });
109
+
110
+ expect(earlierTimestamps.length).toBe(registered.length - 2);
111
+
112
+ /*
113
+ * If this fails, the migration at the end of SchemaMigrations/Index.ts
114
+ * carries a timestamp that sorts below one already registered — rename
115
+ * the new migration's file and class to a timestamp above the current
116
+ * maximum, rather than moving it up the array.
117
+ */
118
+ expect(lastTimestamp).toBeGreaterThan(Math.max(...earlierTimestamps));
119
+ });
120
+
121
+ test("never registers the same timestamp twice", () => {
122
+ const timestamps: Array<number> = registered
123
+ .map(timestampOf)
124
+ .filter((timestamp: number | null): timestamp is number => {
125
+ return timestamp !== null;
126
+ });
127
+
128
+ const duplicates: Array<number> = timestamps.filter(
129
+ (timestamp: number, index: number): boolean => {
130
+ return timestamps.indexOf(timestamp) !== index;
131
+ },
132
+ );
133
+
134
+ expect(duplicates).toEqual([]);
135
+ });
136
+
137
+ test("never registers the same migration class twice", () => {
138
+ const names: Array<string> = registered.map(
139
+ (migrationClass: MigrationClass): string => {
140
+ return migrationClass.name;
141
+ },
142
+ );
143
+
144
+ const duplicates: Array<string> = names.filter(
145
+ (name: string, index: number): boolean => {
146
+ return names.indexOf(name) !== index;
147
+ },
148
+ );
149
+
150
+ expect(duplicates).toEqual([]);
151
+ });
152
+ });
153
+
154
+ /*
155
+ * ...and the timestamps in the class names are the ones on disk. A class
156
+ * renamed without renaming its file — or two files landing on the same
157
+ * timestamp — leaves the ordering above asserting something that is not what
158
+ * actually runs.
159
+ */
160
+ test("backs every registered migration with exactly one file on disk", () => {
161
+ const files: Array<string> = fs.readdirSync(MIGRATION_DIRECTORY);
162
+
163
+ const unbacked: Array<string> = registered
164
+ .filter((migrationClass: MigrationClass): boolean => {
165
+ const timestamp: number | null = timestampOf(migrationClass);
166
+
167
+ if (timestamp === null) {
168
+ return false;
169
+ }
170
+
171
+ return (
172
+ files.filter((file: string): boolean => {
173
+ return file.startsWith(`${timestamp}-`);
174
+ }).length !== 1
175
+ );
176
+ })
177
+ .map((migrationClass: MigrationClass): string => {
178
+ return migrationClass.name;
179
+ });
180
+
181
+ expect(unbacked).toEqual([]);
182
+ });
183
+
184
+ /*
185
+ * The other direction: a migration file that nobody registered never runs at
186
+ * all. That is the failure mode with no symptom until a query hits a column
187
+ * that was never created.
188
+ *
189
+ * The registered side is read out of the exported array rather than out of
190
+ * Index.ts, so a file that is imported but never added to the array — which
191
+ * is exactly as inert as one that is not imported at all — still fails here.
192
+ */
193
+ test("registers every migration file that exists on disk", () => {
194
+ const registeredTimestamps: Set<number> = new Set(
195
+ registered
196
+ .map(timestampOf)
197
+ .filter((timestamp: number | null): timestamp is number => {
198
+ return timestamp !== null;
199
+ }),
200
+ );
201
+
202
+ /*
203
+ * The one file whose class name carries no timestamp, so it cannot be
204
+ * matched by the timestamps above.
205
+ */
206
+ registeredTimestamps.add(INITIAL_MIGRATION_TIMESTAMP);
207
+
208
+ const timestampsOnDisk: Array<string> = fs
209
+ .readdirSync(MIGRATION_DIRECTORY)
210
+ .filter((file: string): boolean => {
211
+ return file.endsWith(".ts") && TIMESTAMPED_FILE.test(file);
212
+ })
213
+ .map((file: string): string => {
214
+ return file.split("-")[0]!;
215
+ });
216
+
217
+ expect(timestampsOnDisk.length).toBeGreaterThan(100);
218
+
219
+ const unregistered: Array<string> = timestampsOnDisk.filter(
220
+ (timestamp: string): boolean => {
221
+ return !registeredTimestamps.has(Number(timestamp));
222
+ },
223
+ );
224
+
225
+ expect(unregistered).toEqual([]);
226
+ });
227
+ });