Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/language/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,14 @@
},
"devDependencies": {
"@types/pluralize": "^0.0.33",
"@types/tmp": "catalog:",
"@zenstackhq/common-helpers": "workspace:*",
"@zenstackhq/eslint-config": "workspace:*",
"@zenstackhq/typescript-config": "workspace:*",
"@zenstackhq/common-helpers": "workspace:*",
"@zenstackhq/vitest-config": "workspace:*",
"glob": "^11.0.2",
"langium-cli": "catalog:",
"tmp": "catalog:",
"@types/tmp": "catalog:"
"tmp": "catalog:"
},
"volta": {
"node": "18.19.1",
Expand Down
4 changes: 2 additions & 2 deletions packages/language/src/validators/expression-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,12 @@ export default class ExpressionValidator implements AstValidator<Expression> {
isDataFieldReference(expr.left) &&
(isThisExpr(expr.right) || isDataFieldReference(expr.right))
) {
accept('error', 'comparison between model-typed fields are not supported', { node: expr });
accept('error', 'comparison between models is not supported', { node: expr });
} else if (
isDataFieldReference(expr.right) &&
(isThisExpr(expr.left) || isDataFieldReference(expr.left))
) {
accept('error', 'comparison between model-typed fields are not supported', { node: expr });
accept('error', 'comparison between models is not supported', { node: expr });
}
} else if (
(isDataModel(leftType) && !isNullExpr(expr.right)) ||
Expand Down
100 changes: 100 additions & 0 deletions packages/language/test/expression-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it } from 'vitest';
import { loadSchema, loadSchemaWithError } from './utils';

describe('Expression Validation Tests', () => {
it('should reject model comparison', async () => {
await loadSchemaWithError(
`
model User {
id Int @id
name String
posts Post[]
}

model Post {
id Int @id
title String
author User @relation(fields: [authorId], references: [id])
@@allow('all', author == this)
}
`,
'comparison between models is not supported',
);
});

it('should reject model comparison', async () => {
await loadSchemaWithError(
`
model User {
id Int @id
name String
profile Profile?
address Address?
@@allow('read', profile == this)
}

model Profile {
id Int @id
bio String
user User @relation(fields: [userId], references: [id])
userId Int @unique
}

model Address {
id Int @id
street String
user User @relation(fields: [userId], references: [id])
userId Int @unique
}
`,
'comparison between models is not supported',
);
});

it('should allow auth comparison with auth type', async () => {
await loadSchema(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

model User {
id Int @id
name String
profile Profile?
@@allow('read', auth() == this)
}

model Profile {
id Int @id
bio String
user User @relation(fields: [userId], references: [id])
userId Int @unique
@@allow('read', auth() == user)
}
`,
);
});

it('should reject auth comparison with non-auth type', async () => {
await loadSchemaWithError(
`
model User {
id Int @id
name String
profile Profile?
}

model Profile {
id Int @id
bio String
user User @relation(fields: [userId], references: [id])
userId Int @unique
@@allow('read', auth() == this)
}
`,
'incompatible operand types',
);
});
});
29 changes: 21 additions & 8 deletions packages/language/test/utils.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { invariant } from '@zenstackhq/common-helpers';
import { glob } from 'glob';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { loadDocument } from '../src';
import { expect } from 'vitest';
import { invariant } from '@zenstackhq/common-helpers';
import { loadDocument } from '../src';

export async function loadSchema(schema: string) {
// create a temp file
const tempFile = path.join(os.tmpdir(), `zenstack-schema-${crypto.randomUUID()}.zmodel`);
fs.writeFileSync(tempFile, schema);
const r = await loadDocument(tempFile);
expect(r.success).toBe(true);
const r = await loadDocument(tempFile, getPluginModels());
expect(r).toSatisfy(
(r) => r.success,
`Failed to load schema: ${(r as any).errors?.map((e) => e.toString()).join(', ')}`,
);
invariant(r.success);
return r.model;
}
Expand All @@ -19,12 +23,21 @@ export async function loadSchemaWithError(schema: string, error: string | RegExp
// create a temp file
const tempFile = path.join(os.tmpdir(), `zenstack-schema-${crypto.randomUUID()}.zmodel`);
fs.writeFileSync(tempFile, schema);
const r = await loadDocument(tempFile);
const r = await loadDocument(tempFile, getPluginModels());
expect(r.success).toBe(false);
invariant(!r.success);
if (typeof error === 'string') {
expect(r.errors.some((e) => e.toString().toLowerCase().includes(error.toLowerCase()))).toBe(true);
expect(r).toSatisfy(
(r) => r.errors.some((e) => e.toString().toLowerCase().includes(error.toLowerCase())),
`Expected error message to include "${error}" but got: ${r.errors.map((e) => e.toString()).join(', ')}`,
);
} else {
expect(r.errors.some((e) => error.test(e))).toBe(true);
expect(r).toSatisfy(
(r) => r.errors.some((e) => error.test(e)),
`Expected error message to match "${error}" but got: ${r.errors.map((e) => e.toString()).join(', ')}`,
);
}
}
function getPluginModels() {
return glob.sync(path.resolve(__dirname, '../../runtime/src/plugins/**/plugin.zmodel'));
}
5 changes: 4 additions & 1 deletion packages/runtime/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,10 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
select: fieldsToSelectObject(referencedPkFields) as any,
});
if (!relationEntity) {
throw new NotFoundError(`Could not find the entity for connect action`);
throw new NotFoundError(
relationModel,
`Could not find the entity to connect for the relation "${relationField.name}"`,
);
}
result = relationEntity;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime/src/client/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class InternalError extends Error {}
* Error thrown when an entity is not found.
*/
export class NotFoundError extends Error {
constructor(model: string) {
super(`Entity not found for model "${model}"`);
constructor(model: string, details?: string) {
super(`Entity not found for model "${model}"${details ? `: ${details}` : ''}`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -305,11 +305,7 @@ export class ExpressionTransformer<Schema extends SchemaDef> {
private _unary(expr: UnaryExpression, context: ExpressionTransformerContext<Schema>) {
// only '!' operator for now
invariant(expr.op === '!', 'only "!" operator is supported');
return BinaryOperationNode.create(
this.transform(expr.operand, context),
this.transformOperator('!='),
trueNode(this.dialect),
);
return logicalNot(this.transform(expr.operand, context));
}

private transformOperator(op: Exclude<BinaryOperator, '?' | '!' | '^'>) {
Expand Down
109 changes: 109 additions & 0 deletions packages/runtime/test/policy/auth-equality.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import { createPolicyTestClient } from './utils';

describe('Reference Equality Tests', () => {
it('works with create and auth equality', async () => {
const db = await createPolicyTestClient(
`
model User {
id1 Int
id2 Int
posts Post[]
@@id([id1, id2])
@@allow('all', auth() == this)
@@allow('read', true)
}

model Post {
id Int @id @default(autoincrement())
title String
authorId1 Int
authorId2 Int
author User @relation(fields: [authorId1, authorId2], references: [id1, id2])
@@allow('all', auth() == author)
}
`,
);

await expect(
db.user.create({
data: { id1: 1, id2: 2 },
}),
).toBeRejectedByPolicy();

await expect(
db.$setAuth({ id1: 1, id2: 2 }).user.create({
data: { id1: 1, id2: 2 },
}),
).resolves.toMatchObject({ id1: 1, id2: 2 });

await expect(
db.post.create({
data: { authorId1: 1, authorId2: 2, title: 'Post 1' },
}),
).toBeRejectedByPolicy();
await expect(
db.post.create({
data: { author: { connect: { id1_id2: { id1: 1, id2: 2 } } }, title: 'Post 1' },
}),
).toBeRejectedByPolicy();

await expect(
db.$setAuth({ id1: 1, id2: 2 }).post.create({
data: { authorId1: 1, authorId2: 2, title: 'Post 1' },
}),
).resolves.toMatchObject({ title: 'Post 1' });
await expect(
db.$setAuth({ id1: 1, id2: 2 }).post.create({
data: { author: { connect: { id1_id2: { id1: 1, id2: 2 } } }, title: 'Post 2' },
}),
).resolves.toMatchObject({ title: 'Post 2' });
});

it('works with create and auth inequality', async () => {
const db = await createPolicyTestClient(
`
model User {
id1 Int
id2 Int
posts Post[]
@@id([id1, id2])
@@allow('all', auth() != this)
@@allow('read', true)
}

model Post {
id Int @id @default(autoincrement())
title String
authorId1 Int
authorId2 Int
author User @relation(fields: [authorId1, authorId2], references: [id1, id2])
@@allow('all', auth() != author)
@@allow('read', true)
}
`,
);

await expect(
db.$setAuth({ id1: 1, id2: 2 }).user.create({
data: { id1: 1, id2: 2 },
}),
).toBeRejectedByPolicy();
await expect(
db.$setAuth({ id1: 2, id2: 2 }).user.create({
data: { id1: 1, id2: 2 },
}),
).toResolveTruthy();

await expect(
db.$setAuth({ id1: 1, id2: 2 }).post.create({
data: { authorId1: 1, authorId2: 2, title: 'Post 1' },
}),
).toBeRejectedByPolicy();
await expect(
db.$setAuth({ id1: 2, id2: 2 }).post.create({
data: { authorId1: 1, authorId2: 2, title: 'Post 1' },
}),
).resolves.toMatchObject({ title: 'Post 1' });
});
});
40 changes: 0 additions & 40 deletions packages/runtime/test/policy/ref-equality.test.ts

This file was deleted.

3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.