mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
- **Scope-aware actor-role revoke (Audit 2026-05-11 A-4).**
|
||||
|
||||
@@ -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(
|
||||
<AuthProvider>
|
||||
<div>child</div>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AuthProvider>
|
||||
<div data-testid="child">child</div>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<AuthProvider>
|
||||
<div data-testid="child">child</div>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AuthProvider>
|
||||
<div data-testid="child">child</div>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<AuthProvider>
|
||||
<div data-testid="child">child</div>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByTestId('demo-mode-banner'));
|
||||
});
|
||||
});
|
||||
@@ -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 <tr> 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(<AuthSettingsPage />);
|
||||
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(<AuthSettingsPage />);
|
||||
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(<AuthSettingsPage />);
|
||||
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(<AuthSettingsPage />);
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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=<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: <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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user