Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m53s
Build App (Preview) / build-linux (pull_request) Successful in 7m5s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Round 4 review findings: - Disclose and warn on a custom Docker image the import would set (HIGH): it's the image every project container is created from, so an undisclosed change here was a sharper version of the redirected-base-URL problem round 3 already flagged for the model backends. - Recreate a running gateway container when an import restores a new secret with the shape unchanged (MEDIUM): reconcile_gateway's shape comparison can't see a secret-only change, so the container would otherwise keep serving old key material indefinitely. - Report keychain write failures back to the caller instead of only logging them (MEDIUM): apply_settings_import now returns SettingsImportOutcome with secret_restore_warnings so a partial restore can't read as unqualified success. - Pin a hash of the previewed file's ciphertext and refuse to apply if it changed on disk (MEDIUM): closes a TOCTOU between preview and apply. - Sanitize and cap every free-form string a preview surfaces, and move the warning boxes above the replace list in the UI (MEDIUM): an unbounded base URL or image name could otherwise push the security warnings below the scroll fold. - Validate the Docker socket path on import the same as the SSH key and CA cert paths (LOW): it was the one mounted host path validate_settings_update didn't cover. - Fix ExportedSecrets::is_empty() to treat whitespace-only as blank, like every other secret-presence check in this feature (LOW). - Authenticate the file header as AEAD associated data (LOW, defense in depth) and correct two doc comments that overstated the password not being cached.
146 lines
6.5 KiB
TypeScript
146 lines
6.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import ImportSettingsModal from "./ImportSettingsModal";
|
|
import type { AppSettings, SettingsImportOutcome, SettingsImportPreview } from "../../lib/types";
|
|
|
|
const previewSettingsImport = vi.fn();
|
|
const applySettingsImport = vi.fn();
|
|
|
|
vi.mock("../../lib/tauri-commands", () => ({
|
|
previewSettingsImport: (password: string) => previewSettingsImport(password),
|
|
applySettingsImport: (password: string) => applySettingsImport(password),
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
const samplePreview: SettingsImportPreview = {
|
|
exported_at: "2026-08-27T00:00:00Z",
|
|
app_version: "0.4.14",
|
|
custom_env_var_count: 2,
|
|
gateway_model_count: 0,
|
|
has_claude_code_settings: false,
|
|
has_claude_oauth_token: true,
|
|
has_gateway_api_key: false,
|
|
has_gateway_master_key: false,
|
|
has_web_terminal_access_token: false,
|
|
enables_web_terminal: false,
|
|
ollama_base_url: null,
|
|
llamacpp_base_url: null,
|
|
openai_compatible_base_url: null,
|
|
gateway_api_base: null,
|
|
image_source: "registry",
|
|
custom_image_name: null,
|
|
};
|
|
|
|
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
|
|
return { settings, secret_restore_warnings: secretRestoreWarnings };
|
|
}
|
|
|
|
describe("ImportSettingsModal", () => {
|
|
it("keeps 'Choose file' disabled until a password is entered", () => {
|
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
|
expect(screen.getByRole("button", { name: /choose file/i })).toBeDisabled();
|
|
|
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
|
expect(screen.getByRole("button", { name: /choose file/i })).not.toBeDisabled();
|
|
});
|
|
|
|
it("shows the preview and confirms with the same password used to open it", async () => {
|
|
previewSettingsImport.mockResolvedValue(samplePreview);
|
|
applySettingsImport.mockResolvedValue(outcome({} as AppSettings));
|
|
const onImported = vi.fn();
|
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={onImported} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
|
|
|
await waitFor(() => expect(previewSettingsImport).toHaveBeenCalledWith("hunter2"));
|
|
expect(await screen.findByText(/2 global custom env vars/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/your shared claude login/i)).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
|
await waitFor(() => expect(applySettingsImport).toHaveBeenCalledWith("hunter2"));
|
|
await waitFor(() => expect(onImported).toHaveBeenCalledWith({}));
|
|
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows a distinct warning when the import would enable the web terminal", async () => {
|
|
previewSettingsImport.mockResolvedValue({ ...samplePreview, enables_web_terminal: true });
|
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
|
|
|
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("warns about a custom Docker image every time, not just on change", async () => {
|
|
previewSettingsImport.mockResolvedValue({
|
|
...samplePreview,
|
|
image_source: "custom",
|
|
custom_image_name: "ghcr.io/attacker/triple-c:latest",
|
|
});
|
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
|
|
|
expect(
|
|
await screen.findByText(/custom docker image: ghcr\.io\/attacker\/triple-c:latest/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows a secret-restore warning alongside success rather than hiding it", async () => {
|
|
previewSettingsImport.mockResolvedValue(samplePreview);
|
|
applySettingsImport.mockResolvedValue(
|
|
outcome({} as AppSettings, ["Could not restore the gateway master key: keychain locked"]),
|
|
);
|
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
|
await screen.findByText(/2 global custom env vars/i);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
|
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/could not restore the gateway master key/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("closes quietly when the file picker is dismissed", async () => {
|
|
previewSettingsImport.mockResolvedValue(null);
|
|
const onClose = vi.fn();
|
|
render(<ImportSettingsModal onClose={onClose} onImported={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
|
|
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
});
|
|
|
|
it("shows an error when the password is wrong rather than a blank preview", async () => {
|
|
previewSettingsImport.mockRejectedValue("Wrong password, or the file is corrupted.");
|
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "wrong" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
|
|
|
expect(await screen.findByText(/wrong password, or the file is corrupted/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows an error if applying the import fails, without claiming success", async () => {
|
|
previewSettingsImport.mockResolvedValue(samplePreview);
|
|
applySettingsImport.mockRejectedValue("Keychain write failed");
|
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
|
await screen.findByText(/2 global custom env vars/i);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
|
expect(await screen.findByText("Keychain write failed")).toBeInTheDocument();
|
|
expect(screen.queryByText(/settings imported/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|