import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { deriveEntityRoles, loadStateGrants, type StateGrants } from '../derive-roles.js' import type { ManifestEntity } from '../types.js' // Role derivation from the seeded state — what makes the permission-negative // scenarios actually exist: with the historical default([]) nobody hand-fed // rolesWithoutAccess, so ZERO negative tests were ever emitted (audit H10). const entity = (overrides: Partial = {}): ManifestEntity => ({ name: 'Employee', section: 'employees', rolesWithRead: [], rolesWithCreate: [], rolesWithUpdate: [], rolesWithDelete: [], rolesWithoutAccess: [], fixture: {}, ...overrides, }) const grants = (): StateGrants => ({ roles: ['hr-admin', 'hr-operator', 'hr-viewer'], grantsByRole: new Map([ ['hr-admin', new Set([ 'hr.staff.employees.read', 'hr.staff.employees.read.all', 'hr.staff.employees.create', 'hr.staff.employees.update', 'hr.staff.employees.delete', ])], ['hr-operator', new Set(['hr.staff.employees.read', 'hr.staff.employees.update'])], ['hr-viewer', new Set(['hr.staff.contracts.read'])], ]), }) describe('deriveEntityRoles', () => { it('fills every EMPTY array from the state grants — negatives included', () => { const derived = deriveEntityRoles(entity(), grants(), 'hr', 'staff') expect(derived.rolesWithRead).toEqual(['hr-admin', 'hr-operator']) expect(derived.rolesWithCreate).toEqual(['hr-admin']) expect(derived.rolesWithUpdate).toEqual(['hr-admin', 'hr-operator']) expect(derived.rolesWithDelete).toEqual(['hr-admin']) // hr-viewer reads ANOTHER section — it is exactly the role the negative // scenario must exercise on THIS one. expect(derived.rolesWithoutAccess).toEqual(['hr-viewer']) }) it('the read.all tier counts as read', () => { const g: StateGrants = { roles: ['auditor'], grantsByRole: new Map([['auditor', new Set(['hr.staff.employees.read.all'])]]), } const derived = deriveEntityRoles(entity(), g, 'hr', 'staff') expect(derived.rolesWithRead).toEqual(['auditor']) expect(derived.rolesWithoutAccess).toEqual([]) }) it('authored arrays WIN — derivation never overwrites explicit intent', () => { const derived = deriveEntityRoles( entity({ rolesWithRead: ['custom-role'], rolesWithoutAccess: ['banned'] }), grants(), 'hr', 'staff', ) expect(derived.rolesWithRead).toEqual(['custom-role']) expect(derived.rolesWithoutAccess).toEqual(['banned']) // Untouched empties still derive: expect(derived.rolesWithCreate).toEqual(['hr-admin']) }) }) describe('loadStateGrants', () => { let root: string beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'ssmanifest-')) mkdirSync(join(root, '.smartstack', 'core-seed'), { recursive: true }) }) afterEach(() => rmSync(root, { recursive: true, force: true })) it('loads roles + grants from the app state file', () => { writeFileSync( join(root, '.smartstack', 'core-seed', 'hr.state.json'), JSON.stringify({ $schema: 'smartstack/core-seed-state', application: 'hr', roles: [{ code: 'hr-admin', name: 'Administration HR' }], rolePermissions: [{ roleCode: 'hr-admin', permissionPath: 'hr.staff.employees.read' }], }), 'utf8', ) const { grants: g, warning } = loadStateGrants(root, 'hr') expect(warning).toBeUndefined() expect(g?.roles).toEqual(['hr-admin']) expect(g?.grantsByRole.get('hr-admin')?.has('hr.staff.employees.read')).toBe(true) }) it('missing state file → null + warning, never a throw', () => { const { grants: g, warning } = loadStateGrants(root, 'hr') expect(g).toBeNull() expect(warning).toContain('not found') }) it('wrong $schema → null + warning (lenient by design)', () => { writeFileSync(join(root, '.smartstack', 'core-seed', 'hr.state.json'), '{"$schema":"other"}', 'utf8') const { grants: g, warning } = loadStateGrants(root, 'hr') expect(g).toBeNull() expect(warning).toContain('$schema') }) })