@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.
- package/Models/DatabaseModels/CephCluster.ts +14 -0
- package/Models/DatabaseModels/CodeRepository.ts +14 -6
- package/Models/DatabaseModels/DockerHost.ts +14 -0
- package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
- package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
- package/Models/DatabaseModels/IoTFleet.ts +14 -0
- package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
- package/Models/DatabaseModels/NetworkDevice.ts +14 -0
- package/Models/DatabaseModels/NetworkInterface.ts +18 -0
- package/Models/DatabaseModels/Project.ts +16 -2
- package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
- package/Models/DatabaseModels/Service.ts +30 -0
- package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
- package/Models/DatabaseModels/User.ts +30 -0
- package/Server/API/AIAgentDataAPI.ts +31 -0
- package/Server/API/DashboardAPI.ts +419 -12
- package/Server/API/GitHubAPI.ts +119 -283
- package/Server/API/UserAPI.ts +263 -1
- package/Server/EnvironmentConfig.ts +7 -3
- package/Server/Infrastructure/GlobalCache.ts +37 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
- package/Server/Services/AnalyticsDatabaseService.ts +72 -1
- package/Server/Services/CodeRepositoryService.ts +105 -2
- package/Server/Services/DatabaseService.ts +185 -2
- package/Server/Services/OpenTelemetryIngestService.ts +230 -26
- package/Server/Services/UserService.ts +184 -0
- package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
- package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
- package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
- package/Server/Utils/PasswordHash.ts +306 -0
- package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
- package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
- package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
- package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
- package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
- package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
- package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
- package/Tests/Server/API/Helpers.ts +6 -1
- package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
- package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
- package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
- package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
- package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
- package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
- package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
- package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
- package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
- package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
- package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
- package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
- package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
- package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
- package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
- package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
- package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
- package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
- package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
- package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
- package/Tests/Types/Database/ColumnLength.test.ts +7 -1
- package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
- package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
- package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
- package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
- package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
- package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
- package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
- package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
- package/Types/Database/ColumnLength.ts +9 -1
- package/Types/Database/TableColumn.ts +11 -0
- package/Types/Database/UnsynchronizedIndex.ts +48 -0
- package/Types/HashedString.ts +139 -4
- package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
- package/UI/Components/Navbar/NavBar.tsx +12 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
- package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
- package/Utils/TeamMembersByProject.ts +237 -0
- package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
- package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
- package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
- package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
- package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
- package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +16 -2
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Service.js +28 -1
- package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
- package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
- package/build/dist/Models/DatabaseModels/User.js +32 -0
- package/build/dist/Models/DatabaseModels/User.js.map +1 -1
- package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
- package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +304 -10
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/GitHubAPI.js +94 -167
- package/build/dist/Server/API/GitHubAPI.js.map +1 -1
- package/build/dist/Server/API/UserAPI.js +187 -2
- package/build/dist/Server/API/UserAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +7 -3
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
- package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
- package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +160 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
- package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +165 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
- package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
- package/build/dist/Server/Utils/PasswordHash.js +237 -0
- package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
- package/build/dist/Types/Database/ColumnLength.js +9 -1
- package/build/dist/Types/Database/ColumnLength.js.map +1 -1
- package/build/dist/Types/Database/TableColumn.js.map +1 -1
- package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
- package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
- package/build/dist/Types/HashedString.js +95 -5
- package/build/dist/Types/HashedString.js.map +1 -1
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
- package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
- package/build/dist/Utils/TeamMembersByProject.js +145 -0
- package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
- package/package.json +1 -1
|
@@ -327,6 +327,134 @@ describe("GlobalCache.setStringIfChanged", () => {
|
|
|
327
327
|
* again on a timer. Jitter breaks the alignment permanently: windows drift
|
|
328
328
|
* apart after the first expiry and stay apart.
|
|
329
329
|
*/
|
|
330
|
+
/*
|
|
331
|
+
* deleteKeyIfValue is the release half of a lease taken with
|
|
332
|
+
* setStringIfNotExists (see InstanceHealthLock). The comparison is the whole
|
|
333
|
+
* point: a plain DEL lets a holder that overran its TTL delete the lease a
|
|
334
|
+
* DIFFERENT worker has since legitimately acquired, which hands the same lease
|
|
335
|
+
* to a third worker and defeats the mutual exclusion entirely. These tests pin
|
|
336
|
+
* the atomicity and the compare, because neither is visible at the call site.
|
|
337
|
+
*/
|
|
338
|
+
describe("GlobalCache.deleteKeyIfValue", () => {
|
|
339
|
+
let client: MockClient;
|
|
340
|
+
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
client = {
|
|
343
|
+
set: jest.fn().mockResolvedValue("OK"),
|
|
344
|
+
expire: jest.fn().mockResolvedValue(1),
|
|
345
|
+
get: jest.fn(),
|
|
346
|
+
del: jest.fn().mockResolvedValue(1),
|
|
347
|
+
eval: jest.fn().mockResolvedValue(1),
|
|
348
|
+
};
|
|
349
|
+
(Redis.getClient as jest.Mock).mockReturnValue(client);
|
|
350
|
+
(Redis.isConnected as jest.Mock).mockReturnValue(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
afterEach(() => {
|
|
354
|
+
jest.clearAllMocks();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("compares and deletes in ONE eval — never GET then DEL", async () => {
|
|
358
|
+
await GlobalCache.deleteKeyIfValue("ns", "key", "token-a");
|
|
359
|
+
|
|
360
|
+
expect(client.eval).toHaveBeenCalledTimes(1);
|
|
361
|
+
// A separate GET/DEL pair would reintroduce the race the script removes.
|
|
362
|
+
expect(client.get).not.toHaveBeenCalled();
|
|
363
|
+
expect(client.del).not.toHaveBeenCalled();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("passes the key as KEYS[1] so it stays correct on Redis Cluster", async () => {
|
|
367
|
+
await GlobalCache.deleteKeyIfValue("ns", "key", "token-a");
|
|
368
|
+
|
|
369
|
+
const call: Array<unknown> = client.eval.mock.calls[0] as Array<unknown>;
|
|
370
|
+
|
|
371
|
+
// numkeys = 1, then the key itself, then the holder token as ARGV[1].
|
|
372
|
+
expect(call[1]).toBe(1);
|
|
373
|
+
expect(call[2]).toBe("ns-key");
|
|
374
|
+
expect(call[3]).toBe("token-a");
|
|
375
|
+
|
|
376
|
+
const script: string = call[0] as string;
|
|
377
|
+
expect(script).toContain("KEYS[1]");
|
|
378
|
+
expect(script).toContain("ARGV[1]");
|
|
379
|
+
// The key must never be interpolated into the script body.
|
|
380
|
+
expect(script).not.toContain("ns-key");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("returns true when this holder's own value was deleted", async () => {
|
|
384
|
+
client.eval.mockResolvedValue(1);
|
|
385
|
+
|
|
386
|
+
await expect(
|
|
387
|
+
GlobalCache.deleteKeyIfValue("ns", "key", "token-a"),
|
|
388
|
+
).resolves.toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("returns false when the key holds a DIFFERENT holder's token", async () => {
|
|
392
|
+
// Redis DEL never ran because the GET comparison failed.
|
|
393
|
+
client.eval.mockResolvedValue(0);
|
|
394
|
+
|
|
395
|
+
await expect(
|
|
396
|
+
GlobalCache.deleteKeyIfValue("ns", "key", "token-a"),
|
|
397
|
+
).resolves.toBe(false);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("returns false when the key has already expired", async () => {
|
|
401
|
+
client.eval.mockResolvedValue(0);
|
|
402
|
+
|
|
403
|
+
await expect(
|
|
404
|
+
GlobalCache.deleteKeyIfValue("ns", "key", "token-a"),
|
|
405
|
+
).resolves.toBe(false);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("treats any non-1 reply as 'did not delete'", async () => {
|
|
409
|
+
/*
|
|
410
|
+
* Losing a release is safe (the lease still expires on its own); wrongly
|
|
411
|
+
* reporting a release is not. Anything unexpected must fail closed.
|
|
412
|
+
*/
|
|
413
|
+
for (const reply of [null, undefined, "1", 2, "OK", {}]) {
|
|
414
|
+
client.eval.mockResolvedValue(reply);
|
|
415
|
+
|
|
416
|
+
await expect(
|
|
417
|
+
GlobalCache.deleteKeyIfValue("ns", "key", "token-a"),
|
|
418
|
+
).resolves.toBe(false);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("throws when the cache is not connected", async () => {
|
|
423
|
+
(Redis.isConnected as jest.Mock).mockReturnValue(false);
|
|
424
|
+
|
|
425
|
+
await expect(
|
|
426
|
+
GlobalCache.deleteKeyIfValue("ns", "key", "token-a"),
|
|
427
|
+
).rejects.toThrow(DatabaseNotConnectedException);
|
|
428
|
+
expect(client.eval).not.toHaveBeenCalled();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("throws when there is no client at all", async () => {
|
|
432
|
+
(Redis.getClient as jest.Mock).mockReturnValue(null);
|
|
433
|
+
|
|
434
|
+
await expect(
|
|
435
|
+
GlobalCache.deleteKeyIfValue("ns", "key", "token-a"),
|
|
436
|
+
).rejects.toThrow(DatabaseNotConnectedException);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("namespaces the key exactly as the setters do", async () => {
|
|
440
|
+
/*
|
|
441
|
+
* Release must target the same key acquire created. If the two ever
|
|
442
|
+
* disagreed the lease would be unreleasable and every tick would block
|
|
443
|
+
* for a full TTL.
|
|
444
|
+
*/
|
|
445
|
+
await GlobalCache.setStringIfNotExists("instance-health", "pg", "tok", {
|
|
446
|
+
expiresInSeconds: 60,
|
|
447
|
+
});
|
|
448
|
+
await GlobalCache.deleteKeyIfValue("instance-health", "pg", "tok");
|
|
449
|
+
|
|
450
|
+
const setKey: unknown = (client.set.mock.calls[0] as Array<unknown>)[0];
|
|
451
|
+
const evalKey: unknown = (client.eval.mock.calls[0] as Array<unknown>)[2];
|
|
452
|
+
|
|
453
|
+
expect(setKey).toBe("instance-health-pg");
|
|
454
|
+
expect(evalKey).toBe(setKey);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
330
458
|
describe("GlobalCache.withJitter", () => {
|
|
331
459
|
test("never returns less than the requested TTL", () => {
|
|
332
460
|
for (let i: number = 0; i < 500; i++) {
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import Redis, { ClientType } from "../../../Server/Infrastructure/Redis";
|
|
2
|
+
import Semaphore, {
|
|
3
|
+
SemaphoreMutex,
|
|
4
|
+
} from "../../../Server/Infrastructure/Semaphore";
|
|
5
|
+
import { Mutex as RedisMutex } from "redis-semaphore";
|
|
6
|
+
import { afterEach, describe, expect, test } from "@jest/globals";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Companion to SemaphorePermit.test.ts, covering the OTHER half of Semaphore:
|
|
10
|
+
* lock()/release(), the exclusive mutex.
|
|
11
|
+
*
|
|
12
|
+
* This is the primitive every cross-process critical section in the codebase
|
|
13
|
+
* runs on -- project counters, timeline writes, the SLO sweep, and the
|
|
14
|
+
* first-Master-Admin election (GHSA-3qqq-hprx-g2jw). What is pinned here is the
|
|
15
|
+
* option plumbing between our wrapper and redis-semaphore: a lockTimeout or
|
|
16
|
+
* acquireTimeout silently dropped on the way through would not fail any test
|
|
17
|
+
* about election outcomes, it would just quietly turn a bounded wait into an
|
|
18
|
+
* unbounded one, or a self-refreshing lock into one with the library default.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface MockMutex {
|
|
22
|
+
acquire: jest.Mock;
|
|
23
|
+
release: jest.Mock;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
jest.mock("redis-semaphore", () => {
|
|
27
|
+
const actual: Record<string, unknown> = jest.requireActual(
|
|
28
|
+
"redis-semaphore",
|
|
29
|
+
) as Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
...actual,
|
|
33
|
+
Mutex: jest.fn().mockImplementation((): MockMutex => {
|
|
34
|
+
return {
|
|
35
|
+
acquire: jest.fn(async (): Promise<void> => {}),
|
|
36
|
+
release: jest.fn(async (): Promise<void> => {}),
|
|
37
|
+
};
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const redisMutexConstructor: jest.MockedClass<typeof RedisMutex> =
|
|
43
|
+
RedisMutex as jest.MockedClass<typeof RedisMutex>;
|
|
44
|
+
|
|
45
|
+
function mockRedisClient(): ClientType {
|
|
46
|
+
const client: ClientType = {} as ClientType;
|
|
47
|
+
jest.spyOn(Redis, "getClient").mockReturnValue(client);
|
|
48
|
+
return client;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function lastMockMutex(): MockMutex {
|
|
52
|
+
/*
|
|
53
|
+
* Indexed rather than .at(-1): this project targets es2017, so
|
|
54
|
+
* Array.prototype.at is outside its lib.
|
|
55
|
+
*/
|
|
56
|
+
const results: Array<{ value: unknown }> = redisMutexConstructor.mock.results;
|
|
57
|
+
const result: unknown = results[results.length - 1]?.value;
|
|
58
|
+
return result as MockMutex;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
jest.restoreAllMocks();
|
|
63
|
+
redisMutexConstructor.mockClear();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("Semaphore exclusive mutex", () => {
|
|
67
|
+
test("constructs and acquires with every option forwarded", async () => {
|
|
68
|
+
const client: ClientType = mockRedisClient();
|
|
69
|
+
|
|
70
|
+
const mutex: SemaphoreMutex = await Semaphore.lock({
|
|
71
|
+
key: "instance",
|
|
72
|
+
namespace: "UserService.firstMasterAdminElection",
|
|
73
|
+
lockTimeout: 10_000,
|
|
74
|
+
acquireTimeout: 5_000,
|
|
75
|
+
acquireAttemptsLimit: 4,
|
|
76
|
+
retryInterval: 25,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(redisMutexConstructor).toHaveBeenCalledWith(
|
|
80
|
+
client,
|
|
81
|
+
"UserService.firstMasterAdminElection-instance",
|
|
82
|
+
{
|
|
83
|
+
lockTimeout: 10_000,
|
|
84
|
+
acquireTimeout: 5_000,
|
|
85
|
+
acquireAttemptsLimit: 4,
|
|
86
|
+
retryInterval: 25,
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
expect(lastMockMutex().acquire).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(mutex).toBe(lastMockMutex());
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("composes the Redis key as namespace-key", async () => {
|
|
94
|
+
mockRedisClient();
|
|
95
|
+
|
|
96
|
+
await Semaphore.lock({
|
|
97
|
+
key: "project-1",
|
|
98
|
+
namespace: "ProjectService.incidentCounter",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/*
|
|
102
|
+
* Callers coordinate purely by agreeing on this string. Two replicas that
|
|
103
|
+
* compose it differently exclude nobody.
|
|
104
|
+
*/
|
|
105
|
+
expect(redisMutexConstructor).toHaveBeenCalledWith(
|
|
106
|
+
expect.anything(),
|
|
107
|
+
"ProjectService.incidentCounter-project-1",
|
|
108
|
+
expect.anything(),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("defaults lockTimeout to five seconds when none is supplied", async () => {
|
|
113
|
+
mockRedisClient();
|
|
114
|
+
|
|
115
|
+
await Semaphore.lock({
|
|
116
|
+
key: "project-1",
|
|
117
|
+
namespace: "ProjectService.incidentCounter",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(redisMutexConstructor).toHaveBeenCalledWith(
|
|
121
|
+
expect.anything(),
|
|
122
|
+
"ProjectService.incidentCounter-project-1",
|
|
123
|
+
{ lockTimeout: 5_000 },
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("omits options the caller did not set rather than passing undefined", async () => {
|
|
128
|
+
mockRedisClient();
|
|
129
|
+
|
|
130
|
+
await Semaphore.lock({
|
|
131
|
+
key: "project-1",
|
|
132
|
+
namespace: "ProjectService.incidentCounter",
|
|
133
|
+
acquireTimeout: 1_000,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const options: Record<string, unknown> = redisMutexConstructor.mock
|
|
137
|
+
.calls[0]![2] as unknown as Record<string, unknown>;
|
|
138
|
+
|
|
139
|
+
/*
|
|
140
|
+
* redis-semaphore destructures its options with defaults, and an explicit
|
|
141
|
+
* `undefined` does take the default -- but only because of how it is
|
|
142
|
+
* written. Not passing the key at all is what actually guarantees the
|
|
143
|
+
* library's own default applies.
|
|
144
|
+
*/
|
|
145
|
+
expect(Object.keys(options).sort()).toEqual([
|
|
146
|
+
"acquireTimeout",
|
|
147
|
+
"lockTimeout",
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("rejects when Redis is not connected", async () => {
|
|
152
|
+
jest.spyOn(Redis, "getClient").mockReturnValue(null);
|
|
153
|
+
|
|
154
|
+
await expect(
|
|
155
|
+
Semaphore.lock({
|
|
156
|
+
key: "instance",
|
|
157
|
+
namespace: "UserService.firstMasterAdminElection",
|
|
158
|
+
}),
|
|
159
|
+
).rejects.toThrow(/Redis client is not connected/i);
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
* Callers treat a throw as "I do not hold the lock". Constructing a mutex
|
|
163
|
+
* against a dead client and returning it would be worse than failing.
|
|
164
|
+
*/
|
|
165
|
+
expect(redisMutexConstructor).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("propagates acquisition failures without returning an unheld mutex", async () => {
|
|
169
|
+
mockRedisClient();
|
|
170
|
+
const acquireError: Error = new Error("acquire timeout");
|
|
171
|
+
redisMutexConstructor.mockImplementationOnce(() => {
|
|
172
|
+
return {
|
|
173
|
+
acquire: jest.fn(async (): Promise<void> => {
|
|
174
|
+
throw acquireError;
|
|
175
|
+
}),
|
|
176
|
+
release: jest.fn(async (): Promise<void> => {}),
|
|
177
|
+
} as unknown as RedisMutex;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
* The load-bearing failure mode for the Master Admin election: a caller
|
|
182
|
+
* that got a mutex handle back would run its critical section believing it
|
|
183
|
+
* was serialized.
|
|
184
|
+
*/
|
|
185
|
+
await expect(
|
|
186
|
+
Semaphore.lock({
|
|
187
|
+
key: "instance",
|
|
188
|
+
namespace: "UserService.firstMasterAdminElection",
|
|
189
|
+
}),
|
|
190
|
+
).rejects.toBe(acquireError);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("releases the exact acquired mutex", async () => {
|
|
194
|
+
mockRedisClient();
|
|
195
|
+
|
|
196
|
+
const mutex: SemaphoreMutex = await Semaphore.lock({
|
|
197
|
+
key: "instance",
|
|
198
|
+
namespace: "UserService.firstMasterAdminElection",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await Semaphore.release(mutex);
|
|
202
|
+
|
|
203
|
+
expect(lastMockMutex().release).toHaveBeenCalledTimes(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("two different keys produce two independent mutexes", async () => {
|
|
207
|
+
mockRedisClient();
|
|
208
|
+
|
|
209
|
+
await Semaphore.lock({ key: "a", namespace: "ns" });
|
|
210
|
+
await Semaphore.lock({ key: "b", namespace: "ns" });
|
|
211
|
+
|
|
212
|
+
expect(redisMutexConstructor.mock.calls[0]![1]).toBe("ns-a");
|
|
213
|
+
expect(redisMutexConstructor.mock.calls[1]![1]).toBe("ns-b");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { AddPerUserPasswordSalt1786018109307 } from "../../../Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt";
|
|
2
|
+
import SchemaMigrations from "../../../Server/Infrastructure/Postgres/SchemaMigrations/Index";
|
|
3
|
+
import { QueryRunner } from "typeorm";
|
|
4
|
+
import { describe, expect, test } from "@jest/globals";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The migration that adds the per-user salt columns.
|
|
8
|
+
*
|
|
9
|
+
* Three things have to hold together or per-user salts silently do not exist
|
|
10
|
+
* in a deployed instance:
|
|
11
|
+
*
|
|
12
|
+
* 1. up() adds exactly the two columns, nullable — existing rows must keep
|
|
13
|
+
* their (unsalted) passwords working, so a NOT NULL column or a default
|
|
14
|
+
* would be wrong;
|
|
15
|
+
* 2. down() removes exactly what up() added;
|
|
16
|
+
* 3. the migration is registered in SchemaMigrations/Index.ts. An
|
|
17
|
+
* unregistered migration never runs on boot, so the column would be
|
|
18
|
+
* missing while the code writes to it — every login and every signup
|
|
19
|
+
* would 500.
|
|
20
|
+
*
|
|
21
|
+
* Pure SQL-contract assertions against a fake QueryRunner. No Postgres.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type MakeQueryRunnerResult = {
|
|
25
|
+
runner: QueryRunner;
|
|
26
|
+
statements: Array<string>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type MakeQueryRunnerFunction = () => MakeQueryRunnerResult;
|
|
30
|
+
|
|
31
|
+
const makeQueryRunner: MakeQueryRunnerFunction = (): MakeQueryRunnerResult => {
|
|
32
|
+
const statements: Array<string> = [];
|
|
33
|
+
|
|
34
|
+
const query: (...args: Array<unknown>) => Promise<undefined> = (
|
|
35
|
+
...args: Array<unknown>
|
|
36
|
+
): Promise<undefined> => {
|
|
37
|
+
statements.push(String(args[0]));
|
|
38
|
+
return Promise.resolve(undefined);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
runner: { query } as unknown as QueryRunner,
|
|
43
|
+
statements,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe("AddPerUserPasswordSalt1786018109307 — SQL contract", () => {
|
|
48
|
+
test("up() adds exactly the two salt columns and nothing else", async () => {
|
|
49
|
+
const { runner, statements } = makeQueryRunner();
|
|
50
|
+
|
|
51
|
+
await new AddPerUserPasswordSalt1786018109307().up(runner);
|
|
52
|
+
|
|
53
|
+
expect(statements).toEqual([
|
|
54
|
+
`ALTER TABLE "User" ADD "passwordSalt" character varying(100)`,
|
|
55
|
+
`ALTER TABLE "StatusPagePrivateUser" ADD "passwordSalt" character varying(100)`,
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the columns are nullable, so every existing password keeps working", async () => {
|
|
60
|
+
/*
|
|
61
|
+
* Rows written before salts existed have no salt, and are verified
|
|
62
|
+
* against the legacy unsalted scheme until the next successful login
|
|
63
|
+
* re-hashes them. A NOT NULL column would have needed a backfill value,
|
|
64
|
+
* and any backfilled salt would be wrong for the hash already stored.
|
|
65
|
+
*/
|
|
66
|
+
const { runner, statements } = makeQueryRunner();
|
|
67
|
+
|
|
68
|
+
await new AddPerUserPasswordSalt1786018109307().up(runner);
|
|
69
|
+
|
|
70
|
+
for (const statement of statements) {
|
|
71
|
+
expect(statement).not.toMatch(/NOT NULL/i);
|
|
72
|
+
expect(statement).not.toMatch(/DEFAULT/i);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the column is wide enough for the salts the code generates", async () => {
|
|
77
|
+
/*
|
|
78
|
+
* HashedString.generateSalt() produces 64 hex characters. A narrower
|
|
79
|
+
* column would truncate on write and the password would never verify
|
|
80
|
+
* again.
|
|
81
|
+
*/
|
|
82
|
+
const { runner, statements } = makeQueryRunner();
|
|
83
|
+
|
|
84
|
+
await new AddPerUserPasswordSalt1786018109307().up(runner);
|
|
85
|
+
|
|
86
|
+
for (const statement of statements) {
|
|
87
|
+
const width: RegExpMatchArray | null = statement.match(
|
|
88
|
+
/character varying\((\d+)\)/,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(width).not.toBeNull();
|
|
92
|
+
expect(Number(width![1])).toBeGreaterThanOrEqual(64);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("down() drops exactly what up() added", async () => {
|
|
97
|
+
const { runner, statements } = makeQueryRunner();
|
|
98
|
+
|
|
99
|
+
await new AddPerUserPasswordSalt1786018109307().down(runner);
|
|
100
|
+
|
|
101
|
+
expect(statements).toEqual([
|
|
102
|
+
`ALTER TABLE "StatusPagePrivateUser" DROP COLUMN "passwordSalt"`,
|
|
103
|
+
`ALTER TABLE "User" DROP COLUMN "passwordSalt"`,
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("up() and down() touch the same tables", async () => {
|
|
108
|
+
const up: MakeQueryRunnerResult = makeQueryRunner();
|
|
109
|
+
const down: MakeQueryRunnerResult = makeQueryRunner();
|
|
110
|
+
|
|
111
|
+
await new AddPerUserPasswordSalt1786018109307().up(up.runner);
|
|
112
|
+
await new AddPerUserPasswordSalt1786018109307().down(down.runner);
|
|
113
|
+
|
|
114
|
+
type TablesFunction = (statements: Array<string>) => Array<string>;
|
|
115
|
+
|
|
116
|
+
const tables: TablesFunction = (
|
|
117
|
+
statements: Array<string>,
|
|
118
|
+
): Array<string> => {
|
|
119
|
+
return statements
|
|
120
|
+
.map((statement: string) => {
|
|
121
|
+
return statement.match(/ALTER TABLE "([^"]+)"/)?.[1] as string;
|
|
122
|
+
})
|
|
123
|
+
.sort();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
expect(tables(up.statements)).toEqual(tables(down.statements));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("AddPerUserPasswordSalt1786018109307 — registration", () => {
|
|
131
|
+
test("is registered in SchemaMigrations/Index.ts", () => {
|
|
132
|
+
/*
|
|
133
|
+
* The step that is easiest to forget and that fails the loudest in
|
|
134
|
+
* production: an unregistered migration is dead code, so the column never
|
|
135
|
+
* appears and the write path fails on the column that is not there.
|
|
136
|
+
*/
|
|
137
|
+
expect(SchemaMigrations).toContain(AddPerUserPasswordSalt1786018109307);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("its class name carries the timestamp its filename does", () => {
|
|
141
|
+
// TypeORM orders migrations by the timestamp suffix on the class name.
|
|
142
|
+
expect(AddPerUserPasswordSalt1786018109307.name).toBe(
|
|
143
|
+
"AddPerUserPasswordSalt1786018109307",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("runs after every migration registered before it", () => {
|
|
148
|
+
const names: Array<string> = SchemaMigrations.map(
|
|
149
|
+
(migration: { name: string }) => {
|
|
150
|
+
return migration.name;
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const index: number = names.indexOf("AddPerUserPasswordSalt1786018109307");
|
|
155
|
+
|
|
156
|
+
expect(index).toBeGreaterThan(-1);
|
|
157
|
+
|
|
158
|
+
type TimestampFunction = (name: string) => number;
|
|
159
|
+
|
|
160
|
+
const timestampOf: TimestampFunction = (name: string): number => {
|
|
161
|
+
return Number(name.match(/(\d+)$/)?.[1] || 0);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
for (const earlier of names.slice(0, index)) {
|
|
165
|
+
expect(timestampOf(earlier)).toBeLessThan(1786018109307);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("appears exactly once", () => {
|
|
170
|
+
const occurrences: number = SchemaMigrations.filter(
|
|
171
|
+
(migration: { name: string }) => {
|
|
172
|
+
return migration.name === "AddPerUserPasswordSalt1786018109307";
|
|
173
|
+
},
|
|
174
|
+
).length;
|
|
175
|
+
|
|
176
|
+
expect(occurrences).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
});
|