@oneuptime/common 12.0.14 → 12.0.15
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/GlobalOidc.ts +20 -0
- package/Models/DatabaseModels/GlobalSso.ts +20 -0
- package/Models/DatabaseModels/MarketingConversion.ts +19 -9
- package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +46 -0
- package/Models/DatabaseModels/StatusPage.ts +7 -4
- package/Server/API/StatusPageAPI.ts +29 -4
- package/Server/EnvironmentConfig.ts +7 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.ts +19 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.ts +40 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Middleware/SlackAuthorization.ts +43 -6
- package/Server/Middleware/UserAuthorization.ts +236 -26
- package/Server/Middleware/WhatsAppAuthorization.ts +43 -3
- package/Server/Services/GlobalOidcProjectService.ts +126 -1
- package/Server/Services/GlobalOidcService.ts +132 -0
- package/Server/Services/GlobalSsoProjectService.ts +126 -1
- package/Server/Services/GlobalSsoService.ts +132 -0
- package/Server/Services/UserEmailService.ts +4 -4
- package/Server/Utils/CronTab.ts +73 -0
- package/Server/Utils/GlobalSsoAuthorization.ts +175 -0
- package/Server/Utils/Marketing/ConversionUploadProvider.ts +35 -5
- package/Server/Utils/Marketing/Providers/GoogleAds.ts +1 -1
- package/Server/Utils/Marketing/Providers/LinkedIn.ts +1 -1
- package/Server/Utils/Marketing/Providers/Meta.ts +1 -1
- package/Server/Utils/Marketing/Providers/MicrosoftAds.ts +1 -1
- package/Server/Utils/Marketing/Providers/Reddit.ts +1 -1
- package/Server/Utils/Monitor/Criteria/APIRequestCriteria.ts +48 -33
- package/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.ts +35 -30
- package/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.ts +33 -28
- package/Server/Utils/Monitor/Criteria/EvaluateOverTime.ts +475 -47
- package/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.ts +35 -30
- package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +34 -28
- package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +33 -28
- package/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.ts +69 -40
- package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +35 -29
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +8 -0
- package/Server/Utils/Monitor/MonitorResource.ts +161 -21
- package/Server/Utils/StatusPageContentSecurityPolicy.ts +56 -0
- package/Server/Utils/StatusPageCustomizationAccess.ts +117 -0
- package/Tests/Server/API/StatusPageCustomizationOrigin.test.ts +348 -0
- package/Tests/Server/Infrastructure/Postgres/FixTotpOtpUrlAlgorithmMigration.test.ts +9 -59
- package/Tests/Server/Infrastructure/Postgres/SchemaMigrationsOrdering.test.ts +227 -0
- package/Tests/Server/Middleware/SlackAuthorization.test.ts +149 -0
- package/Tests/Server/Middleware/UserAuthorization.test.ts +27 -13
- package/Tests/Server/Middleware/UserAuthorizationGlobalSsoGovernance.test.ts +1455 -0
- package/Tests/Server/Middleware/UserAuthorizationSSOProvider.test.ts +91 -2
- package/Tests/Server/Middleware/WhatsAppAuthorization.test.ts +233 -0
- package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +21 -8
- package/Tests/Server/Services/GlobalSsoProviderAuthorization.test.ts +1242 -0
- package/Tests/Server/Services/MonitorResourceProbeAgreementHydration.test.ts +459 -0
- package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +10 -0
- package/Tests/Server/Types/AnalyticsDatabase/AggregateUtilBucketTimestamp.test.ts +416 -0
- package/Tests/Server/Types/Database/QueryHelperManyToManyAndEmptyValues.test.ts +551 -0
- package/Tests/Server/Types/Database/SelectUtil.test.ts +348 -0
- package/Tests/Server/Utils/CronTab.test.ts +116 -0
- package/Tests/Server/Utils/GlobalSSOToken.test.ts +581 -0
- package/Tests/Server/Utils/GlobalSsoAuthorization.test.ts +1401 -0
- package/Tests/Server/Utils/Marketing/AdUploadableConversionTypes.test.ts +254 -0
- package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +1 -1
- package/Tests/Server/Utils/Monitor/Criteria/APIRequestCriteriaEvaluateOverTime.test.ts +508 -0
- package/Tests/Server/Utils/Monitor/Criteria/APIRequestCriteriaPortTimings.test.ts +30 -9
- package/Tests/Server/Utils/Monitor/Criteria/EvaluateOverTime.test.ts +987 -0
- package/Tests/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.test.ts +916 -0
- package/Tests/Server/Utils/Monitor/Criteria/ProfileMonitorCriteria.test.ts +382 -0
- package/Tests/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.test.ts +1219 -0
- package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +86 -0
- package/Tests/Server/Utils/StatusPageContentSecurityPolicy.test.ts +136 -0
- package/Tests/Server/Utils/StatusPageCustomizationAccess.test.ts +223 -0
- package/Tests/Types/Monitor/MonitorStepConfigHelpers.test.ts +165 -0
- package/Tests/Types/NetworkDevice/NetworkDeviceLinkRuleScope.test.ts +215 -0
- package/Tests/UI/Components/Forms/FormSubmitAnalytics.test.tsx +206 -0
- package/Tests/UI/Utils/AIChatExport/ConversationMarkdown.test.ts +774 -0
- package/Tests/UI/Utils/AIChatExport/MarkdownBlocks.test.ts +791 -0
- package/Tests/UI/Utils/AIChatExport/MarkdownSafety.test.ts +544 -0
- package/Tests/Utils/Analytics.test.ts +187 -0
- package/Tests/Utils/Monitor/MonitorMetricType.test.ts +80 -0
- package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +1291 -0
- package/Tests/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.test.ts +214 -0
- package/Types/Marketing/MarketingConversion.ts +12 -0
- package/Types/Monitor/CriteriaFilter.ts +9 -0
- package/Types/NetworkDevice/NetworkDeviceLinkRuleScope.ts +51 -0
- package/UI/Components/FormModal/BasicFormModal.tsx +12 -0
- package/UI/Components/Forms/BasicForm.tsx +13 -1
- package/UI/Components/Forms/BasicModelForm.tsx +2 -1
- package/UI/Components/Forms/ModelForm.tsx +10 -1
- package/UI/Components/Forms/Utils/FormAnalyticsName.ts +39 -0
- package/UI/Components/ModelFormModal/ModelFormModal.tsx +6 -1
- package/Utils/Analytics.ts +55 -2
- package/Utils/Monitor/MonitorMetricType.ts +40 -0
- package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +587 -37
- package/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.ts +115 -0
- package/build/dist/Models/DatabaseModels/GlobalOidc.js +21 -0
- package/build/dist/Models/DatabaseModels/GlobalOidc.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalSso.js +21 -0
- package/build/dist/Models/DatabaseModels/GlobalSso.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MarketingConversion.js +20 -10
- package/build/dist/Models/DatabaseModels/MarketingConversion.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +47 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPage.js +4 -4
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +25 -4
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +6 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.js +29 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.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/SlackAuthorization.js +28 -4
- package/build/dist/Server/Middleware/SlackAuthorization.js.map +1 -1
- package/build/dist/Server/Middleware/UserAuthorization.js +197 -22
- package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
- package/build/dist/Server/Middleware/WhatsAppAuthorization.js +32 -2
- package/build/dist/Server/Middleware/WhatsAppAuthorization.js.map +1 -1
- package/build/dist/Server/Services/GlobalOidcProjectService.js +105 -0
- package/build/dist/Server/Services/GlobalOidcProjectService.js.map +1 -1
- package/build/dist/Server/Services/GlobalOidcService.js +127 -0
- package/build/dist/Server/Services/GlobalOidcService.js.map +1 -1
- package/build/dist/Server/Services/GlobalSsoProjectService.js +105 -0
- package/build/dist/Server/Services/GlobalSsoProjectService.js.map +1 -1
- package/build/dist/Server/Services/GlobalSsoService.js +127 -0
- package/build/dist/Server/Services/GlobalSsoService.js.map +1 -1
- package/build/dist/Server/Services/UserEmailService.js +4 -4
- package/build/dist/Server/Utils/CronTab.js +66 -0
- package/build/dist/Server/Utils/CronTab.js.map +1 -1
- package/build/dist/Server/Utils/GlobalSsoAuthorization.js +112 -0
- package/build/dist/Server/Utils/GlobalSsoAuthorization.js.map +1 -0
- package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js +17 -1
- package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js.map +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js.map +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js.map +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/Meta.js +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/Meta.js.map +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js.map +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/Reddit.js +1 -1
- package/build/dist/Server/Utils/Marketing/Providers/Reddit.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js +37 -35
- package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.js +21 -25
- package/build/dist/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.js +20 -22
- package/build/dist/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/EvaluateOverTime.js +370 -42
- package/build/dist/Server/Utils/Monitor/Criteria/EvaluateOverTime.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.js +21 -25
- package/build/dist/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +21 -25
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +21 -25
- package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js +64 -52
- package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +25 -29
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +8 -0
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +123 -23
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/StatusPageContentSecurityPolicy.js +39 -0
- package/build/dist/Server/Utils/StatusPageContentSecurityPolicy.js.map +1 -0
- package/build/dist/Server/Utils/StatusPageCustomizationAccess.js +85 -0
- package/build/dist/Server/Utils/StatusPageCustomizationAccess.js.map +1 -0
- package/build/dist/Types/Marketing/MarketingConversion.js +10 -0
- package/build/dist/Types/Marketing/MarketingConversion.js.map +1 -1
- package/build/dist/Types/Monitor/CriteriaFilter.js +1 -0
- package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
- package/build/dist/Types/NetworkDevice/NetworkDeviceLinkRuleScope.js +46 -0
- package/build/dist/Types/NetworkDevice/NetworkDeviceLinkRuleScope.js.map +1 -0
- package/build/dist/UI/Components/FormModal/BasicFormModal.js +2 -1
- package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
- package/build/dist/UI/Components/Forms/BasicForm.js +10 -1
- package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
- package/build/dist/UI/Components/Forms/BasicModelForm.js +2 -1
- package/build/dist/UI/Components/Forms/BasicModelForm.js.map +1 -1
- package/build/dist/UI/Components/Forms/ModelForm.js +4 -1
- package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
- package/build/dist/UI/Components/Forms/Utils/FormAnalyticsName.js +33 -0
- package/build/dist/UI/Components/Forms/Utils/FormAnalyticsName.js.map +1 -0
- package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js +2 -1
- package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
- package/build/dist/Utils/Analytics.js +50 -2
- package/build/dist/Utils/Analytics.js.map +1 -1
- package/build/dist/Utils/Monitor/MonitorMetricType.js +37 -0
- package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +318 -37
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.js +87 -0
- package/build/dist/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
import GlobalOidcProjectService from "../../../Server/Services/GlobalOidcProjectService";
|
|
2
|
+
import GlobalOidcService from "../../../Server/Services/GlobalOidcService";
|
|
3
|
+
import GlobalSsoProjectService from "../../../Server/Services/GlobalSsoProjectService";
|
|
4
|
+
import GlobalSsoService from "../../../Server/Services/GlobalSsoService";
|
|
5
|
+
import GlobalOidc from "../../../Models/DatabaseModels/GlobalOidc";
|
|
6
|
+
import GlobalOidcProject from "../../../Models/DatabaseModels/GlobalOidcProject";
|
|
7
|
+
import GlobalSso from "../../../Models/DatabaseModels/GlobalSso";
|
|
8
|
+
import GlobalSsoProject from "../../../Models/DatabaseModels/GlobalSsoProject";
|
|
9
|
+
import {
|
|
10
|
+
GLOBAL_SSO_AUTHORIZATION_CACHE_TTL_MS,
|
|
11
|
+
GlobalProviderTrust,
|
|
12
|
+
clearGlobalSsoAuthorizationCaches,
|
|
13
|
+
} from "../../../Server/Utils/GlobalSsoAuthorization";
|
|
14
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* The STATEFUL half of Global SSO / Global OIDC enforcement.
|
|
19
|
+
*
|
|
20
|
+
* A Global SSO/OIDC token is a self-contained 30-day JWT with no project
|
|
21
|
+
* binding. Its signature, expiry, type, user and pinned-provider checks are
|
|
22
|
+
* pinned elsewhere (Common/Tests/Server/Utils/GlobalSSOToken.test.ts and
|
|
23
|
+
* Common/Tests/Server/Middleware/UserAuthorizationSSOProvider.test.ts) and
|
|
24
|
+
* together they can only ever answer "this person authenticated against SOME
|
|
25
|
+
* instance-wide provider within the last month". This file pins the two
|
|
26
|
+
* questions the token itself cannot answer, and which therefore have to be
|
|
27
|
+
* asked of Postgres on every request:
|
|
28
|
+
*
|
|
29
|
+
* 1. IS THE PROVIDER STILL TRUSTED? getProviderTrust().isUsable - the admin
|
|
30
|
+
* "Enabled" toggle, and a delete, must cut access off now rather than in
|
|
31
|
+
* thirty days.
|
|
32
|
+
* 2. DOES THE PROVIDER GOVERN THIS PROJECT? doesProviderGovernProject() -
|
|
33
|
+
* consulted only for a provider whose admin opted into
|
|
34
|
+
* `restrictToAttachedProjects`, because attachments are the PROVISIONING
|
|
35
|
+
* allow-list by default and reading them as an access boundary for
|
|
36
|
+
* everyone would lock existing users out on upgrade.
|
|
37
|
+
*
|
|
38
|
+
* Because both run per-request they are backed by 60s in-process caches, so
|
|
39
|
+
* the caching is part of the contract and not an implementation detail:
|
|
40
|
+
*
|
|
41
|
+
* - a cached `false` that is dropped (the classic truthiness-cache bug) is a
|
|
42
|
+
* query per request for exactly the providers an attacker is hammering;
|
|
43
|
+
* - a cache that is not invalidated on write turns an admin's "Disable"
|
|
44
|
+
* click into a no-op for a minute;
|
|
45
|
+
* - and the invalidation has to run in the SUCCESS hook, not only the
|
|
46
|
+
* before-hook: a clear that lands before the row is written can be
|
|
47
|
+
* re-filled with the PRE-change answer by a concurrent request on the same
|
|
48
|
+
* node, handing the disabled provider another full TTL of life.
|
|
49
|
+
*
|
|
50
|
+
* Nothing here touches a database. findOneBy/findBy are spied on each service
|
|
51
|
+
* so the test both controls the rows AND counts the round trips.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately NOT covered here (they belong to the middleware suites):
|
|
54
|
+
* isGlobalSsoTokenAuthorizedForProject, isSsoSatisfiedForProject, and the
|
|
55
|
+
* token-shaped checks in UserAuthorization.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const PROVIDER_ID: ObjectID = new ObjectID(
|
|
59
|
+
"11111111-1111-4111-8111-111111111111",
|
|
60
|
+
);
|
|
61
|
+
const OTHER_PROVIDER_ID: ObjectID = new ObjectID(
|
|
62
|
+
"22222222-2222-4222-8222-222222222222",
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
* One id used against BOTH the SSO service and the OIDC service. The two live
|
|
67
|
+
* in different tables and could genuinely collide, so the cache keys are
|
|
68
|
+
* namespaced; these tests are what stops that namespacing from regressing.
|
|
69
|
+
*/
|
|
70
|
+
const SHARED_PROVIDER_ID: ObjectID = new ObjectID(
|
|
71
|
+
"33333333-3333-4333-8333-333333333333",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const PROJECT_A: ObjectID = new ObjectID(
|
|
75
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
76
|
+
);
|
|
77
|
+
const PROJECT_B: ObjectID = new ObjectID(
|
|
78
|
+
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
79
|
+
);
|
|
80
|
+
const PROJECT_C: ObjectID = new ObjectID(
|
|
81
|
+
"cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// The shape the services pass to findOneBy / findBy.
|
|
85
|
+
interface DatabaseCallArgs {
|
|
86
|
+
query: Record<string, unknown>;
|
|
87
|
+
select: Record<string, unknown>;
|
|
88
|
+
props: Record<string, unknown>;
|
|
89
|
+
limit?: number | undefined;
|
|
90
|
+
skip?: number | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function spyOnQuery(service: any, methodName: string): jest.SpyInstance {
|
|
94
|
+
return jest.spyOn(service, methodName) as unknown as jest.SpyInstance;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function callArgs(spy: jest.SpyInstance, callIndex: number): DatabaseCallArgs {
|
|
98
|
+
const calls: Array<Array<unknown>> = spy.mock.calls as Array<Array<unknown>>;
|
|
99
|
+
const call: Array<unknown> | undefined = calls[callIndex];
|
|
100
|
+
|
|
101
|
+
expect(call).toBeDefined();
|
|
102
|
+
|
|
103
|
+
return (call as Array<unknown>)[0] as DatabaseCallArgs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Calls a protected hook without widening the service's public surface.
|
|
107
|
+
function callHook(
|
|
108
|
+
service: any,
|
|
109
|
+
hookName: string,
|
|
110
|
+
args: Array<unknown>,
|
|
111
|
+
): Promise<unknown> {
|
|
112
|
+
const hooks: Record<string, (...a: Array<unknown>) => Promise<unknown>> =
|
|
113
|
+
service as unknown as Record<
|
|
114
|
+
string,
|
|
115
|
+
(...a: Array<unknown>) => Promise<unknown>
|
|
116
|
+
>;
|
|
117
|
+
|
|
118
|
+
const hook: ((...a: Array<unknown>) => Promise<unknown>) | undefined =
|
|
119
|
+
hooks[hookName];
|
|
120
|
+
|
|
121
|
+
if (!hook) {
|
|
122
|
+
throw new Error(`Hook ${hookName} is not defined on the service`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return hook.apply(service, args);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
* `isEnabled` / `restrictToAttachedProjects` are written through an untyped
|
|
130
|
+
* view of the row on purpose: the point of several tests below is what happens
|
|
131
|
+
* when Postgres hands back null, or the column is missing entirely (an old row
|
|
132
|
+
* read by a new binary), which the model's `boolean | undefined` type cannot
|
|
133
|
+
* express.
|
|
134
|
+
*/
|
|
135
|
+
function setRawColumns(
|
|
136
|
+
row: unknown,
|
|
137
|
+
columns: Record<string, unknown>,
|
|
138
|
+
): unknown {
|
|
139
|
+
const mutable: Record<string, unknown> = row as Record<string, unknown>;
|
|
140
|
+
|
|
141
|
+
for (const columnName of Object.keys(columns)) {
|
|
142
|
+
mutable[columnName] = columns[columnName];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return row;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface AttachmentRowSpec {
|
|
149
|
+
projectId: ObjectID | undefined;
|
|
150
|
+
isEnabled: unknown;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
beforeEach(() => {
|
|
154
|
+
/*
|
|
155
|
+
* The caches live at module scope, so without this a `true` cached by one
|
|
156
|
+
* test would answer the next test's question before its stub ever ran.
|
|
157
|
+
*/
|
|
158
|
+
clearGlobalSsoAuthorizationCaches();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
jest.restoreAllMocks();
|
|
163
|
+
clearGlobalSsoAuthorizationCaches();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* -------------------------------------------------------------------------
|
|
168
|
+
* getProviderTrust
|
|
169
|
+
* ----------------------------------------------------------------------
|
|
170
|
+
*/
|
|
171
|
+
|
|
172
|
+
interface TrustSuite {
|
|
173
|
+
name: string;
|
|
174
|
+
service: any;
|
|
175
|
+
getTrust: (providerId: ObjectID) => Promise<GlobalProviderTrust>;
|
|
176
|
+
buildRow: (isEnabled: unknown, restrict: unknown) => unknown;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const TRUST_SUITES: Array<TrustSuite> = [
|
|
180
|
+
{
|
|
181
|
+
name: "GlobalSsoService",
|
|
182
|
+
service: GlobalSsoService,
|
|
183
|
+
getTrust: (providerId: ObjectID): Promise<GlobalProviderTrust> => {
|
|
184
|
+
return GlobalSsoService.getProviderTrust(providerId);
|
|
185
|
+
},
|
|
186
|
+
buildRow: (isEnabled: unknown, restrict: unknown): unknown => {
|
|
187
|
+
const row: GlobalSso = new GlobalSso();
|
|
188
|
+
row.id = PROVIDER_ID;
|
|
189
|
+
return setRawColumns(row, {
|
|
190
|
+
isEnabled: isEnabled,
|
|
191
|
+
restrictToAttachedProjects: restrict,
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "GlobalOidcService",
|
|
197
|
+
service: GlobalOidcService,
|
|
198
|
+
getTrust: (providerId: ObjectID): Promise<GlobalProviderTrust> => {
|
|
199
|
+
return GlobalOidcService.getProviderTrust(providerId);
|
|
200
|
+
},
|
|
201
|
+
buildRow: (isEnabled: unknown, restrict: unknown): unknown => {
|
|
202
|
+
const row: GlobalOidc = new GlobalOidc();
|
|
203
|
+
row.id = PROVIDER_ID;
|
|
204
|
+
return setRawColumns(row, {
|
|
205
|
+
isEnabled: isEnabled,
|
|
206
|
+
restrictToAttachedProjects: restrict,
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
describe.each(TRUST_SUITES)(
|
|
213
|
+
"$name.getProviderTrust",
|
|
214
|
+
(suite: TrustSuite): void => {
|
|
215
|
+
function stubRow(row: unknown): jest.SpyInstance {
|
|
216
|
+
const spy: jest.SpyInstance = spyOnQuery(suite.service, "findOneBy");
|
|
217
|
+
spy.mockResolvedValue(row);
|
|
218
|
+
return spy;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
test("enabled, not restricted -> usable and instance-wide", async () => {
|
|
222
|
+
stubRow(suite.buildRow(true, false));
|
|
223
|
+
|
|
224
|
+
const trust: GlobalProviderTrust = await suite.getTrust(PROVIDER_ID);
|
|
225
|
+
|
|
226
|
+
expect(trust).toEqual({
|
|
227
|
+
isUsable: true,
|
|
228
|
+
restrictToAttachedProjects: false,
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("enabled and restricted -> usable, and the opt-in is reported", async () => {
|
|
233
|
+
stubRow(suite.buildRow(true, true));
|
|
234
|
+
|
|
235
|
+
const trust: GlobalProviderTrust = await suite.getTrust(PROVIDER_ID);
|
|
236
|
+
|
|
237
|
+
expect(trust).toEqual({
|
|
238
|
+
isUsable: true,
|
|
239
|
+
restrictToAttachedProjects: true,
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("disabled -> NOT usable, whether or not it is restricted", async () => {
|
|
244
|
+
/*
|
|
245
|
+
* "Disabled" has exactly one meaning. This is the check that makes the
|
|
246
|
+
* admin toggle revoke the 30-day tokens the provider already minted, so
|
|
247
|
+
* the restrict flag must not be able to soften it in either direction.
|
|
248
|
+
*/
|
|
249
|
+
const unrestricted: jest.SpyInstance = stubRow(
|
|
250
|
+
suite.buildRow(false, false),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(false);
|
|
254
|
+
|
|
255
|
+
unrestricted.mockRestore();
|
|
256
|
+
clearGlobalSsoAuthorizationCaches();
|
|
257
|
+
|
|
258
|
+
stubRow(suite.buildRow(false, true));
|
|
259
|
+
|
|
260
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("the SAME row is usable once isEnabled is flipped back on", async () => {
|
|
264
|
+
/*
|
|
265
|
+
* The pair for the two denials above: identical setup, one field
|
|
266
|
+
* changed. Without this an implementation that always answered `false`
|
|
267
|
+
* would satisfy the disabled cases.
|
|
268
|
+
*/
|
|
269
|
+
stubRow(suite.buildRow(true, true));
|
|
270
|
+
|
|
271
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a deleted provider (no row) -> not usable, not restricted", async () => {
|
|
275
|
+
stubRow(null);
|
|
276
|
+
|
|
277
|
+
expect(await suite.getTrust(PROVIDER_ID)).toEqual({
|
|
278
|
+
isUsable: false,
|
|
279
|
+
restrictToAttachedProjects: false,
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test.each([
|
|
284
|
+
["undefined", undefined],
|
|
285
|
+
["null", null],
|
|
286
|
+
])(
|
|
287
|
+
"isEnabled %s fails CLOSED, and the same row with true is open",
|
|
288
|
+
async (_label: string, missingValue: unknown) => {
|
|
289
|
+
stubRow(suite.buildRow(missingValue, true));
|
|
290
|
+
|
|
291
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(false);
|
|
292
|
+
|
|
293
|
+
jest.restoreAllMocks();
|
|
294
|
+
clearGlobalSsoAuthorizationCaches();
|
|
295
|
+
|
|
296
|
+
stubRow(suite.buildRow(true, true));
|
|
297
|
+
|
|
298
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(true);
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
test.each([
|
|
303
|
+
["undefined", undefined],
|
|
304
|
+
["null", null],
|
|
305
|
+
])(
|
|
306
|
+
"restrictToAttachedProjects %s fails CLOSED to the safe DEFAULT, and true opts in",
|
|
307
|
+
async (_label: string, missingValue: unknown) => {
|
|
308
|
+
stubRow(suite.buildRow(true, missingValue));
|
|
309
|
+
|
|
310
|
+
/*
|
|
311
|
+
* "Closed" for THIS flag means `false` - the default every existing
|
|
312
|
+
* installation gets, where a global login satisfies every project the
|
|
313
|
+
* user belongs to. A null read as `true` would silently start denying
|
|
314
|
+
* projects nobody attached.
|
|
315
|
+
*/
|
|
316
|
+
expect(
|
|
317
|
+
(await suite.getTrust(PROVIDER_ID)).restrictToAttachedProjects,
|
|
318
|
+
).toBe(false);
|
|
319
|
+
|
|
320
|
+
jest.restoreAllMocks();
|
|
321
|
+
clearGlobalSsoAuthorizationCaches();
|
|
322
|
+
|
|
323
|
+
stubRow(suite.buildRow(true, true));
|
|
324
|
+
|
|
325
|
+
expect(
|
|
326
|
+
(await suite.getTrust(PROVIDER_ID)).restrictToAttachedProjects,
|
|
327
|
+
).toBe(true);
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
test("the SELECT asks for restrictToAttachedProjects", async () => {
|
|
332
|
+
/*
|
|
333
|
+
* If the column is not selected, the hydrated row carries `undefined`,
|
|
334
|
+
* `Boolean(undefined)` is false, and the admin's opt-in silently never
|
|
335
|
+
* takes effect - a bug no behavioural assertion above would catch,
|
|
336
|
+
* because "not restricted" is also the legitimate default.
|
|
337
|
+
*/
|
|
338
|
+
const spy: jest.SpyInstance = stubRow(suite.buildRow(true, true));
|
|
339
|
+
|
|
340
|
+
await suite.getTrust(PROVIDER_ID);
|
|
341
|
+
|
|
342
|
+
const args: DatabaseCallArgs = callArgs(spy, 0);
|
|
343
|
+
|
|
344
|
+
expect(args.select["restrictToAttachedProjects"]).toBe(true);
|
|
345
|
+
expect(args.select["isEnabled"]).toBe(true);
|
|
346
|
+
expect(args.query["_id"]).toBe(PROVIDER_ID.toString());
|
|
347
|
+
expect(args.props["isRoot"]).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("a repeat question does NOT hit the database", async () => {
|
|
351
|
+
const spy: jest.SpyInstance = stubRow(suite.buildRow(true, false));
|
|
352
|
+
|
|
353
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(true);
|
|
354
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(true);
|
|
355
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(true);
|
|
356
|
+
|
|
357
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("a DISABLED provider is cached too - it must not re-query per request", async () => {
|
|
361
|
+
/*
|
|
362
|
+
* The truthiness-cache bug: a cached `{ isUsable: false }` that reads as
|
|
363
|
+
* a miss means every single request against a revoked provider pays a
|
|
364
|
+
* query, which is precisely the traffic an attacker with a stale token
|
|
365
|
+
* generates.
|
|
366
|
+
*/
|
|
367
|
+
const spy: jest.SpyInstance = stubRow(suite.buildRow(false, false));
|
|
368
|
+
|
|
369
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(false);
|
|
370
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(false);
|
|
371
|
+
|
|
372
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("a 'row not found' answer is cached too", async () => {
|
|
376
|
+
const spy: jest.SpyInstance = stubRow(null);
|
|
377
|
+
|
|
378
|
+
await suite.getTrust(PROVIDER_ID);
|
|
379
|
+
await suite.getTrust(PROVIDER_ID);
|
|
380
|
+
|
|
381
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("different provider ids are cached independently", async () => {
|
|
385
|
+
const spy: jest.SpyInstance = spyOnQuery(suite.service, "findOneBy");
|
|
386
|
+
|
|
387
|
+
spy.mockImplementation((async (
|
|
388
|
+
findBy: DatabaseCallArgs,
|
|
389
|
+
): Promise<unknown> => {
|
|
390
|
+
return findBy.query["_id"] === PROVIDER_ID.toString()
|
|
391
|
+
? suite.buildRow(true, false)
|
|
392
|
+
: suite.buildRow(false, false);
|
|
393
|
+
}) as never);
|
|
394
|
+
|
|
395
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(true);
|
|
396
|
+
expect((await suite.getTrust(OTHER_PROVIDER_ID)).isUsable).toBe(false);
|
|
397
|
+
|
|
398
|
+
// Two distinct ids, two queries - one answer did not stand in for the other.
|
|
399
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
400
|
+
|
|
401
|
+
// And both are now cached, each under its own key.
|
|
402
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(true);
|
|
403
|
+
expect((await suite.getTrust(OTHER_PROVIDER_ID)).isUsable).toBe(false);
|
|
404
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("concurrent misses for one provider share a single query", async () => {
|
|
408
|
+
/*
|
|
409
|
+
* The multi-tenant permission path fans out over every project the user
|
|
410
|
+
* belongs to at once. Without in-flight de-duplication a cold cache
|
|
411
|
+
* means one query per project for the same provider.
|
|
412
|
+
*/
|
|
413
|
+
const spy: jest.SpyInstance = stubRow(suite.buildRow(true, false));
|
|
414
|
+
|
|
415
|
+
const answers: Array<GlobalProviderTrust> = await Promise.all([
|
|
416
|
+
suite.getTrust(PROVIDER_ID),
|
|
417
|
+
suite.getTrust(PROVIDER_ID),
|
|
418
|
+
suite.getTrust(PROVIDER_ID),
|
|
419
|
+
suite.getTrust(PROVIDER_ID),
|
|
420
|
+
suite.getTrust(PROVIDER_ID),
|
|
421
|
+
]);
|
|
422
|
+
|
|
423
|
+
expect(
|
|
424
|
+
answers.every((trust: GlobalProviderTrust): boolean => {
|
|
425
|
+
return trust.isUsable;
|
|
426
|
+
}),
|
|
427
|
+
).toBe(true);
|
|
428
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("the cached answer expires after the TTL, so re-enabling takes effect", async () => {
|
|
432
|
+
const nowSpy: jest.SpyInstance = jest.spyOn(Date, "now");
|
|
433
|
+
let clock: number = 1_700_000_000_000;
|
|
434
|
+
nowSpy.mockImplementation((): number => {
|
|
435
|
+
return clock;
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const spy: jest.SpyInstance = spyOnQuery(suite.service, "findOneBy");
|
|
439
|
+
spy.mockResolvedValue(suite.buildRow(false, false));
|
|
440
|
+
|
|
441
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(false);
|
|
442
|
+
|
|
443
|
+
// Still inside the window: served from cache, the new row is not seen.
|
|
444
|
+
spy.mockResolvedValue(suite.buildRow(true, false));
|
|
445
|
+
clock += GLOBAL_SSO_AUTHORIZATION_CACHE_TTL_MS - 1;
|
|
446
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(false);
|
|
447
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
448
|
+
|
|
449
|
+
// Past the window: re-read, and the provider is live again.
|
|
450
|
+
clock += 2;
|
|
451
|
+
expect((await suite.getTrust(PROVIDER_ID)).isUsable).toBe(true);
|
|
452
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
453
|
+
});
|
|
454
|
+
},
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
describe("getProviderTrust - the SSO and OIDC caches are separate namespaces", () => {
|
|
458
|
+
test("one id, enabled as SSO and disabled as OIDC, keeps two answers", async () => {
|
|
459
|
+
const ssoSpy: jest.SpyInstance = spyOnQuery(GlobalSsoService, "findOneBy");
|
|
460
|
+
const ssoRow: GlobalSso = new GlobalSso();
|
|
461
|
+
ssoRow.id = SHARED_PROVIDER_ID;
|
|
462
|
+
ssoSpy.mockResolvedValue(
|
|
463
|
+
setRawColumns(ssoRow, {
|
|
464
|
+
isEnabled: true,
|
|
465
|
+
restrictToAttachedProjects: false,
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const oidcSpy: jest.SpyInstance = spyOnQuery(
|
|
470
|
+
GlobalOidcService,
|
|
471
|
+
"findOneBy",
|
|
472
|
+
);
|
|
473
|
+
const oidcRow: GlobalOidc = new GlobalOidc();
|
|
474
|
+
oidcRow.id = SHARED_PROVIDER_ID;
|
|
475
|
+
oidcSpy.mockResolvedValue(
|
|
476
|
+
setRawColumns(oidcRow, {
|
|
477
|
+
isEnabled: false,
|
|
478
|
+
restrictToAttachedProjects: true,
|
|
479
|
+
}),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const ssoTrust: GlobalProviderTrust =
|
|
483
|
+
await GlobalSsoService.getProviderTrust(SHARED_PROVIDER_ID);
|
|
484
|
+
const oidcTrust: GlobalProviderTrust =
|
|
485
|
+
await GlobalOidcService.getProviderTrust(SHARED_PROVIDER_ID);
|
|
486
|
+
|
|
487
|
+
expect(ssoTrust.isUsable).toBe(true);
|
|
488
|
+
expect(oidcTrust.isUsable).toBe(false);
|
|
489
|
+
expect(oidcTrust.restrictToAttachedProjects).toBe(true);
|
|
490
|
+
|
|
491
|
+
// Neither table vouched for the other: each answered from its own query.
|
|
492
|
+
expect(ssoSpy).toHaveBeenCalledTimes(1);
|
|
493
|
+
expect(oidcSpy).toHaveBeenCalledTimes(1);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("the reverse pairing holds too - disabled as SSO, enabled as OIDC", async () => {
|
|
497
|
+
/*
|
|
498
|
+
* The mirror of the case above. A cache that collapsed the two would
|
|
499
|
+
* happen to give the right answer for one ordering and the wrong one for
|
|
500
|
+
* the other, so both orderings are pinned.
|
|
501
|
+
*/
|
|
502
|
+
const ssoSpy: jest.SpyInstance = spyOnQuery(GlobalSsoService, "findOneBy");
|
|
503
|
+
const ssoRow: GlobalSso = new GlobalSso();
|
|
504
|
+
ssoRow.id = SHARED_PROVIDER_ID;
|
|
505
|
+
ssoSpy.mockResolvedValue(
|
|
506
|
+
setRawColumns(ssoRow, {
|
|
507
|
+
isEnabled: false,
|
|
508
|
+
restrictToAttachedProjects: false,
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
const oidcSpy: jest.SpyInstance = spyOnQuery(
|
|
513
|
+
GlobalOidcService,
|
|
514
|
+
"findOneBy",
|
|
515
|
+
);
|
|
516
|
+
const oidcRow: GlobalOidc = new GlobalOidc();
|
|
517
|
+
oidcRow.id = SHARED_PROVIDER_ID;
|
|
518
|
+
oidcSpy.mockResolvedValue(
|
|
519
|
+
setRawColumns(oidcRow, {
|
|
520
|
+
isEnabled: true,
|
|
521
|
+
restrictToAttachedProjects: false,
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
expect(
|
|
526
|
+
(await GlobalOidcService.getProviderTrust(SHARED_PROVIDER_ID)).isUsable,
|
|
527
|
+
).toBe(true);
|
|
528
|
+
expect(
|
|
529
|
+
(await GlobalSsoService.getProviderTrust(SHARED_PROVIDER_ID)).isUsable,
|
|
530
|
+
).toBe(false);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
/*
|
|
535
|
+
* -------------------------------------------------------------------------
|
|
536
|
+
* doesProviderGovernProject
|
|
537
|
+
* ----------------------------------------------------------------------
|
|
538
|
+
*/
|
|
539
|
+
|
|
540
|
+
interface GovernSuite {
|
|
541
|
+
name: string;
|
|
542
|
+
service: any;
|
|
543
|
+
foreignKey: string;
|
|
544
|
+
buildRows: (specs: Array<AttachmentRowSpec>) => Array<unknown>;
|
|
545
|
+
govern: (providerId: ObjectID, projectId: ObjectID) => Promise<boolean>;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const GOVERN_SUITES: Array<GovernSuite> = [
|
|
549
|
+
{
|
|
550
|
+
name: "GlobalSsoProjectService",
|
|
551
|
+
service: GlobalSsoProjectService,
|
|
552
|
+
foreignKey: "globalSsoId",
|
|
553
|
+
buildRows: (specs: Array<AttachmentRowSpec>): Array<unknown> => {
|
|
554
|
+
return specs.map((spec: AttachmentRowSpec): unknown => {
|
|
555
|
+
const row: GlobalSsoProject = new GlobalSsoProject();
|
|
556
|
+
row.globalSsoId = PROVIDER_ID;
|
|
557
|
+
return setRawColumns(row, {
|
|
558
|
+
projectId: spec.projectId,
|
|
559
|
+
isEnabled: spec.isEnabled,
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
},
|
|
563
|
+
govern: (providerId: ObjectID, projectId: ObjectID): Promise<boolean> => {
|
|
564
|
+
return GlobalSsoProjectService.doesProviderGovernProject({
|
|
565
|
+
globalSsoId: providerId,
|
|
566
|
+
projectId: projectId,
|
|
567
|
+
});
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "GlobalOidcProjectService",
|
|
572
|
+
service: GlobalOidcProjectService,
|
|
573
|
+
foreignKey: "globalOidcId",
|
|
574
|
+
buildRows: (specs: Array<AttachmentRowSpec>): Array<unknown> => {
|
|
575
|
+
return specs.map((spec: AttachmentRowSpec): unknown => {
|
|
576
|
+
const row: GlobalOidcProject = new GlobalOidcProject();
|
|
577
|
+
row.globalOidcId = PROVIDER_ID;
|
|
578
|
+
return setRawColumns(row, {
|
|
579
|
+
projectId: spec.projectId,
|
|
580
|
+
isEnabled: spec.isEnabled,
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
},
|
|
584
|
+
govern: (providerId: ObjectID, projectId: ObjectID): Promise<boolean> => {
|
|
585
|
+
return GlobalOidcProjectService.doesProviderGovernProject({
|
|
586
|
+
globalOidcId: providerId,
|
|
587
|
+
projectId: projectId,
|
|
588
|
+
});
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
];
|
|
592
|
+
|
|
593
|
+
describe.each(GOVERN_SUITES)(
|
|
594
|
+
"$name.doesProviderGovernProject",
|
|
595
|
+
(suite: GovernSuite): void => {
|
|
596
|
+
function stubAttachments(
|
|
597
|
+
specs: Array<AttachmentRowSpec>,
|
|
598
|
+
): jest.SpyInstance {
|
|
599
|
+
const spy: jest.SpyInstance = spyOnQuery(suite.service, "findBy");
|
|
600
|
+
spy.mockResolvedValue(suite.buildRows(specs));
|
|
601
|
+
return spy;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
test("NO attachment rows at all -> instance-wide, every project governed", async () => {
|
|
605
|
+
/*
|
|
606
|
+
* This is the shape every existing installation is in, and it is what
|
|
607
|
+
* the login routers already treat an unattached provider as. Denying
|
|
608
|
+
* here would lock people out the moment an admin ticked the opt-in.
|
|
609
|
+
*/
|
|
610
|
+
stubAttachments([]);
|
|
611
|
+
|
|
612
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(true);
|
|
613
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_C)).toBe(true);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("an attached, enabled project is governed; one outside the set is not", async () => {
|
|
617
|
+
stubAttachments([
|
|
618
|
+
{ projectId: PROJECT_A, isEnabled: true },
|
|
619
|
+
{ projectId: PROJECT_B, isEnabled: true },
|
|
620
|
+
]);
|
|
621
|
+
|
|
622
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(true);
|
|
623
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_B)).toBe(true);
|
|
624
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_C)).toBe(false);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("rows exist but ALL are disabled -> denied, NOT widened", async () => {
|
|
628
|
+
/*
|
|
629
|
+
* The widening bug. If "no ENABLED rows" collapsed into "no rows", an
|
|
630
|
+
* admin disabling the last attachment would hand the provider every
|
|
631
|
+
* project on the instance instead of none.
|
|
632
|
+
*/
|
|
633
|
+
stubAttachments([
|
|
634
|
+
{ projectId: PROJECT_A, isEnabled: false },
|
|
635
|
+
{ projectId: PROJECT_B, isEnabled: false },
|
|
636
|
+
]);
|
|
637
|
+
|
|
638
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(false);
|
|
639
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_C)).toBe(false);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("the same rows govern again as soon as one is re-enabled", async () => {
|
|
643
|
+
/*
|
|
644
|
+
* The pair for the all-disabled denial: identical rows, one flag
|
|
645
|
+
* flipped. A blanket `false` would pass the test above and fail here.
|
|
646
|
+
*/
|
|
647
|
+
stubAttachments([
|
|
648
|
+
{ projectId: PROJECT_A, isEnabled: true },
|
|
649
|
+
{ projectId: PROJECT_B, isEnabled: false },
|
|
650
|
+
]);
|
|
651
|
+
|
|
652
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(true);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("a DISABLED row for our project does not borrow another project's enabled row", async () => {
|
|
656
|
+
stubAttachments([
|
|
657
|
+
{ projectId: PROJECT_A, isEnabled: false },
|
|
658
|
+
{ projectId: PROJECT_B, isEnabled: true },
|
|
659
|
+
]);
|
|
660
|
+
|
|
661
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(false);
|
|
662
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_B)).toBe(true);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test.each([
|
|
666
|
+
["undefined", undefined],
|
|
667
|
+
["null", null],
|
|
668
|
+
])(
|
|
669
|
+
"isEnabled %s on the row fails CLOSED, and true on the same row opens it",
|
|
670
|
+
async (_label: string, missingValue: unknown) => {
|
|
671
|
+
stubAttachments([{ projectId: PROJECT_A, isEnabled: missingValue }]);
|
|
672
|
+
|
|
673
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(false);
|
|
674
|
+
|
|
675
|
+
jest.restoreAllMocks();
|
|
676
|
+
clearGlobalSsoAuthorizationCaches();
|
|
677
|
+
|
|
678
|
+
stubAttachments([{ projectId: PROJECT_A, isEnabled: true }]);
|
|
679
|
+
|
|
680
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(true);
|
|
681
|
+
},
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
test("a row with no projectId is ignored, not treated as a wildcard", async () => {
|
|
685
|
+
/*
|
|
686
|
+
* `projectId` is NOT NULL in the schema, but the enforcement path must
|
|
687
|
+
* not crash on - or be widened by - a row that arrives without one.
|
|
688
|
+
*/
|
|
689
|
+
stubAttachments([
|
|
690
|
+
{ projectId: undefined, isEnabled: true },
|
|
691
|
+
{ projectId: PROJECT_A, isEnabled: true },
|
|
692
|
+
]);
|
|
693
|
+
|
|
694
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(true);
|
|
695
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_C)).toBe(false);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("a projectId-less row still counts as 'this provider has attachments'", async () => {
|
|
699
|
+
/*
|
|
700
|
+
* On its own such a row governs nothing, but it must not fall back to
|
|
701
|
+
* the instance-wide reading either - that would be the widening bug
|
|
702
|
+
* arriving through a different door.
|
|
703
|
+
*/
|
|
704
|
+
stubAttachments([{ projectId: undefined, isEnabled: true }]);
|
|
705
|
+
|
|
706
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(false);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("the query does NOT filter on isEnabled, but the SELECT asks for it", async () => {
|
|
710
|
+
/*
|
|
711
|
+
* Filtering in SQL would make "no rows" ambiguous: a provider with two
|
|
712
|
+
* disabled attachments would come back empty and be read as
|
|
713
|
+
* instance-wide. All rows are fetched and the enabled ones picked out in
|
|
714
|
+
* memory precisely so the two cases stay distinguishable.
|
|
715
|
+
*/
|
|
716
|
+
const spy: jest.SpyInstance = stubAttachments([
|
|
717
|
+
{ projectId: PROJECT_A, isEnabled: false },
|
|
718
|
+
]);
|
|
719
|
+
|
|
720
|
+
await suite.govern(PROVIDER_ID, PROJECT_A);
|
|
721
|
+
|
|
722
|
+
const args: DatabaseCallArgs = callArgs(spy, 0);
|
|
723
|
+
|
|
724
|
+
expect(args.query).not.toHaveProperty("isEnabled");
|
|
725
|
+
expect(args.select["isEnabled"]).toBe(true);
|
|
726
|
+
expect(args.select["projectId"]).toBe(true);
|
|
727
|
+
expect(String(args.query[suite.foreignKey])).toBe(PROVIDER_ID.toString());
|
|
728
|
+
expect(args.props["isRoot"]).toBe(true);
|
|
729
|
+
expect(args.skip).toBe(0);
|
|
730
|
+
expect(typeof args.limit).toBe("number");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("ONE query serves several projects - the attachment SET is what is cached", async () => {
|
|
734
|
+
const spy: jest.SpyInstance = stubAttachments([
|
|
735
|
+
{ projectId: PROJECT_A, isEnabled: true },
|
|
736
|
+
]);
|
|
737
|
+
|
|
738
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(true);
|
|
739
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_C)).toBe(false);
|
|
740
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_B)).toBe(false);
|
|
741
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(true);
|
|
742
|
+
|
|
743
|
+
// One round trip answered both a granted and a denied question.
|
|
744
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("an EMPTY attachment set is cached too", async () => {
|
|
748
|
+
const spy: jest.SpyInstance = stubAttachments([]);
|
|
749
|
+
|
|
750
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(true);
|
|
751
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_B)).toBe(true);
|
|
752
|
+
|
|
753
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("concurrent misses for one provider share a single query", async () => {
|
|
757
|
+
const spy: jest.SpyInstance = stubAttachments([
|
|
758
|
+
{ projectId: PROJECT_A, isEnabled: true },
|
|
759
|
+
]);
|
|
760
|
+
|
|
761
|
+
const answers: Array<boolean> = await Promise.all([
|
|
762
|
+
suite.govern(PROVIDER_ID, PROJECT_A),
|
|
763
|
+
suite.govern(PROVIDER_ID, PROJECT_B),
|
|
764
|
+
suite.govern(PROVIDER_ID, PROJECT_C),
|
|
765
|
+
suite.govern(PROVIDER_ID, PROJECT_A),
|
|
766
|
+
]);
|
|
767
|
+
|
|
768
|
+
expect(answers).toEqual([true, false, false, true]);
|
|
769
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("the cached attachment set expires after the TTL", async () => {
|
|
773
|
+
const nowSpy: jest.SpyInstance = jest.spyOn(Date, "now");
|
|
774
|
+
let clock: number = 1_700_000_000_000;
|
|
775
|
+
nowSpy.mockImplementation((): number => {
|
|
776
|
+
return clock;
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
const spy: jest.SpyInstance = spyOnQuery(suite.service, "findBy");
|
|
780
|
+
spy.mockResolvedValue(
|
|
781
|
+
suite.buildRows([{ projectId: PROJECT_A, isEnabled: true }]),
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(true);
|
|
785
|
+
|
|
786
|
+
spy.mockResolvedValue(
|
|
787
|
+
suite.buildRows([{ projectId: PROJECT_A, isEnabled: false }]),
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
clock += GLOBAL_SSO_AUTHORIZATION_CACHE_TTL_MS - 1;
|
|
791
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(true);
|
|
792
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
793
|
+
|
|
794
|
+
clock += 2;
|
|
795
|
+
expect(await suite.govern(PROVIDER_ID, PROJECT_A)).toBe(false);
|
|
796
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
797
|
+
});
|
|
798
|
+
},
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
describe("doesProviderGovernProject - the SSO and OIDC caches are separate namespaces", () => {
|
|
802
|
+
test("one id governs different project sets under each provider type", async () => {
|
|
803
|
+
const ssoSpy: jest.SpyInstance = spyOnQuery(
|
|
804
|
+
GlobalSsoProjectService,
|
|
805
|
+
"findBy",
|
|
806
|
+
);
|
|
807
|
+
const ssoRow: GlobalSsoProject = new GlobalSsoProject();
|
|
808
|
+
ssoRow.globalSsoId = SHARED_PROVIDER_ID;
|
|
809
|
+
ssoSpy.mockResolvedValue([
|
|
810
|
+
setRawColumns(ssoRow, { projectId: PROJECT_A, isEnabled: true }),
|
|
811
|
+
]);
|
|
812
|
+
|
|
813
|
+
const oidcSpy: jest.SpyInstance = spyOnQuery(
|
|
814
|
+
GlobalOidcProjectService,
|
|
815
|
+
"findBy",
|
|
816
|
+
);
|
|
817
|
+
const oidcRow: GlobalOidcProject = new GlobalOidcProject();
|
|
818
|
+
oidcRow.globalOidcId = SHARED_PROVIDER_ID;
|
|
819
|
+
oidcSpy.mockResolvedValue([
|
|
820
|
+
setRawColumns(oidcRow, { projectId: PROJECT_B, isEnabled: true }),
|
|
821
|
+
]);
|
|
822
|
+
|
|
823
|
+
const ssoGovernsA: boolean =
|
|
824
|
+
await GlobalSsoProjectService.doesProviderGovernProject({
|
|
825
|
+
globalSsoId: SHARED_PROVIDER_ID,
|
|
826
|
+
projectId: PROJECT_A,
|
|
827
|
+
});
|
|
828
|
+
const ssoGovernsB: boolean =
|
|
829
|
+
await GlobalSsoProjectService.doesProviderGovernProject({
|
|
830
|
+
globalSsoId: SHARED_PROVIDER_ID,
|
|
831
|
+
projectId: PROJECT_B,
|
|
832
|
+
});
|
|
833
|
+
const oidcGovernsA: boolean =
|
|
834
|
+
await GlobalOidcProjectService.doesProviderGovernProject({
|
|
835
|
+
globalOidcId: SHARED_PROVIDER_ID,
|
|
836
|
+
projectId: PROJECT_A,
|
|
837
|
+
});
|
|
838
|
+
const oidcGovernsB: boolean =
|
|
839
|
+
await GlobalOidcProjectService.doesProviderGovernProject({
|
|
840
|
+
globalOidcId: SHARED_PROVIDER_ID,
|
|
841
|
+
projectId: PROJECT_B,
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
expect(ssoGovernsA).toBe(true);
|
|
845
|
+
expect(ssoGovernsB).toBe(false);
|
|
846
|
+
expect(oidcGovernsA).toBe(false);
|
|
847
|
+
expect(oidcGovernsB).toBe(true);
|
|
848
|
+
|
|
849
|
+
expect(ssoSpy).toHaveBeenCalledTimes(1);
|
|
850
|
+
expect(oidcSpy).toHaveBeenCalledTimes(1);
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
/*
|
|
855
|
+
* -------------------------------------------------------------------------
|
|
856
|
+
* Cache invalidation on write
|
|
857
|
+
* ----------------------------------------------------------------------
|
|
858
|
+
*/
|
|
859
|
+
|
|
860
|
+
interface CacheProbes {
|
|
861
|
+
ssoTrust: jest.SpyInstance;
|
|
862
|
+
oidcTrust: jest.SpyInstance;
|
|
863
|
+
ssoAttachments: jest.SpyInstance;
|
|
864
|
+
oidcAttachments: jest.SpyInstance;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function installProbes(): CacheProbes {
|
|
868
|
+
const ssoTrust: jest.SpyInstance = spyOnQuery(GlobalSsoService, "findOneBy");
|
|
869
|
+
const ssoRow: GlobalSso = new GlobalSso();
|
|
870
|
+
ssoRow.id = PROVIDER_ID;
|
|
871
|
+
ssoTrust.mockResolvedValue(
|
|
872
|
+
setRawColumns(ssoRow, {
|
|
873
|
+
isEnabled: true,
|
|
874
|
+
restrictToAttachedProjects: true,
|
|
875
|
+
}),
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
const oidcTrust: jest.SpyInstance = spyOnQuery(
|
|
879
|
+
GlobalOidcService,
|
|
880
|
+
"findOneBy",
|
|
881
|
+
);
|
|
882
|
+
const oidcRow: GlobalOidc = new GlobalOidc();
|
|
883
|
+
oidcRow.id = PROVIDER_ID;
|
|
884
|
+
oidcTrust.mockResolvedValue(
|
|
885
|
+
setRawColumns(oidcRow, {
|
|
886
|
+
isEnabled: true,
|
|
887
|
+
restrictToAttachedProjects: true,
|
|
888
|
+
}),
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
const ssoAttachments: jest.SpyInstance = spyOnQuery(
|
|
892
|
+
GlobalSsoProjectService,
|
|
893
|
+
"findBy",
|
|
894
|
+
);
|
|
895
|
+
const ssoAttachmentRow: GlobalSsoProject = new GlobalSsoProject();
|
|
896
|
+
ssoAttachmentRow.globalSsoId = PROVIDER_ID;
|
|
897
|
+
ssoAttachments.mockResolvedValue([
|
|
898
|
+
setRawColumns(ssoAttachmentRow, {
|
|
899
|
+
projectId: PROJECT_A,
|
|
900
|
+
isEnabled: true,
|
|
901
|
+
}),
|
|
902
|
+
]);
|
|
903
|
+
|
|
904
|
+
const oidcAttachments: jest.SpyInstance = spyOnQuery(
|
|
905
|
+
GlobalOidcProjectService,
|
|
906
|
+
"findBy",
|
|
907
|
+
);
|
|
908
|
+
const oidcAttachmentRow: GlobalOidcProject = new GlobalOidcProject();
|
|
909
|
+
oidcAttachmentRow.globalOidcId = PROVIDER_ID;
|
|
910
|
+
oidcAttachments.mockResolvedValue([
|
|
911
|
+
setRawColumns(oidcAttachmentRow, {
|
|
912
|
+
projectId: PROJECT_A,
|
|
913
|
+
isEnabled: true,
|
|
914
|
+
}),
|
|
915
|
+
]);
|
|
916
|
+
|
|
917
|
+
return {
|
|
918
|
+
ssoTrust: ssoTrust,
|
|
919
|
+
oidcTrust: oidcTrust,
|
|
920
|
+
ssoAttachments: ssoAttachments,
|
|
921
|
+
oidcAttachments: oidcAttachments,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Asks all four cached questions, so one hook call can be checked against all.
|
|
926
|
+
async function askEverything(): Promise<void> {
|
|
927
|
+
await GlobalSsoService.getProviderTrust(PROVIDER_ID);
|
|
928
|
+
await GlobalOidcService.getProviderTrust(PROVIDER_ID);
|
|
929
|
+
await GlobalSsoProjectService.doesProviderGovernProject({
|
|
930
|
+
globalSsoId: PROVIDER_ID,
|
|
931
|
+
projectId: PROJECT_A,
|
|
932
|
+
});
|
|
933
|
+
await GlobalOidcProjectService.doesProviderGovernProject({
|
|
934
|
+
globalOidcId: PROVIDER_ID,
|
|
935
|
+
projectId: PROJECT_A,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function expectQueryCounts(probes: CacheProbes, expected: number): void {
|
|
940
|
+
expect(probes.ssoTrust).toHaveBeenCalledTimes(expected);
|
|
941
|
+
expect(probes.oidcTrust).toHaveBeenCalledTimes(expected);
|
|
942
|
+
expect(probes.ssoAttachments).toHaveBeenCalledTimes(expected);
|
|
943
|
+
expect(probes.oidcAttachments).toHaveBeenCalledTimes(expected);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
interface HookCase {
|
|
947
|
+
hookName: string;
|
|
948
|
+
buildArgs: () => Array<unknown>;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
interface HookSuite {
|
|
952
|
+
name: string;
|
|
953
|
+
service: any;
|
|
954
|
+
hookCases: Array<HookCase>;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function buildProviderHookCases(buildRow: () => unknown): Array<HookCase> {
|
|
958
|
+
const createBy: () => Record<string, unknown> = (): Record<
|
|
959
|
+
string,
|
|
960
|
+
unknown
|
|
961
|
+
> => {
|
|
962
|
+
return { data: buildRow(), props: { isRoot: true } };
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
const updateBy: () => Record<string, unknown> = (): Record<
|
|
966
|
+
string,
|
|
967
|
+
unknown
|
|
968
|
+
> => {
|
|
969
|
+
return {
|
|
970
|
+
query: { _id: PROVIDER_ID.toString() },
|
|
971
|
+
data: { isEnabled: false },
|
|
972
|
+
props: { isRoot: true },
|
|
973
|
+
};
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
const deleteBy: () => Record<string, unknown> = (): Record<
|
|
977
|
+
string,
|
|
978
|
+
unknown
|
|
979
|
+
> => {
|
|
980
|
+
return { query: { _id: PROVIDER_ID.toString() }, props: { isRoot: true } };
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
return [
|
|
984
|
+
{
|
|
985
|
+
hookName: "onBeforeCreate",
|
|
986
|
+
buildArgs: (): Array<unknown> => {
|
|
987
|
+
return [createBy()];
|
|
988
|
+
},
|
|
989
|
+
},
|
|
990
|
+
{
|
|
991
|
+
hookName: "onCreateSuccess",
|
|
992
|
+
buildArgs: (): Array<unknown> => {
|
|
993
|
+
return [{ createBy: createBy(), carryForward: null }, buildRow()];
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
hookName: "onBeforeUpdate",
|
|
998
|
+
buildArgs: (): Array<unknown> => {
|
|
999
|
+
return [updateBy()];
|
|
1000
|
+
},
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
hookName: "onUpdateSuccess",
|
|
1004
|
+
buildArgs: (): Array<unknown> => {
|
|
1005
|
+
return [{ updateBy: updateBy(), carryForward: null }, [PROVIDER_ID]];
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
hookName: "onBeforeDelete",
|
|
1010
|
+
buildArgs: (): Array<unknown> => {
|
|
1011
|
+
return [deleteBy()];
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
hookName: "onDeleteSuccess",
|
|
1016
|
+
buildArgs: (): Array<unknown> => {
|
|
1017
|
+
return [{ deleteBy: deleteBy(), carryForward: null }, [PROVIDER_ID]];
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
];
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function buildAttachmentHookCases(
|
|
1024
|
+
foreignKey: string,
|
|
1025
|
+
buildRow: () => unknown,
|
|
1026
|
+
): Array<HookCase> {
|
|
1027
|
+
const createBy: () => Record<string, unknown> = (): Record<
|
|
1028
|
+
string,
|
|
1029
|
+
unknown
|
|
1030
|
+
> => {
|
|
1031
|
+
// No `teams`, so the create hook's team validation is a no-op here.
|
|
1032
|
+
return { data: buildRow(), props: { isRoot: true } };
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
const updateBy: () => Record<string, unknown> = (): Record<
|
|
1036
|
+
string,
|
|
1037
|
+
unknown
|
|
1038
|
+
> => {
|
|
1039
|
+
return {
|
|
1040
|
+
query: { [foreignKey]: PROVIDER_ID },
|
|
1041
|
+
data: { isEnabled: false },
|
|
1042
|
+
props: { isRoot: true },
|
|
1043
|
+
};
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
const deleteBy: () => Record<string, unknown> = (): Record<
|
|
1047
|
+
string,
|
|
1048
|
+
unknown
|
|
1049
|
+
> => {
|
|
1050
|
+
return { query: { [foreignKey]: PROVIDER_ID }, props: { isRoot: true } };
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
return [
|
|
1054
|
+
{
|
|
1055
|
+
hookName: "onBeforeCreate",
|
|
1056
|
+
buildArgs: (): Array<unknown> => {
|
|
1057
|
+
return [createBy()];
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
hookName: "onCreateSuccess",
|
|
1062
|
+
buildArgs: (): Array<unknown> => {
|
|
1063
|
+
return [{ createBy: createBy(), carryForward: null }, buildRow()];
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
hookName: "onBeforeUpdate",
|
|
1068
|
+
buildArgs: (): Array<unknown> => {
|
|
1069
|
+
return [updateBy()];
|
|
1070
|
+
},
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
hookName: "onUpdateSuccess",
|
|
1074
|
+
buildArgs: (): Array<unknown> => {
|
|
1075
|
+
return [{ updateBy: updateBy(), carryForward: null }, [PROVIDER_ID]];
|
|
1076
|
+
},
|
|
1077
|
+
},
|
|
1078
|
+
{
|
|
1079
|
+
hookName: "onBeforeDelete",
|
|
1080
|
+
buildArgs: (): Array<unknown> => {
|
|
1081
|
+
return [deleteBy()];
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
{
|
|
1085
|
+
hookName: "onDeleteSuccess",
|
|
1086
|
+
buildArgs: (): Array<unknown> => {
|
|
1087
|
+
return [{ deleteBy: deleteBy(), carryForward: null }, [PROVIDER_ID]];
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1090
|
+
];
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const HOOK_SUITES: Array<HookSuite> = [
|
|
1094
|
+
{
|
|
1095
|
+
name: "GlobalSsoService",
|
|
1096
|
+
service: GlobalSsoService,
|
|
1097
|
+
hookCases: buildProviderHookCases((): unknown => {
|
|
1098
|
+
const row: GlobalSso = new GlobalSso();
|
|
1099
|
+
row.id = PROVIDER_ID;
|
|
1100
|
+
return row;
|
|
1101
|
+
}),
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
name: "GlobalOidcService",
|
|
1105
|
+
service: GlobalOidcService,
|
|
1106
|
+
hookCases: buildProviderHookCases((): unknown => {
|
|
1107
|
+
const row: GlobalOidc = new GlobalOidc();
|
|
1108
|
+
row.id = PROVIDER_ID;
|
|
1109
|
+
return row;
|
|
1110
|
+
}),
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
name: "GlobalSsoProjectService",
|
|
1114
|
+
service: GlobalSsoProjectService,
|
|
1115
|
+
hookCases: buildAttachmentHookCases("globalSsoId", (): unknown => {
|
|
1116
|
+
const row: GlobalSsoProject = new GlobalSsoProject();
|
|
1117
|
+
row.globalSsoId = PROVIDER_ID;
|
|
1118
|
+
row.projectId = PROJECT_A;
|
|
1119
|
+
return row;
|
|
1120
|
+
}),
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
name: "GlobalOidcProjectService",
|
|
1124
|
+
service: GlobalOidcProjectService,
|
|
1125
|
+
hookCases: buildAttachmentHookCases("globalOidcId", (): unknown => {
|
|
1126
|
+
const row: GlobalOidcProject = new GlobalOidcProject();
|
|
1127
|
+
row.globalOidcId = PROVIDER_ID;
|
|
1128
|
+
row.projectId = PROJECT_A;
|
|
1129
|
+
return row;
|
|
1130
|
+
}),
|
|
1131
|
+
},
|
|
1132
|
+
];
|
|
1133
|
+
|
|
1134
|
+
describe("cache invalidation - the control case", () => {
|
|
1135
|
+
test("with no write at all, the cached answers keep serving", async () => {
|
|
1136
|
+
/*
|
|
1137
|
+
* The counterpart to every invalidation test below. Without it, an
|
|
1138
|
+
* implementation that simply never cached would pass the whole
|
|
1139
|
+
* invalidation suite.
|
|
1140
|
+
*/
|
|
1141
|
+
const probes: CacheProbes = installProbes();
|
|
1142
|
+
|
|
1143
|
+
await askEverything();
|
|
1144
|
+
await askEverything();
|
|
1145
|
+
await askEverything();
|
|
1146
|
+
|
|
1147
|
+
expectQueryCounts(probes, 1);
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
describe.each(HOOK_SUITES)(
|
|
1152
|
+
"$name write hooks drop the authorization caches",
|
|
1153
|
+
(suite: HookSuite): void => {
|
|
1154
|
+
test.each(suite.hookCases)(
|
|
1155
|
+
"$hookName re-arms every cached answer",
|
|
1156
|
+
async (hookCase: HookCase) => {
|
|
1157
|
+
const probes: CacheProbes = installProbes();
|
|
1158
|
+
|
|
1159
|
+
await askEverything();
|
|
1160
|
+
await askEverything();
|
|
1161
|
+
expectQueryCounts(probes, 1);
|
|
1162
|
+
|
|
1163
|
+
await callHook(suite.service, hookCase.hookName, hookCase.buildArgs());
|
|
1164
|
+
|
|
1165
|
+
await askEverything();
|
|
1166
|
+
expectQueryCounts(probes, 2);
|
|
1167
|
+
},
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1170
|
+
test("the SUCCESS hooks clear independently of the before-hooks", async () => {
|
|
1171
|
+
/*
|
|
1172
|
+
* Why both matter. The before-hook clear happens while the row still
|
|
1173
|
+
* holds its OLD value, so a concurrent request on the same node can
|
|
1174
|
+
* re-fill the cache with the pre-change answer between the clear and the
|
|
1175
|
+
* commit - handing a provider that was just disabled another full TTL of
|
|
1176
|
+
* life. The success hook is what closes that window, so it is checked on
|
|
1177
|
+
* its own here rather than only as one entry in the list above.
|
|
1178
|
+
*/
|
|
1179
|
+
const successHooks: Array<HookCase> = suite.hookCases.filter(
|
|
1180
|
+
(hookCase: HookCase): boolean => {
|
|
1181
|
+
return hookCase.hookName.endsWith("Success");
|
|
1182
|
+
},
|
|
1183
|
+
);
|
|
1184
|
+
|
|
1185
|
+
expect(successHooks.length).toBe(3);
|
|
1186
|
+
|
|
1187
|
+
for (const hookCase of successHooks) {
|
|
1188
|
+
const probes: CacheProbes = installProbes();
|
|
1189
|
+
|
|
1190
|
+
await askEverything();
|
|
1191
|
+
await askEverything();
|
|
1192
|
+
expectQueryCounts(probes, 1);
|
|
1193
|
+
|
|
1194
|
+
// Simulates the concurrent re-fill: the cache is warm at commit time.
|
|
1195
|
+
await callHook(suite.service, hookCase.hookName, hookCase.buildArgs());
|
|
1196
|
+
|
|
1197
|
+
await askEverything();
|
|
1198
|
+
expectQueryCounts(probes, 2);
|
|
1199
|
+
|
|
1200
|
+
jest.restoreAllMocks();
|
|
1201
|
+
clearGlobalSsoAuthorizationCaches();
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test("the before-hooks pass their payload straight through", async () => {
|
|
1206
|
+
installProbes();
|
|
1207
|
+
|
|
1208
|
+
const updateCase: HookCase | undefined = suite.hookCases.find(
|
|
1209
|
+
(hookCase: HookCase): boolean => {
|
|
1210
|
+
return hookCase.hookName === "onBeforeUpdate";
|
|
1211
|
+
},
|
|
1212
|
+
);
|
|
1213
|
+
const deleteCase: HookCase | undefined = suite.hookCases.find(
|
|
1214
|
+
(hookCase: HookCase): boolean => {
|
|
1215
|
+
return hookCase.hookName === "onBeforeDelete";
|
|
1216
|
+
},
|
|
1217
|
+
);
|
|
1218
|
+
|
|
1219
|
+
expect(updateCase).toBeDefined();
|
|
1220
|
+
expect(deleteCase).toBeDefined();
|
|
1221
|
+
|
|
1222
|
+
const updateArgs: Array<unknown> = (updateCase as HookCase).buildArgs();
|
|
1223
|
+
const deleteArgs: Array<unknown> = (deleteCase as HookCase).buildArgs();
|
|
1224
|
+
|
|
1225
|
+
const onUpdate: Record<string, unknown> = (await callHook(
|
|
1226
|
+
suite.service,
|
|
1227
|
+
"onBeforeUpdate",
|
|
1228
|
+
updateArgs,
|
|
1229
|
+
)) as Record<string, unknown>;
|
|
1230
|
+
const onDelete: Record<string, unknown> = (await callHook(
|
|
1231
|
+
suite.service,
|
|
1232
|
+
"onBeforeDelete",
|
|
1233
|
+
deleteArgs,
|
|
1234
|
+
)) as Record<string, unknown>;
|
|
1235
|
+
|
|
1236
|
+
expect(onUpdate["updateBy"]).toBe(updateArgs[0]);
|
|
1237
|
+
expect(onUpdate["carryForward"]).toBeNull();
|
|
1238
|
+
expect(onDelete["deleteBy"]).toBe(deleteArgs[0]);
|
|
1239
|
+
expect(onDelete["carryForward"]).toBeNull();
|
|
1240
|
+
});
|
|
1241
|
+
},
|
|
1242
|
+
);
|