@oneuptime/common 12.0.12 → 12.0.13
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/DatabaseBaseModel/DatabaseBaseModel.ts +32 -5
- package/Models/DatabaseModels/NetworkDevice.ts +64 -0
- package/Models/DatabaseModels/StatusPage.ts +59 -0
- package/Models/DatabaseModels/StatusPageOidc.ts +2 -0
- package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +19 -2
- package/Server/API/AIChatAPI.ts +55 -56
- package/Server/API/CommonAPI.ts +89 -0
- package/Server/API/MicrosoftTeamsAPI.ts +20 -9
- package/Server/API/SlackAPI.ts +13 -10
- package/Server/API/StatusPageAPI.ts +2197 -2128
- package/Server/API/TelemetryAPI.ts +72 -3
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.ts +30 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.ts +47 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Middleware/UserAuthorization.ts +11 -2
- package/Server/Services/ApiKeyPermissionService.ts +116 -2
- package/Server/Services/DatabaseService.ts +17 -6
- package/Server/Services/LogAggregationService.ts +34 -0
- package/Server/Services/LogService.ts +21 -0
- package/Server/Services/NetworkSiteService.ts +12 -0
- package/Server/Services/SpanService.ts +21 -0
- package/Server/Services/TraceAggregationService.ts +15 -0
- package/Server/Services/WorkspaceNotificationRuleService.ts +172 -220
- package/Server/Types/AnalyticsDatabase/ModelPermission.ts +9 -0
- package/Server/Types/Database/Permissions/AccessControlPermission.ts +47 -16
- package/Server/Utils/APIKey/AccessPermission.ts +16 -40
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +144 -0
- package/Server/Utils/LogRedaction.ts +576 -0
- package/Server/Utils/Logger.ts +123 -46
- package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +94 -23
- package/Server/Utils/Monitor/MonitorResource.ts +29 -9
- package/Server/Utils/Monitor/NetworkInventoryUtil.ts +28 -7
- package/Server/Utils/SessionReplay/SessionReplayGateCacheStore.ts +56 -10
- package/Server/Utils/StartServer.ts +30 -9
- package/Server/Utils/StatusPageSearchEngineIndexing.ts +33 -0
- package/Server/Utils/Telemetry/ResourceEntityFilter.ts +441 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +488 -46
- package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +300 -0
- package/Tests/App/StatusPage/StatusPageIndexPageRobotsMeta.test.ts +130 -0
- package/Tests/Models/DatabaseModels/DatabaseBaseModelToJSONObjectCache.test.ts +180 -0
- package/Tests/Models/StatusPageEnableSearchEngineIndexing.test.ts +157 -0
- package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +23 -1
- package/Tests/Server/API/AIChatRouteAuthorization.test.ts +785 -0
- package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +21 -8
- package/Tests/Server/API/CommonAPIAuthGuard.test.ts +290 -0
- package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +7 -1
- package/Tests/Server/API/StatusPageOverviewCache.test.ts +474 -0
- package/Tests/Server/API/StatusPageSeoSearchEngineIndexing.test.ts +189 -0
- package/Tests/Server/Infrastructure/InMemoryTTLCache.test.ts +158 -0
- package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +9 -60
- package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +24 -15
- package/Tests/Server/Middleware/UserAuthorization.test.ts +79 -0
- package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +332 -0
- package/Tests/Server/Services/ApiKeyPermissionService.test.ts +347 -0
- package/Tests/Server/Services/DatabaseServiceUpdateDebugLogging.test.ts +282 -0
- package/Tests/Server/Services/NetworkSiteService.test.ts +56 -2
- package/Tests/Server/Services/TelemetryResourceFacetFilters.test.ts +267 -0
- package/Tests/Server/Services/WorkspaceNotificationRuleTestRuleChannels.test.ts +702 -0
- package/Tests/Server/Types/Database/Permissions/AccessControlPermission.test.ts +165 -0
- package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +48 -36
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +213 -0
- package/Tests/Server/Utils/LogRedaction.test.ts +415 -0
- package/Tests/Server/Utils/LoggerCredentialLeak.test.ts +245 -0
- package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +374 -4
- package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +455 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceReachabilityRoundTrip.test.ts +369 -0
- package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +86 -8
- package/Tests/Server/Utils/PrivacyFilterUtil.test.ts +64 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayGateCacheStore.test.ts +207 -0
- package/Tests/Server/Utils/StartServerBodyVerify.test.ts +335 -0
- package/Tests/Server/Utils/StatusPageSearchEngineIndexing.test.ts +60 -0
- package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +435 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelSend.test.ts +607 -62
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsInstalledTeamIdSpace.test.ts +778 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsTeamInstalls.test.ts +44 -10
- package/Tests/Types/API/HostnameFromAuthority.test.ts +164 -0
- package/Tests/Types/Database/AccessControl/ColumnAccessControlCache.test.ts +344 -0
- package/Tests/Types/Database/DatabasePropertyFindOperator.test.ts +518 -0
- package/Tests/Types/Database/TableColumnMetadataCache.test.ts +286 -0
- package/Tests/Types/Monitor/MonitorStep.test.ts +100 -0
- package/Tests/Types/Monitor/MonitorTypeKeywords.test.ts +360 -0
- package/Tests/Types/StatusPage/SearchEngineIndexing.test.ts +86 -0
- package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +254 -0
- package/Tests/UI/Components/CardSelect.test.tsx +1021 -0
- package/Tests/UI/Components/MasterPage.test.tsx +36 -1
- package/Tests/Utils/Monitor/MonitorMetricType.test.ts +66 -1
- package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +115 -6
- package/Tests/Utils/NetworkDevice/DeviceReachabilityUtil.test.ts +610 -0
- package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +114 -45
- package/Types/API/Hostname.ts +79 -0
- package/Types/Database/AccessControl/ColumnAccessControl.ts +33 -11
- package/Types/Database/AccessControl/ColumnBillingAccessControl.ts +33 -11
- package/Types/Database/DatabaseProperty.ts +38 -0
- package/Types/Database/TableColumn.ts +34 -7
- package/Types/Events/Recurring.ts +16 -1
- package/Types/Monitor/MonitorStep.ts +13 -1
- package/Types/Monitor/MonitorType.ts +345 -40
- package/Types/Monitor/SSLMonitor/SslMonitorResponse.ts +43 -0
- package/Types/StatusPage/SearchEngineIndexing.ts +61 -0
- package/Types/Telemetry/ResourceEntityFacet.ts +211 -0
- package/UI/Components/CardSelect/CardSelect.tsx +714 -105
- package/UI/Components/EmptyState/EmptyState.tsx +8 -1
- package/UI/Components/Footer/Footer.tsx +32 -6
- package/UI/Components/Forms/Fields/FormField.tsx +3 -0
- package/UI/Components/Forms/Types/Field.ts +8 -0
- package/UI/Components/Loader/PageLoader.tsx +29 -8
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +26 -4
- package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +17 -1
- package/UI/Components/MasterPage/MasterPage.tsx +24 -11
- package/Utils/Monitor/MonitorMetricType.ts +16 -1
- package/Utils/Monitor/NetworkTopologyUtil.ts +60 -8
- package/Utils/NetworkDevice/DeviceReachabilityUtil.ts +262 -0
- package/Utils/NetworkSite/SiteStatusRollupUtil.ts +41 -24
- package/Utils/Telemetry/CrossSignalScope.ts +75 -4
- package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +20 -4
- package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +66 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPage.js +60 -0
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageOidc.js +2 -0
- package/build/dist/Models/DatabaseModels/StatusPageOidc.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
- package/build/dist/Server/API/AIChatAPI.js +48 -35
- package/build/dist/Server/API/AIChatAPI.js.map +1 -1
- package/build/dist/Server/API/CommonAPI.js +59 -0
- package/build/dist/Server/API/CommonAPI.js.map +1 -1
- package/build/dist/Server/API/MicrosoftTeamsAPI.js +20 -6
- package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
- package/build/dist/Server/API/SlackAPI.js +13 -10
- package/build/dist/Server/API/SlackAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +745 -673
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +34 -3
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js +23 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js +34 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/UserAuthorization.js +6 -2
- package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
- package/build/dist/Server/Services/ApiKeyPermissionService.js +90 -1
- package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +15 -6
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/LogAggregationService.js +2 -0
- package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
- package/build/dist/Server/Services/LogService.js +16 -0
- package/build/dist/Server/Services/LogService.js.map +1 -1
- package/build/dist/Server/Services/NetworkSiteService.js +12 -0
- package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
- package/build/dist/Server/Services/SpanService.js +16 -0
- package/build/dist/Server/Services/SpanService.js.map +1 -1
- package/build/dist/Server/Services/TraceAggregationService.js +2 -0
- package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +137 -164
- package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +9 -0
- package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js +21 -13
- package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js.map +1 -1
- package/build/dist/Server/Utils/APIKey/AccessPermission.js +9 -32
- package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +87 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/LogRedaction.js +449 -0
- package/build/dist/Server/Utils/LogRedaction.js.map +1 -0
- package/build/dist/Server/Utils/Logger.js +98 -45
- package/build/dist/Server/Utils/Logger.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +65 -13
- package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +20 -3
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +27 -7
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js +29 -10
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +10 -7
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js +24 -0
- package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +311 -0
- package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +366 -37
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/API/Hostname.js +63 -0
- package/build/dist/Types/API/Hostname.js.map +1 -1
- package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js +20 -6
- package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js.map +1 -1
- package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js +20 -6
- package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js.map +1 -1
- package/build/dist/Types/Database/DatabaseProperty.js +38 -0
- package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
- package/build/dist/Types/Database/TableColumn.js +24 -6
- package/build/dist/Types/Database/TableColumn.js.map +1 -1
- package/build/dist/Types/Events/Recurring.js +13 -1
- package/build/dist/Types/Events/Recurring.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStep.js +9 -1
- package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorType.js +330 -31
- package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
- package/build/dist/Types/StatusPage/SearchEngineIndexing.js +55 -0
- package/build/dist/Types/StatusPage/SearchEngineIndexing.js.map +1 -0
- package/build/dist/Types/Telemetry/ResourceEntityFacet.js +156 -0
- package/build/dist/Types/Telemetry/ResourceEntityFacet.js.map +1 -0
- package/build/dist/UI/Components/CardSelect/CardSelect.js +360 -40
- package/build/dist/UI/Components/CardSelect/CardSelect.js.map +1 -1
- package/build/dist/UI/Components/EmptyState/EmptyState.js +1 -1
- package/build/dist/UI/Components/EmptyState/EmptyState.js.map +1 -1
- package/build/dist/UI/Components/Footer/Footer.js +7 -4
- package/build/dist/UI/Components/Footer/Footer.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
- package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
- package/build/dist/UI/Components/Loader/PageLoader.js +15 -5
- package/build/dist/UI/Components/Loader/PageLoader.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +16 -3
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js +13 -1
- package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js +10 -2
- package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
- package/build/dist/Utils/Monitor/MonitorMetricType.js +13 -1
- package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +34 -7
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js +177 -0
- package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js.map +1 -0
- package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +23 -27
- package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +44 -4
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { AddNetworkDeviceReachabilityColumns1787600000000 } from "../../../Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns";
|
|
2
|
+
import SchemaMigrations from "../../../Server/Infrastructure/Postgres/SchemaMigrations/Index";
|
|
3
|
+
import NetworkDevice from "../../../Models/DatabaseModels/NetworkDevice";
|
|
4
|
+
import { MigrationInterface, QueryRunner } from "typeorm";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { describe, expect, test } from "@jest/globals";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* The migration behind the reachability fix (issue #3220).
|
|
11
|
+
*
|
|
12
|
+
* Reachability moved from "lastSeenAt is recent" to "the last poll
|
|
13
|
+
* succeeded", which needs two new columns. Four things have to hold or a
|
|
14
|
+
* deployed instance is worse off than before the fix:
|
|
15
|
+
*
|
|
16
|
+
* 1. up() adds exactly lastPolledAt and isReachable, both nullable —
|
|
17
|
+
* NULL is the "never polled" state the UI renders as Pending, so a
|
|
18
|
+
* NOT NULL column or a default would put every existing device into a
|
|
19
|
+
* real verdict it has not earned;
|
|
20
|
+
* 2. it backfills existing rows, because an unbackfilled fleet would read
|
|
21
|
+
* as entirely Pending on the first page load after an upgrade — every
|
|
22
|
+
* device gone from both the Up and Down lists;
|
|
23
|
+
* 3. down() removes exactly what up() added;
|
|
24
|
+
* 4. the migration is registered in SchemaMigrations/Index.ts. An
|
|
25
|
+
* unregistered migration never runs on boot, so the columns would be
|
|
26
|
+
* missing while every read and the walk pipeline write to them.
|
|
27
|
+
*
|
|
28
|
+
* Pure SQL-contract assertions against a fake QueryRunner. No Postgres.
|
|
29
|
+
*
|
|
30
|
+
* This file also carries the ORDERING guard, handed on from
|
|
31
|
+
* DeviceRoleAndDeclaredLinkParentMigration.test.ts: this is the newest
|
|
32
|
+
* registered migration, so its timestamp must sort above every other one.
|
|
33
|
+
* `generate-postgres-migration` stamps a WALL-CLOCK timestamp, while the
|
|
34
|
+
* recent migrations here use hand-picked round numbers that already run
|
|
35
|
+
* ahead of it — so a generated file lands BELOW several registered
|
|
36
|
+
* migrations unless somebody renumbers it, and runs out of order without
|
|
37
|
+
* anything saying so. Whoever adds the next migration should move this
|
|
38
|
+
* block to theirs.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const MIGRATION_DIRECTORY: string = path.join(
|
|
42
|
+
__dirname,
|
|
43
|
+
"../../../Server/Infrastructure/Postgres/SchemaMigrations",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const MIGRATION_TIMESTAMP: string = "1787600000000";
|
|
47
|
+
|
|
48
|
+
interface MigrationClass {
|
|
49
|
+
name: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function timestampOf(migrationClass: MigrationClass): number | null {
|
|
53
|
+
const match: RegExpMatchArray | null = migrationClass.name.match(/(\d+)$/);
|
|
54
|
+
return match ? Number(match[1]) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type MakeQueryRunnerResult = {
|
|
58
|
+
runner: QueryRunner;
|
|
59
|
+
statements: Array<string>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type MakeQueryRunnerFunction = () => MakeQueryRunnerResult;
|
|
63
|
+
|
|
64
|
+
const makeQueryRunner: MakeQueryRunnerFunction = (): MakeQueryRunnerResult => {
|
|
65
|
+
const statements: Array<string> = [];
|
|
66
|
+
|
|
67
|
+
const query: (...args: Array<unknown>) => Promise<undefined> = (
|
|
68
|
+
...args: Array<unknown>
|
|
69
|
+
): Promise<undefined> => {
|
|
70
|
+
statements.push(String(args[0]));
|
|
71
|
+
return Promise.resolve(undefined);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
runner: { query } as unknown as QueryRunner,
|
|
76
|
+
statements,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
describe("AddNetworkDeviceReachabilityColumns1787600000000 — SQL contract", () => {
|
|
81
|
+
test("up() adds the two columns and backfills, in that order", async () => {
|
|
82
|
+
const { runner, statements } = makeQueryRunner();
|
|
83
|
+
|
|
84
|
+
await new AddNetworkDeviceReachabilityColumns1787600000000().up(runner);
|
|
85
|
+
|
|
86
|
+
expect(statements).toEqual([
|
|
87
|
+
`ALTER TABLE "NetworkDevice" ADD "lastPolledAt" TIMESTAMP WITH TIME ZONE`,
|
|
88
|
+
`ALTER TABLE "NetworkDevice" ADD "isReachable" boolean`,
|
|
89
|
+
`UPDATE "NetworkDevice" SET "lastPolledAt" = "lastSeenAt", "isReachable" = true WHERE "lastSeenAt" IS NOT NULL`,
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
* NULL is a meaning here, not an absence: it is what the reachability rule
|
|
95
|
+
* reads as "never polled" and the UI renders as a gray Pending pill. A
|
|
96
|
+
* DEFAULT would give every pre-existing device a verdict on the strength
|
|
97
|
+
* of nothing.
|
|
98
|
+
*/
|
|
99
|
+
test("both columns are nullable with no default", async () => {
|
|
100
|
+
const { runner, statements } = makeQueryRunner();
|
|
101
|
+
|
|
102
|
+
await new AddNetworkDeviceReachabilityColumns1787600000000().up(runner);
|
|
103
|
+
|
|
104
|
+
for (const statement of statements.filter((sql: string) => {
|
|
105
|
+
return sql.includes("ALTER TABLE");
|
|
106
|
+
})) {
|
|
107
|
+
expect(statement).not.toContain("NOT NULL");
|
|
108
|
+
expect(statement).not.toContain("DEFAULT");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
* A device with a lastSeenAt answered the last walk we have any record of
|
|
114
|
+
* — there is no record of a failure — so it is seeded reachable with that
|
|
115
|
+
* timestamp as its last attempt. Anything older than the staleness window
|
|
116
|
+
* still reads Down through the ordinary rule, so this is not a
|
|
117
|
+
* whitewash: it just declines to invent a failure that never happened.
|
|
118
|
+
*/
|
|
119
|
+
test("the backfill only touches rows that have actually been seen", async () => {
|
|
120
|
+
const { runner, statements } = makeQueryRunner();
|
|
121
|
+
|
|
122
|
+
await new AddNetworkDeviceReachabilityColumns1787600000000().up(runner);
|
|
123
|
+
|
|
124
|
+
const backfill: string = statements[2]!;
|
|
125
|
+
|
|
126
|
+
expect(backfill).toContain(`WHERE "lastSeenAt" IS NOT NULL`);
|
|
127
|
+
expect(backfill).toContain(`"lastPolledAt" = "lastSeenAt"`);
|
|
128
|
+
expect(backfill).toContain(`"isReachable" = true`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("the backfill runs after the columns exist", async () => {
|
|
132
|
+
const { runner, statements } = makeQueryRunner();
|
|
133
|
+
|
|
134
|
+
await new AddNetworkDeviceReachabilityColumns1787600000000().up(runner);
|
|
135
|
+
|
|
136
|
+
const lastAddIndex: number = Math.max(
|
|
137
|
+
statements.findIndex((sql: string) => {
|
|
138
|
+
return sql.includes(`ADD "lastPolledAt"`);
|
|
139
|
+
}),
|
|
140
|
+
statements.findIndex((sql: string) => {
|
|
141
|
+
return sql.includes(`ADD "isReachable"`);
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
const backfillIndex: number = statements.findIndex((sql: string) => {
|
|
145
|
+
return sql.startsWith("UPDATE");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(backfillIndex).toBeGreaterThan(lastAddIndex);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("down() drops exactly what up() added, and nothing else", async () => {
|
|
152
|
+
const { runner, statements } = makeQueryRunner();
|
|
153
|
+
|
|
154
|
+
await new AddNetworkDeviceReachabilityColumns1787600000000().down(runner);
|
|
155
|
+
|
|
156
|
+
expect(statements).toEqual([
|
|
157
|
+
`ALTER TABLE "NetworkDevice" DROP COLUMN "isReachable"`,
|
|
158
|
+
`ALTER TABLE "NetworkDevice" DROP COLUMN "lastPolledAt"`,
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/*
|
|
163
|
+
* Not just "same count": the exact column names, derived from the two
|
|
164
|
+
* statement sets rather than restated, so adding a column to up() without
|
|
165
|
+
* adding it to down() fails here.
|
|
166
|
+
*/
|
|
167
|
+
test("up() and down() name the same columns", async () => {
|
|
168
|
+
const { runner: upRunner, statements: upStatements } = makeQueryRunner();
|
|
169
|
+
const { runner: downRunner, statements: downStatements } =
|
|
170
|
+
makeQueryRunner();
|
|
171
|
+
|
|
172
|
+
await new AddNetworkDeviceReachabilityColumns1787600000000().up(upRunner);
|
|
173
|
+
await new AddNetworkDeviceReachabilityColumns1787600000000().down(
|
|
174
|
+
downRunner,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const columnsIn: (
|
|
178
|
+
statements: Array<string>,
|
|
179
|
+
keyword: string,
|
|
180
|
+
) => Array<string> = (
|
|
181
|
+
statements: Array<string>,
|
|
182
|
+
keyword: string,
|
|
183
|
+
): Array<string> => {
|
|
184
|
+
return statements
|
|
185
|
+
.filter((sql: string) => {
|
|
186
|
+
return sql.includes(keyword);
|
|
187
|
+
})
|
|
188
|
+
.map((sql: string) => {
|
|
189
|
+
return sql.split(`${keyword} "`)[1]!.split('"')[0]!;
|
|
190
|
+
})
|
|
191
|
+
.sort();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
expect(columnsIn(downStatements, "DROP COLUMN")).toEqual(
|
|
195
|
+
columnsIn(upStatements, "ADD"),
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("registration", () => {
|
|
201
|
+
/*
|
|
202
|
+
* The step AGENTS.md calls out and the one that fails silently: a
|
|
203
|
+
* migration that is not in this array never runs, so the columns are
|
|
204
|
+
* missing in a real deployment while every read path and the walk
|
|
205
|
+
* pipeline reference them.
|
|
206
|
+
*/
|
|
207
|
+
test("the migration is registered so it runs on boot", () => {
|
|
208
|
+
const names: Array<string> = SchemaMigrations.map(
|
|
209
|
+
(migration: new () => MigrationInterface): string => {
|
|
210
|
+
return migration.name;
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(names).toContain("AddNetworkDeviceReachabilityColumns1787600000000");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("it is registered exactly once", () => {
|
|
218
|
+
const occurrences: number = SchemaMigrations.filter(
|
|
219
|
+
(migration: new () => MigrationInterface): boolean => {
|
|
220
|
+
return (
|
|
221
|
+
migration.name === "AddNetworkDeviceReachabilityColumns1787600000000"
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
).length;
|
|
225
|
+
|
|
226
|
+
expect(occurrences).toBe(1);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/*
|
|
230
|
+
* TypeORM records applied migrations by the `name` property, not the file
|
|
231
|
+
* name. A mismatch re-runs the migration on every boot.
|
|
232
|
+
*/
|
|
233
|
+
test("its declared name matches its class name", () => {
|
|
234
|
+
expect(new AddNetworkDeviceReachabilityColumns1787600000000().name).toBe(
|
|
235
|
+
"AddNetworkDeviceReachabilityColumns1787600000000",
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// The guard described in the header. Hand it to the next migration.
|
|
241
|
+
describe("ordering", () => {
|
|
242
|
+
test("its timestamp sorts after every other registered migration", () => {
|
|
243
|
+
const ourTimestamp: number | null = timestampOf(
|
|
244
|
+
AddNetworkDeviceReachabilityColumns1787600000000,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
expect(ourTimestamp).not.toBeNull();
|
|
248
|
+
|
|
249
|
+
const otherTimestamps: Array<number> = [];
|
|
250
|
+
|
|
251
|
+
for (const migrationClass of SchemaMigrations) {
|
|
252
|
+
if (migrationClass === AddNetworkDeviceReachabilityColumns1787600000000) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const timestamp: number | null = timestampOf(
|
|
257
|
+
migrationClass as MigrationClass,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (timestamp !== null) {
|
|
261
|
+
otherTimestamps.push(timestamp);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/*
|
|
266
|
+
* Math.max() of an empty list is -Infinity, which every timestamp beats.
|
|
267
|
+
* Prove the list was actually enumerated before leaning on the maximum.
|
|
268
|
+
*/
|
|
269
|
+
expect(otherTimestamps.length).toBeGreaterThan(100);
|
|
270
|
+
|
|
271
|
+
expect(ourTimestamp).toBeGreaterThan(Math.max(...otherTimestamps));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("it is registered last, matching that timestamp", () => {
|
|
275
|
+
expect(SchemaMigrations[SchemaMigrations.length - 1]).toBe(
|
|
276
|
+
AddNetworkDeviceReachabilityColumns1787600000000,
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/*
|
|
281
|
+
* ...and the timestamp in the class name is the one on disk. A class
|
|
282
|
+
* renamed without renaming its file (or two migrations landing on the
|
|
283
|
+
* same timestamp) leaves the ordering above asserting something that is
|
|
284
|
+
* not what actually runs.
|
|
285
|
+
*/
|
|
286
|
+
test("exactly one file on disk carries its timestamp, and it is this one", () => {
|
|
287
|
+
const matching: Array<string> = fs
|
|
288
|
+
.readdirSync(MIGRATION_DIRECTORY)
|
|
289
|
+
.filter((file: string): boolean => {
|
|
290
|
+
return file.startsWith(`${MIGRATION_TIMESTAMP}-`);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(matching).toEqual([
|
|
294
|
+
`${MIGRATION_TIMESTAMP}-AddNetworkDeviceReachabilityColumns.ts`,
|
|
295
|
+
]);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/*
|
|
300
|
+
* The model side of the same contract. TypeORM generates the schema from
|
|
301
|
+
* these decorators, so a column that exists in the migration but not on the
|
|
302
|
+
* model (or vice versa) is exactly the drift the Postgres Schema Drift
|
|
303
|
+
* workflow fails on.
|
|
304
|
+
*/
|
|
305
|
+
describe("the NetworkDevice model carries the columns the migration adds", () => {
|
|
306
|
+
test("lastPolledAt and isReachable are declared on the model", () => {
|
|
307
|
+
const device: NetworkDevice = new NetworkDevice();
|
|
308
|
+
|
|
309
|
+
expect("lastPolledAt" in device).toBe(true);
|
|
310
|
+
expect("isReachable" in device).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("they default to undefined, not to a fabricated verdict", () => {
|
|
314
|
+
const device: NetworkDevice = new NetworkDevice();
|
|
315
|
+
|
|
316
|
+
expect(device.lastPolledAt).toBeUndefined();
|
|
317
|
+
expect(device.isReachable).toBeUndefined();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("a walk can set them independently of lastSeenAt", () => {
|
|
321
|
+
const device: NetworkDevice = new NetworkDevice();
|
|
322
|
+
const now: Date = new Date("2026-08-18T12:00:00.000Z");
|
|
323
|
+
|
|
324
|
+
device.lastPolledAt = now;
|
|
325
|
+
device.isReachable = false;
|
|
326
|
+
|
|
327
|
+
expect(device.lastPolledAt).toEqual(now);
|
|
328
|
+
expect(device.isReachable).toBe(false);
|
|
329
|
+
// A failed poll never moves the last successful contact.
|
|
330
|
+
expect(device.lastSeenAt).toBeUndefined();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ApiKeyPermissionService.findPermissionsByApiKeyId cache tests.
|
|
3
|
+
*
|
|
4
|
+
* Every API-key-authenticated request builds its tenant permissions from this
|
|
5
|
+
* lookup, so it now consults a per-process 60s TTL cache before running the
|
|
6
|
+
* findBy (with its labels join). The contract pinned here is load-bearing in
|
|
7
|
+
* three ways:
|
|
8
|
+
* - the underlying query is unchanged: keyed by exactly the given apiKeyId,
|
|
9
|
+
* as root, selecting permission / labels._id / isBlockPermission — and the
|
|
10
|
+
* row -> DTO mapping preserves the old edge cases (undefined labels -> [],
|
|
11
|
+
* isBlockPermission passed through unchanged, never defaulted)
|
|
12
|
+
* - hits and misses both return freshly built objects, so a caller mutating
|
|
13
|
+
* its result cannot poison the permissions of every later request
|
|
14
|
+
* - the service's own lifecycle hooks (create/update/delete) clear the cache,
|
|
15
|
+
* so a permission edit takes effect immediately in the process that made it;
|
|
16
|
+
* the TTL only bounds staleness for OTHER processes.
|
|
17
|
+
*
|
|
18
|
+
* ApiKeyPermissionService.findBy is spied on, so no database is touched.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import ApiKeyPermissionService, {
|
|
22
|
+
ApiKeyPermissionRow,
|
|
23
|
+
} from "../../../Server/Services/ApiKeyPermissionService";
|
|
24
|
+
import ApiKeyPermission from "../../../Models/DatabaseModels/ApiKeyPermission";
|
|
25
|
+
import Label from "../../../Models/DatabaseModels/Label";
|
|
26
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
27
|
+
import Permission from "../../../Types/Permission";
|
|
28
|
+
import { getJestSpyOn } from "../../Spy";
|
|
29
|
+
import {
|
|
30
|
+
afterEach,
|
|
31
|
+
beforeEach,
|
|
32
|
+
describe,
|
|
33
|
+
expect,
|
|
34
|
+
jest,
|
|
35
|
+
test,
|
|
36
|
+
} from "@jest/globals";
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* PasswordHash has a known, pre-existing TS5.9 compile failure under ts-jest
|
|
40
|
+
* (Buffer vs BinaryLike) that breaks every suite whose import graph reaches
|
|
41
|
+
* it. Nothing here touches password hashing; stub the module before the
|
|
42
|
+
* service import graph drags it into compilation.
|
|
43
|
+
*/
|
|
44
|
+
jest.mock("../../../Server/Utils/PasswordHash", () => {
|
|
45
|
+
return {
|
|
46
|
+
__esModule: true,
|
|
47
|
+
default: class PasswordHashStub {},
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const TTL_MS: number = 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
* The hooks are protected on purpose — DatabaseService calls them, feature
|
|
55
|
+
* code does not. Reaching them through a cast is how their cache-clearing
|
|
56
|
+
* behaviour can be pinned without standing up Postgres.
|
|
57
|
+
*/
|
|
58
|
+
type HookCallable = {
|
|
59
|
+
onBeforeCreate: (createBy: unknown) => Promise<unknown>;
|
|
60
|
+
onBeforeUpdate: (updateBy: unknown) => Promise<unknown>;
|
|
61
|
+
onBeforeDelete: (deleteBy: unknown) => Promise<unknown>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function asHooks(service: unknown): HookCallable {
|
|
65
|
+
return service as unknown as HookCallable;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeModelRow(data: {
|
|
69
|
+
permission: Permission;
|
|
70
|
+
labelIds?: Array<ObjectID>;
|
|
71
|
+
isBlockPermission?: boolean;
|
|
72
|
+
}): ApiKeyPermission {
|
|
73
|
+
const row: ApiKeyPermission = new ApiKeyPermission();
|
|
74
|
+
row.permission = data.permission;
|
|
75
|
+
if (data.labelIds) {
|
|
76
|
+
row.labels = data.labelIds.map((id: ObjectID) => {
|
|
77
|
+
const label: Label = new Label();
|
|
78
|
+
label.id = id;
|
|
79
|
+
return label;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (data.isBlockPermission !== undefined) {
|
|
83
|
+
row.isBlockPermission = data.isBlockPermission;
|
|
84
|
+
}
|
|
85
|
+
return row;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function spyOnFindBy(): jest.SpyInstance {
|
|
89
|
+
return getJestSpyOn(ApiKeyPermissionService, "findBy");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe("ApiKeyPermissionService.findPermissionsByApiKeyId", () => {
|
|
93
|
+
const apiKeyId: ObjectID = ObjectID.generate();
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
/*
|
|
97
|
+
* The cache lives on the singleton service, so entries written by one
|
|
98
|
+
* test would leak into the next without an explicit clear.
|
|
99
|
+
*/
|
|
100
|
+
ApiKeyPermissionService.clearCache();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
jest.restoreAllMocks();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("runs the exact legacy query and maps rows onto DTOs", async () => {
|
|
108
|
+
const labelId: ObjectID = ObjectID.generate();
|
|
109
|
+
const findBy: jest.SpyInstance = spyOnFindBy().mockResolvedValue([
|
|
110
|
+
makeModelRow({
|
|
111
|
+
permission: Permission.CreateProjectIncident,
|
|
112
|
+
labelIds: [labelId],
|
|
113
|
+
isBlockPermission: true,
|
|
114
|
+
}),
|
|
115
|
+
makeModelRow({
|
|
116
|
+
// labels intentionally left undefined; isBlockPermission too.
|
|
117
|
+
permission: Permission.ReadProjectMonitor,
|
|
118
|
+
}),
|
|
119
|
+
] as never);
|
|
120
|
+
|
|
121
|
+
const rows: Array<ApiKeyPermissionRow> =
|
|
122
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
123
|
+
|
|
124
|
+
expect(findBy).toHaveBeenCalledTimes(1);
|
|
125
|
+
const arg: Record<string, any> = findBy.mock.calls[0]![0] as Record<
|
|
126
|
+
string,
|
|
127
|
+
any
|
|
128
|
+
>;
|
|
129
|
+
expect(arg["query"]["apiKeyId"].toString()).toBe(apiKeyId.toString());
|
|
130
|
+
expect(arg["props"]["isRoot"]).toBe(true);
|
|
131
|
+
expect(arg["select"]).toEqual({
|
|
132
|
+
permission: true,
|
|
133
|
+
labels: {
|
|
134
|
+
_id: true,
|
|
135
|
+
},
|
|
136
|
+
isBlockPermission: true,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(rows).toHaveLength(2);
|
|
140
|
+
// Label scope survives the string round-trip through the cache.
|
|
141
|
+
expect(rows[0]!.permission).toBe(Permission.CreateProjectIncident);
|
|
142
|
+
expect(
|
|
143
|
+
rows[0]!.labelIds.map((id: ObjectID) => {
|
|
144
|
+
return id.toString();
|
|
145
|
+
}),
|
|
146
|
+
).toEqual([labelId.toString()]);
|
|
147
|
+
// A block row stays a block row.
|
|
148
|
+
expect(rows[0]!.isBlockPermission).toBe(true);
|
|
149
|
+
// Undefined labels become [] rather than crashing or staying undefined.
|
|
150
|
+
expect(rows[1]!.labelIds).toEqual([]);
|
|
151
|
+
// isBlockPermission is passed through unchanged, never defaulted.
|
|
152
|
+
expect(rows[1]!.isBlockPermission).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("serves the second call for the same key from cache", async () => {
|
|
156
|
+
const findBy: jest.SpyInstance = spyOnFindBy().mockResolvedValue([
|
|
157
|
+
makeModelRow({
|
|
158
|
+
permission: Permission.ProjectMember,
|
|
159
|
+
isBlockPermission: false,
|
|
160
|
+
}),
|
|
161
|
+
] as never);
|
|
162
|
+
|
|
163
|
+
const first: Array<ApiKeyPermissionRow> =
|
|
164
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
165
|
+
const second: Array<ApiKeyPermissionRow> =
|
|
166
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
167
|
+
|
|
168
|
+
expect(findBy).toHaveBeenCalledTimes(1);
|
|
169
|
+
expect(second).toHaveLength(1);
|
|
170
|
+
expect(second[0]!.permission).toBe(Permission.ProjectMember);
|
|
171
|
+
expect(second[0]!.isBlockPermission).toBe(false);
|
|
172
|
+
expect(first).toEqual(second);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("hits return fresh objects, so mutation cannot poison the cache", async () => {
|
|
176
|
+
const labelId: ObjectID = ObjectID.generate();
|
|
177
|
+
spyOnFindBy().mockResolvedValue([
|
|
178
|
+
makeModelRow({
|
|
179
|
+
permission: Permission.CreateProjectIncident,
|
|
180
|
+
labelIds: [labelId],
|
|
181
|
+
isBlockPermission: false,
|
|
182
|
+
}),
|
|
183
|
+
] as never);
|
|
184
|
+
|
|
185
|
+
const first: Array<ApiKeyPermissionRow> =
|
|
186
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
187
|
+
|
|
188
|
+
// Mutate everything a caller could reach on the first result.
|
|
189
|
+
first[0]!.labelIds.pop();
|
|
190
|
+
first.pop();
|
|
191
|
+
|
|
192
|
+
const second: Array<ApiKeyPermissionRow> =
|
|
193
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
194
|
+
|
|
195
|
+
expect(second).toHaveLength(1);
|
|
196
|
+
expect(
|
|
197
|
+
second[0]!.labelIds.map((id: ObjectID) => {
|
|
198
|
+
return id.toString();
|
|
199
|
+
}),
|
|
200
|
+
).toEqual([labelId.toString()]);
|
|
201
|
+
// Distinct object graphs on every call, hit or miss.
|
|
202
|
+
expect(second).not.toBe(first);
|
|
203
|
+
expect(second[0]).not.toBe(first[0]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("caches distinct api keys independently", async () => {
|
|
207
|
+
const otherApiKeyId: ObjectID = ObjectID.generate();
|
|
208
|
+
const findBy: jest.SpyInstance = spyOnFindBy().mockImplementation(((
|
|
209
|
+
options: Record<string, any>,
|
|
210
|
+
): Promise<Array<ApiKeyPermission>> => {
|
|
211
|
+
const queriedId: string = options["query"]["apiKeyId"].toString();
|
|
212
|
+
return Promise.resolve([
|
|
213
|
+
makeModelRow({
|
|
214
|
+
permission:
|
|
215
|
+
queriedId === apiKeyId.toString()
|
|
216
|
+
? Permission.ProjectMember
|
|
217
|
+
: Permission.ReadProjectMonitor,
|
|
218
|
+
isBlockPermission: false,
|
|
219
|
+
}),
|
|
220
|
+
]);
|
|
221
|
+
}) as never);
|
|
222
|
+
|
|
223
|
+
const first: Array<ApiKeyPermissionRow> =
|
|
224
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
225
|
+
const other: Array<ApiKeyPermissionRow> =
|
|
226
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(otherApiKeyId);
|
|
227
|
+
|
|
228
|
+
expect(findBy).toHaveBeenCalledTimes(2);
|
|
229
|
+
expect(first[0]!.permission).toBe(Permission.ProjectMember);
|
|
230
|
+
expect(other[0]!.permission).toBe(Permission.ReadProjectMonitor);
|
|
231
|
+
|
|
232
|
+
// Both keys are now cached: repeat calls issue no further queries.
|
|
233
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
234
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(otherApiKeyId);
|
|
235
|
+
expect(findBy).toHaveBeenCalledTimes(2);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("a key with zero permission rows still short-circuits the second call", async () => {
|
|
239
|
+
const findBy: jest.SpyInstance = spyOnFindBy().mockResolvedValue(
|
|
240
|
+
[] as never,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
await expect(
|
|
244
|
+
ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId),
|
|
245
|
+
).resolves.toEqual([]);
|
|
246
|
+
await expect(
|
|
247
|
+
ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId),
|
|
248
|
+
).resolves.toEqual([]);
|
|
249
|
+
|
|
250
|
+
expect(findBy).toHaveBeenCalledTimes(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("expires after the 60s TTL and re-queries", async () => {
|
|
254
|
+
const base: number = Date.now();
|
|
255
|
+
let nowMs: number = base;
|
|
256
|
+
jest.spyOn(Date, "now").mockImplementation((): number => {
|
|
257
|
+
return nowMs;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const findBy: jest.SpyInstance = spyOnFindBy().mockResolvedValue(
|
|
261
|
+
[] as never,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
265
|
+
expect(findBy).toHaveBeenCalledTimes(1);
|
|
266
|
+
|
|
267
|
+
// Just inside the TTL: still served from cache.
|
|
268
|
+
nowMs = base + TTL_MS - 1;
|
|
269
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
270
|
+
expect(findBy).toHaveBeenCalledTimes(1);
|
|
271
|
+
|
|
272
|
+
// Past the TTL: the entry has expired and the key is re-queried.
|
|
273
|
+
nowMs = base + TTL_MS + 1;
|
|
274
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
275
|
+
expect(findBy).toHaveBeenCalledTimes(2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("invalidation", () => {
|
|
279
|
+
type PrimeCacheFunction = () => Promise<jest.SpyInstance>;
|
|
280
|
+
|
|
281
|
+
const primeCache: PrimeCacheFunction =
|
|
282
|
+
async (): Promise<jest.SpyInstance> => {
|
|
283
|
+
const findBy: jest.SpyInstance = spyOnFindBy().mockResolvedValue(
|
|
284
|
+
[] as never,
|
|
285
|
+
);
|
|
286
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
287
|
+
expect(findBy).toHaveBeenCalledTimes(1);
|
|
288
|
+
return findBy;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
test("clearCache() drops every cached entry", async () => {
|
|
292
|
+
const findBy: jest.SpyInstance = await primeCache();
|
|
293
|
+
|
|
294
|
+
ApiKeyPermissionService.clearCache();
|
|
295
|
+
|
|
296
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
297
|
+
expect(findBy).toHaveBeenCalledTimes(2);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("creating a permission clears the cache", async () => {
|
|
301
|
+
const findBy: jest.SpyInstance = await primeCache();
|
|
302
|
+
|
|
303
|
+
// The duplicate-permission check inside the hook must find nothing.
|
|
304
|
+
getJestSpyOn(ApiKeyPermissionService, "findOneBy").mockResolvedValue(
|
|
305
|
+
null,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const data: ApiKeyPermission = new ApiKeyPermission();
|
|
309
|
+
data.apiKeyId = apiKeyId;
|
|
310
|
+
data.projectId = ObjectID.generate();
|
|
311
|
+
data.permission = Permission.ProjectMember;
|
|
312
|
+
|
|
313
|
+
await asHooks(ApiKeyPermissionService).onBeforeCreate({
|
|
314
|
+
data: data,
|
|
315
|
+
props: { isRoot: true },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
319
|
+
expect(findBy).toHaveBeenCalledTimes(2);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("updating a permission clears the cache", async () => {
|
|
323
|
+
const findBy: jest.SpyInstance = await primeCache();
|
|
324
|
+
|
|
325
|
+
await asHooks(ApiKeyPermissionService).onBeforeUpdate({
|
|
326
|
+
query: {},
|
|
327
|
+
data: {},
|
|
328
|
+
props: { isRoot: true },
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
332
|
+
expect(findBy).toHaveBeenCalledTimes(2);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("deleting a permission clears the cache", async () => {
|
|
336
|
+
const findBy: jest.SpyInstance = await primeCache();
|
|
337
|
+
|
|
338
|
+
await asHooks(ApiKeyPermissionService).onBeforeDelete({
|
|
339
|
+
query: {},
|
|
340
|
+
props: { isRoot: true },
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
|
|
344
|
+
expect(findBy).toHaveBeenCalledTimes(2);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
});
|