Files
certctl/web/src/pages/auth/UsersPage.test.tsx
T
shankar0123 dfdba5b260 test(gui): Vitest coverage for the 2026-05-10/11 GUI batch (Fix 12)
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.
2026-05-11 12:18:08 +00:00

160 lines
6.5 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 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(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}
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(<UsersPage />);
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 <timestamp> 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(<UsersPage />);
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(<UsersPage />);
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(<UsersPage />);
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(<UsersPage />);
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(<UsersPage />);
// 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(<UsersPage />);
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(<UsersPage />);
expect(screen.getByText(/Loading users…/)).toBeInTheDocument();
});
});