@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.
Files changed (195) hide show
  1. package/Models/DatabaseModels/GlobalOidc.ts +20 -0
  2. package/Models/DatabaseModels/GlobalSso.ts +20 -0
  3. package/Models/DatabaseModels/MarketingConversion.ts +19 -9
  4. package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +46 -0
  5. package/Models/DatabaseModels/StatusPage.ts +7 -4
  6. package/Server/API/StatusPageAPI.ts +29 -4
  7. package/Server/EnvironmentConfig.ts +7 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.ts +19 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.ts +40 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  11. package/Server/Middleware/SlackAuthorization.ts +43 -6
  12. package/Server/Middleware/UserAuthorization.ts +236 -26
  13. package/Server/Middleware/WhatsAppAuthorization.ts +43 -3
  14. package/Server/Services/GlobalOidcProjectService.ts +126 -1
  15. package/Server/Services/GlobalOidcService.ts +132 -0
  16. package/Server/Services/GlobalSsoProjectService.ts +126 -1
  17. package/Server/Services/GlobalSsoService.ts +132 -0
  18. package/Server/Services/UserEmailService.ts +4 -4
  19. package/Server/Utils/CronTab.ts +73 -0
  20. package/Server/Utils/GlobalSsoAuthorization.ts +175 -0
  21. package/Server/Utils/Marketing/ConversionUploadProvider.ts +35 -5
  22. package/Server/Utils/Marketing/Providers/GoogleAds.ts +1 -1
  23. package/Server/Utils/Marketing/Providers/LinkedIn.ts +1 -1
  24. package/Server/Utils/Marketing/Providers/Meta.ts +1 -1
  25. package/Server/Utils/Marketing/Providers/MicrosoftAds.ts +1 -1
  26. package/Server/Utils/Marketing/Providers/Reddit.ts +1 -1
  27. package/Server/Utils/Monitor/Criteria/APIRequestCriteria.ts +48 -33
  28. package/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.ts +35 -30
  29. package/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.ts +33 -28
  30. package/Server/Utils/Monitor/Criteria/EvaluateOverTime.ts +475 -47
  31. package/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.ts +35 -30
  32. package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +34 -28
  33. package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +33 -28
  34. package/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.ts +69 -40
  35. package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +35 -29
  36. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +8 -0
  37. package/Server/Utils/Monitor/MonitorResource.ts +161 -21
  38. package/Server/Utils/StatusPageContentSecurityPolicy.ts +56 -0
  39. package/Server/Utils/StatusPageCustomizationAccess.ts +117 -0
  40. package/Tests/Server/API/StatusPageCustomizationOrigin.test.ts +348 -0
  41. package/Tests/Server/Infrastructure/Postgres/FixTotpOtpUrlAlgorithmMigration.test.ts +9 -59
  42. package/Tests/Server/Infrastructure/Postgres/SchemaMigrationsOrdering.test.ts +227 -0
  43. package/Tests/Server/Middleware/SlackAuthorization.test.ts +149 -0
  44. package/Tests/Server/Middleware/UserAuthorization.test.ts +27 -13
  45. package/Tests/Server/Middleware/UserAuthorizationGlobalSsoGovernance.test.ts +1455 -0
  46. package/Tests/Server/Middleware/UserAuthorizationSSOProvider.test.ts +91 -2
  47. package/Tests/Server/Middleware/WhatsAppAuthorization.test.ts +233 -0
  48. package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +21 -8
  49. package/Tests/Server/Services/GlobalSsoProviderAuthorization.test.ts +1242 -0
  50. package/Tests/Server/Services/MonitorResourceProbeAgreementHydration.test.ts +459 -0
  51. package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +10 -0
  52. package/Tests/Server/Types/AnalyticsDatabase/AggregateUtilBucketTimestamp.test.ts +416 -0
  53. package/Tests/Server/Types/Database/QueryHelperManyToManyAndEmptyValues.test.ts +551 -0
  54. package/Tests/Server/Types/Database/SelectUtil.test.ts +348 -0
  55. package/Tests/Server/Utils/CronTab.test.ts +116 -0
  56. package/Tests/Server/Utils/GlobalSSOToken.test.ts +581 -0
  57. package/Tests/Server/Utils/GlobalSsoAuthorization.test.ts +1401 -0
  58. package/Tests/Server/Utils/Marketing/AdUploadableConversionTypes.test.ts +254 -0
  59. package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +1 -1
  60. package/Tests/Server/Utils/Monitor/Criteria/APIRequestCriteriaEvaluateOverTime.test.ts +508 -0
  61. package/Tests/Server/Utils/Monitor/Criteria/APIRequestCriteriaPortTimings.test.ts +30 -9
  62. package/Tests/Server/Utils/Monitor/Criteria/EvaluateOverTime.test.ts +987 -0
  63. package/Tests/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.test.ts +916 -0
  64. package/Tests/Server/Utils/Monitor/Criteria/ProfileMonitorCriteria.test.ts +382 -0
  65. package/Tests/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.test.ts +1219 -0
  66. package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +86 -0
  67. package/Tests/Server/Utils/StatusPageContentSecurityPolicy.test.ts +136 -0
  68. package/Tests/Server/Utils/StatusPageCustomizationAccess.test.ts +223 -0
  69. package/Tests/Types/Monitor/MonitorStepConfigHelpers.test.ts +165 -0
  70. package/Tests/Types/NetworkDevice/NetworkDeviceLinkRuleScope.test.ts +215 -0
  71. package/Tests/UI/Components/Forms/FormSubmitAnalytics.test.tsx +206 -0
  72. package/Tests/UI/Utils/AIChatExport/ConversationMarkdown.test.ts +774 -0
  73. package/Tests/UI/Utils/AIChatExport/MarkdownBlocks.test.ts +791 -0
  74. package/Tests/UI/Utils/AIChatExport/MarkdownSafety.test.ts +544 -0
  75. package/Tests/Utils/Analytics.test.ts +187 -0
  76. package/Tests/Utils/Monitor/MonitorMetricType.test.ts +80 -0
  77. package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +1291 -0
  78. package/Tests/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.test.ts +214 -0
  79. package/Types/Marketing/MarketingConversion.ts +12 -0
  80. package/Types/Monitor/CriteriaFilter.ts +9 -0
  81. package/Types/NetworkDevice/NetworkDeviceLinkRuleScope.ts +51 -0
  82. package/UI/Components/FormModal/BasicFormModal.tsx +12 -0
  83. package/UI/Components/Forms/BasicForm.tsx +13 -1
  84. package/UI/Components/Forms/BasicModelForm.tsx +2 -1
  85. package/UI/Components/Forms/ModelForm.tsx +10 -1
  86. package/UI/Components/Forms/Utils/FormAnalyticsName.ts +39 -0
  87. package/UI/Components/ModelFormModal/ModelFormModal.tsx +6 -1
  88. package/Utils/Analytics.ts +55 -2
  89. package/Utils/Monitor/MonitorMetricType.ts +40 -0
  90. package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +587 -37
  91. package/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.ts +115 -0
  92. package/build/dist/Models/DatabaseModels/GlobalOidc.js +21 -0
  93. package/build/dist/Models/DatabaseModels/GlobalOidc.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/GlobalSso.js +21 -0
  95. package/build/dist/Models/DatabaseModels/GlobalSso.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/MarketingConversion.js +20 -10
  97. package/build/dist/Models/DatabaseModels/MarketingConversion.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +47 -0
  99. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/StatusPage.js +4 -4
  101. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  102. package/build/dist/Server/API/StatusPageAPI.js +25 -4
  103. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  104. package/build/dist/Server/EnvironmentConfig.js +6 -0
  105. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  106. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.js +12 -0
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.js.map +1 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.js +29 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.js.map +1 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  112. package/build/dist/Server/Middleware/SlackAuthorization.js +28 -4
  113. package/build/dist/Server/Middleware/SlackAuthorization.js.map +1 -1
  114. package/build/dist/Server/Middleware/UserAuthorization.js +197 -22
  115. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  116. package/build/dist/Server/Middleware/WhatsAppAuthorization.js +32 -2
  117. package/build/dist/Server/Middleware/WhatsAppAuthorization.js.map +1 -1
  118. package/build/dist/Server/Services/GlobalOidcProjectService.js +105 -0
  119. package/build/dist/Server/Services/GlobalOidcProjectService.js.map +1 -1
  120. package/build/dist/Server/Services/GlobalOidcService.js +127 -0
  121. package/build/dist/Server/Services/GlobalOidcService.js.map +1 -1
  122. package/build/dist/Server/Services/GlobalSsoProjectService.js +105 -0
  123. package/build/dist/Server/Services/GlobalSsoProjectService.js.map +1 -1
  124. package/build/dist/Server/Services/GlobalSsoService.js +127 -0
  125. package/build/dist/Server/Services/GlobalSsoService.js.map +1 -1
  126. package/build/dist/Server/Services/UserEmailService.js +4 -4
  127. package/build/dist/Server/Utils/CronTab.js +66 -0
  128. package/build/dist/Server/Utils/CronTab.js.map +1 -1
  129. package/build/dist/Server/Utils/GlobalSsoAuthorization.js +112 -0
  130. package/build/dist/Server/Utils/GlobalSsoAuthorization.js.map +1 -0
  131. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js +17 -1
  132. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js.map +1 -1
  133. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js +1 -1
  134. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js.map +1 -1
  135. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js +1 -1
  136. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js.map +1 -1
  137. package/build/dist/Server/Utils/Marketing/Providers/Meta.js +1 -1
  138. package/build/dist/Server/Utils/Marketing/Providers/Meta.js.map +1 -1
  139. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js +1 -1
  140. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js.map +1 -1
  141. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js +1 -1
  142. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js.map +1 -1
  143. package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js +37 -35
  144. package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js.map +1 -1
  145. package/build/dist/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.js +21 -25
  146. package/build/dist/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.js.map +1 -1
  147. package/build/dist/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.js +20 -22
  148. package/build/dist/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.js.map +1 -1
  149. package/build/dist/Server/Utils/Monitor/Criteria/EvaluateOverTime.js +370 -42
  150. package/build/dist/Server/Utils/Monitor/Criteria/EvaluateOverTime.js.map +1 -1
  151. package/build/dist/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.js +21 -25
  152. package/build/dist/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.js.map +1 -1
  153. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +21 -25
  154. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +21 -25
  156. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js +64 -52
  158. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js.map +1 -1
  159. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +25 -29
  160. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
  161. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +8 -0
  162. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  163. package/build/dist/Server/Utils/Monitor/MonitorResource.js +123 -23
  164. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  165. package/build/dist/Server/Utils/StatusPageContentSecurityPolicy.js +39 -0
  166. package/build/dist/Server/Utils/StatusPageContentSecurityPolicy.js.map +1 -0
  167. package/build/dist/Server/Utils/StatusPageCustomizationAccess.js +85 -0
  168. package/build/dist/Server/Utils/StatusPageCustomizationAccess.js.map +1 -0
  169. package/build/dist/Types/Marketing/MarketingConversion.js +10 -0
  170. package/build/dist/Types/Marketing/MarketingConversion.js.map +1 -1
  171. package/build/dist/Types/Monitor/CriteriaFilter.js +1 -0
  172. package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
  173. package/build/dist/Types/NetworkDevice/NetworkDeviceLinkRuleScope.js +46 -0
  174. package/build/dist/Types/NetworkDevice/NetworkDeviceLinkRuleScope.js.map +1 -0
  175. package/build/dist/UI/Components/FormModal/BasicFormModal.js +2 -1
  176. package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
  177. package/build/dist/UI/Components/Forms/BasicForm.js +10 -1
  178. package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
  179. package/build/dist/UI/Components/Forms/BasicModelForm.js +2 -1
  180. package/build/dist/UI/Components/Forms/BasicModelForm.js.map +1 -1
  181. package/build/dist/UI/Components/Forms/ModelForm.js +4 -1
  182. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  183. package/build/dist/UI/Components/Forms/Utils/FormAnalyticsName.js +33 -0
  184. package/build/dist/UI/Components/Forms/Utils/FormAnalyticsName.js.map +1 -0
  185. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js +2 -1
  186. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
  187. package/build/dist/Utils/Analytics.js +50 -2
  188. package/build/dist/Utils/Analytics.js.map +1 -1
  189. package/build/dist/Utils/Monitor/MonitorMetricType.js +37 -0
  190. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  191. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +318 -37
  192. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -1
  193. package/build/dist/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.js +87 -0
  194. package/build/dist/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.js.map +1 -0
  195. package/package.json +1 -1
@@ -0,0 +1,1455 @@
1
+ import UserMiddleware from "../../../Server/Middleware/UserAuthorization";
2
+ import GlobalOidcProjectService from "../../../Server/Services/GlobalOidcProjectService";
3
+ import GlobalOidcService from "../../../Server/Services/GlobalOidcService";
4
+ import GlobalSsoProjectService from "../../../Server/Services/GlobalSsoProjectService";
5
+ import GlobalSsoService from "../../../Server/Services/GlobalSsoService";
6
+ import CookieUtil from "../../../Server/Utils/Cookie";
7
+ import { ExpressRequest } from "../../../Server/Utils/Express";
8
+ import { GlobalProviderTrust } from "../../../Server/Utils/GlobalSsoAuthorization";
9
+ import JSONWebToken from "../../../Server/Utils/JsonWebToken";
10
+ import Email from "../../../Types/Email";
11
+ import JSONWebTokenData from "../../../Types/JsonWebTokenData";
12
+ import Name from "../../../Types/Name";
13
+ import ObjectID from "../../../Types/ObjectID";
14
+ import SsoProviderType from "../../../Types/SSO/SsoProviderType";
15
+ import User from "../../../Models/DatabaseModels/User";
16
+ import {
17
+ afterEach,
18
+ beforeEach,
19
+ describe,
20
+ expect,
21
+ jest,
22
+ test,
23
+ } from "@jest/globals";
24
+ import type { SpyInstance } from "jest-mock";
25
+
26
+ jest.mock("../../../Server/Utils/Logger");
27
+
28
+ /*
29
+ * A Global SSO/OIDC token is a 30-day JWT with NO project binding, so by
30
+ * itself it only proves "this person authenticated against SOME instance-wide
31
+ * identity provider at some point in the last month". Enforcement therefore
32
+ * has two halves:
33
+ *
34
+ * STATELESS - signature, expiry, Global provider type, matching user, and
35
+ * the project's pinned-provider discriminator.
36
+ * STATEFUL - is the provider still trusted (exists AND enabled), and -
37
+ * only when the admin opted in - does it govern this project.
38
+ *
39
+ * `UserMiddleware.isSsoSatisfiedForProject` is THE enforcement entry point and
40
+ * runs both. `UserMiddleware.doesSsoTokenForProjectExist` is the stateless
41
+ * half only, and is deliberately NOT enforcement (see the final describe).
42
+ *
43
+ * These tests mint REAL tokens through CookieUtil (real signing, real
44
+ * verification) and stub ONLY the four service methods that reach Postgres,
45
+ * so the middleware's own routing, ordering and AND-ing logic runs for real.
46
+ *
47
+ * Deliberately NOT repeated here, because it is already covered:
48
+ * - Common/Tests/Server/Middleware/UserAuthorizationSSOProvider.test.ts -
49
+ * the full `requiredSsoProviderId` discriminator matrix, the cookie vs
50
+ * `x-global-sso-token` header transports, and the sync-level refusal of
51
+ * Global-typed tokens in the per-project slot.
52
+ * - Common/Tests/Server/Utils/GlobalSSOToken.test.ts - the global token's
53
+ * payload/TTL contract, wrong-secret and syntactic tampering, and
54
+ * project-typed tokens sitting in the global slot.
55
+ * - Common/Tests/Server/Utils/GlobalSsoAuthorization.test.ts - the
56
+ * attachment/cache primitives underneath the services.
57
+ * What this file adds is the DATABASE-backed half of the decision, and proof
58
+ * that every stateless rejection happens BEFORE the database is consulted.
59
+ *
60
+ * Every "refused" assertion below is paired with an "allowed" assertion over
61
+ * the SAME setup with only the single thing under test corrected. An
62
+ * implementation that simply returned false everywhere would fail this file.
63
+ */
64
+
65
+ const THIRTY_DAYS_IN_SECONDS: number = 30 * 24 * 60 * 60;
66
+
67
+ const TRUSTED_AND_UNRESTRICTED: GlobalProviderTrust = {
68
+ isUsable: true,
69
+ restrictToAttachedProjects: false,
70
+ };
71
+
72
+ const TRUSTED_AND_RESTRICTED: GlobalProviderTrust = {
73
+ isUsable: true,
74
+ restrictToAttachedProjects: true,
75
+ };
76
+
77
+ const REVOKED: GlobalProviderTrust = {
78
+ isUsable: false,
79
+ restrictToAttachedProjects: false,
80
+ };
81
+
82
+ type TrustSpy = SpyInstance<
83
+ (providerId: ObjectID) => Promise<GlobalProviderTrust>
84
+ >;
85
+
86
+ type SsoGovernSpy = SpyInstance<
87
+ (data: { globalSsoId: ObjectID; projectId: ObjectID }) => Promise<boolean>
88
+ >;
89
+
90
+ type OidcGovernSpy = SpyInstance<
91
+ (data: { globalOidcId: ObjectID; projectId: ObjectID }) => Promise<boolean>
92
+ >;
93
+
94
+ let ssoTrustSpy: TrustSpy;
95
+ let oidcTrustSpy: TrustSpy;
96
+ let ssoGovernsSpy: SsoGovernSpy;
97
+ let oidcGovernsSpy: OidcGovernSpy;
98
+
99
+ const buildUser: (userId: ObjectID) => User = (userId: ObjectID): User => {
100
+ const user: User = new User();
101
+ user.id = userId;
102
+ user.name = new Name("Global SSO User");
103
+ user.email = new Email("global-sso@oneuptime.com");
104
+ return user;
105
+ };
106
+
107
+ /** A real Global SSO/OIDC token, exactly as a global login mints it. */
108
+ const mintGlobalToken: (data: {
109
+ userId: ObjectID;
110
+ providerId: ObjectID;
111
+ providerType: SsoProviderType;
112
+ }) => string = (data: {
113
+ userId: ObjectID;
114
+ providerId: ObjectID;
115
+ providerType: SsoProviderType;
116
+ }): string => {
117
+ return CookieUtil.getGlobalSSOToken({
118
+ user: buildUser(data.userId),
119
+ ssoProviderId: data.providerId,
120
+ ssoProviderType: data.providerType,
121
+ });
122
+ };
123
+
124
+ /** A real per-project `sso-<projectId>` token. */
125
+ const mintProjectToken: (data: {
126
+ userId: ObjectID;
127
+ projectId: ObjectID;
128
+ providerId: ObjectID;
129
+ providerType: SsoProviderType;
130
+ }) => string = (data: {
131
+ userId: ObjectID;
132
+ projectId: ObjectID;
133
+ providerId: ObjectID;
134
+ providerType: SsoProviderType;
135
+ }): string => {
136
+ return CookieUtil.getSSOToken({
137
+ user: buildUser(data.userId),
138
+ projectId: data.projectId,
139
+ ssoProviderId: data.providerId,
140
+ ssoProviderType: data.providerType,
141
+ });
142
+ };
143
+
144
+ const decodeToken: (token: string) => JSONWebTokenData = (
145
+ token: string,
146
+ ): JSONWebTokenData => {
147
+ return JSONWebToken.decode(token);
148
+ };
149
+
150
+ interface RequestParts {
151
+ globalToken?: string | undefined;
152
+ projectTokens?: Array<{ projectId: ObjectID; token: string }> | undefined;
153
+ }
154
+
155
+ const buildRequest: (parts: RequestParts) => ExpressRequest = (
156
+ parts: RequestParts,
157
+ ): ExpressRequest => {
158
+ const cookies: Record<string, string> = {};
159
+
160
+ if (parts.globalToken) {
161
+ cookies[CookieUtil.getGlobalSSOKey()] = parts.globalToken;
162
+ }
163
+
164
+ for (const entry of parts.projectTokens || []) {
165
+ cookies[CookieUtil.getUserSSOKey(entry.projectId)] = entry.token;
166
+ }
167
+
168
+ return {
169
+ cookies: cookies,
170
+ headers: {},
171
+ } as unknown as ExpressRequest;
172
+ };
173
+
174
+ /*
175
+ * "The database was never asked." Used by every test that claims a decision
176
+ * was reached statelessly - a stateless gate that silently started issuing
177
+ * queries would be a performance regression on the hottest path in the
178
+ * product, and a gate that ran AFTER the lookup would leak provider existence.
179
+ */
180
+ const expectNoDatabaseLookups: () => void = (): void => {
181
+ expect(ssoTrustSpy).not.toHaveBeenCalled();
182
+ expect(oidcTrustSpy).not.toHaveBeenCalled();
183
+ expect(ssoGovernsSpy).not.toHaveBeenCalled();
184
+ expect(oidcGovernsSpy).not.toHaveBeenCalled();
185
+ };
186
+
187
+ beforeEach(() => {
188
+ ssoTrustSpy = jest.spyOn(GlobalSsoService, "getProviderTrust");
189
+ oidcTrustSpy = jest.spyOn(GlobalOidcService, "getProviderTrust");
190
+ ssoGovernsSpy = jest.spyOn(
191
+ GlobalSsoProjectService,
192
+ "doesProviderGovernProject",
193
+ );
194
+ oidcGovernsSpy = jest.spyOn(
195
+ GlobalOidcProjectService,
196
+ "doesProviderGovernProject",
197
+ );
198
+
199
+ /*
200
+ * Default: a healthy, enabled, unrestricted provider that governs
201
+ * everything - i.e. what an existing installation looks like on upgrade.
202
+ * Each test narrows only the answer it is about.
203
+ */
204
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
205
+ oidcTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
206
+ ssoGovernsSpy.mockResolvedValue(true);
207
+ oidcGovernsSpy.mockResolvedValue(true);
208
+ });
209
+
210
+ afterEach(() => {
211
+ jest.restoreAllMocks();
212
+ });
213
+
214
+ /*
215
+ * The provider-trust check is what makes the admin "Enabled" toggle mean
216
+ * something. A Global SSO token carries no revocation and lives for 30 days,
217
+ * so if this check were conditional - skipped for unrestricted providers, or
218
+ * short-circuited when governance already said yes - turning a provider off
219
+ * (or deleting it) would change nothing for anyone already signed in, for up
220
+ * to a month.
221
+ */
222
+ describe("isGlobalSsoTokenAuthorizedForProject - provider trust is unconditional", () => {
223
+ const projectId: ObjectID = ObjectID.generate();
224
+ const userId: ObjectID = ObjectID.generate();
225
+ const providerId: ObjectID = ObjectID.generate();
226
+
227
+ const samlTokenData: () => JSONWebTokenData = (): JSONWebTokenData => {
228
+ return decodeToken(
229
+ mintGlobalToken({
230
+ userId,
231
+ providerId,
232
+ providerType: SsoProviderType.GlobalSSO,
233
+ }),
234
+ );
235
+ };
236
+
237
+ const oidcTokenData: () => JSONWebTokenData = (): JSONWebTokenData => {
238
+ return decodeToken(
239
+ mintGlobalToken({
240
+ userId,
241
+ providerId,
242
+ providerType: SsoProviderType.GlobalOIDC,
243
+ }),
244
+ );
245
+ };
246
+
247
+ test("SAML: a disabled or deleted provider refuses an otherwise perfect token", async () => {
248
+ ssoTrustSpy.mockResolvedValue(REVOKED);
249
+
250
+ await expect(
251
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
252
+ globalSsoTokenData: samlTokenData(),
253
+ projectId,
254
+ }),
255
+ ).resolves.toBe(false);
256
+
257
+ // Trust said no, so there is nothing left to ask.
258
+ expect(ssoGovernsSpy).not.toHaveBeenCalled();
259
+ });
260
+
261
+ test("SAML: the SAME token is allowed the moment the provider is enabled again", async () => {
262
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
263
+
264
+ await expect(
265
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
266
+ globalSsoTokenData: samlTokenData(),
267
+ projectId,
268
+ }),
269
+ ).resolves.toBe(true);
270
+ });
271
+
272
+ test("OIDC: a disabled or deleted provider refuses an otherwise perfect token", async () => {
273
+ oidcTrustSpy.mockResolvedValue(REVOKED);
274
+
275
+ await expect(
276
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
277
+ globalSsoTokenData: oidcTokenData(),
278
+ projectId,
279
+ }),
280
+ ).resolves.toBe(false);
281
+
282
+ expect(oidcGovernsSpy).not.toHaveBeenCalled();
283
+ });
284
+
285
+ test("OIDC: the SAME token is allowed the moment the provider is enabled again", async () => {
286
+ oidcTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
287
+
288
+ await expect(
289
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
290
+ globalSsoTokenData: oidcTokenData(),
291
+ projectId,
292
+ }),
293
+ ).resolves.toBe(true);
294
+ });
295
+
296
+ test("a disabled provider is refused EVEN when it governs the project - trust is not skippable", async () => {
297
+ // Restriction on, attachment present: governance would happily say yes.
298
+ ssoTrustSpy.mockResolvedValue({
299
+ isUsable: false,
300
+ restrictToAttachedProjects: true,
301
+ });
302
+ ssoGovernsSpy.mockResolvedValue(true);
303
+
304
+ await expect(
305
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
306
+ globalSsoTokenData: samlTokenData(),
307
+ projectId,
308
+ }),
309
+ ).resolves.toBe(false);
310
+
311
+ // Same setup, only `isUsable` flipped -> allowed.
312
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
313
+
314
+ await expect(
315
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
316
+ globalSsoTokenData: samlTokenData(),
317
+ projectId,
318
+ }),
319
+ ).resolves.toBe(true);
320
+ });
321
+
322
+ test("the whole enforcement entry point refuses a request once the provider is disabled", async () => {
323
+ ssoTrustSpy.mockResolvedValue(REVOKED);
324
+
325
+ const req: ExpressRequest = buildRequest({
326
+ globalToken: mintGlobalToken({
327
+ userId,
328
+ providerId,
329
+ providerType: SsoProviderType.GlobalSSO,
330
+ }),
331
+ });
332
+
333
+ await expect(
334
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
335
+ ).resolves.toBe(false);
336
+
337
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
338
+
339
+ await expect(
340
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
341
+ ).resolves.toBe(true);
342
+ });
343
+ });
344
+
345
+ /*
346
+ * THE HEADLINE BEHAVIOUR.
347
+ *
348
+ * The attachment rows (GlobalSsoProject / GlobalOidcProject) were introduced
349
+ * as the PROVISIONING allow-list, and the login routers grant a session on
350
+ * membership of ANY project. Reading them as an access boundary by default
351
+ * would deny existing users the projects they legitimately reach today, the
352
+ * instant they upgraded, with nothing inside the product to recover with. So
353
+ * the boundary is OPT-IN per provider, via `restrictToAttachedProjects`, and
354
+ * with it off the attachment table is not even consulted.
355
+ */
356
+ describe("isGlobalSsoTokenAuthorizedForProject - project governance is opt-in", () => {
357
+ const projectId: ObjectID = ObjectID.generate();
358
+ const userId: ObjectID = ObjectID.generate();
359
+ const providerId: ObjectID = ObjectID.generate();
360
+
361
+ const samlTokenData: () => JSONWebTokenData = (): JSONWebTokenData => {
362
+ return decodeToken(
363
+ mintGlobalToken({
364
+ userId,
365
+ providerId,
366
+ providerType: SsoProviderType.GlobalSSO,
367
+ }),
368
+ );
369
+ };
370
+
371
+ test("restrictToAttachedProjects FALSE -> allowed WITHOUT ever asking the attachment table", async () => {
372
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
373
+ /*
374
+ * Rigged to deny. If the implementation consulted it anyway, the result
375
+ * would be false and this test would fail - which is exactly the
376
+ * upgrade-day lockout being guarded against.
377
+ */
378
+ ssoGovernsSpy.mockResolvedValue(false);
379
+
380
+ await expect(
381
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
382
+ globalSsoTokenData: samlTokenData(),
383
+ projectId,
384
+ }),
385
+ ).resolves.toBe(true);
386
+
387
+ expect(ssoGovernsSpy).not.toHaveBeenCalled();
388
+ expect(oidcGovernsSpy).not.toHaveBeenCalled();
389
+ });
390
+
391
+ test("restrictToAttachedProjects FALSE -> every project the user reaches is satisfied, not just one", async () => {
392
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
393
+ ssoGovernsSpy.mockResolvedValue(false);
394
+
395
+ const otherProjectId: ObjectID = ObjectID.generate();
396
+ const thirdProjectId: ObjectID = ObjectID.generate();
397
+
398
+ for (const candidateProjectId of [
399
+ projectId,
400
+ otherProjectId,
401
+ thirdProjectId,
402
+ ]) {
403
+ await expect(
404
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
405
+ globalSsoTokenData: samlTokenData(),
406
+ projectId: candidateProjectId,
407
+ }),
408
+ ).resolves.toBe(true);
409
+ }
410
+
411
+ expect(ssoGovernsSpy).not.toHaveBeenCalled();
412
+ });
413
+
414
+ test("restrictToAttachedProjects TRUE + provider governs the project -> allowed", async () => {
415
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
416
+ ssoGovernsSpy.mockResolvedValue(true);
417
+
418
+ await expect(
419
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
420
+ globalSsoTokenData: samlTokenData(),
421
+ projectId,
422
+ }),
423
+ ).resolves.toBe(true);
424
+
425
+ expect(ssoGovernsSpy).toHaveBeenCalledTimes(1);
426
+ });
427
+
428
+ test("restrictToAttachedProjects TRUE + provider does NOT govern the project -> refused", async () => {
429
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
430
+ ssoGovernsSpy.mockResolvedValue(false);
431
+
432
+ await expect(
433
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
434
+ globalSsoTokenData: samlTokenData(),
435
+ projectId,
436
+ }),
437
+ ).resolves.toBe(false);
438
+
439
+ expect(ssoGovernsSpy).toHaveBeenCalledTimes(1);
440
+ });
441
+
442
+ test("flipping ONLY restrictToAttachedProjects turns a non-governed project from allowed to refused", async () => {
443
+ ssoGovernsSpy.mockResolvedValue(false);
444
+
445
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
446
+ await expect(
447
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
448
+ globalSsoTokenData: samlTokenData(),
449
+ projectId,
450
+ }),
451
+ ).resolves.toBe(true);
452
+
453
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
454
+ await expect(
455
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
456
+ globalSsoTokenData: samlTokenData(),
457
+ projectId,
458
+ }),
459
+ ).resolves.toBe(false);
460
+ });
461
+
462
+ test("governance flows through the enforcement entry point too, both ways", async () => {
463
+ const req: ExpressRequest = buildRequest({
464
+ globalToken: mintGlobalToken({
465
+ userId,
466
+ providerId,
467
+ providerType: SsoProviderType.GlobalSSO,
468
+ }),
469
+ });
470
+
471
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
472
+
473
+ ssoGovernsSpy.mockResolvedValue(false);
474
+ await expect(
475
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
476
+ ).resolves.toBe(false);
477
+
478
+ ssoGovernsSpy.mockResolvedValue(true);
479
+ await expect(
480
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
481
+ ).resolves.toBe(true);
482
+ });
483
+ });
484
+
485
+ /*
486
+ * Global SAML and Global OIDC are different tables, different services, and
487
+ * different provider-id namespaces - an id from one must never be resolved
488
+ * against the other, or a disabled SAML provider could be vouched for by an
489
+ * unrelated OIDC row that happens to share its id.
490
+ */
491
+ describe("isGlobalSsoTokenAuthorizedForProject - SAML vs OIDC routing", () => {
492
+ const projectId: ObjectID = ObjectID.generate();
493
+ const userId: ObjectID = ObjectID.generate();
494
+ const providerId: ObjectID = ObjectID.generate();
495
+
496
+ test("a GlobalSSO token consults the SSO service and NOT the OIDC one", async () => {
497
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
498
+ ssoGovernsSpy.mockResolvedValue(true);
499
+ // Rigged the other way, so any cross-talk changes the answer.
500
+ oidcTrustSpy.mockResolvedValue(REVOKED);
501
+ oidcGovernsSpy.mockResolvedValue(false);
502
+
503
+ await expect(
504
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
505
+ globalSsoTokenData: decodeToken(
506
+ mintGlobalToken({
507
+ userId,
508
+ providerId,
509
+ providerType: SsoProviderType.GlobalSSO,
510
+ }),
511
+ ),
512
+ projectId,
513
+ }),
514
+ ).resolves.toBe(true);
515
+
516
+ expect(ssoTrustSpy).toHaveBeenCalledTimes(1);
517
+ expect(ssoGovernsSpy).toHaveBeenCalledTimes(1);
518
+ expect(oidcTrustSpy).not.toHaveBeenCalled();
519
+ expect(oidcGovernsSpy).not.toHaveBeenCalled();
520
+ });
521
+
522
+ test("a GlobalOIDC token consults the OIDC service and NOT the SSO one", async () => {
523
+ oidcTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
524
+ oidcGovernsSpy.mockResolvedValue(true);
525
+ ssoTrustSpy.mockResolvedValue(REVOKED);
526
+ ssoGovernsSpy.mockResolvedValue(false);
527
+
528
+ await expect(
529
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
530
+ globalSsoTokenData: decodeToken(
531
+ mintGlobalToken({
532
+ userId,
533
+ providerId,
534
+ providerType: SsoProviderType.GlobalOIDC,
535
+ }),
536
+ ),
537
+ projectId,
538
+ }),
539
+ ).resolves.toBe(true);
540
+
541
+ expect(oidcTrustSpy).toHaveBeenCalledTimes(1);
542
+ expect(oidcGovernsSpy).toHaveBeenCalledTimes(1);
543
+ expect(ssoTrustSpy).not.toHaveBeenCalled();
544
+ expect(ssoGovernsSpy).not.toHaveBeenCalled();
545
+ });
546
+
547
+ test("a disabled SAML provider is NOT rescued by an enabled OIDC row of the same id", async () => {
548
+ ssoTrustSpy.mockResolvedValue(REVOKED);
549
+ oidcTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
550
+
551
+ await expect(
552
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
553
+ globalSsoTokenData: decodeToken(
554
+ mintGlobalToken({
555
+ userId,
556
+ providerId,
557
+ providerType: SsoProviderType.GlobalSSO,
558
+ }),
559
+ ),
560
+ projectId,
561
+ }),
562
+ ).resolves.toBe(false);
563
+
564
+ expect(oidcTrustSpy).not.toHaveBeenCalled();
565
+ });
566
+
567
+ test("a disabled OIDC provider is NOT rescued by an enabled SAML row of the same id", async () => {
568
+ oidcTrustSpy.mockResolvedValue(REVOKED);
569
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
570
+
571
+ await expect(
572
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
573
+ globalSsoTokenData: decodeToken(
574
+ mintGlobalToken({
575
+ userId,
576
+ providerId,
577
+ providerType: SsoProviderType.GlobalOIDC,
578
+ }),
579
+ ),
580
+ projectId,
581
+ }),
582
+ ).resolves.toBe(false);
583
+
584
+ expect(ssoTrustSpy).not.toHaveBeenCalled();
585
+ });
586
+ });
587
+
588
+ /*
589
+ * The decision has to be made about the provider named by THIS token and the
590
+ * project named by THIS request. Passing the wrong id - a hard-coded one, the
591
+ * required-provider id, the user id - would produce answers that look right in
592
+ * a single-provider install and are wrong everywhere else.
593
+ */
594
+ describe("isGlobalSsoTokenAuthorizedForProject - the ids handed to the services", () => {
595
+ const projectId: ObjectID = ObjectID.generate();
596
+ const userId: ObjectID = ObjectID.generate();
597
+ const providerId: ObjectID = ObjectID.generate();
598
+
599
+ test("SAML: the token's provider id and the request's project id are the ones queried", async () => {
600
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
601
+ ssoGovernsSpy.mockResolvedValue(true);
602
+
603
+ await UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
604
+ globalSsoTokenData: decodeToken(
605
+ mintGlobalToken({
606
+ userId,
607
+ providerId,
608
+ providerType: SsoProviderType.GlobalSSO,
609
+ }),
610
+ ),
611
+ projectId,
612
+ });
613
+
614
+ expect(ssoTrustSpy).toHaveBeenCalledTimes(1);
615
+ expect(ssoTrustSpy.mock.calls[0]?.[0]?.toString()).toBe(
616
+ providerId.toString(),
617
+ );
618
+
619
+ expect(ssoGovernsSpy).toHaveBeenCalledTimes(1);
620
+ expect(ssoGovernsSpy.mock.calls[0]?.[0]?.globalSsoId.toString()).toBe(
621
+ providerId.toString(),
622
+ );
623
+ expect(ssoGovernsSpy.mock.calls[0]?.[0]?.projectId.toString()).toBe(
624
+ projectId.toString(),
625
+ );
626
+ });
627
+
628
+ test("OIDC: the token's provider id and the request's project id are the ones queried", async () => {
629
+ oidcTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
630
+ oidcGovernsSpy.mockResolvedValue(true);
631
+
632
+ await UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
633
+ globalSsoTokenData: decodeToken(
634
+ mintGlobalToken({
635
+ userId,
636
+ providerId,
637
+ providerType: SsoProviderType.GlobalOIDC,
638
+ }),
639
+ ),
640
+ projectId,
641
+ });
642
+
643
+ expect(oidcTrustSpy.mock.calls[0]?.[0]?.toString()).toBe(
644
+ providerId.toString(),
645
+ );
646
+ expect(oidcGovernsSpy.mock.calls[0]?.[0]?.globalOidcId.toString()).toBe(
647
+ providerId.toString(),
648
+ );
649
+ expect(oidcGovernsSpy.mock.calls[0]?.[0]?.projectId.toString()).toBe(
650
+ projectId.toString(),
651
+ );
652
+ });
653
+
654
+ test("each project in a multi-project fan-out is queried with its OWN id", async () => {
655
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
656
+ ssoGovernsSpy.mockResolvedValue(true);
657
+
658
+ const projectA: ObjectID = ObjectID.generate();
659
+ const projectB: ObjectID = ObjectID.generate();
660
+
661
+ const tokenData: JSONWebTokenData = decodeToken(
662
+ mintGlobalToken({
663
+ userId,
664
+ providerId,
665
+ providerType: SsoProviderType.GlobalSSO,
666
+ }),
667
+ );
668
+
669
+ await UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
670
+ globalSsoTokenData: tokenData,
671
+ projectId: projectA,
672
+ });
673
+ await UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
674
+ globalSsoTokenData: tokenData,
675
+ projectId: projectB,
676
+ });
677
+
678
+ expect(ssoGovernsSpy.mock.calls[0]?.[0]?.projectId.toString()).toBe(
679
+ projectA.toString(),
680
+ );
681
+ expect(ssoGovernsSpy.mock.calls[1]?.[0]?.projectId.toString()).toBe(
682
+ projectB.toString(),
683
+ );
684
+ });
685
+ });
686
+
687
+ /*
688
+ * "This provider is not allowed here" and "we could not find out" are
689
+ * different answers, and the code must not conflate them.
690
+ *
691
+ * If a failed lookup were caught and turned into `false`, a database blip - a
692
+ * dropped connection, a statement timeout, a failover - would look exactly
693
+ * like a deliberate permission decision. On the multi-tenant permission path
694
+ * that means the request still succeeds, with 200 and an empty permission
695
+ * set: the UI silently loses every button instead of showing an error, and
696
+ * nothing in the logs distinguishes it from a correctly-revoked user. Letting
697
+ * it throw turns an infrastructure fault into an infrastructure-shaped
698
+ * failure that alerts, retries, and gets fixed.
699
+ */
700
+ describe("isGlobalSsoTokenAuthorizedForProject - a failed lookup THROWS, it does not deny", () => {
701
+ const projectId: ObjectID = ObjectID.generate();
702
+ const userId: ObjectID = ObjectID.generate();
703
+ const providerId: ObjectID = ObjectID.generate();
704
+
705
+ const samlTokenData: () => JSONWebTokenData = (): JSONWebTokenData => {
706
+ return decodeToken(
707
+ mintGlobalToken({
708
+ userId,
709
+ providerId,
710
+ providerType: SsoProviderType.GlobalSSO,
711
+ }),
712
+ );
713
+ };
714
+
715
+ const buildGlobalRequest: (
716
+ providerType: SsoProviderType,
717
+ ) => ExpressRequest = (providerType: SsoProviderType): ExpressRequest => {
718
+ return buildRequest({
719
+ globalToken: mintGlobalToken({ userId, providerId, providerType }),
720
+ });
721
+ };
722
+
723
+ test("SAML trust lookup rejects -> the decision rejects rather than resolving false", async () => {
724
+ ssoTrustSpy.mockRejectedValue(new Error("connection terminated"));
725
+
726
+ await expect(
727
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
728
+ globalSsoTokenData: samlTokenData(),
729
+ projectId,
730
+ }),
731
+ ).rejects.toThrow("connection terminated");
732
+
733
+ /*
734
+ * And the same lookup succeeding is a plain "allowed" - so the rejection
735
+ * above is about the failure and nothing else.
736
+ */
737
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
738
+
739
+ await expect(
740
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
741
+ globalSsoTokenData: samlTokenData(),
742
+ projectId,
743
+ }),
744
+ ).resolves.toBe(true);
745
+ });
746
+
747
+ test("OIDC trust lookup rejects -> the decision rejects rather than resolving false", async () => {
748
+ oidcTrustSpy.mockRejectedValue(new Error("statement timeout"));
749
+
750
+ await expect(
751
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
752
+ globalSsoTokenData: decodeToken(
753
+ mintGlobalToken({
754
+ userId,
755
+ providerId,
756
+ providerType: SsoProviderType.GlobalOIDC,
757
+ }),
758
+ ),
759
+ projectId,
760
+ }),
761
+ ).rejects.toThrow("statement timeout");
762
+ });
763
+
764
+ test("governance lookup rejects under restrict-on -> the decision rejects", async () => {
765
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
766
+ ssoGovernsSpy.mockRejectedValue(new Error("attachment read failed"));
767
+
768
+ await expect(
769
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
770
+ globalSsoTokenData: samlTokenData(),
771
+ projectId,
772
+ }),
773
+ ).rejects.toThrow("attachment read failed");
774
+
775
+ // Same restrict-on setup, lookup healthy -> allowed.
776
+ ssoGovernsSpy.mockResolvedValue(true);
777
+
778
+ await expect(
779
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
780
+ globalSsoTokenData: samlTokenData(),
781
+ projectId,
782
+ }),
783
+ ).resolves.toBe(true);
784
+ });
785
+
786
+ test("OIDC governance lookup rejects under restrict-on -> the decision rejects", async () => {
787
+ oidcTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
788
+ oidcGovernsSpy.mockRejectedValue(new Error("oidc attachment read failed"));
789
+
790
+ await expect(
791
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
792
+ globalSsoTokenData: decodeToken(
793
+ mintGlobalToken({
794
+ userId,
795
+ providerId,
796
+ providerType: SsoProviderType.GlobalOIDC,
797
+ }),
798
+ ),
799
+ projectId,
800
+ }),
801
+ ).rejects.toThrow("oidc attachment read failed");
802
+ });
803
+
804
+ test("the enforcement entry point propagates the failure instead of denying", async () => {
805
+ const req: ExpressRequest = buildGlobalRequest(SsoProviderType.GlobalSSO);
806
+
807
+ ssoTrustSpy.mockRejectedValue(new Error("connection terminated"));
808
+
809
+ await expect(
810
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
811
+ ).rejects.toThrow("connection terminated");
812
+
813
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
814
+
815
+ await expect(
816
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
817
+ ).resolves.toBe(true);
818
+ });
819
+
820
+ test("the enforcement entry point propagates a failed governance lookup too", async () => {
821
+ const req: ExpressRequest = buildGlobalRequest(SsoProviderType.GlobalSSO);
822
+
823
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
824
+ ssoGovernsSpy.mockRejectedValue(new Error("attachment read failed"));
825
+
826
+ await expect(
827
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
828
+ ).rejects.toThrow("attachment read failed");
829
+ });
830
+ });
831
+
832
+ /*
833
+ * A Global-typed token with no `ssoProviderId` cannot be checked against
834
+ * either question, so it cannot be trusted for a project. That is a DECISION -
835
+ * `false` - not a failure. It must not throw, and it must not spend a query
836
+ * finding out, because the answer is fully determined by the token.
837
+ */
838
+ describe("isGlobalSsoTokenAuthorizedForProject - a token with no provider id", () => {
839
+ const projectId: ObjectID = ObjectID.generate();
840
+ const userId: ObjectID = ObjectID.generate();
841
+ const providerId: ObjectID = ObjectID.generate();
842
+
843
+ const mintProviderlessGlobalToken: () => string = (): string => {
844
+ return JSONWebToken.sign({
845
+ data: {
846
+ userId: userId,
847
+ name: new Name("Global SSO User"),
848
+ email: new Email("global-sso@oneuptime.com"),
849
+ isMasterAdmin: false,
850
+ isGeneralLogin: false,
851
+ // No ssoProviderId at all - the shape a pre-discriminator token had.
852
+ ssoProviderType: SsoProviderType.GlobalSSO.toString(),
853
+ },
854
+ expiresInSeconds: THIRTY_DAYS_IN_SECONDS,
855
+ });
856
+ };
857
+
858
+ test("the fixture really is a Global token with no provider id", () => {
859
+ const decoded: JSONWebTokenData = decodeToken(
860
+ mintProviderlessGlobalToken(),
861
+ );
862
+
863
+ expect(decoded.ssoProviderType).toBe(SsoProviderType.GlobalSSO);
864
+ expect(decoded.ssoProviderId).toBeUndefined();
865
+ });
866
+
867
+ test("resolves false, and never touches the database", async () => {
868
+ await expect(
869
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
870
+ globalSsoTokenData: decodeToken(mintProviderlessGlobalToken()),
871
+ projectId,
872
+ }),
873
+ ).resolves.toBe(false);
874
+
875
+ expectNoDatabaseLookups();
876
+ });
877
+
878
+ test("the same token WITH a provider id is allowed - the refusal is about the missing id", async () => {
879
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
880
+
881
+ await expect(
882
+ UserMiddleware.isGlobalSsoTokenAuthorizedForProject({
883
+ globalSsoTokenData: decodeToken(
884
+ mintGlobalToken({
885
+ userId,
886
+ providerId,
887
+ providerType: SsoProviderType.GlobalSSO,
888
+ }),
889
+ ),
890
+ projectId,
891
+ }),
892
+ ).resolves.toBe(true);
893
+
894
+ expect(ssoTrustSpy).toHaveBeenCalledTimes(1);
895
+ });
896
+
897
+ test("through the enforcement entry point: refused, with no database lookups", async () => {
898
+ const req: ExpressRequest = buildRequest({
899
+ globalToken: mintProviderlessGlobalToken(),
900
+ });
901
+
902
+ await expect(
903
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
904
+ ).resolves.toBe(false);
905
+
906
+ expectNoDatabaseLookups();
907
+ });
908
+ });
909
+
910
+ /*
911
+ * A per-project SSO token is bound to one project by a login that already
912
+ * proved the project's own provider trusts this user. It is decided
913
+ * statelessly and short-circuits the whole stateful path - which is what
914
+ * keeps a project-SSO-only installation from paying for Global SSO queries it
915
+ * has no use for.
916
+ */
917
+ describe("isSsoSatisfiedForProject - the per-project token short-circuits", () => {
918
+ const projectId: ObjectID = ObjectID.generate();
919
+ const otherProjectId: ObjectID = ObjectID.generate();
920
+ const userId: ObjectID = ObjectID.generate();
921
+ const providerId: ObjectID = ObjectID.generate();
922
+
923
+ test("a valid ProjectSSO token for THIS project -> true, with no global lookups", async () => {
924
+ // Rigged so any fall-through to the global path would refuse.
925
+ ssoTrustSpy.mockResolvedValue(REVOKED);
926
+ oidcTrustSpy.mockResolvedValue(REVOKED);
927
+
928
+ const req: ExpressRequest = buildRequest({
929
+ projectTokens: [
930
+ {
931
+ projectId,
932
+ token: mintProjectToken({
933
+ userId,
934
+ projectId,
935
+ providerId,
936
+ providerType: SsoProviderType.ProjectSSO,
937
+ }),
938
+ },
939
+ ],
940
+ });
941
+
942
+ await expect(
943
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
944
+ ).resolves.toBe(true);
945
+
946
+ expectNoDatabaseLookups();
947
+ });
948
+
949
+ test("a valid ProjectOIDC token for THIS project -> true, with no global lookups", async () => {
950
+ ssoTrustSpy.mockResolvedValue(REVOKED);
951
+ oidcTrustSpy.mockResolvedValue(REVOKED);
952
+
953
+ const req: ExpressRequest = buildRequest({
954
+ projectTokens: [
955
+ {
956
+ projectId,
957
+ token: mintProjectToken({
958
+ userId,
959
+ projectId,
960
+ providerId,
961
+ providerType: SsoProviderType.ProjectOIDC,
962
+ }),
963
+ },
964
+ ],
965
+ });
966
+
967
+ await expect(
968
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
969
+ ).resolves.toBe(true);
970
+
971
+ expectNoDatabaseLookups();
972
+ });
973
+
974
+ test("a per-project token for a DIFFERENT project does not short-circuit - the global path decides", async () => {
975
+ const req: ExpressRequest = buildRequest({
976
+ globalToken: mintGlobalToken({
977
+ userId,
978
+ providerId,
979
+ providerType: SsoProviderType.GlobalSSO,
980
+ }),
981
+ projectTokens: [
982
+ {
983
+ projectId: otherProjectId,
984
+ token: mintProjectToken({
985
+ userId,
986
+ projectId: otherProjectId,
987
+ providerId,
988
+ providerType: SsoProviderType.ProjectSSO,
989
+ }),
990
+ },
991
+ ],
992
+ });
993
+
994
+ /*
995
+ * The global provider is disabled, so the unrelated project token must
996
+ * not carry this request.
997
+ */
998
+ ssoTrustSpy.mockResolvedValue(REVOKED);
999
+
1000
+ await expect(
1001
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
1002
+ ).resolves.toBe(false);
1003
+
1004
+ expect(ssoTrustSpy).toHaveBeenCalledTimes(1);
1005
+
1006
+ // The project the token IS for still short-circuits, disabled or not.
1007
+ ssoTrustSpy.mockClear();
1008
+
1009
+ await expect(
1010
+ UserMiddleware.isSsoSatisfiedForProject({
1011
+ req,
1012
+ projectId: otherProjectId,
1013
+ userId,
1014
+ }),
1015
+ ).resolves.toBe(true);
1016
+
1017
+ expectNoDatabaseLookups();
1018
+ });
1019
+
1020
+ test("with the global provider healthy, the other project is satisfied by the global token", async () => {
1021
+ const req: ExpressRequest = buildRequest({
1022
+ globalToken: mintGlobalToken({
1023
+ userId,
1024
+ providerId,
1025
+ providerType: SsoProviderType.GlobalSSO,
1026
+ }),
1027
+ projectTokens: [
1028
+ {
1029
+ projectId: otherProjectId,
1030
+ token: mintProjectToken({
1031
+ userId,
1032
+ projectId: otherProjectId,
1033
+ providerId,
1034
+ providerType: SsoProviderType.ProjectSSO,
1035
+ }),
1036
+ },
1037
+ ],
1038
+ });
1039
+
1040
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
1041
+
1042
+ await expect(
1043
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
1044
+ ).resolves.toBe(true);
1045
+
1046
+ expect(ssoTrustSpy).toHaveBeenCalledTimes(1);
1047
+ });
1048
+ });
1049
+
1050
+ /*
1051
+ * THE UPGRADE HAZARD.
1052
+ *
1053
+ * Before the single-token redesign the Global SSO router minted one
1054
+ * per-project token PER PROJECT, typed GlobalSSO/GlobalOIDC and signed for 30
1055
+ * days. Those are still sitting in browsers as `sso-<projectId>` cookies and
1056
+ * in the mobile app's AsyncStorage, replayed on every request. If the
1057
+ * per-project slot accepted them they would short-circuit past the
1058
+ * provider-trust check - so an admin disabling a provider would still not
1059
+ * revoke them, for up to a month. The slot refuses them by TYPE, which sends
1060
+ * the user through a fresh global login that mints a properly-typed token.
1061
+ */
1062
+ describe("isSsoSatisfiedForProject - a legacy Global-typed token in the per-project slot", () => {
1063
+ const projectId: ObjectID = ObjectID.generate();
1064
+ const userId: ObjectID = ObjectID.generate();
1065
+ const providerId: ObjectID = ObjectID.generate();
1066
+
1067
+ const buildLegacyRequest: (data: {
1068
+ projectSlotType: SsoProviderType;
1069
+ alsoInGlobalSlot: boolean;
1070
+ }) => ExpressRequest = (data: {
1071
+ projectSlotType: SsoProviderType;
1072
+ alsoInGlobalSlot: boolean;
1073
+ }): ExpressRequest => {
1074
+ return buildRequest({
1075
+ globalToken: data.alsoInGlobalSlot
1076
+ ? mintGlobalToken({
1077
+ userId,
1078
+ providerId,
1079
+ providerType: SsoProviderType.GlobalSSO,
1080
+ })
1081
+ : undefined,
1082
+ projectTokens: [
1083
+ {
1084
+ projectId,
1085
+ token: mintProjectToken({
1086
+ userId,
1087
+ projectId,
1088
+ providerId,
1089
+ providerType: data.projectSlotType,
1090
+ }),
1091
+ },
1092
+ ],
1093
+ });
1094
+ };
1095
+
1096
+ test("with the provider disabled, the legacy GlobalSSO-typed token no longer buys 30 days of access", async () => {
1097
+ ssoTrustSpy.mockResolvedValue(REVOKED);
1098
+
1099
+ await expect(
1100
+ UserMiddleware.isSsoSatisfiedForProject({
1101
+ req: buildLegacyRequest({
1102
+ projectSlotType: SsoProviderType.GlobalSSO,
1103
+ alsoInGlobalSlot: true,
1104
+ }),
1105
+ projectId,
1106
+ userId,
1107
+ }),
1108
+ ).resolves.toBe(false);
1109
+
1110
+ // It really did go through the stateful path rather than being dropped.
1111
+ expect(ssoTrustSpy).toHaveBeenCalledTimes(1);
1112
+ });
1113
+
1114
+ test("the SAME request with the project-slot token typed ProjectSSO short-circuits and is allowed", async () => {
1115
+ /*
1116
+ * The pairing that keeps the test above honest: a blanket-deny
1117
+ * implementation would fail here. A properly project-typed token is a
1118
+ * project login, decided statelessly, so the disabled GLOBAL provider is
1119
+ * irrelevant to it.
1120
+ */
1121
+ ssoTrustSpy.mockResolvedValue(REVOKED);
1122
+
1123
+ await expect(
1124
+ UserMiddleware.isSsoSatisfiedForProject({
1125
+ req: buildLegacyRequest({
1126
+ projectSlotType: SsoProviderType.ProjectSSO,
1127
+ alsoInGlobalSlot: true,
1128
+ }),
1129
+ projectId,
1130
+ userId,
1131
+ }),
1132
+ ).resolves.toBe(true);
1133
+
1134
+ expectNoDatabaseLookups();
1135
+ });
1136
+
1137
+ test("a legacy GlobalOIDC-typed token in the project slot is refused the same way", async () => {
1138
+ oidcTrustSpy.mockResolvedValue(REVOKED);
1139
+
1140
+ const req: ExpressRequest = buildRequest({
1141
+ globalToken: mintGlobalToken({
1142
+ userId,
1143
+ providerId,
1144
+ providerType: SsoProviderType.GlobalOIDC,
1145
+ }),
1146
+ projectTokens: [
1147
+ {
1148
+ projectId,
1149
+ token: mintProjectToken({
1150
+ userId,
1151
+ projectId,
1152
+ providerId,
1153
+ providerType: SsoProviderType.GlobalOIDC,
1154
+ }),
1155
+ },
1156
+ ],
1157
+ });
1158
+
1159
+ await expect(
1160
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
1161
+ ).resolves.toBe(false);
1162
+
1163
+ expect(oidcTrustSpy).toHaveBeenCalledTimes(1);
1164
+ });
1165
+
1166
+ test("a legacy Global-typed token ALONE in the project slot is not a credential at all", async () => {
1167
+ // Provider perfectly healthy - the refusal is about the slot and type.
1168
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
1169
+
1170
+ await expect(
1171
+ UserMiddleware.isSsoSatisfiedForProject({
1172
+ req: buildLegacyRequest({
1173
+ projectSlotType: SsoProviderType.GlobalSSO,
1174
+ alsoInGlobalSlot: false,
1175
+ }),
1176
+ projectId,
1177
+ userId,
1178
+ }),
1179
+ ).resolves.toBe(false);
1180
+
1181
+ // There was no global token to check, so nothing was looked up.
1182
+ expectNoDatabaseLookups();
1183
+ });
1184
+
1185
+ test("moving that same credential into the global slot makes it work again", async () => {
1186
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
1187
+
1188
+ await expect(
1189
+ UserMiddleware.isSsoSatisfiedForProject({
1190
+ req: buildRequest({
1191
+ globalToken: mintGlobalToken({
1192
+ userId,
1193
+ providerId,
1194
+ providerType: SsoProviderType.GlobalSSO,
1195
+ }),
1196
+ }),
1197
+ projectId,
1198
+ userId,
1199
+ }),
1200
+ ).resolves.toBe(true);
1201
+
1202
+ expect(ssoTrustSpy).toHaveBeenCalledTimes(1);
1203
+ });
1204
+ });
1205
+
1206
+ /*
1207
+ * Every stateless gate must be decided BEFORE the database is consulted. Two
1208
+ * reasons: a forged or expired token should never cost a query (this path runs
1209
+ * on every authenticated request), and a lookup that ran first would make the
1210
+ * request's fate depend on database health for tokens that were never valid
1211
+ * in the first place.
1212
+ */
1213
+ describe("isSsoSatisfiedForProject - stateless gates run before the database", () => {
1214
+ const projectId: ObjectID = ObjectID.generate();
1215
+ const userId: ObjectID = ObjectID.generate();
1216
+ const providerId: ObjectID = ObjectID.generate();
1217
+
1218
+ const validGlobalToken: () => string = (): string => {
1219
+ return mintGlobalToken({
1220
+ userId,
1221
+ providerId,
1222
+ providerType: SsoProviderType.GlobalSSO,
1223
+ });
1224
+ };
1225
+
1226
+ test("no token at all -> false, no lookups", async () => {
1227
+ await expect(
1228
+ UserMiddleware.isSsoSatisfiedForProject({
1229
+ req: buildRequest({}),
1230
+ projectId,
1231
+ userId,
1232
+ }),
1233
+ ).resolves.toBe(false);
1234
+
1235
+ expectNoDatabaseLookups();
1236
+ });
1237
+
1238
+ test("a token belonging to a DIFFERENT user -> false, no lookups", async () => {
1239
+ const req: ExpressRequest = buildRequest({
1240
+ globalToken: validGlobalToken(),
1241
+ });
1242
+ const otherUserId: ObjectID = ObjectID.generate();
1243
+
1244
+ await expect(
1245
+ UserMiddleware.isSsoSatisfiedForProject({
1246
+ req,
1247
+ projectId,
1248
+ userId: otherUserId,
1249
+ }),
1250
+ ).resolves.toBe(false);
1251
+
1252
+ expectNoDatabaseLookups();
1253
+
1254
+ // The user it was minted for still gets in, via the stateful path.
1255
+ await expect(
1256
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
1257
+ ).resolves.toBe(true);
1258
+
1259
+ expect(ssoTrustSpy).toHaveBeenCalledTimes(1);
1260
+ });
1261
+
1262
+ test("an expired token -> false, no lookups", async () => {
1263
+ const expiredToken: string = JSONWebToken.sign({
1264
+ data: {
1265
+ userId: userId,
1266
+ name: new Name("Global SSO User"),
1267
+ email: new Email("global-sso@oneuptime.com"),
1268
+ isMasterAdmin: false,
1269
+ isGeneralLogin: false,
1270
+ ssoProviderId: providerId.toString(),
1271
+ ssoProviderType: SsoProviderType.GlobalSSO.toString(),
1272
+ },
1273
+ expiresInSeconds: -60,
1274
+ });
1275
+
1276
+ await expect(
1277
+ UserMiddleware.isSsoSatisfiedForProject({
1278
+ req: buildRequest({ globalToken: expiredToken }),
1279
+ projectId,
1280
+ userId,
1281
+ }),
1282
+ ).resolves.toBe(false);
1283
+
1284
+ expectNoDatabaseLookups();
1285
+
1286
+ // The identical payload, unexpired, is allowed.
1287
+ await expect(
1288
+ UserMiddleware.isSsoSatisfiedForProject({
1289
+ req: buildRequest({ globalToken: validGlobalToken() }),
1290
+ projectId,
1291
+ userId,
1292
+ }),
1293
+ ).resolves.toBe(true);
1294
+ });
1295
+
1296
+ test("a token whose payload was edited after signing -> false, no lookups", async () => {
1297
+ const genuine: string = validGlobalToken();
1298
+ const parts: Array<string> = genuine.split(".");
1299
+
1300
+ const payload: Record<string, unknown> = JSON.parse(
1301
+ Buffer.from(parts[1] as string, "base64url").toString("utf8"),
1302
+ ) as Record<string, unknown>;
1303
+
1304
+ // Re-point the token at a provider the attacker would rather use.
1305
+ payload["ssoProviderId"] = ObjectID.generate().toString();
1306
+
1307
+ const forged: string = `${parts[0]}.${Buffer.from(
1308
+ JSON.stringify(payload),
1309
+ "utf8",
1310
+ ).toString("base64url")}.${parts[2]}`;
1311
+
1312
+ await expect(
1313
+ UserMiddleware.isSsoSatisfiedForProject({
1314
+ req: buildRequest({ globalToken: forged }),
1315
+ projectId,
1316
+ userId,
1317
+ }),
1318
+ ).resolves.toBe(false);
1319
+
1320
+ expectNoDatabaseLookups();
1321
+
1322
+ // The untampered token is allowed, so the refusal is about the edit.
1323
+ await expect(
1324
+ UserMiddleware.isSsoSatisfiedForProject({
1325
+ req: buildRequest({ globalToken: genuine }),
1326
+ projectId,
1327
+ userId,
1328
+ }),
1329
+ ).resolves.toBe(true);
1330
+ });
1331
+
1332
+ test("a project pinned to a DIFFERENT provider -> false, no lookups", async () => {
1333
+ const req: ExpressRequest = buildRequest({
1334
+ globalToken: validGlobalToken(),
1335
+ });
1336
+ const requiredProviderId: ObjectID = ObjectID.generate();
1337
+
1338
+ await expect(
1339
+ UserMiddleware.isSsoSatisfiedForProject({
1340
+ req,
1341
+ projectId,
1342
+ userId,
1343
+ requiredSsoProviderId: requiredProviderId,
1344
+ }),
1345
+ ).resolves.toBe(false);
1346
+
1347
+ expectNoDatabaseLookups();
1348
+
1349
+ // Pinned to the provider the token actually carries -> allowed.
1350
+ await expect(
1351
+ UserMiddleware.isSsoSatisfiedForProject({
1352
+ req,
1353
+ projectId,
1354
+ userId,
1355
+ requiredSsoProviderId: providerId,
1356
+ }),
1357
+ ).resolves.toBe(true);
1358
+
1359
+ expect(ssoTrustSpy).toHaveBeenCalledTimes(1);
1360
+ });
1361
+
1362
+ test("a project-typed token sitting in the global slot -> false, no lookups", async () => {
1363
+ const req: ExpressRequest = buildRequest({
1364
+ globalToken: mintProjectToken({
1365
+ userId,
1366
+ projectId,
1367
+ providerId,
1368
+ providerType: SsoProviderType.ProjectSSO,
1369
+ }),
1370
+ });
1371
+
1372
+ await expect(
1373
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
1374
+ ).resolves.toBe(false);
1375
+
1376
+ expectNoDatabaseLookups();
1377
+ });
1378
+ });
1379
+
1380
+ /*
1381
+ * `doesSsoTokenForProjectExist` is the STATELESS half only. It is kept for the
1382
+ * existing suites and for callers that just want to know whether a credential
1383
+ * is present and well-formed - it is NOT enforcement, and this test exists so
1384
+ * that anyone tempted to wire a middleware back to it sees the divergence
1385
+ * spelled out: it answers "yes" for a token whose provider an admin revoked
1386
+ * an hour ago.
1387
+ */
1388
+ describe("doesSsoTokenForProjectExist is NOT the enforcement entry point", () => {
1389
+ const projectId: ObjectID = ObjectID.generate();
1390
+ const userId: ObjectID = ObjectID.generate();
1391
+ const providerId: ObjectID = ObjectID.generate();
1392
+
1393
+ test("the sync check says yes where the enforcement check refuses (disabled provider)", async () => {
1394
+ const req: ExpressRequest = buildRequest({
1395
+ globalToken: mintGlobalToken({
1396
+ userId,
1397
+ providerId,
1398
+ providerType: SsoProviderType.GlobalSSO,
1399
+ }),
1400
+ });
1401
+
1402
+ ssoTrustSpy.mockResolvedValue(REVOKED);
1403
+
1404
+ // Stateless: the token is genuine, unexpired, and for this user.
1405
+ expect(
1406
+ UserMiddleware.doesSsoTokenForProjectExist(req, projectId, userId),
1407
+ ).toBe(true);
1408
+
1409
+ // Stateful: the provider behind it is gone, so the request is refused.
1410
+ await expect(
1411
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
1412
+ ).resolves.toBe(false);
1413
+ });
1414
+
1415
+ test("the sync check says yes where the enforcement check refuses (restrict-on, unattached project)", async () => {
1416
+ const req: ExpressRequest = buildRequest({
1417
+ globalToken: mintGlobalToken({
1418
+ userId,
1419
+ providerId,
1420
+ providerType: SsoProviderType.GlobalSSO,
1421
+ }),
1422
+ });
1423
+
1424
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_RESTRICTED);
1425
+ ssoGovernsSpy.mockResolvedValue(false);
1426
+
1427
+ expect(
1428
+ UserMiddleware.doesSsoTokenForProjectExist(req, projectId, userId),
1429
+ ).toBe(true);
1430
+
1431
+ await expect(
1432
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
1433
+ ).resolves.toBe(false);
1434
+ });
1435
+
1436
+ test("the two agree whenever the provider is healthy - the divergence is only about revocation", async () => {
1437
+ const req: ExpressRequest = buildRequest({
1438
+ globalToken: mintGlobalToken({
1439
+ userId,
1440
+ providerId,
1441
+ providerType: SsoProviderType.GlobalSSO,
1442
+ }),
1443
+ });
1444
+
1445
+ ssoTrustSpy.mockResolvedValue(TRUSTED_AND_UNRESTRICTED);
1446
+
1447
+ expect(
1448
+ UserMiddleware.doesSsoTokenForProjectExist(req, projectId, userId),
1449
+ ).toBe(true);
1450
+
1451
+ await expect(
1452
+ UserMiddleware.isSsoSatisfiedForProject({ req, projectId, userId }),
1453
+ ).resolves.toBe(true);
1454
+ });
1455
+ });