zenstack/packages/language/test/expression-validation.test.ts
Mike Willbanks 077f03f732
feat(zmodel): collection predicate binding (#548)
* feat: audit policy collection aliases

provides a means to alias collections in @@allow collections by extending the ast
this allows for utilizing collections inside of @@allow like:

```
memberships?[m,
    auth().memberships?[
        tenantId == m.tenantId ...
    ]
  ]
```

* fix: code review comments + syntax fixes

* refactor: extract collection predicate binding to its own language construct (#2)

- adjusted language processing chain accordingly
- fixed several issues in policy transformer/evaluator
- more test cases

* addressing PR comments

---------

Co-authored-by: Yiming Cao <yiming@whimslab.io>
Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com>
2026-01-18 14:08:10 +08:00

360 lines
10 KiB
TypeScript

import { describe, it } from 'vitest';
import { loadSchema, loadSchemaWithError } from './utils';
describe('Expression Validation Tests', () => {
describe('Model Comparison Tests', () => {
it('should reject model comparison1', 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])
authorId Int
@@allow('all', author == this)
}
`,
'comparison between models is not supported',
);
});
it('should reject model comparison2', 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',
);
});
});
describe('Collection Predicate Tests', () => {
it('should reject standalone binding access', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id
memberships Membership[]
@@allow('read', memberships?[m, m != null])
}
model Membership {
id Int @id
tenantId Int
user User @relation(fields: [userId], references: [id])
userId Int
}
`,
'binding cannot be used without a member access',
);
});
it('should allow referencing binding', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id
memberships Membership[]
@@allow('read', memberships?[m, m.tenantId == id])
}
model Membership {
id Int @id
tenantId Int
user User @relation(fields: [userId], references: [id])
userId Int
}
`);
});
it('should keep supporting unbound collection predicate syntax', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id
memberships Membership[]
@@allow('read', memberships?[tenantId == id])
}
model Membership {
id Int @id
tenantId Int
user User @relation(fields: [userId], references: [id])
userId Int
}
`);
});
it('should support mixing bound and unbound syntax', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id
memberships Membership[]
@@allow('read', memberships?[m, m.tenantId == id && tenantId == id])
}
model Membership {
id Int @id
tenantId Int
user User @relation(fields: [userId], references: [id])
userId Int
}
`);
});
it('should allow disambiguation with this', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id
memberships Membership[]
value Int
@@allow('read', memberships?[m, m.value == this.value])
}
model Membership {
id Int @id
tenantId Int
user User @relation(fields: [userId], references: [id])
userId Int
value Int
}
`);
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id
memberships Membership[]
value String
@@allow('read', memberships?[m, m.value == this.value])
}
model Membership {
id Int @id
tenantId Int
user User @relation(fields: [userId], references: [id])
userId Int
value Int
}
`,
'incompatible operand types',
);
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id
memberships Membership[]
value String
@@allow('read', memberships?[value == this.value])
}
model Membership {
id Int @id
tenantId Int
user User @relation(fields: [userId], references: [id])
userId Int
value Int
}
`,
'incompatible operand types',
);
});
it('should support accessing binding from deep context', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id
memberships Membership[]
@@allow('read', memberships?[m, roles?[value == m.value]])
}
model Membership {
id Int @id
tenantId Int
user User @relation(fields: [userId], references: [id])
userId Int
value Int
roles Role[]
}
model Role {
id Int @id
membership Membership @relation(fields: [membershipId], references: [id])
membershipId Int
value Int
}
`);
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id
memberships Membership[]
@@allow('read', memberships?[m, roles?[r, r.value == m.value]])
}
model Membership {
id Int @id
tenantId Int
user User @relation(fields: [userId], references: [id])
userId Int
value Int
roles Role[]
}
model Role {
id Int @id
membership Membership @relation(fields: [membershipId], references: [id])
membershipId Int
value Int
}
`);
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id
memberships Membership[]
x Int
@@allow('read', memberships?[m, roles?[this.x == m.value]])
}
model Membership {
id Int @id
tenantId Int
user User @relation(fields: [userId], references: [id])
userId Int
value Int
roles Role[]
}
model Role {
id Int @id
membership Membership @relation(fields: [membershipId], references: [id])
membershipId Int
value Int
}
`);
});
});
});