diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a247c..d16cc56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ ## Unreleased +### Tests + +- **Vitest coverage for the 2026-05-10/11 GUI batch (Audit 2026-05-11 Fix 12).** + The original GUI-batch commit `661b6db` claimed `npx tsc --noEmit PASS` + but shipped no Vitest cases for the new surfaces. The regression- + prevention layer was missing — a future refactor of `KeysPage`'s + assign modal could silently drop scope_type handling, the LOW-1 demo + banner could be hidden by a stray predicate flip, the LOW-11 hide of + the delete button on default roles could disappear and let operators + click straight into a backend 409, and nothing would surface in CI. + This closure adds 35 new test cases across five files: + `web/src/pages/auth/UsersPage.test.tsx` (new, 8 cases pinning the + active/deactivated/reactivate flow + provider filter + empty state + + loading state), `web/src/pages/auth/AuthSettingsPage.test.tsx` + (extended +4 cases pinning the MED-12 runtime-config panel — + alphabetical sort, `(empty)` placeholder, 403 silent-hide), + `web/src/pages/auth/KeysPage.test.tsx` (extended +8 cases pinning + the HIGH-10 GUI half — scope_type=global/profile/issuer body shape, + expires_at omission vs RFC3339 promotion, whitespace-only scope_id + rejection, demo-anon row mutation-button hide), + `web/src/pages/auth/RoleDetailPage.test.tsx` (new, 9 cases pinning + the MED-8 scope picker + the LOW-11 default-role delete-button hide + via the `DEFAULT_ROLE_IDS` set against `r-admin` + `r-auditor`), + `web/src/components/AuthProvider.test.tsx` (new, 5 cases pinning the + LOW-1 demo-banner visibility predicate — `authType==='none' && + !loading` — across happy/api-key/oidc/loading/rejected branches; the + rejected-fetch path keeps the banner visible because the catch + treats it as an old-server-fallback to demo-mode, and that behavior + is pinned here so a future change surfaces in the diff). 40/40 + test-file-scoped pass; `tsc --noEmit` clean. + ### Security - **Demo-mode residual-grants detector + cleanup endpoint + CI guard (Audit 2026-05-11 A-8).** diff --git a/web/src/components/AuthProvider.test.tsx b/web/src/components/AuthProvider.test.tsx new file mode 100644 index 0000000..c5ef50c --- /dev/null +++ b/web/src/components/AuthProvider.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; + +// ============================================================================= +// Audit 2026-05-11 Fix 12 — AuthProvider demo-mode banner regression coverage. +// +// The LOW-1 closure added a sticky red banner that renders when the +// server reports `auth_type=none`. Pre-fix-12 there was no test pinning +// the visibility-condition contract, so a future refactor could silently +// flip the predicate (e.g. swap `authType === 'none'` for `!authRequired` +// — looks equivalent but treats backwards-compat fallback the same as +// demo mode). This block pins: +// - auth_type='none' → banner visible (data-testid="demo-mode-banner"). +// - auth_type='api-key' → banner absent. +// - auth_type='oidc' → banner absent. +// - getAuthInfo still in flight → banner absent (avoid the flash where +// the page momentarily shows it before the fetch resolves). +// - getAuthInfo rejected → banner absent (the catch branch keeps the +// default authType='none' state in raw values, but loading→true→false +// transitions complete; the banner predicate is `authType==='none' && +// !loading` and the rejection path doesn't mutate authType, so the +// state lingers at 'none'. That looks like a footgun BUT the rejection +// catch comment "assume no auth required (server may be old version)" +// means downstream code treats this as anonymous — so the banner +// SHOULD render. This test pins the actual behavior, not the spec's +// assumption.) +// ============================================================================= + +vi.mock('../api/client', () => ({ + getAuthInfo: vi.fn(), + checkAuth: vi.fn(), + setApiKey: vi.fn(), + logout: vi.fn(), +})); + +import AuthProvider from './AuthProvider'; +import * as client from '../api/client'; + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); +}); + +describe('AuthProvider — LOW-1 demo-mode banner', () => { + it('renders the banner when getAuthInfo reports auth_type=none', async () => { + vi.mocked(client.getAuthInfo).mockResolvedValue({ + auth_type: 'none', + required: false, + }); + + render( + +
child
+
, + ); + + await waitFor(() => screen.getByTestId('demo-mode-banner')); + expect(screen.getByTestId('demo-mode-banner').textContent) + .toContain('Demo mode active'); + expect(screen.getByTestId('demo-mode-banner').getAttribute('role')) + .toBe('alert'); + }); + + it('hides the banner when getAuthInfo reports auth_type=api-key', async () => { + vi.mocked(client.getAuthInfo).mockResolvedValue({ + auth_type: 'api-key', + required: true, + }); + + render( + +
child
+
, + ); + + // Wait for the auth-info fetch to complete (children render after + // the provider's loading state flips), then assert no banner. + await waitFor(() => screen.getByTestId('child')); + expect(screen.queryByTestId('demo-mode-banner')).toBeNull(); + }); + + it('hides the banner when getAuthInfo reports auth_type=oidc', async () => { + vi.mocked(client.getAuthInfo).mockResolvedValue({ + auth_type: 'oidc', + required: true, + }); + + render( + +
child
+
, + ); + + await waitFor(() => screen.getByTestId('child')); + expect(screen.queryByTestId('demo-mode-banner')).toBeNull(); + }); + + it('hides the banner while loading (no flash before fetch resolves)', () => { + // Never-resolving promise so loading stays true. The banner's + // predicate is `authType === 'none' && !loading`, so the + // synchronous render must NOT show the banner. + vi.mocked(client.getAuthInfo).mockReturnValue(new Promise(() => {})); + + render( + +
child
+
, + ); + + // Children render eagerly; banner is gated on !loading so it + // shouldn't show up on the initial paint. + expect(screen.queryByTestId('demo-mode-banner')).toBeNull(); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('shows the banner when getAuthInfo rejects (fallback treats as anonymous demo mode)', async () => { + // The catch branch in AuthProvider's mount effect treats a failed + // /auth/info call as "assume no auth required (server may be old + // version)". authType state stays at its default 'none' value and + // loading flips to false in the finally clause, so the banner's + // predicate fires. This pins that fallback behavior — a future + // change that resets authType to something else on error would + // surface as a test failure. + vi.mocked(client.getAuthInfo).mockRejectedValue(new Error('network')); + + render( + +
child
+
, + ); + + await waitFor(() => screen.getByTestId('demo-mode-banner')); + }); +}); diff --git a/web/src/pages/auth/AuthSettingsPage.test.tsx b/web/src/pages/auth/AuthSettingsPage.test.tsx index 05b88a7..290381a 100644 --- a/web/src/pages/auth/AuthSettingsPage.test.tsx +++ b/web/src/pages/auth/AuthSettingsPage.test.tsx @@ -12,6 +12,12 @@ import type { ReactNode } from 'react'; vi.mock('../../api/client', () => ({ authMe: vi.fn(), authBootstrapAvailable: vi.fn(), + // Audit 2026-05-11 Fix 12 — runtime-config panel coverage. The page + // calls authRuntimeConfig via TanStack Query (retry: false), so a + // rejected mock makes the panel quietly absent. Tests mock it as + // needed; the two pre-existing tests rely on the panel being absent + // (no positive assertion against it) so the rejected default works. + authRuntimeConfig: vi.fn(), })); import AuthSettingsPage from './AuthSettingsPage'; @@ -69,3 +75,104 @@ describe('AuthSettingsPage', () => { expect(screen.getByTestId('auth-settings-bootstrap-status').textContent).toMatch(/OPEN/); }); }); + +// ============================================================================= +// Audit 2026-05-11 Fix 12 — AuthSettingsPage runtime-config panel coverage. +// +// The MED-12 closure added the auth-runtime-config panel +// (`data-testid="auth-settings-runtime-config"`) but the pre-existing tests +// don't exercise it. This block pins: +// - Happy path renders one per key in the flat map. +// - Sort is alphabetical by key — operators rely on stable ordering when +// correlating CERTCTL_* config across logs and the GUI. +// - Empty string values render the "(empty)" placeholder, NOT a blank cell +// (otherwise the row visually disappears). +// - 403 / rejected query hides the panel silently — non-admins shouldn't +// see a half-rendered shell. +// ============================================================================= + +function setupAuthMeAdmin() { + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'admin', + actor_type: 'APIKey', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [{ permission: 'auth.role.assign', scope_type: 'global' }], + }); + vi.mocked(client.authBootstrapAvailable).mockResolvedValue({ available: false }); +} + +describe('AuthSettingsPage — runtime config panel (MED-12)', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it('renders one table row per runtime-config key', async () => { + setupAuthMeAdmin(); + vi.mocked(client.authRuntimeConfig).mockResolvedValue({ + CERTCTL_AUTH_TYPE: 'oidc', + CERTCTL_BREAKGLASS_ENABLED: 'false', + CERTCTL_TRUSTED_PROXIES_COUNT: '2', + }); + + renderWithProviders(); + await waitFor(() => screen.getByTestId('auth-settings-runtime-config')); + + const panel = screen.getByTestId('auth-settings-runtime-config'); + expect(panel.textContent).toContain('CERTCTL_AUTH_TYPE'); + expect(panel.textContent).toContain('oidc'); + expect(panel.textContent).toContain('CERTCTL_BREAKGLASS_ENABLED'); + expect(panel.textContent).toContain('false'); + expect(panel.textContent).toContain('CERTCTL_TRUSTED_PROXIES_COUNT'); + expect(panel.textContent).toContain('2'); + }); + + it('sorts rows alphabetically by key (stable correlation with log scraping)', async () => { + setupAuthMeAdmin(); + vi.mocked(client.authRuntimeConfig).mockResolvedValue({ + // Intentionally out of order — the sort comparator should normalize. + CERTCTL_TRUSTED_PROXIES_COUNT: '0', + CERTCTL_AUTH_TYPE: 'api-key', + CERTCTL_BREAKGLASS_ENABLED: 'true', + }); + + renderWithProviders(); + await waitFor(() => screen.getByTestId('auth-settings-runtime-config')); + + const panel = screen.getByTestId('auth-settings-runtime-config'); + const auth = panel.textContent!.indexOf('CERTCTL_AUTH_TYPE'); + const bg = panel.textContent!.indexOf('CERTCTL_BREAKGLASS_ENABLED'); + const tp = panel.textContent!.indexOf('CERTCTL_TRUSTED_PROXIES_COUNT'); + expect(auth).toBeGreaterThan(-1); + expect(bg).toBeGreaterThan(auth); + expect(tp).toBeGreaterThan(bg); + }); + + it('empty value renders the "(empty)" placeholder, not a blank cell', async () => { + setupAuthMeAdmin(); + vi.mocked(client.authRuntimeConfig).mockResolvedValue({ + CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID: '', + }); + + renderWithProviders(); + await waitFor(() => screen.getByTestId('auth-settings-runtime-config')); + + expect(screen.getByTestId('auth-settings-runtime-config').textContent) + .toContain('(empty)'); + }); + + it('rejected runtime-config query hides the panel silently (e.g. 403 for non-admins)', async () => { + setupAuthMeAdmin(); + vi.mocked(client.authRuntimeConfig).mockRejectedValue(new Error('HTTP 403: forbidden')); + + renderWithProviders(); + // Wait for the identity surface so we know render completed. + await waitFor(() => screen.getByTestId('auth-settings-roles')); + + // Panel never renders — non-admins must not see the shell of a + // surface they can't read. + expect(screen.queryByTestId('auth-settings-runtime-config')).toBeNull(); + }); +}); diff --git a/web/src/pages/auth/KeysPage.test.tsx b/web/src/pages/auth/KeysPage.test.tsx index 656de52..002afe4 100644 --- a/web/src/pages/auth/KeysPage.test.tsx +++ b/web/src/pages/auth/KeysPage.test.tsx @@ -106,8 +106,193 @@ describe('KeysPage', () => { }); fireEvent.click(screen.getByTestId('assign-role-submit')); - await waitFor(() => - expect(client.authAssignKeyRole).toHaveBeenCalledWith('alice', 'r-operator'), - ); + await waitFor(() => expect(client.authAssignKeyRole).toHaveBeenCalledTimes(1)); + const args = vi.mocked(client.authAssignKeyRole).mock.calls[0]; + expect(args[0]).toBe('alice'); + expect(args[1]).toBe('r-operator'); + // Default state: scope_type=global, no scope_id, no expires_at. + expect(args[2]).toMatchObject({ scope_type: 'global' }); + }); +}); + +// ============================================================================= +// Audit 2026-05-11 Fix 12 — HIGH-10 GUI half scope/expiry coverage. +// +// The HIGH-10 GUI half added the scope picker + scope_id input + expires_at +// datetime-local to the assign modal, but the pre-existing test only +// asserted the (actor, role) pair on the call. This block pins the third +// opts arg's shape so a future refactor that drops the scope wiring +// surfaces in the diff. Test cases mirror the spec's Phase 3 enumeration: +// - global scope → no scope_id field visible + scope_type='global' +// - profile scope → scope_id input visible + required, body carries +// scope_type='profile' + scope_id= +// - expires_at empty → omitted (undefined) from body +// - expires_at filled → promoted to RFC3339 with :00Z suffix +// - actor-demo-anon row → no assign / no revoke buttons (system-managed) +// ============================================================================= + +async function openAssignModalForAlice() { + vi.mocked(client.authListKeys).mockResolvedValue([sampleKeys[0]]); + vi.mocked(client.authListRoles).mockResolvedValue([ + { id: 'r-operator', tenant_id: 't-default', name: 'operator' }, + ]); + vi.mocked(client.authAssignKeyRole).mockResolvedValue({}); + vi.mocked(client.authMe).mockResolvedValue(adminMe); + + renderWithProviders(); + await waitFor(() => screen.getByTestId('keys-assign-alice')); + fireEvent.click(screen.getByTestId('keys-assign-alice')); + await waitFor(() => screen.getByTestId('assign-role-modal')); + fireEvent.change(screen.getByTestId('assign-role-select'), { + target: { value: 'r-operator' }, + }); +} + +describe('KeysPage — HIGH-10 GUI half scope + expiry', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it('global scope hides the scope_id input', async () => { + await openAssignModalForAlice(); + // Default scope_type is 'global'; the conditional scope_id input + // is only rendered when scope_type !== 'global'. + expect(screen.getByTestId('assign-role-scope-type')).toBeInTheDocument(); + expect(screen.queryByTestId('assign-role-scope-id')).toBeNull(); + }); + + it('switching to profile scope reveals the scope_id input and marks it required', async () => { + await openAssignModalForAlice(); + + fireEvent.change(screen.getByTestId('assign-role-scope-type'), { + target: { value: 'profile' }, + }); + await waitFor(() => screen.getByTestId('assign-role-scope-id')); + + const scopeID = screen.getByTestId('assign-role-scope-id') as HTMLInputElement; + expect(scopeID.required).toBe(true); + expect(scopeID.placeholder).toContain('p-acme'); + }); + + it('profile scope submit sends {scope_type: profile, scope_id: }', async () => { + await openAssignModalForAlice(); + + fireEvent.change(screen.getByTestId('assign-role-scope-type'), { + target: { value: 'profile' }, + }); + await waitFor(() => screen.getByTestId('assign-role-scope-id')); + fireEvent.change(screen.getByTestId('assign-role-scope-id'), { + target: { value: ' p-acme-corp ' }, // whitespace deliberate; submit must trim + }); + fireEvent.click(screen.getByTestId('assign-role-submit')); + + await waitFor(() => expect(client.authAssignKeyRole).toHaveBeenCalledTimes(1)); + const [, , opts] = vi.mocked(client.authAssignKeyRole).mock.calls[0]; + if (!opts) throw new Error('opts arg missing'); + expect(opts).toMatchObject({ + scope_type: 'profile', + scope_id: 'p-acme-corp', + }); + }); + + it('issuer scope submit sends {scope_type: issuer, scope_id: }', async () => { + await openAssignModalForAlice(); + + fireEvent.change(screen.getByTestId('assign-role-scope-type'), { + target: { value: 'issuer' }, + }); + await waitFor(() => screen.getByTestId('assign-role-scope-id')); + fireEvent.change(screen.getByTestId('assign-role-scope-id'), { + target: { value: 'iss-internal-pki' }, + }); + fireEvent.click(screen.getByTestId('assign-role-submit')); + + await waitFor(() => expect(client.authAssignKeyRole).toHaveBeenCalledTimes(1)); + const [, , opts] = vi.mocked(client.authAssignKeyRole).mock.calls[0]; + if (!opts) throw new Error('opts arg missing'); + expect(opts.scope_type).toBe('issuer'); + expect(opts.scope_id).toBe('iss-internal-pki'); + }); + + it('global scope submit omits scope_id (undefined, not empty string)', async () => { + await openAssignModalForAlice(); + fireEvent.click(screen.getByTestId('assign-role-submit')); + + await waitFor(() => expect(client.authAssignKeyRole).toHaveBeenCalledTimes(1)); + const [, , opts] = vi.mocked(client.authAssignKeyRole).mock.calls[0]; + if (!opts) throw new Error('opts arg missing'); + expect(opts.scope_type).toBe('global'); + // The implementation explicitly passes undefined when scope_type==='global'. + expect(opts.scope_id).toBeUndefined(); + }); + + it('empty expires_at omits the field from the body', async () => { + await openAssignModalForAlice(); + fireEvent.click(screen.getByTestId('assign-role-submit')); + + await waitFor(() => expect(client.authAssignKeyRole).toHaveBeenCalledTimes(1)); + const [, , opts] = vi.mocked(client.authAssignKeyRole).mock.calls[0]; + if (!opts) throw new Error('opts arg missing'); + // The page converts an empty datetime-local value to undefined, NOT to + // an empty string. An empty string would fail the backend's RFC3339 + // parse with a confusing error; the GUI prevents that footgun. + expect(opts.expires_at).toBeUndefined(); + }); + + it('filled expires_at gets the :00Z UTC suffix appended', async () => { + await openAssignModalForAlice(); + fireEvent.change(screen.getByTestId('assign-role-expires-at'), { + target: { value: '2027-06-15T13:30' }, + }); + fireEvent.click(screen.getByTestId('assign-role-submit')); + + await waitFor(() => expect(client.authAssignKeyRole).toHaveBeenCalledTimes(1)); + const [, , opts] = vi.mocked(client.authAssignKeyRole).mock.calls[0]; + if (!opts) throw new Error('opts arg missing'); + // datetime-local emits "YYYY-MM-DDTHH:MM"; the page promotes to RFC3339 + // by appending :00Z. Operators wanting non-UTC must use curl. + expect(opts.expires_at).toBe('2027-06-15T13:30:00Z'); + }); + + it('profile scope with whitespace-only scope_id shows an inline error and does NOT POST', async () => { + await openAssignModalForAlice(); + + fireEvent.change(screen.getByTestId('assign-role-scope-type'), { + target: { value: 'profile' }, + }); + await waitFor(() => screen.getByTestId('assign-role-scope-id')); + fireEvent.change(screen.getByTestId('assign-role-scope-id'), { + target: { value: ' ' }, + }); + + // The form's native `required` attribute blocks submit when the + // input is empty after trimming, but a whitespace-only value + // bypasses native validation; the JS handler then sets a typed + // error and returns before calling the API. + const form = screen.getByTestId('assign-role-modal').querySelector('form')!; + fireEvent.submit(form); + + await waitFor(() => { + const modal = screen.getByTestId('assign-role-modal'); + expect(modal.textContent).toContain('scope_id is required when scope_type is profile'); + }); + expect(client.authAssignKeyRole).not.toHaveBeenCalled(); + }); + + it('actor-demo-anon row hides both assign and revoke buttons', async () => { + vi.mocked(client.authListKeys).mockResolvedValue([sampleKeys[1]]); // demo-anon row + vi.mocked(client.authListRoles).mockResolvedValue([]); + vi.mocked(client.authMe).mockResolvedValue(adminMe); + + renderWithProviders(); + await waitFor(() => screen.getByTestId('keys-table')); + + // The "(system-managed)" tag flags the row. + expect(screen.getByText('(system-managed)')).toBeInTheDocument(); + // Both action affordances are missing — reserved-actor mutation guard + // at the service layer would reject anyway; the GUI hides them. + expect(screen.queryByTestId('keys-assign-actor-demo-anon')).toBeNull(); + expect(screen.queryByTestId('keys-revoke-actor-demo-anon-r-admin')).toBeNull(); }); }); diff --git a/web/src/pages/auth/RoleDetailPage.test.tsx b/web/src/pages/auth/RoleDetailPage.test.tsx new file mode 100644 index 0000000..cc98ec9 --- /dev/null +++ b/web/src/pages/auth/RoleDetailPage.test.tsx @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// ============================================================================= +// Audit 2026-05-11 Fix 12 — RoleDetailPage regression coverage. +// +// The MED-8 GUI closure added the scope picker + scope_id input to the +// Add-permission form, and the LOW-11 closure hid the Delete button on +// the seven seeded default role ids. Neither change had a Vitest case. +// This block pins: +// - Default role (e.g. r-admin) renders the +// 'role-delete-disabled-tooltip' element + does NOT render the +// 'role-delete-button'. Hides the destructive button on system +// roles the server would refuse to delete anyway (DELETE → 409). +// - Custom role renders the 'role-delete-button' + does NOT render +// the tooltip. +// - Add-permission form with scope_type=global hides the scope_id +// input. +// - Add-permission form with scope_type=profile reveals the +// scope_id input + the Add button is disabled until scope_id is +// non-empty. +// - Submitting with profile scope POSTs body +// {permission, scope_type: 'profile', scope_id: }. +// - Submitting with global scope POSTs body {permission} (no +// scope_type / scope_id keys). +// ============================================================================= + +vi.mock('../../api/client', () => ({ + authGetRole: vi.fn(), + authListPermissions: vi.fn(), + authUpdateRole: vi.fn(), + authDeleteRole: vi.fn(), + authAddRolePermission: vi.fn(), + authRemoveRolePermission: vi.fn(), + authMe: vi.fn(), +})); + +import RoleDetailPage from './RoleDetailPage'; +import * as client from '../../api/client'; + +function renderRoute(ui: ReactNode, path = '/auth/roles/r-customrole') { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + + + } /> + + + , + ); +} + +const adminMe = { + actor_id: 'alice', + actor_type: 'APIKey', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [ + { permission: 'auth.role.edit', scope_type: 'global' as const }, + { permission: 'auth.role.delete', scope_type: 'global' as const }, + ], +}; + +const sampleCatalogue = [ + { id: 'p-cert-read', name: 'cert.read', namespace: 'cert', description: '' }, + { id: 'p-cert-issue', name: 'cert.issue', namespace: 'cert', description: '' }, + { id: 'p-profile-edit', name: 'profile.edit', namespace: 'profile', description: '' }, +]; + +function roleDetail(roleID: string, name: string) { + return { + role: { id: roleID, tenant_id: 't-default', name, description: '' }, + permissions: [], // empty so every catalogue entry is available + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + vi.mocked(client.authMe).mockResolvedValue(adminMe); + vi.mocked(client.authListPermissions).mockResolvedValue(sampleCatalogue); +}); + +describe('RoleDetailPage — LOW-11 default-role delete-button hide', () => { + it('default role (r-admin) renders the disabled tooltip + NO delete button', async () => { + vi.mocked(client.authGetRole).mockResolvedValue(roleDetail('r-admin', 'Admin')); + + renderRoute(, '/auth/roles/r-admin'); + await waitFor(() => screen.getByTestId('role-delete-disabled-tooltip')); + + expect(screen.getByTestId('role-delete-disabled-tooltip').textContent) + .toContain('System role'); + expect(screen.queryByTestId('role-delete-button')).toBeNull(); + }); + + it('default role (r-auditor) also hides delete', async () => { + vi.mocked(client.authGetRole).mockResolvedValue(roleDetail('r-auditor', 'Auditor')); + + renderRoute(, '/auth/roles/r-auditor'); + await waitFor(() => screen.getByTestId('role-delete-disabled-tooltip')); + expect(screen.queryByTestId('role-delete-button')).toBeNull(); + }); + + it('custom role renders the delete button + NO disabled tooltip', async () => { + vi.mocked(client.authGetRole).mockResolvedValue(roleDetail('r-customrole', 'Custom')); + + renderRoute(, '/auth/roles/r-customrole'); + await waitFor(() => screen.getByTestId('role-delete-button')); + + expect(screen.queryByTestId('role-delete-disabled-tooltip')).toBeNull(); + }); +}); + +describe('RoleDetailPage — MED-8 Add-permission scope picker', () => { + it('global scope hides the scope_id input', async () => { + vi.mocked(client.authGetRole).mockResolvedValue(roleDetail('r-customrole', 'Custom')); + + renderRoute(, '/auth/roles/r-customrole'); + await waitFor(() => screen.getByTestId('role-add-permission-scope-type')); + + // Default state — scope_type is 'global' so the conditional + // scope_id input is not in the DOM. + expect(screen.queryByTestId('role-add-permission-scope-id')).toBeNull(); + }); + + it('switching to profile scope reveals scope_id and gates the Add button', async () => { + vi.mocked(client.authGetRole).mockResolvedValue(roleDetail('r-customrole', 'Custom')); + + renderRoute(, '/auth/roles/r-customrole'); + await waitFor(() => screen.getByTestId('role-add-permission-select')); + + // Pick a permission first so the Add button's non-perm guard is satisfied. + fireEvent.change(screen.getByTestId('role-add-permission-select'), { + target: { value: 'cert.read' }, + }); + fireEvent.change(screen.getByTestId('role-add-permission-scope-type'), { + target: { value: 'profile' }, + }); + + await waitFor(() => screen.getByTestId('role-add-permission-scope-id')); + const submit = screen.getByTestId('role-add-permission-submit') as HTMLButtonElement; + // Empty scope_id → button disabled. + expect(submit.disabled).toBe(true); + + // Fill it; button enables. + fireEvent.change(screen.getByTestId('role-add-permission-scope-id'), { + target: { value: 'p-acme' }, + }); + expect(submit.disabled).toBe(false); + }); + + it('profile-scope submit POSTs body {permission, scope_type: profile, scope_id}', async () => { + vi.mocked(client.authGetRole).mockResolvedValue(roleDetail('r-customrole', 'Custom')); + vi.mocked(client.authAddRolePermission).mockResolvedValue({} as never); + + renderRoute(, '/auth/roles/r-customrole'); + await waitFor(() => screen.getByTestId('role-add-permission-select')); + + fireEvent.change(screen.getByTestId('role-add-permission-select'), { + target: { value: 'cert.issue' }, + }); + fireEvent.change(screen.getByTestId('role-add-permission-scope-type'), { + target: { value: 'profile' }, + }); + await waitFor(() => screen.getByTestId('role-add-permission-scope-id')); + fireEvent.change(screen.getByTestId('role-add-permission-scope-id'), { + target: { value: ' p-acme ' }, // whitespace deliberate; submit trims + }); + fireEvent.click(screen.getByTestId('role-add-permission-submit')); + + await waitFor(() => expect(client.authAddRolePermission).toHaveBeenCalledTimes(1)); + expect(client.authAddRolePermission).toHaveBeenCalledWith('r-customrole', { + permission: 'cert.issue', + scope_type: 'profile', + scope_id: 'p-acme', + }); + }); + + it('global-scope submit POSTs body {permission} only (no scope_type / scope_id)', async () => { + vi.mocked(client.authGetRole).mockResolvedValue(roleDetail('r-customrole', 'Custom')); + vi.mocked(client.authAddRolePermission).mockResolvedValue({} as never); + + renderRoute(, '/auth/roles/r-customrole'); + await waitFor(() => screen.getByTestId('role-add-permission-select')); + + fireEvent.change(screen.getByTestId('role-add-permission-select'), { + target: { value: 'cert.read' }, + }); + // scope_type stays at 'global' (default). + fireEvent.click(screen.getByTestId('role-add-permission-submit')); + + await waitFor(() => expect(client.authAddRolePermission).toHaveBeenCalledTimes(1)); + expect(client.authAddRolePermission).toHaveBeenCalledWith('r-customrole', { + permission: 'cert.read', + }); + // The submit handler intentionally omits the scope keys on global + // so the backend's default-scope path runs. Asserting the body + // shape pins that contract. + }); + + it('issuer-scope submit POSTs body {permission, scope_type: issuer, scope_id}', async () => { + vi.mocked(client.authGetRole).mockResolvedValue(roleDetail('r-customrole', 'Custom')); + vi.mocked(client.authAddRolePermission).mockResolvedValue({} as never); + + renderRoute(, '/auth/roles/r-customrole'); + await waitFor(() => screen.getByTestId('role-add-permission-select')); + + fireEvent.change(screen.getByTestId('role-add-permission-select'), { + target: { value: 'profile.edit' }, + }); + fireEvent.change(screen.getByTestId('role-add-permission-scope-type'), { + target: { value: 'issuer' }, + }); + await waitFor(() => screen.getByTestId('role-add-permission-scope-id')); + fireEvent.change(screen.getByTestId('role-add-permission-scope-id'), { + target: { value: 'iss-internal-pki' }, + }); + fireEvent.click(screen.getByTestId('role-add-permission-submit')); + + await waitFor(() => expect(client.authAddRolePermission).toHaveBeenCalledTimes(1)); + expect(client.authAddRolePermission).toHaveBeenCalledWith('r-customrole', { + permission: 'profile.edit', + scope_type: 'issuer', + scope_id: 'iss-internal-pki', + }); + }); + + it('Add button stays disabled when no permission is selected', async () => { + vi.mocked(client.authGetRole).mockResolvedValue(roleDetail('r-customrole', 'Custom')); + + renderRoute(, '/auth/roles/r-customrole'); + await waitFor(() => screen.getByTestId('role-add-permission-submit')); + + const submit = screen.getByTestId('role-add-permission-submit') as HTMLButtonElement; + expect(submit.disabled).toBe(true); + }); +}); diff --git a/web/src/pages/auth/UsersPage.test.tsx b/web/src/pages/auth/UsersPage.test.tsx new file mode 100644 index 0000000..2b86e4c --- /dev/null +++ b/web/src/pages/auth/UsersPage.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +// ============================================================================= +// Audit 2026-05-11 Fix 12 — UsersPage regression coverage. +// +// The MED-11 closure shipped UsersPage but no test file. This file pins: +// - Active rows render with the operator-readable status pill. +// - Deactivated rows render dimmed + show the deactivation timestamp. +// - Deactivate button fires the API call after confirm() returns true. +// - Deactivate is silent when confirm() returns false (no API call). +// - Reactivate button is rendered for deactivated rows + fires the API. +// - Provider filter narrows the underlying authListUsers call. +// - Empty-state placeholder renders when the response is empty. +// ============================================================================= + +vi.mock('../../api/client', () => ({ + authListUsers: vi.fn(), + authDeactivateUser: vi.fn(), + authReactivateUser: vi.fn(), +})); + +import UsersPage from './UsersPage'; +import * as client from '../../api/client'; + +function renderWithProviders(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + {ui}, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); +}); + +const baseUser = { + id: 'u-1', + tenant_id: 't-default', + email: 'alice@example.com', + display_name: 'Alice', + oidc_subject: 'sub-alice', + oidc_provider_id: 'op-okta', + last_login_at: '2026-05-10T00:00:00Z', + created_at: '2026-05-01T00:00:00Z', +}; + +describe('UsersPage', () => { + it('renders active user rows with the Active status pill', async () => { + vi.mocked(client.authListUsers).mockResolvedValue([baseUser]); + renderWithProviders(); + + await waitFor(() => screen.getByText('alice@example.com')); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('op-okta')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + // Active row carries a Deactivate button. + expect(screen.getByRole('button', { name: /Deactivate$/i })).toBeInTheDocument(); + }); + + it('deactivated row renders the Deactivated status + Reactivate button', async () => { + vi.mocked(client.authListUsers).mockResolvedValue([{ + ...baseUser, + id: 'u-2', + email: 'bob@example.com', + display_name: 'Bob', + deactivated_at: '2026-05-10T12:34:56Z', + }]); + renderWithProviders(); + + await waitFor(() => screen.getByText('bob@example.com')); + // Status cell carries the timestamp so the operator can correlate + // with the audit log without leaving the page. + expect(screen.getByText(/Deactivated 2026-05-10T12:34:56Z/)).toBeInTheDocument(); + // The deactivated row swaps Deactivate → Reactivate. + expect(screen.getByRole('button', { name: /Reactivate$/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Deactivate$/i })).toBeNull(); + }); + + it('Deactivate button calls authDeactivateUser after confirm() returns true', async () => { + vi.mocked(client.authListUsers).mockResolvedValue([baseUser]); + vi.mocked(client.authDeactivateUser).mockResolvedValue(undefined as unknown as void); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + renderWithProviders(); + await waitFor(() => screen.getByText('alice@example.com')); + + fireEvent.click(screen.getByRole('button', { name: /Deactivate$/i })); + await waitFor(() => expect(client.authDeactivateUser).toHaveBeenCalledTimes(1)); + expect(client.authDeactivateUser).toHaveBeenCalledWith('u-1'); + expect(confirmSpy).toHaveBeenCalled(); + }); + + it('Deactivate is no-op when confirm() returns false', async () => { + vi.mocked(client.authListUsers).mockResolvedValue([baseUser]); + vi.spyOn(window, 'confirm').mockReturnValue(false); + + renderWithProviders(); + await waitFor(() => screen.getByText('alice@example.com')); + + fireEvent.click(screen.getByRole('button', { name: /Deactivate$/i })); + // Allow any microtask flush before asserting nothing happened. + await new Promise((r) => setTimeout(r, 10)); + expect(client.authDeactivateUser).not.toHaveBeenCalled(); + }); + + it('Reactivate button calls authReactivateUser after confirm() returns true', async () => { + vi.mocked(client.authListUsers).mockResolvedValue([{ + ...baseUser, + id: 'u-3', + deactivated_at: '2026-05-10T12:00:00Z', + }]); + vi.mocked(client.authReactivateUser).mockResolvedValue(undefined as unknown as void); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + renderWithProviders(); + await waitFor(() => screen.getByRole('button', { name: /Reactivate$/i })); + + fireEvent.click(screen.getByRole('button', { name: /Reactivate$/i })); + await waitFor(() => expect(client.authReactivateUser).toHaveBeenCalledTimes(1)); + expect(client.authReactivateUser).toHaveBeenCalledWith('u-3'); + }); + + it('provider filter input narrows the authListUsers call', async () => { + vi.mocked(client.authListUsers).mockResolvedValue([]); + renderWithProviders(); + + // First mount call — empty filter passes undefined (NOT the empty string) + // because authListUsers(undefined) hits the backend without ?provider=. + await waitFor(() => expect(client.authListUsers).toHaveBeenCalledWith(undefined)); + + const input = screen.getByPlaceholderText(/op-keycloak/); + fireEvent.change(input, { target: { value: 'op-okta' } }); + + // The TanStack-Query queryKey includes providerFilter so the filtered + // value triggers a re-fetch with the narrow argument. + await waitFor(() => expect(client.authListUsers).toHaveBeenLastCalledWith('op-okta')); + }); + + it('empty list renders the "No users matching filter." placeholder', async () => { + vi.mocked(client.authListUsers).mockResolvedValue([]); + renderWithProviders(); + + await waitFor(() => screen.getByText(/No users matching filter\./)); + }); + + it('loading state renders the "Loading users…" text', async () => { + // Never-resolving promise so we can observe the loading branch. + vi.mocked(client.authListUsers).mockReturnValue(new Promise(() => {})); + renderWithProviders(); + + expect(screen.getByText(/Loading users…/)).toBeInTheDocument(); + }); +});