UNPKG

@schoolai/spicedb-zed-schema-parser

Version:

SpiceDB .zed file format parser and analyzer written in Typescript

462 lines (427 loc) 16.3 kB
import { describe, expect, it } from 'vitest' import { ObjectTypeDefinition, parseSpiceDBSchema, SchemaAST, } from '../schema-parser/parser' import { analyzeSpiceDbSchema } from './analyzer' import { SymbolTable } from './symbol-table' import { TypeInferenceEngine } from './type-inference' describe('Type Inference in Augmented AST', () => { it('should infer types for a permission referencing a simple relation', () => { const schema = ` definition user {} definition document { relation viewer: user permission view = viewer } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(true) expect(result.errors).toHaveLength(0) expect(result.augmentedAst).toBeDefined() const docDef = result.augmentedAst?.definitions.find( d => d.name === 'document' && d.type === 'definition', ) as any // AugmentedObjectTypeDefinition expect(docDef).toBeDefined() const viewPerm = docDef.permissions.find((p: any) => p.name === 'view') expect(viewPerm).toBeDefined() expect(viewPerm.inferredSubjectTypes).toEqual([{ typeName: 'user' }]) }) it('should infer types for a permission referencing another permission', () => { const schema = ` definition user {} definition document { relation editor: user permission edit = editor permission view = edit } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(true) expect(result.errors).toHaveLength(0) const docDef = result.augmentedAst?.definitions.find( d => d.name === 'document' && d.type === 'definition', ) as any const viewPerm = docDef.permissions.find((p: any) => p.name === 'view') expect(viewPerm.inferredSubjectTypes).toEqual([{ typeName: 'user' }]) }) it('should infer types for a permission with a union of relations', () => { const schema = ` definition user {} definition group {} definition document { relation owner: user relation collaborator: group permission view = owner + collaborator } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(true) expect(result.errors).toHaveLength(0) const docDef = result.augmentedAst?.definitions.find( d => d.name === 'document' && d.type === 'definition', ) as any const viewPerm = docDef.permissions.find((p: any) => p.name === 'view') expect(viewPerm.inferredSubjectTypes).toEqual( expect.arrayContaining([{ typeName: 'user' }, { typeName: 'group' }]), ) expect(viewPerm.inferredSubjectTypes).toHaveLength(2) }) it('should infer types for a permission with an intersection of relations', () => { const schema = ` definition user {} definition role { relation member: user } definition document { relation primary_contact: user relation project_lead: role#member permission sign_off = primary_contact & project_lead } ` // primary_contact is user // project_lead is user (from role#member) // intersection should be user const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(true) expect(result.errors).toHaveLength(0) const docDef = result.augmentedAst?.definitions.find( d => d.name === 'document' && d.type === 'definition', ) as any const signOffPerm = docDef.permissions.find( (p: any) => p.name === 'sign_off', ) expect(signOffPerm.inferredSubjectTypes).toEqual([{ typeName: 'user' }]) }) it('should infer types for a permission with an exclusion (type of left side)', () => { const schema = ` definition user {} definition banned_user {} definition resource { relation all_users: user relation banned: banned_user // Different type, won't affect intersection for type inference permission access = all_users - banned } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(true) expect(result.errors).toHaveLength(0) const resourceDef = result.augmentedAst?.definitions.find( d => d.name === 'resource' && d.type === 'definition', ) as any const accessPerm = resourceDef.permissions.find( (p: any) => p.name === 'access', ) expect(accessPerm.inferredSubjectTypes).toEqual([{ typeName: 'user' }]) }) it('should infer types for an arrow expression', () => { const schema = ` definition user {} definition group { relation member: user } definition document { relation parent_group: group permission view = parent_group->member } ` // parent_group is 'group', member on 'group' is 'user'. So view is 'user'. const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(true) expect(result.errors).toHaveLength(0) const docDef = result.augmentedAst?.definitions.find( d => d.name === 'document' && d.type === 'definition', ) as any const viewPerm = docDef.permissions.find((p: any) => p.name === 'view') expect(viewPerm.inferredSubjectTypes).toEqual([{ typeName: 'user' }]) }) it('should infer types for an arrow expression targeting a permission', () => { const schema = ` definition user {} definition organization { relation admin: user permission manage = admin } definition project { relation org: organization permission edit_project = org->manage } ` // org is 'organization', manage on 'organization' is 'admin' (user). So edit_project is 'user'. const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(true) expect(result.errors).toHaveLength(0) const projectDef = result.augmentedAst?.definitions.find( d => d.name === 'project' && d.type === 'definition', ) as any const editPerm = projectDef.permissions.find( (p: any) => p.name === 'edit_project', ) expect(editPerm.inferredSubjectTypes).toEqual([{ typeName: 'user' }]) }) it('should handle arrow expression with multiple possible types on left, yielding multiple result types', () => { const schema = ` definition user {} definition service_account {} definition team { relation direct_member: user relation service_member: service_account permission team_members = direct_member + service_member // team_members is now part of team } definition resource { // This definition is not strictly needed for the resource_c test but is fine relation owner_team: team permission access = owner_team->direct_member + owner_team->service_member } // Removed resource_b as its purpose is now incorporated into the main 'team' definition definition resource_c { relation current_team: team permission effective_access = current_team->team_members // team_members on team is user | service_account } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(true) expect(result.errors).toHaveLength(0) const resourceCDef = result.augmentedAst?.definitions.find( d => d.name === 'resource_c' && d.type === 'definition', ) as any const effectiveAccessPerm = resourceCDef.permissions.find( (p: any) => p.name === 'effective_access', ) expect(effectiveAccessPerm.inferredSubjectTypes).toEqual( expect.arrayContaining([ { typeName: 'user' }, { typeName: 'service_account' }, ]), ) expect(effectiveAccessPerm.inferredSubjectTypes).toHaveLength(2) }) it('should result in null inferred types for arrow with undefined target and report error', () => { const schema = ` definition user {} definition document { relation owner: user permission view = owner->non_existent_relation // error here } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(false) // Because UNDEFINED_ARROW_TARGET is a fatal error for usage expect(result.errors).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'UNDEFINED_ARROW_TARGET' }), ]), ) // Augmented AST might be undefined due to fatal error if (result.augmentedAst) { const docDef = result.augmentedAst.definitions.find( d => d.name === 'document' && d.type === 'definition', ) as any const viewPerm = docDef.permissions.find((p: any) => p.name === 'view') // Type inference might proceed up to the point of failure or return null for the problematic part // Depending on implementation, inferredSubjectTypes could be null or an empty array if error occurs expect(viewPerm.inferredSubjectTypes).toBeNull() } else { // If augmentedAst is undefined, this also implies the error was fatal enough. expect(result.augmentedAst).toBeUndefined() } }) it('should result in null inferred types for permission referencing undefined identifier and report error', () => { const schema = ` definition user {} definition document { permission view = non_existent_relation // error here } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(false) // Because UNDEFINED_IDENTIFIER is a fatal error expect(result.errors).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'UNDEFINED_IDENTIFIER' }), ]), ) if (result.augmentedAst) { const docDef = result.augmentedAst.definitions.find( d => d.name === 'document' && d.type === 'definition', ) as any const viewPerm = docDef.permissions.find((p: any) => p.name === 'view') expect(viewPerm.inferredSubjectTypes).toBeNull() } else { expect(result.augmentedAst).toBeUndefined() } }) it('should correctly infer types for relation with wildcard', () => { const schema = ` definition user {} definition resource { relation viewer: user:* permission view = viewer } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(true) expect(result.errors).toHaveLength(0) const resourceDef = result.augmentedAst?.definitions.find( d => d.name === 'resource' && d.type === 'definition', ) as any const viewPerm = resourceDef.permissions.find((p: any) => p.name === 'view') expect(viewPerm.inferredSubjectTypes).toEqual([ { typeName: 'user', wildcard: true }, ]) }) it('should correctly infer types for relation with specific subject relation', () => { const schema = ` definition user {} definition group { relation member: user } definition resource { relation shared_with_group_members: group#member permission access = shared_with_group_members } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(true) expect(result.errors).toHaveLength(0) const resourceDef = result.augmentedAst?.definitions.find( d => d.name === 'resource' && d.type === 'definition', ) as any const accessPerm = resourceDef.permissions.find( (p: any) => p.name === 'access', ) // The type of 'group#member' is 'user' expect(accessPerm.inferredSubjectTypes).toEqual([{ typeName: 'user' }]) }) it('should not produce augmented AST if critical pre-augmentation errors exist', () => { const schema = ` definition document { relation viewer: non_existent_type // UNDEFINED_TYPE error permission view = viewer } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(false) expect(result.errors).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'UNDEFINED_TYPE' }), ]), ) expect(result.augmentedAst).toBeUndefined() // Critical error should prevent augmentation expect(result.symbolTable).toBeDefined() // Symbol table is always built }) it('should not produce augmented AST if cycle detected', () => { const schema = ` definition node { permission p1 = p2 permission p2 = p1 // CIRCULAR_DEPENDENCY } ` const { ast } = parseSpiceDBSchema(schema) if (!ast) throw new Error('AST is undefined') const result = analyzeSpiceDbSchema(ast) expect(result.isValid).toBe(false) expect(result.errors).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'CIRCULAR_DEPENDENCY' }), ]), ) expect(result.augmentedAst).toBeUndefined() // Cycle is a fatal error for usage }) }) // ============================================================================ // Focused Unit Tests for TypeInferenceEngine // ============================================================================ describe('Isolated TypeInferenceEngine', () => { it('should correctly intersect two relations that both resolve to the same type', () => { // 1. Setup: Manually create the AST and SymbolTable const ast: SchemaAST = { definitions: [ { type: 'definition', name: 'user', relations: [], permissions: [], }, { type: 'definition', name: 'role', relations: [ { name: 'member', types: [{ typeName: 'user' }], }, ], permissions: [], }, { type: 'definition', name: 'document', relations: [ { name: 'primary_contact', types: [{ typeName: 'user' }], }, { name: 'project_lead', types: [{ typeName: 'role', relation: 'member' }], }, ], permissions: [ { name: 'sign_off', expression: { type: 'intersection', operands: [ { type: 'identifier', name: 'primary_contact' }, { type: 'identifier', name: 'project_lead' }, ], }, }, ], }, ], } const symbolTable = new SymbolTable() for (const def of ast.definitions) { symbolTable.addDefinition(def as ObjectTypeDefinition) } const typeInference = new TypeInferenceEngine(symbolTable) // 2. Act: Directly call inferExpressionType on the intersection expression const signOffPermission = (ast.definitions[2] as ObjectTypeDefinition) .permissions[0] if (!signOffPermission) { throw new Error('sign_off permission not found in test setup') } const inferredTypes = typeInference.inferExpressionType( 'document', signOffPermission.expression, ) // 3. Assert: Check if the result is as expected expect(inferredTypes).toEqual([{ typeName: 'user' }]) }) })