mirror of
https://github.com/certctl-io/certctl.git
synced 2026-08-28 19:21:25 +02:00
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.
219 lines
8.3 KiB
TypeScript
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();
|
|
});
|
|
});
|