diff --git a/package.json b/package.json index 58c1f0f23..a51aa74aa 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index 15496fa2c..d84e5788d 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/better-auth", "displayName": "ZenStack Better Auth Adapter", "description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index a7d012d1c..dfdf7436d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/cli", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index e46a1ebe0..e48265ca6 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/client-helpers", "displayName": "ZenStack Client Helpers", "description": "Helpers for implementing clients that consume ZenStack's CRUD service", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index 0e9b30052..8b3ce4a11 100644 --- a/packages/clients/fetch-client/package.json +++ b/packages/clients/fetch-client/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/fetch-client", "displayName": "ZenStack Fetch Client", "description": "Simple fetch-based client for consuming ZenStack's RPC-style CRUD API", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index dfa4eeeb3..b3ad06d78 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack TanStack Query Integration", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index da71da33c..072a7eb1f 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/common-helpers", "displayName": "ZenStack Common Helpers", "description": "ZenStack Common Helpers", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index dea370a18..90ca80633 100644 --- a/packages/config/eslint-config/package.json +++ b/packages/config/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index 43a85deea..0689946fe 100644 --- a/packages/config/tsdown-config/package.json +++ b/packages/config/tsdown-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tsdown-config", - "version": "3.9.5", + "version": "3.9.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 5854f65ea..28ff30647 100644 --- a/packages/config/typescript-config/package.json +++ b/packages/config/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.9.5", + "version": "3.9.6", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index bb68069e6..ff8ef0928 100644 --- a/packages/config/vitest-config/package.json +++ b/packages/config/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.9.5", + "version": "3.9.6", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 2a111c2b9..09efe1929 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -2,7 +2,7 @@ "name": "create-zenstack", "displayName": "Create ZenStack", "description": "Create a new ZenStack project", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 1574d0d24..be8104e15 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.9.5", + "version": "3.9.6", "displayName": "ZenStack V3 Language Tools", "description": "VSCode extension for ZenStack (v3) ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index b47b6ab7a..ffb090fa7 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/language", "displayName": "ZenStack Language Tooling", "description": "ZenStack ZModel language specification", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/package.json b/packages/orm/package.json index 28bac3da5..2a98043fa 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/orm", "displayName": "ZenStack ORM", "description": "ZenStack ORM", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/src/client/executor/name-mapper.ts b/packages/orm/src/client/executor/name-mapper.ts index 3157537c9..7cb5c72ec 100644 --- a/packages/orm/src/client/executor/name-mapper.ts +++ b/packages/orm/src/client/executor/name-mapper.ts @@ -17,6 +17,7 @@ import { type OrderByItemNode, PrimitiveValueListNode, type QueryId, + RawNode, ReferenceNode, ReturningNode, SelectAllNode, @@ -810,6 +811,15 @@ export class QueryNameMapper extends OperationNodeTransformer { if (mappedValue) { return mappedValue; } + } else if ( + this.isOperationNode(value) && + ValueNode.is(value) && + Array.isArray(value.value) && + value.value.every((v) => typeof v === 'string') + ) { + return ValueNode.create(value.value.map((v) => enumValueMapping[v] ?? v)); + } else if (Array.isArray(value) && value.every((v) => typeof v === 'string')) { + return value.map((v) => enumValueMapping[v] ?? v); } return value; @@ -839,23 +849,39 @@ export class QueryNameMapper extends OperationNodeTransformer { return selection; } + const shouldUseArray = fieldDef.array; + const eb = expressionBuilder(); - const caseBuilder = eb.case(); + const caseNode = shouldUseArray ? ColumnNode.create('t') : node; let caseWhen: CaseWhenBuilder | undefined; + for (const [key, value] of Object.entries(enumValueMapping)) { if (!caseWhen) { - caseWhen = caseBuilder.when(new ExpressionWrapper(node), '=', value).then(key); + caseWhen = eb.case().when(new ExpressionWrapper(caseNode), '=', value).then(key); } else { - caseWhen = caseWhen.when(new ExpressionWrapper(node), '=', value).then(key); + caseWhen = caseWhen.when(new ExpressionWrapper(caseNode), '=', value).then(key); } } // the explicit cast to "text" is needed to address postgres's case-when type inference issue - const finalExpr = caseWhen!.else(this.dialect.castText(new ExpressionWrapper(node))).end(); + const caseExpr = caseWhen!.else(this.dialect.castText(new ExpressionWrapper(caseNode))).end(); + + if (!shouldUseArray) { + if (aliasName) { + return caseExpr.as(aliasName).toOperationNode() as SelectionNodeChild; + } else { + return caseExpr.toOperationNode() as SelectionNodeChild; + } + } + + const arrayExpr = new ExpressionWrapper( + RawNode.create(['ARRAY(SELECT ', ' FROM unnest( ', ' ) AS t)'], [caseExpr.toOperationNode(), node]), + ); + if (aliasName) { - return finalExpr.as(aliasName).toOperationNode() as SelectionNodeChild; + return arrayExpr.as(aliasName).toOperationNode() as SelectionNodeChild; } else { - return finalExpr.toOperationNode() as SelectionNodeChild; + return arrayExpr.toOperationNode() as SelectionNodeChild; } } diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 41067c2fc..979738300 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-policy", "displayName": "ZenStack Access Policy Plugin", "description": "ZenStack plugin that enforces access control policies defined in the schema", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index b84c39cd4..b192d72b5 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -13,6 +13,7 @@ import { AndNode, BinaryOperationNode, ColumnNode, + DefaultInsertValueNode, DeleteQueryNode, expressionBuilder, ExpressionWrapper, @@ -33,6 +34,7 @@ import { sql, TableNode, UpdateQueryNode, + ValueListNode, ValueNode, ValuesNode, WhereNode, @@ -921,6 +923,16 @@ export class PolicyHandler extends OperationNodeTransf const valueRows = node.values ? this.unwrapCreateValueRows(node.values, mutationModel, fields, isManyToManyJoinTable) : [[]]; + + if (!isManyToManyJoinTable) { + // A create filter built only from `auth()` doesn't reference the row being inserted, + // so it holds for every row alike and checking it per row re-asks the same question. + const filter = this.buildPolicyFilter(mutationModel, undefined, 'create'); + if (isTrueNode(filter)) { + return; + } + } + for (const values of valueRows) { if (isManyToManyJoinTable) { await this.enforcePreCreatePolicyForManyToManyJoinTable( @@ -1060,10 +1072,16 @@ export class PolicyHandler extends OperationNodeTransf fields: string[], isManyToManyJoinTable: boolean, ) { + // Whether row items are operation nodes is determined by the row node's kind: + // `ValueListNode` items are always nodes, `PrimitiveValueListNode` items are always + // raw values. Never inspect an item's shape, because user data (e.g., Json values) + // may legitimately carry a `kind` key. if (ValuesNode.is(node)) { - return node.values.map((v) => this.unwrapCreateValueRow(v.values, model, fields, isManyToManyJoinTable)); + return node.values.map((v) => + this.unwrapCreateValueRow(v.values, model, fields, isManyToManyJoinTable, ValueListNode.is(v)), + ); } else if (PrimitiveValueListNode.is(node)) { - return [this.unwrapCreateValueRow(node.values, model, fields, isManyToManyJoinTable)]; + return [this.unwrapCreateValueRow(node.values, model, fields, isManyToManyJoinTable, false)]; } else { invariant(false, `Unexpected node kind: ${node.kind} for unwrapping create values`); } @@ -1074,27 +1092,25 @@ export class PolicyHandler extends OperationNodeTransf model: string, fields: string[], isImplicitManyToManyJoinTable: boolean, + itemsAreNodes: boolean, ) { invariant(data.length === fields.length, 'data length must match fields length'); const result: { node: OperationNode; raw: unknown }[] = []; for (let i = 0; i < data.length; i++) { const item = data[i]!; - if (typeof item === 'object' && item && 'kind' in item) { - if (item.kind === 'DefaultInsertValueNode') { + if (itemsAreNodes) { + const itemNode = item as OperationNode; + if (DefaultInsertValueNode.is(itemNode)) { result.push({ node: ValueNode.create(null), raw: null }); continue; } const fieldDef = QueryUtils.requireField(this.client.$schema, model, fields[i]!); - invariant(item.kind === 'ValueNode', 'expecting a ValueNode'); + invariant(ValueNode.is(itemNode), `expecting a ValueNode, got ${itemNode.kind}`); result.push({ node: ValueNode.create( - this.dialect.transformInput( - (item as ValueNode).value, - fieldDef.type as BuiltinType, - !!fieldDef.array, - ), + this.dialect.transformInput(itemNode.value, fieldDef.type as BuiltinType, !!fieldDef.array), ), - raw: (item as ValueNode).value, + raw: itemNode.value, }); } else { let value: unknown = item; diff --git a/packages/plugins/soft-delete/package.json b/packages/plugins/soft-delete/package.json index 93fc95489..a3e57b9d3 100644 --- a/packages/plugins/soft-delete/package.json +++ b/packages/plugins/soft-delete/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-soft-delete", "displayName": "ZenStack Soft Delete Plugin", "description": "ZenStack plugin that implements soft-delete by intercepting Kysely queries", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index 885bee1f7..c90f4107c 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/schema", "displayName": "ZenStack Schema Object Model", "description": "TypeScript representation of ZModel schema", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 9d72324de..e15a1875d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/sdk", "displayName": "ZenStack SDK", "description": "Utilities for building ZenStack plugins", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/package.json b/packages/server/package.json index 9f2c44e0d..2948237ee 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/server", "displayName": "ZenStack Automatic CRUD Server", "description": "ZenStack automatic CRUD API handlers and server adapters for popular frameworks", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 0ac964bfd..7824503eb 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/testtools", "displayName": "ZenStack Test Tools", "description": "ZenStack Test Tools", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index 057fc4abb..57c113fc8 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/zod", "displayName": "ZenStack Zod Integration", "description": "Automatically deriving Zod schemas from ZModel schemas", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "author": { "name": "ZenStack Team", diff --git a/samples/orm/package.json b/samples/orm/package.json index e4782bea5..37a578b72 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.9.5", + "version": "3.9.6", "description": "", "main": "index.js", "private": true, diff --git a/samples/taskforge/package.json b/samples/taskforge/package.json index 817c298b4..b80ec8936 100644 --- a/samples/taskforge/package.json +++ b/samples/taskforge/package.json @@ -1,6 +1,6 @@ { "name": "taskforge", - "version": "3.9.5", + "version": "3.9.6", "type": "module", "private": true, "description": "A CLI for a team collaboration / project-tracking platform, built on ZenStack v3 (ORM) and better-auth.", diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 8db9d13a4..94bcf4f1c 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.9.5", + "version": "3.9.6", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index 3de21604a..e2004b158 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.9.5", + "version": "3.9.6", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/test/issue-2791.test.ts b/tests/regression/test/issue-2791.test.ts new file mode 100644 index 000000000..6e00a6655 --- /dev/null +++ b/tests/regression/test/issue-2791.test.ts @@ -0,0 +1,82 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2791 +// Policy pre-create checks unwrap insert values and used to treat any object with a +// `kind` key as a Kysely operation node. A Json payload like `{ kind: "artwork" }` +// then failed with `Invariant failed: expecting a ValueNode`. +const schema = ` +model User { + id Int @id @default(autoincrement()) +} + +model Item { + id Int @id @default(autoincrement()) + payload Json + + // non-constant: constant @@allow('all', true) skips pre-create value unwrapping + @@allow('all', auth() == null) +} +`; + +describe('Regression for issue 2791', () => { + it('creates a Json value that has a top-level kind key', async () => { + const db = await createPolicyTestClient(schema, { provider: 'postgresql' }); + + await expect(db.item.create({ data: { payload: { kind: 'artwork' } } })).resolves.toMatchObject({ + payload: { kind: 'artwork' }, + }); + }); + + it('creates many Json values that have a top-level kind key', async () => { + const db = await createPolicyTestClient(schema, { provider: 'postgresql' }); + + await expect( + db.item.createMany({ data: [{ payload: { kind: 'artwork' } }, { payload: { kind: 'photo' } }] }), + ).resolves.toMatchObject({ count: 2 }); + + await expect(db.item.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ payload: { kind: 'artwork' } }), + expect.objectContaining({ payload: { kind: 'photo' } }), + ]), + ); + }); + + it('does not mistake a Json value for a Kysely node when its kind matches a node name', async () => { + const db = await createPolicyTestClient(schema, { provider: 'postgresql' }); + + // if the payload were treated as a `ValueNode`, the policy check would unwrap + // `value` and the stored payload would not round-trip + await expect(db.item.create({ data: { payload: { kind: 'ValueNode', value: 'x' } } })).resolves.toMatchObject({ + payload: { kind: 'ValueNode', value: 'x' }, + }); + + await expect(db.item.create({ data: { payload: { kind: 'DefaultInsertValueNode' } } })).resolves.toMatchObject({ + payload: { kind: 'DefaultInsertValueNode' }, + }); + }); + + it('still rejects creates that violate the policy regardless of Json shape', async () => { + const db = await createPolicyTestClient(schema, { provider: 'postgresql' }); + const authDb = db.$setAuth({ id: 1 }); + + await expect(authDb.item.create({ data: { payload: { kind: 'artwork' } } })).toBeRejectedByPolicy(); + await expect( + authDb.item.create({ data: { payload: { kind: 'ValueNode', value: 'x' } } }), + ).toBeRejectedByPolicy(); + await expect( + authDb.item.createMany({ data: [{ payload: { kind: 'artwork' } }, { payload: { kind: 'photo' } }] }), + ).toBeRejectedByPolicy(); + await expect(db.item.findMany()).resolves.toHaveLength(0); + }); + + it('still accepts the same Json payload via update', async () => { + const db = await createPolicyTestClient(schema, { provider: 'postgresql' }); + + const item = await db.item.create({ data: { payload: {} } }); + await expect( + db.item.update({ where: { id: item.id }, data: { payload: { kind: 'artwork' } } }), + ).resolves.toMatchObject({ payload: { kind: 'artwork' } }); + }); +}); diff --git a/tests/regression/test/issue-2818.test.ts b/tests/regression/test/issue-2818.test.ts new file mode 100644 index 000000000..fd610e10a --- /dev/null +++ b/tests/regression/test/issue-2818.test.ts @@ -0,0 +1,92 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2818 +describe('Regression for issue #2818', () => { + it('supported enum array', async () => { + const schema = ` +enum TestTag { + INFO @map("info") + WARN @map("warn") + + @@map("post_tag") +} + +model Test { + id Int @id + tag TestTag? + tags TestTag[] + + @@map("test") +} +`; + + const db = await createTestClient(schema, { usePrismaPush: true, provider: 'postgresql' }); + + await db.test.create({ data: { id: 0 } }); + await db.test.create({ data: { id: 1, tag: 'INFO' } }); + await db.test.create({ data: { id: 2, tags: [] } }); + await db.test.create({ data: { id: 3, tags: ['INFO'] } }); + await db.test.create({ data: { id: 4, tags: ['INFO', 'WARN'] } }); + + await expect(db.test.findMany({ orderBy: { id: 'asc' } })).resolves.toEqual([ + { id: 0, tag: null, tags: [] }, + { id: 1, tag: 'INFO', tags: [] }, + { id: 2, tag: null, tags: [] }, + { id: 3, tag: null, tags: ['INFO'] }, + { id: 4, tag: null, tags: ['INFO', 'WARN'] }, + ]); + + await db.$qb.updateTable('test').set({ tag: 'WARN' }).where('id', '=', 2).executeTakeFirst(); + await db.$qb + .updateTable('test') + .set({ tags: ['INFO', 'WARN'] }) + .where('id', '=', 2) + .executeTakeFirst(); + await db.$qb.updateTable('test').set({ tags: [] }).where('id', '=', 4).executeTakeFirst(); + + await expect(db.test.findMany({ orderBy: { id: 'asc' } })).resolves.toEqual([ + { id: 0, tag: null, tags: [] }, + { id: 1, tag: 'INFO', tags: [] }, + { id: 2, tag: 'WARN', tags: ['INFO', 'WARN'] }, + { id: 3, tag: null, tags: ['INFO'] }, + { id: 4, tag: null, tags: [] }, + ]); + }); + + it('supports enum array with partially mapped values', async () => { + const schema = ` +enum TestTag { + INFO @map("info") + WARN + + @@map("post_tag") +} + +model Test { + id Int @id + tag TestTag? + tags TestTag[] + + @@map("test") +} +`; + + const db = await createTestClient(schema, { usePrismaPush: true, provider: 'postgresql' }); + + await db.test.create({ data: { id: 1, tag: 'WARN', tags: ['INFO', 'WARN'] } }); + await db.test.create({ data: { id: 2, tags: ['WARN'] } }); + + await expect(db.test.findMany({ orderBy: { id: 'asc' } })).resolves.toEqual([ + { id: 1, tag: 'WARN', tags: ['INFO', 'WARN'] }, + { id: 2, tag: null, tags: ['WARN'] }, + ]); + + await db.test.update({ where: { id: 2 }, data: { tags: { push: 'INFO' } } }); + await expect(db.test.findUnique({ where: { id: 2 } })).resolves.toEqual({ + id: 2, + tag: null, + tags: ['WARN', 'INFO'], + }); + }); +}); diff --git a/tests/regression/test/issue-2841.test.ts b/tests/regression/test/issue-2841.test.ts new file mode 100644 index 000000000..e0acca7c9 --- /dev/null +++ b/tests/regression/test/issue-2841.test.ts @@ -0,0 +1,91 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2841 +describe('Regression for issue #2841', () => { + const schema = ` +type Auth { + id String + isSystem Boolean + + @@auth +} + +model AuditLog { + id String @id @default(cuid()) + action String + + @@allow('create', auth().isSystem) + @@allow('read', true) +} + +model Note { + id String @id @default(cuid()) + ownerId String + + @@allow('create', ownerId == auth().id) + @@allow('read', true) +} +`; + + async function createClient() { + const sqls: string[] = []; + const db = await createPolicyTestClient(schema, { + log: (event) => { + if (event.level === 'query') { + sqls.push(event.query.sql); + } + }, + }); + // the pre-create policy check is the only query selecting a `$condition` alias + return { db, policyChecks: () => sqls.filter((sql) => sql.includes('$condition')) }; + } + + it('does not run a per-row policy check when the create filter does not reference the row', async () => { + const { db, policyChecks } = await createClient(); + const authDb = db.$setAuth({ id: 'u1', isSystem: true }); + + await expect( + authDb.auditLog.createMany({ + data: Array.from({ length: 5 }, (_, i) => ({ action: `action-${i}` })), + }), + ).resolves.toMatchObject({ count: 5 }); + + // `auth().isSystem` resolves to a constant, so one answer covers the whole batch + expect(policyChecks()).toHaveLength(0); + await expect(db.auditLog.findMany()).resolves.toHaveLength(5); + }); + + it('still rejects the batch when a non-row-dependent create filter denies', async () => { + const { db } = await createClient(); + const authDb = db.$setAuth({ id: 'u1', isSystem: false }); + + await expect( + authDb.auditLog.createMany({ + data: [{ action: 'action-0' }], + }), + ).rejects.toThrow(/rejected by access policies/i); + + await expect(db.auditLog.findMany()).resolves.toHaveLength(0); + }); + + it('still checks each row when the create filter references the row', async () => { + const { db, policyChecks } = await createClient(); + const authDb = db.$setAuth({ id: 'u1', isSystem: true }); + + await expect( + authDb.note.createMany({ + data: [{ ownerId: 'u1' }, { ownerId: 'u1' }], + }), + ).resolves.toMatchObject({ count: 2 }); + expect(policyChecks()).toHaveLength(2); + + // a single offending row still rejects the whole batch + await expect( + authDb.note.createMany({ + data: [{ ownerId: 'u1' }, { ownerId: 'someone-else' }], + }), + ).rejects.toThrow(/rejected by access policies/i); + await expect(db.note.findMany()).resolves.toHaveLength(2); + }); +}); diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 714da3ce8..068b86e82 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.9.5", + "version": "3.9.6", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 3a4af4027..dbc5e747d 100644 --- a/tests/runtimes/edge-runtime/package.json +++ b/tests/runtimes/edge-runtime/package.json @@ -1,6 +1,6 @@ { "name": "edge-runtime-e2e", - "version": "3.9.5", + "version": "3.9.6", "private": true, "type": "module", "scripts": {