feat(frontend): Introduce secrets prop to hide from the terminal (#4529)

This commit is contained in:
sp.wack 2024-10-29 23:18:01 +04:00 committed by GitHub
parent 997dc80d18
commit 981b05fc2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 128 additions and 9 deletions

View File

@ -25,7 +25,7 @@ vi.mock("@xterm/xterm", async (importOriginal) => ({
}));
const renderTerminal = (commands: Command[] = []) =>
renderWithProviders(<Terminal />, {
renderWithProviders(<Terminal secrets={[]} />, {
preloadedState: {
cmd: {
commands,
@ -121,7 +121,7 @@ describe.skip("Terminal", () => {
// This test fails because it expects `disposeMock` to have been called before the component is unmounted.
it.skip("should dispose the terminal on unmount", () => {
const { unmount } = renderWithProviders(<Terminal />);
const { unmount } = renderWithProviders(<Terminal secrets={[]} />);
expect(mockTerminal.dispose).not.toHaveBeenCalled();

View File

@ -0,0 +1,101 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { afterEach } from "node:test";
import { useTerminal } from "#/hooks/useTerminal";
import { SocketProvider } from "#/context/socket";
import { Command } from "#/state/commandSlice";
interface TestTerminalComponentProps {
commands: Command[];
secrets: string[];
}
function TestTerminalComponent({
commands,
secrets,
}: TestTerminalComponentProps) {
const ref = useTerminal(commands, secrets);
return <div ref={ref} />;
}
describe("useTerminal", () => {
const mockTerminal = vi.hoisted(() => ({
loadAddon: vi.fn(),
open: vi.fn(),
write: vi.fn(),
writeln: vi.fn(),
onKey: vi.fn(),
attachCustomKeyEventHandler: vi.fn(),
dispose: vi.fn(),
}));
beforeAll(() => {
// mock ResizeObserver
window.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// mock Terminal
vi.mock("@xterm/xterm", async (importOriginal) => ({
...(await importOriginal<typeof import("@xterm/xterm")>()),
Terminal: vi.fn().mockImplementation(() => mockTerminal),
}));
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render", () => {
render(<TestTerminalComponent commands={[]} secrets={[]} />, {
wrapper: SocketProvider,
});
});
it("should render the commands in the terminal", () => {
const commands: Command[] = [
{ content: "echo hello", type: "input" },
{ content: "hello", type: "output" },
];
render(<TestTerminalComponent commands={commands} secrets={[]} />, {
wrapper: SocketProvider,
});
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "hello");
});
it("should hide secrets in the terminal", () => {
const secret = "super_secret_github_token";
const anotherSecret = "super_secret_another_token";
const commands: Command[] = [
{
content: `export GITHUB_TOKEN=${secret},${anotherSecret},${secret}`,
type: "input",
},
{ content: secret, type: "output" },
];
render(
<TestTerminalComponent
commands={commands}
secrets={[secret, anotherSecret]}
/>,
{
wrapper: SocketProvider,
},
);
// BUG: `vi.clearAllMocks()` does not clear the number of calls
// therefore, we need to assume the order of the calls based
// on the test order
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(
3,
`export GITHUB_TOKEN=${"*".repeat(10)},${"*".repeat(10)},${"*".repeat(10)}`,
);
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(4, "*".repeat(10));
});
});

View File

@ -4,9 +4,13 @@ import { useTerminal } from "../../hooks/useTerminal";
import "@xterm/xterm/css/xterm.css";
function Terminal() {
interface TerminalProps {
secrets: string[];
}
function Terminal({ secrets }: TerminalProps) {
const { commands } = useSelector((state: RootState) => state.cmd);
const ref = useTerminal(commands);
const ref = useTerminal(commands, secrets);
return (
<div className="h-full p-2 min-h-0">

View File

@ -11,7 +11,10 @@ import { useSocket } from "#/context/socket";
The reason for this is that the hook exposes a ref that requires a DOM element to be rendered.
*/
export const useTerminal = (commands: Command[] = []) => {
export const useTerminal = (
commands: Command[] = [],
secrets: string[] = [],
) => {
const { send } = useSocket();
const terminal = React.useRef<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null);
@ -131,10 +134,16 @@ export const useTerminal = (commands: Command[] = []) => {
if (terminal.current && commands.length > 0) {
// Start writing commands from the last command index
for (let i = lastCommandIndex.current; i < commands.length; i += 1) {
const command = commands[i];
terminal.current?.writeln(parseTerminalOutput(command.content));
// eslint-disable-next-line prefer-const
let { content, type } = commands[i];
if (command.type === "output") {
secrets.forEach((secret) => {
content = content.replaceAll(secret, "*".repeat(10));
});
terminal.current?.writeln(parseTerminalOutput(content));
if (type === "output") {
terminal.current.write(`\n$ `);
}
}

View File

@ -133,6 +133,11 @@ function App() {
const fetcher = useFetcher();
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const secrets = React.useMemo(
() => [ghToken, token].filter((secret) => secret !== null),
[ghToken, token],
);
// To avoid re-rendering the component when the user object changes, we memoize the user ID.
// We use this to ensure the github token is valid before exporting it to the terminal.
const userId = React.useMemo(() => {
@ -321,7 +326,7 @@ function App() {
* that it loads only in the client-side. */}
<Container className="h-1/3 overflow-scroll" label="Terminal">
<React.Suspense fallback={<div className="h-full" />}>
<Terminal />
<Terminal secrets={secrets} />
</React.Suspense>
</Container>
</div>