@oneuptime/common 12.0.27 → 12.0.29
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/AnalyticsModels/RumSessionChunk.ts +52 -0
- package/Models/DatabaseModels/CloudResource.ts +17 -1
- package/Models/DatabaseModels/DashboardDomain.ts +43 -0
- package/Models/DatabaseModels/MonitorTemplate.ts +15 -3
- package/Models/DatabaseModels/RumApplication.ts +35 -2
- package/Models/DatabaseModels/StatusPageDomain.ts +43 -0
- package/Server/API/DashboardDomainAPI.ts +74 -0
- package/Server/API/MicrosoftTeamsAPI.ts +81 -180
- package/Server/API/StatusPageDomainAPI.ts +74 -0
- package/Server/API/TelemetryAPI.ts +104 -15
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.ts +43 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.ts +40 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Middleware/ProjectAuthorization.ts +6 -1
- package/Server/Services/DashboardDomainService.ts +131 -0
- package/Server/Services/LogPipelineProcessorService.ts +88 -0
- package/Server/Services/MonitorTemplateService.ts +70 -11
- package/Server/Services/StatusPageDomainService.ts +163 -3
- package/Server/Services/StatusPagePrivateUserSessionService.ts +197 -56
- package/Server/Types/Domain.ts +17 -0
- package/Server/Utils/APIKey/AccessPermission.ts +24 -0
- package/Server/Utils/Greenlock/Greenlock.ts +156 -58
- package/Server/Utils/LogPipelineProcessorValidation.ts +112 -0
- package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.ts +1094 -45
- package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.ts +153 -17
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +12 -5
- package/Server/Utils/SessionReplay/SessionReplayIdentity.ts +102 -0
- package/Server/Utils/SessionReplay/SessionReplayReadService.ts +11 -2
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +23 -11
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +0 -170
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.ts +158 -0
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +23 -11
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +35 -13
- package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +505 -0
- package/Tests/App/StatusPage/StatusPageLoginCodeBootstrap.test.ts +106 -0
- package/Tests/App/StatusPage/StatusPageLoginCodeUtil.test.ts +84 -0
- package/Tests/App/StatusPage/StatusPageRedirectSafety.test.ts +119 -0
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +81 -0
- package/Tests/Models/MonitorTemplateMonitorNameColumn.test.ts +258 -0
- package/Tests/Server/API/CustomDomainReissueSslAPI.test.ts +469 -0
- package/Tests/Server/API/MicrosoftTeamsBotMessagesEndpoint.test.ts +729 -0
- package/Tests/Server/API/MicrosoftTeamsBotTestEndpoint.test.ts +26 -0
- package/Tests/Server/API/MicrosoftTeamsLegacyWebhookRemoval.test.ts +402 -0
- package/Tests/Server/API/SessionReplayAPI.test.ts +232 -0
- package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +4 -0
- package/Tests/Server/Services/AddCertificateReissueRequestedAtToDomainsMigration.test.ts +287 -0
- package/Tests/Server/Services/CustomDomainCertificateReissue.test.ts +477 -0
- package/Tests/Server/Services/LogPipelineProcessorSaveValidation.test.ts +249 -0
- package/Tests/Server/Services/MonitorTemplateServiceBulkSync.test.ts +549 -0
- package/Tests/Server/Services/MonitorTemplateServiceNetworkDeviceSync.test.ts +16 -3
- package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +57 -0
- package/Tests/Server/Services/SecurityEventLastErrorColumnWidth.test.ts +151 -15
- package/Tests/Server/Services/StatusPagePrivateUserSessionService.test.ts +587 -0
- package/Tests/Server/Types/Database/Permissions/ApiKeyFileAccess.test.ts +286 -0
- package/Tests/Server/Types/Database/Permissions/MonitorTemplateColumnAccess.test.ts +328 -0
- package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAndIsNull.test.ts +83 -0
- package/Tests/Server/Utils/AI/SRE/ConfidenceSignal.test.ts +377 -0
- package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +16 -1
- package/Tests/Server/Utils/Greenlock/CertificateRenewalSchedule.test.ts +250 -0
- package/Tests/Server/Utils/LogPipelineProcessorValidation.test.ts +198 -0
- package/Tests/Server/Utils/SecurityEvent/ConnectorErrorMessage.test.ts +48 -9
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsAuth.test.ts +726 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsClient.test.ts +74 -11
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsErrorHandling.test.ts +1107 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPoller.test.ts +20 -3
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerFailureTaxonomy.test.ts +51 -11
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerHardening.test.ts +1246 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsRequestContract.test.ts +961 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsResponseParsing.test.ts +653 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsActionAuthorization.test.ts +880 -0
- package/Tests/Types/API/HTTPResponse.test.ts +175 -0
- package/Tests/Types/API/Route.test.ts +36 -4
- package/Tests/Types/Database/DatabaseProperty.test.ts +171 -0
- package/Tests/Types/DateLocalShortDateTimeString.test.ts +518 -0
- package/Tests/Types/DateLocalTimeString.test.ts +259 -0
- package/Tests/Types/DateUserPrefers12HourFormat.test.ts +316 -0
- package/Tests/UI/Components/LogsHistogram.test.tsx +41 -2
- package/Tests/UI/Components/LogsViewerEmptyStateTimeRange.test.tsx +212 -0
- package/Tests/UI/Components/TelemetryChartClockFormat.test.tsx +359 -0
- package/Tests/UI/Components/TimeRangePickerClockFormat.test.tsx +422 -0
- package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +146 -8
- package/Tests/UI/MicrosoftTeams/MicrosoftTeamsMessagingEndpointGuidance.test.tsx +520 -0
- package/Tests/UI/Utils/Navigation.test.ts +82 -0
- package/Tests/Utils/Array.test.ts +100 -0
- package/Tests/Utils/CertificateReissue.test.ts +225 -0
- package/Tests/Utils/Grok/Grok.test.ts +492 -0
- package/Tests/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.test.ts +181 -13
- package/Types/API/Route.ts +20 -5
- package/Types/Date.ts +143 -13
- package/Types/Rum/SessionReplay.ts +76 -1
- package/UI/Components/Date/TimeRangePickerDropdown.tsx +9 -5
- package/UI/Components/LogsViewer/LogsViewer.tsx +12 -7
- package/UI/Components/LogsViewer/components/HistogramTooltip.tsx +18 -17
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +21 -28
- package/UI/Components/LogsViewer/components/LogsHistogram.tsx +2 -4
- package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +2 -4
- package/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.tsx +17 -16
- package/UI/Utils/Navigation.ts +42 -0
- package/Utils/Array.ts +37 -0
- package/Utils/CertificateReissue.ts +128 -0
- package/Utils/Grok/Grok.ts +461 -0
- package/Utils/Grok/GrokPatterns.ts +118 -0
- package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +58 -6
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js +48 -0
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CloudResource.js +19 -2
- package/build/dist/Models/DatabaseModels/CloudResource.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DashboardDomain.js +44 -0
- package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MonitorTemplate.js +17 -4
- package/build/dist/Models/DatabaseModels/MonitorTemplate.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +37 -3
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js +44 -0
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
- package/build/dist/Server/API/DashboardDomainAPI.js +43 -1
- package/build/dist/Server/API/DashboardDomainAPI.js.map +1 -1
- package/build/dist/Server/API/MicrosoftTeamsAPI.js +74 -118
- package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageDomainAPI.js +43 -1
- package/build/dist/Server/API/StatusPageDomainAPI.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +67 -14
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js +34 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js +28 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.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/ProjectAuthorization.js +6 -1
- package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
- package/build/dist/Server/Services/DashboardDomainService.js +104 -0
- package/build/dist/Server/Services/DashboardDomainService.js.map +1 -1
- package/build/dist/Server/Services/LogPipelineProcessorService.js +65 -0
- package/build/dist/Server/Services/LogPipelineProcessorService.js.map +1 -1
- package/build/dist/Server/Services/MonitorTemplateService.js +57 -11
- package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageDomainService.js +132 -3
- package/build/dist/Server/Services/StatusPageDomainService.js.map +1 -1
- package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js +112 -32
- package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js.map +1 -1
- package/build/dist/Server/Types/Domain.js +16 -0
- package/build/dist/Server/Types/Domain.js.map +1 -1
- package/build/dist/Server/Utils/APIKey/AccessPermission.js +24 -0
- package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
- package/build/dist/Server/Utils/Greenlock/Greenlock.js +110 -35
- package/build/dist/Server/Utils/Greenlock/Greenlock.js.map +1 -1
- package/build/dist/Server/Utils/LogPipelineProcessorValidation.js +77 -0
- package/build/dist/Server/Utils/LogPipelineProcessorValidation.js.map +1 -0
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js +777 -36
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js.map +1 -1
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js +108 -17
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js +59 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js.map +1 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js +9 -2
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +17 -6
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +0 -159
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js +128 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +17 -6
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +25 -13
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/API/Route.js +16 -5
- package/build/dist/Types/API/Route.js.map +1 -1
- package/build/dist/Types/Date.js +115 -13
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplay.js +38 -0
- package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +9 -5
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +12 -7
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js +15 -14
- package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +17 -22
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +2 -4
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +2 -4
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js +15 -14
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js.map +1 -1
- package/build/dist/UI/Utils/Navigation.js +30 -0
- package/build/dist/UI/Utils/Navigation.js.map +1 -1
- package/build/dist/Utils/Array.js +25 -0
- package/build/dist/Utils/Array.js.map +1 -1
- package/build/dist/Utils/CertificateReissue.js +97 -0
- package/build/dist/Utils/CertificateReissue.js.map +1 -0
- package/build/dist/Utils/Grok/Grok.js +324 -0
- package/build/dist/Utils/Grok/Grok.js.map +1 -0
- package/build/dist/Utils/Grok/GrokPatterns.js +112 -0
- package/build/dist/Utils/Grok/GrokPatterns.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +63 -3
- package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
- package/package.json +1 -1
- package/UI/Utils/JsonWebToken.ts +0 -14
- package/build/dist/UI/Utils/JsonWebToken.js +0 -10
- package/build/dist/UI/Utils/JsonWebToken.js.map +0 -1
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import StatusPagePrivateUserSession from "../../../Models/DatabaseModels/StatusPagePrivateUserSession";
|
|
2
|
+
import { EncryptionSecret } from "../../../Server/EnvironmentConfig";
|
|
3
|
+
import FindOneBy from "../../../Server/Types/Database/FindOneBy";
|
|
4
|
+
import {
|
|
5
|
+
CreateSessionOptions,
|
|
6
|
+
Service,
|
|
7
|
+
SessionMetadata,
|
|
8
|
+
STATUS_PAGE_LOGIN_CODE_TTL_MINUTES,
|
|
9
|
+
} from "../../../Server/Services/StatusPagePrivateUserSessionService";
|
|
10
|
+
import HashedString from "../../../Types/HashedString";
|
|
11
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
12
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
13
|
+
import { getJestSpyOn } from "../../Spy";
|
|
14
|
+
import {
|
|
15
|
+
afterEach,
|
|
16
|
+
beforeEach,
|
|
17
|
+
describe,
|
|
18
|
+
expect,
|
|
19
|
+
jest,
|
|
20
|
+
test,
|
|
21
|
+
} from "@jest/globals";
|
|
22
|
+
import { FindOperator, Repository, UpdateResult } from "typeorm";
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* The SAML/OIDC callback now hands the status-page origin a short-lived,
|
|
26
|
+
* single-use login code instead of putting an access token in browser-readable
|
|
27
|
+
* state. These tests pin the security boundary at the service that creates and
|
|
28
|
+
* consumes that code:
|
|
29
|
+
*
|
|
30
|
+
* - it is recognisably a login code and expires in five minutes;
|
|
31
|
+
* - the page and purpose are both checked before redemption;
|
|
32
|
+
* - redemption is one atomic compare-and-swap on the old code hash;
|
|
33
|
+
* - the replacement credential is already hashed when it reaches TypeORM;
|
|
34
|
+
* - only the replacement plaintext can be looked up after redemption.
|
|
35
|
+
*
|
|
36
|
+
* The final group uses a small in-memory repository boundary. It models the
|
|
37
|
+
* predicates PostgreSQL evaluates in the UPDATE, which lets two real
|
|
38
|
+
* exchangeLoginCode calls race without requiring a database for this unit
|
|
39
|
+
* suite. If the old hash, expiry, purpose, or affected-row check is removed,
|
|
40
|
+
* those tests fail for the same reason the production exchange becomes
|
|
41
|
+
* replayable.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const PURPOSE_KEY: string = "oneuptimeStatusPageSessionPurpose";
|
|
45
|
+
const PURPOSE_VALUE: string = "login-code";
|
|
46
|
+
const FROZEN_NOW: Date = new Date("2026-08-31T10:00:00.000Z");
|
|
47
|
+
const ONE_MINUTE_IN_MILLISECONDS: number = 60 * 1000;
|
|
48
|
+
const ONE_DAY_IN_MILLISECONDS: number = 24 * 60 * ONE_MINUTE_IN_MILLISECONDS;
|
|
49
|
+
|
|
50
|
+
type SessionIds = {
|
|
51
|
+
projectId: ObjectID;
|
|
52
|
+
statusPageId: ObjectID;
|
|
53
|
+
privateUserId: ObjectID;
|
|
54
|
+
sessionId: ObjectID;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type LoginSessionOptions = {
|
|
58
|
+
ids: SessionIds;
|
|
59
|
+
loginCode: string;
|
|
60
|
+
purpose?: string | undefined;
|
|
61
|
+
statusPageId?: ObjectID | undefined;
|
|
62
|
+
expiresAt?: Date | undefined;
|
|
63
|
+
additionalInfo?: JSONObject | undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type CasWhere = {
|
|
67
|
+
_id: string;
|
|
68
|
+
statusPageId: ObjectID;
|
|
69
|
+
refreshToken: HashedString;
|
|
70
|
+
refreshTokenExpiresAt: FindOperator<Date>;
|
|
71
|
+
isRevoked: boolean;
|
|
72
|
+
additionalInfo: FindOperator<JSONObject>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type CasCall = {
|
|
76
|
+
where: CasWhere;
|
|
77
|
+
data: Partial<StatusPagePrivateUserSession>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type InMemoryBoundary = {
|
|
81
|
+
casCalls: Array<CasCall>;
|
|
82
|
+
replacementWasPreHashed: Array<boolean>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const buildIds: () => SessionIds = (): SessionIds => {
|
|
86
|
+
return {
|
|
87
|
+
projectId: ObjectID.generate(),
|
|
88
|
+
statusPageId: ObjectID.generate(),
|
|
89
|
+
privateUserId: ObjectID.generate(),
|
|
90
|
+
sessionId: ObjectID.generate(),
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const cloneSession: (
|
|
95
|
+
source: StatusPagePrivateUserSession,
|
|
96
|
+
) => StatusPagePrivateUserSession = (
|
|
97
|
+
source: StatusPagePrivateUserSession,
|
|
98
|
+
): StatusPagePrivateUserSession => {
|
|
99
|
+
const clone: StatusPagePrivateUserSession =
|
|
100
|
+
new StatusPagePrivateUserSession();
|
|
101
|
+
|
|
102
|
+
Object.assign(clone, source);
|
|
103
|
+
|
|
104
|
+
if (source.additionalInfo) {
|
|
105
|
+
clone.additionalInfo = { ...source.additionalInfo } as JSONObject;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return clone;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const buildLoginSession: (
|
|
112
|
+
options: LoginSessionOptions,
|
|
113
|
+
) => Promise<StatusPagePrivateUserSession> = async (
|
|
114
|
+
options: LoginSessionOptions,
|
|
115
|
+
): Promise<StatusPagePrivateUserSession> => {
|
|
116
|
+
const session: StatusPagePrivateUserSession =
|
|
117
|
+
new StatusPagePrivateUserSession();
|
|
118
|
+
|
|
119
|
+
session.id = options.ids.sessionId;
|
|
120
|
+
session.projectId = options.ids.projectId;
|
|
121
|
+
session.statusPageId = options.statusPageId || options.ids.statusPageId;
|
|
122
|
+
session.statusPagePrivateUserId = options.ids.privateUserId;
|
|
123
|
+
session.refreshToken = new HashedString(
|
|
124
|
+
await HashedString.hashValue(options.loginCode, EncryptionSecret),
|
|
125
|
+
true,
|
|
126
|
+
);
|
|
127
|
+
session.refreshTokenExpiresAt =
|
|
128
|
+
options.expiresAt ||
|
|
129
|
+
new Date(
|
|
130
|
+
FROZEN_NOW.getTime() +
|
|
131
|
+
STATUS_PAGE_LOGIN_CODE_TTL_MINUTES * ONE_MINUTE_IN_MILLISECONDS,
|
|
132
|
+
);
|
|
133
|
+
session.lastActiveAt = FROZEN_NOW;
|
|
134
|
+
session.isRevoked = false;
|
|
135
|
+
session.additionalInfo = {
|
|
136
|
+
...(options.additionalInfo || {}),
|
|
137
|
+
...(options.purpose === undefined
|
|
138
|
+
? { [PURPOSE_KEY]: PURPOSE_VALUE }
|
|
139
|
+
: options.purpose
|
|
140
|
+
? { [PURPOSE_KEY]: options.purpose }
|
|
141
|
+
: {}),
|
|
142
|
+
} as JSONObject;
|
|
143
|
+
|
|
144
|
+
return session;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const asCasWhere: (value: unknown) => CasWhere = (value: unknown): CasWhere => {
|
|
148
|
+
return value as CasWhere;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const asPartialSession: (
|
|
152
|
+
value: unknown,
|
|
153
|
+
) => Partial<StatusPagePrivateUserSession> = (
|
|
154
|
+
value: unknown,
|
|
155
|
+
): Partial<StatusPagePrivateUserSession> => {
|
|
156
|
+
return value as Partial<StatusPagePrivateUserSession>;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const makeUpdateResult: (affected: number) => UpdateResult = (
|
|
160
|
+
affected: number,
|
|
161
|
+
): UpdateResult => {
|
|
162
|
+
return {
|
|
163
|
+
affected,
|
|
164
|
+
generatedMaps: [],
|
|
165
|
+
raw: [],
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const installInMemoryBoundary: (
|
|
170
|
+
service: Service,
|
|
171
|
+
row: StatusPagePrivateUserSession,
|
|
172
|
+
synchronizeFirstTwoReads?: boolean,
|
|
173
|
+
) => InMemoryBoundary = (
|
|
174
|
+
service: Service,
|
|
175
|
+
row: StatusPagePrivateUserSession,
|
|
176
|
+
synchronizeFirstTwoReads: boolean = false,
|
|
177
|
+
): InMemoryBoundary => {
|
|
178
|
+
const casCalls: Array<CasCall> = [];
|
|
179
|
+
const replacementWasPreHashed: Array<boolean> = [];
|
|
180
|
+
let matchingReadCount: number = 0;
|
|
181
|
+
let releaseReads: (() => void) | undefined;
|
|
182
|
+
const readsReleased: Promise<void> = new Promise<void>(
|
|
183
|
+
(resolve: () => void): void => {
|
|
184
|
+
releaseReads = resolve;
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
getJestSpyOn(service, "findOneBy").mockImplementation(
|
|
189
|
+
async (
|
|
190
|
+
request: FindOneBy<StatusPagePrivateUserSession>,
|
|
191
|
+
): Promise<StatusPagePrivateUserSession | null> => {
|
|
192
|
+
const requestedHash: HashedString = request.query[
|
|
193
|
+
"refreshToken"
|
|
194
|
+
] as HashedString;
|
|
195
|
+
|
|
196
|
+
if (
|
|
197
|
+
!row.refreshToken ||
|
|
198
|
+
row.refreshToken.toString() !== requestedHash.toString() ||
|
|
199
|
+
row.isRevoked !== false
|
|
200
|
+
) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A database read returns a snapshot, not the mutable stored-row object.
|
|
205
|
+
const snapshot: StatusPagePrivateUserSession = cloneSession(row);
|
|
206
|
+
|
|
207
|
+
if (synchronizeFirstTwoReads && matchingReadCount < 2) {
|
|
208
|
+
matchingReadCount++;
|
|
209
|
+
|
|
210
|
+
if (matchingReadCount === 2) {
|
|
211
|
+
releaseReads!();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await readsReleased;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return snapshot;
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const update: (
|
|
222
|
+
whereValue: unknown,
|
|
223
|
+
dataValue: unknown,
|
|
224
|
+
) => Promise<UpdateResult> = async (
|
|
225
|
+
whereValue: unknown,
|
|
226
|
+
dataValue: unknown,
|
|
227
|
+
): Promise<UpdateResult> => {
|
|
228
|
+
const where: CasWhere = asCasWhere(whereValue);
|
|
229
|
+
const data: Partial<StatusPagePrivateUserSession> =
|
|
230
|
+
asPartialSession(dataValue);
|
|
231
|
+
|
|
232
|
+
casCalls.push({ where, data });
|
|
233
|
+
replacementWasPreHashed.push(Boolean(data.refreshToken?.isValueHashed()));
|
|
234
|
+
|
|
235
|
+
const requiredInfo: JSONObject = where.additionalInfo.value;
|
|
236
|
+
const containsRequiredInfo: boolean = Object.entries(requiredInfo).every(
|
|
237
|
+
([key, value]: [string, unknown]): boolean => {
|
|
238
|
+
return row.additionalInfo?.[key] === value;
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const matches: boolean = Boolean(
|
|
243
|
+
row._id === where._id &&
|
|
244
|
+
row.statusPageId?.toString() === where.statusPageId.toString() &&
|
|
245
|
+
row.refreshToken?.toString() === where.refreshToken.toString() &&
|
|
246
|
+
row.refreshTokenExpiresAt &&
|
|
247
|
+
row.refreshTokenExpiresAt > where.refreshTokenExpiresAt.value &&
|
|
248
|
+
row.isRevoked === where.isRevoked &&
|
|
249
|
+
containsRequiredInfo,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (!matches) {
|
|
253
|
+
return makeUpdateResult(0);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
Object.assign(row, data);
|
|
257
|
+
return makeUpdateResult(1);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
getJestSpyOn(service, "getRepository").mockReturnValue({
|
|
261
|
+
update,
|
|
262
|
+
} as unknown as Repository<StatusPagePrivateUserSession>);
|
|
263
|
+
|
|
264
|
+
return { casCalls, replacementWasPreHashed };
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
describe("StatusPagePrivateUserSessionService login-code exchange", () => {
|
|
268
|
+
beforeEach(() => {
|
|
269
|
+
jest.restoreAllMocks();
|
|
270
|
+
jest.useFakeTimers();
|
|
271
|
+
jest.setSystemTime(FROZEN_NOW);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
afterEach(() => {
|
|
275
|
+
jest.useRealTimers();
|
|
276
|
+
jest.restoreAllMocks();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("creates a purpose-marked login code that expires after five minutes", async () => {
|
|
280
|
+
const service: Service = new Service();
|
|
281
|
+
const ids: SessionIds = buildIds();
|
|
282
|
+
const loginCode: string = ObjectID.generate().toString();
|
|
283
|
+
const created: StatusPagePrivateUserSession =
|
|
284
|
+
new StatusPagePrivateUserSession();
|
|
285
|
+
|
|
286
|
+
const createSessionSpy: jest.SpyInstance = getJestSpyOn(
|
|
287
|
+
service,
|
|
288
|
+
"createSession",
|
|
289
|
+
).mockImplementation(
|
|
290
|
+
async (options: CreateSessionOptions): Promise<SessionMetadata> => {
|
|
291
|
+
created.id = ids.sessionId;
|
|
292
|
+
created.additionalInfo = options.additionalInfo || {};
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
session: created,
|
|
296
|
+
refreshToken: loginCode,
|
|
297
|
+
refreshTokenExpiresAt: options.refreshTokenExpiresAt!,
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const result: SessionMetadata = await service.createLoginCodeSession({
|
|
303
|
+
projectId: ids.projectId,
|
|
304
|
+
statusPageId: ids.statusPageId,
|
|
305
|
+
statusPagePrivateUserId: ids.privateUserId,
|
|
306
|
+
additionalInfo: { identityProvider: "saml" },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(STATUS_PAGE_LOGIN_CODE_TTL_MINUTES).toBe(5);
|
|
310
|
+
expect(createSessionSpy).toHaveBeenCalledTimes(1);
|
|
311
|
+
|
|
312
|
+
const options: CreateSessionOptions = createSessionSpy.mock.calls[0]![0];
|
|
313
|
+
expect(options.refreshToken).toBeUndefined();
|
|
314
|
+
expect(options.refreshTokenExpiresAt).toEqual(
|
|
315
|
+
new Date(FROZEN_NOW.getTime() + 5 * ONE_MINUTE_IN_MILLISECONDS),
|
|
316
|
+
);
|
|
317
|
+
expect(options.additionalInfo).toEqual({
|
|
318
|
+
identityProvider: "saml",
|
|
319
|
+
[PURPOSE_KEY]: PURPOSE_VALUE,
|
|
320
|
+
});
|
|
321
|
+
expect(service.isLoginCodeSession(result.session)).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("rejects a malformed code before attempting a session lookup", async () => {
|
|
325
|
+
const service: Service = new Service();
|
|
326
|
+
const findActiveSessionSpy: jest.SpyInstance = getJestSpyOn(
|
|
327
|
+
service,
|
|
328
|
+
"findActiveSessionByRefreshToken",
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
await expect(
|
|
332
|
+
service.exchangeLoginCode("not-a-uuid", {
|
|
333
|
+
statusPageId: ObjectID.generate(),
|
|
334
|
+
}),
|
|
335
|
+
).resolves.toBeNull();
|
|
336
|
+
expect(findActiveSessionSpy).not.toHaveBeenCalled();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("rejects an ordinary refresh session with no login-code purpose", async () => {
|
|
340
|
+
const service: Service = new Service();
|
|
341
|
+
const ids: SessionIds = buildIds();
|
|
342
|
+
const loginCode: string = ObjectID.generate().toString();
|
|
343
|
+
const session: StatusPagePrivateUserSession = await buildLoginSession({
|
|
344
|
+
ids,
|
|
345
|
+
loginCode,
|
|
346
|
+
purpose: "",
|
|
347
|
+
});
|
|
348
|
+
const update: CallableFunction = jest.fn();
|
|
349
|
+
|
|
350
|
+
getJestSpyOn(service, "findActiveSessionByRefreshToken").mockResolvedValue(
|
|
351
|
+
session,
|
|
352
|
+
);
|
|
353
|
+
getJestSpyOn(service, "getRepository").mockReturnValue({
|
|
354
|
+
update,
|
|
355
|
+
} as unknown as Repository<StatusPagePrivateUserSession>);
|
|
356
|
+
|
|
357
|
+
await expect(
|
|
358
|
+
service.exchangeLoginCode(loginCode, {
|
|
359
|
+
statusPageId: ids.statusPageId,
|
|
360
|
+
}),
|
|
361
|
+
).resolves.toBeNull();
|
|
362
|
+
expect(update).not.toHaveBeenCalled();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("rejects a valid login code presented for another status page", async () => {
|
|
366
|
+
const service: Service = new Service();
|
|
367
|
+
const ids: SessionIds = buildIds();
|
|
368
|
+
const loginCode: string = ObjectID.generate().toString();
|
|
369
|
+
const session: StatusPagePrivateUserSession = await buildLoginSession({
|
|
370
|
+
ids,
|
|
371
|
+
loginCode,
|
|
372
|
+
});
|
|
373
|
+
const update: CallableFunction = jest.fn();
|
|
374
|
+
|
|
375
|
+
getJestSpyOn(service, "findActiveSessionByRefreshToken").mockResolvedValue(
|
|
376
|
+
session,
|
|
377
|
+
);
|
|
378
|
+
getJestSpyOn(service, "getRepository").mockReturnValue({
|
|
379
|
+
update,
|
|
380
|
+
} as unknown as Repository<StatusPagePrivateUserSession>);
|
|
381
|
+
|
|
382
|
+
await expect(
|
|
383
|
+
service.exchangeLoginCode(loginCode, {
|
|
384
|
+
statusPageId: ObjectID.generate(),
|
|
385
|
+
}),
|
|
386
|
+
).resolves.toBeNull();
|
|
387
|
+
expect(update).not.toHaveBeenCalled();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("binds the atomic exchange to the old hash, expiry, purpose, and revocation state", async () => {
|
|
391
|
+
const service: Service = new Service();
|
|
392
|
+
const ids: SessionIds = buildIds();
|
|
393
|
+
const loginCode: string = ObjectID.generate().toString();
|
|
394
|
+
const session: StatusPagePrivateUserSession = await buildLoginSession({
|
|
395
|
+
ids,
|
|
396
|
+
loginCode,
|
|
397
|
+
additionalInfo: { identityProvider: "oidc", retained: true },
|
|
398
|
+
});
|
|
399
|
+
let call: CasCall | undefined;
|
|
400
|
+
|
|
401
|
+
const update: (
|
|
402
|
+
whereValue: unknown,
|
|
403
|
+
dataValue: unknown,
|
|
404
|
+
) => Promise<UpdateResult> = async (
|
|
405
|
+
whereValue: unknown,
|
|
406
|
+
dataValue: unknown,
|
|
407
|
+
): Promise<UpdateResult> => {
|
|
408
|
+
call = {
|
|
409
|
+
where: asCasWhere(whereValue),
|
|
410
|
+
data: asPartialSession(dataValue),
|
|
411
|
+
};
|
|
412
|
+
return makeUpdateResult(1);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
getJestSpyOn(service, "findActiveSessionByRefreshToken").mockResolvedValue(
|
|
416
|
+
session,
|
|
417
|
+
);
|
|
418
|
+
getJestSpyOn(service, "getRepository").mockReturnValue({
|
|
419
|
+
update,
|
|
420
|
+
} as unknown as Repository<StatusPagePrivateUserSession>);
|
|
421
|
+
|
|
422
|
+
const result: SessionMetadata | null = await service.exchangeLoginCode(
|
|
423
|
+
loginCode,
|
|
424
|
+
{
|
|
425
|
+
statusPageId: ids.statusPageId,
|
|
426
|
+
ipAddress: "192.0.2.10",
|
|
427
|
+
additionalInfo: { exchangedBy: "status-page-origin" },
|
|
428
|
+
},
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
expect(result).not.toBeNull();
|
|
432
|
+
expect(call).toBeDefined();
|
|
433
|
+
|
|
434
|
+
const where: CasWhere = call!.where;
|
|
435
|
+
const data: Partial<StatusPagePrivateUserSession> = call!.data;
|
|
436
|
+
const oldHash: string = await HashedString.hashValue(
|
|
437
|
+
loginCode,
|
|
438
|
+
EncryptionSecret,
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
expect(where._id).toBe(ids.sessionId.toString());
|
|
442
|
+
expect(where.statusPageId.toString()).toBe(ids.statusPageId.toString());
|
|
443
|
+
expect(where.refreshToken.toString()).toBe(oldHash);
|
|
444
|
+
expect(where.refreshToken.isValueHashed()).toBe(true);
|
|
445
|
+
expect(where.refreshTokenExpiresAt.type).toBe("moreThan");
|
|
446
|
+
expect(where.refreshTokenExpiresAt.value).toEqual(FROZEN_NOW);
|
|
447
|
+
expect(where.isRevoked).toBe(false);
|
|
448
|
+
expect(where.additionalInfo.type).toBe("jsonContains");
|
|
449
|
+
expect(where.additionalInfo.value).toEqual({
|
|
450
|
+
[PURPOSE_KEY]: PURPOSE_VALUE,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const replacementHash: string = await HashedString.hashValue(
|
|
454
|
+
result!.refreshToken,
|
|
455
|
+
EncryptionSecret,
|
|
456
|
+
);
|
|
457
|
+
expect(data.refreshToken).toBeInstanceOf(HashedString);
|
|
458
|
+
expect(data.refreshToken?.isValueHashed()).toBe(true);
|
|
459
|
+
expect(data.refreshToken?.toString()).toBe(replacementHash);
|
|
460
|
+
expect(data.refreshToken?.toString()).not.toBe(result!.refreshToken);
|
|
461
|
+
expect(result!.session.refreshToken?.isValueHashed()).toBe(true);
|
|
462
|
+
expect(result!.session.refreshToken?.toString()).toBe(replacementHash);
|
|
463
|
+
expect(data.refreshTokenExpiresAt).toEqual(
|
|
464
|
+
new Date(FROZEN_NOW.getTime() + 30 * ONE_DAY_IN_MILLISECONDS),
|
|
465
|
+
);
|
|
466
|
+
expect(data.additionalInfo).toEqual({
|
|
467
|
+
identityProvider: "oidc",
|
|
468
|
+
retained: true,
|
|
469
|
+
exchangedBy: "status-page-origin",
|
|
470
|
+
});
|
|
471
|
+
expect(data.additionalInfo?.[PURPOSE_KEY]).toBeUndefined();
|
|
472
|
+
expect(result!.session.additionalInfo?.[PURPOSE_KEY]).toBeUndefined();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("returns null when the compare-and-swap affects no row", async () => {
|
|
476
|
+
const service: Service = new Service();
|
|
477
|
+
const ids: SessionIds = buildIds();
|
|
478
|
+
const loginCode: string = ObjectID.generate().toString();
|
|
479
|
+
const session: StatusPagePrivateUserSession = await buildLoginSession({
|
|
480
|
+
ids,
|
|
481
|
+
loginCode,
|
|
482
|
+
});
|
|
483
|
+
const update: CallableFunction = jest.fn(
|
|
484
|
+
async (): Promise<UpdateResult> => {
|
|
485
|
+
return makeUpdateResult(0);
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
getJestSpyOn(service, "findActiveSessionByRefreshToken").mockResolvedValue(
|
|
490
|
+
session,
|
|
491
|
+
);
|
|
492
|
+
getJestSpyOn(service, "getRepository").mockReturnValue({
|
|
493
|
+
update,
|
|
494
|
+
} as unknown as Repository<StatusPagePrivateUserSession>);
|
|
495
|
+
|
|
496
|
+
await expect(
|
|
497
|
+
service.exchangeLoginCode(loginCode, {
|
|
498
|
+
statusPageId: ids.statusPageId,
|
|
499
|
+
}),
|
|
500
|
+
).resolves.toBeNull();
|
|
501
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("allows exactly one of two concurrent redemptions", async () => {
|
|
505
|
+
const service: Service = new Service();
|
|
506
|
+
const ids: SessionIds = buildIds();
|
|
507
|
+
const loginCode: string = ObjectID.generate().toString();
|
|
508
|
+
const row: StatusPagePrivateUserSession = await buildLoginSession({
|
|
509
|
+
ids,
|
|
510
|
+
loginCode,
|
|
511
|
+
additionalInfo: { identityProvider: "saml" },
|
|
512
|
+
});
|
|
513
|
+
const boundary: InMemoryBoundary = installInMemoryBoundary(
|
|
514
|
+
service,
|
|
515
|
+
row,
|
|
516
|
+
true,
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const results: Array<SessionMetadata | null> = await Promise.all([
|
|
520
|
+
service.exchangeLoginCode(loginCode, {
|
|
521
|
+
statusPageId: ids.statusPageId,
|
|
522
|
+
deviceBrowser: "first contender",
|
|
523
|
+
}),
|
|
524
|
+
service.exchangeLoginCode(loginCode, {
|
|
525
|
+
statusPageId: ids.statusPageId,
|
|
526
|
+
deviceBrowser: "second contender",
|
|
527
|
+
}),
|
|
528
|
+
]);
|
|
529
|
+
|
|
530
|
+
const successful: Array<SessionMetadata> = results.filter(
|
|
531
|
+
(result: SessionMetadata | null): result is SessionMetadata => {
|
|
532
|
+
return result !== null;
|
|
533
|
+
},
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
expect(successful).toHaveLength(1);
|
|
537
|
+
expect(
|
|
538
|
+
results.filter((result: SessionMetadata | null) => {
|
|
539
|
+
return !result;
|
|
540
|
+
}),
|
|
541
|
+
).toHaveLength(1);
|
|
542
|
+
expect(boundary.casCalls).toHaveLength(2);
|
|
543
|
+
expect(boundary.replacementWasPreHashed).toEqual([true, true]);
|
|
544
|
+
expect(row.additionalInfo).toEqual({ identityProvider: "saml" });
|
|
545
|
+
expect(row.additionalInfo?.[PURPOSE_KEY]).toBeUndefined();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("resolves the rotated plaintext credential but never the consumed code", async () => {
|
|
549
|
+
const service: Service = new Service();
|
|
550
|
+
const ids: SessionIds = buildIds();
|
|
551
|
+
const loginCode: string = ObjectID.generate().toString();
|
|
552
|
+
const oldHash: string = await HashedString.hashValue(
|
|
553
|
+
loginCode,
|
|
554
|
+
EncryptionSecret,
|
|
555
|
+
);
|
|
556
|
+
const row: StatusPagePrivateUserSession = await buildLoginSession({
|
|
557
|
+
ids,
|
|
558
|
+
loginCode,
|
|
559
|
+
});
|
|
560
|
+
const boundary: InMemoryBoundary = installInMemoryBoundary(service, row);
|
|
561
|
+
|
|
562
|
+
const exchanged: SessionMetadata | null = await service.exchangeLoginCode(
|
|
563
|
+
loginCode,
|
|
564
|
+
{ statusPageId: ids.statusPageId },
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
expect(exchanged).not.toBeNull();
|
|
568
|
+
expect(boundary.casCalls).toHaveLength(1);
|
|
569
|
+
expect(boundary.replacementWasPreHashed).toEqual([true]);
|
|
570
|
+
expect(row.refreshToken?.toString()).not.toBe(oldHash);
|
|
571
|
+
|
|
572
|
+
const rotated: StatusPagePrivateUserSession | null =
|
|
573
|
+
await service.findActiveSessionByRefreshToken(exchanged!.refreshToken);
|
|
574
|
+
const consumed: StatusPagePrivateUserSession | null =
|
|
575
|
+
await service.findActiveSessionByRefreshToken(loginCode);
|
|
576
|
+
|
|
577
|
+
expect(rotated?.id?.toString()).toBe(ids.sessionId.toString());
|
|
578
|
+
expect(consumed).toBeNull();
|
|
579
|
+
|
|
580
|
+
await expect(
|
|
581
|
+
service.exchangeLoginCode(loginCode, {
|
|
582
|
+
statusPageId: ids.statusPageId,
|
|
583
|
+
}),
|
|
584
|
+
).resolves.toBeNull();
|
|
585
|
+
expect(boundary.casCalls).toHaveLength(1);
|
|
586
|
+
});
|
|
587
|
+
});
|