@@ -3659,3 +3659,194 @@ describe('getPublicConfig devSeedAdmin (dev-only login hint)', () => {
36593659 expect ( ( manager . getPublicConfig ( ) as any ) . devSeedAdmin ) . toBeUndefined ( ) ;
36603660 } ) ;
36613661} ) ;
3662+
3663+ // ---------------------------------------------------------------------------
3664+ // [#5942] `isOrgOrPlatformAdmin` — the ADR-0024 `/sso/register` admin gate's
3665+ // criterion — asks "does this membership administer the org" through the ONE
3666+ // grade ladder (`isOrgAdminGrade`, `invitation-role-cap.ts`), not a hand-copied
3667+ // `role === 'owner' || role === 'admin'`.
3668+ //
3669+ // The hand-copy it replaces did `.split(',').map(trim).some(=== 'owner' ||
3670+ // === 'admin')` — case-SENSITIVE, and blind to the array spelling. The grade
3671+ // ladder additionally `.toLowerCase()`s and joins arrays, so the two answered
3672+ // differently on `Owner` / `ADMIN` / `['owner']`: this gate refused a real
3673+ // administrator (false negative) while the break-glass ban guard
3674+ // (`last-admin-ban-guard.ts`, same ladder) counted the same row AS an
3675+ // administrator. Two spellings of one security question, diverging silently.
3676+ //
3677+ // Direction of the change, measured (see the PR body): every difference is a
3678+ // WIDENING, and only over values the old spelling judged wrongly. There is no
3679+ // value that was admin before and is not admin now — the closed ADR-0108
3680+ // vocabulary (all lowercase) answers identically on both sides, which is why
3681+ // no user could hit this today.
3682+ //
3683+ // NOTE on `' admin '`: it is a regression pin, NOT a before-red case. The
3684+ // hand-copy already trimmed, so it answered `true` before the change too. Only
3685+ // the CASE and ARRAY spellings actually move.
3686+ //
3687+ // The platform-admin half of this method is deliberately untouched (#5942 is
3688+ // scoped to the org ruler); the platform-admin cases below pin that.
3689+ // ---------------------------------------------------------------------------
3690+ describe ( 'isOrgOrPlatformAdmin – one grade ruler for "is this membership an admin" (#5942)' , ( ) => {
3691+ const SECRET = 'test-secret-at-least-32-chars-long' ;
3692+
3693+ /**
3694+ * Read-only engine stub: `members` are the `sys_member` rows, `platformAdmin`
3695+ * controls the org-less `admin_full_access` link. `find` honours the `where`
3696+ * the gate actually passes (`user_id`, and `organization_id` when an active
3697+ * org is set) so the org-scoping half is the product's, not the fixture's.
3698+ */
3699+ const makeEngine = ( opts : { members ?: any [ ] ; platformAdmin ?: boolean ; throws ?: boolean } = { } ) => ( {
3700+ find : vi . fn ( async ( object : string , query ?: any ) => {
3701+ if ( opts . throws ) throw new Error ( 'db down' ) ;
3702+ if ( object === 'sys_user_permission_set' ) {
3703+ return opts . platformAdmin
3704+ ? [ { user_id : 'u-1' , permission_set_id : 'ps-admin' , organization_id : null } ]
3705+ : [ ] ;
3706+ }
3707+ if ( object === 'sys_permission_set' ) return [ { id : 'ps-admin' , name : 'admin_full_access' } ] ;
3708+ if ( object === 'sys_member' ) {
3709+ const where = query ?. where ?? { } ;
3710+ return ( opts . members ?? [ ] ) . filter ( ( row ) =>
3711+ Object . entries ( where ) . every ( ( [ k , v ] ) => row [ k ] === v ) ,
3712+ ) ;
3713+ }
3714+ return [ ] ;
3715+ } ) ,
3716+ findOne : vi . fn ( ) ,
3717+ } ) ;
3718+
3719+ /** The gate's criterion, invoked exactly as the `/sso/register` hook does. */
3720+ const judge = async (
3721+ engine : any ,
3722+ activeOrgId ?: string ,
3723+ userId = 'u-1' ,
3724+ ) : Promise < boolean > => {
3725+ const warn = vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ;
3726+ const manager = new AuthManager ( {
3727+ secret : SECRET ,
3728+ baseUrl : 'http://localhost:3000' ,
3729+ dataEngine : engine ,
3730+ } ) ;
3731+ warn . mockRestore ( ) ;
3732+ return ( manager as any ) . isOrgOrPlatformAdmin ( userId , activeOrgId ) ;
3733+ } ;
3734+
3735+ const memberRow = ( role : unknown ) => ( {
3736+ id : 'm-1' ,
3737+ user_id : 'u-1' ,
3738+ organization_id : 'org-1' ,
3739+ role,
3740+ } ) ;
3741+
3742+ // -- (1) the fix itself: values the hand-copy refused, the ladder admits ----
3743+ describe ( 'case-insensitive + array spellings (before: refused, after: admitted)' , ( ) => {
3744+ it . each ( [
3745+ [ 'Owner' , 'better-auth owner, capitalized by an import' ] ,
3746+ [ 'ADMIN' , 'shout-cased by a hand-written SQL insert' ] ,
3747+ [ ' Admin ' , 'padded AND capitalized' ] ,
3748+ [ 'OWNER' , 'shout-cased owner' ] ,
3749+ [ 'member,Owner' , 'comma-joined with one capitalized administrative role' ] ,
3750+ ] ) ( 'grades %j as an administrator (%s)' , async ( role ) => {
3751+ expect ( await judge ( makeEngine ( { members : [ memberRow ( role ) ] } ) , 'org-1' ) ) . toBe ( true ) ;
3752+ } ) ;
3753+
3754+ it ( 'grades the ARRAY spelling ["owner"] as an administrator' , async ( ) => {
3755+ // The hand-copy read `typeof m.role === 'string' ? m.role : ''`, so any
3756+ // array-valued role graded as nothing at all.
3757+ expect ( await judge ( makeEngine ( { members : [ memberRow ( [ 'owner' ] ) ] } ) , 'org-1' ) ) . toBe ( true ) ;
3758+ } ) ;
3759+
3760+ it ( 'grades the ARRAY spelling ["member","Admin"] as an administrator' , async ( ) => {
3761+ expect (
3762+ await judge ( makeEngine ( { members : [ memberRow ( [ 'member' , 'Admin' ] ) ] } ) , 'org-1' ) ,
3763+ ) . toBe ( true ) ;
3764+ } ) ;
3765+ } ) ;
3766+
3767+ // -- (2) regression: the closed ADR-0108 vocabulary answers identically -----
3768+ describe ( 'closed membership vocabulary (ADR-0108) — unchanged by the new ruler' , ( ) => {
3769+ it . each ( [
3770+ [ 'owner' , true ] ,
3771+ [ 'admin' , true ] ,
3772+ [ 'delegated_admin' , false ] ,
3773+ [ 'member' , false ] ,
3774+ ] as const ) ( 'grades the built-in %j as admin=%s' , async ( role , expected ) => {
3775+ expect ( await judge ( makeEngine ( { members : [ memberRow ( role ) ] } ) , 'org-1' ) ) . toBe ( expected ) ;
3776+ } ) ;
3777+
3778+ it . each ( [
3779+ [ 'owner,member' , true ] ,
3780+ [ 'member,admin' , true ] ,
3781+ [ ' admin ' , true ] ,
3782+ [ 'member,delegated_admin' , false ] ,
3783+ ] as const ) (
3784+ 'grades the comma/whitespace spelling %j as admin=%s (already true before #5942)' ,
3785+ async ( role , expected ) => {
3786+ expect ( await judge ( makeEngine ( { members : [ memberRow ( role ) ] } ) , 'org-1' ) ) . toBe ( expected ) ;
3787+ } ,
3788+ ) ;
3789+ } ) ;
3790+
3791+ // -- (3) non-administrative values still refused (no widening past admin) ---
3792+ describe ( 'fail-closed floor — nothing else is admitted' , ( ) => {
3793+ it . each ( [
3794+ [ 'manager' , 'an app-registered name that is not an administrative grade' ] ,
3795+ [ 'administrator' , 'a near-miss that is not the vocabulary' ] ,
3796+ [ 'adminx' , 'a prefix collision' ] ,
3797+ [ '' , 'an empty role' ] ,
3798+ ] ) ( 'refuses %j (%s)' , async ( role ) => {
3799+ expect ( await judge ( makeEngine ( { members : [ memberRow ( role ) ] } ) , 'org-1' ) ) . toBe ( false ) ;
3800+ } ) ;
3801+
3802+ it . each ( [
3803+ [ null , 'null' ] ,
3804+ [ undefined , 'undefined' ] ,
3805+ [ 42 , 'a number' ] ,
3806+ [ { role : 'owner' } , 'an object that merely mentions owner' ] ,
3807+ ] ) ( 'refuses a non-string role (%s: %s)' , async ( role ) => {
3808+ expect ( await judge ( makeEngine ( { members : [ memberRow ( role ) ] } ) , 'org-1' ) ) . toBe ( false ) ;
3809+ } ) ;
3810+
3811+ it ( 'refuses when the user has no membership row at all' , async ( ) => {
3812+ expect ( await judge ( makeEngine ( { members : [ ] } ) , 'org-1' ) ) . toBe ( false ) ;
3813+ } ) ;
3814+
3815+ it ( 'refuses when the engine read throws (fail CLOSED — ADR-0024)' , async ( ) => {
3816+ expect ( await judge ( makeEngine ( { throws : true } ) , 'org-1' ) ) . toBe ( false ) ;
3817+ } ) ;
3818+ } ) ;
3819+
3820+ // -- (4) org scoping and the untouched platform-admin half -----------------
3821+ describe ( 'scoping and the platform-admin half (untouched by #5942)' , ( ) => {
3822+ it ( 'judges only the ACTIVE org when one is set' , async ( ) => {
3823+ const engine = makeEngine ( {
3824+ members : [
3825+ { id : 'm-1' , user_id : 'u-1' , organization_id : 'org-other' , role : 'Owner' } ,
3826+ { id : 'm-2' , user_id : 'u-1' , organization_id : 'org-1' , role : 'member' } ,
3827+ ] ,
3828+ } ) ;
3829+ // Administrative elsewhere, plain member here → refused for org-1 …
3830+ expect ( await judge ( engine , 'org-1' ) ) . toBe ( false ) ;
3831+ // … and admitted when that other org is the active one.
3832+ expect ( await judge ( engine , 'org-other' ) ) . toBe ( true ) ;
3833+ } ) ;
3834+
3835+ it ( 'accepts an administrative membership in ANY org when no active org is set' , async ( ) => {
3836+ const engine = makeEngine ( {
3837+ members : [ { id : 'm-1' , user_id : 'u-1' , organization_id : 'org-other' , role : 'ADMIN' } ] ,
3838+ } ) ;
3839+ expect ( await judge ( engine , undefined ) ) . toBe ( true ) ;
3840+ } ) ;
3841+
3842+ it ( 'still admits a platform admin whose membership is a plain member' , async ( ) => {
3843+ const engine = makeEngine ( { platformAdmin : true , members : [ memberRow ( 'member' ) ] } ) ;
3844+ expect ( await judge ( engine , 'org-1' ) ) . toBe ( true ) ;
3845+ } ) ;
3846+
3847+ it ( 'still refuses a non-platform-admin with no administrative membership' , async ( ) => {
3848+ const engine = makeEngine ( { platformAdmin : false , members : [ memberRow ( 'member' ) ] } ) ;
3849+ expect ( await judge ( engine , 'org-1' ) ) . toBe ( false ) ;
3850+ } ) ;
3851+ } ) ;
3852+ } ) ;
0 commit comments