@oneuptime/common 14.0.5 → 14.0.7
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/InventoryItem.ts +23 -12
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/AlertService.ts +32 -36
- package/Server/Services/IncidentAlertService.ts +609 -2
- package/Server/Services/IncidentService.ts +134 -44
- package/Server/Services/InventoryItemService.ts +38 -0
- package/Server/Services/IoTDeviceCredentialService.ts +13 -4
- package/Server/Services/MetricService.ts +1 -1
- package/Server/Services/ScheduledMaintenanceService.ts +50 -46
- package/Server/Services/UserNotificationRuleService.ts +55 -53
- package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
- package/Server/Types/Database/QueryHelper.ts +54 -1
- package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
- package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
- package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
- package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
- package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
- package/Server/Utils/Response.ts +31 -0
- package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
- package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
- package/Server/Utils/Topology/TopologyRequest.ts +463 -0
- package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
- package/Server/Utils/Topology/TopologySql.ts +1024 -0
- package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
- package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
- package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
- package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
- package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
- package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
- package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
- package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
- package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
- package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
- package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
- package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
- package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
- package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
- package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
- package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
- package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
- package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
- package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
- package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
- package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
- package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
- package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
- package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
- package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
- package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
- package/Tests/Server/Services/DomainService.test.ts +525 -0
- package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
- package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
- package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
- package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
- package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
- package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
- package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
- package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
- package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
- package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
- package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
- package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
- package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
- package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
- package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
- package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
- package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
- package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
- package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
- package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
- package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
- package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
- package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
- package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
- package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
- package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
- package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
- package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
- package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
- package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
- package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
- package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
- package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
- package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
- package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
- package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
- package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
- package/Types/BaseDatabase/AggregationType.ts +7 -0
- package/Types/Dashboard/DashboardTemplates.ts +20 -7
- package/Types/Incident/IncidentAlertLink.ts +19 -0
- package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
- package/Types/Rum/WebVitals.ts +13 -0
- package/Types/Telemetry/InventoryLiveness.ts +26 -0
- package/Types/Topology/TopologyApi.ts +428 -0
- package/Types/Topology/TopologyTypeRules.ts +191 -0
- package/UI/Components/Input/Input.tsx +11 -0
- package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
- package/Utils/Incident/IncidentFromAlerts.ts +78 -0
- package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +21 -33
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +457 -3
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +96 -42
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemService.js +31 -0
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
- package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
- package/build/dist/Server/Services/MetricService.js +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
- package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
- package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +30 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
- package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
- package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
- package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
- package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +12 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
- package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
- package/build/dist/Types/Topology/TopologyApi.js +55 -0
- package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
- package/build/dist/UI/Components/Input/Input.js +1 -1
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
- package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
- package/package.json +1 -1
|
@@ -87,9 +87,20 @@ const FOREIGN_KEY_COLUMNS: Array<Array<string>> = [
|
|
|
87
87
|
["deletedByUserId"],
|
|
88
88
|
];
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
interface DeclaredIndex {
|
|
91
|
+
columns: Array<string>;
|
|
92
|
+
/*
|
|
93
|
+
* The name the model pins with `@Index("NAME", [...])`, when it pins one.
|
|
94
|
+
*
|
|
95
|
+
* Only TypeORM's derived names contain the table name and so had to be
|
|
96
|
+
* renamed. A pinned name reads the same before and after the rename, so the
|
|
97
|
+
* rename has nothing to do for it - and must not claim to, or it would try
|
|
98
|
+
* to rename a name that never existed.
|
|
99
|
+
*/
|
|
100
|
+
name: string | undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type DeclaredIndexesFunction = (modelType: ModelClass) => Array<DeclaredIndex>;
|
|
93
104
|
|
|
94
105
|
/*
|
|
95
106
|
* The indexes a model actually declares, read out of TypeORM's decorator
|
|
@@ -97,19 +108,19 @@ type DeclaredIndexColumnsFunction = (
|
|
|
97
108
|
* `@Index([...])` carries its columns; a column-level `@Index()` records the
|
|
98
109
|
* property it decorates instead.
|
|
99
110
|
*/
|
|
100
|
-
const
|
|
111
|
+
const getDeclaredIndexes: DeclaredIndexesFunction = (
|
|
101
112
|
modelType: ModelClass,
|
|
102
|
-
): Array<
|
|
113
|
+
): Array<DeclaredIndex> => {
|
|
103
114
|
return getMetadataArgsStorage()
|
|
104
115
|
.indices.filter((index: IndexMetadataArgs): boolean => {
|
|
105
116
|
return index.target === modelType;
|
|
106
117
|
})
|
|
107
|
-
.map((index: IndexMetadataArgs):
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
.map((index: IndexMetadataArgs): DeclaredIndex => {
|
|
119
|
+
const columns: Array<string> = Array.isArray(index.columns)
|
|
120
|
+
? (index.columns as Array<string>)
|
|
121
|
+
: [String((index as { propertyName?: string }).propertyName)];
|
|
111
122
|
|
|
112
|
-
return
|
|
123
|
+
return { columns: columns, name: index.name };
|
|
113
124
|
});
|
|
114
125
|
};
|
|
115
126
|
|
|
@@ -145,9 +156,10 @@ const MIGRATION_TIMESTAMP: number = 1786800000000;
|
|
|
145
156
|
* yet. Read out of the migration directory rather than hand-listed, so a
|
|
146
157
|
* future migration adding an index does not require editing this test.
|
|
147
158
|
*/
|
|
148
|
-
|
|
159
|
+
/** The source of every migration that runs after the rename. */
|
|
160
|
+
function getMigrationSourcesAfterTheRename(): Array<string> {
|
|
149
161
|
const directory: string = path.dirname(MIGRATION_PATH);
|
|
150
|
-
const
|
|
162
|
+
const sources: Array<string> = [];
|
|
151
163
|
|
|
152
164
|
for (const file of fs.readdirSync(directory)) {
|
|
153
165
|
const timestamp: number = Number(file.split("-")[0]);
|
|
@@ -156,11 +168,27 @@ function getIndexesCreatedAfterTheRename(): Set<string> {
|
|
|
156
168
|
continue;
|
|
157
169
|
}
|
|
158
170
|
|
|
159
|
-
|
|
171
|
+
sources.push(fs.readFileSync(path.join(directory, file), "utf8"));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return sources;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const MIGRATIONS_AFTER_THE_RENAME: Array<string> =
|
|
178
|
+
getMigrationSourcesAfterTheRename();
|
|
179
|
+
|
|
180
|
+
function getIndexesCreatedAfterTheRename(): Set<string> {
|
|
181
|
+
const created: Set<string> = new Set<string>();
|
|
160
182
|
|
|
183
|
+
for (const source of MIGRATIONS_AFTER_THE_RENAME) {
|
|
161
184
|
for (const table of ["InventoryItem", "InventoryItemRelationship"]) {
|
|
185
|
+
/*
|
|
186
|
+
* `IF NOT EXISTS` is allowed for: a migration that has to be safe to
|
|
187
|
+
* re-run still creates the index, and skipping it here would demand a
|
|
188
|
+
* rename pair for an index that did not exist when the rename ran.
|
|
189
|
+
*/
|
|
162
190
|
const pattern: RegExp = new RegExp(
|
|
163
|
-
`CREATE (?:UNIQUE )?INDEX "(IDX_[a-f0-9]+)" ON "${table}"`,
|
|
191
|
+
`CREATE (?:UNIQUE )?INDEX (?:IF NOT EXISTS )?"(IDX_[a-f0-9]+)" ON "${table}"`,
|
|
164
192
|
"g",
|
|
165
193
|
);
|
|
166
194
|
|
|
@@ -178,6 +206,23 @@ function getIndexesCreatedAfterTheRename(): Set<string> {
|
|
|
178
206
|
|
|
179
207
|
const INDEXES_ADDED_LATER: Set<string> = getIndexesCreatedAfterTheRename();
|
|
180
208
|
|
|
209
|
+
type PinnedIndexCreatedFunction = (indexName: string) => boolean;
|
|
210
|
+
|
|
211
|
+
/*
|
|
212
|
+
* Whether some migration after the rename creates the index a model pins by
|
|
213
|
+
* name. Matched by the name appearing anywhere in that migration rather than
|
|
214
|
+
* by a CREATE INDEX pattern: a pinned name is normally held in an exported
|
|
215
|
+
* constant and interpolated into the statement, so the statement itself
|
|
216
|
+
* carries `${THE_CONSTANT}` and only the declaration carries the literal.
|
|
217
|
+
*/
|
|
218
|
+
const isPinnedIndexCreatedAfterTheRename: PinnedIndexCreatedFunction = (
|
|
219
|
+
indexName: string,
|
|
220
|
+
): boolean => {
|
|
221
|
+
return MIGRATIONS_AFTER_THE_RENAME.some((source: string): boolean => {
|
|
222
|
+
return source.includes(indexName);
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
181
226
|
type PairMapFunction = (prefix: string) => Map<string, string>;
|
|
182
227
|
|
|
183
228
|
const pairsWithPrefix: PairMapFunction = (
|
|
@@ -228,15 +273,34 @@ describe("the data survives", () => {
|
|
|
228
273
|
|
|
229
274
|
describe("index names match TypeORM's naming strategy", () => {
|
|
230
275
|
for (const table of TABLES) {
|
|
231
|
-
const declared: Array<
|
|
232
|
-
table.modelType,
|
|
233
|
-
);
|
|
276
|
+
const declared: Array<DeclaredIndex> = getDeclaredIndexes(table.modelType);
|
|
234
277
|
|
|
235
278
|
test(`${table.newTable} declares indexes to rename`, () => {
|
|
236
279
|
expect(declared.length).toBeGreaterThan(0);
|
|
237
280
|
});
|
|
238
281
|
|
|
239
|
-
for (const
|
|
282
|
+
for (const index of declared) {
|
|
283
|
+
const columns: Array<string> = index.columns;
|
|
284
|
+
const pinnedName: string | undefined = index.name;
|
|
285
|
+
|
|
286
|
+
if (pinnedName) {
|
|
287
|
+
/*
|
|
288
|
+
* A pinned name carries no table name, so the rename had nothing to
|
|
289
|
+
* change and must not claim otherwise. What still has to hold is that
|
|
290
|
+
* a migration creates it - otherwise the model declares an index that
|
|
291
|
+
* only ever exists on a schema the builder synchronizes, and these are
|
|
292
|
+
* declared `synchronize: false` precisely so it does not.
|
|
293
|
+
*/
|
|
294
|
+
test(`${table.newTable} (${columns.join(", ")}) is pinned by name, so the rename leaves it alone`, () => {
|
|
295
|
+
const indexPairs: Map<string, string> = pairsWithPrefix("IDX_");
|
|
296
|
+
|
|
297
|
+
expect(indexPairs.has(pinnedName)).toBe(false);
|
|
298
|
+
expect(Array.from(indexPairs.values())).not.toContain(pinnedName);
|
|
299
|
+
expect(isPinnedIndexCreatedAfterTheRename(pinnedName)).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
240
304
|
const oldName: string = namingStrategy.indexName(table.oldTable, columns);
|
|
241
305
|
const newName: string = namingStrategy.indexName(table.newTable, columns);
|
|
242
306
|
|
|
@@ -263,16 +327,21 @@ describe("index names match TypeORM's naming strategy", () => {
|
|
|
263
327
|
const expected: Set<string> = new Set<string>();
|
|
264
328
|
|
|
265
329
|
for (const table of TABLES) {
|
|
266
|
-
for (const
|
|
330
|
+
for (const index of getDeclaredIndexes(table.modelType)) {
|
|
331
|
+
if (index.name) {
|
|
332
|
+
// Pinned name: table-independent, so the rename never touched it.
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
267
336
|
if (
|
|
268
337
|
INDEXES_ADDED_LATER.has(
|
|
269
|
-
namingStrategy.indexName(table.newTable, columns),
|
|
338
|
+
namingStrategy.indexName(table.newTable, index.columns),
|
|
270
339
|
)
|
|
271
340
|
) {
|
|
272
341
|
continue;
|
|
273
342
|
}
|
|
274
343
|
|
|
275
|
-
expected.add(namingStrategy.indexName(table.oldTable, columns));
|
|
344
|
+
expected.add(namingStrategy.indexName(table.oldTable, index.columns));
|
|
276
345
|
}
|
|
277
346
|
}
|
|
278
347
|
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import Model from "../../../Models/DatabaseModels/Domain";
|
|
2
|
+
import { Service as DomainServiceType } from "../../../Server/Services/DomainService";
|
|
3
|
+
import Domain from "../../../Server/Types/Domain";
|
|
4
|
+
import CreateBy from "../../../Server/Types/Database/CreateBy";
|
|
5
|
+
import UpdateBy from "../../../Server/Types/Database/UpdateBy";
|
|
6
|
+
import { OnCreate, OnUpdate } from "../../../Server/Types/Database/Hooks";
|
|
7
|
+
import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
8
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
9
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
10
|
+
import { beforeEach, describe, expect, jest, test } from "@jest/globals";
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* WHAT THIS FILE IS DEFENDING
|
|
14
|
+
*
|
|
15
|
+
* A verified Domain is what lets a project serve a status page on its own
|
|
16
|
+
* hostname. Verification is therefore an ownership proof, and the only thing
|
|
17
|
+
* proving it is a TXT record the project had to be able to publish. Two rules
|
|
18
|
+
* carry that, and neither can live in Postgres:
|
|
19
|
+
*
|
|
20
|
+
* 1. A CALLER CANNOT MARK ITS OWN DOMAIN VERIFIED. Not at create (where
|
|
21
|
+
* there is nothing to check against) and not at update without the DNS
|
|
22
|
+
* lookup actually passing. A caller that could set isVerified itself
|
|
23
|
+
* could claim a hostname it does not own.
|
|
24
|
+
*
|
|
25
|
+
* 2. THE VERIFICATION TEXT IS SERVER-GENERATED. If the caller chose it, it
|
|
26
|
+
* could pick a string already present in some TXT record it does not
|
|
27
|
+
* control, and the lookup would pass while proving nothing.
|
|
28
|
+
*
|
|
29
|
+
* Both have a deliberate exception each, and the exceptions are the part worth
|
|
30
|
+
* pinning because they are what an over-eager tightening would break:
|
|
31
|
+
*
|
|
32
|
+
* - ROOT bypasses verification. Internal callers create already-verified
|
|
33
|
+
* domains, so the guards are gated on `!props.isRoot` rather than applied
|
|
34
|
+
* to everyone.
|
|
35
|
+
* - TEST DOMAINS skip the DNS lookup. .test / .example.com and friends are
|
|
36
|
+
* IANA-reserved and cannot be given real DNS records, so the e2e suite
|
|
37
|
+
* could not verify a domain at all if they were not exempt.
|
|
38
|
+
*
|
|
39
|
+
* Also pinned: the domain is normalized before it is stored (a hostname
|
|
40
|
+
* matched case-sensitively would not match the request that arrives
|
|
41
|
+
* lowercased), the pre-read is tenant-scoped, and an update that does not
|
|
42
|
+
* touch isVerified pays for no read at all.
|
|
43
|
+
*
|
|
44
|
+
* Nothing here touches DNS or Postgres: the hooks are invoked directly on a
|
|
45
|
+
* fresh service instance whose reads are stubbed and whose TXT lookup is
|
|
46
|
+
* mocked.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const DOMAIN_ID: ObjectID = new ObjectID(
|
|
50
|
+
"11111111-1111-4111-8111-111111111111",
|
|
51
|
+
);
|
|
52
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
53
|
+
"22222222-2222-4222-8222-222222222222",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const VERIFICATION_TEXT: string = "oneuptime-verification-abcdefghij0123456789";
|
|
57
|
+
|
|
58
|
+
type ServiceInternals = {
|
|
59
|
+
onBeforeCreate: (createBy: CreateBy<Model>) => Promise<OnCreate<Model>>;
|
|
60
|
+
onBeforeUpdate: (updateBy: UpdateBy<Model>) => Promise<OnUpdate<Model>>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* Typed loosely on purpose: jest.spyOn's SpiedFunction and this repo's
|
|
65
|
+
* @types/jest disagree about whether mock.lastCall is optional, so the narrow
|
|
66
|
+
* generic form does not assign. Nothing below needs more than these members,
|
|
67
|
+
* and `unknown` arguments keep the call sites free of casts.
|
|
68
|
+
*/
|
|
69
|
+
type Spy = {
|
|
70
|
+
mockResolvedValue: (value: unknown) => unknown;
|
|
71
|
+
mockResolvedValueOnce: (value: unknown) => unknown;
|
|
72
|
+
mock: { calls: Array<Array<unknown>> };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
interface Harness {
|
|
76
|
+
internals: ServiceInternals;
|
|
77
|
+
findBy: Spy;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let verifyTxtRecord: Spy;
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
jest.restoreAllMocks();
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* Mocked rather than pointed at a real hostname: a test that resolves DNS
|
|
87
|
+
* fails in a sandboxed runner and, worse, passes or fails on someone else's
|
|
88
|
+
* zone file.
|
|
89
|
+
*/
|
|
90
|
+
verifyTxtRecord = jest.spyOn(Domain, "verifyTxtRecord") as unknown as Spy;
|
|
91
|
+
verifyTxtRecord.mockResolvedValue(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function buildService(rows: Array<Model> = []): Harness {
|
|
95
|
+
const service: DomainServiceType = new DomainServiceType();
|
|
96
|
+
|
|
97
|
+
const findBy: Spy = jest.spyOn(service, "findBy") as unknown as Spy;
|
|
98
|
+
findBy.mockResolvedValue(rows);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
internals: service as unknown as ServiceInternals,
|
|
102
|
+
findBy: findBy,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createBy(
|
|
107
|
+
payload: Record<string, unknown>,
|
|
108
|
+
props: DatabaseCommonInteractionProps = {
|
|
109
|
+
isRoot: false,
|
|
110
|
+
tenantId: PROJECT_ID,
|
|
111
|
+
},
|
|
112
|
+
): CreateBy<Model> {
|
|
113
|
+
return {
|
|
114
|
+
data: Object.assign(new Model(), payload) as Model,
|
|
115
|
+
props: props,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function updateBy(
|
|
120
|
+
data: Record<string, unknown>,
|
|
121
|
+
query: Record<string, unknown> = { _id: DOMAIN_ID.toString() },
|
|
122
|
+
props: DatabaseCommonInteractionProps = {
|
|
123
|
+
isRoot: false,
|
|
124
|
+
tenantId: PROJECT_ID,
|
|
125
|
+
},
|
|
126
|
+
): UpdateBy<Model> {
|
|
127
|
+
return {
|
|
128
|
+
query: query,
|
|
129
|
+
data: data,
|
|
130
|
+
props: props,
|
|
131
|
+
limit: 10,
|
|
132
|
+
skip: 0,
|
|
133
|
+
} as unknown as UpdateBy<Model>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* A row as the pre-read sees it, with only the two columns it selects. */
|
|
137
|
+
function existingRow(
|
|
138
|
+
domain: string | undefined,
|
|
139
|
+
verificationText: string | undefined,
|
|
140
|
+
): Model {
|
|
141
|
+
const row: Model = new Model(DOMAIN_ID);
|
|
142
|
+
const values: Record<string, unknown> = row as unknown as Record<
|
|
143
|
+
string,
|
|
144
|
+
unknown
|
|
145
|
+
>;
|
|
146
|
+
|
|
147
|
+
values["domain"] = domain === undefined ? undefined : new Domain(domain);
|
|
148
|
+
values["domainVerificationText"] = verificationText;
|
|
149
|
+
|
|
150
|
+
return row;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function written(create: CreateBy<Model>): Record<string, unknown> {
|
|
154
|
+
return create.data as unknown as Record<string, unknown>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
describe("a domain is normalized before it is stored", () => {
|
|
158
|
+
test("case and surrounding whitespace are removed", async () => {
|
|
159
|
+
/*
|
|
160
|
+
* The hostname arrives from a Host header lowercased, so a row stored as
|
|
161
|
+
* "Status.Example.Com" would never match the request it was created for.
|
|
162
|
+
*/
|
|
163
|
+
const harness: Harness = buildService();
|
|
164
|
+
const create: CreateBy<Model> = createBy({
|
|
165
|
+
domain: " Status.ACME.Com ",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await harness.internals.onBeforeCreate(create);
|
|
169
|
+
|
|
170
|
+
expect(String(written(create)["domain"])).toBe("status.acme.com");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("an already-constructed Domain is accepted", async () => {
|
|
174
|
+
// The API layer hands over a Domain; an internal caller hands a string.
|
|
175
|
+
const harness: Harness = buildService();
|
|
176
|
+
const create: CreateBy<Model> = createBy({
|
|
177
|
+
domain: new Domain("status.acme.com"),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await harness.internals.onBeforeCreate(create);
|
|
181
|
+
|
|
182
|
+
expect(String(written(create)["domain"])).toBe("status.acme.com");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("anything that is neither a string nor a Domain is refused", async () => {
|
|
186
|
+
/*
|
|
187
|
+
* Refused rather than coerced: String({}) would store "[object Object]"
|
|
188
|
+
* as a hostname, and Domain's own format check is the next thing that
|
|
189
|
+
* would have to catch it.
|
|
190
|
+
*/
|
|
191
|
+
const harness: Harness = buildService();
|
|
192
|
+
const create: CreateBy<Model> = createBy({
|
|
193
|
+
domain: { host: "status.acme.com" },
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await expect(harness.internals.onBeforeCreate(create)).rejects.toThrow(
|
|
197
|
+
BadDataException,
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("the verification text is chosen by the server", () => {
|
|
203
|
+
test("a create always gets one", async () => {
|
|
204
|
+
const harness: Harness = buildService();
|
|
205
|
+
const create: CreateBy<Model> = createBy({ domain: "status.acme.com" });
|
|
206
|
+
|
|
207
|
+
await harness.internals.onBeforeCreate(create);
|
|
208
|
+
|
|
209
|
+
expect(String(written(create)["domainVerificationText"])).toMatch(
|
|
210
|
+
/^oneuptime-verification-.{20}$/,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a client-supplied text is overwritten, not honoured", async () => {
|
|
215
|
+
/*
|
|
216
|
+
* The rule the ownership proof rests on. A caller that chose this string
|
|
217
|
+
* could name something already published in a TXT record on a domain it
|
|
218
|
+
* does not own, and the lookup would then pass while proving nothing.
|
|
219
|
+
*/
|
|
220
|
+
const harness: Harness = buildService();
|
|
221
|
+
const create: CreateBy<Model> = createBy({
|
|
222
|
+
domain: "status.acme.com",
|
|
223
|
+
domainVerificationText: "oneuptime-verification-attacker-chose-this",
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await harness.internals.onBeforeCreate(create);
|
|
227
|
+
|
|
228
|
+
expect(String(written(create)["domainVerificationText"])).not.toBe(
|
|
229
|
+
"oneuptime-verification-attacker-chose-this",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("two domains never get the same text", async () => {
|
|
234
|
+
const harness: Harness = buildService();
|
|
235
|
+
const texts: Array<string> = [];
|
|
236
|
+
|
|
237
|
+
for (const domain of ["one.acme.com", "two.acme.com"]) {
|
|
238
|
+
const create: CreateBy<Model> = createBy({ domain: domain });
|
|
239
|
+
await harness.internals.onBeforeCreate(create);
|
|
240
|
+
texts.push(String(written(create)["domainVerificationText"]));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
expect(new Set(texts).size).toBe(2);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("a caller cannot create a domain already marked verified", () => {
|
|
248
|
+
test("a real domain created with isVerified is refused", async () => {
|
|
249
|
+
const harness: Harness = buildService();
|
|
250
|
+
|
|
251
|
+
await expect(
|
|
252
|
+
harness.internals.onBeforeCreate(
|
|
253
|
+
createBy({ domain: "status.acme.com", isVerified: true }),
|
|
254
|
+
),
|
|
255
|
+
).rejects.toThrow(
|
|
256
|
+
"Domain cannot be verified during creation. Please verify the domain after creation. Please set isVerified to false.",
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test.each([
|
|
261
|
+
["a .test domain", "status.acme.test"],
|
|
262
|
+
["an .example.com domain", "status.example.com"],
|
|
263
|
+
["an .example.org domain", "status.example.org"],
|
|
264
|
+
["an .example.net domain", "status.example.net"],
|
|
265
|
+
])(
|
|
266
|
+
"%s may be auto-verified",
|
|
267
|
+
async (_label: string, domain: string): Promise<void> => {
|
|
268
|
+
/*
|
|
269
|
+
* These TLDs are IANA-reserved and cannot be given real DNS records, so
|
|
270
|
+
* there is no TXT record to find. Without the exemption the e2e suite
|
|
271
|
+
* could not verify a domain at all.
|
|
272
|
+
*/
|
|
273
|
+
const harness: Harness = buildService();
|
|
274
|
+
|
|
275
|
+
const result: OnCreate<Model> = await harness.internals.onBeforeCreate(
|
|
276
|
+
createBy({ domain: domain, isVerified: true }),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(result.createBy).toBeDefined();
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
test("the test-domain exemption is decided AFTER normalization", async () => {
|
|
284
|
+
/*
|
|
285
|
+
* isTestDomain reads createBy.data.domain, which the hook has already
|
|
286
|
+
* rewritten - so a mixed-case reserved domain has to be recognised. A
|
|
287
|
+
* case-sensitive suffix check here would refuse a legitimate create.
|
|
288
|
+
*/
|
|
289
|
+
const harness: Harness = buildService();
|
|
290
|
+
|
|
291
|
+
const result: OnCreate<Model> = await harness.internals.onBeforeCreate(
|
|
292
|
+
createBy({ domain: "Status.EXAMPLE.com", isVerified: true }),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(result.createBy).toBeDefined();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("root may create an already-verified real domain", async () => {
|
|
299
|
+
// Internal callers register domains that are verified by construction.
|
|
300
|
+
const harness: Harness = buildService();
|
|
301
|
+
|
|
302
|
+
const result: OnCreate<Model> = await harness.internals.onBeforeCreate(
|
|
303
|
+
createBy(
|
|
304
|
+
{ domain: "status.acme.com", isVerified: true },
|
|
305
|
+
{
|
|
306
|
+
isRoot: true,
|
|
307
|
+
},
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
expect(result.createBy).toBeDefined();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("an ordinary create is untouched by the guard", async () => {
|
|
315
|
+
const harness: Harness = buildService();
|
|
316
|
+
|
|
317
|
+
const result: OnCreate<Model> = await harness.internals.onBeforeCreate(
|
|
318
|
+
createBy({ domain: "status.acme.com" }),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
expect(result.createBy).toBeDefined();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("marking a domain verified requires the TXT record to be there", () => {
|
|
326
|
+
test("a present record verifies the domain", async () => {
|
|
327
|
+
const harness: Harness = buildService([
|
|
328
|
+
existingRow("status.acme.com", VERIFICATION_TEXT),
|
|
329
|
+
]);
|
|
330
|
+
|
|
331
|
+
const result: OnUpdate<Model> = await harness.internals.onBeforeUpdate(
|
|
332
|
+
updateBy({ isVerified: true }),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
expect(result.updateBy).toBeDefined();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("the lookup is given the domain AND the text stored for it", async () => {
|
|
339
|
+
/*
|
|
340
|
+
* Both arguments matter: looking up the right domain for the wrong text,
|
|
341
|
+
* or the reverse, would verify a domain against a record that proves
|
|
342
|
+
* nothing about it.
|
|
343
|
+
*/
|
|
344
|
+
const harness: Harness = buildService([
|
|
345
|
+
existingRow("status.acme.com", VERIFICATION_TEXT),
|
|
346
|
+
]);
|
|
347
|
+
|
|
348
|
+
await harness.internals.onBeforeUpdate(updateBy({ isVerified: true }));
|
|
349
|
+
|
|
350
|
+
const call: Array<unknown> = verifyTxtRecord.mock.calls[0]!;
|
|
351
|
+
|
|
352
|
+
expect(String(call[0])).toBe("status.acme.com");
|
|
353
|
+
expect(call[1]).toBe(VERIFICATION_TEXT);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("a missing record refuses the update and says which record to add", async () => {
|
|
357
|
+
/*
|
|
358
|
+
* The message carries the record and the domain because it is the only
|
|
359
|
+
* instruction the user gets - a bare "not verified" leaves them nothing
|
|
360
|
+
* to act on.
|
|
361
|
+
*/
|
|
362
|
+
const harness: Harness = buildService([
|
|
363
|
+
existingRow("status.acme.com", VERIFICATION_TEXT),
|
|
364
|
+
]);
|
|
365
|
+
verifyTxtRecord.mockResolvedValue(false);
|
|
366
|
+
|
|
367
|
+
await expect(
|
|
368
|
+
harness.internals.onBeforeUpdate(updateBy({ isVerified: true })),
|
|
369
|
+
).rejects.toThrow(
|
|
370
|
+
`Verification TXT record ${VERIFICATION_TEXT} not found in domain status.acme.com.`,
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("ONE unverified domain fails an update that matches several", async () => {
|
|
375
|
+
/*
|
|
376
|
+
* The query can match more than one row. Verifying only the first would
|
|
377
|
+
* let a caller mark an unowned domain verified by batching it behind one
|
|
378
|
+
* it does own.
|
|
379
|
+
*/
|
|
380
|
+
const harness: Harness = buildService([
|
|
381
|
+
existingRow("owned.acme.com", VERIFICATION_TEXT),
|
|
382
|
+
existingRow("not-owned.acme.com", VERIFICATION_TEXT),
|
|
383
|
+
]);
|
|
384
|
+
// The first domain verifies, the second does not.
|
|
385
|
+
verifyTxtRecord.mockResolvedValueOnce(true);
|
|
386
|
+
verifyTxtRecord.mockResolvedValueOnce(false);
|
|
387
|
+
|
|
388
|
+
await expect(
|
|
389
|
+
harness.internals.onBeforeUpdate(updateBy({ isVerified: true })),
|
|
390
|
+
).rejects.toThrow(BadDataException);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("a test domain is verified without any DNS lookup", async () => {
|
|
394
|
+
const harness: Harness = buildService([
|
|
395
|
+
existingRow("status.acme.test", VERIFICATION_TEXT),
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
await harness.internals.onBeforeUpdate(updateBy({ isVerified: true }));
|
|
399
|
+
|
|
400
|
+
expect(verifyTxtRecord.mock.calls).toHaveLength(0);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("a row with no stored domain is refused", async () => {
|
|
404
|
+
const harness: Harness = buildService([
|
|
405
|
+
existingRow(undefined, VERIFICATION_TEXT),
|
|
406
|
+
]);
|
|
407
|
+
|
|
408
|
+
await expect(
|
|
409
|
+
harness.internals.onBeforeUpdate(updateBy({ isVerified: true })),
|
|
410
|
+
).rejects.toThrow("Domain not found.");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("a row with no verification text is refused rather than passed empty", async () => {
|
|
414
|
+
/*
|
|
415
|
+
* An empty text handed to the lookup would ask DNS whether "" is present,
|
|
416
|
+
* which is not a question that can prove ownership.
|
|
417
|
+
*/
|
|
418
|
+
const harness: Harness = buildService([
|
|
419
|
+
existingRow("status.acme.com", undefined),
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
await expect(
|
|
423
|
+
harness.internals.onBeforeUpdate(updateBy({ isVerified: true })),
|
|
424
|
+
).rejects.toThrow(BadDataException);
|
|
425
|
+
expect(verifyTxtRecord.mock.calls).toHaveLength(0);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("root may verify without the lookup", async () => {
|
|
429
|
+
const harness: Harness = buildService();
|
|
430
|
+
|
|
431
|
+
await harness.internals.onBeforeUpdate(
|
|
432
|
+
updateBy(
|
|
433
|
+
{ isVerified: true },
|
|
434
|
+
{ _id: DOMAIN_ID.toString() },
|
|
435
|
+
{
|
|
436
|
+
isRoot: true,
|
|
437
|
+
},
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
expect(harness.findBy.mock.calls).toHaveLength(0);
|
|
442
|
+
expect(verifyTxtRecord.mock.calls).toHaveLength(0);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe("the verification read is scoped and only paid for when needed", () => {
|
|
447
|
+
test("it is scoped to the caller's project", async () => {
|
|
448
|
+
/*
|
|
449
|
+
* Tenant isolation on the read that feeds the check: without projectId the
|
|
450
|
+
* query could match another project's row and verify it.
|
|
451
|
+
*/
|
|
452
|
+
const harness: Harness = buildService([
|
|
453
|
+
existingRow("status.acme.com", VERIFICATION_TEXT),
|
|
454
|
+
]);
|
|
455
|
+
|
|
456
|
+
await harness.internals.onBeforeUpdate(updateBy({ isVerified: true }));
|
|
457
|
+
|
|
458
|
+
const call: Record<string, unknown> = harness.findBy.mock
|
|
459
|
+
.calls[0]![0] as Record<string, unknown>;
|
|
460
|
+
const query: Record<string, unknown> = call["query"] as Record<
|
|
461
|
+
string,
|
|
462
|
+
unknown
|
|
463
|
+
>;
|
|
464
|
+
|
|
465
|
+
expect(String(query["projectId"])).toBe(PROJECT_ID.toString());
|
|
466
|
+
expect(call["props"]).toEqual({ isRoot: true });
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("the project comes from the query when it carries one", async () => {
|
|
470
|
+
const harness: Harness = buildService([
|
|
471
|
+
existingRow("status.acme.com", VERIFICATION_TEXT),
|
|
472
|
+
]);
|
|
473
|
+
|
|
474
|
+
await harness.internals.onBeforeUpdate(
|
|
475
|
+
updateBy(
|
|
476
|
+
{ isVerified: true },
|
|
477
|
+
{
|
|
478
|
+
_id: DOMAIN_ID.toString(),
|
|
479
|
+
projectId: PROJECT_ID.toString(),
|
|
480
|
+
},
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const query: Record<string, unknown> = (
|
|
485
|
+
harness.findBy.mock.calls[0]![0] as Record<string, unknown>
|
|
486
|
+
)["query"] as Record<string, unknown>;
|
|
487
|
+
|
|
488
|
+
expect(String(query["projectId"])).toBe(PROJECT_ID.toString());
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("with no project anywhere the update is refused, not run unscoped", async () => {
|
|
492
|
+
/*
|
|
493
|
+
* The failure mode this prevents: an unscoped query matching every
|
|
494
|
+
* project's domains.
|
|
495
|
+
*/
|
|
496
|
+
const harness: Harness = buildService();
|
|
497
|
+
|
|
498
|
+
await expect(
|
|
499
|
+
harness.internals.onBeforeUpdate(
|
|
500
|
+
updateBy(
|
|
501
|
+
{ isVerified: true },
|
|
502
|
+
{ _id: DOMAIN_ID.toString() },
|
|
503
|
+
{
|
|
504
|
+
isRoot: false,
|
|
505
|
+
},
|
|
506
|
+
),
|
|
507
|
+
),
|
|
508
|
+
).rejects.toThrow("Project ID is required to verify the domain.");
|
|
509
|
+
expect(harness.findBy.mock.calls).toHaveLength(0);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("an update that does not touch isVerified reads nothing", async () => {
|
|
513
|
+
/*
|
|
514
|
+
* Renaming a domain, or any other patch, must not start paying for a read
|
|
515
|
+
* and a DNS lookup it cannot be affected by.
|
|
516
|
+
*/
|
|
517
|
+
const harness: Harness = buildService();
|
|
518
|
+
|
|
519
|
+
await harness.internals.onBeforeUpdate(updateBy({ isVerified: false }));
|
|
520
|
+
await harness.internals.onBeforeUpdate(updateBy({ description: "prod" }));
|
|
521
|
+
|
|
522
|
+
expect(harness.findBy.mock.calls).toHaveLength(0);
|
|
523
|
+
expect(verifyTxtRecord.mock.calls).toHaveLength(0);
|
|
524
|
+
});
|
|
525
|
+
});
|