Преглед на файлове

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

sp.wack преди 1 година
родител
ревизия
981b05fc2b

+ 2 - 2
frontend/__tests__/components/terminal/Terminal.test.tsx

@@ -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();
 

+ 101 - 0
frontend/__tests__/hooks/use-terminal.test.tsx

@@ -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));
+  });
+});

+ 6 - 2
frontend/src/components/terminal/Terminal.tsx

@@ -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">

+ 13 - 4
frontend/src/hooks/useTerminal.ts

@@ -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$ `);
         }
       }

+ 6 - 1
frontend/src/routes/_oh.app.tsx

@@ -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>