Files
certctl/web/src/pages/auth/OIDCProviderDetailPage.test.tsx
T
shankar0123 96db3e72c9 auth-bundle-2 Phase 8: GUI auth surface (OIDC providers + group mappings + sessions + LoginPage IdP buttons + AuthState refactor + logout wiring)
Closes Phase 8 of cowork/auth-bundle-2-prompt.md. Every Bundle 2 endpoint
now has a permission-gated, data-testid-instrumented React surface.

Frontend changes
================

api/client.ts (Category H — AuthState refactor):
* fetchJSON now sends `credentials: 'include'` on every request so the
  HttpOnly session cookie + the JS-readable CSRF cookie ride along with
  Bearer-mode requests transparently. Mode is determined per call by
  what cookies are present, NOT by a state-machine — the same client
  works for Bearer-only deploys, session-only deploys, and the mixed
  upgrade path described in cowork/auth-bundles-index.md Category H.
* readCSRFCookie() + isStateChangingMethod() helpers auto-attach
  `X-CSRF-Token` to POST/PUT/PATCH/DELETE when the CSRF cookie exists.
  Bearer-only callers ride through unchanged (no CSRF cookie → no
  header → backend's CSRF middleware skips).
* AuthInfoResponse extended with optional `oidc_providers?:
  AuthInfoOIDCProvider[]` matching the Phase 6 server extension.
* New API helpers (1:1 with Phase 5 / 7.5 endpoints):
  - listOIDCProviders / createOIDCProvider / updateOIDCProvider /
    deleteOIDCProvider / refreshOIDCProvider
  - listGroupMappings / addGroupMapping / removeGroupMapping
  - listSessions(actorID?, actorType?) / revokeSession / logout
  - breakglassLogin / breakglassSetPassword / breakglassUnlock /
    breakglassRemove
  Permission gates fire server-side; the GUI predicates are UX only.

pages/auth/OIDCProvidersPage.tsx (NEW):
* Lists configured OIDC providers, gated on `auth.oidc.list`.
* Empty state + error state + loading state.
* Embedded Configure-Provider modal with form fields for name,
  issuer_url, client_id, client_secret, redirect_uri,
  groups_claim_path/format, fetch_userinfo, scopes. Modal hidden
  unless caller has `auth.oidc.create`.
* Unsaved-changes confirmation on cancel.

pages/auth/OIDCProviderDetailPage.tsx (NEW):
* Provider config dl + edit/delete/refresh action buttons.
* Edit and refresh require `auth.oidc.edit`. Delete requires
  `auth.oidc.delete`.
* Type-confirm-name delete dialog. Surfaces server's 409 Conflict
  ("ErrOIDCProviderInUse") inline so the operator knows to revoke
  the provider's active sessions first.
* Refresh discovery cache button → POST .../refresh → server re-runs
  RefreshKeys with the IdP-downgrade-attack defense from Phase 3.
* Group→role mappings link.

pages/auth/GroupMappingsPage.tsx (NEW):
* Per-provider group-claim → role-id mapping CRUD.
* Empty state explains the fail-closed semantics from Phase 3
  (no mappings ⇒ no users authenticate via this provider).
* Inline add form (group_name input + role_id select populated from
  `authListRoles`); add/remove gated on `auth.oidc.edit`.

pages/auth/SessionsPage.tsx (NEW):
* Default "My sessions" view available to anyone holding
  `auth.session.list`.
* "All actors (admin)" toggle exposed only when caller holds
  `auth.session.list.all`; renders an actor_id filter input that
  threads ?actor_id= through the GET.
* Self-pill marker on the caller's own rows.
* Revoke button is shown when (a) the row is the caller's own session
  (handler-side own-bypass) OR (b) caller holds `auth.session.revoke`.
* Confirms via window.confirm; surfaces revocation errors inline.

pages/LoginPage.tsx (MODIFIED):
* Fetches /v1/auth/info on mount; if `oidc_providers[]` is non-empty,
  renders one "Sign in with X" button per provider linking to the
  provider's `login_url` (the server-side handler in Phase 5 builds
  this URL with state + nonce + PKCE verifier sealed in the pre-login
  cookie; the GUI never touches those values).
* The API-key form remains as a fallback for Bearer-mode deploys and
  the Phase 7.5 break-glass path.
* All interactive elements carry data-testid:
  login-oidc-providers / login-oidc-button-{id} / login-api-key-form /
  login-api-key-input / login-api-key-submit.

components/AuthProvider.tsx (MODIFIED):
* logout() now also fires POST /auth/logout via the api/client helper
  before clearing local state. The endpoint is auth-exempt; the
  catch-and-swallow keeps the local logout flow working even if the
  cookie is already invalid (idempotent server-side as well).

components/Layout.tsx (MODIFIED):
* Two new nav entries under the Auth section: "OIDC Providers" + "Sessions".

main.tsx (MODIFIED):
* Four new routes:
  - /auth/oidc/providers
  - /auth/oidc/providers/:id
  - /auth/oidc/providers/:id/mappings
  - /auth/sessions

Vitest coverage
===============

Five new test files, 28 new test cases. Pattern matches Bundle 1
Phase 10's Vitest scaffold (vi.mock api/client, render with
QueryClient + MemoryRouter, authMe-driven permission shaping,
data-testid selectors).

* OIDCProvidersPage.test.tsx (5 tests): ErrorState w/o auth.oidc.list,
  empty state, list + create button render, hide-create-button
  without auth.oidc.create, submit-creates-via-API.
* OIDCProviderDetailPage.test.tsx (5 tests): ErrorState w/o list,
  full-perms render, hide edit/refresh/delete with only list,
  refresh button calls API, delete confirm-button stays disabled
  until typed text matches provider name.
* GroupMappingsPage.test.tsx (5 tests): ErrorState w/o list, empty
  fail-closed warning, mapping rows render, hide-form without
  auth.oidc.edit, submit-add-form-calls-API.
* SessionsPage.test.tsx (6 tests): ErrorState w/o list, own sessions
  + self-pill, hide All-actors toggle without list.all, show
  toggle with list.all, hide revoke on other-actor sessions without
  auth.session.revoke, click-revoke calls API after window.confirm.
* LoginPage.test.tsx (extended +2 tests): renders OIDC buttons when
  /auth/info reports providers; omits the OIDC block when none.

Verification
============

* `npx tsc --noEmit` — 0 errors.
* Vitest run across api/components/hooks/utils/auth/pages = 475 tests,
  all green.
* `npm run build` — green (980 KB bundle, no surprises vs Phase 7).
* No backend (Go) changes in this commit; Phase 5-7.5 surfaces
  consumed unchanged.

Not in this commit (deferred)
=============================

* "Test login flow" button on the provider detail page (prompt §Phase 8
  optional row). Requires a server-side test=true flag on the OIDC
  login handler — out of scope for the GUI commit.
* `web/src/__tests__/e2e/` Keycloak-via-testcontainers harness for the
  15 comprehensive flow checks. Tracked under Phase 10 of
  cowork/auth-bundle-2-prompt.md.
2026-05-10 07:23:41 +00:00

179 lines
6.6 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, Route, Routes } from 'react-router-dom';
import type { ReactNode } from 'react';
// Bundle 2 Phase 8 — OIDCProviderDetailPage tests. Pins:
// - 403 ErrorState when caller lacks auth.oidc.list.
// - "Edit"/"Refresh"/"Delete" buttons HIDDEN without their respective perms.
// - "Edit"/"Refresh"/"Delete" buttons SHOWN when perms present.
// - Refresh button calls refreshOIDCProvider.
// - Delete confirmation flow + button enabled only when typed text matches.
vi.mock('../../api/client', () => ({
listOIDCProviders: vi.fn(),
updateOIDCProvider: vi.fn(),
deleteOIDCProvider: vi.fn(),
refreshOIDCProvider: vi.fn(),
authMe: vi.fn(),
}));
import OIDCProviderDetailPage from './OIDCProviderDetailPage';
import * as client from '../../api/client';
function renderRoute(ui: ReactNode, path = '/auth/oidc/providers/op-okta') {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/auth/oidc/providers/:id" element={ui} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
const sampleProvider = {
id: 'op-okta',
tenant_id: 't-default',
name: 'Okta',
issuer_url: 'https://example.okta.com',
client_id: 'certctl',
redirect_uri: 'https://certctl.example.com/auth/oidc/callback',
groups_claim_path: 'groups',
groups_claim_format: 'string-array',
fetch_userinfo: false,
scopes: ['openid'],
iat_window_seconds: 300,
jwks_cache_ttl_seconds: 3600,
created_at: '2026-05-10T00:00:00Z',
updated_at: '2026-05-10T00:00:00Z',
};
describe('OIDCProviderDetailPage', () => {
it('renders ErrorState when caller lacks auth.oidc.list', async () => {
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-x',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: [],
effective_permissions: [],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => {
expect(screen.queryByText(/auth\.oidc\.list/)).toBeTruthy();
});
});
it('renders provider config and edit/delete/refresh buttons with full perms', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.edit', scope_type: 'global' },
{ permission: 'auth.oidc.delete', scope_type: 'global' },
],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-edit-button')).toBeTruthy();
});
expect(screen.getByTestId('oidc-provider-refresh-button')).toBeTruthy();
expect(screen.getByTestId('oidc-provider-delete-button')).toBeTruthy();
expect(screen.getByTestId('oidc-provider-mappings-link')).toBeTruthy();
// The provider's issuer_url renders in the dl.
expect(screen.getAllByText('https://example.okta.com').length).toBeGreaterThan(0);
});
it('hides edit/refresh/delete when caller has only auth.oidc.list', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-viewer',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: ['r-viewer'],
effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-mappings-link')).toBeTruthy();
});
expect(screen.queryByTestId('oidc-provider-edit-button')).toBeNull();
expect(screen.queryByTestId('oidc-provider-refresh-button')).toBeNull();
expect(screen.queryByTestId('oidc-provider-delete-button')).toBeNull();
});
it('refresh button calls refreshOIDCProvider', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] });
vi.mocked(client.refreshOIDCProvider).mockResolvedValue({ refreshed: true });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.edit', scope_type: 'global' },
],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-refresh-button')).toBeTruthy();
});
fireEvent.click(screen.getByTestId('oidc-provider-refresh-button'));
await waitFor(() => {
expect(client.refreshOIDCProvider).toHaveBeenCalledWith('op-okta');
});
});
it('delete confirm button stays disabled until typed text matches provider name', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.delete', scope_type: 'global' },
],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-delete-button')).toBeTruthy();
});
fireEvent.click(screen.getByTestId('oidc-provider-delete-button'));
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-delete-confirm')).toBeTruthy();
});
const confirmBtn = screen.getByTestId('oidc-provider-delete-confirm-button') as HTMLButtonElement;
expect(confirmBtn.disabled).toBe(true);
fireEvent.change(screen.getByTestId('oidc-provider-delete-confirm-input'), {
target: { value: 'Wrong' },
});
expect(confirmBtn.disabled).toBe(true);
fireEvent.change(screen.getByTestId('oidc-provider-delete-confirm-input'), {
target: { value: 'Okta' },
});
expect(confirmBtn.disabled).toBe(false);
});
});