@oneuptime/common 12.0.3 → 12.0.4

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 (173) hide show
  1. package/Models/DatabaseModels/CephCluster.ts +14 -0
  2. package/Models/DatabaseModels/CodeRepository.ts +14 -6
  3. package/Models/DatabaseModels/DockerHost.ts +14 -0
  4. package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
  5. package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
  6. package/Models/DatabaseModels/IoTFleet.ts +14 -0
  7. package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +14 -0
  9. package/Models/DatabaseModels/NetworkInterface.ts +18 -0
  10. package/Models/DatabaseModels/Project.ts +16 -2
  11. package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
  12. package/Models/DatabaseModels/Service.ts +30 -0
  13. package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
  14. package/Models/DatabaseModels/User.ts +30 -0
  15. package/Server/API/AIAgentDataAPI.ts +31 -0
  16. package/Server/API/DashboardAPI.ts +419 -12
  17. package/Server/API/GitHubAPI.ts +119 -283
  18. package/Server/API/UserAPI.ts +263 -1
  19. package/Server/EnvironmentConfig.ts +7 -3
  20. package/Server/Infrastructure/GlobalCache.ts +37 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
  27. package/Server/Services/AnalyticsDatabaseService.ts +72 -1
  28. package/Server/Services/CodeRepositoryService.ts +105 -2
  29. package/Server/Services/DatabaseService.ts +185 -2
  30. package/Server/Services/OpenTelemetryIngestService.ts +230 -26
  31. package/Server/Services/UserService.ts +184 -0
  32. package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
  33. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
  34. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
  35. package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
  36. package/Server/Utils/PasswordHash.ts +306 -0
  37. package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
  38. package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
  39. package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
  40. package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
  41. package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
  42. package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
  43. package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
  44. package/Tests/Server/API/Helpers.ts +6 -1
  45. package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
  46. package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
  47. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
  48. package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
  49. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
  50. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
  51. package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
  52. package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
  53. package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
  54. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
  55. package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
  56. package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
  57. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
  58. package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
  59. package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
  60. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
  62. package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
  63. package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
  64. package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
  65. package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
  66. package/Tests/Types/Database/ColumnLength.test.ts +7 -1
  67. package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
  68. package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
  69. package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
  70. package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
  71. package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
  72. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
  73. package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
  74. package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
  75. package/Types/Database/ColumnLength.ts +9 -1
  76. package/Types/Database/TableColumn.ts +11 -0
  77. package/Types/Database/UnsynchronizedIndex.ts +48 -0
  78. package/Types/HashedString.ts +139 -4
  79. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
  80. package/UI/Components/Navbar/NavBar.tsx +12 -2
  81. package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
  82. package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
  83. package/Utils/TeamMembersByProject.ts +237 -0
  84. package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
  85. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
  87. package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
  89. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
  91. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
  93. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
  95. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
  97. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
  99. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
  101. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/Project.js +16 -2
  103. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
  105. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/Service.js +28 -1
  107. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
  109. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  110. package/build/dist/Models/DatabaseModels/User.js +32 -0
  111. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  112. package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
  113. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  114. package/build/dist/Server/API/DashboardAPI.js +304 -10
  115. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  116. package/build/dist/Server/API/GitHubAPI.js +94 -167
  117. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  118. package/build/dist/Server/API/UserAPI.js +187 -2
  119. package/build/dist/Server/API/UserAPI.js.map +1 -1
  120. package/build/dist/Server/EnvironmentConfig.js +7 -3
  121. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
  123. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  136. package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
  137. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  138. package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
  139. package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
  140. package/build/dist/Server/Services/DatabaseService.js +160 -2
  141. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  142. package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
  143. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  144. package/build/dist/Server/Services/UserService.js +165 -0
  145. package/build/dist/Server/Services/UserService.js.map +1 -1
  146. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
  147. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
  148. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
  149. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  150. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
  151. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  152. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
  153. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
  154. package/build/dist/Server/Utils/PasswordHash.js +237 -0
  155. package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
  156. package/build/dist/Types/Database/ColumnLength.js +9 -1
  157. package/build/dist/Types/Database/ColumnLength.js.map +1 -1
  158. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  159. package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
  160. package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
  161. package/build/dist/Types/HashedString.js +95 -5
  162. package/build/dist/Types/HashedString.js.map +1 -1
  163. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
  164. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  165. package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
  166. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  167. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
  168. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  169. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
  170. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
  171. package/build/dist/Utils/TeamMembersByProject.js +145 -0
  172. package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
  173. package/package.json +1 -1
@@ -0,0 +1,885 @@
1
+ import PositiveNumber from "../../../Types/PositiveNumber";
2
+ import Email from "../../../Types/Email";
3
+ import { getJestSpyOn } from "../../Spy";
4
+ import {
5
+ beforeAll,
6
+ beforeEach,
7
+ describe,
8
+ expect,
9
+ jest,
10
+ test,
11
+ } from "@jest/globals";
12
+
13
+ /*
14
+ * ---------------------------------------------------------------------------
15
+ * GHSA-3qqq-hprx-g2jw -- "First-signup race condition can grant Master Admin to
16
+ * two users at once".
17
+ *
18
+ * On a self-hosted instance the first person to sign up becomes Master Admin.
19
+ * The check was a plain read followed by a plain write:
20
+ *
21
+ * const userCount = await UserService.countBy({ query: {} });
22
+ * partialUser.isMasterAdmin = userCount.isZero();
23
+ * ...
24
+ * await UserService.create({ data: partialUser });
25
+ *
26
+ * Two signups arriving together both finished the COUNT before either INSERT
27
+ * committed, so both read zero and both were created as Master Admin. Nothing
28
+ * downstream reconciles that, and there is no uniqueness constraint to catch it
29
+ * -- multiple master admins are a supported product state (the Admin Dashboard
30
+ * has a per-user toggle, and the instance-health jobs page through all of them),
31
+ * so the reporter's suggested partial unique index is not available as a fix.
32
+ *
33
+ * The fix serializes the check and the insert together, instance-wide, with the
34
+ * Redis mutex in Common/Server/Infrastructure/Semaphore.ts -- the same
35
+ * primitive every other cross-process critical section in this codebase uses.
36
+ * It was originally a Postgres advisory lock; the mutex holds no database
37
+ * connection and no open transaction, so serializing signups can no longer pin
38
+ * a pooled backend `idle in transaction`.
39
+ *
40
+ * WHAT THESE TESTS ACTUALLY EXERCISE: the real UserService.createUserOnSignup.
41
+ * Only the two edges are faked -- the rows (countBy/create against an in-memory
42
+ * array) and Redis (an in-process mutex standing in for the redis-semaphore
43
+ * Mutex). The fake mutex has the semantics that matter: exclusive per key, and
44
+ * handed to the next waiter on release.
45
+ *
46
+ * The suite is kept honest by `serializeLock = false`, which runs the same race
47
+ * with the mutex neutered. That case asserts the vulnerable outcome -- two
48
+ * master admins -- so a harness that could not observe the bug in the first
49
+ * place fails loudly instead of passing vacuously.
50
+ * ---------------------------------------------------------------------------
51
+ */
52
+
53
+ /*
54
+ * IsBillingEnabled is read from process.env at import time, and the test
55
+ * environment sets BILLING_ENABLED=true. Both deployment shapes therefore have
56
+ * to be loaded deliberately rather than inherited from whatever the runner
57
+ * happens to export.
58
+ */
59
+ interface LoadedModules {
60
+ userService: any;
61
+ projectService: any;
62
+ semaphore: any;
63
+ logger: any;
64
+ User: any;
65
+ }
66
+
67
+ type LoadModulesFunction = (data: { billingEnabled: boolean }) => LoadedModules;
68
+
69
+ const loadModules: LoadModulesFunction = (data: {
70
+ billingEnabled: boolean;
71
+ }): LoadedModules => {
72
+ const previous: string | undefined = process.env["BILLING_ENABLED"];
73
+ process.env["BILLING_ENABLED"] = data.billingEnabled ? "true" : "false";
74
+
75
+ let loaded: LoadedModules | null = null;
76
+
77
+ jest.isolateModules(() => {
78
+ /* eslint-disable @typescript-eslint/no-var-requires */
79
+ /* eslint-disable @typescript-eslint/no-require-imports */
80
+ loaded = {
81
+ userService: require("../../../Server/Services/UserService").default,
82
+ projectService: require("../../../Server/Services/ProjectService")
83
+ .default,
84
+ semaphore: require("../../../Server/Infrastructure/Semaphore").default,
85
+ logger: require("../../../Server/Utils/Logger").default,
86
+ User: require("../../../Models/DatabaseModels/User").default,
87
+ };
88
+ /* eslint-enable @typescript-eslint/no-require-imports */
89
+ /* eslint-enable @typescript-eslint/no-var-requires */
90
+ });
91
+
92
+ if (previous === undefined) {
93
+ delete process.env["BILLING_ENABLED"];
94
+ } else {
95
+ process.env["BILLING_ENABLED"] = previous;
96
+ }
97
+
98
+ return loaded as unknown as LoadedModules;
99
+ };
100
+
101
+ /*
102
+ * Stands in for the redis-semaphore Mutex: exclusive per key, FIFO, and handed
103
+ * straight to the next waiter on release so ownership is never ambiguous.
104
+ */
105
+ class FakeRedisMutexes {
106
+ private held: Set<string> = new Set();
107
+ private waiters: Map<string, Array<() => void>> = new Map();
108
+
109
+ public async acquire(key: string): Promise<void> {
110
+ if (!this.held.has(key)) {
111
+ this.held.add(key);
112
+ return;
113
+ }
114
+
115
+ await new Promise<void>((resolve: () => void) => {
116
+ const queue: Array<() => void> = this.waiters.get(key) || [];
117
+ queue.push(resolve);
118
+ this.waiters.set(key, queue);
119
+ });
120
+ }
121
+
122
+ public release(key: string): void {
123
+ const queue: Array<() => void> | undefined = this.waiters.get(key);
124
+
125
+ if (queue && queue.length > 0) {
126
+ // Ownership transfers directly to the next waiter; the key stays held.
127
+ const next: () => void = queue.shift()!;
128
+ next();
129
+ return;
130
+ }
131
+
132
+ this.held.delete(key);
133
+ }
134
+
135
+ public isHeld(key: string): boolean {
136
+ return this.held.has(key);
137
+ }
138
+ }
139
+
140
+ type YieldToEventLoopFunction = () => Promise<void>;
141
+
142
+ /*
143
+ * A real countBy/create is a network round trip. Yielding here is what lets two
144
+ * in-flight signups interleave at all -- without it the whole election would run
145
+ * to completion inside one synchronous microtask burst and no race could exist
146
+ * in the harness, mutex or no mutex.
147
+ */
148
+ const yieldToEventLoop: YieldToEventLoopFunction = (): Promise<void> => {
149
+ return new Promise<void>((resolve: () => void) => {
150
+ setTimeout(resolve, 0);
151
+ });
152
+ };
153
+
154
+ /*
155
+ * The coordination protocol between replicas. Pinned literally rather than
156
+ * imported: a rename that silently changed the key would let two deploys of
157
+ * different versions elect two master admins, which is precisely the bug.
158
+ */
159
+ const LOCK_NAMESPACE: string = "UserService.firstMasterAdminElection";
160
+ const LOCK_KEY: string = "instance";
161
+ const LOCK_TIMEOUT_MS: number = 10_000;
162
+ const ACQUIRE_TIMEOUT_MS: number = 5_000;
163
+
164
+ describe("UserService.createUserOnSignup -- first Master Admin election", () => {
165
+ let selfHosted: LoadedModules;
166
+ let hosted: LoadedModules;
167
+
168
+ let mutexes: FakeRedisMutexes;
169
+ let userRows: Array<any>;
170
+ let projectCount: number;
171
+ let events: Array<string>;
172
+ let releases: number;
173
+ let serializeLock: boolean;
174
+
175
+ beforeAll(() => {
176
+ // Loading the module graph is expensive; do it once per deployment shape.
177
+ selfHosted = loadModules({ billingEnabled: false });
178
+ hosted = loadModules({ billingEnabled: true });
179
+ });
180
+
181
+ type BuildUserFunction = (data: {
182
+ modules: LoadedModules;
183
+ email: string;
184
+ isMasterAdmin?: boolean | undefined;
185
+ }) => any;
186
+
187
+ const buildUser: BuildUserFunction = (data: {
188
+ modules: LoadedModules;
189
+ email: string;
190
+ isMasterAdmin?: boolean | undefined;
191
+ }): any => {
192
+ const user: any = new data.modules.User();
193
+ user.email = new Email(data.email);
194
+
195
+ if (data.isMasterAdmin !== undefined) {
196
+ user.isMasterAdmin = data.isMasterAdmin;
197
+ }
198
+
199
+ return user;
200
+ };
201
+
202
+ type WireUpFunction = (modules: LoadedModules) => void;
203
+
204
+ const wireUp: WireUpFunction = (modules: LoadedModules): void => {
205
+ getJestSpyOn(modules.semaphore, "lock").mockImplementation(
206
+ async (data: any): Promise<any> => {
207
+ const key: string = `${data.namespace}-${data.key}`;
208
+
209
+ if (serializeLock) {
210
+ await mutexes.acquire(key);
211
+ }
212
+
213
+ events.push("lock");
214
+
215
+ // Stands in for the SemaphoreMutex handle the real lock() returns.
216
+ return { key };
217
+ },
218
+ );
219
+
220
+ getJestSpyOn(modules.semaphore, "release").mockImplementation(
221
+ async (mutex: any): Promise<void> => {
222
+ releases++;
223
+
224
+ if (serializeLock) {
225
+ mutexes.release(mutex.key);
226
+ }
227
+
228
+ events.push("unlock");
229
+ },
230
+ );
231
+
232
+ getJestSpyOn(modules.userService, "countBy").mockImplementation(
233
+ async (): Promise<PositiveNumber> => {
234
+ await yieldToEventLoop();
235
+ events.push("countUsers");
236
+ return new PositiveNumber(userRows.length);
237
+ },
238
+ );
239
+
240
+ getJestSpyOn(modules.projectService, "countBy").mockImplementation(
241
+ async (): Promise<PositiveNumber> => {
242
+ await yieldToEventLoop();
243
+ events.push("countProjects");
244
+ return new PositiveNumber(projectCount);
245
+ },
246
+ );
247
+
248
+ getJestSpyOn(modules.userService, "create").mockImplementation(
249
+ async (createBy: any): Promise<any> => {
250
+ await yieldToEventLoop();
251
+ userRows.push(createBy.data);
252
+ events.push("create");
253
+ return createBy.data;
254
+ },
255
+ );
256
+
257
+ getJestSpyOn(modules.logger, "warn").mockImplementation((): void => {
258
+ return undefined;
259
+ });
260
+
261
+ getJestSpyOn(modules.logger, "error").mockImplementation((): void => {
262
+ return undefined;
263
+ });
264
+ };
265
+
266
+ beforeEach(() => {
267
+ jest.restoreAllMocks();
268
+
269
+ mutexes = new FakeRedisMutexes();
270
+ userRows = [];
271
+ projectCount = 0;
272
+ events = [];
273
+ releases = 0;
274
+ serializeLock = true;
275
+
276
+ wireUp(selfHosted);
277
+ wireUp(hosted);
278
+ });
279
+
280
+ describe("self-hosted (billing disabled)", () => {
281
+ test("elects the very first user of an empty instance as Master Admin", async () => {
282
+ const created: any = await selfHosted.userService.createUserOnSignup({
283
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
284
+ props: { isRoot: true },
285
+ });
286
+
287
+ expect(created.isMasterAdmin).toBe(true);
288
+ });
289
+
290
+ test("does not elect anyone once the instance already has a user", async () => {
291
+ userRows.push({ email: "existing@example.com" });
292
+
293
+ const created: any = await selfHosted.userService.createUserOnSignup({
294
+ user: buildUser({ modules: selfHosted, email: "second@example.com" }),
295
+ props: { isRoot: true },
296
+ });
297
+
298
+ expect(created.isMasterAdmin).toBe(false);
299
+ });
300
+
301
+ test("overwrites an isMasterAdmin smuggled in by the caller", async () => {
302
+ /*
303
+ * Signup creates with isRoot, which bypasses the column's empty
304
+ * `create: []` access control -- so a request body carrying
305
+ * isMasterAdmin: true would be honoured if this method did not assign the
306
+ * column on every path.
307
+ */
308
+ userRows.push({ email: "existing@example.com" });
309
+
310
+ const created: any = await selfHosted.userService.createUserOnSignup({
311
+ user: buildUser({
312
+ modules: selfHosted,
313
+ email: "attacker@example.com",
314
+ isMasterAdmin: true,
315
+ }),
316
+ props: { isRoot: true },
317
+ });
318
+
319
+ expect(created.isMasterAdmin).toBe(false);
320
+ expect(userRows[userRows.length - 1].isMasterAdmin).toBe(false);
321
+ });
322
+
323
+ test("refuses to elect when the User table is empty but projects exist", async () => {
324
+ /*
325
+ * The advisory calls this out: the race re-opens any time the User table
326
+ * reaches zero rows -- a bad restore, a tenant-deletion script, a partial
327
+ * backup. Projects with no users at all is not reachable through any
328
+ * supported path (a user who belongs to a project cannot be deleted), so
329
+ * it means the instance was emptied out from under itself. Handing Master
330
+ * Admin to the next stranger who signs up is not an acceptable recovery.
331
+ */
332
+ projectCount = 3;
333
+
334
+ const created: any = await selfHosted.userService.createUserOnSignup({
335
+ user: buildUser({ modules: selfHosted, email: "stranger@example.com" }),
336
+ props: { isRoot: true },
337
+ });
338
+
339
+ expect(created.isMasterAdmin).toBe(false);
340
+ expect(selfHosted.logger.warn).toHaveBeenCalled();
341
+ });
342
+
343
+ test("still elects on a genuinely fresh instance -- no users and no projects", async () => {
344
+ projectCount = 0;
345
+
346
+ const created: any = await selfHosted.userService.createUserOnSignup({
347
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
348
+ props: { isRoot: true },
349
+ });
350
+
351
+ expect(created.isMasterAdmin).toBe(true);
352
+ });
353
+
354
+ test("does not bother counting projects when users already exist", async () => {
355
+ userRows.push({ email: "existing@example.com" });
356
+
357
+ await selfHosted.userService.createUserOnSignup({
358
+ user: buildUser({ modules: selfHosted, email: "second@example.com" }),
359
+ props: { isRoot: true },
360
+ });
361
+
362
+ expect(selfHosted.projectService.countBy).not.toHaveBeenCalled();
363
+ });
364
+
365
+ test("counts and creates inside the same critical section", async () => {
366
+ await selfHosted.userService.createUserOnSignup({
367
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
368
+ props: { isRoot: true },
369
+ });
370
+
371
+ /*
372
+ * The load-bearing shape: the INSERT is inside the lock, not just the
373
+ * COUNT. Releasing after the count would let the next holder count a
374
+ * table the winner had not written to yet -- the same race with extra
375
+ * steps.
376
+ */
377
+ expect(events).toEqual([
378
+ "lock",
379
+ "countUsers",
380
+ "countProjects",
381
+ "create",
382
+ "unlock",
383
+ ]);
384
+ });
385
+
386
+ test("takes the lock before it reads anything", async () => {
387
+ /*
388
+ * A check-then-lock ordering would be the same vulnerability: both
389
+ * racers would finish their COUNT before either took the mutex.
390
+ */
391
+ await selfHosted.userService.createUserOnSignup({
392
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
393
+ props: { isRoot: true },
394
+ });
395
+
396
+ expect(events[0]).toBe("lock");
397
+ expect(events.indexOf("lock")).toBeLessThan(events.indexOf("countUsers"));
398
+ });
399
+
400
+ test("passes the caller's props through to create", async () => {
401
+ await selfHosted.userService.createUserOnSignup({
402
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
403
+ props: { isRoot: true },
404
+ });
405
+
406
+ const call: any = (selfHosted.userService.create as any).mock.calls[0][0];
407
+ expect(call.props).toEqual({ isRoot: true });
408
+ });
409
+
410
+ test("releases the lock when the create fails", async () => {
411
+ getJestSpyOn(selfHosted.userService, "create").mockImplementation(
412
+ async (): Promise<never> => {
413
+ throw new Error("insert failed");
414
+ },
415
+ );
416
+
417
+ await expect(
418
+ selfHosted.userService.createUserOnSignup({
419
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
420
+ props: { isRoot: true },
421
+ }),
422
+ ).rejects.toThrow("insert failed");
423
+
424
+ // A leaked lock would block every subsequent signup on the instance.
425
+ expect(events).toContain("unlock");
426
+ expect(releases).toBe(1);
427
+ expect(mutexes.isHeld(`${LOCK_NAMESPACE}-${LOCK_KEY}`)).toBe(false);
428
+ });
429
+
430
+ test("releases the lock when the eligibility read fails", async () => {
431
+ getJestSpyOn(selfHosted.userService, "countBy").mockImplementation(
432
+ async (): Promise<never> => {
433
+ throw new Error("count failed");
434
+ },
435
+ );
436
+
437
+ await expect(
438
+ selfHosted.userService.createUserOnSignup({
439
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
440
+ props: { isRoot: true },
441
+ }),
442
+ ).rejects.toThrow("count failed");
443
+
444
+ expect(releases).toBe(1);
445
+ expect(mutexes.isHeld(`${LOCK_NAMESPACE}-${LOCK_KEY}`)).toBe(false);
446
+ });
447
+
448
+ test("releases the lock exactly once on the happy path", async () => {
449
+ await selfHosted.userService.createUserOnSignup({
450
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
451
+ props: { isRoot: true },
452
+ });
453
+
454
+ // A double release would hand the lock to two waiters at once.
455
+ expect(releases).toBe(1);
456
+ expect(mutexes.isHeld(`${LOCK_NAMESPACE}-${LOCK_KEY}`)).toBe(false);
457
+ });
458
+
459
+ test("a signup that fails does not stop the next one from being elected", async () => {
460
+ getJestSpyOn(selfHosted.userService, "create").mockImplementationOnce(
461
+ async (): Promise<never> => {
462
+ throw new Error("insert failed");
463
+ },
464
+ );
465
+
466
+ await expect(
467
+ selfHosted.userService.createUserOnSignup({
468
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
469
+ props: { isRoot: true },
470
+ }),
471
+ ).rejects.toThrow("insert failed");
472
+
473
+ const created: any = await selfHosted.userService.createUserOnSignup({
474
+ user: buildUser({ modules: selfHosted, email: "second@example.com" }),
475
+ props: { isRoot: true },
476
+ });
477
+
478
+ expect(created.isMasterAdmin).toBe(true);
479
+ });
480
+ });
481
+
482
+ describe("how the mutex is taken", () => {
483
+ test("uses the expected key, namespace and timeouts", async () => {
484
+ await selfHosted.userService.createUserOnSignup({
485
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
486
+ props: { isRoot: true },
487
+ });
488
+
489
+ expect(selfHosted.semaphore.lock).toHaveBeenCalledTimes(1);
490
+ expect((selfHosted.semaphore.lock as any).mock.calls[0][0]).toEqual({
491
+ key: LOCK_KEY,
492
+ namespace: LOCK_NAMESPACE,
493
+ lockTimeout: LOCK_TIMEOUT_MS,
494
+ acquireTimeout: ACQUIRE_TIMEOUT_MS,
495
+ });
496
+ });
497
+
498
+ test("bounds the wait rather than blocking the request indefinitely", async () => {
499
+ /*
500
+ * Signup is user-facing. redis-semaphore defaults acquireAttemptsLimit to
501
+ * Infinity, so an acquireTimeout is the only thing standing between a
502
+ * contended lock and a request that hangs until the client gives up.
503
+ */
504
+ await selfHosted.userService.createUserOnSignup({
505
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
506
+ props: { isRoot: true },
507
+ });
508
+
509
+ const options: any = (selfHosted.semaphore.lock as any).mock.calls[0][0];
510
+
511
+ expect(options.acquireTimeout).toBeGreaterThan(0);
512
+ expect(options.acquireTimeout).toBeLessThanOrEqual(10_000);
513
+ });
514
+
515
+ test("gives the lock a crash bound longer than the wait it imposes", async () => {
516
+ /*
517
+ * lockTimeout is what frees the key when a holder is SIGKILLed mid
518
+ * election. It has to outlive the acquire wait, or a waiter would time
519
+ * out before a dead holder's key ever expired.
520
+ */
521
+ await selfHosted.userService.createUserOnSignup({
522
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
523
+ props: { isRoot: true },
524
+ });
525
+
526
+ const options: any = (selfHosted.semaphore.lock as any).mock.calls[0][0];
527
+
528
+ expect(options.lockTimeout).toBeGreaterThan(options.acquireTimeout);
529
+ });
530
+ });
531
+
532
+ describe("when Redis will not cooperate", () => {
533
+ test("fails the signup instead of electing unserialized when the lock cannot be acquired", async () => {
534
+ /*
535
+ * The whole point. Falling back to an unlocked election on a Redis
536
+ * outage would reinstate GHSA-3qqq-hprx-g2jw exactly, and it would do so
537
+ * at the worst possible moment -- a fresh instance whose Redis is not up
538
+ * yet is precisely when the User table is empty.
539
+ */
540
+ getJestSpyOn(selfHosted.semaphore, "lock").mockImplementation(
541
+ async (): Promise<never> => {
542
+ throw new Error("Redis client is not connected");
543
+ },
544
+ );
545
+
546
+ await expect(
547
+ selfHosted.userService.createUserOnSignup({
548
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
549
+ props: { isRoot: true },
550
+ }),
551
+ ).rejects.toThrow("Redis client is not connected");
552
+
553
+ expect(selfHosted.userService.create).not.toHaveBeenCalled();
554
+ expect(userRows).toHaveLength(0);
555
+ });
556
+
557
+ test("does not try to release a lock it never acquired", async () => {
558
+ getJestSpyOn(selfHosted.semaphore, "lock").mockImplementation(
559
+ async (): Promise<never> => {
560
+ throw new Error("acquire-timeout");
561
+ },
562
+ );
563
+
564
+ await expect(
565
+ selfHosted.userService.createUserOnSignup({
566
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
567
+ props: { isRoot: true },
568
+ }),
569
+ ).rejects.toThrow("acquire-timeout");
570
+
571
+ // Releasing a mutex nobody holds can free somebody else's critical section.
572
+ expect(selfHosted.semaphore.release).not.toHaveBeenCalled();
573
+ });
574
+
575
+ test("logs why the signup was refused", async () => {
576
+ getJestSpyOn(selfHosted.semaphore, "lock").mockImplementation(
577
+ async (): Promise<never> => {
578
+ throw new Error("Redis client is not connected");
579
+ },
580
+ );
581
+
582
+ await expect(
583
+ selfHosted.userService.createUserOnSignup({
584
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
585
+ props: { isRoot: true },
586
+ }),
587
+ ).rejects.toThrow();
588
+
589
+ /*
590
+ * "Signups 500 and nothing says why" is an outage nobody can diagnose.
591
+ */
592
+ expect(selfHosted.logger.error).toHaveBeenCalled();
593
+ });
594
+
595
+ test("a failed release does not mask a successful signup", async () => {
596
+ getJestSpyOn(selfHosted.semaphore, "release").mockImplementation(
597
+ async (): Promise<never> => {
598
+ throw new Error("redis closed");
599
+ },
600
+ );
601
+
602
+ const created: any = await selfHosted.userService.createUserOnSignup({
603
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
604
+ props: { isRoot: true },
605
+ });
606
+
607
+ /*
608
+ * The row is already committed at this point. Throwing here would tell
609
+ * the caller their signup failed when it did not, and the lock expires on
610
+ * its own anyway.
611
+ */
612
+ expect(created.isMasterAdmin).toBe(true);
613
+ expect(selfHosted.logger.error).toHaveBeenCalled();
614
+ });
615
+
616
+ test("a failed release does not mask the real failure underneath it", async () => {
617
+ getJestSpyOn(selfHosted.userService, "create").mockImplementation(
618
+ async (): Promise<never> => {
619
+ throw new Error("insert failed");
620
+ },
621
+ );
622
+
623
+ getJestSpyOn(selfHosted.semaphore, "release").mockImplementation(
624
+ async (): Promise<never> => {
625
+ throw new Error("redis closed");
626
+ },
627
+ );
628
+
629
+ // The caller needs "insert failed", not a connection-management detail.
630
+ await expect(
631
+ selfHosted.userService.createUserOnSignup({
632
+ user: buildUser({ modules: selfHosted, email: "first@example.com" }),
633
+ props: { isRoot: true },
634
+ }),
635
+ ).rejects.toThrow("insert failed");
636
+ });
637
+ });
638
+
639
+ describe("hosted (billing enabled)", () => {
640
+ test("never elects a Master Admin", async () => {
641
+ const created: any = await hosted.userService.createUserOnSignup({
642
+ user: buildUser({
643
+ modules: hosted,
644
+ email: "first@example.com",
645
+ isMasterAdmin: true,
646
+ }),
647
+ props: { isRoot: true },
648
+ });
649
+
650
+ expect(created.isMasterAdmin).toBe(false);
651
+ });
652
+
653
+ test("takes no lock and runs no bootstrap queries", async () => {
654
+ await hosted.userService.createUserOnSignup({
655
+ user: buildUser({ modules: hosted, email: "first@example.com" }),
656
+ props: { isRoot: true },
657
+ });
658
+
659
+ /*
660
+ * There is no first-user bootstrap on the hosted service, so there is
661
+ * nothing to serialize -- every signup paying for a Redis round trip
662
+ * would be pure cost, and a Redis blip would take signup down with it.
663
+ */
664
+ expect(events).toEqual(["create"]);
665
+ expect(hosted.userService.countBy).not.toHaveBeenCalled();
666
+ expect(hosted.projectService.countBy).not.toHaveBeenCalled();
667
+ expect(hosted.semaphore.lock).not.toHaveBeenCalled();
668
+ expect(hosted.semaphore.release).not.toHaveBeenCalled();
669
+ });
670
+
671
+ test("signup survives a Redis outage", async () => {
672
+ getJestSpyOn(hosted.semaphore, "lock").mockImplementation(
673
+ async (): Promise<never> => {
674
+ throw new Error("Redis client is not connected");
675
+ },
676
+ );
677
+
678
+ const created: any = await hosted.userService.createUserOnSignup({
679
+ user: buildUser({ modules: hosted, email: "first@example.com" }),
680
+ props: { isRoot: true },
681
+ });
682
+
683
+ expect(created).toBeTruthy();
684
+ });
685
+ });
686
+
687
+ describe("the race itself", () => {
688
+ test("two simultaneous signups produce exactly one Master Admin", async () => {
689
+ const [first, second]: Array<any> = await Promise.all([
690
+ selfHosted.userService.createUserOnSignup({
691
+ user: buildUser({ modules: selfHosted, email: "race1@example.com" }),
692
+ props: { isRoot: true },
693
+ }),
694
+ selfHosted.userService.createUserOnSignup({
695
+ user: buildUser({ modules: selfHosted, email: "race2@example.com" }),
696
+ props: { isRoot: true },
697
+ }),
698
+ ]);
699
+
700
+ const elected: Array<any> = [first, second].filter((user: any) => {
701
+ return user.isMasterAdmin === true;
702
+ });
703
+
704
+ expect(elected).toHaveLength(1);
705
+ expect(userRows).toHaveLength(2);
706
+ });
707
+
708
+ test("ten simultaneous signups produce exactly one Master Admin", async () => {
709
+ const signups: Array<Promise<any>> = [];
710
+
711
+ for (let index: number = 0; index < 10; index++) {
712
+ signups.push(
713
+ selfHosted.userService.createUserOnSignup({
714
+ user: buildUser({
715
+ modules: selfHosted,
716
+ email: `race${index}@example.com`,
717
+ }),
718
+ props: { isRoot: true },
719
+ }),
720
+ );
721
+ }
722
+
723
+ const created: Array<any> = await Promise.all(signups);
724
+
725
+ const elected: Array<any> = created.filter((user: any) => {
726
+ return user.isMasterAdmin === true;
727
+ });
728
+
729
+ expect(elected).toHaveLength(1);
730
+ expect(userRows).toHaveLength(10);
731
+ });
732
+
733
+ test("critical sections never interleave", async () => {
734
+ const signups: Array<Promise<any>> = [];
735
+
736
+ for (let index: number = 0; index < 5; index++) {
737
+ signups.push(
738
+ selfHosted.userService.createUserOnSignup({
739
+ user: buildUser({
740
+ modules: selfHosted,
741
+ email: `race${index}@example.com`,
742
+ }),
743
+ props: { isRoot: true },
744
+ }),
745
+ );
746
+ }
747
+
748
+ await Promise.all(signups);
749
+
750
+ /*
751
+ * "Exactly one master admin" can pass by luck of scheduling. This asserts
752
+ * the mechanism instead: the event stream has to be flat lock/unlock
753
+ * pairs, never a second lock while one is still open.
754
+ */
755
+ let depth: number = 0;
756
+
757
+ for (const event of events) {
758
+ if (event === "lock") {
759
+ depth++;
760
+ expect(depth).toBe(1);
761
+ }
762
+
763
+ if (event === "unlock") {
764
+ depth--;
765
+ expect(depth).toBe(0);
766
+ }
767
+ }
768
+
769
+ expect(depth).toBe(0);
770
+ });
771
+
772
+ test("the loser of the race is created as an ordinary user, not rejected", async () => {
773
+ const created: Array<any> = await Promise.all([
774
+ selfHosted.userService.createUserOnSignup({
775
+ user: buildUser({ modules: selfHosted, email: "race1@example.com" }),
776
+ props: { isRoot: true },
777
+ }),
778
+ selfHosted.userService.createUserOnSignup({
779
+ user: buildUser({ modules: selfHosted, email: "race2@example.com" }),
780
+ props: { isRoot: true },
781
+ }),
782
+ ]);
783
+
784
+ // Serializing the election must not turn a valid signup into an error.
785
+ for (const user of created) {
786
+ expect(user).toBeTruthy();
787
+ expect(typeof user.isMasterAdmin).toBe("boolean");
788
+ }
789
+ });
790
+
791
+ test("every racer takes the same key", async () => {
792
+ await Promise.all([
793
+ selfHosted.userService.createUserOnSignup({
794
+ user: buildUser({ modules: selfHosted, email: "race1@example.com" }),
795
+ props: { isRoot: true },
796
+ }),
797
+ selfHosted.userService.createUserOnSignup({
798
+ user: buildUser({ modules: selfHosted, email: "race2@example.com" }),
799
+ props: { isRoot: true },
800
+ }),
801
+ ]);
802
+
803
+ /*
804
+ * Two callers taking two DIFFERENT keys exclude nobody. Pin the exact
805
+ * strings: they are the entire coordination protocol between replicas.
806
+ */
807
+ const keys: Array<string> = (
808
+ selfHosted.semaphore.lock as any
809
+ ).mock.calls.map((call: Array<any>) => {
810
+ return `${call[0].namespace}-${call[0].key}`;
811
+ });
812
+
813
+ expect(keys).toEqual([
814
+ `${LOCK_NAMESPACE}-${LOCK_KEY}`,
815
+ `${LOCK_NAMESPACE}-${LOCK_KEY}`,
816
+ ]);
817
+ });
818
+
819
+ test("a racer that fails mid-election still frees the lock for the rest", async () => {
820
+ getJestSpyOn(selfHosted.userService, "create").mockImplementationOnce(
821
+ async (): Promise<never> => {
822
+ throw new Error("insert failed");
823
+ },
824
+ );
825
+
826
+ const outcomes: Array<any> = await Promise.allSettled([
827
+ selfHosted.userService.createUserOnSignup({
828
+ user: buildUser({ modules: selfHosted, email: "race1@example.com" }),
829
+ props: { isRoot: true },
830
+ }),
831
+ selfHosted.userService.createUserOnSignup({
832
+ user: buildUser({ modules: selfHosted, email: "race2@example.com" }),
833
+ props: { isRoot: true },
834
+ }),
835
+ selfHosted.userService.createUserOnSignup({
836
+ user: buildUser({ modules: selfHosted, email: "race3@example.com" }),
837
+ props: { isRoot: true },
838
+ }),
839
+ ]);
840
+
841
+ const fulfilled: Array<any> = outcomes.filter((outcome: any) => {
842
+ return outcome.status === "fulfilled";
843
+ });
844
+
845
+ /*
846
+ * A leaked lock would deadlock the two survivors, and Promise.allSettled
847
+ * would never resolve -- the test would time out rather than fail.
848
+ */
849
+ expect(fulfilled).toHaveLength(2);
850
+ expect(mutexes.isHeld(`${LOCK_NAMESPACE}-${LOCK_KEY}`)).toBe(false);
851
+
852
+ const elected: Array<any> = fulfilled.filter((outcome: any) => {
853
+ return outcome.value.isMasterAdmin === true;
854
+ });
855
+
856
+ expect(elected).toHaveLength(1);
857
+ });
858
+
859
+ test("without serialization the same harness reproduces the vulnerability", async () => {
860
+ /*
861
+ * Sensitivity check, not a description of desired behaviour. If this ever
862
+ * starts producing one master admin, the harness has stopped being able
863
+ * to observe the bug and every passing test above means nothing.
864
+ */
865
+ serializeLock = false;
866
+
867
+ const created: Array<any> = await Promise.all([
868
+ selfHosted.userService.createUserOnSignup({
869
+ user: buildUser({ modules: selfHosted, email: "race1@example.com" }),
870
+ props: { isRoot: true },
871
+ }),
872
+ selfHosted.userService.createUserOnSignup({
873
+ user: buildUser({ modules: selfHosted, email: "race2@example.com" }),
874
+ props: { isRoot: true },
875
+ }),
876
+ ]);
877
+
878
+ const elected: Array<any> = created.filter((user: any) => {
879
+ return user.isMasterAdmin === true;
880
+ });
881
+
882
+ expect(elected).toHaveLength(2);
883
+ });
884
+ });
885
+ });