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(
+
+
+ ,
+ );
+
+ // 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();
+ });
+});