zenstack/tests/regression/test/issue-598.test.ts
Yiming Cao ab9535ea90
fix(language): resolve mixin fields from imported files in scope (#598) (#632)
Fixed issue where access policy rules couldn't reference fields inherited from mixins defined in separate imported files. The language service now correctly resolves these fields during scope computation.

## Root Cause

The `getRecursiveBases()` function only searched for mixin declarations in the current document (`decl.$container.declarations`), which failed for imported mixins.

## Solution

- Modified `getRecursiveBases()` to accept optional `LangiumDocuments` parameter
- Implemented two-strategy approach:
  1. Use resolved reference if available (post-linking)
  2. Search by name across all documents including imports (pre-linking)
- Updated `ZModelScopeComputation.processNode()` to pass `LangiumDocuments`
- Leverages existing `getAllDeclarationsIncludingImports()` helper

## Changes

- **packages/language/src/utils.ts**: Fixed `getRecursiveBases()` to search imported documents
- **packages/language/src/zmodel-scope.ts**: Pass LangiumDocuments to scope computation
- **packages/language/test/mixin.test.ts**: Added tests for imported mixin field resolution
- **packages/testtools**: Added `extraZModelFiles` option for multi-file test schemas
- **tests/regression/test/issue-598.test.ts**: Regression test for the issue

## Test Results

 All language package tests pass (65 tests)
 Regression test validates policy rules can access imported mixin fields
 Handles edge cases: cyclic imports, nested mixins, transitive imports

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 13:38:10 +08:00

79 lines
2.2 KiB
TypeScript

import { createPolicyTestClient } from '@zenstackhq/testtools';
import { describe, expect, it } from 'vitest';
describe('Regression for issue 598', () => {
it('access policy can reference mixin fields from imported files', async () => {
const db = await createPolicyTestClient(
`
import './mixins'
datasource db {
provider = 'postgresql'
url = '$DB_URL'
}
model User {
id Int @id @default(autoincrement())
email String @unique
documents Document[]
@@allow('all', true)
}
model Document with AuditMixin {
id String @id @default(cuid())
title String
ownerId Int
owner User @relation(fields: [ownerId], references: [id])
@@allow('create,read', auth() != null)
@@allow('update', auth().id == createdById) // createdById from mixin
}
`,
{
extraZModelFiles: {
mixins: `
type AuditMixin {
createdById Int
createdAt DateTime @default(now())
}
`,
},
},
);
// Test that the policy rule using mixin field works correctly
const userDb = db.$setAuth({ id: 1 });
const otherUserDb = db.$setAuth({ id: 2 });
// Create users first
await db.user.create({ data: { id: 1, email: 'user1@test.com' } });
await db.user.create({ data: { id: 2, email: 'user2@test.com' } });
// Create document as user 1
await userDb.document.create({
data: {
id: 'doc-1',
title: 'Test Document',
ownerId: 1,
createdById: 1, // From mixin
},
});
// User 1 should be able to update (matches createdById)
await expect(
userDb.document.update({
where: { id: 'doc-1' },
data: { title: 'Updated' },
}),
).toResolveTruthy();
// User 2 should NOT be able to update (different createdById)
await expect(
otherUserDb.document.update({
where: { id: 'doc-1' },
data: { title: 'Hacked' },
}),
).toBeRejectedNotFound();
});
});