mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:51:30 +00:00
8564e2fcd6
Audit 2026-05-11 Fix 12 closure. The original GUI-batch commit
661b6db 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.
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import type { ReactNode } from 'react';
|
|
|
|
// =============================================================================
|
|
// Bundle 1 Phase 10 — KeysPage Vitest coverage. Pins the demo-anon
|
|
// system-managed flag (no assign / revoke buttons) and the per-row
|
|
// permission gating.
|
|
// =============================================================================
|
|
|
|
vi.mock('../../api/client', () => ({
|
|
authListKeys: vi.fn(),
|
|
authListRoles: vi.fn(),
|
|
authAssignKeyRole: vi.fn(),
|
|
authRevokeKeyRole: vi.fn(),
|
|
authMe: vi.fn(),
|
|
}));
|
|
|
|
import KeysPage from './KeysPage';
|
|
import * as client from '../../api/client';
|
|
|
|
function renderWithProviders(ui: ReactNode) {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<MemoryRouter>{ui}</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
cleanup();
|
|
});
|
|
|
|
const adminMe = {
|
|
actor_id: 'alice',
|
|
actor_type: 'APIKey',
|
|
tenant_id: 't-default',
|
|
admin: true,
|
|
roles: ['r-admin'],
|
|
effective_permissions: [{ permission: 'auth.role.assign', scope_type: 'global' as const }],
|
|
};
|
|
|
|
const auditorMe = {
|
|
actor_id: 'audrey',
|
|
actor_type: 'APIKey',
|
|
tenant_id: 't-default',
|
|
admin: false,
|
|
roles: ['r-auditor'],
|
|
effective_permissions: [{ permission: 'audit.read', scope_type: 'global' as const }],
|
|
};
|
|
|
|
const sampleKeys = [
|
|
{ actor_id: 'alice', actor_type: 'APIKey', tenant_id: 't-default', role_ids: ['r-admin'] },
|
|
{ actor_id: 'actor-demo-anon', actor_type: 'Anonymous', tenant_id: 't-default', role_ids: ['r-admin'] },
|
|
];
|
|
|
|
describe('KeysPage', () => {
|
|
it('flags actor-demo-anon as system-managed and hides its mutation buttons', async () => {
|
|
vi.mocked(client.authListKeys).mockResolvedValue(sampleKeys);
|
|
vi.mocked(client.authListRoles).mockResolvedValue([]);
|
|
vi.mocked(client.authMe).mockResolvedValue(adminMe);
|
|
|
|
renderWithProviders(<KeysPage />);
|
|
|
|
await waitFor(() => screen.getByTestId('keys-table'));
|
|
expect(screen.getByText(/system-managed/i)).toBeTruthy();
|
|
// alice has the assign + revoke affordances; demo-anon does NOT.
|
|
expect(screen.queryByTestId('keys-assign-alice')).toBeTruthy();
|
|
expect(screen.queryByTestId('keys-assign-actor-demo-anon')).toBeNull();
|
|
expect(screen.queryByTestId('keys-revoke-alice-r-admin')).toBeTruthy();
|
|
expect(screen.queryByTestId('keys-revoke-actor-demo-anon-r-admin')).toBeNull();
|
|
});
|
|
|
|
it('hides the assign + revoke affordances when the caller lacks auth.role.assign', async () => {
|
|
vi.mocked(client.authListKeys).mockResolvedValue([sampleKeys[0]]);
|
|
vi.mocked(client.authListRoles).mockResolvedValue([]);
|
|
vi.mocked(client.authMe).mockResolvedValue(auditorMe);
|
|
|
|
renderWithProviders(<KeysPage />);
|
|
|
|
await waitFor(() => screen.getByTestId('keys-table'));
|
|
expect(screen.queryByTestId('keys-assign-alice')).toBeNull();
|
|
expect(screen.queryByTestId('keys-revoke-alice-r-admin')).toBeNull();
|
|
});
|
|
|
|
it('opens the assign modal and POSTs the role choice', async () => {
|
|
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(<KeysPage />);
|
|
|
|
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' },
|
|
});
|
|
fireEvent.click(screen.getByTestId('assign-role-submit'));
|
|
|
|
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=<input>
|
|
// - 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(<KeysPage />);
|
|
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: <trimmed input>}', 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: <trimmed input>}', 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(<KeysPage />);
|
|
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();
|
|
});
|
|
});
|