1+ import { ORMError } from '@zenstackhq/orm' ;
12import { PolicyPlugin } from '@zenstackhq/plugin-policy' ;
23import { createPolicyTestClient , createTestClient } from '@zenstackhq/testtools' ;
34import { describe , expect , it } from 'vitest' ;
@@ -10,7 +11,11 @@ describe('Policy error code tests', () => {
1011 // │ allow rule fails (string code) │ ✓ │ ✓ │ ✓ │ ✓ │ ✓ │
1112 // │ constant deny (true condition) │ ✓ │ │ │ │ │
1213 // │ no errorCode on rule │ ✓ │ │ │ │ │
13- // │ code opt-in: NotFound vs RejectedByPol. │ │ │ ✓ │ ✓ │ ✓ │
14+ // │ policyCodes undefined when no codes │ ✓ │ │ │ │ │
15+ // │ code opt-in: NotFound vs RejByPol. │ │ │ ✓ │ ✓ │ │
16+ // │ findFirst/findUnique: always null │ │ │ │ │ ✓ │
17+ // │ findXOrThrow: deny/allow rule fires │ │ │ │ │ ✓ │
18+ // │ findXOrThrow: NOT_FOUND vs RejByPol. │ │ │ │ │ ✓ │
1419 // │ findMany not affected (filter-based) │ │ │ │ │ ✓ │
1520 // │ findMany({ take:1 }) not affected │ │ │ │ │ ✓ │
1621 // │ multiple deny rules fire │ ✓ │ ✓ │ │ │ │
@@ -75,6 +80,26 @@ describe('Policy error code tests', () => {
7580 await expect ( db . foo . create ( { data : { x : 0 } } ) ) . toBeRejectedByPolicy ( undefined , [ ] ) ;
7681 } ) ;
7782
83+ it ( 'policyCodes is undefined (not []) when no error codes are configured' , async ( ) => {
84+ const db = await createPolicyTestClient (
85+ `
86+ model Foo {
87+ id Int @id @default(autoincrement())
88+ x Int
89+ @@deny('create', x <= 0)
90+ @@allow('create,read', true)
91+ }
92+ ` ,
93+ ) ;
94+ try {
95+ await db . foo . create ( { data : { x : 0 } } ) ;
96+ expect . fail ( 'expected error' ) ;
97+ } catch ( err ) {
98+ expect ( err ) . toBeInstanceOf ( ORMError ) ;
99+ expect ( ( err as ORMError ) . policyCodes ) . toBeUndefined ( ) ;
100+ }
101+ } ) ;
102+
78103 // ── opt-in: adding a code changes error type from NotFound to RejectedByPolicy ──
79104
80105 it ( 'blocked update/delete yields NotFound without code, RejectedByPolicy with code' , async ( ) => {
@@ -104,7 +129,27 @@ model Foo {
104129
105130 // ── read: single rule, single code ───────────────────────────────────────
106131
107- it ( 'surfaces code from deny/allow rule on findFirst/findUnique violation' , async ( ) => {
132+ it ( 'findFirst/findUnique always return null on policy violation, never throw' , async ( ) => {
133+ const db = await createPolicyTestClient (
134+ `
135+ model Foo {
136+ id Int @id @default(autoincrement())
137+ x Int
138+ @@allow('create', true)
139+ @@allow('read', x > 0, 'NEED_POSITIVE_X')
140+ }
141+ ` ,
142+ ) ;
143+ const blocked = await db . $unuseAll ( ) . foo . create ( { data : { x : 0 } } ) ;
144+ await expect ( db . foo . findFirst ( { where : { id : blocked . id } } ) ) . resolves . toBeNull ( ) ;
145+ await expect ( db . foo . findUnique ( { where : { id : blocked . id } } ) ) . resolves . toBeNull ( ) ;
146+ // happy path
147+ const visible = await db . $unuseAll ( ) . foo . create ( { data : { x : 1 } } ) ;
148+ await expect ( db . foo . findFirst ( { where : { id : visible . id } } ) ) . resolves . toMatchObject ( { x : 1 } ) ;
149+ await expect ( db . foo . findUnique ( { where : { id : visible . id } } ) ) . resolves . toMatchObject ( { x : 1 } ) ;
150+ } ) ;
151+
152+ it ( 'surfaces code from deny/allow rule on findFirstOrThrow/findUniqueOrThrow violation' , async ( ) => {
108153 const db = await createPolicyTestClient (
109154 `
110155 model Foo {
@@ -123,17 +168,17 @@ model Foo {
123168 const positiveXY = await unprotected . foo . create ( { data : { x : 1 , y : 1 } } ) ;
124169
125170 // deny code: x <= 0 triggers deny rule
126- await expect ( db . foo . findFirst ( { where : { id : zeroX . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEGATIVE_X' ] ) ;
127- await expect ( db . foo . findUnique ( { where : { id : zeroX . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEGATIVE_X' ] ) ;
171+ await expect ( db . foo . findFirstOrThrow ( { where : { id : zeroX . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEGATIVE_X' ] ) ;
172+ await expect ( db . foo . findUniqueOrThrow ( { where : { id : zeroX . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEGATIVE_X' ] ) ;
128173 // allow code: y is not > 0 so allow rule fails
129- await expect ( db . foo . findFirst ( { where : { id : zeroY . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEED_POSITIVE_Y' ] ) ;
130- await expect ( db . foo . findUnique ( { where : { id : zeroY . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEED_POSITIVE_Y' ] ) ;
174+ await expect ( db . foo . findFirstOrThrow ( { where : { id : zeroY . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEED_POSITIVE_Y' ] ) ;
175+ await expect ( db . foo . findUniqueOrThrow ( { where : { id : zeroY . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEED_POSITIVE_Y' ] ) ;
131176 // happy path
132- await expect ( db . foo . findFirst ( { where : { id : positiveXY . id } } ) ) . resolves . toMatchObject ( { x : 1 , y : 1 } ) ;
133- await expect ( db . foo . findUnique ( { where : { id : positiveXY . id } } ) ) . resolves . toMatchObject ( { x : 1 , y : 1 } ) ;
177+ await expect ( db . foo . findFirstOrThrow ( { where : { id : positiveXY . id } } ) ) . resolves . toMatchObject ( { x : 1 , y : 1 } ) ;
178+ await expect ( db . foo . findUniqueOrThrow ( { where : { id : positiveXY . id } } ) ) . resolves . toMatchObject ( { x : 1 , y : 1 } ) ;
134179 } ) ;
135180
136- it ( 'blocked read yields null without code, RejectedByPolicy with code' , async ( ) => {
181+ it ( 'blocked findFirstOrThrow/findUniqueOrThrow yields NOT_FOUND without code, REJECTED_BY_POLICY with code' , async ( ) => {
137182 const schema = ( withCode : boolean ) => `
138183model Foo {
139184 id Int @id @default(autoincrement())
@@ -142,19 +187,19 @@ model Foo {
142187 @@allow('read', x > 0${ withCode ? ", 'NEED_POSITIVE_X'" : '' } )
143188}
144189` ;
145- // Without error code: policy filters the row silently → null (not found )
190+ // Without error code: policy filters the row silently → NOT_FOUND (orThrow always throws )
146191 const dbNoCode = await createPolicyTestClient ( schema ( false ) ) ;
147192 const noCodeRow = await dbNoCode . $unuseAll ( ) . foo . create ( { data : { x : 0 } } ) ;
148- await expect ( dbNoCode . foo . findUnique ( { where : { id : noCodeRow . id } } ) ) . resolves . toBeNull ( ) ;
149- await expect ( dbNoCode . foo . findFirst ( { where : { id : noCodeRow . id } } ) ) . resolves . toBeNull ( ) ;
193+ await expect ( dbNoCode . foo . findUniqueOrThrow ( { where : { id : noCodeRow . id } } ) ) . toBeRejectedNotFound ( ) ;
194+ await expect ( dbNoCode . foo . findFirstOrThrow ( { where : { id : noCodeRow . id } } ) ) . toBeRejectedNotFound ( ) ;
150195
151- // With error code: the plugin detects the policy block → RejectedByPolicy
196+ // With error code: the plugin detects the policy block → REJECTED_BY_POLICY
152197 const dbWithCode = await createPolicyTestClient ( schema ( true ) ) ;
153198 const withCodeRow = await dbWithCode . $unuseAll ( ) . foo . create ( { data : { x : 0 } } ) ;
154- await expect ( dbWithCode . foo . findUnique ( { where : { id : withCodeRow . id } } ) ) . toBeRejectedByPolicy ( undefined , [
199+ await expect ( dbWithCode . foo . findUniqueOrThrow ( { where : { id : withCodeRow . id } } ) ) . toBeRejectedByPolicy ( undefined , [
155200 'NEED_POSITIVE_X' ,
156201 ] ) ;
157- await expect ( dbWithCode . foo . findFirst ( { where : { id : withCodeRow . id } } ) ) . toBeRejectedByPolicy ( undefined , [
202+ await expect ( dbWithCode . foo . findFirstOrThrow ( { where : { id : withCodeRow . id } } ) ) . toBeRejectedByPolicy ( undefined , [
158203 'NEED_POSITIVE_X' ,
159204 ] ) ;
160205 } ) ;
@@ -653,8 +698,10 @@ model Foo {
653698 await expect ( db . foo . create ( { data : { x : 1 , y : 0 } } ) ) . toBeRejectedByPolicy ( undefined , [ ] ) ;
654699 const positiveX = await db . foo . create ( { data : { x : 1 , y : 1 } } ) ;
655700 const negX = await db . $unuseAll ( ) . foo . create ( { data : { x : - 1 , y : 1 } } ) ;
656- // read: diagnostic query skipped entirely — behaves as if no codes → null (filter-based)
701+ // read: diagnostic query skipped entirely — behaves as if no codes → null/NOT_FOUND
657702 await expect ( db . foo . findFirst ( { where : { id : negX . id } } ) ) . resolves . toBeNull ( ) ;
703+ await expect ( db . foo . findFirstOrThrow ( { where : { id : negX . id } } ) ) . toBeRejectedNotFound ( ) ;
704+ await expect ( db . foo . findUniqueOrThrow ( { where : { id : negX . id } } ) ) . toBeRejectedNotFound ( ) ;
658705 // update/delete: diagnostic query skipped entirely — behaves as if no codes → NOT_FOUND
659706 await expect ( db . foo . update ( { where : { id : negX . id } , data : { x : 0 } } ) ) . toBeRejectedNotFound ( ) ;
660707 await expect ( db . foo . delete ( { where : { id : negX . id } } ) ) . toBeRejectedNotFound ( ) ;
@@ -693,10 +740,14 @@ model Foo {
693740 await expect ( db . foo . create ( { data : { x : 1 , y : 0 } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEGATIVE_Y_CREATE' ] ) ;
694741 const positiveX = await db . foo . create ( { data : { x : 1 , y : 1 } } ) ;
695742 const negX = await db . $unuseAll ( ) . foo . create ( { data : { x : - 1 , y : 1 } } ) ;
696- // read: flag skips diagnostic query entirely → null (filter-based, same as no codes)
743+ // read: flag skips diagnostic query entirely → null/NOT_FOUND (filter-based, same as no codes)
697744 await expect ( db . foo . findFirst ( { where : { id : negX . id } , fetchPolicyCodes : false } ) ) . resolves . toBeNull ( ) ;
698- // read: without flag, codes surface
699- await expect ( db . foo . findFirst ( { where : { id : negX . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEGATIVE_X_READ' ] ) ;
745+ await expect ( db . foo . findFirstOrThrow ( { where : { id : negX . id } , fetchPolicyCodes : false } ) ) . toBeRejectedNotFound ( ) ;
746+ await expect ( db . foo . findUniqueOrThrow ( { where : { id : negX . id } , fetchPolicyCodes : false } ) ) . toBeRejectedNotFound ( ) ;
747+ // read: findFirst always returns null; OrThrow variants surface codes
748+ await expect ( db . foo . findFirst ( { where : { id : negX . id } } ) ) . resolves . toBeNull ( ) ;
749+ await expect ( db . foo . findFirstOrThrow ( { where : { id : negX . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEGATIVE_X_READ' ] ) ;
750+ await expect ( db . foo . findUniqueOrThrow ( { where : { id : negX . id } } ) ) . toBeRejectedByPolicy ( undefined , [ 'NEGATIVE_X_READ' ] ) ;
700751 // update: flag skips diagnostic query entirely → NOT_FOUND (same as no codes defined)
701752 await expect ( db . foo . update ( { where : { id : negX . id } , data : { x : 0 } , fetchPolicyCodes : false } ) ) . toBeRejectedNotFound ( ) ;
702753 // update: without flag, codes surface
@@ -785,12 +836,18 @@ model Foo {
785836 await expect ( db . foo . create ( { data : { x : 1 , y : 0 } } ) ) . toBeRejectedByPolicy ( undefined , [ ] ) ;
786837 const positiveX = await db . foo . create ( { data : { x : 1 , y : 1 } } ) ;
787838 const negX = await db . $unuseAll ( ) . foo . create ( { data : { x : - 1 , y : 1 } } ) ;
788- // read: query-level true re-enables codes despite plugin false
789- await expect ( db . foo . findFirst ( { where : { id : negX . id } , fetchPolicyCodes : true } ) ) . toBeRejectedByPolicy ( undefined , [
839+ // read: findFirst always returns null; query-level true re-enables codes for OrThrow variants
840+ await expect ( db . foo . findFirst ( { where : { id : negX . id } , fetchPolicyCodes : true } ) ) . resolves . toBeNull ( ) ;
841+ await expect ( db . foo . findFirstOrThrow ( { where : { id : negX . id } , fetchPolicyCodes : true } ) ) . toBeRejectedByPolicy ( undefined , [
842+ 'NEGATIVE_X_READ' ,
843+ ] ) ;
844+ await expect ( db . foo . findUniqueOrThrow ( { where : { id : negX . id } , fetchPolicyCodes : true } ) ) . toBeRejectedByPolicy ( undefined , [
790845 'NEGATIVE_X_READ' ,
791846 ] ) ;
792- // read: without override, codes are suppressed → null (filter-based)
847+ // read: without override, codes are suppressed → null/NOT_FOUND (filter-based)
793848 await expect ( db . foo . findFirst ( { where : { id : negX . id } } ) ) . resolves . toBeNull ( ) ;
849+ await expect ( db . foo . findFirstOrThrow ( { where : { id : negX . id } } ) ) . toBeRejectedNotFound ( ) ;
850+ await expect ( db . foo . findUniqueOrThrow ( { where : { id : negX . id } } ) ) . toBeRejectedNotFound ( ) ;
794851 // update: query-level true re-enables codes despite plugin false
795852 await expect ( db . foo . update ( { where : { id : negX . id } , data : { x : 0 } , fetchPolicyCodes : true } ) ) . toBeRejectedByPolicy (
796853 undefined ,
0 commit comments