* 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>
474 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|