Secret Scan / scan (push) Successful in 8s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 9s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 6m29s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Closes #35. Exports the host environment — global AppSettings (already the non-secret shape persisted to settings.json) plus the global secrets that live in the OS keychain instead (the shared Claude Code OAuth login, the model gateway's provider API key and master key) — to one password-encrypted file, and restores it on another machine. Per-project settings, per-project secrets, and Docker volumes are deliberately out of scope; this is not a project backup. Designed with the user in issue #35's comments: global settings only, no docker volumes, the password is the lock/key, and the export is portable as one file. Crypto (storage/settings_crypto.rs): Argon2id derives a 256-bit key from the password (memory-hard, meaningfully resistant to GPU/ASIC brute-forcing in a way PBKDF2 at any reasonable iteration count is not), AES-256-GCM does the actual encryption. A wrong password fails GCM's authentication tag rather than producing silent garbage. Salt and nonce are random per export and stored in the clear in the file header — their job is uniqueness, not secrecy. The save/open dialogs are opened from Rust, matching the boundary file_commands.rs's pick_save_path/pick_files_to_upload already establish: a frontend-driven dialog handing Rust a host path is the exact shape of bug that produced this app's past criticals. preview_settings_import resolves the chosen import path itself and remembers it (AppState::pending_settings_import) so apply_settings_import re-reads the same file without a path crossing back over IPC. The password is re-entered rather than cached between preview and apply, so nothing here holds decrypted plaintext in memory for longer than one command's execution; the preview returned to the frontend carries counts and presence flags only, never a secret value. Import replaces settings wholesale (an import is "restore this environment"), but only writes secrets actually present in the file — an absent secret means "the source machine never had this configured," not "delete this on import." Added storage::secure::store_gateway_master_key and get_gateway_master_key (read-only, unlike get_or_create_gateway_master_key which mints one as a side effect) since neither existed and import needs to restore an exact captured value rather than mint a new random one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
92 lines
4.1 KiB
TypeScript
92 lines
4.1 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, 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,
|
|
};
|
|
|
|
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({} 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("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();
|
|
});
|
|
});
|