import { describe, expect, it } from 'vitest' import { FLOOR_BY_GRAIN, PERMISSION_ACTIONS, PERMISSION_ACTION_ENUM, PERMISSION_DATA_ACTIONS, PERMISSION_FLOOR_ACTIONS, PERMISSION_GRAINS, PERMISSION_STRUCTURAL_ACTIONS, RESERVED_NODE_CODES, SCOPE_TIER_ACTION, buildPermissionPath, floorPathsForNode, grainOfNodePath, isPermissionAction, isValidPermissionPath, parsePermissionPath, permissionActionEnumName, renderActionVocabularyBlock, renderPermissionFloorBlock, validateNodeCode, } from '../permission-actions.js' describe('vocabulary invariants', () => { it('carries exactly 12 actions, no duplicates', () => { expect(PERMISSION_ACTIONS).toHaveLength(12) expect(new Set(PERMISSION_ACTIONS).size).toBe(12) }) it('structural ∪ data = the 12, disjoint', () => { const union = [...PERMISSION_STRUCTURAL_ACTIONS, ...PERMISSION_DATA_ACTIONS] expect(union).toHaveLength(12) expect(new Set(union)).toEqual(new Set(PERMISSION_ACTIONS)) }) it('floor (7) ⊆ the 12 and includes every structural action', () => { expect(PERMISSION_FLOOR_ACTIONS).toHaveLength(7) for (const a of PERMISSION_FLOOR_ACTIONS) expect(isPermissionAction(a)).toBe(true) for (const a of PERMISSION_STRUCTURAL_ACTIONS) { expect(PERMISSION_FLOOR_ACTIONS).toContain(a) } }) it('enum map covers the 12 + the read.all tier, PascalCase members', () => { expect(Object.keys(PERMISSION_ACTION_ENUM)).toHaveLength(13) for (const a of PERMISSION_ACTIONS) { expect(PERMISSION_ACTION_ENUM[a]).toBe(a.charAt(0).toUpperCase() + a.slice(1)) } expect(PERMISSION_ACTION_ENUM[SCOPE_TIER_ACTION]).toBe('Read') expect(permissionActionEnumName('read.all')).toBe('Read') expect(permissionActionEnumName('manage')).toBeNull() }) it('FLOOR_BY_GRAIN: access-only on app/module, full floor on section/resource', () => { expect(FLOOR_BY_GRAIN.application).toEqual(['access']) expect(FLOOR_BY_GRAIN.module).toEqual(['access']) expect(FLOOR_BY_GRAIN.section).toEqual(PERMISSION_FLOOR_ACTIONS) expect(FLOOR_BY_GRAIN.resource).toEqual(PERMISSION_FLOOR_ACTIONS) }) }) describe('parsePermissionPath — the 6 legal shapes', () => { it('2-seg → application grain', () => { expect(parsePermissionPath('crm.access')).toEqual({ grain: 'application', appCode: 'crm', moduleCode: undefined, sectionCode: undefined, resourceCode: undefined, nodeCode: 'crm', nodePath: 'crm', action: 'access', isScopeTier: false, }) }) it('3-seg → module grain', () => { const p = parsePermissionPath('crm.pipeline.access') expect(p).toMatchObject({ grain: 'module', appCode: 'crm', moduleCode: 'pipeline', nodeCode: 'pipeline', nodePath: 'crm.pipeline', action: 'access', isScopeTier: false, }) }) it('4-seg → section grain', () => { const p = parsePermissionPath('crm.pipeline.opportunites.read') expect(p).toMatchObject({ grain: 'section', appCode: 'crm', moduleCode: 'pipeline', sectionCode: 'opportunites', nodeCode: 'opportunites', nodePath: 'crm.pipeline.opportunites', action: 'read', }) }) it('5-seg read.all → section scope tier', () => { const p = parsePermissionPath('crm.pipeline.opportunites.read.all') expect(p).toMatchObject({ grain: 'section', sectionCode: 'opportunites', nodeCode: 'opportunites', nodePath: 'crm.pipeline.opportunites', action: 'read.all', isScopeTier: true, }) }) it('5-seg other action → resource grain', () => { const p = parsePermissionPath('crm.pipeline.opportunites.devis.create') expect(p).toMatchObject({ grain: 'resource', appCode: 'crm', moduleCode: 'pipeline', sectionCode: 'opportunites', resourceCode: 'devis', nodeCode: 'devis', nodePath: 'crm.pipeline.opportunites.devis', action: 'create', isScopeTier: false, }) }) it('6-seg read.all → resource scope tier', () => { const p = parsePermissionPath('crm.pipeline.opportunites.devis.read.all') expect(p).toMatchObject({ grain: 'resource', resourceCode: 'devis', nodeCode: 'devis', nodePath: 'crm.pipeline.opportunites.devis', action: 'read.all', isScopeTier: true, }) }) it('kebab-case segments with digits/dashes parse', () => { expect(parsePermissionPath('hr-portal.time2.entries.read')).not.toBeNull() }) }) describe('parsePermissionPath — rejections', () => { it.each([ ['flat', 'single segment'], ['crm.pipeline.opportunites.manage', 'unknown action'], ['crm.pipeline.opportunites.devis.all', 'trailing all without read'], ['crm.read.all', 'app-grain scope tier'], ['crm.pipeline.read.all', 'module-grain scope tier (historical NULL-action bug)'], ['crm.pipeline.opportunites.devis.extra.read', '6-seg non-tier'], ['a.b.c.d.e.read.all', '7 segments'], ['Crm.pipeline.opportunites.read', 'uppercase segment'], ['crm..opportunites.read', 'empty segment'], ['crm.pipeline.opportunites.', 'trailing dot'], ])('rejects %s (%s)', (path) => { expect(parsePermissionPath(path)).toBeNull() expect(isValidPermissionPath(path)).toBe(false) }) it('a section literally coded read still parses when unambiguous', () => { // `read` as a SECTION code only collides on the `.all` suffix — the // reserved-code guard exists precisely because of the ambiguous cases. const p = parsePermissionPath('crm.pipeline.read.create') expect(p).toMatchObject({ grain: 'section', sectionCode: 'read', action: 'create' }) }) }) describe('helpers', () => { it('grainOfNodePath maps 1..4 segments, rejects the rest', () => { expect(grainOfNodePath('crm')).toBe('application') expect(grainOfNodePath('crm.pipeline')).toBe('module') expect(grainOfNodePath('crm.pipeline.opportunites')).toBe('section') expect(grainOfNodePath('crm.pipeline.opportunites.devis')).toBe('resource') expect(grainOfNodePath('a.b.c.d.e')).toBeNull() expect(grainOfNodePath('')).toBeNull() expect(grainOfNodePath('Bad.segment')).toBeNull() }) it('buildPermissionPath round-trips through parsePermissionPath', () => { for (const nodePath of ['crm', 'crm.pipeline', 'crm.pipeline.opportunites', 'crm.pipeline.opportunites.devis']) { const grain = grainOfNodePath(nodePath)! for (const action of FLOOR_BY_GRAIN[grain]) { const path = buildPermissionPath(nodePath, action) const parsed = parsePermissionPath(path) expect(parsed).not.toBeNull() expect(parsed!.nodePath).toBe(nodePath) expect(parsed!.action).toBe(action) expect(parsed!.grain).toBe(grain) } } }) it('buildPermissionPath throws on bad input instead of silently skipping', () => { expect(() => buildPermissionPath('a.b.c.d.e', 'read')).toThrow(/malformed node path/) expect(() => buildPermissionPath('crm.pipeline', 'manage')).toThrow(/unknown action/) }) it('floorPathsForNode derives the per-grain floor', () => { expect(floorPathsForNode('crm')).toEqual(['crm.access']) expect(floorPathsForNode('crm.pipeline')).toEqual(['crm.pipeline.access']) expect(floorPathsForNode('crm.pipeline.opportunites')).toEqual([ 'crm.pipeline.opportunites.access', 'crm.pipeline.opportunites.lookup', 'crm.pipeline.opportunites.read', 'crm.pipeline.opportunites.create', 'crm.pipeline.opportunites.update', 'crm.pipeline.opportunites.delete', 'crm.pipeline.opportunites.execute', ]) expect(floorPathsForNode('crm.pipeline.opportunites.devis')).toHaveLength(7) expect(() => floorPathsForNode('a.b.c.d.e')).toThrow(/malformed node path/) }) it('validateNodeCode flags exactly the reserved codes', () => { for (const code of RESERVED_NODE_CODES) { expect(validateNodeCode(code)).toMatch(/reserved/) } expect(validateNodeCode('opportunites')).toBeNull() expect(validateNodeCode('readings')).toBeNull() // whole-code match only }) }) describe('rendered markdown blocks', () => { it('action vocabulary block carries the markers and all 12 actions', () => { const block = renderActionVocabularyBlock() expect(block).toContain('') for (const a of PERMISSION_ACTIONS) expect(block).toContain(`\`${a}\``) }) it('floor block carries one row per grain matching FLOOR_BY_GRAIN', () => { const block = renderPermissionFloorBlock() expect(block).toContain('