@oneuptime/common 11.7.1 → 11.7.2
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/DashboardDomain.ts +1 -0
- package/Models/DatabaseModels/Incident.ts +2 -0
- package/Models/DatabaseModels/IncidentEpisode.ts +1 -0
- package/Models/DatabaseModels/IncidentEpisodePublicNote.ts +1 -0
- package/Models/DatabaseModels/IncidentEpisodeStateTimeline.ts +1 -0
- package/Models/DatabaseModels/IncidentPublicNote.ts +1 -0
- package/Models/DatabaseModels/IncidentStateTimeline.ts +1 -0
- package/Models/DatabaseModels/Monitor.ts +1 -0
- package/Models/DatabaseModels/MonitorStatusTimeline.ts +1 -0
- package/Models/DatabaseModels/NetworkSite.ts +1 -0
- package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +1 -0
- package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +1 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +2 -0
- package/Models/DatabaseModels/Probe.ts +13 -13
- package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -0
- package/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +1 -0
- package/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.ts +1 -0
- package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -0
- package/Models/DatabaseModels/StatusPage.ts +1 -0
- package/Models/DatabaseModels/StatusPageAnnouncement.ts +1 -0
- package/Models/DatabaseModels/StatusPageDomain.ts +1 -0
- package/Models/DatabaseModels/WorkspaceNotificationSummary.ts +1 -0
- package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +11 -0
- package/Server/API/BaseAPI.ts +17 -1
- package/Server/API/CommonAPI.ts +32 -0
- package/Server/API/MicrosoftTeamsAPI.ts +99 -2
- package/Server/API/ProbeAPI.ts +2 -0
- package/Server/API/SlackAPI.ts +62 -0
- package/Server/API/StatusPageAPI.ts +157 -192
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.ts +89 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.ts +61 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.ts +280 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
- package/Server/Services/DatabaseService.ts +11 -3
- package/Server/Services/MonitorGroupService.ts +145 -0
- package/Server/Services/MonitorService.ts +231 -30
- package/Server/Services/ProbeService.ts +319 -16
- package/Server/Services/StatusPageService.ts +72 -42
- package/Server/Services/WorkspaceNotificationRuleService.ts +232 -2
- package/Server/Utils/Database/PostgresErrorTranslator.ts +131 -0
- package/Server/Utils/Monitor/MonitorResource.ts +88 -35
- package/Server/Utils/Monitor/MonitorStatusTimeline.ts +21 -1
- package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +158 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +706 -6
- package/Tests/Models/DatabaseModels/ProbeColumnAccessControl.test.ts +103 -0
- package/Tests/Server/API/BaseAPIUpdateNoOp.test.ts +135 -0
- package/Tests/Server/API/CommonAPIAuthGuard.test.ts +317 -0
- package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +228 -0
- package/Tests/Server/API/ProbeAPI.test.ts +6 -0
- package/Tests/Server/API/ZZVerifyChatsAuth.test.ts +167 -0
- package/Tests/Server/Services/AddHotQueryIndexesMigration.test.ts +255 -0
- package/Tests/Server/Services/AddHotQueryIndexesSecondPassMigration.test.ts +258 -0
- package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +1 -1
- package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +121 -0
- package/Tests/Server/Services/HeartbeatWriteFastPathSafety.test.ts +200 -0
- package/Tests/Server/Services/HookFreeWriteSafetySecondPass.test.ts +181 -0
- package/Tests/Server/Services/MonitorGroupServiceBatchedStatuses.test.ts +513 -0
- package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +492 -0
- package/Tests/Server/Services/MonitorServiceProbeSelection.test.ts +401 -0
- package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +453 -0
- package/Tests/Server/Services/ProbeAuthKeyCacheAndHeartbeat.test.ts +679 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +3 -3
- package/Tests/Server/Services/StatusPageDomainResolutionCache.test.ts +245 -0
- package/Tests/Server/Services/StatusPageOverviewTimelineWindow.test.ts +283 -0
- package/Tests/Server/Services/StatusPageServiceMcp.test.ts +17 -1
- package/Tests/Server/Services/StatusPageServiceMonitorGroupBatching.test.ts +331 -0
- package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +12 -12
- package/Tests/Server/Services/WorkspaceNotificationRuleChats.test.ts +1390 -0
- package/Tests/Server/Utils/AI/Insights/InsightFixRouting.test.ts +4 -4
- package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +2 -2
- package/Tests/Server/Utils/AI/Insights/InsightStore.test.ts +1 -3
- package/Tests/Server/Utils/AI/Insights/InsightStoreHardening.test.ts +1 -3
- package/Tests/Server/Utils/AI/Insights/InsightTriage.test.ts +1 -1
- package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +5 -5
- package/Tests/Server/Utils/Alert/AlertPrivacyFilter.test.ts +187 -0
- package/Tests/Server/Utils/AlertEpisode/AlertEpisodePrivacyFilter.test.ts +170 -0
- package/Tests/Server/Utils/Database/PostgresErrorTranslator.test.ts +125 -0
- package/Tests/Server/Utils/Incident/IncidentPrivacyFilter.test.ts +264 -0
- package/Tests/Server/Utils/IncidentEpisode/IncidentEpisodePrivacyFilter.test.ts +175 -0
- package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +181 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +1 -1
- package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +2 -2
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelsList.test.ts +341 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +2175 -0
- package/Tests/Server/Utils/Workspace/SlackChannelsList.test.ts +585 -0
- package/Tests/Types/Billing/MeteredPlan.test.ts +46 -0
- package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +219 -1
- package/Tests/UI/Components/BulkUpdateForm.test.tsx +174 -0
- package/Tests/UI/Components/CardModelDetailEdit.test.tsx +315 -0
- package/Tests/UI/Components/ModelDetailSelect.test.tsx +229 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkSelectAll.test.tsx +1258 -0
- package/Tests/UI/Components/ModelTableExportFromColumns.test.ts +153 -0
- package/Tests/UI/Components/MonacoLoader.test.ts +67 -0
- package/Tests/UI/Components/MonacoRuntime.test.ts +121 -0
- package/Tests/UI/Components/TableBulkCsvExport.test.tsx +212 -3
- package/Tests/UI/Utils/Project.test.ts +809 -0
- package/Tests/UI/Utils/TableColumnsToCsv.test.ts +360 -0
- package/Tests/Utils/Alerts/AlertMetricType.test.ts +143 -0
- package/Tests/Utils/Dashboard/VariableUrlState.test.ts +219 -0
- package/Tests/Utils/Incident/IncidentMetricType.test.ts +174 -0
- package/Tests/Utils/Monitor/MonitorProbeSelectionUtil.test.ts +145 -0
- package/Tests/Utils/StatusPage/ResourceUptime.test.ts +263 -0
- package/Types/Workspace/NotificationRules/BaseNotificationRule.ts +4 -0
- package/Types/Workspace/NotificationRules/NotificationRuleCondition.ts +25 -10
- package/Types/Workspace/WorkspaceMessagePayload.ts +1 -0
- package/UI/Components/BulkUpdate/BulkUpdateForm.tsx +63 -8
- package/UI/Components/CodeEditor/CodeEditor.tsx +3 -0
- package/UI/Components/CodeEditor/MonacoLoader.ts +36 -0
- package/UI/Components/Forms/ModelForm.tsx +41 -18
- package/UI/Components/ModelDetail/CardModelDetail.tsx +23 -1
- package/UI/Components/ModelDetail/ModelDetail.tsx +46 -1
- package/UI/Components/ModelTable/BaseModelTable.tsx +300 -30
- package/UI/Components/ModelTable/Column.ts +12 -0
- package/UI/Components/ModelTable/ExportFromColumns.ts +57 -0
- package/UI/Components/Navbar/NavBar.tsx +66 -1
- package/UI/Components/Table/Table.tsx +52 -13
- package/UI/Components/Table/Types/Column.ts +18 -0
- package/UI/Utils/Project.ts +214 -18
- package/UI/Utils/TableColumnsToCsv.ts +86 -6
- package/UI/esbuild-config.js +34 -0
- package/Utils/Monitor/MonitorProbeSelectionUtil.ts +72 -0
- package/build/dist/Models/DatabaseModels/DashboardDomain.js +1 -0
- package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Incident.js +2 -0
- package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisode.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentPublicNote.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentPublicNote.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Monitor.js +1 -0
- package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkSite.js +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +1 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +2 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Probe.js +13 -13
- package/build/dist/Models/DatabaseModels/Probe.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPage.js +1 -0
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +1 -0
- package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js +1 -0
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js +1 -0
- package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
- package/build/dist/Server/API/BaseAPI.js +12 -1
- package/build/dist/Server/API/BaseAPI.js.map +1 -1
- package/build/dist/Server/API/CommonAPI.js +21 -0
- package/build/dist/Server/API/CommonAPI.js.map +1 -1
- package/build/dist/Server/API/MicrosoftTeamsAPI.js +72 -2
- package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
- package/build/dist/Server/API/ProbeAPI.js +2 -0
- package/build/dist/Server/API/ProbeAPI.js.map +1 -1
- package/build/dist/Server/API/SlackAPI.js +44 -0
- package/build/dist/Server/API/SlackAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +98 -152
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js +36 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js +254 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +10 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/MonitorGroupService.js +118 -0
- package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +198 -24
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/ProbeService.js +238 -9
- package/build/dist/Server/Services/ProbeService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +46 -39
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +178 -3
- package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js +80 -0
- package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +67 -29
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +31 -14
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +117 -0
- package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +549 -7
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js +17 -7
- package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js.map +1 -1
- package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js +37 -11
- package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js.map +1 -1
- package/build/dist/UI/Components/CodeEditor/CodeEditor.js +2 -0
- package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
- package/build/dist/UI/Components/CodeEditor/MonacoLoader.js +34 -0
- package/build/dist/UI/Components/CodeEditor/MonacoLoader.js.map +1 -0
- package/build/dist/UI/Components/Forms/ModelForm.js +37 -17
- package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
- package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +22 -1
- package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
- package/build/dist/UI/Components/ModelDetail/ModelDetail.js +31 -1
- package/build/dist/UI/Components/ModelDetail/ModelDetail.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +234 -21
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/ExportFromColumns.js +30 -0
- package/build/dist/UI/Components/ModelTable/ExportFromColumns.js.map +1 -0
- package/build/dist/UI/Components/Navbar/NavBar.js +39 -1
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Table/Table.js +31 -11
- package/build/dist/UI/Components/Table/Table.js.map +1 -1
- package/build/dist/UI/Utils/Project.js +161 -15
- package/build/dist/UI/Utils/Project.js.map +1 -1
- package/build/dist/UI/Utils/TableColumnsToCsv.js +67 -6
- package/build/dist/UI/Utils/TableColumnsToCsv.js.map +1 -1
- package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js +52 -0
- package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js.map +1 -0
- package/package.json +4 -3
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Probe liveness hardening in ProbeService: a customer's probes were flagged
|
|
3
|
+
* Disconnected because /probe-ingest/alive hung 45+ seconds — every probe
|
|
4
|
+
* request re-authenticated with a Probe SELECT that queued behind a starved
|
|
5
|
+
* Postgres pool, and the throttled lastAlive stamp could silently suppress
|
|
6
|
+
* its own retry after a failed write. This suite pins the fixes:
|
|
7
|
+
*
|
|
8
|
+
* - verifyProbeKey: Redis-cached SHA-256 key verification (namespace
|
|
9
|
+
* "probe-auth-key-hash", TTL 120s) with DB fallback, so repeat probe
|
|
10
|
+
* requests skip the per-request Probe SELECT entirely.
|
|
11
|
+
* - boundedCacheOperation: a 2s cap on every cache op — ioredis has no
|
|
12
|
+
* command timeout, so a stalled-but-open Redis socket must not hang
|
|
13
|
+
* probe auth.
|
|
14
|
+
* - updateLastAlive: clears the 30s "probe-last-alive" throttle stamp when
|
|
15
|
+
* the DB write FAILS, so the next request retries instead of silently
|
|
16
|
+
* letting lastAlive cross the 3-minute Disconnected cutoff.
|
|
17
|
+
* - onUpdateSuccess/onDeleteSuccess: key rotation and probe deletion
|
|
18
|
+
* overwrite the cached auth hash cluster-wide with a revocation
|
|
19
|
+
* sentinel, and verifyProbeKey's write-only-if-unchanged re-read keeps
|
|
20
|
+
* an in-flight verification from resurrecting the revoked hash.
|
|
21
|
+
*
|
|
22
|
+
* GlobalCache and MonitorService are mocked at the module boundary — the
|
|
23
|
+
* real modules pull in Redis and the whole database stack. DB calls are
|
|
24
|
+
* spied on the ProbeService singleton. No Postgres, no Redis.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const mockGetString: jest.Mock = jest.fn();
|
|
28
|
+
const mockSetString: jest.Mock = jest.fn();
|
|
29
|
+
const mockDeleteKey: jest.Mock = jest.fn();
|
|
30
|
+
|
|
31
|
+
jest.mock("../../../Server/Infrastructure/GlobalCache", () => {
|
|
32
|
+
return {
|
|
33
|
+
__esModule: true,
|
|
34
|
+
default: {
|
|
35
|
+
getString: (...args: Array<unknown>) => {
|
|
36
|
+
return mockGetString(...args);
|
|
37
|
+
},
|
|
38
|
+
setString: (...args: Array<unknown>) => {
|
|
39
|
+
return mockSetString(...args);
|
|
40
|
+
},
|
|
41
|
+
deleteKey: (...args: Array<unknown>) => {
|
|
42
|
+
return mockDeleteKey(...args);
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const mockRefreshProbeStatus: jest.Mock = jest.fn();
|
|
49
|
+
|
|
50
|
+
jest.mock("../../../Server/Services/MonitorService", () => {
|
|
51
|
+
return {
|
|
52
|
+
__esModule: true,
|
|
53
|
+
default: {
|
|
54
|
+
refreshProbeStatus: (...args: Array<unknown>) => {
|
|
55
|
+
return mockRefreshProbeStatus(...args);
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
jest.mock("../../../Server/Utils/Logger", () => {
|
|
62
|
+
return {
|
|
63
|
+
__esModule: true,
|
|
64
|
+
default: {
|
|
65
|
+
debug: jest.fn(),
|
|
66
|
+
info: jest.fn(),
|
|
67
|
+
warn: jest.fn(),
|
|
68
|
+
error: jest.fn(),
|
|
69
|
+
},
|
|
70
|
+
getLogAttributesFromRequest: jest.fn(() => {
|
|
71
|
+
return {};
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
import crypto from "crypto";
|
|
77
|
+
import logger from "../../../Server/Utils/Logger";
|
|
78
|
+
import ProbeService from "../../../Server/Services/ProbeService";
|
|
79
|
+
import Probe, {
|
|
80
|
+
ProbeConnectionStatus,
|
|
81
|
+
} from "../../../Models/DatabaseModels/Probe";
|
|
82
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
83
|
+
import OneUptimeDate from "../../../Types/Date";
|
|
84
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
85
|
+
import UpdateBy from "../../../Server/Types/Database/UpdateBy";
|
|
86
|
+
import DeleteBy from "../../../Server/Types/Database/DeleteBy";
|
|
87
|
+
import { OnDelete, OnUpdate } from "../../../Server/Types/Database/Hooks";
|
|
88
|
+
import { describe, expect, test, beforeEach, afterEach } from "@jest/globals";
|
|
89
|
+
|
|
90
|
+
const PROBE_ID: ObjectID = new ObjectID("11111111-1111-4111-8111-111111111111");
|
|
91
|
+
const SECOND_PROBE_ID: ObjectID = new ObjectID(
|
|
92
|
+
"22222222-2222-4222-8222-222222222222",
|
|
93
|
+
);
|
|
94
|
+
const PROBE_KEY: string = "probe-key-abc-123";
|
|
95
|
+
const ROTATED_PROBE_KEY: string = "probe-key-rotated-456";
|
|
96
|
+
|
|
97
|
+
const AUTH_CACHE_NAMESPACE: string = "probe-auth-key-hash";
|
|
98
|
+
const AUTH_CACHE_TTL_IN_SECONDS: number = 120;
|
|
99
|
+
const AUTH_CACHE_REVOKED_SENTINEL: string = "revoked";
|
|
100
|
+
const LAST_ALIVE_CACHE_NAMESPACE: string = "probe-last-alive";
|
|
101
|
+
const CACHE_OPERATION_TIMEOUT_IN_MS: number = 2000;
|
|
102
|
+
|
|
103
|
+
type Sha256Function = (value: string) => string;
|
|
104
|
+
|
|
105
|
+
/*
|
|
106
|
+
* Computed independently of ProbeService.hashProbeKey so the tests pin the
|
|
107
|
+
* EXACT on-the-wire cache format, not whatever the service happens to do.
|
|
108
|
+
*/
|
|
109
|
+
const sha256: Sha256Function = (value: string): string => {
|
|
110
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
type MakeProbeRowFunction = () => Probe;
|
|
114
|
+
|
|
115
|
+
const makeProbeRow: MakeProbeRowFunction = (): Probe => {
|
|
116
|
+
const probe: Probe = new Probe();
|
|
117
|
+
probe.id = PROBE_ID;
|
|
118
|
+
return probe;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
describe("ProbeService probe-auth cache and heartbeat throttle", () => {
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
jest.clearAllMocks();
|
|
124
|
+
mockGetString.mockResolvedValue(null);
|
|
125
|
+
mockSetString.mockResolvedValue(undefined);
|
|
126
|
+
mockDeleteKey.mockResolvedValue(undefined);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
jest.restoreAllMocks();
|
|
131
|
+
jest.useRealTimers();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("verifyProbeKey (cache-backed auth)", () => {
|
|
135
|
+
/*
|
|
136
|
+
* The whole point of the cache: a repeat request from a known probe must
|
|
137
|
+
* authenticate WITHOUT touching Postgres. If findOneBy fires here, the
|
|
138
|
+
* per-request Probe SELECT is back and probe auth queues behind the pool
|
|
139
|
+
* again — the original 45s-hang ingredient.
|
|
140
|
+
*/
|
|
141
|
+
test("cache hit with matching SHA-256 authenticates with NO Probe SELECT", async () => {
|
|
142
|
+
mockGetString.mockResolvedValue(sha256(PROBE_KEY));
|
|
143
|
+
// Would return "not found" if consulted — proving the cache decided.
|
|
144
|
+
const findOneBySpy: jest.SpyInstance = jest
|
|
145
|
+
.spyOn(ProbeService, "findOneBy")
|
|
146
|
+
.mockResolvedValue(null);
|
|
147
|
+
|
|
148
|
+
const result: boolean = await ProbeService.verifyProbeKey({
|
|
149
|
+
probeId: PROBE_ID,
|
|
150
|
+
probeKey: PROBE_KEY,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result).toBe(true);
|
|
154
|
+
expect(findOneBySpy).not.toHaveBeenCalled();
|
|
155
|
+
expect(mockSetString).not.toHaveBeenCalled();
|
|
156
|
+
expect(mockGetString).toHaveBeenCalledWith(
|
|
157
|
+
AUTH_CACHE_NAMESPACE,
|
|
158
|
+
PROBE_ID.toString(),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/*
|
|
163
|
+
* Security invariant: the cache stores only a SHA-256 hex digest of the
|
|
164
|
+
* key with a bounded TTL — a Redis dump must never contain a usable
|
|
165
|
+
* probe credential.
|
|
166
|
+
*/
|
|
167
|
+
test("caches only the SHA-256 hex of the key with the 120s TTL — never the raw key", async () => {
|
|
168
|
+
const findOneBySpy: jest.SpyInstance = jest
|
|
169
|
+
.spyOn(ProbeService, "findOneBy")
|
|
170
|
+
.mockResolvedValue(makeProbeRow());
|
|
171
|
+
|
|
172
|
+
const result: boolean = await ProbeService.verifyProbeKey({
|
|
173
|
+
probeId: PROBE_ID,
|
|
174
|
+
probeKey: PROBE_KEY,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result).toBe(true);
|
|
178
|
+
expect(findOneBySpy).toHaveBeenCalledTimes(1);
|
|
179
|
+
expect(mockSetString).toHaveBeenCalledTimes(1);
|
|
180
|
+
expect(mockSetString).toHaveBeenCalledWith(
|
|
181
|
+
AUTH_CACHE_NAMESPACE,
|
|
182
|
+
PROBE_ID.toString(),
|
|
183
|
+
sha256(PROBE_KEY),
|
|
184
|
+
{ expiresInSeconds: AUTH_CACHE_TTL_IN_SECONDS },
|
|
185
|
+
);
|
|
186
|
+
// The raw key must not appear ANYWHERE in the cache-write arguments.
|
|
187
|
+
expect(JSON.stringify(mockSetString.mock.calls)).not.toContain(PROBE_KEY);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("cache miss verifies against the DB with the presented id+key pair", async () => {
|
|
191
|
+
const findOneBySpy: jest.SpyInstance = jest
|
|
192
|
+
.spyOn(ProbeService, "findOneBy")
|
|
193
|
+
.mockResolvedValue(makeProbeRow());
|
|
194
|
+
|
|
195
|
+
const result: boolean = await ProbeService.verifyProbeKey({
|
|
196
|
+
probeId: PROBE_ID,
|
|
197
|
+
probeKey: PROBE_KEY,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(result).toBe(true);
|
|
201
|
+
expect(findOneBySpy).toHaveBeenCalledTimes(1);
|
|
202
|
+
expect(findOneBySpy).toHaveBeenCalledWith(
|
|
203
|
+
expect.objectContaining({
|
|
204
|
+
query: { _id: PROBE_ID.toString(), key: PROBE_KEY },
|
|
205
|
+
props: { isRoot: true },
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/*
|
|
211
|
+
* A cache entry may only ever be written from a value that was JUST
|
|
212
|
+
* verified against the database — caching an UNVERIFIED hash would let a
|
|
213
|
+
* wrong key poison the cache and then authenticate itself on retry.
|
|
214
|
+
*/
|
|
215
|
+
test("DB not-found returns false and writes NOTHING to the cache", async () => {
|
|
216
|
+
const findOneBySpy: jest.SpyInstance = jest
|
|
217
|
+
.spyOn(ProbeService, "findOneBy")
|
|
218
|
+
.mockResolvedValue(null);
|
|
219
|
+
|
|
220
|
+
const result: boolean = await ProbeService.verifyProbeKey({
|
|
221
|
+
probeId: PROBE_ID,
|
|
222
|
+
probeKey: "wrong-key",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(result).toBe(false);
|
|
226
|
+
expect(findOneBySpy).toHaveBeenCalledTimes(1);
|
|
227
|
+
expect(mockSetString).not.toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
/*
|
|
231
|
+
* Key rotation: the cached hash belongs to the OLD key, the probe
|
|
232
|
+
* presents the NEW one. The mismatch must fall through to the DB (so a
|
|
233
|
+
* freshly rotated key works immediately, no TTL wait). The cache write
|
|
234
|
+
* is SKIPPED while a conflicting entry is still present — the
|
|
235
|
+
* write-only-if-unchanged rule never overwrites a value someone else
|
|
236
|
+
* put there — so requests keep DB-verifying until the old entry
|
|
237
|
+
* expires, at which point caching resumes (pinned below).
|
|
238
|
+
*/
|
|
239
|
+
test("stale cached hash (rotated key) falls through to the DB and does not clobber the existing entry", async () => {
|
|
240
|
+
mockGetString.mockResolvedValue(sha256(PROBE_KEY));
|
|
241
|
+
const findOneBySpy: jest.SpyInstance = jest
|
|
242
|
+
.spyOn(ProbeService, "findOneBy")
|
|
243
|
+
.mockResolvedValue(makeProbeRow());
|
|
244
|
+
|
|
245
|
+
const result: boolean = await ProbeService.verifyProbeKey({
|
|
246
|
+
probeId: PROBE_ID,
|
|
247
|
+
probeKey: ROTATED_PROBE_KEY,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(result).toBe(true);
|
|
251
|
+
expect(findOneBySpy).toHaveBeenCalledTimes(1);
|
|
252
|
+
expect(mockSetString).not.toHaveBeenCalled();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
/*
|
|
256
|
+
* The revocation sentinel written by invalidation can never equal a
|
|
257
|
+
* SHA-256 digest, so it must behave as a miss on the auth path (DB
|
|
258
|
+
* decides) and must NOT be overwritten by the verification it forced —
|
|
259
|
+
* that write-back is exactly the rotation race the sentinel exists to
|
|
260
|
+
* stop.
|
|
261
|
+
*/
|
|
262
|
+
test("the revocation sentinel forces a DB check and is never overwritten", async () => {
|
|
263
|
+
mockGetString.mockResolvedValue(AUTH_CACHE_REVOKED_SENTINEL);
|
|
264
|
+
const findOneBySpy: jest.SpyInstance = jest
|
|
265
|
+
.spyOn(ProbeService, "findOneBy")
|
|
266
|
+
.mockResolvedValue(makeProbeRow());
|
|
267
|
+
|
|
268
|
+
const result: boolean = await ProbeService.verifyProbeKey({
|
|
269
|
+
probeId: PROBE_ID,
|
|
270
|
+
probeKey: PROBE_KEY,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(result).toBe(true);
|
|
274
|
+
expect(findOneBySpy).toHaveBeenCalledTimes(1);
|
|
275
|
+
expect(mockSetString).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/*
|
|
279
|
+
* THE rotation-TOCTOU pin. A verification can straddle a key rotation:
|
|
280
|
+
* its DB SELECT resolves against the pre-rotation row, THEN the
|
|
281
|
+
* rotation commits and invalidation lands. An unconditional cache write
|
|
282
|
+
* here would re-cache the just-revoked key for a fresh TTL —
|
|
283
|
+
* resurrecting a credential the rotation was meant to kill. The
|
|
284
|
+
* pre-write re-read must see the change and skip the write.
|
|
285
|
+
*/
|
|
286
|
+
test("a rotation landing mid-verification wins: the straddling write is skipped", async () => {
|
|
287
|
+
mockGetString
|
|
288
|
+
.mockResolvedValueOnce(null) // initial read: plain miss
|
|
289
|
+
.mockResolvedValueOnce(AUTH_CACHE_REVOKED_SENTINEL); // pre-write read: rotation landed
|
|
290
|
+
const findOneBySpy: jest.SpyInstance = jest
|
|
291
|
+
.spyOn(ProbeService, "findOneBy")
|
|
292
|
+
.mockResolvedValue(makeProbeRow());
|
|
293
|
+
|
|
294
|
+
const result: boolean = await ProbeService.verifyProbeKey({
|
|
295
|
+
probeId: PROBE_ID,
|
|
296
|
+
probeKey: PROBE_KEY,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// The DB verified the (pre-rotation) key, so THIS request passes...
|
|
300
|
+
expect(result).toBe(true);
|
|
301
|
+
expect(findOneBySpy).toHaveBeenCalledTimes(1);
|
|
302
|
+
// ...but nothing may be written over the invalidation.
|
|
303
|
+
expect(mockSetString).not.toHaveBeenCalled();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("caching resumes once the conflicting entry has expired", async () => {
|
|
307
|
+
// Both reads see an empty slot — the sentinel/old hash TTL'd out.
|
|
308
|
+
mockGetString.mockResolvedValue(null);
|
|
309
|
+
jest.spyOn(ProbeService, "findOneBy").mockResolvedValue(makeProbeRow());
|
|
310
|
+
|
|
311
|
+
await ProbeService.verifyProbeKey({
|
|
312
|
+
probeId: PROBE_ID,
|
|
313
|
+
probeKey: ROTATED_PROBE_KEY,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(mockSetString).toHaveBeenCalledWith(
|
|
317
|
+
AUTH_CACHE_NAMESPACE,
|
|
318
|
+
PROBE_ID.toString(),
|
|
319
|
+
sha256(ROTATED_PROBE_KEY),
|
|
320
|
+
{ expiresInSeconds: AUTH_CACHE_TTL_IN_SECONDS },
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
/*
|
|
325
|
+
* Redis down must degrade to "cache off", not "auth off": the DB is the
|
|
326
|
+
* source of truth and a cache failure may neither fail the request
|
|
327
|
+
* (closed) nor skip verification (open).
|
|
328
|
+
*/
|
|
329
|
+
test("cache read rejection falls back to the DB and still authenticates", async () => {
|
|
330
|
+
mockGetString.mockRejectedValue(new Error("Redis connection refused"));
|
|
331
|
+
const findOneBySpy: jest.SpyInstance = jest
|
|
332
|
+
.spyOn(ProbeService, "findOneBy")
|
|
333
|
+
.mockResolvedValue(makeProbeRow());
|
|
334
|
+
|
|
335
|
+
const result: boolean = await ProbeService.verifyProbeKey({
|
|
336
|
+
probeId: PROBE_ID,
|
|
337
|
+
probeKey: PROBE_KEY,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(result).toBe(true);
|
|
341
|
+
expect(findOneBySpy).toHaveBeenCalledTimes(1);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
/*
|
|
345
|
+
* THE hang pin. ioredis has no command timeout: a stalled-but-open Redis
|
|
346
|
+
* socket returns a promise that never settles, and before the 2s
|
|
347
|
+
* boundedCacheOperation cap that promise would have hung probe auth
|
|
348
|
+
* forever — the async middleware's rejection-free stall was exactly the
|
|
349
|
+
* silent-hang mode that got healthy probes flagged Disconnected. A
|
|
350
|
+
* never-settling cache read must time out and fall back to the DB.
|
|
351
|
+
*/
|
|
352
|
+
test("a never-settling cache read is capped at 2s and falls back to the DB", async () => {
|
|
353
|
+
jest.useFakeTimers();
|
|
354
|
+
|
|
355
|
+
mockGetString.mockReturnValue(
|
|
356
|
+
new Promise<string | null>(() => {
|
|
357
|
+
// Never settles — simulates a dead-but-open Redis socket.
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
const findOneBySpy: jest.SpyInstance = jest
|
|
361
|
+
.spyOn(ProbeService, "findOneBy")
|
|
362
|
+
.mockResolvedValue(makeProbeRow());
|
|
363
|
+
|
|
364
|
+
const resultPromise: Promise<boolean> = ProbeService.verifyProbeKey({
|
|
365
|
+
probeId: PROBE_ID,
|
|
366
|
+
probeKey: PROBE_KEY,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
/*
|
|
370
|
+
* Two caps fire on this path: the initial read (→ DB fallback) and
|
|
371
|
+
* the pre-write re-read (→ skip the cache write). The microtask
|
|
372
|
+
* flushes between the advances let the promise chain progress far
|
|
373
|
+
* enough that the second bounded op has registered its cap timer
|
|
374
|
+
* before it is advanced.
|
|
375
|
+
*/
|
|
376
|
+
jest.advanceTimersByTime(CACHE_OPERATION_TIMEOUT_IN_MS);
|
|
377
|
+
for (let i: number = 0; i < 10; i++) {
|
|
378
|
+
await Promise.resolve();
|
|
379
|
+
}
|
|
380
|
+
jest.advanceTimersByTime(CACHE_OPERATION_TIMEOUT_IN_MS);
|
|
381
|
+
|
|
382
|
+
await expect(resultPromise).resolves.toBe(true);
|
|
383
|
+
expect(findOneBySpy).toHaveBeenCalledTimes(1);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
/*
|
|
387
|
+
* The cap timer must not outlive a fast cache operation: a leaked 2s
|
|
388
|
+
* timer per probe request would accumulate thousands of live timers on
|
|
389
|
+
* a busy ingest pod.
|
|
390
|
+
*/
|
|
391
|
+
test("a settled cache operation leaves no cap timer behind", async () => {
|
|
392
|
+
jest.useFakeTimers();
|
|
393
|
+
mockGetString.mockResolvedValue(sha256(PROBE_KEY));
|
|
394
|
+
|
|
395
|
+
const result: boolean = await ProbeService.verifyProbeKey({
|
|
396
|
+
probeId: PROBE_ID,
|
|
397
|
+
probeKey: PROBE_KEY,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(result).toBe(true);
|
|
401
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("missing probeId or probeKey is rejected with BadDataException", async () => {
|
|
405
|
+
await expect(
|
|
406
|
+
ProbeService.verifyProbeKey({
|
|
407
|
+
probeId: undefined as unknown as ObjectID,
|
|
408
|
+
probeKey: PROBE_KEY,
|
|
409
|
+
}),
|
|
410
|
+
).rejects.toThrow(BadDataException);
|
|
411
|
+
|
|
412
|
+
await expect(
|
|
413
|
+
ProbeService.verifyProbeKey({
|
|
414
|
+
probeId: PROBE_ID,
|
|
415
|
+
probeKey: "",
|
|
416
|
+
}),
|
|
417
|
+
).rejects.toThrow(BadDataException);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe("updateLastAlive (throttled heartbeat stamp)", () => {
|
|
422
|
+
/*
|
|
423
|
+
* The throttle is what makes it safe to stamp lastAlive on EVERY
|
|
424
|
+
* authenticated probe request: at most one single-statement UPDATE per
|
|
425
|
+
* probe per 30s, no matter how chatty the probe is.
|
|
426
|
+
*/
|
|
427
|
+
test("skips the DB write while the 30s throttle stamp is fresh", async () => {
|
|
428
|
+
mockGetString.mockResolvedValue(
|
|
429
|
+
OneUptimeDate.toString(OneUptimeDate.getSomeSecondsAgo(10)),
|
|
430
|
+
);
|
|
431
|
+
const updateSpy: jest.SpyInstance = jest
|
|
432
|
+
.spyOn(ProbeService, "updateColumnsByIdWithoutHooks")
|
|
433
|
+
.mockResolvedValue(undefined);
|
|
434
|
+
|
|
435
|
+
await ProbeService.updateLastAlive(PROBE_ID);
|
|
436
|
+
|
|
437
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
438
|
+
// The stamp is not refreshed either — that would extend the throttle.
|
|
439
|
+
expect(mockSetString).not.toHaveBeenCalled();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("writes exactly one hookless lastAlive UPDATE once the stamp is stale", async () => {
|
|
443
|
+
mockGetString.mockResolvedValue(
|
|
444
|
+
OneUptimeDate.toString(OneUptimeDate.getSomeSecondsAgo(45)),
|
|
445
|
+
);
|
|
446
|
+
const updateSpy: jest.SpyInstance = jest
|
|
447
|
+
.spyOn(ProbeService, "updateColumnsByIdWithoutHooks")
|
|
448
|
+
.mockResolvedValue(undefined);
|
|
449
|
+
|
|
450
|
+
await ProbeService.updateLastAlive(PROBE_ID);
|
|
451
|
+
|
|
452
|
+
expect(updateSpy).toHaveBeenCalledTimes(1);
|
|
453
|
+
expect(updateSpy).toHaveBeenCalledWith({
|
|
454
|
+
id: PROBE_ID,
|
|
455
|
+
data: { lastAlive: expect.any(Date) },
|
|
456
|
+
});
|
|
457
|
+
// The throttle stamp is re-written for the next 30s window.
|
|
458
|
+
expect(mockSetString).toHaveBeenCalledWith(
|
|
459
|
+
LAST_ALIVE_CACHE_NAMESPACE,
|
|
460
|
+
PROBE_ID.toString(),
|
|
461
|
+
expect.any(String),
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("writes when no throttle stamp exists (first heartbeat)", async () => {
|
|
466
|
+
// Default mockGetString resolves null — nothing cached yet.
|
|
467
|
+
const updateSpy: jest.SpyInstance = jest
|
|
468
|
+
.spyOn(ProbeService, "updateColumnsByIdWithoutHooks")
|
|
469
|
+
.mockResolvedValue(undefined);
|
|
470
|
+
|
|
471
|
+
await ProbeService.updateLastAlive(PROBE_ID);
|
|
472
|
+
|
|
473
|
+
expect(updateSpy).toHaveBeenCalledTimes(1);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
/*
|
|
477
|
+
* THE self-heal pin. The throttle stamp is written BEFORE the DB write
|
|
478
|
+
* is attempted (deliberately, as dogpile protection). Leaving it in
|
|
479
|
+
* place after a FAILED write means every request for the next 30s
|
|
480
|
+
* silently skips the retry; a couple of consecutive failures and
|
|
481
|
+
* lastAlive crosses the connection-status worker's 3-minute staleness
|
|
482
|
+
* cutoff — a HEALTHY probe gets flagged Disconnected, which is the
|
|
483
|
+
* customer-reported failure mode. A failed write must clear the stamp
|
|
484
|
+
* so the very next request retries, and must rethrow so the caller's
|
|
485
|
+
* error logging still fires.
|
|
486
|
+
*/
|
|
487
|
+
test("a FAILED lastAlive write clears the throttle stamp and rethrows", async () => {
|
|
488
|
+
const updateSpy: jest.SpyInstance = jest
|
|
489
|
+
.spyOn(ProbeService, "updateColumnsByIdWithoutHooks")
|
|
490
|
+
.mockRejectedValue(new Error("connection pool exhausted"));
|
|
491
|
+
|
|
492
|
+
await expect(ProbeService.updateLastAlive(PROBE_ID)).rejects.toThrow(
|
|
493
|
+
"connection pool exhausted",
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
expect(updateSpy).toHaveBeenCalledTimes(1);
|
|
497
|
+
expect(mockDeleteKey).toHaveBeenCalledWith(
|
|
498
|
+
LAST_ALIVE_CACHE_NAMESPACE,
|
|
499
|
+
PROBE_ID.toString(),
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
/*
|
|
504
|
+
* The throttle-clear is best-effort: if Redis is ALSO down, the stamp
|
|
505
|
+
* expires on its own — but the ORIGINAL database error must surface,
|
|
506
|
+
* not the secondary cache error.
|
|
507
|
+
*/
|
|
508
|
+
test("throttle-clear failure does not mask the original DB error", async () => {
|
|
509
|
+
mockDeleteKey.mockRejectedValue(new Error("redis down"));
|
|
510
|
+
jest
|
|
511
|
+
.spyOn(ProbeService, "updateColumnsByIdWithoutHooks")
|
|
512
|
+
.mockRejectedValue(new Error("connection pool exhausted"));
|
|
513
|
+
|
|
514
|
+
await expect(ProbeService.updateLastAlive(PROBE_ID)).rejects.toThrow(
|
|
515
|
+
"connection pool exhausted",
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("a successful write leaves the throttle stamp in place", async () => {
|
|
520
|
+
jest
|
|
521
|
+
.spyOn(ProbeService, "updateColumnsByIdWithoutHooks")
|
|
522
|
+
.mockResolvedValue(undefined);
|
|
523
|
+
|
|
524
|
+
await ProbeService.updateLastAlive(PROBE_ID);
|
|
525
|
+
|
|
526
|
+
expect(mockDeleteKey).not.toHaveBeenCalled();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("auth cache invalidation hooks", () => {
|
|
531
|
+
/*
|
|
532
|
+
* The auth middleware trusts a cached hash of the key, so the moment
|
|
533
|
+
* the key column changes the cached entries for the updated probes must
|
|
534
|
+
* be revoked — otherwise the OLD key keeps authenticating until the
|
|
535
|
+
* 120s TTL runs out. Invalidation WRITES the sentinel rather than
|
|
536
|
+
* deleting: a bare delete can be silently undone by an in-flight
|
|
537
|
+
* verification re-caching the old hash (see the TOCTOU pin above),
|
|
538
|
+
* while the sentinel makes that straggler's write-only-if-unchanged
|
|
539
|
+
* check fail.
|
|
540
|
+
*/
|
|
541
|
+
test("onUpdateSuccess writes the revocation sentinel for every updated probe when the key changes", async () => {
|
|
542
|
+
const onUpdate: OnUpdate<Probe> = {
|
|
543
|
+
updateBy: {
|
|
544
|
+
query: {},
|
|
545
|
+
data: { key: ROTATED_PROBE_KEY },
|
|
546
|
+
props: { isRoot: true },
|
|
547
|
+
} as UpdateBy<Probe>,
|
|
548
|
+
carryForward: { probesToNotifyOwners: [] },
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
await (ProbeService as any)["onUpdateSuccess"](onUpdate, [
|
|
552
|
+
PROBE_ID,
|
|
553
|
+
SECOND_PROBE_ID,
|
|
554
|
+
]);
|
|
555
|
+
|
|
556
|
+
expect(mockSetString).toHaveBeenCalledTimes(2);
|
|
557
|
+
expect(mockSetString).toHaveBeenCalledWith(
|
|
558
|
+
AUTH_CACHE_NAMESPACE,
|
|
559
|
+
PROBE_ID.toString(),
|
|
560
|
+
AUTH_CACHE_REVOKED_SENTINEL,
|
|
561
|
+
{ expiresInSeconds: AUTH_CACHE_TTL_IN_SECONDS },
|
|
562
|
+
);
|
|
563
|
+
expect(mockSetString).toHaveBeenCalledWith(
|
|
564
|
+
AUTH_CACHE_NAMESPACE,
|
|
565
|
+
SECOND_PROBE_ID.toString(),
|
|
566
|
+
AUTH_CACHE_REVOKED_SENTINEL,
|
|
567
|
+
{ expiresInSeconds: AUTH_CACHE_TTL_IN_SECONDS },
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("onUpdateSuccess leaves the cache alone when data does not touch the key", async () => {
|
|
572
|
+
const onUpdate: OnUpdate<Probe> = {
|
|
573
|
+
updateBy: {
|
|
574
|
+
query: {},
|
|
575
|
+
data: { name: "renamed probe" },
|
|
576
|
+
props: { isRoot: true },
|
|
577
|
+
} as UpdateBy<Probe>,
|
|
578
|
+
carryForward: { probesToNotifyOwners: [] },
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
await (ProbeService as any)["onUpdateSuccess"](onUpdate, [PROBE_ID]);
|
|
582
|
+
|
|
583
|
+
expect(mockSetString).not.toHaveBeenCalled();
|
|
584
|
+
expect(mockRefreshProbeStatus).not.toHaveBeenCalled();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
/*
|
|
588
|
+
* Invalidation is best-effort per probe: one Redis failure must be
|
|
589
|
+
* logged loudly (a revoked key may keep working for up to the TTL) but
|
|
590
|
+
* must neither fail the surrounding update nor stop the remaining
|
|
591
|
+
* probes from being invalidated.
|
|
592
|
+
*/
|
|
593
|
+
test("an invalidation failure is logged, does not throw, and does not stop the remaining probes", async () => {
|
|
594
|
+
mockSetString
|
|
595
|
+
.mockRejectedValueOnce(new Error("redis write failed"))
|
|
596
|
+
.mockResolvedValue(undefined);
|
|
597
|
+
|
|
598
|
+
await expect(
|
|
599
|
+
ProbeService.invalidateProbeAuthCache([PROBE_ID, SECOND_PROBE_ID]),
|
|
600
|
+
).resolves.toBeUndefined();
|
|
601
|
+
|
|
602
|
+
// Both probes were attempted despite the first failure...
|
|
603
|
+
expect(mockSetString).toHaveBeenCalledTimes(2);
|
|
604
|
+
expect(mockSetString).toHaveBeenCalledWith(
|
|
605
|
+
AUTH_CACHE_NAMESPACE,
|
|
606
|
+
SECOND_PROBE_ID.toString(),
|
|
607
|
+
AUTH_CACHE_REVOKED_SENTINEL,
|
|
608
|
+
{ expiresInSeconds: AUTH_CACHE_TTL_IN_SECONDS },
|
|
609
|
+
);
|
|
610
|
+
// ...and the failure was surfaced in the logs.
|
|
611
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
612
|
+
expect.stringContaining("CRITICAL"),
|
|
613
|
+
expect.anything(),
|
|
614
|
+
);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
/*
|
|
618
|
+
* Existing behavior preserved: the cache-invalidation addition must not
|
|
619
|
+
* displace the connection-status owner-notification path that
|
|
620
|
+
* onUpdateSuccess already ran.
|
|
621
|
+
*/
|
|
622
|
+
test("connection-status change still refreshes monitors and notifies owners", async () => {
|
|
623
|
+
const probeRow: Probe = makeProbeRow();
|
|
624
|
+
const notifySpy: jest.SpyInstance = jest
|
|
625
|
+
.spyOn(ProbeService, "notifyOwnersOnStatusChange")
|
|
626
|
+
.mockResolvedValue(undefined);
|
|
627
|
+
|
|
628
|
+
const onUpdate: OnUpdate<Probe> = {
|
|
629
|
+
updateBy: {
|
|
630
|
+
query: {},
|
|
631
|
+
data: { connectionStatus: ProbeConnectionStatus.Disconnected },
|
|
632
|
+
props: { isRoot: true },
|
|
633
|
+
} as UpdateBy<Probe>,
|
|
634
|
+
carryForward: { probesToNotifyOwners: [probeRow] },
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
await (ProbeService as any)["onUpdateSuccess"](onUpdate, [PROBE_ID]);
|
|
638
|
+
|
|
639
|
+
expect(mockRefreshProbeStatus).toHaveBeenCalledTimes(1);
|
|
640
|
+
expect(mockRefreshProbeStatus).toHaveBeenCalledWith(PROBE_ID);
|
|
641
|
+
expect(notifySpy).toHaveBeenCalledWith({ probeId: PROBE_ID });
|
|
642
|
+
// A status flip is not a key change — cached auth stays valid.
|
|
643
|
+
expect(mockDeleteKey).not.toHaveBeenCalled();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
/*
|
|
647
|
+
* A deleted probe's key must stop authenticating immediately, not after
|
|
648
|
+
* the cache TTL.
|
|
649
|
+
*/
|
|
650
|
+
test("onDeleteSuccess writes the revocation sentinel for every deleted probe", async () => {
|
|
651
|
+
const onDelete: OnDelete<Probe> = {
|
|
652
|
+
deleteBy: {
|
|
653
|
+
query: {},
|
|
654
|
+
props: { isRoot: true },
|
|
655
|
+
} as DeleteBy<Probe>,
|
|
656
|
+
carryForward: null,
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
await (ProbeService as any)["onDeleteSuccess"](onDelete, [
|
|
660
|
+
PROBE_ID,
|
|
661
|
+
SECOND_PROBE_ID,
|
|
662
|
+
]);
|
|
663
|
+
|
|
664
|
+
expect(mockSetString).toHaveBeenCalledTimes(2);
|
|
665
|
+
expect(mockSetString).toHaveBeenCalledWith(
|
|
666
|
+
AUTH_CACHE_NAMESPACE,
|
|
667
|
+
PROBE_ID.toString(),
|
|
668
|
+
AUTH_CACHE_REVOKED_SENTINEL,
|
|
669
|
+
{ expiresInSeconds: AUTH_CACHE_TTL_IN_SECONDS },
|
|
670
|
+
);
|
|
671
|
+
expect(mockSetString).toHaveBeenCalledWith(
|
|
672
|
+
AUTH_CACHE_NAMESPACE,
|
|
673
|
+
SECOND_PROBE_ID.toString(),
|
|
674
|
+
AUTH_CACHE_REVOKED_SENTINEL,
|
|
675
|
+
{ expiresInSeconds: AUTH_CACHE_TTL_IN_SECONDS },
|
|
676
|
+
);
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
});
|
|
@@ -858,7 +858,7 @@ describe("ServiceLevelObjectiveService.onCreateSuccess - burn rate rule seeding
|
|
|
858
858
|
beforeEach(() => {
|
|
859
859
|
updateOneByIdSpy = jest
|
|
860
860
|
.spyOn(ServiceLevelObjectiveService, "updateOneById")
|
|
861
|
-
.mockResolvedValue(
|
|
861
|
+
.mockResolvedValue(1);
|
|
862
862
|
|
|
863
863
|
const severity: AlertSeverity = new AlertSeverity();
|
|
864
864
|
severity._id = SEVERITY_ID.toString();
|
|
@@ -1143,7 +1143,7 @@ describe("ServiceLevelObjectiveService.onCreateSuccess - default alert severity"
|
|
|
1143
1143
|
beforeEach(() => {
|
|
1144
1144
|
jest
|
|
1145
1145
|
.spyOn(ServiceLevelObjectiveService, "updateOneById")
|
|
1146
|
-
.mockResolvedValue(
|
|
1146
|
+
.mockResolvedValue(1);
|
|
1147
1147
|
|
|
1148
1148
|
burnRateRuleCreateSpy = jest
|
|
1149
1149
|
.spyOn(ServiceLevelObjectiveBurnRateRuleService, "create")
|
|
@@ -1234,7 +1234,7 @@ describe("ServiceLevelObjectiveService.onUpdateSuccess", () => {
|
|
|
1234
1234
|
|
|
1235
1235
|
updateOneByIdSpy = jest
|
|
1236
1236
|
.spyOn(ServiceLevelObjectiveService, "updateOneById")
|
|
1237
|
-
.mockResolvedValue(
|
|
1237
|
+
.mockResolvedValue(1);
|
|
1238
1238
|
|
|
1239
1239
|
findOneByIdSpy = jest
|
|
1240
1240
|
.spyOn(ServiceLevelObjectiveService, "findOneById")
|