Files
certctl/web/src/pages/auth/OIDCTestConnectionPanel.test.tsx
shankar0123 64ad8e525c feat(gui/oidc): Test Connection panel on create + edit forms (MED-5 GUI half)
Audit 2026-05-11 Fix 09 closure. MED-5's backend dry-run endpoint
(POST /api/v1/auth/oidc/test, gated auth.oidc.create) shipped on
dev/auth-bundle-2 (commit b4b9879) but the GUI never called it —
authOIDCTestProvider in web/src/api/client.ts was dead code.

Operator gap before this fix: complete the create form blind, save,
then click 'Refresh' to discover whether the issuer URL worked.
Discovery failures left a broken provider row in the DB that had
to be deleted before retrying. The MED-5 backend exists to short-
circuit this — surface the dry-run result before commit.

New shared component web/src/pages/auth/OIDCTestConnectionPanel.tsx
calls authOIDCTestProvider against the live form state (issuer URL
+ client ID + parsed scopes) and renders a four-row status panel
inline:

* ✓/✗ Discovery fetched (with issuer-echo from the well-known doc)
* ✓/✗ JWKS reachable (with the discovered jwks_uri)
* ✓/⚠ Supported algs (warning glyph when the IdP advertises none —
  distinct from a discovery failure)
* ✓/· RFC 9207 iss-parameter advertised (informational · glyph
  rather than ✗ because the spec is SHOULD, not MUST)

Backend per-leg errors[] flow into an inline bullet list. A
top-level rectangle catches network/fetch failures separately.
The Run button is disabled when the issuer URL is empty or
whitespace-only. The component does NOT persist anything — safe
to run repeatedly before the operator clicks Save.

The panel is mounted in two places:

* OIDCProvidersPage create modal (between the form fields and the
  Create button) — short-circuits the blind-save footgun for new
  provider configs.
* OIDCProviderDetailPage edit form (between the field grid and
  the Save button) — load-bearing for verifying IdP rotations
  (Keycloak realm rename, Okta tenant move, certctl side-by-side
  hostname change) without committing first.

A testIDSuffix prop (default 'create' / 'edit') gives each mount
point a distinct data-testid namespace so both panels can coexist
on a hypothetical page that uses both without DOM-id collisions.

8 Vitest tests in OIDCTestConnectionPanel.test.tsx:

* RunButton — disabled until issuer URL is non-empty
* RunButton — also disabled when issuer URL is whitespace-only
* RunButton — enabled when issuer URL is non-empty
* HappyPath — all four primary checks render green with detail
  rows for authorization_url / token_url / userinfo_endpoint
  (asserts both the glyph contract AND the mocked POST body shape)
* FailurePath — discovery=false renders ✗ on discovery + ✗ on
  JWKS + ⚠ on empty supported algs + error list with backend
  per-leg messages
* IssParamFalse — load-bearing UX claim that the iss-parameter
  row renders · (informational), not ✗; body must contain the
  word 'informational' so operators understand it's not a failure
* FetchError — top-level error rectangle when the POST throws
* TestIDSuffix — same component mounted twice with different
  suffixes renders both without DOM-id collision

Verify gate:
* tsc --noEmit — clean
* vitest OIDCTestConnectionPanel.test.tsx — 8/8 pass
* vitest OIDCProvidersPage.test.tsx + OIDCProviderDetailPage.test.tsx
  — 38/38 pass (panel-mount in both pages does not regress
  existing tests because they don't trigger the test button)

Operator runbook: the four glyph meanings are documented inline on
the panel's subtitle. Audit doc annotation at
cowork/auth-bundles-audit-2026-05-10.md flips MED-5 from
'BACKEND CLOSED' to 'CLOSED' with the GUI-half annotation.

Refs cowork/auth-bundles-fixes-2026-05-11/09-med-oidc-test-connection-button.md.
2026-05-11 11:52:26 +00:00

219 lines
8.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import OIDCTestConnectionPanel from './OIDCTestConnectionPanel';
// Audit 2026-05-11 Fix 09 — OIDCTestConnectionPanel regression coverage.
// Mocks authOIDCTestProvider so the test is hermetic (no real network).
// Pins: button-disabled-without-issuer, happy-path renders all checks
// green, failure-path renders the errors list, iss_param_supported=false
// renders the informational `·` glyph rather than ✗ (since RFC 9207 is
// SHOULD, not MUST).
vi.mock('../../api/client', () => ({
authOIDCTestProvider: vi.fn(),
}));
import * as client from '../../api/client';
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
describe('OIDCTestConnectionPanel', () => {
it('RunButton — disabled until issuer URL is non-empty', () => {
render(<OIDCTestConnectionPanel issuerURL="" clientID="cid" scopes={['openid']} />);
const btn = screen.getByTestId('oidc-test-connection-run-default') as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('RunButton — enabled when issuer URL is non-empty', () => {
render(
<OIDCTestConnectionPanel
issuerURL="https://idp.example.com"
clientID="cid"
scopes={['openid']}
/>,
);
const btn = screen.getByTestId('oidc-test-connection-run-default') as HTMLButtonElement;
expect(btn.disabled).toBe(false);
});
it('RunButton — also disabled when issuer URL is whitespace-only', () => {
render(<OIDCTestConnectionPanel issuerURL=" " clientID="cid" scopes={[]} />);
const btn = screen.getByTestId('oidc-test-connection-run-default') as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('HappyPath — renders all four primary checks green when discovery succeeds', async () => {
vi.mocked(client.authOIDCTestProvider).mockResolvedValue({
discovery_succeeded: true,
jwks_reachable: true,
supported_alg_values: ['RS256', 'ES256'],
iss_param_supported: true,
issuer_echo: 'https://idp.example.com',
authorization_url: 'https://idp.example.com/authorize',
token_url: 'https://idp.example.com/token',
jwks_uri: 'https://idp.example.com/jwks',
userinfo_endpoint: 'https://idp.example.com/userinfo',
errors: [],
});
render(
<OIDCTestConnectionPanel
issuerURL="https://idp.example.com"
clientID="certctl"
scopes={['openid', 'profile', 'email']}
/>,
);
fireEvent.click(screen.getByTestId('oidc-test-connection-run-default'));
await waitFor(() => screen.getByTestId('oidc-test-connection-result-default'));
// All four primary checks visible + green.
expect(screen.getByTestId('oidc-test-connection-check-discovery-default').textContent)
.toContain('✓');
expect(screen.getByTestId('oidc-test-connection-check-jwks-default').textContent)
.toContain('✓');
expect(screen.getByTestId('oidc-test-connection-check-algs-default').textContent)
.toContain('✓');
// iss_param SUPPORTED → ✓, not `·`.
expect(screen.getByTestId('oidc-test-connection-check-iss-param-default').textContent)
.toContain('✓');
// Detail rows present.
expect(screen.getByTestId('oidc-test-connection-detail-authz-url-default')).toBeTruthy();
expect(screen.getByTestId('oidc-test-connection-detail-token-url-default')).toBeTruthy();
expect(screen.getByTestId('oidc-test-connection-detail-userinfo-url-default')).toBeTruthy();
// No errors block on happy path.
expect(screen.queryByTestId('oidc-test-connection-errors-list-default')).toBeNull();
// The mocked POST received the staged input.
expect(client.authOIDCTestProvider).toHaveBeenCalledTimes(1);
expect(client.authOIDCTestProvider).toHaveBeenCalledWith({
issuer_url: 'https://idp.example.com',
client_id: 'certctl',
scopes: ['openid', 'profile', 'email'],
});
});
it('FailurePath — renders the errors list when discovery_succeeded is false', async () => {
vi.mocked(client.authOIDCTestProvider).mockResolvedValue({
discovery_succeeded: false,
jwks_reachable: false,
supported_alg_values: [],
iss_param_supported: false,
errors: ['discovery fetch failed: connection refused', 'jwks_uri not advertised'],
});
render(
<OIDCTestConnectionPanel
issuerURL="https://broken.idp.example.com"
clientID="cid"
scopes={['openid']}
/>,
);
fireEvent.click(screen.getByTestId('oidc-test-connection-run-default'));
await waitFor(() => screen.getByTestId('oidc-test-connection-result-default'));
// Discovery + JWKS marked ✗.
expect(screen.getByTestId('oidc-test-connection-check-discovery-default').textContent)
.toContain('✗');
expect(screen.getByTestId('oidc-test-connection-check-jwks-default').textContent)
.toContain('✗');
// Empty alg list → ⚠ warning, not ✗ (the IdP responded but advertised nothing).
expect(screen.getByTestId('oidc-test-connection-check-algs-default').textContent)
.toContain('⚠');
// Errors list rendered with both entries.
const errs = screen.getByTestId('oidc-test-connection-errors-list-default');
expect(errs.textContent).toContain('connection refused');
expect(errs.textContent).toContain('jwks_uri not advertised');
});
it('IssParamFalse — renders the informational `·` glyph when iss_param_supported is false', async () => {
vi.mocked(client.authOIDCTestProvider).mockResolvedValue({
discovery_succeeded: true,
jwks_reachable: true,
supported_alg_values: ['RS256'],
iss_param_supported: false,
issuer_echo: 'https://idp.example.com',
jwks_uri: 'https://idp.example.com/jwks',
errors: [],
});
render(
<OIDCTestConnectionPanel
issuerURL="https://idp.example.com"
clientID="cid"
scopes={['openid']}
/>,
);
fireEvent.click(screen.getByTestId('oidc-test-connection-run-default'));
await waitFor(() => screen.getByTestId('oidc-test-connection-result-default'));
const issRow = screen.getByTestId('oidc-test-connection-check-iss-param-default');
expect(issRow.textContent).toContain('·');
// Must NOT be ✗ — RFC 9207 is SHOULD, not MUST; the panel must
// not visually mark this as a failure.
expect(issRow.textContent).not.toContain('✗');
// Body should explain that this is informational.
expect(issRow.textContent).toContain('informational');
});
it('FetchError — renders a top-level error when authOIDCTestProvider throws', async () => {
vi.mocked(client.authOIDCTestProvider).mockRejectedValue(new Error('network down'));
render(
<OIDCTestConnectionPanel
issuerURL="https://idp.example.com"
clientID="cid"
scopes={['openid']}
/>,
);
fireEvent.click(screen.getByTestId('oidc-test-connection-run-default'));
await waitFor(() => screen.getByTestId('oidc-test-connection-error-default'));
expect(screen.getByTestId('oidc-test-connection-error-default').textContent)
.toContain('network down');
// The success result panel must NOT render alongside an error.
expect(screen.queryByTestId('oidc-test-connection-result-default')).toBeNull();
});
it('TestIDSuffix — same component renders twice on a page without colliding test IDs', async () => {
vi.mocked(client.authOIDCTestProvider).mockResolvedValue({
discovery_succeeded: true,
jwks_reachable: true,
supported_alg_values: ['RS256'],
iss_param_supported: false,
});
render(
<>
<OIDCTestConnectionPanel
issuerURL="https://idp.a.example.com"
clientID="a"
scopes={['openid']}
testIDSuffix="create"
/>
<OIDCTestConnectionPanel
issuerURL="https://idp.b.example.com"
clientID="b"
scopes={['openid']}
testIDSuffix="edit"
/>
</>,
);
// Both panels visible with distinct test IDs — no DOM-id collisions.
expect(screen.getByTestId('oidc-test-connection-panel-create')).toBeTruthy();
expect(screen.getByTestId('oidc-test-connection-panel-edit')).toBeTruthy();
expect(screen.getByTestId('oidc-test-connection-run-create')).toBeTruthy();
expect(screen.getByTestId('oidc-test-connection-run-edit')).toBeTruthy();
});
});