1+ import { BadRequestException , ForbiddenException } from '@nestjs/common' ;
12import { BrowserbaseSessionService } from './browserbase-session.service' ;
23import { BrowserAuthProfileService } from './browser-auth-profile.service' ;
34
@@ -10,15 +11,21 @@ jest.mock('@db', () => ({
1011 findFirst : jest . fn ( ) ,
1112 create : jest . fn ( ) ,
1213 update : jest . fn ( ) ,
14+ updateMany : jest . fn ( ) ,
15+ delete : jest . fn ( ) ,
1316 deleteMany : jest . fn ( ) ,
1417 } ,
1518 browserbaseContext : {
1619 findUnique : jest . fn ( ) ,
20+ findFirst : jest . fn ( ) ,
1721 create : jest . fn ( ) ,
1822 update : jest . fn ( ) ,
1923 updateMany : jest . fn ( ) ,
2024 deleteMany : jest . fn ( ) ,
2125 } ,
26+ browserAutomation : {
27+ findMany : jest . fn ( ) ,
28+ } ,
2229 } ,
2330} ) ) ;
2431
@@ -37,6 +44,57 @@ describe('BrowserAuthProfileService', () => {
3744 service = new BrowserAuthProfileService ( sessions ) ;
3845 } ) ;
3946
47+ describe ( 'tenant guards (cross-org IDOR)' , ( ) => {
48+ it ( 'assertContextOwnedByOrg passes when a profile in the org owns the context' , async ( ) => {
49+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( { id : 'bap_1' } ) ;
50+ ( db . browserbaseContext . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
51+ await expect (
52+ service . assertContextOwnedByOrg ( { organizationId : 'org_1' , contextId : 'ctx_1' } ) ,
53+ ) . resolves . toBeUndefined ( ) ;
54+ } ) ;
55+
56+ it ( 'assertContextOwnedByOrg passes when the org-level context row owns it' , async ( ) => {
57+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
58+ ( db . browserbaseContext . findFirst as jest . Mock ) . mockResolvedValue ( { id : 'bbc_1' } ) ;
59+ await expect (
60+ service . assertContextOwnedByOrg ( { organizationId : 'org_1' , contextId : 'ctx_1' } ) ,
61+ ) . resolves . toBeUndefined ( ) ;
62+ } ) ;
63+
64+ it ( 'assertContextOwnedByOrg rejects a context that belongs to another org' , async ( ) => {
65+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
66+ ( db . browserbaseContext . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
67+ await expect (
68+ service . assertContextOwnedByOrg ( { organizationId : 'org_1' , contextId : 'ctx_other' } ) ,
69+ ) . rejects . toBeInstanceOf ( ForbiddenException ) ;
70+ } ) ;
71+
72+ it ( 'assertSessionOwnedByOrg returns the contextId when the org owns the session' , async ( ) => {
73+ jest . spyOn ( sessions , 'getSessionContextId' ) . mockResolvedValue ( 'ctx_1' ) ;
74+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( { id : 'bap_1' } ) ;
75+ ( db . browserbaseContext . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
76+ await expect (
77+ service . assertSessionOwnedByOrg ( { organizationId : 'org_1' , sessionId : 'sess_1' } ) ,
78+ ) . resolves . toBe ( 'ctx_1' ) ;
79+ } ) ;
80+
81+ it ( 'assertSessionOwnedByOrg rejects a session whose context is another org' , async ( ) => {
82+ jest . spyOn ( sessions , 'getSessionContextId' ) . mockResolvedValue ( 'ctx_other' ) ;
83+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
84+ ( db . browserbaseContext . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
85+ await expect (
86+ service . assertSessionOwnedByOrg ( { organizationId : 'org_1' , sessionId : 'sess_x' } ) ,
87+ ) . rejects . toBeInstanceOf ( ForbiddenException ) ;
88+ } ) ;
89+
90+ it ( 'assertSessionOwnedByOrg rejects when the session context cannot be resolved' , async ( ) => {
91+ jest . spyOn ( sessions , 'getSessionContextId' ) . mockResolvedValue ( undefined ) ;
92+ await expect (
93+ service . assertSessionOwnedByOrg ( { organizationId : 'org_1' , sessionId : 'gone' } ) ,
94+ ) . rejects . toBeInstanceOf ( ForbiddenException ) ;
95+ } ) ;
96+ } ) ;
97+
4098 it ( 'normalizes hostname and login identity when creating a profile' , async ( ) => {
4199 ( db . browserAuthProfile . findUnique as jest . Mock ) . mockResolvedValue ( null ) ;
42100 ( db . browserbaseContext . findUnique as jest . Mock ) . mockResolvedValue ( null ) ;
@@ -188,4 +246,217 @@ describe('BrowserAuthProfileService', () => {
188246 where : { organizationId : 'org_1' , contextId : '__PENDING__' } ,
189247 } ) ;
190248 } ) ;
249+
250+ describe ( 'listProfiles' , ( ) => {
251+ it ( 'attaches an automation count per connection by hostname' , async ( ) => {
252+ ( db . browserAuthProfile . findMany as jest . Mock ) . mockResolvedValue ( [
253+ { id : 'bap_gh' , organizationId : 'org_1' , hostname : 'github.com' } ,
254+ { id : 'bap_aws' , organizationId : 'org_1' , hostname : 'aws.amazon.com' } ,
255+ { id : 'bap_dd' , organizationId : 'org_1' , hostname : 'datadoghq.com' } ,
256+ ] ) ;
257+ ( db . browserAutomation . findMany as jest . Mock ) . mockResolvedValue ( [
258+ { targetUrl : 'https://github.com/acme/repo' } ,
259+ { targetUrl : 'https://github.com/acme/other' } ,
260+ { targetUrl : 'https://aws.amazon.com/console' } ,
261+ { targetUrl : 'not-a-url' } , // malformed -> skipped, not fatal
262+ ] ) ;
263+
264+ const result = await service . listProfiles ( 'org_1' ) ;
265+
266+ expect ( db . browserAutomation . findMany ) . toHaveBeenCalledWith ( {
267+ where : { task : { organizationId : 'org_1' } } ,
268+ select : { targetUrl : true } ,
269+ } ) ;
270+ expect ( result . find ( ( p ) => p . id === 'bap_gh' ) ?. automationCount ) . toBe ( 2 ) ;
271+ expect ( result . find ( ( p ) => p . id === 'bap_aws' ) ?. automationCount ) . toBe ( 1 ) ;
272+ expect ( result . find ( ( p ) => p . id === 'bap_dd' ) ?. automationCount ) . toBe ( 0 ) ;
273+ } ) ;
274+ } ) ;
275+
276+ describe ( 'updateProfile / deleteProfile' , ( ) => {
277+ const existing = {
278+ id : 'bap_1' ,
279+ organizationId : 'org_1' ,
280+ hostname : 'app.example.com' ,
281+ displayName : 'Example' ,
282+ status : 'verified' ,
283+ } ;
284+
285+ beforeEach ( ( ) => {
286+ ( db . browserAuthProfile . update as jest . Mock ) . mockImplementation (
287+ ( { data } ) => ( { ...existing , ...data } ) ,
288+ ) ;
289+ ( db . browserAuthProfile . delete as jest . Mock ) . mockResolvedValue ( existing ) ;
290+ } ) ;
291+
292+ it ( 'updates only the display name (trimmed)' , async ( ) => {
293+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( existing ) ;
294+ await service . updateProfile ( {
295+ organizationId : 'org_1' ,
296+ profileId : 'bap_1' ,
297+ displayName : ' New name ' ,
298+ } ) ;
299+ expect ( db . browserAuthProfile . update ) . toHaveBeenCalledWith ( {
300+ where : { id : 'bap_1' } ,
301+ data : { displayName : 'New name' } ,
302+ } ) ;
303+ } ) ;
304+
305+ it ( 'keeps the connection signed in when the URL stays on the same host' , async ( ) => {
306+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( existing ) ;
307+ await service . updateProfile ( {
308+ organizationId : 'org_1' ,
309+ profileId : 'bap_1' ,
310+ url : 'https://app.example.com/account/login' ,
311+ } ) ;
312+ const data = ( db . browserAuthProfile . update as jest . Mock ) . mock . calls [ 0 ] [ 0 ]
313+ . data ;
314+ expect ( data . lastAuthCheckUrl ) . toBe (
315+ 'https://app.example.com/account/login' ,
316+ ) ;
317+ expect ( data . status ) . toBeUndefined ( ) ;
318+ } ) ;
319+
320+ it ( 'marks needs_reauth when the URL moves to a different host' , async ( ) => {
321+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( existing ) ;
322+ await service . updateProfile ( {
323+ organizationId : 'org_1' ,
324+ profileId : 'bap_1' ,
325+ url : 'https://other.com/login' ,
326+ } ) ;
327+ const data = ( db . browserAuthProfile . update as jest . Mock ) . mock . calls [ 0 ] [ 0 ]
328+ . data ;
329+ expect ( data . status ) . toBe ( 'needs_reauth' ) ;
330+ } ) ;
331+
332+ it ( 'deletes the profile' , async ( ) => {
333+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( existing ) ;
334+ const result = await service . deleteProfile ( {
335+ organizationId : 'org_1' ,
336+ profileId : 'bap_1' ,
337+ } ) ;
338+ expect ( db . browserAuthProfile . delete ) . toHaveBeenCalledWith ( {
339+ where : { id : 'bap_1' } ,
340+ } ) ;
341+ expect ( result . success ) . toBe ( true ) ;
342+ } ) ;
343+
344+ it ( 'throws when updating a missing profile' , async ( ) => {
345+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
346+ await expect (
347+ service . updateProfile ( {
348+ organizationId : 'org_1' ,
349+ profileId : 'nope' ,
350+ displayName : 'x' ,
351+ } ) ,
352+ ) . rejects . toThrow ( 'not found' ) ;
353+ } ) ;
354+ } ) ;
355+
356+ describe ( 'recordSignInAttempt' , ( ) => {
357+ it ( 'persists the outcome, detail, and a timestamp, scoped to the org' , async ( ) => {
358+ ( db . browserAuthProfile . updateMany as jest . Mock ) . mockResolvedValue ( {
359+ count : 1 ,
360+ } ) ;
361+
362+ await service . recordSignInAttempt ( {
363+ organizationId : 'org_1' ,
364+ profileId : 'bap_1' ,
365+ outcome : 'needs_2fa' ,
366+ detail : 'The account requires a two-factor code to sign in.' ,
367+ } ) ;
368+
369+ expect ( db . browserAuthProfile . updateMany ) . toHaveBeenCalledWith ( {
370+ where : { id : 'bap_1' , organizationId : 'org_1' } ,
371+ data : expect . objectContaining ( {
372+ lastSignInOutcome : 'needs_2fa' ,
373+ lastSignInDetail : 'The account requires a two-factor code to sign in.' ,
374+ lastSignInAt : expect . any ( Date ) ,
375+ } ) ,
376+ } ) ;
377+ } ) ;
378+
379+ it ( 'stores null detail when none is provided' , async ( ) => {
380+ ( db . browserAuthProfile . updateMany as jest . Mock ) . mockResolvedValue ( {
381+ count : 1 ,
382+ } ) ;
383+
384+ await service . recordSignInAttempt ( {
385+ organizationId : 'org_1' ,
386+ profileId : 'bap_1' ,
387+ outcome : 'logged_in' ,
388+ } ) ;
389+
390+ expect ( db . browserAuthProfile . updateMany ) . toHaveBeenCalledWith (
391+ expect . objectContaining ( {
392+ data : expect . objectContaining ( {
393+ lastSignInOutcome : 'logged_in' ,
394+ lastSignInDetail : null ,
395+ } ) ,
396+ } ) ,
397+ ) ;
398+ } ) ;
399+
400+ it ( 'never throws — a logging write failure cannot break sign-in' , async ( ) => {
401+ ( db . browserAuthProfile . updateMany as jest . Mock ) . mockRejectedValue (
402+ new Error ( 'db down' ) ,
403+ ) ;
404+
405+ await expect (
406+ service . recordSignInAttempt ( {
407+ organizationId : 'org_1' ,
408+ profileId : 'bap_1' ,
409+ outcome : 'error' ,
410+ detail : 'boom' ,
411+ } ) ,
412+ ) . resolves . toBeUndefined ( ) ;
413+ } ) ;
414+ } ) ;
415+
416+ describe ( 'assertUrlMatchesProfileHostname (credential-safety guard)' , ( ) => {
417+ it ( 'passes when the URL host matches the connection hostname' , ( ) => {
418+ expect ( ( ) =>
419+ service . assertUrlMatchesProfileHostname ( {
420+ url : 'https://app.example.com/login' ,
421+ profileHostname : 'app.example.com' ,
422+ } ) ,
423+ ) . not . toThrow ( ) ;
424+ } ) ;
425+
426+ it ( 'rejects a URL on a different host' , ( ) => {
427+ expect ( ( ) =>
428+ service . assertUrlMatchesProfileHostname ( {
429+ url : 'https://evil.example.net/login' ,
430+ profileHostname : 'app.example.com' ,
431+ } ) ,
432+ ) . toThrow ( BadRequestException ) ;
433+ } ) ;
434+
435+ it ( 'rejects an unparseable URL' , ( ) => {
436+ expect ( ( ) =>
437+ service . assertUrlMatchesProfileHostname ( {
438+ url : 'not a url' ,
439+ profileHostname : 'app.example.com' ,
440+ } ) ,
441+ ) . toThrow ( BadRequestException ) ;
442+ } ) ;
443+ } ) ;
444+
445+ describe ( 'resolveProfileForTarget (explicit profileId host binding)' , ( ) => {
446+ it ( 'rejects an explicit profile whose host does not match the target URL' , async ( ) => {
447+ ( db . browserAuthProfile . findFirst as jest . Mock ) . mockResolvedValue ( {
448+ id : 'bap_1' ,
449+ organizationId : 'org_1' ,
450+ hostname : 'app.example.com' ,
451+ } ) ;
452+
453+ await expect (
454+ service . resolveProfileForTarget ( {
455+ organizationId : 'org_1' ,
456+ targetUrl : 'https://evil.example.net/app' ,
457+ profileId : 'bap_1' ,
458+ } ) ,
459+ ) . rejects . toBeInstanceOf ( BadRequestException ) ;
460+ } ) ;
461+ } ) ;
191462} ) ;
0 commit comments