11/**
22 * @vitest -environment node
33 */
4- import { createEnvMock , createMockRequest } from '@sim/testing'
5- import { beforeEach , describe , expect , it , vi } from 'vitest'
4+ import {
5+ createEnvMock ,
6+ createMockRequest ,
7+ dbChainMock ,
8+ dbChainMockFns ,
9+ queueTableRows ,
10+ resetDbChainMock ,
11+ schemaMock ,
12+ } from '@sim/testing'
13+ import { afterAll , beforeEach , describe , expect , it , vi } from 'vitest'
614
715const {
816 mockGetSession,
917 mockRegisterSSOProvider,
1018 mockHasSSOAccess,
1119 mockValidateUrlWithDNS,
1220 mockSecureFetchWithPinnedIP,
13- dbState,
14- memberTable,
15- ssoProviderTable,
1621} = vi . hoisted ( ( ) => ( {
1722 mockGetSession : vi . fn ( ) ,
1823 mockRegisterSSOProvider : vi . fn ( ) ,
1924 mockHasSSOAccess : vi . fn ( ) ,
2025 mockValidateUrlWithDNS : vi . fn ( ) ,
2126 mockSecureFetchWithPinnedIP : vi . fn ( ) ,
22- dbState : { members : [ ] as any [ ] , providers : [ ] as any [ ] } ,
23- memberTable : {
24- userId : 'member.userId' ,
25- organizationId : 'member.organizationId' ,
26- role : 'member.role' ,
27- } ,
28- ssoProviderTable : {
29- id : 'sso.id' ,
30- providerId : 'sso.providerId' ,
31- domain : 'sso.domain' ,
32- issuer : 'sso.issuer' ,
33- userId : 'sso.userId' ,
34- organizationId : 'sso.organizationId' ,
35- oidcConfig : 'sso.oidcConfig' ,
36- samlConfig : 'sso.samlConfig' ,
37- } ,
3827} ) )
3928
40- function makeBuilder ( rows : any [ ] ) : any {
41- const thenable : any = Promise . resolve ( rows )
42- thenable . where = ( condition : any ) => {
43- const values = condition ?. values
44- if ( Array . isArray ( values ) && values . length > 0 ) {
45- const target = String ( values [ values . length - 1 ] ) . toLowerCase ( )
46- return makeBuilder ( rows . filter ( ( r ) => String ( r . domain ?? '' ) . toLowerCase ( ) === target ) )
47- }
48- return makeBuilder ( rows )
49- }
50- thenable . limit = ( ) => Promise . resolve ( rows )
51- thenable . orderBy = ( ) => Promise . resolve ( rows )
52- return thenable
29+ vi . mock ( '@sim/db' , ( ) => ( { ...dbChainMock , ...schemaMock } ) )
30+
31+ /** Queues the caller's org membership row(s) for the admin/owner check. */
32+ function queueMembers ( rows : Array < Record < string , unknown > > ) {
33+ queueTableRows ( schemaMock . member , rows )
5334}
5435
55- vi . mock ( '@sim/db' , ( ) => ( {
56- db : {
57- select : ( ) => ( {
58- from : ( table : unknown ) =>
59- makeBuilder ( table === memberTable ? dbState . members : dbState . providers ) ,
60- } ) ,
61- } ,
62- member : memberTable ,
63- ssoProvider : ssoProviderTable ,
64- } ) )
36+ /**
37+ * Queues existing SSO provider rows for BOTH domain-conflict lookups (the
38+ * pre-registration check and the post-registration re-check).
39+ */
40+ function queueProviders ( rows : Array < Record < string , unknown > > ) {
41+ queueTableRows ( schemaMock . ssoProvider , rows )
42+ queueTableRows ( schemaMock . ssoProvider , rows )
43+ }
6544
6645vi . mock ( '@/lib/auth' , ( ) => ( {
6746 getSession : mockGetSession ,
@@ -109,15 +88,18 @@ function request(body: Record<string, unknown>) {
10988describe ( 'POST /api/auth/sso/register' , ( ) => {
11089 beforeEach ( ( ) => {
11190 vi . clearAllMocks ( )
112- dbState . members = [ ]
113- dbState . providers = [ ]
91+ resetDbChainMock ( )
11492 mockGetSession . mockResolvedValue ( { user : { id : 'u1' } } )
11593 mockHasSSOAccess . mockResolvedValue ( true )
11694 mockValidateUrlWithDNS . mockResolvedValue ( { isValid : true , resolvedIP : '1.2.3.4' } )
11795 mockSecureFetchWithPinnedIP . mockRejectedValue ( new Error ( 'discovery not mocked for this test' ) )
11896 mockRegisterSSOProvider . mockResolvedValue ( { providerId : 'acme-oidc' } )
11997 } )
12098
99+ afterAll ( ( ) => {
100+ resetDbChainMock ( )
101+ } )
102+
121103 it ( 'rejects callers without an Enterprise plan' , async ( ) => {
122104 mockHasSSOAccess . mockResolvedValue ( false )
123105 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org1' } ) )
@@ -126,22 +108,22 @@ describe('POST /api/auth/sso/register', () => {
126108 } )
127109
128110 it ( 'rejects callers who are not an admin/owner of the target org' , async ( ) => {
129- dbState . members = [ { organizationId : 'org1' , role : 'member' } ]
111+ queueMembers ( [ { organizationId : 'org1' , role : 'member' } ] )
130112 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org1' } ) )
131113 expect ( res . status ) . toBe ( 403 )
132114 expect ( mockRegisterSSOProvider ) . not . toHaveBeenCalled ( )
133115 } )
134116
135117 it ( 'rejects an invalid domain' , async ( ) => {
136- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
118+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
137119 const res = await POST ( request ( { ...OIDC_BODY , domain : 'not-a-domain' , orgId : 'org1' } ) )
138120 expect ( res . status ) . toBe ( 400 )
139121 expect ( mockRegisterSSOProvider ) . not . toHaveBeenCalled ( )
140122 } )
141123
142124 it ( 'rejects a domain already registered by another organization' , async ( ) => {
143- dbState . members = [ { organizationId : 'org-attacker' , role : 'owner' } ]
144- dbState . providers = [ { domain : 'acme.com' , userId : 'u-victim' , organizationId : 'org-victim' } ]
125+ queueMembers ( [ { organizationId : 'org-attacker' , role : 'owner' } ] )
126+ queueProviders ( [ { domain : 'acme.com' , userId : 'u-victim' , organizationId : 'org-victim' } ] )
145127 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org-attacker' } ) )
146128 const json = await res . json ( )
147129 expect ( res . status ) . toBe ( 409 )
@@ -150,46 +132,51 @@ describe('POST /api/auth/sso/register', () => {
150132 } )
151133
152134 it ( 'matches conflicts across casing variants' , async ( ) => {
153- dbState . members = [ { organizationId : 'org-attacker' , role : 'owner' } ]
154- dbState . providers = [ { domain : 'ACME.com' , userId : 'u-victim' , organizationId : 'org-victim' } ]
135+ queueMembers ( [ { organizationId : 'org-attacker' , role : 'owner' } ] )
136+ queueProviders ( [ { domain : 'ACME.com' , userId : 'u-victim' , organizationId : 'org-victim' } ] )
155137 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org-attacker' } ) )
156138 expect ( res . status ) . toBe ( 409 )
157139 expect ( mockRegisterSSOProvider ) . not . toHaveBeenCalled ( )
140+ // The conflict lookup itself must be case-insensitive: lower(domain) = <normalized domain>.
141+ const conflictWhere = dbChainMockFns . where . mock . calls . find ( ( [ condition ] ) =>
142+ condition ?. strings ?. join ( '?' ) . includes ( 'lower(' )
143+ )
144+ expect ( conflictWhere ?. [ 0 ] ?. values ) . toContain ( 'acme.com' )
158145 } )
159146
160147 it ( 'registers when the domain is unclaimed' , async ( ) => {
161- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
148+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
162149 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org1' } ) )
163150 expect ( res . status ) . toBe ( 200 )
164151 expect ( mockRegisterSSOProvider ) . toHaveBeenCalledTimes ( 1 )
165152 } )
166153
167154 it ( 'allows the owning tenant to update its own provider for the same domain' , async ( ) => {
168- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
169- dbState . providers = [ { domain : 'acme.com' , userId : 'u1' , organizationId : 'org1' } ]
155+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
156+ queueProviders ( [ { domain : 'acme.com' , userId : 'u1' , organizationId : 'org1' } ] )
170157 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org1' } ) )
171158 expect ( res . status ) . toBe ( 200 )
172159 expect ( mockRegisterSSOProvider ) . toHaveBeenCalledTimes ( 1 )
173160 } )
174161
175162 it ( 'lets an org admin adopt their own user-scoped provider for the same domain' , async ( ) => {
176- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
177- dbState . providers = [ { domain : 'acme.com' , userId : 'u1' , organizationId : null } ]
163+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
164+ queueProviders ( [ { domain : 'acme.com' , userId : 'u1' , organizationId : null } ] )
178165 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org1' } ) )
179166 expect ( res . status ) . toBe ( 200 )
180167 expect ( mockRegisterSSOProvider ) . toHaveBeenCalledTimes ( 1 )
181168 } )
182169
183170 it ( "still blocks an org admin from claiming another user's user-scoped domain" , async ( ) => {
184- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
185- dbState . providers = [ { domain : 'acme.com' , userId : 'someone-else' , organizationId : null } ]
171+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
172+ queueProviders ( [ { domain : 'acme.com' , userId : 'someone-else' , organizationId : null } ] )
186173 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org1' } ) )
187174 expect ( res . status ) . toBe ( 409 )
188175 expect ( mockRegisterSSOProvider ) . not . toHaveBeenCalled ( )
189176 } )
190177
191178 it ( 'normalizes the domain before persisting it' , async ( ) => {
192- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
179+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
193180 const res = await POST ( request ( { ...OIDC_BODY , domain : 'ACME.com' , orgId : 'org1' } ) )
194181 expect ( res . status ) . toBe ( 200 )
195182 expect ( mockRegisterSSOProvider ) . toHaveBeenCalledTimes ( 1 )
@@ -198,23 +185,23 @@ describe('POST /api/auth/sso/register', () => {
198185 } )
199186
200187 it ( 'passes skipDiscovery since Sim already resolved and validated the OIDC endpoints' , async ( ) => {
201- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
188+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
202189 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org1' } ) )
203190 expect ( res . status ) . toBe ( 200 )
204191 const config = mockRegisterSSOProvider . mock . calls [ 0 ] [ 0 ] . body
205192 expect ( config . oidcConfig . skipDiscovery ) . toBe ( true )
206193 } )
207194
208195 it ( 'omits userInfoEndpoint when skipUserInfoEndpoint is requested, forcing ID token claims' , async ( ) => {
209- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
196+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
210197 const res = await POST ( request ( { ...OIDC_BODY , skipUserInfoEndpoint : true , orgId : 'org1' } ) )
211198 expect ( res . status ) . toBe ( 200 )
212199 const config = mockRegisterSSOProvider . mock . calls [ 0 ] [ 0 ] . body
213200 expect ( config . oidcConfig . userInfoEndpoint ) . toBeUndefined ( )
214201 } )
215202
216203 it ( 'does not SSRF-validate userInfoEndpoint when skipUserInfoEndpoint is requested' , async ( ) => {
217- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
204+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
218205 mockValidateUrlWithDNS . mockImplementation ( async ( url : string , label : string ) => {
219206 if ( label === 'OIDC userInfoEndpoint' ) {
220207 return { isValid : false , error : 'resolves to a private IP address' }
@@ -228,7 +215,7 @@ describe('POST /api/auth/sso/register', () => {
228215 } )
229216
230217 it ( 'does not SSRF-validate a discovered userinfo_endpoint when skipUserInfoEndpoint is requested' , async ( ) => {
231- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
218+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
232219 mockValidateUrlWithDNS . mockImplementation ( async ( url : string , label : string ) => {
233220 if ( label === 'OIDC userinfo_endpoint' ) {
234221 return { isValid : false , error : 'resolves to a private IP address' }
@@ -258,15 +245,15 @@ describe('POST /api/auth/sso/register', () => {
258245 } )
259246
260247 it ( 'keeps userInfoEndpoint when skipUserInfoEndpoint is not requested' , async ( ) => {
261- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
248+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
262249 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org1' } ) )
263250 expect ( res . status ) . toBe ( 200 )
264251 const config = mockRegisterSSOProvider . mock . calls [ 0 ] [ 0 ] . body
265252 expect ( config . oidcConfig . userInfoEndpoint ) . toBe ( 'https://idp.acme.com/userinfo' )
266253 } )
267254
268255 it ( 'selects tokenEndpointAuthentication from the discovery document when endpoints are auto-discovered' , async ( ) => {
269- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
256+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
270257 mockSecureFetchWithPinnedIP . mockResolvedValue ( {
271258 ok : true ,
272259 json : async ( ) => ( {
@@ -290,7 +277,7 @@ describe('POST /api/auth/sso/register', () => {
290277 } )
291278
292279 it ( 'still selects tokenEndpointAuthentication from discovery when all endpoints are explicit' , async ( ) => {
293- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
280+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
294281 mockSecureFetchWithPinnedIP . mockResolvedValue ( {
295282 ok : true ,
296283 json : async ( ) => ( {
@@ -305,7 +292,7 @@ describe('POST /api/auth/sso/register', () => {
305292 } )
306293
307294 it ( 'registers successfully when discovery is unreachable and all endpoints are explicit' , async ( ) => {
308- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
295+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
309296 mockSecureFetchWithPinnedIP . mockRejectedValue ( new Error ( 'ECONNREFUSED' ) )
310297 const res = await POST ( request ( { ...OIDC_BODY , orgId : 'org1' } ) )
311298 expect ( res . status ) . toBe ( 200 )
@@ -316,7 +303,7 @@ describe('POST /api/auth/sso/register', () => {
316303 } )
317304
318305 it ( 'prefers client_secret_post over client_secret_basic when an IdP supports both' , async ( ) => {
319- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
306+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
320307 mockSecureFetchWithPinnedIP . mockResolvedValue ( {
321308 ok : true ,
322309 json : async ( ) => ( {
@@ -330,7 +317,7 @@ describe('POST /api/auth/sso/register', () => {
330317 } )
331318
332319 it ( 'defaults to client_secret_post when discovery advertises no auth methods' , async ( ) => {
333- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
320+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
334321 mockSecureFetchWithPinnedIP . mockResolvedValue ( {
335322 ok : true ,
336323 json : async ( ) => ( { } ) ,
@@ -342,7 +329,7 @@ describe('POST /api/auth/sso/register', () => {
342329 } )
343330
344331 it ( 'surfaces the specific discovery failure reason when endpoints are missing' , async ( ) => {
345- dbState . members = [ { organizationId : 'org1' , role : 'owner' } ]
332+ queueMembers ( [ { organizationId : 'org1' , role : 'owner' } ] )
346333 mockValidateUrlWithDNS . mockImplementation ( async ( url : string , label : string ) => {
347334 if ( label === 'OIDC discovery URL' ) {
348335 return { isValid : false , error : 'resolves to a private IP address' }
0 commit comments