@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.
- package/Models/DatabaseModels/GlobalOidc.ts +20 -0
- package/Models/DatabaseModels/GlobalSso.ts +20 -0
- package/Models/DatabaseModels/MarketingConversion.ts +19 -9
- package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +46 -0
- package/Models/DatabaseModels/StatusPage.ts +7 -4
- package/Server/API/StatusPageAPI.ts +29 -4
- package/Server/EnvironmentConfig.ts +7 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.ts +19 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.ts +40 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Middleware/SlackAuthorization.ts +43 -6
- package/Server/Middleware/UserAuthorization.ts +236 -26
- package/Server/Middleware/WhatsAppAuthorization.ts +43 -3
- package/Server/Services/GlobalOidcProjectService.ts +126 -1
- package/Server/Services/GlobalOidcService.ts +132 -0
- package/Server/Services/GlobalSsoProjectService.ts +126 -1
- package/Server/Services/GlobalSsoService.ts +132 -0
- package/Server/Services/UserEmailService.ts +4 -4
- package/Server/Utils/CronTab.ts +73 -0
- package/Server/Utils/GlobalSsoAuthorization.ts +175 -0
- package/Server/Utils/Marketing/ConversionUploadProvider.ts +35 -5
- package/Server/Utils/Marketing/Providers/GoogleAds.ts +1 -1
- package/Server/Utils/Marketing/Providers/LinkedIn.ts +1 -1
- package/Server/Utils/Marketing/Providers/Meta.ts +1 -1
- package/Server/Utils/Marketing/Providers/MicrosoftAds.ts +1 -1
- package/Server/Utils/Marketing/Providers/Reddit.ts +1 -1
- package/Server/Utils/Monitor/Criteria/APIRequestCriteria.ts +48 -33
- package/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.ts +35 -30
- package/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.ts +33 -28
- package/Server/Utils/Monitor/Criteria/EvaluateOverTime.ts +475 -47
- package/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.ts +35 -30
- package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +34 -28
- package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +33 -28
- package/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.ts +69 -40
- package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +35 -29
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +8 -0
- package/Server/Utils/Monitor/MonitorResource.ts +161 -21
- package/Server/Utils/StatusPageContentSecurityPolicy.ts +56 -0
- package/Server/Utils/StatusPageCustomizationAccess.ts +117 -0
- package/Tests/Server/API/StatusPageCustomizationOrigin.test.ts +348 -0
- package/Tests/Server/Infrastructure/Postgres/FixTotpOtpUrlAlgorithmMigration.test.ts +9 -59
- package/Tests/Server/Infrastructure/Postgres/SchemaMigrationsOrdering.test.ts +227 -0
- package/Tests/Server/Middleware/SlackAuthorization.test.ts +149 -0
- package/Tests/Server/Middleware/UserAuthorization.test.ts +27 -13
- package/Tests/Server/Middleware/UserAuthorizationGlobalSsoGovernance.test.ts +1455 -0
- package/Tests/Server/Middleware/UserAuthorizationSSOProvider.test.ts +91 -2
- package/Tests/Server/Middleware/WhatsAppAuthorization.test.ts +233 -0
- package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +21 -8
- package/Tests/Server/Services/GlobalSsoProviderAuthorization.test.ts +1242 -0
- package/Tests/Server/Services/MonitorResourceProbeAgreementHydration.test.ts +459 -0
- package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +10 -0
- package/Tests/Server/Types/AnalyticsDatabase/AggregateUtilBucketTimestamp.test.ts +416 -0
- package/Tests/Server/Types/Database/QueryHelperManyToManyAndEmptyValues.test.ts +551 -0
- package/Tests/Server/Types/Database/SelectUtil.test.ts +348 -0
- package/Tests/Server/Utils/CronTab.test.ts +116 -0
- package/Tests/Server/Utils/GlobalSSOToken.test.ts +581 -0
- package/Tests/Server/Utils/GlobalSsoAuthorization.test.ts +1401 -0
- package/Tests/Server/Utils/Marketing/AdUploadableConversionTypes.test.ts +254 -0
- package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +1 -1
- package/Tests/Server/Utils/Monitor/Criteria/APIRequestCriteriaEvaluateOverTime.test.ts +508 -0
- package/Tests/Server/Utils/Monitor/Criteria/APIRequestCriteriaPortTimings.test.ts +30 -9
- package/Tests/Server/Utils/Monitor/Criteria/EvaluateOverTime.test.ts +987 -0
- package/Tests/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.test.ts +916 -0
- package/Tests/Server/Utils/Monitor/Criteria/ProfileMonitorCriteria.test.ts +382 -0
- package/Tests/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.test.ts +1219 -0
- package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +86 -0
- package/Tests/Server/Utils/StatusPageContentSecurityPolicy.test.ts +136 -0
- package/Tests/Server/Utils/StatusPageCustomizationAccess.test.ts +223 -0
- package/Tests/Types/Monitor/MonitorStepConfigHelpers.test.ts +165 -0
- package/Tests/Types/NetworkDevice/NetworkDeviceLinkRuleScope.test.ts +215 -0
- package/Tests/UI/Components/Forms/FormSubmitAnalytics.test.tsx +206 -0
- package/Tests/UI/Utils/AIChatExport/ConversationMarkdown.test.ts +774 -0
- package/Tests/UI/Utils/AIChatExport/MarkdownBlocks.test.ts +791 -0
- package/Tests/UI/Utils/AIChatExport/MarkdownSafety.test.ts +544 -0
- package/Tests/Utils/Analytics.test.ts +187 -0
- package/Tests/Utils/Monitor/MonitorMetricType.test.ts +80 -0
- package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +1291 -0
- package/Tests/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.test.ts +214 -0
- package/Types/Marketing/MarketingConversion.ts +12 -0
- package/Types/Monitor/CriteriaFilter.ts +9 -0
- package/Types/NetworkDevice/NetworkDeviceLinkRuleScope.ts +51 -0
- package/UI/Components/FormModal/BasicFormModal.tsx +12 -0
- package/UI/Components/Forms/BasicForm.tsx +13 -1
- package/UI/Components/Forms/BasicModelForm.tsx +2 -1
- package/UI/Components/Forms/ModelForm.tsx +10 -1
- package/UI/Components/Forms/Utils/FormAnalyticsName.ts +39 -0
- package/UI/Components/ModelFormModal/ModelFormModal.tsx +6 -1
- package/Utils/Analytics.ts +55 -2
- package/Utils/Monitor/MonitorMetricType.ts +40 -0
- package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +587 -37
- package/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.ts +115 -0
- package/build/dist/Models/DatabaseModels/GlobalOidc.js +21 -0
- package/build/dist/Models/DatabaseModels/GlobalOidc.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalSso.js +21 -0
- package/build/dist/Models/DatabaseModels/GlobalSso.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MarketingConversion.js +20 -10
- package/build/dist/Models/DatabaseModels/MarketingConversion.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +47 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPage.js +4 -4
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +25 -4
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +6 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.js +29 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/SlackAuthorization.js +28 -4
- package/build/dist/Server/Middleware/SlackAuthorization.js.map +1 -1
- package/build/dist/Server/Middleware/UserAuthorization.js +197 -22
- package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
- package/build/dist/Server/Middleware/WhatsAppAuthorization.js +32 -2
- package/build/dist/Server/Middleware/WhatsAppAuthorization.js.map +1 -1
- package/build/dist/Server/Services/GlobalOidcProjectService.js +105 -0
- package/build/dist/Server/Services/GlobalOidcProjectService.js.map +1 -1
- package/build/dist/Server/Services/GlobalOidcService.js +127 -0
- package/build/dist/Server/Services/GlobalOidcService.js.map +1 -1
- package/build/dist/Server/Services/GlobalSsoProjectService.js +105 -0
- package/build/dist/Server/Services/GlobalSsoProjectService.js.map +1 -1
- package/build/dist/Server/Services/GlobalSsoService.js +127 -0
- package/build/dist/Server/Services/GlobalSsoService.js.map +1 -1
- package/build/dist/Server/Services/UserEmailService.js +4 -4
- package/build/dist/Server/Utils/CronTab.js +66 -0
- package/build/dist/Server/Utils/CronTab.js.map +1 -1
- package/build/dist/Server/Utils/GlobalSsoAuthorization.js +112 -0
- package/build/dist/Server/Utils/GlobalSsoAuthorization.js.map +1 -0
- package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js +17 -1
- package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js.map +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js.map +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js.map +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/Meta.js +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/Meta.js.map +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js.map +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/Reddit.js +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/Reddit.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js +37 -35
- package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.js +21 -25
- package/build/dist/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.js +20 -22
- package/build/dist/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/EvaluateOverTime.js +370 -42
- package/build/dist/Server/Utils/Monitor/Criteria/EvaluateOverTime.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.js +21 -25
- package/build/dist/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +21 -25
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +21 -25
- package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js +64 -52
- package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +25 -29
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +8 -0
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +123 -23
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/StatusPageContentSecurityPolicy.js +39 -0
- package/build/dist/Server/Utils/StatusPageContentSecurityPolicy.js.map +1 -0
- package/build/dist/Server/Utils/StatusPageCustomizationAccess.js +85 -0
- package/build/dist/Server/Utils/StatusPageCustomizationAccess.js.map +1 -0
- package/build/dist/Types/Marketing/MarketingConversion.js +10 -0
- package/build/dist/Types/Marketing/MarketingConversion.js.map +1 -1
- package/build/dist/Types/Monitor/CriteriaFilter.js +1 -0
- package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
- package/build/dist/Types/NetworkDevice/NetworkDeviceLinkRuleScope.js +46 -0
- package/build/dist/Types/NetworkDevice/NetworkDeviceLinkRuleScope.js.map +1 -0
- package/build/dist/UI/Components/FormModal/BasicFormModal.js +2 -1
- package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
- package/build/dist/UI/Components/Forms/BasicForm.js +10 -1
- package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
- package/build/dist/UI/Components/Forms/BasicModelForm.js +2 -1
- package/build/dist/UI/Components/Forms/BasicModelForm.js.map +1 -1
- package/build/dist/UI/Components/Forms/ModelForm.js +4 -1
- package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
- package/build/dist/UI/Components/Forms/Utils/FormAnalyticsName.js +33 -0
- package/build/dist/UI/Components/Forms/Utils/FormAnalyticsName.js.map +1 -0
- package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js +2 -1
- package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
- package/build/dist/Utils/Analytics.js +50 -2
- package/build/dist/Utils/Analytics.js.map +1 -1
- package/build/dist/Utils/Monitor/MonitorMetricType.js +37 -0
- package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +318 -37
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.js +87 -0
- package/build/dist/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.js.map +1 -0
- 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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
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
|
|
161
|
-
*
|
|
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
|
+
});
|