@oneuptime/common 14.0.6 → 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.
Files changed (127) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  10. package/Server/Services/UserNotificationRuleService.ts +55 -53
  11. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  12. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  13. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  14. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  15. package/Server/Utils/Response.ts +31 -0
  16. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  17. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  18. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  19. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  20. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  21. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  22. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  23. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  24. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  25. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  26. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  27. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  28. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  29. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  30. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  31. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  32. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  33. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  34. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  35. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  36. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  37. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  38. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  39. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  40. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  41. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  42. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  43. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  44. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  45. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  46. package/Tests/Server/Services/DomainService.test.ts +525 -0
  47. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  48. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  49. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  50. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  51. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  52. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  53. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  54. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  55. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  56. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  57. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  58. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  59. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  60. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  61. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  62. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  63. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  64. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  65. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  66. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  67. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  68. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  69. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  70. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  71. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  72. package/Types/Incident/IncidentAlertLink.ts +19 -0
  73. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  74. package/Types/Topology/TopologyApi.ts +428 -0
  75. package/Types/Topology/TopologyTypeRules.ts +191 -0
  76. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  77. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  78. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  82. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  83. package/build/dist/Server/Services/AlertService.js +21 -33
  84. package/build/dist/Server/Services/AlertService.js.map +1 -1
  85. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  86. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  87. package/build/dist/Server/Services/IncidentService.js +96 -42
  88. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  89. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  90. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  91. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  92. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  93. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  94. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  95. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  96. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  97. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  98. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  99. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  100. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  101. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  102. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  103. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  104. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  105. package/build/dist/Server/Utils/Response.js +30 -0
  106. package/build/dist/Server/Utils/Response.js.map +1 -1
  107. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  108. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  109. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  110. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  111. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  112. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  113. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  114. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  115. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  116. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  117. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  118. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  119. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  120. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  121. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  122. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  123. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  124. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  125. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  126. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  127. package/package.json +1 -1
@@ -87,9 +87,20 @@ const FOREIGN_KEY_COLUMNS: Array<Array<string>> = [
87
87
  ["deletedByUserId"],
88
88
  ];
89
89
 
90
- type DeclaredIndexColumnsFunction = (
91
- modelType: ModelClass,
92
- ) => Array<Array<string>>;
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 getDeclaredIndexColumns: DeclaredIndexColumnsFunction = (
111
+ const getDeclaredIndexes: DeclaredIndexesFunction = (
101
112
  modelType: ModelClass,
102
- ): Array<Array<string>> => {
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): Array<string> => {
108
- if (Array.isArray(index.columns)) {
109
- return index.columns as Array<string>;
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 [String((index as { propertyName?: string }).propertyName)];
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
- function getIndexesCreatedAfterTheRename(): Set<string> {
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 created: Set<string> = new Set<string>();
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
- const source: string = fs.readFileSync(path.join(directory, file), "utf8");
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<Array<string>> = getDeclaredIndexColumns(
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 columns of declared) {
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 columns of getDeclaredIndexColumns(table.modelType)) {
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
+ });