zenstack/packages/language/test/attribute-application.test.ts
Yiming Cao e4b79d0c6f
feat: field-level access control (#557)
* WIP

* WIP: implement read policies

* fix tests

* Update packages/plugins/policy/src/policy-handler.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix name mapper

* fix tests

* fix build

* implement update field-level policies

* update tests

* update tests

* add more tests

* add more tests

* simplify queries

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-05 18:14:23 +08:00

458 lines
14 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)
}
`);
});
});
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/,
);
});
});