Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
Copyright The CryptOS Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { MintNodeScepChallengeDialog } from "@/components/mint-node-scep-challenge-dialog";
import { expectNoA11yViolations } from "@/test/axe";

const mintNodeScepChallenge = vi.fn();
vi.mock("@/lib/node-scep-challenges", () => ({
mintNodeScepChallenge: (...a: unknown[]) => mintNodeScepChallenge(...a),
}));

const mintButton = () => screen.getByRole("button", { name: /^mint challenge$/i });

const renderDialog = () =>
render(
<MintNodeScepChallengeDialog nodeName="acme-edge-07" onClose={vi.fn()} onMinted={vi.fn()} />,
);

describe("MintNodeScepChallengeDialog", () => {
beforeEach(() => {
mintNodeScepChallenge.mockReset();
mintNodeScepChallenge.mockResolvedValue({
challenge: {
boundNames: [],
createdAt: "",
createdByCn: "",
expiresAt: "",
id: "ch-1",
profile: "network-device",
},
challengePassword: "one-time-password-123",
});
});

it("has no axe violations before minting", async () => {
renderDialog();
await expectNoA11yViolations(document.body);
});

it("mints with the entered profile, ttl and bound names, then shows the password once", async () => {
const onMinted = vi.fn();
render(
<MintNodeScepChallengeDialog nodeName="acme-edge-07" onClose={vi.fn()} onMinted={onMinted} />,
);

fireEvent.change(screen.getByLabelText(/^profile$/i), { target: { value: "network-device" } });
fireEvent.change(screen.getByLabelText(/ttl/i), { target: { value: "7200" } });
fireEvent.change(screen.getByLabelText(/bound names/i), {
target: { value: "switch-1.example.org, 10.0.0.5" },
});
fireEvent.click(mintButton());

await waitFor(() =>
expect(mintNodeScepChallenge).toHaveBeenCalledWith("acme-edge-07", {
boundNames: ["switch-1.example.org", "10.0.0.5"],
profile: "network-device",
ttlSeconds: 7200,
}),
);
expect(await screen.findByText("one-time-password-123")).toBeInTheDocument();
expect(onMinted).toHaveBeenCalled();
});

it("has no axe violations after minting (shown-once secret)", async () => {
renderDialog();
fireEvent.click(mintButton());
await screen.findByText("one-time-password-123");
await expectNoA11yViolations(document.body);
});

it("surfaces a server-side refusal inline", async () => {
mintNodeScepChallenge.mockRejectedValue(new Error('node has no profile named "bogus"'));
renderDialog();
fireEvent.click(mintButton());
await waitFor(() => expect(screen.getByText(/no profile named/i)).toBeInTheDocument());
});
});
139 changes: 139 additions & 0 deletions apps/console/src/components/mint-node-scep-challenge-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
Copyright The CryptOS Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { Button, Dialog, Field, Input, Notice, ShownOnceSecret } from "@cryptos-pki/ui";
import { useState } from "react";

import { mintNodeScepChallenge } from "@/lib/node-scep-challenges";

const parseBoundNames = (raw: string): string[] =>
raw
.split(",")
.map((n) => n.trim())
.filter((n) => n !== "");

// MintNodeScepChallengeDialog mints a one-time SCEP challenge password on a
// managed node, for example to enroll a network device via its SCEP
// trustpoint. The plaintext lives only in this dialog's state: the page is
// told a challenge was made, never what the password is, and closing the
// dialog discards it.
export const MintNodeScepChallengeDialog = ({
nodeName,
onClose,
onMinted,
}: {
nodeName: string;
onClose: () => void;
onMinted: () => void;
}) => {
const [profile, setProfile] = useState("");
const [ttlSeconds, setTtlSeconds] = useState("3600");
const [boundNames, setBoundNames] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<null | string>(null);
const [challengePassword, setChallengePassword] = useState("");

const close = () => {
setChallengePassword("");
onClose();
};

const mint = () => {
setPending(true);
setError(null);
mintNodeScepChallenge(nodeName, {
boundNames: parseBoundNames(boundNames),
profile: profile.trim(),
ttlSeconds: Number(ttlSeconds) || 0,
})
.then((result) => {
setChallengePassword(result.challengePassword);
setPending(false);
onMinted();
})
.catch((error_: unknown) => {
setError(error_ instanceof Error ? error_.message : "Mint failed");
setPending(false);
});
};

return (
<Dialog
description={nodeName}
footer={
challengePassword ? null : (
<>
<Button disabled={pending} onClick={close} size="sm" variant="outline">
Cancel
</Button>
<Button disabled={pending} onClick={mint} size="sm">
{pending ? "Minting…" : "Mint challenge"}
</Button>
</>
)
}
onClose={close}
open
title="Mint a SCEP challenge"
>
{challengePassword ? (
<ShownOnceSecret
label="Challenge password"
onDone={close}
secret={challengePassword}
warning="Give this to the device now. It will not be shown again, and the manager keeps only the challenge metadata, not the password."
/>
) : (
<>
<Field help="The node's Scep.profiles entry to issue from." label="Profile">
<Input
disabled={pending}
onChange={(e) => setProfile(e.target.value)}
value={profile}
/>
</Field>
<Field
help="How long the challenge stays usable. Zero means the node's default of one hour."
label="TTL (seconds)"
>
<Input
disabled={pending}
onChange={(e) => setTtlSeconds(e.target.value)}
type="number"
value={ttlSeconds}
/>
</Field>
<Field
help="Optional, comma-separated. Pins the names the enrolment may request."
label="Bound names (optional)"
>
<Input
disabled={pending}
onChange={(e) => setBoundNames(e.target.value)}
placeholder="switch-1.example.org, 10.0.0.5"
value={boundNames}
/>
</Field>
{error ? (
<Notice role="alert" tone="danger">
{error}
</Notice>
) : null}
</>
)}
</Dialog>
);
};
61 changes: 61 additions & 0 deletions apps/console/src/components/node-scep-challenges.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
Copyright The CryptOS Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { NodeScepChallenges } from "@/components/node-scep-challenges";
import { expectNoA11yViolations } from "@/test/axe";

let level: "admin" | "operator" | "viewer" = "operator";
vi.mock("@/context/auth", () => ({
useOptionalAuth: () => ({
operator: { commonName: "op@acme.example", level, serial: "AA" },
status: "authenticated",
}),
}));

describe("NodeScepChallenges", () => {
beforeEach(() => {
level = "operator";
});

it("has no axe violations", async () => {
const { container } = render(<NodeScepChallenges nodeName="acme-issuing-01" />);
await screen.findByText("ch-mock-1");
await expectNoA11yViolations(container);
});

it("lists the node's SCEP challenges", async () => {
render(<NodeScepChallenges nodeName="acme-issuing-01" />);
expect(await screen.findByText("ch-mock-1")).toBeInTheDocument();
});

it("shows Mint and Revoke to an operator", async () => {
render(<NodeScepChallenges nodeName="acme-issuing-01" />);
await screen.findByText("ch-mock-1");
expect(screen.getByRole("button", { name: /mint challenge/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^revoke$/i })).toBeInTheDocument();
});

it("hides Mint and Revoke from a viewer", async () => {
level = "viewer";
render(<NodeScepChallenges nodeName="acme-issuing-01" />);
await screen.findByText("ch-mock-1");
expect(screen.queryByRole("button", { name: /mint challenge/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^revoke$/i })).not.toBeInTheDocument();
});
});
Loading
Loading