mirror of
https://github.com/certctl-io/certctl.git
synced 2026-08-28 13:31:24 +02:00
Audit 2026-05-11 Fix 12 closure. The original GUI-batch commit
191384c claimed 'npx tsc --noEmit PASS' but shipped no Vitest
cases for the new surfaces, leaving the regression-prevention
layer wide open. This closure backfills 35 cases across five
files; the next refactor of KeysPage's assign modal that drops
scope_type, or the AuthProvider demo-banner predicate that
gets flipped to !authRequired, surfaces in CI instead of
silently shipping.
What's added:
* web/src/pages/auth/UsersPage.test.tsx (NEW, 8 cases) — pins
the MED-11 closure's UsersPage flow: active rows render the
Active status pill, deactivated rows render dimmed with the
Deactivated <timestamp> status, Deactivate button fires the
API call after confirm() returns true and is a no-op on
false, Reactivate button works inversely, provider filter
narrows the underlying authListUsers call (undefined vs
provider-id), empty list renders the placeholder, loading
renders 'Loading users…'.
* web/src/pages/auth/AuthSettingsPage.test.tsx (EXTENDED, +4
cases) — the pre-existing 2 cases only exercised identity +
bootstrap status; the runtime-config panel (MED-12 closure)
had no test. New cases cover: per-key row rendering,
alphabetical sort (stable for log-scraping correlation),
empty-value '(empty)' placeholder, 403 rejected query
silently hides the panel (non-admins shouldn't see the
shell).
* web/src/pages/auth/KeysPage.test.tsx (EXTENDED, +8 cases) —
the HIGH-10 GUI half added scope picker + scope_id input +
expires_at datetime-local to the assign modal but the
pre-existing test only asserted (actor, role). New cases
pin the third opts arg shape: global hides scope_id input,
profile/issuer scope reveal scope_id + mark required,
trimmed scope_id round-trips into the body, global omits
scope_id (undefined NOT empty string), empty expires_at
omits the field, filled expires_at gets :00Z appended for
RFC3339 promotion, whitespace-only scope_id fires the
'scope_id is required' typed error WITHOUT calling the
API, actor-demo-anon row hides both assign and revoke
affordances.
* web/src/pages/auth/RoleDetailPage.test.tsx (NEW, 9 cases) —
no test file pre-Fix 12. Pins the MED-8 scope picker for
AddPermissionForm: global hides scope_id, profile reveals +
gates the Add button until scope_id is filled, submit POSTs
{permission, scope_type: profile, scope_id} with whitespace
trimming, global submit omits scope keys entirely, issuer
scope path, Add button stays disabled without a permission
selection. Plus the LOW-11 default-role delete-button hide:
r-admin renders the role-delete-disabled-tooltip + NO
role-delete-button, r-auditor same, custom role renders the
delete button. The DEFAULT_ROLE_IDS set tracking the
migration-seeded role ids is the load-bearing client-side
decision so a future drift between migrations and the GUI
set surfaces here too.
* web/src/components/AuthProvider.test.tsx (NEW, 5 cases) —
the LOW-1 demo banner had no test for its visibility
predicate. Pins all four authType branches (none → visible,
api-key → hidden, oidc → hidden, loading → hidden to avoid
flash) plus the rejected-getAuthInfo branch: the catch
treats failure as an old-server-fallback to demo mode (no
authType mutation, loading flips false), so the banner
SHOWS — that's the actual behavior, and pinning it prevents
a future change from silently hiding the banner when the
/auth/info endpoint is unreachable.
Spec deviations: Phase 6 (Layout.test.tsx users-nav) and
Phase 7 (per-Fix tests for Fixes 03/05/07/09/10) live on those
fixes' own branches — already authored there. Including them
here would have produced merge conflicts.
Verify gate:
* tsc --noEmit — clean
* vitest run touched files — 40/40 pass (8 + 6 + 12 + 9 + 5,
including the 2 + 4 + 4 pre-existing cases in the extended
AuthSettingsPage + KeysPage files)
* full suite (162 tests across 15 files) green — no regression
from the panel-mount-in-existing-page setup or the new
mocked-module entries.
Refs cowork/auth-bundles-fixes-2026-05-11/12-test-vitest-gui-coverage.md.
246 lines
10 KiB
TypeScript
246 lines
10 KiB
TypeScript
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: <trimmed>}.
|
|
// - 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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter initialEntries={[path]}>
|
|
<Routes>
|
|
<Route path="/auth/roles/:id" element={ui} />
|
|
<Route path="/auth/roles" element={<div data-testid="roles-list-stub" />} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
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(<RoleDetailPage />, '/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(<RoleDetailPage />, '/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(<RoleDetailPage />, '/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(<RoleDetailPage />, '/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(<RoleDetailPage />, '/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(<RoleDetailPage />, '/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(<RoleDetailPage />, '/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(<RoleDetailPage />, '/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(<RoleDetailPage />, '/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);
|
|
});
|
|
});
|