inspector/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx
Cliff Hall 402d7b204d
Server Settings: disambiguate EMA OAuth credential fields (#1692) (#1749)
* Server Settings: disambiguate EMA OAuth credential fields (#1692)

Under enterprise-managed authorization the OAuth Settings "Client ID" /
"Client Secret" fields hold the *resource authorization server* credentials
(EMA leg 3 — the Test Client from the xaa.dev resource registration), not the
app/IdP pair configured in Client Settings. The unqualified labels sat under a
toggle naming the enterprise IdP, pulling readers toward the wrong pair.

- When enterpriseManaged is on, relabel to "Resource AS Client ID" /
  "Resource AS Client Secret" and add a description naming the resource
  authorization server and pointing the app pair back to Client Settings.
- Document the Scopes delimiter: space-separated (RFC 6749), with a warning
  that commas produce one invalid token, plus a placeholder example.

Adds tests for both label states and the scopes hint, plus an
EnterpriseManagedOAuth story.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* pr-screenshots: OAuth EMA field disambiguation proof shots (#1692)

Add before/after screenshots of the Server Settings → OAuth Settings fields
(EMA off = plain Client ID/Secret; EMA on = Resource AS Client ID/Secret) plus
the new space-separated Scopes hint, with a README section documenting them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Address review: genericize EMA copy, reuse enterpriseManaged const (#1692)

- Drop the hardcoded xaa.dev staging host from the resource-AS field
  description (it's a general EMA concept, not env-specific); phrase it as
  "the resource authorization server's registered client credential (EMA leg
  3)". Also trims the copy's overlap with the checkbox description.
- Reuse the existing `enterpriseManaged` const for the EMA Checkbox's `checked`
  prop instead of recomputing `settings.enterpriseManaged ?? false`.
- Update the test assertion and pr-screenshots README/EMA-on screenshot to
  match the new copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Client Settings: mirror EMA disambiguation (IdP-qualified labels) (#1692)

The Server Settings OAuth fields now label the leg-3 resource-AS pair
explicitly; make Client Settings parallel so each modal names its pair and
points at where the other lives.

- Relabel the EMA credential fields to "IdP Client ID" / "IdP Client Secret"
  (parallel to Server Settings' "Resource AS Client ID/Secret"), so the label
  alone disambiguates without relying on the description.
- Add the reciprocal cross-reference to each description: these are the
  enterprise IdP pair (EMA legs 1–2), not the per-server resource authorization
  server credentials, which go in Server Settings → OAuth Settings.
- Update ClientSettingsForm/ClientSettingsModal tests for the new labels; add a
  pr-screenshots section + Client Settings screenshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:51:24 -04:00

474 lines
14 KiB
TypeScript

import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ClientSettingsModal } from "./ClientSettingsModal";
import {
EMPTY_CLIENT_SETTINGS,
ISSUER_URL_ERROR,
CLIENT_ID_REQUIRED_ERROR,
CLIENT_METADATA_URL_REQUIRED_ERROR,
type ClientSettingsFormValues,
} from "../ClientSettingsForm/clientSettingsValues";
import { CIMD_METADATA_URL_INVALID_ERROR } from "@inspector/core/client/config-parse.js";
function resolveSettingsChange(
call: unknown,
prev: ClientSettingsFormValues,
): ClientSettingsFormValues {
return typeof call === "function"
? (call as (p: ClientSettingsFormValues) => ClientSettingsFormValues)(prev)
: (call as ClientSettingsFormValues);
}
async function expandCimdSection(user: ReturnType<typeof userEvent.setup>) {
await user.click(
screen.getByRole("button", { name: /OAuth Client ID Metadata Document/i }),
);
}
describe("ClientSettingsModal", () => {
it("renders the title when opened", () => {
renderWithMantine(
<ClientSettingsModal
opened
settings={EMPTY_CLIENT_SETTINGS}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.getByText("Client Settings")).toBeInTheDocument();
});
it("invokes onClose when the CloseButton is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={EMPTY_CLIENT_SETTINGS}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
);
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
);
expect(onClose).toHaveBeenCalled();
});
it("calls onSettingsChange when toggling EMA enable", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={EMPTY_CLIENT_SETTINGS}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("checkbox", {
name: "Enable enterprise IdP configuration",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function));
expect(
resolveSettingsChange(
onSettingsChange.mock.calls[0]![0],
EMPTY_CLIENT_SETTINGS,
),
).toEqual({
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
});
});
it("shows IdP fields when EMA is enabled", () => {
renderWithMantine(
<ClientSettingsModal
opened
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.getByLabelText("Issuer")).toBeInTheDocument();
expect(screen.getByLabelText("IdP Client ID")).toBeInTheDocument();
expect(screen.getByLabelText("IdP Client Secret")).toBeInTheDocument();
});
it("calls onSettingsChange when typing issuer", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
const initial = { ...EMPTY_CLIENT_SETTINGS, emaEnabled: true };
await user.type(screen.getByLabelText("Issuer"), "h");
expect(onSettingsChange).toHaveBeenCalled();
const call =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1]![0];
expect(resolveSettingsChange(call, initial).issuer).toBe("h");
});
it("collapses then re-expands all sections via the list toggle", async () => {
const user = userEvent.setup();
renderWithMantine(
<ClientSettingsModal
opened
settings={EMPTY_CLIENT_SETTINGS}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
// Default: EMA open, CIMD collapsed — toggle offers "Expand all".
const expand = screen.getByRole("button", { name: "Expand all" });
expect(expand).toBeInTheDocument();
expect(
screen.getByText("Enable enterprise IdP configuration"),
).toBeInTheDocument();
await user.click(expand);
expect(
screen.getByRole("button", { name: "Collapse all" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Collapse all" }));
expect(
screen.getByRole("button", { name: "Expand all" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(
screen.getByRole("button", { name: "Collapse all" }),
).toBeInTheDocument();
});
const clickClose = (user: ReturnType<typeof userEvent.setup>) =>
user.click(document.querySelector("button.mantine-CloseButton-root")!);
it("blocks close and reveals the issuer error when the issuer is invalid", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "not-a-url",
clientId: "client-1",
}}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
);
// Error is hidden initially (form gates it on blur)...
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
// ...but a close attempt reveals it and does NOT close (no silent drop).
await clickClose(user);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
});
it("blocks close and reveals the required error when the client ID is blank", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "https://idp.test", // valid issuer, but clientId is blank
}}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
);
expect(
screen.queryByText(CLIENT_ID_REQUIRED_ERROR),
).not.toBeInTheDocument();
await clickClose(user);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText(CLIENT_ID_REQUIRED_ERROR)).toBeInTheDocument();
});
it("allows close once the invalid issuer is corrected", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
function Host() {
const [settings, setSettings] = useState({
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "not-a-url",
clientId: "client-1", // complete except for the issuer
});
return (
<ClientSettingsModal
opened
settings={settings}
onClose={onClose}
onSettingsChange={setSettings}
/>
);
}
renderWithMantine(<Host />);
await clickClose(user); // blocked, error revealed
expect(onClose).not.toHaveBeenCalled();
const issuer = screen.getByLabelText("Issuer");
await user.clear(issuer);
await user.type(issuer, "https://idp.test");
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
await clickClose(user); // now complete + valid -> closes
expect(onClose).toHaveBeenCalledTimes(1);
});
it("allows close once a blank client ID is filled", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
function Host() {
const [settings, setSettings] = useState({
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "https://idp.test", // complete except for the clientId
});
return (
<ClientSettingsModal
opened
settings={settings}
onClose={onClose}
onSettingsChange={setSettings}
/>
);
}
renderWithMantine(<Host />);
await clickClose(user); // blocked on blank clientId
expect(onClose).not.toHaveBeenCalled();
await user.type(screen.getByLabelText("IdP Client ID"), "client-1");
await clickClose(user); // now complete -> closes
expect(onClose).toHaveBeenCalledTimes(1);
});
it("allows close by disabling enterprise IdP when the config is incomplete", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
function Host() {
const [settings, setSettings] = useState({
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true, // enabled but issuer + clientId blank
});
return (
<ClientSettingsModal
opened
settings={settings}
onClose={onClose}
onSettingsChange={setSettings}
/>
);
}
renderWithMantine(<Host />);
await clickClose(user); // blocked: required fields blank
expect(onClose).not.toHaveBeenCalled();
// Disabling enterprise IdP is the escape hatch — nothing required to save.
await user.click(
screen.getByRole("checkbox", {
name: "Enable enterprise IdP configuration",
}),
);
await clickClose(user);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("blocks close and reveals the CIMD URL error when the URL is invalid", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={{
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
clientMetadataUrl: "not-a-url",
}}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
);
expect(
screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR),
).not.toBeInTheDocument();
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
);
expect(onClose).not.toHaveBeenCalled();
expect(
screen.getByText(CIMD_METADATA_URL_INVALID_ERROR),
).toBeInTheDocument();
});
it("allows close once an invalid CIMD URL is corrected", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
function Host() {
const [settings, setSettings] = useState({
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
clientMetadataUrl: "not-a-url",
});
return (
<ClientSettingsModal
opened
settings={settings}
onClose={onClose}
onSettingsChange={setSettings}
/>
);
}
renderWithMantine(<Host />);
const close = () =>
user.click(document.querySelector("button.mantine-CloseButton-root")!);
await close();
expect(onClose).not.toHaveBeenCalled();
await expandCimdSection(user);
const url = screen.getByLabelText("Client ID metadata document URL");
await user.clear(url);
await user.type(url, "https://example.com/cimd.json");
expect(
screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR),
).not.toBeInTheDocument();
await close();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("blocks close and reveals the required error when the CIMD URL is blank", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={{
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true, // enabled but metadata URL blank
}}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
);
expect(
screen.queryByText(CLIENT_METADATA_URL_REQUIRED_ERROR),
).not.toBeInTheDocument();
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
);
expect(onClose).not.toHaveBeenCalled();
expect(
screen.getByText(CLIENT_METADATA_URL_REQUIRED_ERROR),
).toBeInTheDocument();
});
it("allows close once a blank CIMD URL is filled", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
function Host() {
const [settings, setSettings] = useState({
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true, // enabled but metadata URL blank
});
return (
<ClientSettingsModal
opened
settings={settings}
onClose={onClose}
onSettingsChange={setSettings}
/>
);
}
renderWithMantine(<Host />);
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
); // blocked on blank URL
expect(onClose).not.toHaveBeenCalled();
await expandCimdSection(user);
await user.type(
screen.getByLabelText("Client ID metadata document URL"),
"https://example.com/cimd.json",
);
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("allows close by disabling CIMD when the URL is blank", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
function Host() {
const [settings, setSettings] = useState({
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true, // enabled but metadata URL blank
});
return (
<ClientSettingsModal
opened
settings={settings}
onClose={onClose}
onSettingsChange={setSettings}
/>
);
}
renderWithMantine(<Host />);
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
); // blocked: URL required
expect(onClose).not.toHaveBeenCalled();
// Disabling CIMD is the escape hatch — nothing required to save.
await expandCimdSection(user);
await user.click(
screen.getByRole("checkbox", { name: "Use Client ID Metadata Document" }),
);
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not render the modal body when closed", () => {
renderWithMantine(
<ClientSettingsModal
opened={false}
settings={EMPTY_CLIENT_SETTINGS}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.queryByText("Client Settings")).not.toBeInTheDocument();
});
});