// @vitest-environment jsdom // // `/admin/` — per-entity CRUD. It resolves the entity spec from the // capability map (useParams + useCapabilityManifest + deriveEntityAdmins) and // renders one of three observable states: a loading skeleton, an "unknown // entity" banner, or the bound CRUD surface (heading + AutoForm + DataTable). // We mock @voltro/web (useParams + the AutoForm/DataTable components) and // @voltro/client (the discovery + access hooks). // // The access cases below are the point of this file. The page must gate on the // DECLARED access the manifest carries, never on a scope string it invented, // and it must SHOW an `unknown` decision instead of hiding it — a per-row guard // is the server's to answer, and hiding it empties the admin for every // multi-tenant app whose subjects hold no global scopes. import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { act, createElement, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { I18nProvider } from '@voltro/i18n' import enCatalog from '../../locales/en' const params = vi.fn<() => { entity?: string }>() vi.mock('@voltro/web', () => ({ useParams: () => params(), AutoForm: ({ mutation }: { mutation: string }) => createElement('div', { 'data-testid': 'auto-form' }, mutation), DataTable: ({ query, rowActions }: { query: string; rowActions?: (r: Record) => ReactNode }) => createElement('div', { 'data-testid': 'data-table' }, query, rowActions?.({ id: 'r1', entryNo: 'e1' })), })) interface ManifestState { manifest: unknown; loading: boolean } const capabilityManifest = vi.fn<() => ManifestState>() const entityAdmins = vi.fn<() => Array>>() const accessDecision = vi.fn<() => 'allowed' | 'denied' | 'unknown'>() const mutate = vi.fn() vi.mock('@voltro/client', () => ({ useCapabilityManifest: () => capabilityManifest(), deriveEntityAdmins: () => entityAdmins(), useAccessDecision: () => accessDecision(), requiredScopes: (guards?: ReadonlyArray<{ kind: string; scope?: ReadonlyArray }>) => (guards ?? []).flatMap((g) => (g.kind === 'scope' ? [...(g.scope ?? [])] : [])), openAccessReason: (guards?: ReadonlyArray<{ kind: string; reason?: string }>) => (guards ?? []).find((g) => g.kind === 'open')?.reason, useMutation: () => ({ mutate }), useProvenance: () => ({ data: undefined, loading: false, error: undefined }), })) const { default: EntityPage, meta, renderMode } = await import('./[entity]/page') ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true let container: HTMLDivElement let root: Root // The page uses /useTFn, so wrap renders in the framework's . const render = (node: ReactNode): void => { container = document.createElement('div') document.body.appendChild(container) act(() => { root = createRoot(container) root.render( createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }), ) }) } const cell = (id: string): string | null => container.querySelector(`[data-testid="${id}"]`)?.textContent ?? null /** A derived spec in its current shape. */ const spec = (over: Record = {}): Record => ({ table: 'todos', columns: [{ name: 'id' }, { name: 'title' }], serverOnlyColumns: [], sensitiveColumns: [], reactive: true, pkColumn: 'id', editable: true, list: { tag: 'todos.list' }, create: { tag: 'todos.create', guards: [{ kind: 'scope', scope: ['todos:manage'], mode: 'all', resourceScoped: false }] }, update: {}, delete: { tag: 'todos.delete' }, ...over, }) const showEntity = (over: Record = {}): void => { params.mockReturnValue({ entity: (over['table'] as string) ?? 'todos' }) capabilityManifest.mockReturnValue({ manifest: {}, loading: false }) entityAdmins.mockReturnValue([spec(over)]) render(createElement(EntityPage)) } beforeEach(() => { params.mockReset() capabilityManifest.mockReset() entityAdmins.mockReset() accessDecision.mockReset() mutate.mockReset() accessDecision.mockReturnValue('allowed') }) afterEach(() => { if (root) act(() => root.unmount()) container?.remove() document.body.innerHTML = '' }) describe('entity page — config', () => { test('is an SSR page', () => { expect(renderMode).toBe('ssr') expect(meta({ locale: 'en' }).title).toBe('Entity') }) }) describe('entity page — render states', () => { test('shows a skeleton while the manifest is still loading', () => { params.mockReturnValue({ entity: 'todos' }) capabilityManifest.mockReturnValue({ manifest: undefined, loading: true }) entityAdmins.mockReturnValue([]) render(createElement(EntityPage)) expect(container.querySelector('.skeleton')).toBeTruthy() }) test('shows an unknown-entity banner when the entity is not in the map', () => { params.mockReturnValue({ entity: 'ghosts' }) capabilityManifest.mockReturnValue({ manifest: {}, loading: false }) entityAdmins.mockReturnValue([{ table: 'todos' }]) render(createElement(EntityPage)) expect(container.querySelector('.banner')!.textContent).toContain('No entity') expect(container.textContent).toContain('ghosts') }) test('renders the bound CRUD surface (heading + AutoForm + DataTable) for a known entity', () => { showEntity() expect(container.querySelector('h1')!.textContent).toBe('todos') expect(cell('auto-form')).toBe('todos.create') expect(cell('data-table')).toContain('todos.list') }) }) describe('entity page — gates on the DECLARED access', () => { test('shows the create form when the api says the caller may create', () => { accessDecision.mockReturnValue('allowed') showEntity() expect(cell('auto-form')).toBe('todos.create') }) test('hides it on a DEFINITE denial', () => { accessDecision.mockReturnValue('denied') showEntity() expect(container.querySelector('[data-testid="auto-form"]')).toBeNull() // …and the list is still there. A denied write is not a denied read. expect(cell('data-table')).toContain('todos.list') }) test('SHOWS it on an indeterminate decision, and says why', () => { // The false-deny trap: a per-row guard cannot be pre-computed in a browser. // Hiding it would empty the admin for exactly the multi-tenant apps whose // subjects carry no global scopes. accessDecision.mockReturnValue('unknown') showEntity() expect(cell('auto-form')).toBe('todos.create') expect(container.textContent).toContain('checks permission per row') }) test('names the scope the api ACTUALLY requires, so a refusal is readable', () => { showEntity() expect(container.textContent).toContain('requires todos:manage') }) test('reports an openAccess decision with its reason', () => { showEntity({ create: { tag: 'todos.create', guards: [{ kind: 'open', reason: 'demo seeding' }] } }) expect(container.textContent).toContain('open access — demo seeding') }) test('says so when a procedure declared no access at all', () => { showEntity({ create: { tag: 'todos.create' } }) expect(container.textContent).toContain('no access decision declared') }) test('renders nothing to create when the api exposes no create mutation', () => { showEntity({ create: {} }) expect(container.querySelector('[data-testid="auto-form"]')).toBeNull() }) }) describe('entity page — the three exposure axes', () => { test('names the .serverOnly() columns it withheld, and why', () => { showEntity({ serverOnlyColumns: ['pwHash', 'internalNote'] }) expect(container.textContent).toContain('2 server-only columns') expect(container.textContent).toContain('pwHash, internalNote') expect(container.textContent).toContain('cannot be submitted') }) test('flags .sensitive() columns as export-masked, NOT as hidden', () => { // The category error to avoid: `.sensitive()` is the export axis. The value // is real wire data the operator is meant to see. showEntity({ sensitiveColumns: ['email'] }) expect(container.textContent).toContain('masked in exports: email') }) test('says nothing when no column is marked', () => { showEntity() expect(container.textContent).not.toContain('server-only') expect(container.textContent).not.toContain('masked in exports') }) }) describe('entity page — row identity', () => { test('keys the delete on the pk the api declares, not a hard-coded id', () => { showEntity({ pkColumn: 'entryNo' }) const del = container.querySelector('button.danger')! act(() => { del.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) // A hard-coded `id` would have sent `{ id: 'r1' }` — or `{ id: '' }` for a // table that has no `id` column at all. expect(mutate).toHaveBeenCalledWith({ entryNo: 'e1' }) }) test('offers no delete for a table with no addressable row', () => { showEntity({ editable: false, pkColumn: undefined }) expect(container.querySelector('button.danger')).toBeNull() expect(container.textContent).toContain('No single primary key') }) })