zenstack/packages/language/test/attribute-application.test.ts
Yiming Cao 8ddbfdebdc
feat(orm): add field-level @fuzzy attribute to gate fuzzy search (#2642)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:41:09 -07:00

526 lines
16 KiB
TypeScript

import { describe, it } from 'vitest';
import { loadSchema, loadSchemaWithError } from './utils';
describe('Attribute application validation tests', () => {
describe('Model-level policy attributes (@@allow, @@deny)', () => {
it('accepts valid policy kinds', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int
@@allow('create', true)
@@allow('read', true)
@@allow('update', true)
@@allow('post-update', true)
@@allow('delete', true)
@@deny('all', false)
}
`);
});
it('accepts comma-separated policy kinds', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int
@@allow('create, read, update', true)
@@deny('delete, post-update', false)
}
`);
});
it('rejects invalid policy kind', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int
@@allow('invalid', true)
}
`,
`Invalid policy rule kind`,
);
});
it('rejects before in create policies', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int
@@allow('all', true)
@@deny('create', before(x) > 2)
}
`,
`"before()" is only allowed in "post-update" policy rules`,
);
});
it('rejects before in read policies', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int
@@allow('all', true)
@@deny('read', before(x) > 2)
}
`,
`"before()" is only allowed in "post-update" policy rules`,
);
});
it('rejects before in non-post-update policies', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int
@@allow('all', true)
@@deny('update', before(x) > 2)
}
`,
`"before()" is only allowed in "post-update" policy rules`,
);
});
it('rejects before in delete policies', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int
@@allow('all', true)
@@deny('delete', before(x) > 2)
}
`,
`"before()" is only allowed in "post-update" policy rules`,
);
});
it('accepts before in post-update policies', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int
@@allow('all', true)
@@deny('post-update', before().x > 2)
}
`);
});
it('rejects non-owned relation in create policy', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
bars Bar[]
@@allow('create', bars?[x > 0])
}
model Bar {
id Int @id @default(autoincrement())
x Int
foo Foo @relation(fields: [fooId], references: [id])
fooId Int
@@allow('all', true)
}
`,
`non-owned relation fields are not allowed in "create" rules`,
);
});
it('rejects non-owned relation in all policy', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
bars Bar[]
@@allow('all', bars?[x > 0])
}
model Bar {
id Int @id @default(autoincrement())
x Int
foo Foo @relation(fields: [fooId], references: [id])
fooId Int
@@allow('all', true)
}
`,
`non-owned relation fields are not allowed in "create" rules`,
);
});
it('accepts owned relation in create policy', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
bar Bar @relation(fields: [barId], references: [id])
barId Int
@@allow('create', bar.x > 0)
}
model Bar {
id Int @id @default(autoincrement())
x Int
foos Foo[]
@@allow('all', true)
}
`);
});
it('accepts non-owned relation in read policy', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
bars Bar[]
@@allow('read', bars?[x > 0])
}
model Bar {
id Int @id @default(autoincrement())
x Int
foo Foo @relation(fields: [fooId], references: [id])
fooId Int
@@allow('all', true)
}
`);
});
it('accepts non-owned relation in update policy', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
bars Bar[]
@@allow('update', bars?[x > 0])
}
model Bar {
id Int @id @default(autoincrement())
x Int
foo Foo @relation(fields: [fooId], references: [id])
fooId Int
@@allow('all', true)
}
`);
});
it('accepts auth() relation access in create policy', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id @default(autoincrement())
posts Post[]
@@allow('all', true)
@@auth()
}
model Post {
id Int @id @default(autoincrement())
author User @relation(fields: [authorId], references: [id])
authorId Int
@@allow('create', auth().posts?[id > 0])
}
`);
});
});
describe('Field-level policy attributes (@allow, @deny)', () => {
it('accepts valid field-level policy kinds', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int @allow('read', true)
y Int @allow('update', true)
z Int @deny('all', false)
@@allow('all', true)
}
`);
});
it('accepts comma-separated field-level policy kinds', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int @allow('read, update', true)
@@allow('all', true)
}
`);
});
it('rejects before in field-level policies', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int @deny('update', before(x) > 2)
@@allow('all', true)
}
`,
`"before()" is not allowed in field-level policies`,
);
});
it('rejects field-level policy on relation fields', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
bar Bar @relation(fields: [barId], references: [id]) @allow('read', true)
barId Int
@@allow('all', true)
}
model Bar {
id Int @id @default(autoincrement())
foos Foo[]
@@allow('all', true)
}
`,
`Field-level policies are not allowed for relation fields`,
);
});
it('rejects field-level policy on computed fields', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int
doubled Int @computed @allow('read', true)
@@allow('all', true)
}
`,
`Field-level policies are not allowed for computed fields`,
);
});
it('accepts field-level policy on regular fields', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
x Int @allow('read', true)
y String @deny('update', false)
@@allow('all', true)
}
`);
});
it('accepts complex expressions in field-level policies', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id @default(autoincrement())
email String
posts Post[]
@@allow('all', true)
@@auth()
}
model Post {
id Int @id @default(autoincrement())
title String @allow('update', auth() != null && auth().id == authorId)
author User @relation(fields: [authorId], references: [id])
authorId Int
@@allow('all', true)
}
`);
});
});
describe('Field-level @fuzzy attribute', () => {
it('accepts @fuzzy on a String field with postgres provider', async () => {
await loadSchema(`
datasource db {
provider = 'postgresql'
url = 'postgresql://localhost/test'
}
model Flavor {
id Int @id @default(autoincrement())
name String @fuzzy
description String? @fuzzy
}
`);
});
it('rejects @fuzzy with sqlite provider', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Flavor {
id Int @id @default(autoincrement())
name String @fuzzy
}
`,
/`@fuzzy` is only supported for the `postgresql` provider/,
);
});
it('rejects @fuzzy with mysql provider', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'mysql'
url = 'mysql://localhost/test'
}
model Flavor {
id Int @id @default(autoincrement())
name String @fuzzy
}
`,
/`@fuzzy` is only supported for the `postgresql` provider/,
);
});
it('rejects @fuzzy on a non-String field', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'postgresql'
url = 'postgresql://localhost/test'
}
model Flavor {
id Int @id @default(autoincrement())
count Int @fuzzy
}
`,
/attribute "@fuzzy" cannot be used on this type of field/,
);
});
});
it('requires relation and fk to have consistent optionality', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model Foo {
id Int @id @default(autoincrement())
bar Bar @relation(fields: [barId], references: [id])
barId Int?
@@allow('all', true)
}
model Bar {
id Int @id @default(autoincrement())
foos Foo[]
@@allow('all', true)
}
`,
/relation "bar" is not optional/,
);
});
});