Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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-validationt.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.