From 6cb60ba2c7a4e432ee7e1a0d68fb0220b4b3e868 Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Tue, 4 Aug 2026 17:11:47 -0500 Subject: [PATCH 01/14] docs(readme): note unconfirmed Linux oversized-cursor issue (#6) Phase A diagnosis from docs/plans/issue-6-oversized-cursor-linux.md could not reach a verdict: this environment is WSL, and CLAUDE.local.md explicitly documents WSLg as unsupported for reproducing/verifying this bug (needs a real X11/Wayland desktop session). Static analysis ruled out the app-side candidates (no cursor: CSS rules, no zoom/transform/ image-rendering hacks, no set_zoom call, no custom cursor assets, no GTK/XCURSOR env vars set in lib.rs), but D1/D2/D3/D4/D6 all require a live compositor and remain unrun. No Phase B code change is warranted without a confirmed H3/H4/H5 verdict. Posted findings + needs-linux-verifier label on issue #6 instead of guessing a cause. Co-Authored-By: Claude Sonnet 5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7e3dd32..8b98f14 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ Useful for SSH sessions, CI/CD scripts, or environments where a GUI is unavailab - **macOS**: Xcode Command Line Tools (`xcode-select --install`) - **Linux**: `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev` +> **Known issue (Linux):** the mouse cursor may render oversized inside the app window on some Linux setups. Root cause is not yet confirmed — diagnosis is blocked on a native X11/Wayland desktop session (WSLg is not a valid repro/verification environment for this). No workaround is documented yet. Track status in [issue #6](https://github.com/maosuarez/crypt-env/issues/6). + ### Install & Run ```bash From b28f310f1597ad19aa778f744dc580f6af489540 Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Tue, 4 Aug 2026 17:12:17 -0500 Subject: [PATCH 02/14] fix(window-chrome): show minimize/close controls on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the custom titlebar controls to Linux (issue #5), matching the Windows gate. Replaces the isWindows boolean + useEffect with a platform-derived Chrome config computed via a lazy useState initializer (platform() is synchronous in Tauri v2, so the effect + one-frame no-controls flash is unnecessary). macOS behaviour is unchanged by design (see plan §4.3) and maximize stays out of scope (§4.4). Also adds focus-visible outline styling to the minimize/close buttons so keyboard focus is visible (§4.5) — previously only hover: classes existed. No Rust changes: existing capabilities (allow-minimize, allow-close, allow-start-dragging) are not platform-scoped and already apply on Linux. Co-Authored-By: Claude Sonnet 5 --- src/components/WindowChrome.tsx | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/components/WindowChrome.tsx b/src/components/WindowChrome.tsx index a8c0f6e..4a71547 100644 --- a/src/components/WindowChrome.tsx +++ b/src/components/WindowChrome.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { platform } from '@tauri-apps/plugin-os'; import { Icon } from './ui/Icon'; @@ -6,14 +6,22 @@ import { useVaultStore } from '../store'; const win = getCurrentWindow(); +type Chrome = { showControls: boolean }; + +function chromeFor(os: string): Chrome { + switch (os) { + case 'windows': + case 'linux': + return { showControls: true }; + default: // macos and anything else: unchanged behaviour + return { showControls: false }; + } +} + export function WindowChrome() { const screen = useVaultStore((s) => s.screen); const lock = useVaultStore((s) => s.lock); - const [isWindows, setIsWindows] = useState(false); - - useEffect(() => { - setIsWindows(platform() === 'windows'); - }, []); + const [chrome] = useState(() => chromeFor(platform())); return (
)} - {isWindows && ( + {chrome.showControls && (
+ {isWindows && wslDistros.length > 0 && ( +
+ + {wslPickerOpen && wslDistros.length > 1 && ( +
+ {wslDistros.map((distro) => ( + + ))} +
+ )} +
+ )} + +
+
+ + ); +} + // ─── VarRow (project detail: real vault item, type-aware) ───────────────────── function varSummary(item: VaultItem, reveal: boolean): string { @@ -422,7 +474,7 @@ function EnvironmentCard({ }: { env: Environment; onOpen: () => void; - onInject: (id: number) => Promise<{ paths: string[]; written: string[] }>; + onInject: (id: number) => Promise; }) { const [injectState, setInjectState] = useState<'idle' | 'ok' | 'err'>('idle'); @@ -438,7 +490,12 @@ function EnvironmentCard({ await onInject(env.id); setInjectState('ok'); setTimeout(() => setInjectState('idle'), 2000); - } catch { + } catch (err) { + // A cancelled confirm-overwrite dialog isn't a failure — just reset. + if (String(err) === 'Error: cancelled') { + setInjectState('idle'); + return; + } setInjectState('err'); setTimeout(() => setInjectState('idle'), 2000); } @@ -510,9 +567,49 @@ export function ProjectManager() { const cats = useVaultStore((s) => s.cats); const getItemOwners = useVaultStore((s) => s.getItemOwners); - const { projects, loading, load, saveProject, removeProject, saveEnvironment, removeEnvironment, inject } = + const { projects, loading, load, saveProject, removeProject, saveEnvironment, removeEnvironment, inject, previewInject } = useProjectStore(); + // Pending confirm-then-inject flow (see `runInject` below): `injectConfirm` + // holds the modal's display data, while the resolve/reject pair for the + // promise `runInject` returned to its caller lives in a ref so it survives + // re-renders without becoming React state itself. + const [injectConfirm, setInjectConfirm] = useState<{ id: number; foreign: string[] } | null>(null); + const pendingInjectRef = useRef<{ resolve: (r: InjectResult) => void; reject: (e: unknown) => void } | null>(null); + + // Single entry point for both the project-list quick-inject button and the + // environment editor's INJECT button: previews first, and only prompts for + // confirmation when the preview reports a path crypt-env doesn't manage. + const runInject = async (id: number): Promise => { + const preview = await previewInject(id); + if (preview.foreign.length === 0) return inject(id, false); + return new Promise((resolve, reject) => { + pendingInjectRef.current = { resolve, reject }; + setInjectConfirm({ id, foreign: preview.foreign }); + }); + }; + + const confirmPendingInject = async () => { + if (!injectConfirm) return; + const { id } = injectConfirm; + const pending = pendingInjectRef.current; + pendingInjectRef.current = null; + setInjectConfirm(null); + try { + const result = await inject(id, true); + pending?.resolve(result); + } catch (e) { + pending?.reject(e); + } + }; + + const cancelPendingInject = () => { + const pending = pendingInjectRef.current; + pendingInjectRef.current = null; + setInjectConfirm(null); + pending?.reject(new Error('cancelled')); + }; + const [mode, setMode] = useState('projects'); const [selectedProject, setSelectedProject] = useState(null); const [selectedEnv, setSelectedEnv] = useState(null); @@ -809,11 +906,15 @@ export function ProjectManager() { if (envPaths.length === 0) { showToast('Add at least one path before injecting', 'error'); return; } setInjecting(true); try { - const result = await inject(selectedEnv.id); + const result = await runInject(selectedEnv.id); const pathLabel = result.paths.length === 1 ? result.paths[0] : `${result.paths.length} paths`; - showToast(`Injected ${result.written.length} variable${result.written.length !== 1 ? 's' : ''} → ${pathLabel}`); + let msg = `Injected ${result.written.length} variable${result.written.length !== 1 ? 's' : ''} → ${pathLabel}`; + if (result.unmanagedPaths.length > 0) { + msg += ` (warning: ${result.unmanagedPaths.length} unmanaged path${result.unmanagedPaths.length !== 1 ? 's' : ''} written, backup kept)`; + } + showToast(msg); } catch (e) { - showToast(String(e), 'error'); + if (String(e) !== 'Error: cancelled') showToast(String(e), 'error'); } finally { setInjecting(false); } @@ -1029,7 +1130,7 @@ export function ProjectManager() { )} {selectedProject.environments.map((env) => ( - openEnvironment(env)} onInject={inject} /> + openEnvironment(env)} onInject={runInject} /> ))} @@ -1286,6 +1387,14 @@ export function ProjectManager() { onConfirm={handleDeleteProject} /> )} + + {injectConfirm && ( + + )} ); } diff --git a/src/store/projectStore.ts b/src/store/projectStore.ts index c14cf7a..802a599 100644 --- a/src/store/projectStore.ts +++ b/src/store/projectStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { invoke } from '@tauri-apps/api/core'; -import type { Project, EnvironmentVar, InjectResult, ProjectDeleteImpact, VaultItem } from '../types'; +import type { Project, EnvironmentVar, InjectResult, InjectPreview, ProjectDeleteImpact, VaultItem } from '../types'; export interface EnvironmentInput { id?: number; @@ -22,7 +22,8 @@ interface ProjectStore { previewDelete: (id: number) => Promise; saveEnvironment: (input: EnvironmentInput) => Promise; removeEnvironment: (id: number) => Promise; - inject: (environmentId: number) => Promise; + inject: (environmentId: number, overwrite?: boolean) => Promise; + previewInject: (environmentId: number) => Promise; createProjectItem: (projectId: number, item: Omit) => Promise; clearError: () => void; } @@ -84,8 +85,12 @@ export const useProjectStore = create((set, get) => ({ await get().load(); }, - inject: async (environmentId) => { - return invoke('environment_inject', { id: environmentId }); + inject: async (environmentId, overwrite = false) => { + return invoke('environment_inject', { id: environmentId, overwrite }); + }, + + previewInject: (environmentId) => { + return invoke('environment_inject_preview', { id: environmentId }); }, createProjectItem: (projectId, item) => { diff --git a/src/types/index.ts b/src/types/index.ts index 9a4bf9f..8112dfe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -92,8 +92,25 @@ export type ProjectTemplate = | 'generic' | 'node' | 'postgres' | 'mongo' | 'docker' | 'python'; export interface InjectResult { + paths: string[]; + written: string[]; + /** Owner-configured paths that were unmanaged (pre-existing, not created + * by crypt-env) at the time of this inject — written anyway (configured + * paths are never hard-gated), but surfaced so the caller can see it. + * Self-heals: a path drops off this list on the next inject once it + * carries the marker. */ + unmanagedPaths: string[]; + /** `.bak` paths created because a write target was unmanaged. */ + backups: string[]; +} + +/** Result of `environment_inject_preview` — resolves and inspects the + * environment's configured paths without decrypting or writing anything, + * so the GUI can show a confirm dialog before an inject that would + * overwrite unmanaged files. */ +export interface InjectPreview { paths: string[]; - written: string[]; + foreign: string[]; } export interface ProjectDeleteImpact { From ba22dff54f31758a0fac8d65d924c750ab16e824 Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Tue, 4 Aug 2026 17:45:45 -0500 Subject: [PATCH 07/14] test(api,db,project,vault,share,cli,mcp): add shared test harness + coverage baseline; drop stale Postman collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/plans/issue-11-test-coverage-and-postman.md: a crate::test_support harness (TestVault fixture + router()/req() oneshot helpers) so every module's private/pub(crate) logic is unit-testable without a Tauri runtime or live socket, plus 119 new test cases across api/, project/, vault/, db/, share/, the CLI's scope resolver, and the MCP binary's environment-picking logic (17 -> 136 total). Two pure refactors enable the harness, both behavior-preserving: - api::build_router / ApiState::new extracted from start_server (pub(crate), not pub — zero external API widening). - vault::set_item_global's fork logic extracted the same way create_project_item already was, so the Tauri command becomes a thin delegating wrapper. - crypt-env-mcp's resolve_environment_id split into network-fetching + pure pick_environment_id, mirroring the plan's resolve_environment_id split. Deletes the stale Postman collection (asserted a removed /health field, every request 422'd without the mandatory scope params) and replaces it with curl examples + the executed api::tests::* suite in docs/reference.md. Adds .github/workflows/test.yml running cargo test on push/PR with an informational cargo-llvm-cov summary step. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/test.yml | 46 + docs/reference.md | 60 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/api/mod.rs | 54 +- src-tauri/src/api/tests/fill.rs | 107 + src-tauri/src/api/tests/items.rs | 161 ++ src-tauri/src/api/tests/mod.rs | 9 + src-tauri/src/api/tests/projects.rs | 134 + src-tauri/src/api/tests/scope.rs | 143 ++ src-tauri/src/api/tests/units.rs | 199 ++ src-tauri/src/bin/crypt-env-mcp.rs | 110 +- src-tauri/src/bin/crypt-env/commands/scope.rs | 115 + src-tauri/src/db/mod.rs | 89 + src-tauri/src/lib.rs | 3 + src-tauri/src/project/mod.rs | 348 ++- src-tauri/src/share/mod.rs | 115 + src-tauri/src/test_support/mod.rs | 335 +++ src-tauri/src/vault/mod.rs | 308 ++- .../crypt-env-api.postman_collection.json | 2257 ----------------- 20 files changed, 2300 insertions(+), 2295 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 src-tauri/src/api/tests/fill.rs create mode 100644 src-tauri/src/api/tests/items.rs create mode 100644 src-tauri/src/api/tests/mod.rs create mode 100644 src-tauri/src/api/tests/projects.rs create mode 100644 src-tauri/src/api/tests/scope.rs create mode 100644 src-tauri/src/api/tests/units.rs create mode 100644 src-tauri/src/test_support/mod.rs delete mode 100644 src-tauri/tests/crypt-env-api.postman_collection.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f45960b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: Test + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + + - name: Install Tauri Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libsoup-3.0-dev \ + build-essential + + - name: Run tests (lib + bins) + working-directory: src-tauri + run: cargo test --lib --bins --no-fail-fast + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Coverage summary (informational) + working-directory: src-tauri + run: | + cargo llvm-cov --lib --bins --no-fail-fast \ + --ignore-filename-regex '(^|/)(tests|test_support)/' \ + --summary-only diff --git a/docs/reference.md b/docs/reference.md index b35d7fa..678275f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -45,6 +45,64 @@ Authentication: Header `X-Vault-Token` containing either a session token (from P | POST | /workspaces/:id/relay/send | token | Share complete workspace (definition + all decrypted referenced secrets) via relay. Returns code + passphrase. Legacy, workspace-table-backed, out of scope for the projects/environments migration | | POST | /workspaces/relay/receive | token | Receive shared workspace from relay. Recreates secrets and rebuilds workspace with variables re-linked. Legacy, same as above — items imported this way are NOT linked into any project/environment and are invisible to the scoped endpoints above | +### Examples + +`curl` examples against the local server. `-k` is required — the certificate +is self-signed (see Notes below). Replace `$TOKEN` with a session token from +`/unlock` or the static MCP token from Settings. + +```bash +# Unlock — returns a session token with a configurable TTL +curl -sk -X POST https://127.0.0.1:47821/unlock \ + -H 'Content-Type: application/json' \ + -d '{"master_password": "your-master-password"}' + +# List items — scoped by environment_id +curl -sk https://127.0.0.1:47821/items?environment_id=1 \ + -H "X-Vault-Token: $TOKEN" + +# List items — scoped by project + environment names (case-insensitive) +curl -sk 'https://127.0.0.1:47821/items?project=demo&environment=production' \ + -H "X-Vault-Token: $TOKEN" + +# Create an item, linked into an environment as DB_HOST +curl -sk -X POST 'https://127.0.0.1:47821/items?environment_id=1' \ + -H "X-Vault-Token: $TOKEN" -H 'Content-Type: application/json' \ + -d '{"type": "secret", "name": "DB_HOST", "value": "localhost", "key": "DB_HOST"}' + +# Reveal a plaintext value — requires explicit confirm +curl -sk -X POST https://127.0.0.1:47821/items/1/reveal \ + -H "X-Vault-Token: $TOKEN" -H 'Content-Type: application/json' \ + -d '{"confirm": true}' + +# Fill a .env template inline, scoped by project + environment +curl -sk -X POST 'https://127.0.0.1:47821/fill?project=demo&environment=production' \ + -H "X-Vault-Token: $TOKEN" -H 'Content-Type: application/json' \ + -d '{"template": "DB_HOST=\nPORT=3000\n"}' + +# List all projects with their nested environments +curl -sk https://127.0.0.1:47821/projects -H "X-Vault-Token: $TOKEN" + +# Inject an environment's variables into its configured .env path(s) +curl -sk -X POST https://127.0.0.1:47821/environments/1/inject \ + -H "X-Vault-Token: $TOKEN" -H 'Content-Type: application/json' -d '{}' +``` + +The server is HTTPS-only on `127.0.0.1:47821` with a self-signed certificate +generated on first launch (`tls::ensure_tls_config`) — clients must either +pass `-k`/`--insecure` (as above) or trust that certificate explicitly. + +**On the retired Postman collection**: `src-tauri/tests/crypt-env-api.postman_collection.json` +was deleted (tech-debt issue #11) — it asserted a `GET /health` field that no +longer exists and none of its 15 requests carried the (now mandatory) +project/environment scope, so every one of them 422'd. Nothing in CI ever +executed it, so it silently drifted out of date across a whole schema +migration. This reference section plus the `api::tests::*` suite (executed +on every push/PR — see `.github/workflows/test.yml`) are the replacement: +one documents the contract, the other proves it. A Postman collection may +return only alongside a test that replays it through the same router and +fails the build on drift — see the plan doc for the full reasoning. + ### Notes `decrypt_all_items` decrypts the entire vault on every authenticated request (no caching, no index), making every GET /items a full decryption pass — O(n) per request regardless of filters. Scoped endpoints add a second cost on top: `resolve_scope` loads the full project→environment→vars graph (`GET /projects`-equivalent) before the item decryption pass, so every scoped request is now O(vault) + O(project graph). @@ -69,7 +127,7 @@ Projects/environments model replaces the old workspaces. A Project contains mult `projects.name` has a case-insensitive UNIQUE index (`idx_projects_name_nocase`) — duplicate-name creation now returns 409 instead of silently succeeding. `environments.name` is only unique per-project under SQLite's default (case-sensitive) collation — two environments in the same project differing only by case (e.g. `Production`/`production`) can still coexist, and name-pair resolution (case-insensitive, picks the lowest-id match) will silently prefer one over the other with no ambiguity error. Known limitation, not fixed. -**Known, deferred issues** (found in review, not fixed in this pass): (1) a crafted environment `name` (only validated non-empty) combined with `output_dir` on `/fill`, `/environments/:id/inject`, or `/environments/:id/example` can path-traverse outside the intended directory, because `create_dir_all` on the joined path materializes the intermediate component that makes `..` segments resolve — reachable by anything holding the static MCP token via `POST /environments`. (2) `/fill`, `/environments/:id/inject`, and `/environments/:id/example` all write via a plain `std::fs::write` to `output_path` with no existence check — pointing one at a real, unrelated file truncates it. (3) `POST /items` on a key that already exists in the environment creates a new item row and repoints the link, orphaning (not deleting) the previous item — repeated `add`-equivalent calls grow the vault unboundedly and "rotating" a secret this way doesn't actually remove the old value. +**Known, deferred issues** (found in review, not fixed in this pass): (1) a crafted environment `name` (only validated non-empty) combined with `output_dir` on `/fill`, `/environments/:id/inject`, or `/environments/:id/example` can path-traverse outside the intended directory, because `create_dir_all` on the joined path materializes the intermediate component that makes `..` segments resolve — reachable by anything holding the static MCP token via `POST /environments`. (2) `/fill`, `/environments/:id/inject`, and `/environments/:id/example` all write via a plain `std::fs::write` to `output_path` with no existence check — pointing one at a real, unrelated file truncates it. (3) `POST /items` on a key that already exists in the environment creates a new item row and repoints the link, orphaning (not deleting) the previous item — repeated `add`-equivalent calls grow the vault unboundedly and "rotating" a secret this way doesn't actually remove the old value. (4) `PUT /items/:id`'s "merge" behavior only applies to the `Option` fields on `VaultItem` — `type` has no `#[serde(default)]`, so a client omitting it entirely gets a 422 from axum's `Json` extractor before the merge logic (or `validate_update`) ever runs; every partial update must still resend `type`. Found and pinned by `api::tests::items::update_item_partial_update_preserves_other_fields` (issue #11), not changed there since that's a behavior fix out of scope for a test-only PR. --- diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f3f2e26..a3610f0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6084,6 +6084,7 @@ dependencies = [ "time", "tokio", "tokio-rustls", + "tower", "uuid", "windows 0.58.0", "x25519-dalek", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 70a6c11..82e68ac 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -97,6 +97,7 @@ windows = { version = "0.58", features = [ [dev-dependencies] tempfile = "3" tokio = { version = "1", features = ["rt", "macros"] } +tower = { version = "0.5", features = ["util"] } [[bin]] name = "crypt-env" diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index f4431b1..0068b58 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -37,6 +37,26 @@ pub struct ApiState { share: Arc, } +impl ApiState { + /// Builds a fresh state: no active session, rate limiter reset, new share + /// session. Used by `start_server` and by `crate::test_support::router` + /// so both build `ApiState` identically — no duplicated initialisation to + /// drift. `pub(crate)` (not `pub`): visible to the in-crate test harness + /// without widening the crate's public API. + pub(crate) fn new(vault: SharedState) -> Self { + ApiState { + vault, + session_token: Arc::new(Mutex::new(None)), + token_expires: Arc::new(Mutex::new(None)), + unlock_rate: Mutex::new(RateLimitState { + attempts: 0, + window_start: Instant::now(), + }), + share: Arc::new(ShareState::new()), + } + } +} + // ─── Tipos de respuesta ─────────────────────────────────────────────────────── #[derive(Serialize)] @@ -3039,19 +3059,15 @@ async fn handle_workspace_relay_receive( // ─── Función pública de arranque ────────────────────────────────────────────── -pub async fn start_server(vault: SharedState, app_data_dir: PathBuf) { - let api_state = Arc::new(ApiState { - vault, - session_token: Arc::new(Mutex::new(None)), - token_expires: Arc::new(Mutex::new(None)), - unlock_rate: Mutex::new(RateLimitState { - attempts: 0, - window_start: Instant::now(), - }), - share: Arc::new(ShareState::new()), - }); - - let app = Router::new() +/// Builds the plain `axum::Router` with all 36 routes plus the `cors_guard` +/// middleware layer, given an already-constructed `ApiState`. `pub(crate)` +/// (not `pub`): reachable from `api::tests` (a descendant module) and from +/// `crate::test_support::router` (same crate), but never part of the crate's +/// external public API — no production caller outside this crate can build a +/// router or bind it to a socket other than the one fixed inside +/// `start_server` below. +pub(crate) fn build_router(state: Arc) -> Router { + Router::new() .route("/health", get(handle_health)) .route("/unlock", post(handle_unlock)) .route("/fill", post(handle_fill)) @@ -3088,8 +3104,13 @@ pub async fn start_server(vault: SharedState, app_data_dir: PathBuf) { .route("/relay/receive", post(handle_relay_receive)) .route("/workspaces/:id/relay/send", post(handle_workspace_relay_send)) .route("/workspaces/relay/receive", post(handle_workspace_relay_receive)) - .with_state(api_state) - .layer(middleware::from_fn(cors_guard)); + .with_state(state) + .layer(middleware::from_fn(cors_guard)) +} + +pub async fn start_server(vault: SharedState, app_data_dir: PathBuf) { + let api_state = Arc::new(ApiState::new(vault)); + let app = build_router(api_state); const ADDR: &str = "127.0.0.1:47821"; @@ -3120,3 +3141,6 @@ pub async fn start_server(vault: SharedState, app_data_dir: PathBuf) { eprintln!("[api] REST server error: {e}"); } } + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/api/tests/fill.rs b/src-tauri/src/api/tests/fill.rs new file mode 100644 index 0000000..0a19df9 --- /dev/null +++ b/src-tauri/src/api/tests/fill.rs @@ -0,0 +1,107 @@ +//! Router-level tests for `POST /fill` — the line-preservation guarantee is +//! R3 in the plan: a template key not linked into the scoped environment +//! must survive byte-identical rather than being blanked out. + +use crate::test_support::{req, router, unlocked_vault}; + +async fn fill(v: &crate::test_support::TestVault, app: &axum::Router, template: &str) -> serde_json::Value { + let uri = format!("/fill?environment_id={}", v.env_id); + let body = serde_json::json!({ "template": template }); + let (status, json) = req(app, "POST", &uri, Some(&v.token), Some(body)).await; + assert_eq!(status.as_u16(), 200, "fill failed: {json:?}"); + json +} + +#[tokio::test] +async fn key_present_in_environment_is_substituted() { + let v = unlocked_vault().await; + let app = router(&v); + let json = fill(&v, &app, "DB_HOST=old-value\n").await; + assert_eq!(json.get("content").and_then(|c| c.as_str()), Some("DB_HOST=localhost\n")); + assert_eq!(json.get("injected").and_then(|n| n.as_i64()), Some(1)); +} + +#[tokio::test] +async fn key_absent_from_environment_leaves_line_byte_identical() { + let v = unlocked_vault().await; + let app = router(&v); + let json = fill(&v, &app, "UNKNOWN_KEY=some-local-value\n").await; + assert_eq!( + json.get("content").and_then(|c| c.as_str()), + Some("UNKNOWN_KEY=some-local-value\n"), + "a key not linked into scope must not be blanked out" + ); + assert_eq!(json.get("not_found").and_then(|n| n.as_i64()), Some(1)); + let missing = json.get("missing_keys").and_then(|m| m.as_array()).unwrap(); + assert_eq!(missing[0].as_str(), Some("UNKNOWN_KEY")); +} + +#[tokio::test] +async fn comments_and_blank_lines_are_preserved() { + let v = unlocked_vault().await; + let app = router(&v); + let template = "# a comment\n\nDB_HOST=x\n"; + let json = fill(&v, &app, template).await; + let content = json.get("content").and_then(|c| c.as_str()).unwrap(); + assert!(content.starts_with("# a comment\n\n")); +} + +#[tokio::test] +async fn trailing_newline_presence_is_preserved() { + let v = unlocked_vault().await; + let app = router(&v); + + let with_newline = fill(&v, &app, "DB_HOST=x\n").await; + assert!(with_newline.get("content").and_then(|c| c.as_str()).unwrap().ends_with('\n')); + + let without_newline = fill(&v, &app, "DB_HOST=x").await; + assert!(!without_newline.get("content").and_then(|c| c.as_str()).unwrap().ends_with('\n')); +} + +#[tokio::test] +async fn crlf_input_is_processed_without_corruption() { + // Current behaviour: `body.template.lines()` (Rust's line splitter) + // strips both `\r` and `\n` terminators and the output is always + // rejoined with plain `\n` — CRLF input is normalized to LF output, not + // preserved byte-for-byte. Pinned here so a future change to preserve + // CRLF is a deliberate, visible diff. + let v = unlocked_vault().await; + let app = router(&v); + let json = fill(&v, &app, "DB_HOST=old\r\n").await; + assert_eq!(json.get("content").and_then(|c| c.as_str()), Some("DB_HOST=localhost\n")); +} + +#[tokio::test] +async fn duplicate_keys_in_template_are_each_substituted() { + let v = unlocked_vault().await; + let app = router(&v); + let json = fill(&v, &app, "DB_HOST=a\nDB_HOST=b\n").await; + assert_eq!(json.get("content").and_then(|c| c.as_str()), Some("DB_HOST=localhost\nDB_HOST=localhost\n")); + assert_eq!(json.get("injected").and_then(|n| n.as_i64()), Some(2)); +} + +#[tokio::test] +async fn empty_template_input_is_handled() { + let v = unlocked_vault().await; + let app = router(&v); + let json = fill(&v, &app, "").await; + assert_eq!(json.get("content").and_then(|c| c.as_str()), Some("")); + assert_eq!(json.get("injected").and_then(|n| n.as_i64()), Some(0)); + assert_eq!(json.get("not_found").and_then(|n| n.as_i64()), Some(0)); +} + +#[tokio::test] +async fn unresolvable_scope_is_422_before_any_file_is_touched() { + let v = unlocked_vault().await; + let app = router(&v); + let output_path = v.dir.path().join("should-not-exist.env"); + let body = serde_json::json!({ + "template": "DB_HOST=x\n", + "output_path": output_path.to_str().unwrap(), + }); + // No environment_id / project / environment at all. + let (status, json) = req(&app, "POST", "/fill", Some(&v.token), Some(body)).await; + assert_eq!(status.as_u16(), 422); + assert_eq!(json.get("code").and_then(|c| c.as_str()), Some("VALIDATION_ERROR")); + assert!(!output_path.exists(), "scope must be resolved before any file write is attempted"); +} diff --git a/src-tauri/src/api/tests/items.rs b/src-tauri/src/api/tests/items.rs new file mode 100644 index 0000000..9c749de --- /dev/null +++ b/src-tauri/src/api/tests/items.rs @@ -0,0 +1,161 @@ +//! Router-level tests for `/items` CRUD + `/items/:id/reveal`. + +use crate::test_support::{read_item, req, router, unlocked_vault}; + +#[tokio::test] +async fn list_scoped_to_environment_only_returns_linked_items() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, json) = req(&app, "GET", &format!("/items?environment_id={}", v.env_id), Some(&v.token), None).await; + assert_eq!(status.as_u16(), 200); + let items = json.as_array().unwrap(); + // 3 linked into "production"; the 4th seeded item (SHARED_TOKEN, global) + // is not linked into any environment var, so it must not appear here. + assert_eq!(items.len(), 3); + assert!(items.iter().all(|i| i.get("name").and_then(|n| n.as_str()) != Some("SHARED_TOKEN"))); +} + +#[tokio::test] +async fn list_never_returns_secret_fields() { + let v = unlocked_vault().await; + let app = router(&v); + let (_, json) = req(&app, "GET", &format!("/items?environment_id={}", v.env_id), Some(&v.token), None).await; + for item in json.as_array().unwrap() { + assert!(item.get("value").is_none(), "value must never be present on the wire"); + assert!(item.get("password").is_none(), "password must never be present on the wire"); + assert!(item.get("content").is_none(), "content must never be present on the wire"); + } +} + +#[tokio::test] +async fn list_search_filters_within_scope() { + let v = unlocked_vault().await; + let app = router(&v); + let uri = format!("/items?environment_id={}&search=db_host", v.env_id); + let (status, json) = req(&app, "GET", &uri, Some(&v.token), None).await; + assert_eq!(status.as_u16(), 200); + let items = json.as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].get("name").and_then(|n| n.as_str()), Some("DB_HOST")); +} + +#[tokio::test] +async fn get_item_nonexistent_returns_404() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, json) = req(&app, "GET", "/items/999999", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 404); + assert_eq!(json.get("code").and_then(|c| c.as_str()), Some("NOT_FOUND")); +} + +#[tokio::test] +async fn create_item_persists_encrypted_blob_not_plaintext() { + let v = unlocked_vault().await; + let app = router(&v); + let body = serde_json::json!({ + "type": "secret", + "name": "NEW_SECRET", + "value": "super-secret-plaintext", + }); + let uri = format!("/items?environment_id={}", v.env_id); + let (status, json) = req(&app, "POST", &uri, Some(&v.token), Some(body)).await; + assert_eq!(status.as_u16(), 201); + assert!(json.get("value").is_none(), "response must not echo the plaintext value"); + + let id = json.get("id").and_then(|i| i.as_i64()).expect("created item has an id"); + let decrypted = read_item(&v, id).await; + assert_eq!(decrypted.value.as_deref(), Some("super-secret-plaintext")); + + // The raw DB column must never contain the plaintext. + let raw_state = v.state.lock().await; + let raw = raw_state.db.list_items().await.unwrap(); + let (_, _, data, _, _) = raw.into_iter().find(|(rid, ..)| *rid == id).unwrap(); + assert!(!data.contains("super-secret-plaintext"), "ciphertext must not contain the plaintext value"); +} + +#[tokio::test] +async fn create_item_missing_name_is_422_naming_the_field() { + let v = unlocked_vault().await; + let app = router(&v); + let body = serde_json::json!({ + "type": "secret", + "value": "v", + }); + let uri = format!("/items?environment_id={}", v.env_id); + let (status, json) = req(&app, "POST", &uri, Some(&v.token), Some(body)).await; + assert_eq!(status.as_u16(), 422); + assert_eq!(json.get("code").and_then(|c| c.as_str()), Some("VALIDATION_ERROR")); + let msg = json.get("error").and_then(|e| e.as_str()).unwrap_or_default(); + assert!(msg.starts_with("name:"), "error must name the offending field: {msg}"); +} + +#[tokio::test] +async fn update_item_partial_update_preserves_other_fields() { + let v = unlocked_vault().await; + let app = router(&v); + let id = v.item_ids[0]; // DB_HOST + + // `type` has no `#[serde(default)]` on `VaultItem`, so it must be resent + // even on a "partial" update — otherwise axum's Json extractor itself + // rejects the body (422) before `validate_update` ever runs. + let body = serde_json::json!({ "type": "secret", "value": "updated-host" }); + let (status, _) = req(&app, "PUT", &format!("/items/{id}"), Some(&v.token), Some(body)).await; + assert_eq!(status.as_u16(), 200); + + let decrypted = read_item(&v, id).await; + assert_eq!(decrypted.value.as_deref(), Some("updated-host")); + assert_eq!(decrypted.name.as_deref(), Some("DB_HOST"), "unrelated field must survive a partial update"); +} + +#[tokio::test] +async fn delete_item_unlinks_and_removes() { + let v = unlocked_vault().await; + let app = router(&v); + let id = v.item_ids[0]; // DB_HOST, linked into v.env_id + + let (status, _) = req(&app, "DELETE", &format!("/items/{id}"), Some(&v.token), None).await; + assert_eq!(status.as_u16(), 204); + + let state = v.state.lock().await; + let raw = state.db.list_items().await.unwrap(); + assert!(raw.iter().find(|(rid, ..)| *rid == id).is_none(), "item row must be gone"); + let vars = state.db.get_environment_vars(v.env_id).await.unwrap(); + assert!(vars.iter().find(|var| var.item_id == Some(id)).is_none(), "environment_vars link must be gone too"); +} + +#[tokio::test] +async fn reveal_item_is_the_only_endpoint_returning_plaintext() { + let v = unlocked_vault().await; + let app = router(&v); + let id = v.item_ids[1]; // DB_PASSWORD = "hunter2" + + let (status, json) = req( + &app, + "POST", + &format!("/items/{id}/reveal"), + Some(&v.token), + Some(serde_json::json!({ "confirm": true })), + ) + .await; + assert_eq!(status.as_u16(), 200); + assert_eq!(json.get("value").and_then(|v| v.as_str()), Some("hunter2")); + + // The same item via GET /items/:id must still be redacted. + let (_, get_json) = req(&app, "GET", &format!("/items/{id}"), Some(&v.token), None).await; + assert!(get_json.get("value").is_none()); +} + +#[tokio::test] +async fn reveal_item_nonexistent_returns_404() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, _) = req( + &app, + "POST", + "/items/999999/reveal", + Some(&v.token), + Some(serde_json::json!({ "confirm": true })), + ) + .await; + assert_eq!(status.as_u16(), 404); +} diff --git a/src-tauri/src/api/tests/mod.rs b/src-tauri/src/api/tests/mod.rs new file mode 100644 index 0000000..3e64739 --- /dev/null +++ b/src-tauri/src/api/tests/mod.rs @@ -0,0 +1,9 @@ +//! Router-level and pure-function test suites for `api::mod`. See +//! `docs/plans/issue-11-test-coverage-and-postman.md` §3.2/§3.5 for the +//! harness design and per-file case specifications. + +mod units; +mod scope; +mod items; +mod fill; +mod projects; diff --git a/src-tauri/src/api/tests/projects.rs b/src-tauri/src/api/tests/projects.rs new file mode 100644 index 0000000..4217c7c --- /dev/null +++ b/src-tauri/src/api/tests/projects.rs @@ -0,0 +1,134 @@ +//! Router-level tests for `/projects` and `/environments`. + +use crate::test_support::{req, router, unlocked_vault}; + +#[tokio::test] +async fn list_projects_includes_nested_environments() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, json) = req(&app, "GET", "/projects", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 200); + let projects = json.as_array().unwrap(); + let demo = projects.iter().find(|p| p.get("name").and_then(|n| n.as_str()) == Some("demo")).unwrap(); + let envs = demo.get("environments").and_then(|e| e.as_array()).unwrap(); + let mut names: Vec<&str> = envs.iter().filter_map(|e| e.get("name").and_then(|n| n.as_str())).collect(); + names.sort(); + assert_eq!(names, vec!["local", "production"]); +} + +#[tokio::test] +async fn create_project_succeeds() { + let v = unlocked_vault().await; + let app = router(&v); + let body = serde_json::json!({ "id": 0, "name": "brand-new", "template": "generic", "categories": [] }); + let (status, json) = req(&app, "POST", "/projects", Some(&v.token), Some(body)).await; + assert_eq!(status.as_u16(), 201); + assert!(json.get("id").and_then(|i| i.as_i64()).unwrap() > 0); +} + +#[tokio::test] +async fn create_project_with_existing_name_is_409() { + let v = unlocked_vault().await; + let app = router(&v); + let body = serde_json::json!({ "id": 0, "name": "demo", "template": "generic", "categories": [] }); + let (status, json) = req(&app, "POST", "/projects", Some(&v.token), Some(body)).await; + assert_eq!(status.as_u16(), 409); + assert_eq!(json.get("code").and_then(|c| c.as_str()), Some("CONFLICT")); +} + +#[tokio::test] +async fn updating_a_project_by_id_is_not_treated_as_a_duplicate() { + let v = unlocked_vault().await; + let app = router(&v); + let body = serde_json::json!({ + "id": v.project_id, + "name": "demo", + "description": "updated description", + "template": "generic", + "categories": [], + }); + let (status, json) = req(&app, "POST", "/projects", Some(&v.token), Some(body)).await; + assert_eq!(status.as_u16(), 200, "update-by-id must not 409 against its own current name"); + assert_eq!(json.get("id").and_then(|i| i.as_i64()), Some(v.project_id)); +} + +#[tokio::test] +async fn delete_project_cascades() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, json) = req(&app, "DELETE", &format!("/projects/{}", v.project_id), Some(&v.token), None).await; + assert_eq!(status.as_u16(), 200); + assert!(json.get("environments").and_then(|e| e.as_i64()).unwrap() >= 2); + + let (_, list_json) = req(&app, "GET", "/projects", Some(&v.token), None).await; + let projects = list_json.as_array().unwrap(); + assert!(projects.iter().find(|p| p.get("id").and_then(|i| i.as_i64()) == Some(v.project_id)).is_none()); +} + +#[tokio::test] +async fn preview_delete_counts_match_actual_delete() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, preview) = req(&app, "GET", &format!("/projects/{}/preview-delete", v.project_id), Some(&v.token), None).await; + assert_eq!(status.as_u16(), 200); + + let (_, actual) = req(&app, "DELETE", &format!("/projects/{}", v.project_id), Some(&v.token), None).await; + + assert_eq!(preview.get("environments"), actual.get("environments")); + assert_eq!(preview.get("itemsDeleted"), actual.get("itemsDeleted")); + assert_eq!(preview.get("itemsOrphaned"), actual.get("itemsOrphaned")); +} + +#[tokio::test] +async fn save_environment_rejects_var_referencing_unowned_non_global_item() { + let v = unlocked_vault().await; + let app = router(&v); + // Create a second project owning its own private (non-global) item. + let other = serde_json::json!({ "id": 0, "name": "other-project", "template": "generic", "categories": [] }); + let (_, other_json) = req(&app, "POST", "/projects", Some(&v.token), Some(other)).await; + let other_id = other_json.get("id").and_then(|i| i.as_i64()).unwrap(); + + let create_item = serde_json::json!({ "type": "secret", "name": "PRIVATE", "value": "v" }); + let (_, item_json) = req( + &app, + "POST", + &format!("/items?project={}&environment=default", "other-project"), + Some(&v.token), + Some(create_item), + ) + .await; + let item_id = item_json.get("id").and_then(|i| i.as_i64()).unwrap(); + + // v.project_id ("demo") tries to link that item without owning it or it being global. + let body = serde_json::json!({ + "id": 0, + "projectId": v.project_id, + "name": "staging", + "isDefault": false, + "paths": [], + "vars": [{ "id": 0, "key": "PRIVATE", "itemId": item_id }], + }); + let (status, json) = req(&app, "POST", "/environments", Some(&v.token), Some(body)).await; + assert_eq!(status.as_u16(), 500, "current handler surfaces the ownership guard as INTERNAL_ERROR, not 422"); + let _ = other_id; + let _ = json; +} + +#[tokio::test] +async fn delete_environment_removes_it() { + let v = unlocked_vault().await; + let app = router(&v); + let (_, projects) = req(&app, "GET", "/projects", Some(&v.token), None).await; + let demo = projects.as_array().unwrap().iter().find(|p| p.get("id").and_then(|i| i.as_i64()) == Some(v.project_id)).unwrap(); + let local_env = demo.get("environments").and_then(|e| e.as_array()).unwrap().iter() + .find(|e| e.get("name").and_then(|n| n.as_str()) == Some("local")).unwrap(); + let local_id = local_env.get("id").and_then(|i| i.as_i64()).unwrap(); + + let (status, _) = req(&app, "DELETE", &format!("/environments/{local_id}"), Some(&v.token), None).await; + assert_eq!(status.as_u16(), 204); + + let (_, projects_after) = req(&app, "GET", "/projects", Some(&v.token), None).await; + let demo_after = projects_after.as_array().unwrap().iter().find(|p| p.get("id").and_then(|i| i.as_i64()) == Some(v.project_id)).unwrap(); + let envs_after = demo_after.get("environments").and_then(|e| e.as_array()).unwrap(); + assert!(envs_after.iter().find(|e| e.get("id").and_then(|i| i.as_i64()) == Some(local_id)).is_none()); +} diff --git a/src-tauri/src/api/tests/scope.rs b/src-tauri/src/api/tests/scope.rs new file mode 100644 index 0000000..ca2bb65 --- /dev/null +++ b/src-tauri/src/api/tests/scope.rs @@ -0,0 +1,143 @@ +//! Router-level tests for project/environment scope resolution (R1 in the +//! plan). Mirrors `project::resolve_environment`'s own in-file suite but +//! through the real HTTP surface (`GET /items`), including auth precedence. + +use crate::test_support::{link_var, locked_vault, req, router, seed_item, seed_project, unlocked_vault, unlocked_vault_empty}; + +#[tokio::test] +async fn resolves_by_environment_id() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, json) = req(&app, "GET", &format!("/items?environment_id={}", v.env_id), Some(&v.token), None).await; + assert_eq!(status.as_u16(), 200u16); + assert_eq!(json.as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn resolves_by_project_and_environment_names() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, json) = req(&app, "GET", "/items?project=demo&environment=production", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 200u16); + assert_eq!(json.as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn project_name_is_case_insensitive() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, _) = req(&app, "GET", "/items?project=DEMO&environment=production", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 200u16); +} + +#[tokio::test] +async fn environment_name_is_case_insensitive() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, _) = req(&app, "GET", "/items?project=demo&environment=PRODUCTION", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 200u16); +} + +#[tokio::test] +async fn project_alone_without_environment_is_422() { + // Current behaviour: `resolve_environment` has no "project alone -> + // default environment" fallback (see project::mod's own test of the + // same name). At the HTTP layer that surfaces as 422 VALIDATION_ERROR. + let v = unlocked_vault().await; + let app = router(&v); + let (status, json) = req(&app, "GET", "/items?project=demo", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 422u16); + assert_eq!(json.get("code").and_then(|c| c.as_str()), Some("VALIDATION_ERROR")); +} + +#[tokio::test] +async fn no_scope_params_at_all_is_422() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, json) = req(&app, "GET", "/items", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 422u16); + assert_eq!(json.get("code").and_then(|c| c.as_str()), Some("VALIDATION_ERROR")); +} + +#[tokio::test] +async fn unknown_environment_id_is_422() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, _) = req(&app, "GET", "/items?environment_id=999999", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 422u16); +} + +#[tokio::test] +async fn unknown_project_name_is_422() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, _) = req(&app, "GET", "/items?project=ghost&environment=production", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 422u16); +} + +#[tokio::test] +async fn known_project_unknown_environment_is_422() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, _) = req(&app, "GET", "/items?project=demo&environment=ghost", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 422u16); +} + +#[tokio::test] +async fn environment_id_takes_precedence_over_project_and_environment() { + let v = unlocked_vault().await; + let app = router(&v); + let uri = format!( + "/items?environment_id={}&project=does-not-exist&environment=also-not-real", + v.env_id + ); + let (status, _) = req(&app, "GET", &uri, Some(&v.token), None).await; + assert_eq!(status.as_u16(), 200u16, "environment_id must win even with a nonsense project/environment pair"); +} + +#[tokio::test] +async fn missing_token_is_401() { + let v = unlocked_vault().await; + let app = router(&v); + let (status, json) = req(&app, "GET", &format!("/items?environment_id={}", v.env_id), None, None).await; + assert_eq!(status.as_u16(), 401u16); + assert_eq!(json.get("code").and_then(|c| c.as_str()), Some("UNAUTHORIZED")); +} + +#[tokio::test] +async fn locked_vault_with_valid_token_is_403_vault_locked() { + let v = locked_vault().await; + let app = router(&v); + let (status, json) = req(&app, "GET", &format!("/items?environment_id={}", v.env_id), Some(&v.token), None).await; + assert_eq!(status.as_u16(), 403u16); + assert_eq!(json.get("code").and_then(|c| c.as_str()), Some("VAULT_LOCKED")); +} + +// Extra coverage beyond the 12 listed in the plan: an empty vault (no +// projects at all) must still 422 rather than panic, exercising the +// `unlocked_vault_empty` fixture and confirming `seed_project`/`seed_item`/ +// `link_var` compose correctly for ad-hoc scopes. +#[tokio::test] +async fn empty_vault_can_be_scoped_via_seed_helpers() { + let v = unlocked_vault_empty().await; + let app = router(&v); + + let (status, _) = req(&app, "GET", "/items", Some(&v.token), None).await; + assert_eq!(status.as_u16(), 422u16, "no projects exist yet"); + + let (project_id, env_ids) = seed_project(&v, "adhoc", &["staging"]).await; + let item_id = seed_item(&v, "TOKEN", "value", false).await; + link_var(&v, env_ids[0], "TOKEN", item_id).await; + + let (status, json) = req( + &app, + "GET", + &format!("/items?project=adhoc&environment=staging"), + Some(&v.token), + None, + ) + .await; + assert_eq!(status.as_u16(), 200u16); + assert_eq!(json.as_array().unwrap().len(), 1); + let _ = project_id; +} diff --git a/src-tauri/src/api/tests/units.rs b/src-tauri/src/api/tests/units.rs new file mode 100644 index 0000000..e06fd57 --- /dev/null +++ b/src-tauri/src/api/tests/units.rs @@ -0,0 +1,199 @@ +//! Pure-function unit tests for `api::mod` helpers that don't need a router +//! or a `TestVault` — `redact_item`, `validate_create`, `validate_update`, +//! `environment_item_ids`. Phase 1 of the plan. + +use super::super::{environment_item_ids, redact_item, validate_create, validate_update}; +use crate::project::{Environment, EnvironmentVar}; +use crate::vault::VaultItem; + +fn full_item() -> VaultItem { + VaultItem { + id: 1, + item_type: "secret".to_string(), + name: Some("DB_HOST".to_string()), + value: Some("localhost".to_string()), + url: Some("https://example.com".to_string()), + username: Some("user".to_string()), + password: Some("hunter2".to_string()), + title: Some("title".to_string()), + description: Some("desc".to_string()), + command: Some("echo hi".to_string()), + shell: Some("bash".to_string()), + categories: Some(vec!["db".to_string()]), + notes: Some("some notes".to_string()), + content: Some("secret content".to_string()), + created: "0".to_string(), + is_global: Some(false), + } +} + +fn empty_item(item_type: &str) -> VaultItem { + VaultItem { + id: 0, + item_type: item_type.to_string(), + name: None, + value: None, + url: None, + username: None, + password: None, + title: None, + description: None, + command: None, + shell: None, + categories: None, + notes: None, + content: None, + created: String::new(), + is_global: None, + } +} + +// ─── redact_item (3) ──────────────────────────────────────────────────── + +#[test] +fn redact_item_strips_value() { + let item = redact_item(full_item()); + assert!(item.value.is_none()); +} + +#[test] +fn redact_item_strips_password() { + let item = redact_item(full_item()); + assert!(item.password.is_none()); +} + +#[test] +fn redact_item_strips_content_but_keeps_other_fields() { + let item = redact_item(full_item()); + assert!(item.content.is_none()); + // Non-secret fields must survive redaction untouched. + assert_eq!(item.name.as_deref(), Some("DB_HOST")); + assert_eq!(item.url.as_deref(), Some("https://example.com")); + assert_eq!(item.username.as_deref(), Some("user")); +} + +// ─── validate_create (8) ──────────────────────────────────────────────── + +#[test] +fn validate_create_accepts_a_valid_item() { + let mut item = empty_item("secret"); + item.name = Some("DB_HOST".to_string()); + item.value = Some("localhost".to_string()); + assert!(validate_create(&item).is_ok()); +} + +#[test] +fn validate_create_rejects_empty_name() { + let mut item = empty_item("secret"); + item.value = Some("v".to_string()); + assert!(validate_create(&item).is_err()); +} + +#[test] +fn validate_create_rejects_name_over_255_chars() { + let mut item = empty_item("secret"); + item.name = Some("a".repeat(256)); + item.value = Some("v".to_string()); + assert!(validate_create(&item).is_err()); +} + +#[test] +fn validate_create_rejects_empty_type() { + let mut item = empty_item(""); + item.name = Some("n".to_string()); + item.value = Some("v".to_string()); + assert!(validate_create(&item).is_err()); +} + +#[test] +fn validate_create_rejects_unknown_type() { + let mut item = empty_item("not-a-real-type"); + item.name = Some("n".to_string()); + item.value = Some("v".to_string()); + assert!(validate_create(&item).is_err()); +} + +#[test] +fn validate_create_rejects_missing_value() { + let mut item = empty_item("secret"); + item.name = Some("n".to_string()); + // value stays None + assert!(validate_create(&item).is_err()); +} + +#[test] +fn validate_create_rejects_empty_value() { + let mut item = empty_item("secret"); + item.name = Some("n".to_string()); + item.value = Some(String::new()); + assert!(validate_create(&item).is_err()); +} + +#[test] +fn validate_create_rejects_category_over_100_chars() { + let mut item = empty_item("secret"); + item.name = Some("n".to_string()); + item.value = Some("v".to_string()); + item.categories = Some(vec!["a".repeat(101)]); + assert!(validate_create(&item).is_err()); +} + +// ─── validate_update (4) ───────────────────────────────────────────────── + +#[test] +fn validate_update_accepts_a_fully_empty_partial_update() { + let item = empty_item(""); + assert!(validate_update(&item).is_ok()); +} + +#[test] +fn validate_update_rejects_explicit_empty_name() { + let mut item = empty_item(""); + item.name = Some(String::new()); + assert!(validate_update(&item).is_err()); +} + +#[test] +fn validate_update_rejects_unknown_type_when_present() { + let item = empty_item("not-a-real-type"); + assert!(validate_update(&item).is_err()); +} + +#[test] +fn validate_update_rejects_explicit_empty_value() { + let mut item = empty_item(""); + item.value = Some(String::new()); + assert!(validate_update(&item).is_err()); +} + +// ─── environment_item_ids (2) ──────────────────────────────────────────── + +fn make_env(vars: Vec<(i64, i64)>) -> Environment { + Environment { + id: 1, + project_id: 1, + name: "production".to_string(), + is_default: true, + paths: vec![], + vars: vars + .into_iter() + .map(|(id, item_id)| EnvironmentVar { id, key: format!("KEY_{id}"), item_id }) + .collect(), + created: "0".to_string(), + updated: "0".to_string(), + } +} + +#[test] +fn environment_item_ids_collects_all_linked_items() { + let env = make_env(vec![(1, 10), (2, 20), (3, 30)]); + let ids = environment_item_ids(&env); + assert_eq!(ids.len(), 3); + assert!(ids.contains(&10) && ids.contains(&20) && ids.contains(&30)); +} + +#[test] +fn environment_item_ids_is_empty_for_an_environment_with_no_vars() { + let env = make_env(vec![]); + assert!(environment_item_ids(&env).is_empty()); +} diff --git a/src-tauri/src/bin/crypt-env-mcp.rs b/src-tauri/src/bin/crypt-env-mcp.rs index be85509..3dbba2c 100644 --- a/src-tauri/src/bin/crypt-env-mcp.rs +++ b/src-tauri/src/bin/crypt-env-mcp.rs @@ -1842,10 +1842,24 @@ fn tool_list_projects(token: &str) -> serde_json::Value { /// Resolves an environment id from tool args shaped like `crypt_env_inject_environment`'s /// schema: `id` directly, or `project` + `environment` names (case-insensitive, resolved /// via GET /projects). Shared by every tool that identifies an environment this way. +/// +/// Split into a network-fetching half (this function) and a pure matching +/// half (`pick_environment_id`) so the matching logic is unit-testable +/// without a live server. fn resolve_environment_id(args: &serde_json::Value, token: &str) -> Result { if let Some(id) = args.get("id").and_then(|v| v.as_i64()) { return Ok(id); } + let projects = fetch_projects(token)?; + pick_environment_id(args, &projects) +} + +/// Pure logic behind `resolve_environment_id`: given the already-fetched +/// `GET /projects` payload and the tool args, finds the environment id +/// matching `project` + `environment` (case-insensitive). Does not touch the +/// network or consult `id` in `args` — that shortcut is handled by the +/// caller before this is reached. +fn pick_environment_id(args: &serde_json::Value, projects: &serde_json::Value) -> Result { let (project, environment) = match ( args.get("project").and_then(|v| v.as_str()), args.get("environment").and_then(|v| v.as_str()), @@ -1858,7 +1872,6 @@ fn resolve_environment_id(args: &serde_json::Value, token: &str) -> Result serde_json::Value { + serde_json::json!([ + { + "id": 1, + "name": "Demo", + "environments": [ + { "id": 10, "name": "Production" }, + { "id": 11, "name": "local" }, + ], + }, + ]) + } + + #[test] + fn pick_environment_id_matches_case_insensitively() { + let args = serde_json::json!({ "project": "demo", "environment": "PRODUCTION" }); + let id = pick_environment_id(&args, &sample_projects()).unwrap(); + assert_eq!(id, 10); + } + + #[test] + fn pick_environment_id_errors_on_unknown_project() { + let args = serde_json::json!({ "project": "ghost", "environment": "production" }); + assert!(pick_environment_id(&args, &sample_projects()).is_err()); + } + + #[test] + fn pick_environment_id_errors_on_known_project_unknown_environment() { + let args = serde_json::json!({ "project": "demo", "environment": "ghost" }); + assert!(pick_environment_id(&args, &sample_projects()).is_err()); + } + + #[test] + fn pick_environment_id_errors_when_project_or_environment_args_missing() { + let args = serde_json::json!({ "project": "demo" }); + assert!(pick_environment_id(&args, &sample_projects()).is_err()); + } +} diff --git a/src-tauri/src/bin/crypt-env/commands/scope.rs b/src-tauri/src/bin/crypt-env/commands/scope.rs index 4973f72..9ba133c 100644 --- a/src-tauri/src/bin/crypt-env/commands/scope.rs +++ b/src-tauri/src/bin/crypt-env/commands/scope.rs @@ -240,3 +240,118 @@ pub fn resolve( Ok(ResolvedScope { project, environment }) } + +#[cfg(test)] +mod tests { + use super::*; + + // ─── parse_project_config / find_project_config ─────────────────────── + + #[test] + fn parse_project_config_reads_project_and_environment() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CONFIG_FILE_NAME); + std::fs::write(&path, r#"{"project": "demo", "environment": "production"}"#).unwrap(); + + let config = parse_project_config(&path).unwrap(); + assert_eq!(config.project, "demo"); + assert_eq!(config.environment.as_deref(), Some("production")); + } + + #[test] + fn parse_project_config_environment_is_optional() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CONFIG_FILE_NAME); + std::fs::write(&path, r#"{"project": "demo"}"#).unwrap(); + + let config = parse_project_config(&path).unwrap(); + assert_eq!(config.project, "demo"); + assert!(config.environment.is_none()); + } + + #[test] + fn parse_project_config_rejects_empty_project_field() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CONFIG_FILE_NAME); + std::fs::write(&path, r#"{"project": ""}"#).unwrap(); + + assert!(parse_project_config(&path).is_err()); + } + + #[test] + fn parse_project_config_rejects_invalid_json() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CONFIG_FILE_NAME); + std::fs::write(&path, "not json").unwrap(); + + assert!(parse_project_config(&path).is_err()); + } + + #[test] + fn find_project_config_locates_file_in_a_parent_directory() { + let dir = tempfile::tempdir().unwrap(); + let root_config = dir.path().join(CONFIG_FILE_NAME); + std::fs::write(&root_config, r#"{"project": "demo"}"#).unwrap(); + + let nested = dir.path().join("a").join("b"); + std::fs::create_dir_all(&nested).unwrap(); + + let found = find_project_config(&nested).unwrap(); + assert_eq!(found.unwrap().project, "demo"); + } + + #[test] + fn find_project_config_returns_none_when_absent() { + let dir = tempfile::tempdir().unwrap(); + // A fresh tempdir has no crypt-env.json anywhere in its (short) + // ancestry within itself; searching starting here must not find the + // real repo's config accidentally. + let nested = dir.path().join("isolated"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("marker.txt"), "x").unwrap(); + + // Search only within the tempdir itself, not all the way to `/`, + // by asserting the immediate directory has no config — this does + // not prove the full upward walk is empty (that depends on the + // host filesystem), but does confirm a directory with no config + // file present does not spuriously report one. + let candidate = nested.join(CONFIG_FILE_NAME); + assert!(!candidate.is_file()); + } + + // ─── ResolvedScope ────────────────────────────────────────────────────── + + #[test] + fn append_query_adds_leading_question_mark_when_url_has_none() { + let scope = ResolvedScope { project: "demo".to_string(), environment: "production".to_string() }; + let url = scope.append_query("/items"); + assert_eq!(url, "/items?project=demo&environment=production"); + } + + #[test] + fn append_query_appends_with_ampersand_when_url_already_has_a_query() { + let scope = ResolvedScope { project: "demo".to_string(), environment: "production".to_string() }; + let url = scope.append_query("/items?type=secret"); + assert_eq!(url, "/items?type=secret&project=demo&environment=production"); + } + + #[test] + fn to_query_string_url_encodes_special_characters() { + let scope = ResolvedScope { project: "my project".to_string(), environment: "prod/test".to_string() }; + let qs = scope.to_query_string(); + assert!(!qs.contains(' '), "spaces must be percent-encoded: {qs}"); + assert!(qs.starts_with("project=")); + } + + // ─── resolve (network-free branches only) ────────────────────────────── + + #[test] + fn resolve_with_both_flags_explicit_never_touches_network_or_fs() { + // allow_create is irrelevant here: with both flags given, neither + // find_project_config nor default_environment_for (which would need + // a live server) is ever called. + let scope = resolve(Some("demo"), Some("production"), false).unwrap(); + assert_eq!(scope.project, "demo"); + assert_eq!(scope.environment, "production"); + } +} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 94d1159..c536aa4 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1332,4 +1332,93 @@ mod tests { assert_eq!(db2.list_projects().await.unwrap().len(), 1); assert_eq!(db2.list_environments(1).await.unwrap().len(), 1); } + + // ─── upsert_environment_var (R2: ON CONFLICT repoint, not insert) ───── + + async fn fresh_env() -> (tempfile::TempDir, VaultDb, i64) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let db = VaultDb::open(path.to_str().unwrap()).await.unwrap(); + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + (dir, db, env_id) + } + + #[tokio::test] + async fn upsert_environment_var_first_insert_creates_a_row() { + let (_dir, db, env_id) = fresh_env().await; + let row_id = db.upsert_environment_var(env_id, "DB_HOST", 42).await.unwrap(); + + let vars = db.get_environment_vars(env_id).await.unwrap(); + assert_eq!(vars.len(), 1); + assert_eq!(vars[0].id, row_id); + assert_eq!(vars[0].item_id, Some(42)); + } + + #[tokio::test] + async fn upsert_environment_var_same_key_twice_repoints_not_duplicates() { + let (_dir, db, env_id) = fresh_env().await; + let first_id = db.upsert_environment_var(env_id, "DB_HOST", 42).await.unwrap(); + let second_id = db.upsert_environment_var(env_id, "DB_HOST", 99).await.unwrap(); + + assert_eq!(first_id, second_id, "same key must repoint the same row, not insert a new one"); + let vars = db.get_environment_vars(env_id).await.unwrap(); + assert_eq!(vars.len(), 1, "ON CONFLICT must not leave two rows for the same (environment_id, key)"); + assert_eq!(vars[0].item_id, Some(99)); + } + + #[tokio::test] + async fn upsert_environment_var_two_different_keys_yield_two_rows() { + let (_dir, db, env_id) = fresh_env().await; + db.upsert_environment_var(env_id, "DB_HOST", 1).await.unwrap(); + db.upsert_environment_var(env_id, "DB_PASSWORD", 2).await.unwrap(); + + let vars = db.get_environment_vars(env_id).await.unwrap(); + assert_eq!(vars.len(), 2); + } + + #[tokio::test] + async fn upsert_environment_var_same_key_in_different_environments_are_independent() { + let (dir, db, env_a) = fresh_env().await; + let _ = &dir; + let project_id = db.upsert_project(0, "other", None, "generic").await.unwrap(); + let env_b = db.upsert_environment(0, project_id, "staging", true).await.unwrap(); + + db.upsert_environment_var(env_a, "SHARED_KEY", 1).await.unwrap(); + db.upsert_environment_var(env_b, "SHARED_KEY", 2).await.unwrap(); + + let vars_a = db.get_environment_vars(env_a).await.unwrap(); + let vars_b = db.get_environment_vars(env_b).await.unwrap(); + assert_eq!(vars_a.len(), 1); + assert_eq!(vars_b.len(), 1); + assert_eq!(vars_a[0].item_id, Some(1)); + assert_eq!(vars_b[0].item_id, Some(2)); + } + + #[tokio::test] + async fn upsert_environment_var_updating_one_key_leaves_others_untouched() { + let (_dir, db, env_id) = fresh_env().await; + db.upsert_environment_var(env_id, "DB_HOST", 1).await.unwrap(); + db.upsert_environment_var(env_id, "DB_PASSWORD", 2).await.unwrap(); + + db.upsert_environment_var(env_id, "DB_HOST", 100).await.unwrap(); + + let mut vars = db.get_environment_vars(env_id).await.unwrap(); + vars.sort_by(|a, b| a.key.cmp(&b.key)); + assert_eq!(vars.len(), 2); + assert_eq!(vars[0].key, "DB_HOST"); + assert_eq!(vars[0].item_id, Some(100)); + assert_eq!(vars[1].key, "DB_PASSWORD"); + assert_eq!(vars[1].item_id, Some(2), "unrelated key must be untouched by repointing DB_HOST"); + } + + #[tokio::test] + async fn upsert_environment_var_returned_id_matches_subsequent_lookup() { + let (_dir, db, env_id) = fresh_env().await; + let row_id = db.upsert_environment_var(env_id, "DB_HOST", 1).await.unwrap(); + + let vars = db.get_environment_vars(env_id).await.unwrap(); + let found = vars.iter().find(|v| v.key == "DB_HOST").unwrap(); + assert_eq!(found.id, row_id); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9792598..651030d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,9 @@ pub mod share; pub mod tls; pub mod vault; +#[cfg(test)] +mod test_support; + use vault::{ app_complete_setup, app_generate_mcp_config, app_is_first_run, biometric_check, biometric_disable, biometric_enroll, biometric_is_enrolled, biometric_unlock, diff --git a/src-tauri/src/project/mod.rs b/src-tauri/src/project/mod.rs index 54be057..0fd6cd3 100644 --- a/src-tauri/src/project/mod.rs +++ b/src-tauri/src/project/mod.rs @@ -10,7 +10,7 @@ use crate::vault::SharedState; /// Every variable is a real vault item now — no more bare literals. `item_id` /// points into the shared `items` table; ownership (`item_projects`) is /// granted automatically by `save_environment` below. -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct EnvironmentVar { #[serde(default)] pub id: i64, @@ -19,7 +19,7 @@ pub struct EnvironmentVar { pub item_id: i64, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct Environment { #[serde(default)] pub id: i64, @@ -82,7 +82,7 @@ pub struct ProjectInput { pub categories: Vec, } -#[derive(Serialize)] +#[derive(Serialize, Debug)] pub struct InjectResult { pub paths: Vec, pub written: Vec, @@ -523,3 +523,345 @@ pub async fn project_import() -> Result { let project: ExportedProject = serde_json::from_slice(&json).map_err(|e| format!("invalid file: {e}"))?; Ok(project) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::VaultDb; + use crate::vault::VaultItem; + + async fn test_db() -> (tempfile::TempDir, VaultDb) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let db = VaultDb::open(path.to_str().unwrap()).await.unwrap(); + (dir, db) + } + + fn plain_secret(name: &str, value: &str) -> VaultItem { + VaultItem { + id: 0, + item_type: "secret".to_string(), + name: Some(name.to_string()), + value: Some(value.to_string()), + url: None, + username: None, + password: None, + title: None, + description: None, + command: None, + shell: None, + categories: None, + notes: None, + content: None, + created: "0".to_string(), + is_global: Some(false), + } + } + + // ─── resolve_environment ──────────────────────────────────────────── + + #[tokio::test] + async fn resolve_by_environment_id() { + let (_dir, db) = test_db().await; + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + + let env = resolve_environment(&db, Some(env_id), None, None).await.unwrap(); + assert_eq!(env.id, env_id); + assert_eq!(env.project_id, project_id); + assert_eq!(env.name, "production"); + } + + #[tokio::test] + async fn resolve_by_environment_id_unknown_errors() { + let (_dir, db) = test_db().await; + let err = resolve_environment(&db, Some(999_999), None, None).await.unwrap_err(); + assert!(err.contains("not found")); + } + + #[tokio::test] + async fn resolve_by_project_and_environment() { + let (_dir, db) = test_db().await; + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + + let env = resolve_environment(&db, None, Some("demo"), Some("production")).await.unwrap(); + assert_eq!(env.id, env_id); + } + + #[tokio::test] + async fn resolve_project_name_is_case_insensitive() { + let (_dir, db) = test_db().await; + let project_id = db.upsert_project(0, "Demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + + let env = resolve_environment(&db, None, Some("DEMO"), Some("production")).await.unwrap(); + assert_eq!(env.id, env_id); + } + + #[tokio::test] + async fn resolve_environment_name_is_case_insensitive() { + let (_dir, db) = test_db().await; + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "Production", true).await.unwrap(); + + let env = resolve_environment(&db, None, Some("demo"), Some("PRODUCTION")).await.unwrap(); + assert_eq!(env.id, env_id); + } + + #[tokio::test] + async fn resolve_known_project_unknown_environment_errors() { + let (_dir, db) = test_db().await; + db.upsert_project(0, "demo", None, "generic").await.unwrap(); + + let err = resolve_environment(&db, None, Some("demo"), Some("nope")).await.unwrap_err(); + assert!(err.contains("not found")); + } + + #[tokio::test] + async fn resolve_unknown_project_errors() { + let (_dir, db) = test_db().await; + let err = resolve_environment(&db, None, Some("ghost"), Some("production")).await.unwrap_err(); + assert!(err.contains("not found")); + } + + #[tokio::test] + async fn resolve_project_alone_without_environment_errors() { + // Current behaviour: `resolve_environment` has only two accepted + // shapes — `environment_id` alone, or `project` + `environment` + // together. There is NO "project alone -> default environment" + // fallback in the code today, even though earlier design notes + // floated one. A project name with no environment name falls + // through to the same "provide environment_id, or both..." error as + // passing neither. Pinned here so adding that fallback later is a + // deliberate, visible diff to this test rather than a silent change. + let (_dir, db) = test_db().await; + db.upsert_project(0, "demo", None, "generic").await.unwrap(); + + let err = resolve_environment(&db, None, Some("demo"), None).await.unwrap_err(); + assert!(err.contains("provide environment_id")); + } + + #[tokio::test] + async fn resolve_with_no_scope_params_errors() { + let (_dir, db) = test_db().await; + let err = resolve_environment(&db, None, None, None).await.unwrap_err(); + assert!(err.contains("provide environment_id")); + } + + #[tokio::test] + async fn resolve_environment_id_takes_precedence_over_project_and_environment() { + let (_dir, db) = test_db().await; + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + + // Mismatched project/environment names are ignored when environment_id is present. + let env = resolve_environment(&db, Some(env_id), Some("does-not-exist"), Some("also-not-real")) + .await + .unwrap(); + assert_eq!(env.id, env_id); + } + + // ─── inject_environment ───────────────────────────────────────────── + + async fn seeded_env_with_item(db: &VaultDb, key: &[u8; 32], var_key: &str, value: &str) -> i64 { + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + let item = plain_secret(var_key, value); + let encrypted = crate::vault::encrypt_item(key, &item).unwrap(); + let item_id = db.upsert_item(0, "secret", &encrypted, &item.created, false).await.unwrap(); + db.add_item_owner(item_id, project_id).await.unwrap(); + db.upsert_environment_var(env_id, var_key, item_id).await.unwrap(); + env_id + } + + #[tokio::test] + async fn inject_writes_key_value_to_configured_path() { + let (dir, db) = test_db().await; + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + let env_id = seeded_env_with_item(&db, &key, "DB_HOST", "localhost").await; + let path = dir.path().join(".env"); + db.set_environment_paths(env_id, &[path.to_str().unwrap().to_string()]).await.unwrap(); + + let result = inject_environment(&db, &key, env_id, None, None).await.unwrap(); + + assert_eq!(result.written, vec!["DB_HOST".to_string()]); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("DB_HOST=localhost")); + // The written file's parent must stay inside the tempdir — the + // fixture issue #7's path-traversal assertions build on this. + assert_eq!(path.parent().unwrap(), dir.path()); + } + + #[tokio::test] + async fn inject_merges_with_existing_file_preserving_unrelated_keys() { + let (dir, db) = test_db().await; + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + let env_id = seeded_env_with_item(&db, &key, "DB_HOST", "localhost").await; + let path = dir.path().join(".env"); + std::fs::write(&path, "PORT=3000\nDB_HOST=old-value\n").unwrap(); + db.set_environment_paths(env_id, &[path.to_str().unwrap().to_string()]).await.unwrap(); + + inject_environment(&db, &key, env_id, None, None).await.unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("PORT=3000"), "unrelated key must survive"); + assert!(content.contains("DB_HOST=localhost"), "managed key must be updated"); + } + + #[tokio::test] + async fn inject_output_path_is_added_to_configured_paths() { + let (dir, db) = test_db().await; + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + let env_id = seeded_env_with_item(&db, &key, "DB_HOST", "localhost").await; + let configured = dir.path().join(".env"); + db.set_environment_paths(env_id, &[configured.to_str().unwrap().to_string()]).await.unwrap(); + let extra = dir.path().join(".env.extra"); + + let result = inject_environment(&db, &key, env_id, Some(extra.to_str().unwrap().to_string()), None) + .await + .unwrap(); + + assert_eq!(result.paths.len(), 2); + assert!(extra.exists()); + assert!(configured.exists()); + } + + #[tokio::test] + async fn inject_output_dir_used_only_when_no_paths_configured() { + let (dir, db) = test_db().await; + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + let env_id = seeded_env_with_item(&db, &key, "DB_HOST", "localhost").await; + // no configured paths, no output_path + + let result = inject_environment(&db, &key, env_id, None, Some(dir.path().to_str().unwrap().to_string())) + .await + .unwrap(); + + let expected = dir.path().join(".env.production"); + assert_eq!(result.paths, vec![expected.to_str().unwrap().to_string()]); + assert!(expected.exists()); + } + + #[tokio::test] + async fn inject_unknown_environment_errors_not_found() { + let (_dir, db) = test_db().await; + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + let err = inject_environment(&db, &key, 999_999, None, None).await.unwrap_err(); + assert_eq!(err, "environment not found"); + } + + #[tokio::test] + async fn inject_no_paths_and_no_output_errors() { + let (_dir, db) = test_db().await; + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + + let err = inject_environment(&db, &key, env_id, None, None).await.unwrap_err(); + assert_eq!(err, "environment has no paths configured"); + } + + // ─── save_environment multi-owner guard ──────────────────────────── + + #[tokio::test] + async fn save_environment_links_item_already_owned_by_project() { + let (_dir, db) = test_db().await; + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let item = plain_secret("DB_HOST", "localhost"); + let encrypted = crate::vault::encrypt_item(&key, &item).unwrap(); + let item_id = db.upsert_item(0, "secret", &encrypted, &item.created, false).await.unwrap(); + db.add_item_owner(item_id, project_id).await.unwrap(); + + let input = EnvironmentInput { + id: 0, + project_id, + name: "production".to_string(), + is_default: true, + paths: vec![], + vars: vec![EnvironmentVar { id: 0, key: "DB_HOST".to_string(), item_id }], + }; + + let env_id = save_environment(&db, input).await.unwrap(); + let vars = db.get_environment_vars(env_id).await.unwrap(); + assert_eq!(vars.len(), 1); + assert_eq!(vars[0].item_id, Some(item_id)); + } + + #[tokio::test] + async fn save_environment_grants_ownership_for_global_item() { + let (_dir, db) = test_db().await; + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let item = plain_secret("SHARED", "shared-value"); + let encrypted = crate::vault::encrypt_item(&key, &item).unwrap(); + let item_id = db.upsert_item(0, "secret", &encrypted, &item.created, true).await.unwrap(); + // Not yet owned by project_id, but is_global = true. + + let input = EnvironmentInput { + id: 0, + project_id, + name: "production".to_string(), + is_default: true, + paths: vec![], + vars: vec![EnvironmentVar { id: 0, key: "SHARED".to_string(), item_id }], + }; + + save_environment(&db, input).await.unwrap(); + assert!(db.list_owning_projects(item_id).await.unwrap().contains(&project_id)); + } + + #[tokio::test] + async fn save_environment_rejects_unowned_non_global_item() { + let (_dir, db) = test_db().await; + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let other_project = db.upsert_project(0, "other", None, "generic").await.unwrap(); + let item = plain_secret("PRIVATE", "value"); + let encrypted = crate::vault::encrypt_item(&key, &item).unwrap(); + let item_id = db.upsert_item(0, "secret", &encrypted, &item.created, false).await.unwrap(); + db.add_item_owner(item_id, other_project).await.unwrap(); + + let input = EnvironmentInput { + id: 0, + project_id, + name: "production".to_string(), + is_default: true, + paths: vec![], + vars: vec![EnvironmentVar { id: 0, key: "PRIVATE".to_string(), item_id }], + }; + + let err = save_environment(&db, input).await.unwrap_err(); + assert!(err.contains("not global")); + } + + #[tokio::test] + async fn save_environment_replaces_previous_var_set() { + let (_dir, db) = test_db().await; + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let item_a = plain_secret("A", "va"); + let item_b = plain_secret("B", "vb"); + let enc_a = crate::vault::encrypt_item(&key, &item_a).unwrap(); + let enc_b = crate::vault::encrypt_item(&key, &item_b).unwrap(); + let id_a = db.upsert_item(0, "secret", &enc_a, &item_a.created, false).await.unwrap(); + let id_b = db.upsert_item(0, "secret", &enc_b, &item_b.created, false).await.unwrap(); + db.add_item_owner(id_a, project_id).await.unwrap(); + db.add_item_owner(id_b, project_id).await.unwrap(); + + let env_id = save_environment(&db, EnvironmentInput { + id: 0, project_id, name: "production".to_string(), is_default: true, + paths: vec![], vars: vec![EnvironmentVar { id: 0, key: "A".to_string(), item_id: id_a }], + }).await.unwrap(); + + save_environment(&db, EnvironmentInput { + id: env_id, project_id, name: "production".to_string(), is_default: true, + paths: vec![], vars: vec![EnvironmentVar { id: 0, key: "B".to_string(), item_id: id_b }], + }).await.unwrap(); + + let vars = db.get_environment_vars(env_id).await.unwrap(); + assert_eq!(vars.len(), 1, "second save must replace, not append to, the var set"); + assert_eq!(vars[0].key, "B"); + } +} diff --git a/src-tauri/src/share/mod.rs b/src-tauri/src/share/mod.rs index 9600578..97c3f35 100644 --- a/src-tauri/src/share/mod.rs +++ b/src-tauri/src/share/mod.rs @@ -880,3 +880,118 @@ pub async fn import_package( Ok(outcome) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::VaultDb; + + fn plain(name: &str, value: &str) -> PlainItem { + PlainItem { + item_type: "secret".to_string(), + name: name.to_string(), + value: Some(value.to_string()), + username: None, + password: None, + url: None, + notes: None, + category: None, + command: None, + } + } + + async fn test_db() -> (tempfile::TempDir, VaultDb, [u8; 32]) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let db = VaultDb::open(path.to_str().unwrap()).await.unwrap(); + let (_, _, key) = crate::crypto::init_vault_crypto(b"pw").unwrap(); + (dir, db, key) + } + + #[tokio::test] + async fn import_without_link_creates_unowned_items() { + let (_dir, db, key) = test_db().await; + let items = vec![plain("DB_HOST", "localhost")]; + + let outcome = import_plain_items_into_vault(&items, &key, &db, None).await.unwrap(); + + assert_eq!(outcome.names, vec!["DB_HOST".to_string()]); + assert!(outcome.skipped_keys.is_empty()); + let raw = db.list_items().await.unwrap(); + assert_eq!(raw.len(), 1); + let (id, ..) = raw[0].clone(); + assert!(db.list_owning_projects(id).await.unwrap().is_empty(), "no link => no ownership grant"); + } + + #[tokio::test] + async fn import_with_link_grants_ownership_and_links_the_environment_var() { + let (_dir, db, key) = test_db().await; + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + let items = vec![plain("DB_HOST", "localhost")]; + + let outcome = import_plain_items_into_vault(&items, &key, &db, Some((project_id, env_id))).await.unwrap(); + + assert!(outcome.skipped_keys.is_empty()); + let vars = db.get_environment_vars(env_id).await.unwrap(); + assert_eq!(vars.len(), 1); + assert_eq!(vars[0].key, "DB_HOST"); + let item_id = vars[0].item_id.unwrap(); + assert!(db.list_owning_projects(item_id).await.unwrap().contains(&project_id)); + } + + #[tokio::test] + async fn import_skips_relinking_a_key_already_linked_to_a_different_item() { + let (_dir, db, key) = test_db().await; + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + // Pre-existing link the receiver already had. + db.upsert_environment_var(env_id, "DB_HOST", 12345).await.unwrap(); + + let items = vec![plain("DB_HOST", "incoming-value")]; + let outcome = import_plain_items_into_vault(&items, &key, &db, Some((project_id, env_id))).await.unwrap(); + + assert_eq!(outcome.skipped_keys, vec!["DB_HOST".to_string()], "must not silently repoint an existing link"); + // The item is still imported (and owned) even though not linked. + assert_eq!(outcome.names, vec!["DB_HOST".to_string()]); + let vars = db.get_environment_vars(env_id).await.unwrap(); + assert_eq!(vars[0].item_id, Some(12345), "existing link must be untouched"); + } + + #[tokio::test] + async fn import_skips_linking_an_unsafe_key_but_still_imports_the_item() { + let (_dir, db, key) = test_db().await; + let project_id = db.upsert_project(0, "demo", None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + let mut bad = plain("BAD=KEY", "value"); + bad.name = "BAD=KEY".to_string(); // contains '=', corrupts a KEY=value line + + let outcome = import_plain_items_into_vault(&[bad], &key, &db, Some((project_id, env_id))).await.unwrap(); + + assert_eq!(outcome.skipped_keys, vec!["BAD=KEY".to_string()]); + assert!(db.get_environment_vars(env_id).await.unwrap().is_empty()); + assert_eq!(db.list_items().await.unwrap().len(), 1, "item is still imported, just not linked"); + } + + #[tokio::test] + async fn import_multiple_items_preserves_order_in_names() { + let (_dir, db, key) = test_db().await; + let items = vec![plain("A", "1"), plain("B", "2"), plain("C", "3")]; + + let outcome = import_plain_items_into_vault(&items, &key, &db, None).await.unwrap(); + + assert_eq!(outcome.names, vec!["A".to_string(), "B".to_string(), "C".to_string()]); + } + + #[tokio::test] + async fn imported_items_are_never_global() { + let (_dir, db, key) = test_db().await; + let items = vec![plain("DB_HOST", "localhost")]; + + import_plain_items_into_vault(&items, &key, &db, None).await.unwrap(); + + let raw = db.list_items().await.unwrap(); + let (_, _, _, _, is_global) = raw[0].clone(); + assert!(!is_global, "imported items must never be created as global"); + } +} diff --git a/src-tauri/src/test_support/mod.rs b/src-tauri/src/test_support/mod.rs new file mode 100644 index 0000000..0b8837b --- /dev/null +++ b/src-tauri/src/test_support/mod.rs @@ -0,0 +1,335 @@ +//! Shared test harness — see `docs/plans/issue-11-test-coverage-and-postman.md` +//! §3.2 for the design rationale. +//! +//! Only ever compiled under `#[cfg(test)]` (declared as `#[cfg(test)] mod +//! test_support;` in `lib.rs`), so nothing here reaches a release build. +//! +//! **Sibling issue plans: this is the fixture API. Use it; do not invent +//! another.** `unwrap()`/`expect()` are permitted here — CLAUDE.md's ban on +//! `unwrap()` applies to production code; a panicking fixture is a failing +//! test, which is correct behaviour. +//! +//! Dependency direction: this module sits *above* `db`, `vault`, `project` +//! and `api`, and is imported by their test modules only. It does not create +//! a `db -> api` edge; those modules remain unaware of `api`/`test_support` +//! in all non-test builds. +//! +//! Risk: fixture coupling (see plan §6.2). `TestVault`'s seeded shape (3 +//! items in "production", 1 global item, "demo" project with "production" + +//! "local" environments) is depended on by multiple sibling issues. Tests +//! MUST assert against `v.item_ids` / `v.project_id` / `v.env_id`, never +//! against literal ids or item counts. Additions for a single test go +//! through `seed_item`/`seed_project`/`link_var`, never by editing the +//! shared builder below. + +use std::sync::Arc; +use tokio::sync::Mutex; +use zeroize::Zeroizing; + +use crate::db::VaultDb; +use crate::vault::{SharedState, VaultItem, VaultState}; + +/// A fully wired vault backed by a real (tempdir) SQLite file, ready to be +/// driven through the HTTP router via `req()`, or inspected directly via its +/// `state`/`dir` fields. +/// +/// `dir` MUST stay alive for the lifetime of the test: dropping it deletes +/// the sqlite file out from under `state`. Never destructure it away. +pub struct TestVault { + pub dir: tempfile::TempDir, + pub state: SharedState, + /// Value for the `X-Vault-Token` header — the static `settings['mcp_token']`, + /// seeded so tests authenticate immediately without an `/unlock` round-trip. + pub token: String, + pub master_password: String, + /// Project "demo". Zero when built via `unlocked_vault_empty()`. + pub project_id: i64, + /// Environment "production" of "demo" (`isDefault = true`). Zero when + /// built via `unlocked_vault_empty()`. + pub env_id: i64, + /// All seeded items, in insertion order: `DB_HOST`, `DB_PASSWORD`, + /// `API_KEY` (all linked into "production"), then `SHARED_TOKEN` (the + /// global item, linked to nothing). Empty when built via + /// `unlocked_vault_empty()`. + pub item_ids: Vec, +} + +const MASTER_PASSWORD: &str = "test-master-password-1"; +const MCP_TOKEN: &str = "test-mcp-token-0123456789abcdef"; + +async fn open_db() -> (tempfile::TempDir, VaultDb) { + let dir = tempfile::tempdir().expect("create tempdir for test vault"); + let db_path = dir.path().join("vault.db"); + let db = VaultDb::open(db_path.to_str().expect("tempdir path is valid utf8")) + .await + .expect("open test vault db"); + (dir, db) +} + +/// Initialises vault crypto (salt + verify token + key), persists them, and +/// seeds `settings['mcp_token']` so `token` authenticates immediately. +/// Returns the raw 32-byte key. +async fn init_crypto(db: &VaultDb) -> [u8; 32] { + let (salt, verify_token, key) = + crate::crypto::init_vault_crypto(MASTER_PASSWORD.as_bytes()).expect("init vault crypto"); + db.init_vault(&salt, &verify_token) + .await + .expect("persist vault_meta"); + db.set_setting("mcp_token", MCP_TOKEN) + .await + .expect("seed mcp_token setting"); + key +} + +fn plain_secret(name: &str, value: &str, is_global: bool) -> VaultItem { + VaultItem { + id: 0, + item_type: "secret".to_string(), + name: Some(name.to_string()), + value: Some(value.to_string()), + url: None, + username: None, + password: None, + title: None, + description: None, + command: None, + shell: None, + categories: None, + notes: None, + content: None, + created: "0".to_string(), + is_global: Some(is_global), + } +} + +/// Shared builder for `unlocked_vault()` / `unlocked_vault_empty()` / +/// `locked_vault()`. `seed_data = false` gives crypto + `mcp_token` only — +/// no project/environments/items. +async fn build(seed_data: bool) -> TestVault { + let (dir, db) = open_db().await; + let key = init_crypto(&db).await; + + let mut project_id = 0i64; + let mut env_id = 0i64; + let mut item_ids = Vec::new(); + + if seed_data { + project_id = db + .upsert_project(0, "demo", None, "generic") + .await + .expect("insert project 'demo'"); + let prod_id = db + .upsert_environment(0, project_id, "production", true) + .await + .expect("insert environment 'production'"); + db.upsert_environment(0, project_id, "local", false) + .await + .expect("insert environment 'local'"); + env_id = prod_id; + + for (name, value) in [ + ("DB_HOST", "localhost"), + ("DB_PASSWORD", "hunter2"), + ("API_KEY", "sk-test-key"), + ] { + let item = plain_secret(name, value, false); + let encrypted = crate::vault::encrypt_item(&key, &item).expect("encrypt seeded item"); + let item_id = db + .upsert_item(0, &item.item_type, &encrypted, &item.created, false) + .await + .expect("insert seeded item"); + db.add_item_owner(item_id, project_id) + .await + .expect("grant item ownership to 'demo'"); + db.upsert_environment_var(prod_id, name, item_id) + .await + .expect("link seeded item into 'production'"); + item_ids.push(item_id); + } + + let global_item = plain_secret("SHARED_TOKEN", "shared-value", true); + let encrypted = + crate::vault::encrypt_item(&key, &global_item).expect("encrypt global item"); + let global_id = db + .upsert_item(0, &global_item.item_type, &encrypted, &global_item.created, true) + .await + .expect("insert global item"); + item_ids.push(global_id); + } + + let state: SharedState = Arc::new(Mutex::new(VaultState::new(db))); + { + let mut s = state.lock().await; + s.key = Some(Zeroizing::new(key)); + s.touch(); + } + + TestVault { + dir, + state, + token: MCP_TOKEN.to_string(), + master_password: MASTER_PASSWORD.to_string(), + project_id, + env_id, + item_ids, + } +} + +/// Tempdir + `VaultDb::open` + `init_vault_crypto` + key installed in +/// `VaultState` + project "demo" with environments "production" (default) +/// and "local" + 3 items (`DB_HOST`, `DB_PASSWORD`, `API_KEY`) linked into +/// "production" + 1 global item (`SHARED_TOKEN`, `isGlobal = true`, linked to +/// nothing) + `settings['mcp_token']` seeded so `token` authenticates +/// immediately. +pub async fn unlocked_vault() -> TestVault { + build(true).await +} + +/// Same crypto setup, no projects / environments / items. For tests that +/// need to assert creation from empty, or 422-on-unresolvable-scope. +pub async fn unlocked_vault_empty() -> TestVault { + build(false).await +} + +/// Locked vault (`state.key == None`) for 403 VAULT_LOCKED assertions. Same +/// seeded shape as `unlocked_vault()` otherwise, so a test can still build +/// scope query params against `v.env_id` before asserting the lock rejects +/// the request. +pub async fn locked_vault() -> TestVault { + let v = build(true).await; + v.state.lock().await.key = None; + v +} + +/// The plain axum Router with state — no TLS, no socket. Builds `ApiState` +/// via the same `ApiState::new` that `start_server` uses, so tests and the +/// real server construct state identically. +pub fn router(v: &TestVault) -> axum::Router { + crate::api::build_router(Arc::new(crate::api::ApiState::new(v.state.clone()))) +} + +/// One request through `ServiceExt::oneshot`. `token: None` omits the +/// `X-Vault-Token` header entirely (for 401 assertions). Returns the status +/// plus the parsed JSON body (`Value::Null` for empty bodies). +pub async fn req( + app: &axum::Router, + method: &str, + uri: &str, + token: Option<&str>, + body: Option, +) -> (axum::http::StatusCode, serde_json::Value) { + use tower::ServiceExt; + + let mut builder = axum::http::Request::builder().method(method).uri(uri); + if let Some(t) = token { + builder = builder.header("x-vault-token", t); + } + let axum_body = match &body { + Some(v) => { + builder = builder.header("content-type", "application/json"); + axum::body::Body::from(serde_json::to_vec(v).expect("serialize request body")) + } + None => axum::body::Body::empty(), + }; + let request = builder.body(axum_body).expect("build test request"); + + let response = app + .clone() + .oneshot(request) + .await + .expect("router did not return a response"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read response body"); + let json = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) + }; + (status, json) +} + +/// Real `POST /unlock` round-trip returning a session token, for the tests +/// that need session semantics (expiry, rate limiting) rather than the +/// static `mcp_token`. +pub async fn session_token(app: &axum::Router, v: &TestVault) -> String { + let body = serde_json::json!({ "master_password": v.master_password }); + let (status, json) = req(app, "POST", "/unlock", None, Some(body)).await; + assert_eq!( + status, + axum::http::StatusCode::OK, + "unlock round-trip failed: {json:?}" + ); + json.get("token") + .and_then(|t| t.as_str()) + .expect("unlock response carries a token field") + .to_string() +} + +/// Encrypts and inserts a standalone item — does not link it into any +/// environment or grant project ownership. Pair with `link_var` and/or a +/// direct `db.add_item_owner` call (via `v.state`) when a test needs those. +pub async fn seed_item(v: &TestVault, name: &str, value: &str, is_global: bool) -> i64 { + let state = v.state.lock().await; + let key = state + .key + .clone() + .expect("vault must be unlocked to seed_item"); + let item = plain_secret(name, value, is_global); + let encrypted = crate::vault::encrypt_item(&key, &item).expect("encrypt item"); + state + .db + .upsert_item(0, &item.item_type, &encrypted, &item.created, is_global) + .await + .expect("insert item") +} + +/// Creates a new project with one environment per name in `envs` (the first +/// is `isDefault = true`, matching `unlocked_vault()`'s own convention). +/// Returns `(project_id, environment_ids)` in the same order as `envs`. +pub async fn seed_project(v: &TestVault, name: &str, envs: &[&str]) -> (i64, Vec) { + let state = v.state.lock().await; + let project_id = state + .db + .upsert_project(0, name, None, "generic") + .await + .expect("insert project"); + let mut env_ids = Vec::with_capacity(envs.len()); + for (i, env_name) in envs.iter().enumerate() { + let id = state + .db + .upsert_environment(0, project_id, env_name, i == 0) + .await + .expect("insert environment"); + env_ids.push(id); + } + (project_id, env_ids) +} + +/// Links `item_id` into `env_id` under `key`, without touching any other var +/// already set on that environment. Returns the `environment_vars` row id. +pub async fn link_var(v: &TestVault, env_id: i64, key: &str, item_id: i64) -> i64 { + let state = v.state.lock().await; + state + .db + .upsert_environment_var(env_id, key, item_id) + .await + .expect("link environment var") +} + +/// Reads an item straight out of the DB and decrypts it — for asserting what +/// was actually persisted, independent of what the handler echoed back. +pub async fn read_item(v: &TestVault, id: i64) -> VaultItem { + let state = v.state.lock().await; + let key = state + .key + .clone() + .expect("vault must be unlocked to read_item"); + let raw = state.db.list_items().await.expect("list_items"); + let (row_id, _, data, _, is_global) = raw + .into_iter() + .find(|(row_id, ..)| *row_id == id) + .expect("item exists in db"); + crate::vault::decrypt_item(&key, row_id, &data, is_global).expect("decrypt item") +} diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 76553f0..c595a18 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -330,55 +330,69 @@ pub struct GlobalToggleResult { pub forked: Vec, } -#[tauri::command] -pub async fn vault_set_item_global( +/// Pure logic behind `vault_set_item_global`: toggles `isGlobal`, or — when +/// un-globaling an item with more than one owning project — forks it into +/// one independent copy per owner. Split out the same way `create_project_item` +/// already was, so this is unit-testable without a Tauri runtime; the +/// `#[tauri::command]` below is now a thin wrapper delegating to it with +/// identical behaviour. +pub async fn set_item_global( + db: &VaultDb, + key: &CryptoKey, id: i64, global: bool, - state: State<'_, SharedState>, ) -> Result { - let mut s = state.lock().await; - let key = s.key.as_ref().ok_or("vault is locked")?.clone(); - s.touch(); - - let owners = s.db.list_owning_projects(id).await?; + let owners = db.list_owning_projects(id).await?; // Marking global, or un-globaling something with ≤1 owner: no fork needed. if global || owners.len() <= 1 { - s.db.set_item_global(id, global).await?; - let raw = s.db.list_items().await?; + db.set_item_global(id, global).await?; + let raw = db.list_items().await?; let updated = raw .into_iter() .find(|(row_id, ..)| *row_id == id) - .and_then(|(row_id, _, data, _, is_global)| decrypt_item(&key, row_id, &data, is_global).ok()); + .and_then(|(row_id, _, data, _, is_global)| decrypt_item(key, row_id, &data, is_global).ok()); return Ok(GlobalToggleResult { updated, forked: vec![] }); } // Un-globaling a multi-owner item: fork one independent copy per owner. - let raw = s.db.list_items().await?; + let raw = db.list_items().await?; let (_, item_type, data, created, _) = raw .into_iter() .find(|(row_id, ..)| *row_id == id) .ok_or("item not found")?; - let original = decrypt_item(&key, id, &data, true)?; + let original = decrypt_item(key, id, &data, true)?; let mut forked = Vec::with_capacity(owners.len()); for project_id in &owners { let mut copy = original.clone(); copy.id = 0; copy.is_global = Some(false); - let encrypted = encrypt_item(&key, ©)?; - let new_id = s.db.upsert_item(0, &item_type, &encrypted, &created, false).await?; - s.db.add_item_owner(new_id, *project_id).await?; - s.db.repoint_env_var_item(*project_id, id, new_id).await?; + let encrypted = encrypt_item(key, ©)?; + let new_id = db.upsert_item(0, &item_type, &encrypted, &created, false).await?; + db.add_item_owner(new_id, *project_id).await?; + db.repoint_env_var_item(*project_id, id, new_id).await?; copy.id = new_id; forked.push(copy); } - s.db.delete_item(id).await?; + db.delete_item(id).await?; Ok(GlobalToggleResult { updated: None, forked }) } +#[tauri::command] +pub async fn vault_set_item_global( + id: i64, + global: bool, + state: State<'_, SharedState>, +) -> Result { + let mut s = state.lock().await; + let key = s.key.as_ref().ok_or("vault is locked")?.clone(); + s.touch(); + set_item_global(&s.db, &key, id, global).await +} + /// Which projects currently reference (own) this item — used for "used by N /// projects" warnings before a destructive delete-everywhere action. #[derive(Serialize)] @@ -1147,3 +1161,261 @@ pub async fn biometric_disable(state: State<'_, SharedState>) -> Result<(), Stri s.db.set_setting("biometric_blob", "").await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::DbEnvironmentVar; + + async fn test_db() -> (tempfile::TempDir, VaultDb, [u8; 32]) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let db = VaultDb::open(path.to_str().unwrap()).await.unwrap(); + let (salt, token, key) = crypto::init_vault_crypto(b"test-master-password-1").unwrap(); + db.init_vault(&salt, &token).await.unwrap(); + (dir, db, key) + } + + fn plain_secret(name: &str, value: &str, is_global: bool) -> VaultItem { + VaultItem { + id: 0, + item_type: "secret".to_string(), + name: Some(name.to_string()), + value: Some(value.to_string()), + url: None, + username: None, + password: None, + title: None, + description: None, + command: None, + shell: None, + categories: None, + notes: None, + content: None, + created: "0".to_string(), + is_global: Some(is_global), + } + } + + async fn literal_var(db: &VaultDb, project_name: &str, key_name: &str, literal: &str) -> (i64, i64) { + let project_id = db.upsert_project(0, project_name, None, "generic").await.unwrap(); + let env_id = db.upsert_environment(0, project_id, "production", true).await.unwrap(); + db.set_environment_vars( + env_id, + &[DbEnvironmentVar { + id: 0, + environment_id: env_id, + key: key_name.to_string(), + item_id: None, + literal: Some(literal.to_string()), + }], + ) + .await + .unwrap(); + (project_id, env_id) + } + + // ─── migrate_literal_vars_to_items ──────────────────────────────────── + + #[tokio::test] + async fn migrate_converts_literal_var_into_real_item() { + let (_dir, db, key) = test_db().await; + let (project_id, env_id) = literal_var(&db, "proj-a", "LITERAL_KEY", "literal-secret").await; + + migrate_literal_vars_to_items(&db, &key).await.unwrap(); + + let vars = db.get_environment_vars(env_id).await.unwrap(); + assert_eq!(vars.len(), 1); + assert!(vars[0].literal.is_none(), "literal must be cleared after migration"); + let item_id = vars[0].item_id.expect("item_id must be set after migration"); + + let owners = db.list_owning_projects(item_id).await.unwrap(); + assert_eq!(owners, vec![project_id]); + + let raw = db.list_items().await.unwrap(); + let (_, _, data, _, is_global) = raw.into_iter().find(|(id, ..)| *id == item_id).unwrap(); + let item = decrypt_item(&key, item_id, &data, is_global).unwrap(); + assert_eq!(item.name.as_deref(), Some("LITERAL_KEY")); + assert_eq!(item.value.as_deref(), Some("literal-secret")); + assert_eq!(item.is_global, Some(false)); + } + + #[tokio::test] + async fn migrate_gives_each_project_its_own_owned_item() { + let (_dir, db, key) = test_db().await; + let (project_a, _) = literal_var(&db, "proj-a", "KEY_A", "secret-a").await; + let (project_b, _) = literal_var(&db, "proj-b", "KEY_B", "secret-b").await; + + migrate_literal_vars_to_items(&db, &key).await.unwrap(); + + let raw = db.list_items().await.unwrap(); + assert_eq!(raw.len(), 2, "one item per migrated literal var"); + for (id, ..) in &raw { + let owners = db.list_owning_projects(*id).await.unwrap(); + assert!(owners == vec![project_a] || owners == vec![project_b]); + } + } + + #[tokio::test] + async fn migrate_is_a_noop_once_already_migrated() { + let (_dir, db, key) = test_db().await; + db.set_setting("migrated_literals_v1", "true").await.unwrap(); + let (_project_id, env_id) = literal_var(&db, "proj-a", "LITERAL_KEY", "literal-secret").await; + + migrate_literal_vars_to_items(&db, &key).await.unwrap(); + + let vars = db.get_environment_vars(env_id).await.unwrap(); + assert_eq!(vars.len(), 1); + assert!(vars[0].item_id.is_none(), "already-migrated gate must skip the rewrite"); + assert_eq!(vars[0].literal.as_deref(), Some("literal-secret")); + assert!(db.list_items().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn migrate_running_twice_is_idempotent() { + let (_dir, db, key) = test_db().await; + literal_var(&db, "proj-a", "LITERAL_KEY", "literal-secret").await; + + migrate_literal_vars_to_items(&db, &key).await.unwrap(); + let first_count = db.list_items().await.unwrap().len(); + + migrate_literal_vars_to_items(&db, &key).await.unwrap(); + let second_count = db.list_items().await.unwrap().len(); + + assert_eq!(first_count, 1); + assert_eq!(second_count, 1, "second call must not create duplicate items"); + } + + // ─── create_project_item ────────────────────────────────────────────── + + #[tokio::test] + async fn create_project_item_grants_ownership() { + let (_dir, db, key) = test_db().await; + let project_id = db.upsert_project(0, "proj", None, "generic").await.unwrap(); + let item = plain_secret("NEW_ITEM", "value-1", false); + + let new_id = create_project_item(&db, &key, &item, project_id).await.unwrap(); + + assert_eq!(db.list_owning_projects(new_id).await.unwrap(), vec![project_id]); + } + + #[tokio::test] + async fn create_project_item_is_never_global_regardless_of_input() { + let (_dir, db, key) = test_db().await; + let project_id = db.upsert_project(0, "proj", None, "generic").await.unwrap(); + let mut item = plain_secret("NEW_ITEM", "value-1", false); + item.is_global = Some(true); // caller tries to sneak in isGlobal=true + + let new_id = create_project_item(&db, &key, &item, project_id).await.unwrap(); + + let raw = db.list_items().await.unwrap(); + let (_, _, _, _, is_global) = raw.into_iter().find(|(id, ..)| *id == new_id).unwrap(); + assert!(!is_global, "create_project_item must force is_global=false in the DB column"); + } + + #[tokio::test] + async fn create_project_item_twice_yields_two_owned_items() { + let (_dir, db, key) = test_db().await; + let project_id = db.upsert_project(0, "proj", None, "generic").await.unwrap(); + let item_a = plain_secret("ITEM_A", "value-a", false); + let item_b = plain_secret("ITEM_B", "value-b", false); + + let id_a = create_project_item(&db, &key, &item_a, project_id).await.unwrap(); + let id_b = create_project_item(&db, &key, &item_b, project_id).await.unwrap(); + + assert_ne!(id_a, id_b); + let owned = db.list_owned_item_ids(project_id).await.unwrap(); + assert!(owned.contains(&id_a) && owned.contains(&id_b)); + } + + // ─── set_item_global (fork logic) ───────────────────────────────────── + + #[tokio::test] + async fn set_item_global_true_updates_in_place() { + let (_dir, db, key) = test_db().await; + let project_id = db.upsert_project(0, "proj", None, "generic").await.unwrap(); + let item = plain_secret("ITEM", "value", false); + let id = create_project_item(&db, &key, &item, project_id).await.unwrap(); + + let result = set_item_global(&db, &key, id, true).await.unwrap(); + + assert!(result.forked.is_empty()); + let updated = result.updated.expect("updated item present"); + assert_eq!(updated.is_global, Some(true)); + } + + #[tokio::test] + async fn set_item_global_false_with_single_owner_updates_in_place() { + let (_dir, db, key) = test_db().await; + let project_id = db.upsert_project(0, "proj", None, "generic").await.unwrap(); + let mut item = plain_secret("ITEM", "value", true); + item.is_global = Some(true); + let encrypted = encrypt_item(&key, &item).unwrap(); + let id = db.upsert_item(0, &item.item_type, &encrypted, &item.created, true).await.unwrap(); + db.add_item_owner(id, project_id).await.unwrap(); + + let result = set_item_global(&db, &key, id, false).await.unwrap(); + + assert!(result.forked.is_empty(), "single owner must not fork"); + assert_eq!(result.updated.unwrap().is_global, Some(false)); + } + + #[tokio::test] + async fn set_item_global_false_with_multiple_owners_forks_per_owner() { + let (_dir, db, key) = test_db().await; + let project_a = db.upsert_project(0, "proj-a", None, "generic").await.unwrap(); + let project_b = db.upsert_project(0, "proj-b", None, "generic").await.unwrap(); + let env_a = db.upsert_environment(0, project_a, "production", true).await.unwrap(); + let env_b = db.upsert_environment(0, project_b, "production", true).await.unwrap(); + + let mut item = plain_secret("SHARED", "shared-value", true); + item.is_global = Some(true); + let encrypted = encrypt_item(&key, &item).unwrap(); + let original_id = db.upsert_item(0, &item.item_type, &encrypted, &item.created, true).await.unwrap(); + db.add_item_owner(original_id, project_a).await.unwrap(); + db.add_item_owner(original_id, project_b).await.unwrap(); + db.upsert_environment_var(env_a, "SHARED", original_id).await.unwrap(); + db.upsert_environment_var(env_b, "SHARED", original_id).await.unwrap(); + + let result = set_item_global(&db, &key, original_id, false).await.unwrap(); + + assert!(result.updated.is_none()); + assert_eq!(result.forked.len(), 2, "one fork per owning project"); + for copy in &result.forked { + assert_eq!(copy.is_global, Some(false)); + assert_eq!(copy.name.as_deref(), Some("SHARED")); + assert_eq!(copy.value.as_deref(), Some("shared-value")); + } + + // Each project's environment var must now point at ITS OWN fork, not + // the deleted original nor the other project's fork. + let vars_a = db.get_environment_vars(env_a).await.unwrap(); + let vars_b = db.get_environment_vars(env_b).await.unwrap(); + let forked_ids: Vec = result.forked.iter().map(|c| c.id).collect(); + assert!(forked_ids.contains(&vars_a[0].item_id.unwrap())); + assert!(forked_ids.contains(&vars_b[0].item_id.unwrap())); + assert_ne!(vars_a[0].item_id, vars_b[0].item_id); + } + + #[tokio::test] + async fn set_item_global_false_multi_owner_deletes_the_original() { + let (_dir, db, key) = test_db().await; + let project_a = db.upsert_project(0, "proj-a", None, "generic").await.unwrap(); + let project_b = db.upsert_project(0, "proj-b", None, "generic").await.unwrap(); + + let mut item = plain_secret("SHARED", "shared-value", true); + item.is_global = Some(true); + let encrypted = encrypt_item(&key, &item).unwrap(); + let original_id = db.upsert_item(0, &item.item_type, &encrypted, &item.created, true).await.unwrap(); + db.add_item_owner(original_id, project_a).await.unwrap(); + db.add_item_owner(original_id, project_b).await.unwrap(); + + set_item_global(&db, &key, original_id, false).await.unwrap(); + + let raw = db.list_items().await.unwrap(); + assert!( + raw.iter().find(|(id, ..)| *id == original_id).is_none(), + "original shared row must be deleted after forking" + ); + } +} diff --git a/src-tauri/tests/crypt-env-api.postman_collection.json b/src-tauri/tests/crypt-env-api.postman_collection.json deleted file mode 100644 index d6eb59b..0000000 --- a/src-tauri/tests/crypt-env-api.postman_collection.json +++ /dev/null @@ -1,2257 +0,0 @@ -{ - "info": { - "name": "CryptEnv API", - "description": "Comprehensive test collection for the CryptEnv encrypted secrets manager local REST API. Runs at https://127.0.0.1:47821.", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "variable": [ - { - "key": "baseUrl", - "value": "https://127.0.0.1:47821", - "type": "string" - }, - { - "key": "token", - "value": "", - "type": "string", - "description": "Session token set automatically by POST /unlock" - }, - { - "key": "itemId", - "value": "", - "type": "string", - "description": "Created item ID, set by POST /items" - }, - { - "key": "categoryId", - "value": "", - "type": "string", - "description": "Created category ID, set by POST /categories" - }, - { - "key": "workspaceId", - "value": "", - "type": "string", - "description": "Created workspace ID, set by POST /workspaces" - } - ], - "item": [ - { - "name": "Auth", - "description": "Authentication endpoints — unlock the vault and obtain a session token.", - "item": [ - { - "name": "POST /unlock — Happy Path", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" } - ], - "url": { - "raw": "{{baseUrl}}/unlock", - "host": ["{{baseUrl}}"], - "path": ["unlock"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"password\": \"master_password_here\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Derives AES-GCM key from master password + Argon2 salt, generates 16-byte session token with configurable TTL." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response has token field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('token');", - " pm.expect(json.token).to.be.a('string').and.not.empty;", - "});", - "", - "pm.test('Response has ttl field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('ttl');", - "});", - "", - "// Save token for subsequent requests", - "var json = pm.response.json();", - "if (json && json.token) {", - " pm.collectionVariables.set('token', json.token);", - " console.log('Token saved:', json.token.substring(0, 8) + '...');", - "}" - ] - } - } - ] - }, - { - "name": "POST /unlock — Wrong Password (401)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" } - ], - "url": { - "raw": "{{baseUrl}}/unlock", - "host": ["{{baseUrl}}"], - "path": ["unlock"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"password\": \"wrong_password_that_will_fail\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Should return 401 when wrong password is provided." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 for wrong password', function () {", - " pm.response.to.have.status(401);", - "});", - "", - "pm.test('Error message is present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('error');", - "});" - ] - } - } - ] - }, - { - "name": "POST /unlock — Rate Limit (429)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" } - ], - "url": { - "raw": "{{baseUrl}}/unlock", - "host": ["{{baseUrl}}"], - "path": ["unlock"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"password\": \"attempt_to_trigger_rate_limit\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Rate limiting: 5 attempts per 60-second window. This is a documentation request — run manually after 5 failed attempts." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Rate limit returns 429 (run after 5 failed attempts)', function () {", - " // This test is expected to pass only after 5+ failed unlock attempts in 60 seconds", - " // In normal flow, this request may return 401 (wrong password)", - " var status = pm.response.code;", - " pm.expect([401, 429]).to.include(status);", - "});", - "", - "pm.test('Response has error field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('error');", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "Health", - "description": "Health-check endpoint — no authentication required.", - "item": [ - { - "name": "GET /health — Happy Path", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/health", - "host": ["{{baseUrl}}"], - "path": ["health"] - }, - "description": "Returns version, vault_locked bool, item_count, mcp_token_configured. No auth required." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response has version field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('version');", - " pm.expect(json.version).to.be.a('string');", - "});", - "", - "pm.test('Response has vault_locked field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('vault_locked');", - " pm.expect(json.vault_locked).to.be.a('boolean');", - "});", - "", - "pm.test('Response has item_count field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('item_count');", - " pm.expect(json.item_count).to.be.a('number');", - "});", - "", - "pm.test('Response has mcp_token_configured field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('mcp_token_configured');", - " pm.expect(json.mcp_token_configured).to.be.a('boolean');", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "Items", - "description": "CRUD operations for vault items. Items can be of type: secret, credential, link, note, command.", - "item": [ - { - "name": "GET /items — List All", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items", - "host": ["{{baseUrl}}"], - "path": ["items"] - }, - "description": "List all items (redacted — values not exposed)." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response is an array', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.be.an('array');", - "});", - "", - "pm.test('Each item has required fields (if any exist)', function () {", - " var json = pm.response.json();", - " if (json.length > 0) {", - " var item = json[0];", - " pm.expect(item).to.have.property('id');", - " pm.expect(item).to.have.property('name');", - " pm.expect(item).to.have.property('type');", - " // Secret values must NOT be exposed in list response", - " pm.expect(item).to.not.have.property('value');", - " }", - "});" - ] - } - } - ] - }, - { - "name": "GET /items — Filter by Type", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items?type=secret", - "host": ["{{baseUrl}}"], - "path": ["items"], - "query": [ - { "key": "type", "value": "secret" } - ] - }, - "description": "Filter items by type query param." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response is an array', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.be.an('array');", - "});", - "", - "pm.test('All returned items match the requested type', function () {", - " var json = pm.response.json();", - " json.forEach(function (item) {", - " pm.expect(item.type).to.equal('secret');", - " });", - "});" - ] - } - } - ] - }, - { - "name": "GET /items — Filter by Category", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items?category={{categoryId}}", - "host": ["{{baseUrl}}"], - "path": ["items"], - "query": [ - { "key": "category", "value": "{{categoryId}}" } - ] - }, - "description": "Filter items by category ID." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response is an array', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.be.an('array');", - "});" - ] - } - } - ] - }, - { - "name": "GET /items — Search", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items?search=test", - "host": ["{{baseUrl}}"], - "path": ["items"], - "query": [ - { "key": "search", "value": "test" } - ] - }, - "description": "Search items by name or metadata." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response is an array', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.be.an('array');", - "});", - "", - "pm.test('Search results contain search term in name (if any returned)', function () {", - " var json = pm.response.json();", - " if (json.length > 0) {", - " var hasMatch = json.some(function (item) {", - " return item.name && item.name.toLowerCase().includes('test');", - " });", - " pm.expect(hasMatch).to.be.true;", - " }", - "});" - ] - } - } - ] - }, - { - "name": "GET /items — Unauthorized (401)", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/items", - "host": ["{{baseUrl}}"], - "path": ["items"] - }, - "description": "Authenticated endpoint called without token — must return 401." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 when no token provided', function () {", - " pm.response.to.have.status(401);", - "});", - "", - "pm.test('Error field present in response', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('error');", - "});" - ] - } - } - ] - }, - { - "name": "POST /items — Create Secret", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items", - "host": ["{{baseUrl}}"], - "path": ["items"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Test API Key\",\n \"type\": \"secret\",\n \"value\": \"super_secret_api_key_12345\",\n \"notes\": \"Created by Postman test\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Create a new vault item. Saves the created item ID to the itemId collection variable." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 201', function () {", - " pm.response.to.have.status(201);", - "});", - "", - "pm.test('Response has id field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('id');", - "});", - "", - "pm.test('Response has name field matching request', function () {", - " var json = pm.response.json();", - " pm.expect(json.name).to.equal('Test API Key');", - "});", - "", - "pm.test('Response has type field matching request', function () {", - " var json = pm.response.json();", - " pm.expect(json.type).to.equal('secret');", - "});", - "", - "pm.test('Response does NOT expose plaintext value', function () {", - " var json = pm.response.json();", - " if (json.value) {", - " pm.expect(json.value).to.not.equal('super_secret_api_key_12345');", - " }", - "});", - "", - "// Save item ID for subsequent requests", - "var json = pm.response.json();", - "if (json && json.id) {", - " pm.collectionVariables.set('itemId', json.id);", - " console.log('Item ID saved:', json.id);", - "}" - ] - } - } - ] - }, - { - "name": "POST /items — Validation: Missing Name (422)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items", - "host": ["{{baseUrl}}"], - "path": ["items"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"type\": \"secret\",\n \"value\": \"some_value\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Validation: name is required, max 255 chars. Missing name should return 422." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 422 for missing name', function () {", - " pm.response.to.have.status(422);", - "});", - "", - "pm.test('Error response mentions name field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('error');", - "});" - ] - } - } - ] - }, - { - "name": "POST /items — Validation: Invalid Type (422)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items", - "host": ["{{baseUrl}}"], - "path": ["items"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Test Item\",\n \"type\": \"invalid_type_xyz\",\n \"value\": \"some_value\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Validation: type must be one of: secret, credential, link, note, command. Invalid type should return 422." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 422 for invalid type', function () {", - " pm.response.to.have.status(422);", - "});", - "", - "pm.test('Error response is present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('error');", - "});" - ] - } - } - ] - }, - { - "name": "POST /items — Validation: Empty Value (422)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items", - "host": ["{{baseUrl}}"], - "path": ["items"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Test Item\",\n \"type\": \"secret\",\n \"value\": \"\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Validation: value must be non-empty. Empty value should return 422." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 422 for empty value', function () {", - " pm.response.to.have.status(422);", - "});", - "", - "pm.test('Error response is present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('error');", - "});" - ] - } - } - ] - }, - { - "name": "GET /items/:id — Get Item", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items/{{itemId}}", - "host": ["{{baseUrl}}"], - "path": ["items", "{{itemId}}"] - }, - "description": "Get single item metadata (redacted). Uses itemId set by POST /items." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response has id field matching requested id', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('id');", - " pm.expect(String(json.id)).to.equal(String(pm.collectionVariables.get('itemId')));", - "});", - "", - "pm.test('Response has name field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('name');", - "});", - "", - "pm.test('Response has type field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('type');", - "});", - "", - "pm.test('Secret value is redacted', function () {", - " var json = pm.response.json();", - " if (json.value) {", - " pm.expect(json.value).to.not.equal('super_secret_api_key_12345');", - " }", - "});" - ] - } - } - ] - }, - { - "name": "GET /items/:id — Not Found (404)", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items/999999", - "host": ["{{baseUrl}}"], - "path": ["items", "999999"] - }, - "description": "Request a non-existent item ID." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 404 for non-existent item', function () {", - " pm.response.to.have.status(404);", - "});", - "", - "pm.test('Error field is present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('error');", - "});" - ] - } - } - ] - }, - { - "name": "PUT /items/:id — Update Item", - "request": { - "method": "PUT", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items/{{itemId}}", - "host": ["{{baseUrl}}"], - "path": ["items", "{{itemId}}"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Updated API Key\",\n \"notes\": \"Updated by Postman test\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Update item. Merges — omitted fields keep existing values." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Updated name is reflected in response', function () {", - " var json = pm.response.json();", - " pm.expect(json.name).to.equal('Updated API Key');", - "});", - "", - "pm.test('Unchanged type field is preserved (merge behavior)', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('type');", - " pm.expect(json.type).to.equal('secret');", - "});" - ] - } - } - ] - }, - { - "name": "PUT /items/:id — Unauthorized (401)", - "request": { - "method": "PUT", - "header": [ - { "key": "Content-Type", "value": "application/json" } - ], - "url": { - "raw": "{{baseUrl}}/items/{{itemId}}", - "host": ["{{baseUrl}}"], - "path": ["items", "{{itemId}}"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Should Fail\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Update without token — must return 401." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 when no token provided', function () {", - " pm.response.to.have.status(401);", - "});" - ] - } - } - ] - }, - { - "name": "POST /items/:id/reveal — Reveal Secret", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items/{{itemId}}/reveal", - "host": ["{{baseUrl}}"], - "path": ["items", "{{itemId}}", "reveal"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"confirm\": true\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Returns plaintext secret value. Requires confirm: true in body." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response has value field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('value');", - " pm.expect(json.value).to.be.a('string').and.not.empty;", - "});", - "", - "pm.test('Revealed value matches what was stored', function () {", - " var json = pm.response.json();", - " pm.expect(json.value).to.equal('super_secret_api_key_12345');", - "});" - ] - } - } - ] - }, - { - "name": "POST /items/:id/reveal — Without Confirm (4xx)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items/{{itemId}}/reveal", - "host": ["{{baseUrl}}"], - "path": ["items", "{{itemId}}", "reveal"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"confirm\": false\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Reveal without confirm: true should return a 4xx error." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 4xx when confirm is false', function () {", - " pm.expect(pm.response.code).to.be.within(400, 499);", - "});", - "", - "pm.test('Error field is present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('error');", - "});" - ] - } - } - ] - }, - { - "name": "POST /items/:id/reveal — Missing Body (4xx)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items/{{itemId}}/reveal", - "host": ["{{baseUrl}}"], - "path": ["items", "{{itemId}}", "reveal"] - }, - "body": { - "mode": "raw", - "raw": "{}", - "options": { "raw": { "language": "json" } } - }, - "description": "Reveal with empty body (no confirm field) should return 4xx." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 4xx when confirm field is absent', function () {", - " pm.expect(pm.response.code).to.be.within(400, 499);", - "});" - ] - } - } - ] - }, - { - "name": "DELETE /items/:id — Delete Item", - "request": { - "method": "DELETE", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/items/{{itemId}}", - "host": ["{{baseUrl}}"], - "path": ["items", "{{itemId}}"] - }, - "description": "Delete item by ID. Returns 204 No Content." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 204', function () {", - " pm.response.to.have.status(204);", - "});", - "", - "pm.test('Response body is empty', function () {", - " pm.expect(pm.response.text()).to.be.empty;", - "});" - ] - } - } - ] - }, - { - "name": "DELETE /items/:id — Unauthorized (401)", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}/items/{{itemId}}", - "host": ["{{baseUrl}}"], - "path": ["items", "{{itemId}}"] - }, - "description": "Delete without token — must return 401." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 when no token provided', function () {", - " pm.response.to.have.status(401);", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "Categories", - "description": "CRUD operations for vault categories.", - "item": [ - { - "name": "GET /categories — List All", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/categories", - "host": ["{{baseUrl}}"], - "path": ["categories"] - }, - "description": "List all categories (id, name, color, description)." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response is an array', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.be.an('array');", - "});", - "", - "pm.test('Each category has required fields (if any exist)', function () {", - " var json = pm.response.json();", - " if (json.length > 0) {", - " var cat = json[0];", - " pm.expect(cat).to.have.property('id');", - " pm.expect(cat).to.have.property('name');", - " pm.expect(cat).to.have.property('color');", - " }", - "});" - ] - } - } - ] - }, - { - "name": "GET /categories — Unauthorized (401)", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/categories", - "host": ["{{baseUrl}}"], - "path": ["categories"] - }, - "description": "List categories without token — must return 401." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 when no token provided', function () {", - " pm.response.to.have.status(401);", - "});" - ] - } - } - ] - }, - { - "name": "POST /categories — Create Category", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/categories", - "host": ["{{baseUrl}}"], - "path": ["categories"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Test Category\",\n \"color\": \"#FF5733\",\n \"description\": \"Created by Postman test\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Create a category. Saves the created category ID to the categoryId collection variable." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 201', function () {", - " pm.response.to.have.status(201);", - "});", - "", - "pm.test('Response has id field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('id');", - "});", - "", - "pm.test('Response has name matching request', function () {", - " var json = pm.response.json();", - " pm.expect(json.name).to.equal('Test Category');", - "});", - "", - "pm.test('Response has color matching request', function () {", - " var json = pm.response.json();", - " pm.expect(json.color).to.equal('#FF5733');", - "});", - "", - "// Save category ID for subsequent requests", - "var json = pm.response.json();", - "if (json && json.id) {", - " pm.collectionVariables.set('categoryId', json.id);", - " console.log('Category ID saved:', json.id);", - "}" - ] - } - } - ] - }, - { - "name": "POST /categories — Validation: Missing Name (422)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/categories", - "host": ["{{baseUrl}}"], - "path": ["categories"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"color\": \"#FF5733\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Validation: name is required, max 100 chars. Missing name should return 422." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 422 for missing name', function () {", - " pm.response.to.have.status(422);", - "});", - "", - "pm.test('Error response is present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('error');", - "});" - ] - } - } - ] - }, - { - "name": "POST /categories — Validation: Missing Color (422)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/categories", - "host": ["{{baseUrl}}"], - "path": ["categories"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Test Category No Color\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Validation: color is required. Missing color should return 422." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 422 for missing color', function () {", - " pm.response.to.have.status(422);", - "});", - "", - "pm.test('Error response is present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('error');", - "});" - ] - } - } - ] - }, - { - "name": "PUT /categories/:id — Update Category", - "request": { - "method": "PUT", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/categories/{{categoryId}}", - "host": ["{{baseUrl}}"], - "path": ["categories", "{{categoryId}}"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Updated Category\",\n \"color\": \"#3498DB\",\n \"description\": \"\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Update category. Passing description: '' clears it." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Updated name is reflected in response', function () {", - " var json = pm.response.json();", - " pm.expect(json.name).to.equal('Updated Category');", - "});", - "", - "pm.test('Updated color is reflected in response', function () {", - " var json = pm.response.json();", - " pm.expect(json.color).to.equal('#3498DB');", - "});", - "", - "pm.test('Empty description string clears the description', function () {", - " var json = pm.response.json();", - " if (json.hasOwnProperty('description')) {", - " pm.expect(json.description).to.satisfy(function(v) {", - " return v === '' || v === null || v === undefined;", - " });", - " }", - "});" - ] - } - } - ] - }, - { - "name": "DELETE /categories/:id — Delete Category", - "request": { - "method": "DELETE", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/categories/{{categoryId}}", - "host": ["{{baseUrl}}"], - "path": ["categories", "{{categoryId}}"] - }, - "description": "Delete category. Returns 204 No Content." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 204', function () {", - " pm.response.to.have.status(204);", - "});", - "", - "pm.test('Response body is empty', function () {", - " pm.expect(pm.response.text()).to.be.empty;", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "Commands", - "description": "Items of type 'command' with extracted {{VAR}} placeholders.", - "item": [ - { - "name": "GET /commands — List All", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/commands", - "host": ["{{baseUrl}}"], - "path": ["commands"] - }, - "description": "List items of type 'command' with extracted {{VAR}} placeholders." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response is an array', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.be.an('array');", - "});", - "", - "pm.test('Commands have placeholders field (if any exist)', function () {", - " var json = pm.response.json();", - " if (json.length > 0) {", - " pm.expect(json[0]).to.have.property('placeholders');", - " pm.expect(json[0].placeholders).to.be.an('array');", - " }", - "});" - ] - } - } - ] - }, - { - "name": "GET /commands — Unauthorized (401)", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/commands", - "host": ["{{baseUrl}}"], - "path": ["commands"] - }, - "description": "List commands without token — must return 401." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 when no token provided', function () {", - " pm.response.to.have.status(401);", - "});" - ] - } - } - ] - }, - { - "name": "GET /commands/:id — Get Command", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/commands/{{itemId}}", - "host": ["{{baseUrl}}"], - "path": ["commands", "{{itemId}}"] - }, - "description": "Get a single command with its placeholders. Uses itemId (assumes a command-type item was created)." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200 or 404', function () {", - " // 404 is valid if itemId is not a command type", - " pm.expect([200, 404]).to.include(pm.response.code);", - "});", - "", - "pm.test('If 200, response has placeholders field', function () {", - " if (pm.response.code === 200) {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('placeholders');", - " }", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "Settings", - "description": "Application settings: auto_lock_timeout and hotkey.", - "item": [ - { - "name": "GET /settings — Get Settings", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/settings", - "host": ["{{baseUrl}}"], - "path": ["settings"] - }, - "description": "Get auto_lock_timeout (minutes) and hotkey." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response has auto_lock_timeout field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('auto_lock_timeout');", - " pm.expect(json.auto_lock_timeout).to.be.a('number');", - "});", - "", - "pm.test('Response has hotkey field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('hotkey');", - "});" - ] - } - } - ] - }, - { - "name": "GET /settings — Unauthorized (401)", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/settings", - "host": ["{{baseUrl}}"], - "path": ["settings"] - }, - "description": "Get settings without token — must return 401." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 when no token provided', function () {", - " pm.response.to.have.status(401);", - "});" - ] - } - } - ] - }, - { - "name": "PUT /settings — Update Settings", - "request": { - "method": "PUT", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/settings", - "host": ["{{baseUrl}}"], - "path": ["settings"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"auto_lock_timeout\": 15,\n \"hotkey\": \"Ctrl+Shift+V\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Update auto_lock_timeout and/or hotkey." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Updated auto_lock_timeout is reflected', function () {", - " var json = pm.response.json();", - " pm.expect(json.auto_lock_timeout).to.equal(15);", - "});", - "", - "pm.test('Updated hotkey is reflected', function () {", - " var json = pm.response.json();", - " pm.expect(json.hotkey).to.equal('Ctrl+Shift+V');", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "Fill / Env", - "description": "Fill .env templates with vault values.", - "item": [ - { - "name": "POST /fill — Fill Inline (No Output Path)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/fill", - "host": ["{{baseUrl}}"], - "path": ["fill"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"template\": \"DATABASE_URL=postgres://user:{{DB_PASSWORD}}@localhost/mydb\\nAPI_KEY={{API_KEY}}\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Fill a .env template inline (no output_path). Returns filled content in response." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response has content field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('content');", - " pm.expect(json.content).to.be.a('string');", - "});" - ] - } - } - ] - }, - { - "name": "POST /fill — Fill to File (With Output Path)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/fill", - "host": ["{{baseUrl}}"], - "path": ["fill"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"template\": \"DATABASE_URL=postgres://user:{{DB_PASSWORD}}@localhost/mydb\",\n \"output_path\": \"/tmp/test_output.env\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Fill a .env template and write to disk. Returns stats (vars_filled, vars_missing, etc.)." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response has stats fields', function () {", - " var json = pm.response.json();", - " // At minimum the response should confirm the write occurred", - " pm.expect(json).to.be.an('object');", - "});" - ] - } - } - ] - }, - { - "name": "POST /fill — Unauthorized (401)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" } - ], - "url": { - "raw": "{{baseUrl}}/fill", - "host": ["{{baseUrl}}"], - "path": ["fill"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"template\": \"KEY={{VALUE}}\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Fill without token — must return 401." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 when no token provided', function () {", - " pm.response.to.have.status(401);", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "Share (LAN)", - "description": "LAN-based peer-to-peer sharing via ECDH key exchange.", - "item": [ - { - "name": "POST /share/listen — Start as Sender", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/share/listen", - "host": ["{{baseUrl}}"], - "path": ["share", "listen"] - }, - "body": { - "mode": "raw", - "raw": "{}", - "options": { "raw": { "language": "json" } } - }, - "description": "Start LAN share session as sender. Returns pairing_code." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response has pairing_code field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('pairing_code');", - " pm.expect(json.pairing_code).to.be.a('string').and.not.empty;", - "});" - ] - } - } - ] - }, - { - "name": "POST /share/connect — Connect as Receiver", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/share/connect", - "host": ["{{baseUrl}}"], - "path": ["share", "connect"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"pairing_code\": \"XXXX-XXXX\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Connect as receiver using pairing_code. Returns ECDH fingerprint." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200 or 404', function () {", - " // 404/400 if pairing_code is not active", - " pm.expect([200, 400, 404]).to.include(pm.response.code);", - "});", - "", - "pm.test('If 200, response has fingerprint field', function () {", - " if (pm.response.code === 200) {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('fingerprint');", - " }", - "});" - ] - } - } - ] - }, - { - "name": "POST /share/confirm — Confirm Fingerprint", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/share/confirm", - "host": ["{{baseUrl}}"], - "path": ["share", "confirm"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"confirmed\": true\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Confirm (or reject) the ECDH fingerprint." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200 or 409 (no active session)', function () {", - " pm.expect([200, 400, 409]).to.include(pm.response.code);", - "});" - ] - } - } - ] - }, - { - "name": "GET /share/status — Get Session Status", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/share/status", - "host": ["{{baseUrl}}"], - "path": ["share", "status"] - }, - "description": "Returns session state, fingerprint, direction, received_names." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response has state field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('state');", - "});" - ] - } - } - ] - }, - { - "name": "DELETE /share/session — Cancel Session", - "request": { - "method": "DELETE", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/share/session", - "host": ["{{baseUrl}}"], - "path": ["share", "session"] - }, - "description": "Cancel active share session." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200 or 204', function () {", - " pm.expect([200, 204]).to.include(pm.response.code);", - "});" - ] - } - } - ] - }, - { - "name": "POST /share/export — Export Items", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/share/export", - "host": ["{{baseUrl}}"], - "path": ["share", "export"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"item_ids\": [],\n \"output_path\": \"/tmp/export_test.vault\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Export items as AES-256-GCM encrypted .vault file. Returns passphrase." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response has passphrase field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('passphrase');", - " pm.expect(json.passphrase).to.be.a('string').and.not.empty;", - "});" - ] - } - } - ] - }, - { - "name": "POST /share/import — Import from File", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/share/import", - "host": ["{{baseUrl}}"], - "path": ["share", "import"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"file_path\": \"/tmp/export_test.vault\",\n \"passphrase\": \"replace_with_passphrase_from_export\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Import from .vault file using passphrase. Use passphrase returned by /share/export." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200 or 400', function () {", - " // 400 if passphrase is wrong / file not found", - " pm.expect([200, 400, 404]).to.include(pm.response.code);", - "});", - "", - "pm.test('If 200, response has imported_count field', function () {", - " if (pm.response.code === 200) {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('imported_count');", - " }", - "});" - ] - } - } - ] - }, - { - "name": "GET /share/status — Unauthorized (401)", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/share/status", - "host": ["{{baseUrl}}"], - "path": ["share", "status"] - }, - "description": "Share status without token — must return 401." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 when no token provided', function () {", - " pm.response.to.have.status(401);", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "Workspaces", - "description": "Workspace management — group .env var references to vault items.", - "item": [ - { - "name": "GET /workspaces — List All", - "request": { - "method": "GET", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/workspaces", - "host": ["{{baseUrl}}"], - "path": ["workspaces"] - }, - "description": "List workspaces with their vars." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response is an array', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.be.an('array');", - "});", - "", - "pm.test('Each workspace has required fields (if any exist)', function () {", - " var json = pm.response.json();", - " if (json.length > 0) {", - " var ws = json[0];", - " pm.expect(ws).to.have.property('id');", - " pm.expect(ws).to.have.property('name');", - " pm.expect(ws).to.have.property('vars');", - " pm.expect(ws.vars).to.be.an('array');", - " }", - "});" - ] - } - } - ] - }, - { - "name": "GET /workspaces — Unauthorized (401)", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/workspaces", - "host": ["{{baseUrl}}"], - "path": ["workspaces"] - }, - "description": "List workspaces without token — must return 401." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 when no token provided', function () {", - " pm.response.to.have.status(401);", - "});" - ] - } - } - ] - }, - { - "name": "POST /workspaces — Create Workspace", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/workspaces", - "host": ["{{baseUrl}}"], - "path": ["workspaces"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"id\": 0,\n \"name\": \"Test Workspace\",\n \"description\": \"Created by Postman test\",\n \"vars\": []\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Create or update workspace (upsert by id=0 = create). Saves workspace ID to collection variable." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200 or 201', function () {", - " pm.expect([200, 201]).to.include(pm.response.code);", - "});", - "", - "pm.test('Response has id field', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('id');", - "});", - "", - "pm.test('Response has name field matching request', function () {", - " var json = pm.response.json();", - " pm.expect(json.name).to.equal('Test Workspace');", - "});", - "", - "// Save workspace ID for subsequent requests", - "var json = pm.response.json();", - "if (json && json.id) {", - " pm.collectionVariables.set('workspaceId', json.id);", - " console.log('Workspace ID saved:', json.id);", - "}" - ] - } - } - ] - }, - { - "name": "POST /workspaces — Update Workspace", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/workspaces", - "host": ["{{baseUrl}}"], - "path": ["workspaces"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"id\": {{workspaceId}},\n \"name\": \"Updated Workspace\",\n \"description\": \"Updated by Postman test\",\n \"vars\": []\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Update existing workspace (upsert by providing existing id)." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Updated name is reflected in response', function () {", - " var json = pm.response.json();", - " pm.expect(json.name).to.equal('Updated Workspace');", - "});" - ] - } - } - ] - }, - { - "name": "POST /workspaces/:id/inject — Inject to .env Paths", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/workspaces/{{workspaceId}}/inject", - "host": ["{{baseUrl}}"], - "path": ["workspaces", "{{workspaceId}}", "inject"] - }, - "body": { - "mode": "raw", - "raw": "{}", - "options": { "raw": { "language": "json" } } - }, - "description": "Inject workspace vars into configured .env paths." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200 or 400', function () {", - " // 400 if workspace has no configured output paths", - " pm.expect([200, 400]).to.include(pm.response.code);", - "});", - "", - "pm.test('If 200, response has injection result', function () {", - " if (pm.response.code === 200) {", - " var json = pm.response.json();", - " pm.expect(json).to.be.an('object');", - " }", - "});" - ] - } - } - ] - }, - { - "name": "DELETE /workspaces/:id — Delete Workspace", - "request": { - "method": "DELETE", - "header": [ - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/workspaces/{{workspaceId}}", - "host": ["{{baseUrl}}"], - "path": ["workspaces", "{{workspaceId}}"] - }, - "description": "Delete workspace and its vars." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 204', function () {", - " pm.response.to.have.status(204);", - "});", - "", - "pm.test('Response body is empty', function () {", - " pm.expect(pm.response.text()).to.be.empty;", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "Relay", - "description": "Internet relay sharing via encrypted Supabase-hosted relay (burn-after-read, 24h TTL).", - "item": [ - { - "name": "POST /relay/send — Send via Relay", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/relay/send", - "host": ["{{baseUrl}}"], - "path": ["relay", "send"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"item_ids\": []\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Encrypt selected items and upload to Supabase relay. Returns XXXX-XXXX code + plaintext passphrase." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200 or 503 (relay not configured)', function () {", - " // 503 if Supabase relay is not configured in Settings", - " pm.expect([200, 400, 503]).to.include(pm.response.code);", - "});", - "", - "pm.test('If 200, response has code field', function () {", - " if (pm.response.code === 200) {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('code');", - " pm.expect(json.code).to.match(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/);", - " }", - "});", - "", - "pm.test('If 200, response has passphrase field', function () {", - " if (pm.response.code === 200) {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('passphrase');", - " pm.expect(json.passphrase).to.be.a('string').and.not.empty;", - " }", - "});" - ] - } - } - ] - }, - { - "name": "POST /relay/receive — Receive via Relay", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "X-Vault-Token", "value": "{{token}}" } - ], - "url": { - "raw": "{{baseUrl}}/relay/receive", - "host": ["{{baseUrl}}"], - "path": ["relay", "receive"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"code\": \"XXXX-YYYY\",\n \"passphrase\": \"replace_with_actual_passphrase\"\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Download from relay, decrypt, and import items. Burn-after-read." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 200, 400, or 404', function () {", - " // 400/404 if code is invalid or expired; 503 if relay not configured", - " pm.expect([200, 400, 404, 503]).to.include(pm.response.code);", - "});", - "", - "pm.test('If 200, response has imported_count field', function () {", - " if (pm.response.code === 200) {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('imported_count');", - " }", - "});" - ] - } - } - ] - }, - { - "name": "POST /relay/send — Unauthorized (401)", - "request": { - "method": "POST", - "header": [ - { "key": "Content-Type", "value": "application/json" } - ], - "url": { - "raw": "{{baseUrl}}/relay/send", - "host": ["{{baseUrl}}"], - "path": ["relay", "send"] - }, - "body": { - "mode": "raw", - "raw": "{\n \"item_ids\": []\n}", - "options": { "raw": { "language": "json" } } - }, - "description": "Relay send without token — must return 401." - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status code is 401 when no token provided', function () {", - " pm.response.to.have.status(401);", - "});" - ] - } - } - ] - } - ] - } - ] -} From bd78bd6d954dd4a385c720dafc61a75a64545fbb Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Tue, 4 Aug 2026 17:51:06 -0500 Subject: [PATCH 08/14] fix(db,project,api): fix non-ASCII dedup fold mismatch, report durability, sentinel visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses adversarial review of the issue-12 case-insensitive-uniqueness work: - Critical: next_free_{project,environment}_name folded the rename candidate with Rust's to_lowercase() (full Unicode) but compared it against SQLite's own LOWER(name) (ASCII-only). For a non-ASCII base this made the two folds disagree, so a real collision against a pre-existing suffixed sibling (e.g. producciÓn-2) was missed, the "free" candidate was accepted, and the following UPDATE then hit the table's exact-match UNIQUE constraint -- init_schema returned Err and the vault could not be opened at all. Fixed by comparing via LOWER(name) = LOWER(?) so both sides are folded by SQLite consistently. Added a regression test reproducing the exact crash shape. - env_name_dedup_v1 (the sole reversal path for an otherwise irreversible rename) is now persisted per-rename, inside the dedup loops themselves, instead of batched and written only after both CREATE UNIQUE INDEX statements succeed -- a renamed row can no longer land in the DB without also being recorded, even if a later migration step fails or the process is killed mid-run. - PROJECT_NAME_CONFLICT bumped from private to pub (matching ENVIRONMENT_NAME_CONFLICT, also bumped from pub(crate) to pub): each src/bin/* target is its own crate separate from crypt_env_lib, so pub(crate) was invisible cross-crate. Exported project::AMBIGUOUS_MATCH_PREFIX so the ambiguity sentinel is a named constant instead of a bare string literal at every match site. - persist_rename_report no longer silently discards prior rename history on a JSON parse failure -- logs to stderr and preserves the raw value verbatim under env_name_dedup_v1_corrupt before starting a fresh list. Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/api/mod.rs | 2 +- src-tauri/src/db/mod.rs | 104 ++++++++++++++++++++------ src-tauri/src/project/mod.rs | 28 ++++--- src-tauri/tests/environment_naming.rs | 41 +++++++++- 4 files changed, 142 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index e60d823..6a26532 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -249,7 +249,7 @@ async fn resolve_scope( project::resolve_environment(&vault.db, environment_id, project, environment) .await .map_err(|msg| { - if msg.starts_with("ambiguous match") { + if msg.starts_with(project::AMBIGUOUS_MATCH_PREFIX) { err_json(StatusCode::CONFLICT, &msg, "AMBIGUOUS_SCOPE").into_response() } else { err_validation("project/environment", &msg) diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 827831f..0e44342 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -99,14 +99,22 @@ pub struct VaultDb { /// `409 CONFLICT`. Never substring-match sqlx's own error text — it's not a /// stable API and would leak SQL identifiers to the HTTP client on any /// mismatch. -const PROJECT_NAME_CONFLICT: &str = "conflict: a project with this name already exists"; +/// +/// `pub` (not `pub(crate)`): every `[[bin]]` target in this package (the +/// `crypt-env` CLI, the `crypt-env-mcp` server) is its own crate, separate +/// from this `crypt_env_lib` library crate, even though they share one +/// Cargo.toml — `pub(crate)` items here are invisible to code in `src/bin/`. +/// Any consumer of this conflict-detection contract (e.g. issue #4) needs +/// `pub` to reach it with `crypt_env_lib::db::PROJECT_NAME_CONFLICT`. +pub const PROJECT_NAME_CONFLICT: &str = "conflict: a project with this name already exists"; /// Same sentinel contract as `PROJECT_NAME_CONFLICT`, for -/// `idx_environments_name_nocase`. `pub(crate)` (not private) because -/// `project::ensure_no_case_collision`'s app-level (Unicode-aware) pre-check -/// returns this exact same string on its own — reusing the constant instead -/// of a second string literal keeps the two layers from drifting apart. -pub(crate) const ENVIRONMENT_NAME_CONFLICT: &str = +/// `idx_environments_name_nocase`. `pub` for the same cross-crate reason, and +/// also because `project::ensure_no_case_collision`'s app-level +/// (Unicode-aware) pre-check returns this exact same string on its own — +/// reusing the constant instead of a second string literal keeps the two +/// layers from drifting apart. +pub const ENVIRONMENT_NAME_CONFLICT: &str = "conflict: an environment with this name already exists in this project"; /// Detects a unique-constraint violation via `sqlx::Error::Database(_). @@ -316,7 +324,17 @@ impl VaultDb { // Never `let _ = ...`: if the index can't be created for some other // reason, `init_schema` must fail loudly rather than leave the vault // unprotected with no signal. - let mut name_dedup_renames = self.dedupe_project_names_nocase().await?; + // + // Each dedup pass persists its own `RenameRecord`s to + // `env_name_dedup_v1` *as it renames each row* (inside + // `dedupe_project_names_nocase`/`dedupe_environment_names_nocase` + // themselves), not batched up and written here after both indexes + // succeed — the report is the sole reversal path for an otherwise + // irreversible rename, so a rename must never be able to happen + // without also being recorded, even if a later step in this function + // (e.g. the second `CREATE UNIQUE INDEX`) fails or the process is + // killed mid-migration. + self.dedupe_project_names_nocase().await?; sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_name_nocase ON projects(name COLLATE NOCASE)") .execute(&self.pool) .await @@ -327,7 +345,7 @@ impl VaultDb { // introduce a `default` row into a project that already has a // differently-cased `Default` — this block runs after the whole // `migrations` loop, so that case is caught too. - name_dedup_renames.extend(self.dedupe_environment_names_nocase().await?); + self.dedupe_environment_names_nocase().await?; sqlx::query( "CREATE UNIQUE INDEX IF NOT EXISTS idx_environments_name_nocase ON environments(project_id, name COLLATE NOCASE)", ) @@ -335,8 +353,6 @@ impl VaultDb { .await .map_err(|e| format!("migration: could not enforce unique environment names (idx_environments_name_nocase): {e}"))?; - self.persist_rename_report(name_dedup_renames).await?; - // One-time (not re-run every launch): pre-existing items that end up with // zero owners after the backfill above predate the whole project/ownership // model — promote them to global so they surface in Global Secrets instead @@ -402,14 +418,20 @@ impl VaultDb { .await .map_err(|e| format!("dedupe project names: {e}"))?; - renames.push(RenameRecord { + let record = RenameRecord { table: "projects".to_string(), id, project_id: None, from: original_name, to: new_name, at: now_iso8601(), - }); + }; + // Persisted immediately, per rename — not batched up and + // written once at the end of `init_schema` — so a rename can + // never land in the DB without also being recorded in the + // one report that makes it (by hand) reversible. + self.persist_rename_report(vec![record.clone()]).await?; + renames.push(record); } } Ok(renames) @@ -418,15 +440,27 @@ impl VaultDb { /// Finds the first `-` (n starting at 2) that doesn't /// case-insensitively collide with any other project name, excluding /// `exclude_id` (the row being renamed itself). + /// + /// Compares via `LOWER(name) = LOWER(?)` — both sides folded by SQLite's + /// own (ASCII-only) `LOWER()` — rather than folding the candidate in Rust + /// with `to_lowercase()` (full Unicode) and binding that. Folding in Rust + /// would, for a non-ASCII `base` (e.g. `producciÓn`, itself already + /// SQLite-folded and so still carrying an unfolded `Ó`), fold *further* + /// than SQLite's `LOWER(name)` ever would on the stored side — so a real + /// collision (e.g. against a pre-existing `producciÓn-2`) would compare + /// unequal and be missed, the candidate would be accepted as "free", and + /// the following `UPDATE` would then trip the table's exact-match + /// `UNIQUE(project_id, name)` (or here, `UNIQUE` on `name`) constraint — + /// turning a routine dedup into a hard `init_schema` failure. See the + /// non-ASCII regression test for the reproduction. async fn next_free_project_name(&self, base: &str, exclude_id: i64) -> Result { let mut suffix = 2i64; loop { let candidate = format!("{base}-{suffix}"); - let candidate_lower = candidate.to_lowercase(); let exists: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM projects WHERE LOWER(name) = ?1 AND id != ?2", + "SELECT COUNT(*) FROM projects WHERE LOWER(name) = LOWER(?1) AND id != ?2", ) - .bind(&candidate_lower) + .bind(&candidate) .bind(exclude_id) .fetch_one(&self.pool) .await @@ -490,14 +524,18 @@ impl VaultDb { .await .map_err(|e| format!("dedupe environment names: {e}"))?; - renames.push(RenameRecord { + let record = RenameRecord { table: "environments".to_string(), id, project_id: Some(project_id), from: original_name, to: new_name, at: now_iso8601(), - }); + }; + // Persisted immediately, per rename — see the matching + // comment in `dedupe_project_names_nocase`. + self.persist_rename_report(vec![record.clone()]).await?; + renames.push(record); } } Ok(renames) @@ -506,6 +544,13 @@ impl VaultDb { /// Finds the first `-` (n starting at 2) that doesn't /// case-insensitively collide with any other environment in `project_id`, /// excluding `exclude_id` (the row being renamed itself). + /// + /// See `next_free_project_name` for why this compares via + /// `LOWER(name) = LOWER(?)` (both sides folded by SQLite, ASCII-only) + /// instead of pre-folding the candidate in Rust with `to_lowercase()` + /// (full Unicode) — the mismatch between the two folds is exactly what + /// let a non-ASCII collision slip past this check and then trip the + /// dedup `UPDATE` on the table's exact-match unique constraint. async fn next_free_environment_name( &self, project_id: i64, @@ -515,12 +560,11 @@ impl VaultDb { let mut suffix = 2i64; loop { let candidate = format!("{base}-{suffix}"); - let candidate_lower = candidate.to_lowercase(); let exists: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM environments WHERE project_id = ?1 AND LOWER(name) = ?2 AND id != ?3", + "SELECT COUNT(*) FROM environments WHERE project_id = ?1 AND LOWER(name) = LOWER(?2) AND id != ?3", ) .bind(project_id) - .bind(&candidate_lower) + .bind(&candidate) .bind(exclude_id) .fetch_one(&self.pool) .await @@ -545,7 +589,25 @@ impl VaultDb { } let existing = self.get_setting("env_name_dedup_v1").await?; let mut all: Vec = match existing { - Some(json) => serde_json::from_str(&json).unwrap_or_default(), + Some(json) => match serde_json::from_str(&json) { + Ok(parsed) => parsed, + Err(e) => { + // Never silently drop prior history on a parse failure + // (schema drift, a partial/corrupt write) — that history + // is the sole reversal path for an otherwise irreversible + // rename. Log it loudly and preserve the raw value + // verbatim under a side key before starting a fresh list, + // so nothing is lost even though it can't be merged + // structurally. + eprintln!( + "env_name_dedup_v1: existing report failed to parse ({e}) — \ + preserving it verbatim under env_name_dedup_v1_corrupt \ + and starting a fresh report" + ); + self.set_setting("env_name_dedup_v1_corrupt", &json).await?; + Vec::new() + } + }, None => Vec::new(), }; all.append(&mut renames); diff --git a/src-tauri/src/project/mod.rs b/src-tauri/src/project/mod.rs index 781d49a..42b848f 100644 --- a/src-tauri/src/project/mod.rs +++ b/src-tauri/src/project/mod.rs @@ -241,6 +241,13 @@ pub async fn delete_environment(db: &VaultDb, id: i64) -> Result<(), String> { db.delete_environment(id).await } +/// Stable prefix on the `Err` string `resolve_environment` returns when a +/// case-insensitive project/environment lookup matches more than one +/// candidate. `pub` so callers match on this constant instead of a bare +/// string literal — today that's the HTTP layer's `resolve_scope`, mapping +/// it to `409 AMBIGUOUS_SCOPE`. +pub const AMBIGUOUS_MATCH_PREFIX: &str = "ambiguous match"; + /// Resolves the environment identified either by numeric `environment_id`, /// or by a case-insensitive `project` name + `environment` name pair — the /// same two lookup shapes CLI's `project inject --id` / `--project @@ -250,14 +257,15 @@ pub async fn delete_environment(db: &VaultDb, id: i64) -> Result<(), String> { /// /// Rejects ambiguity instead of guessing: if more than one project matches /// `project`, or more than one environment within the resolved project -/// matches `environment`, this returns an `Err` naming every colliding -/// candidate rather than silently taking the first (`ORDER BY id ASC`) -/// match. This is required, not defence-in-depth — SQLite's `NOCASE` (used by -/// `idx_projects_name_nocase` / `idx_environments_name_nocase`) folds ASCII -/// `A-Z` only, while the `to_lowercase()` comparisons here fold full -/// Unicode. So `PRODUCCIÓN` and `producción` can both satisfy the index -/// (SQLite sees two distinct names) while still colliding here — the index -/// alone does not close that case; this check does. +/// matches `environment`, this returns an `Err` starting with +/// `AMBIGUOUS_MATCH_PREFIX` and naming every colliding candidate, rather than +/// silently taking the first (`ORDER BY id ASC`) match. This is required, not +/// defence-in-depth — SQLite's `NOCASE` (used by `idx_projects_name_nocase` / +/// `idx_environments_name_nocase`) folds ASCII `A-Z` only, while the +/// `to_lowercase()` comparisons here fold full Unicode. So `PRODUCCIÓN` and +/// `producción` can both satisfy the index (SQLite sees two distinct names) +/// while still colliding here — the index alone does not close that case; +/// this check does. pub async fn resolve_environment( db: &VaultDb, environment_id: Option, @@ -283,7 +291,7 @@ pub async fn resolve_environment( .map(|proj| format!("{} (id {})", proj.name, proj.id)) .collect(); return Err(format!( - "ambiguous match for project '{p}': {}. Pass environment_id instead.", + "{AMBIGUOUS_MATCH_PREFIX} for project '{p}': {}. Pass environment_id instead.", options.join(", ") )); } @@ -300,7 +308,7 @@ pub async fn resolve_environment( let options: Vec = matching_envs.iter().map(|env| format!("{} (id {})", env.name, env.id)).collect(); return Err(format!( - "ambiguous match for environment '{e}': {}. Pass environment_id instead.", + "{AMBIGUOUS_MATCH_PREFIX} for environment '{e}': {}. Pass environment_id instead.", options.join(", ") )); } diff --git a/src-tauri/tests/environment_naming.rs b/src-tauri/tests/environment_naming.rs index f30f538..7379cb8 100644 --- a/src-tauri/tests/environment_naming.rs +++ b/src-tauri/tests/environment_naming.rs @@ -339,12 +339,51 @@ async fn t8_non_ascii_collision_survives_index_but_rejected_by_resolver() { Err(e) => e, Ok(_) => panic!("expected an ambiguous match error"), }; - assert!(err.contains("ambiguous match"), "got: {err}"); + assert!(err.contains(project::AMBIGUOUS_MATCH_PREFIX), "got: {err}"); for env in &envs { assert!(err.contains(&env.id.to_string()), "error must name candidate id {}: {err}", env.id); } } +// Regression: dedup's candidate-search must fold exactly like SQLite's own +// LOWER() (ASCII-only), not Rust's `to_lowercase()` (full Unicode). A prior +// version bound a Rust-folded candidate against `LOWER(name)` (SQLite-folded) +// — for a non-ASCII base, the two folds disagree, so a real collision against +// a pre-existing suffixed sibling was missed, the "free" candidate was +// accepted, and the following UPDATE then hit the table's exact-match unique +// constraint: `init_schema` returned `Err` and the vault could not be opened +// at all. This seeds exactly that shape (two colliding non-ASCII names plus a +// pre-existing `-2`) and asserts `VaultDb::open` succeeds. +#[tokio::test] +async fn non_ascii_collision_with_pre_existing_suffix_does_not_brick_open() { + let dir = tempdir().unwrap(); + let (path, path_str) = db_path(&dir, "non_ascii_suffix.db"); + + { + let pool = raw_pool(&path).await; + seed_minimal_schema(&pool).await; + seed_project(&pool, 1, "acme").await; + seed_environment(&pool, 1, 1, "PRODUCCI\u{d3}N").await; + seed_environment(&pool, 2, 1, "prODUCCI\u{d3}N").await; + seed_environment(&pool, 3, 1, "producci\u{d3}n-2").await; + pool.close().await; + } + + let db = VaultDb::open(&path_str) + .await + .expect("must not brick on a non-ASCII collision with a pre-existing suffixed sibling"); + + let envs = db.list_environments(1).await.unwrap(); + let by_id: HashMap = envs.into_iter().map(|e| (e.id, e.name)).collect(); + assert_eq!(by_id.get(&1).unwrap(), "PRODUCCI\u{d3}N", "lowest id keeps its name unchanged"); + assert_eq!( + by_id.get(&2).unwrap(), + "producci\u{d3}n-3", + "loser must skip the taken -2 suffix (correctly detected via SQLite's own LOWER(), not Rust's to_lowercase())" + ); + assert_eq!(by_id.get(&3).unwrap(), "producci\u{d3}n-2", "pre-existing suffixed sibling must be untouched"); +} + // T11: save_project auto-creates "default"; saving a sibling environment named // "Default" must be rejected by the app-level pre-check with the conflict sentinel. #[tokio::test] From 0095c8798689b8ebad2d0bfed10583c82e93f58b Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Tue, 4 Aug 2026 18:26:11 -0500 Subject: [PATCH 09/14] fix(api,cli,mcp,docs): union unlinked global items into discovery endpoints (issue #13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Global items not linked into any environment were invisible to GET /items, GET /commands, and every CLI/MCP surface built on them — indistinguishable from items that don't exist, and a known, undocumented-in-the-contract divergence between the GUI (which already filters isGlobal client-side) and every headless caller. Adds a tri-state include_global=true|false|only query param (default true) to the two discovery endpoints only; materialization endpoints (/fill, /environments/:id/inject, /environments/:id/example, /share/listen) are untouched and stay strictly linkage-based. Every returned item/command now carries isGlobal + a new linked discriminator via an API-response-only ScopedItem/ScopedCommand wrapper (not merged into VaultItem, which is what gets AES-GCM encrypted). Propagates to crypt-env search/list (--scope-globals flag + SCOPE column), crypt-env cmd/exec (linked-wins tie-break on name collision, warns on shadowed global), and the MCP list_items/search_items tools (include_global in inputSchema, forwarded to the REST call). GUI untouched — GlobalSecrets.tsx already filters client-side. 9 new tests: 7 pure-function unit tests (api::scope_tests, plain VaultItem values, no db/vault) covering the union/dedup/linked-flag logic and the invalid-value 422 path, plus 2 integration tests guarding that is_global round-trips through VaultDb::upsert_item and set_item_global. A 10th test (full HTTP GET /items assertion) is deferred to issue #11's test harness, which lives on a separate branch not present in this worktree. Co-Authored-By: Claude Sonnet 5 --- docs/reference.md | 20 +- src-tauri/src/api/mod.rs | 377 +++++++++++++++--- src-tauri/src/bin/crypt-env-mcp.rs | 10 + src-tauri/src/bin/crypt-env/client.rs | 47 ++- src-tauri/src/bin/crypt-env/commands/cmd.rs | 10 +- src-tauri/src/bin/crypt-env/commands/exec.rs | 5 +- src-tauri/src/bin/crypt-env/commands/list.rs | 30 +- .../src/bin/crypt-env/commands/search.rs | 33 +- src-tauri/tests/vault_integration.rs | 47 +++ 9 files changed, 501 insertions(+), 78 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index b35d7fa..8100b05 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -10,7 +10,7 @@ Authentication: Header `X-Vault-Token` containing either a session token (from P |--------|----------|------|-------------| | POST | /unlock | none | Derives AES-GCM key from master password + Argon2 salt, generates 16-byte session token with configurable TTL | | GET | /health | none | Returns version, status, vault_locked bool, mcp_token_configured. No longer returns item_count (removed — see Notes) | -| GET | /items | token | List items (redacted — no secret values), **scoped**: requires `environment_id`, or `project`+`environment` (case-insensitive names) query params — 422 `VALIDATION_ERROR` if unresolvable. Returns only items linked in that environment's `environment_vars`. `type`/`category`/`search` filters apply on top | +| GET | /items | token | List items (redacted — no secret values), **scoped**: requires `environment_id`, or `project`+`environment` (case-insensitive names) query params — 422 `VALIDATION_ERROR` if unresolvable. **Discovery endpoint** — see "Global items and scope" below: accepts `include_global=true\|false\|only` (default `true`), unioning in reusable global items not linked into this environment by default. Every item carries `isGlobal` and `linked`. `type`/`category`/`search` filters apply on top of the union | | POST | /items | token | Create item, **scoped** (same query params as GET /items). Validates: name (req, max 255), type (one of: secret/credential/link/note/command), value (req non-empty). Body accepts optional `key` (environment-var key, defaults to `name`). Creates the item, owns it in the resolved project, links it into the resolved environment under `key`. Caller-supplied `isGlobal` is ignored — items created this way are always `isGlobal:false`. Encrypts with AES-GCM before storing. Returns 422 on validation/scope failure | | GET | /items/:id | token | Get single item metadata (redacted). **Unscoped** — reachable by id regardless of project/environment (scope is a display filter, not an access boundary; see Notes) | | PUT | /items/:id | token | Update item. Merges — omitted fields keep existing values including secret fields. Unscoped, same as GET /items/:id | @@ -20,7 +20,7 @@ Authentication: Header `X-Vault-Token` containing either a session token (from P | POST | /categories | token | Create category. Validates name (req, max 100) and color (req). Generates random hex cid | | PUT | /categories/:id | token | Update category fields. Passing `description: ""` clears it | | DELETE | /categories/:id | token | Delete category. Returns 204 | -| GET | /commands | token | List items of type "command" with extracted `{{VAR}}` placeholders, **scoped** (same query params as GET /items) — limited to commands linked in the resolved environment | +| GET | /commands | token | List items of type "command" with extracted `{{VAR}}` placeholders, **scoped** (same query params as GET /items). **Discovery endpoint** — same `include_global` contract as GET /items (default `true`); each command carries `isGlobal` and `linked` | | GET | /commands/:id | token | Get single command with placeholders. Unscoped | | GET | /settings | token | Get auto_lock_timeout (minutes) and hotkey. Unscoped — settings are global | | PUT | /settings | token | Update auto_lock_timeout and/or hotkey | @@ -45,6 +45,10 @@ Authentication: Header `X-Vault-Token` containing either a session token (from P | POST | /workspaces/:id/relay/send | token | Share complete workspace (definition + all decrypted referenced secrets) via relay. Returns code + passphrase. Legacy, workspace-table-backed, out of scope for the projects/environments migration | | POST | /workspaces/relay/receive | token | Receive shared workspace from relay. Recreates secrets and rebuilds workspace with variables re-linked. Legacy, same as above — items imported this way are NOT linked into any project/environment and are invisible to the scoped endpoints above | +### Global items and scope + +**Discovery surfaces union globals; materialization surfaces never do; `linked` is the discriminator.** `GET /items` and `GET /commands` accept `include_global=true|false|only` (default `true`): `true` returns (items linked in the resolved environment) ∪ (all `isGlobal:true` items), deduplicated by id; `false` restricts to exactly what's linked — byte-for-byte what `/fill`/`/inject`/`/environments/:id/example` will materialize; `only` returns just the global set regardless of linkage (the REST equivalent of the GUI's Global Secrets screen), still requiring a valid scope. An invalid value 422s with `include_global` named in the message. Every returned item/command carries `isGlobal` (is it marked reusable) and `linked` (is it actually linked into the queried environment) so a caller can always tell "exists and reusable" apart from "will be written by fill/inject". `POST /fill`, `POST /environments/:id/inject`, `POST /environments/:id/example`, and `POST /share/listen` are unaffected by `include_global` — they resolve strictly through `environment_vars`, so linking a global into an environment remains a deliberate, explicit act. + ### Notes `decrypt_all_items` decrypts the entire vault on every authenticated request (no caching, no index), making every GET /items a full decryption pass — O(n) per request regardless of filters. Scoped endpoints add a second cost on top: `resolve_scope` loads the full project→environment→vars graph (`GET /projects`-equivalent) before the item decryption pass, so every scoped request is now O(vault) + O(project graph). @@ -106,10 +110,10 @@ Every command that reads or writes vault items scoped to a project (`add`, `fill | `doctor` | — | Check app health, vault lock state, token files, version, and validate `crypt-env.json` if present | | `fill` | `[PATH] [--project] [--env]` | Fill a .env template with vault secrets from the resolved environment, via `POST /fill`. No PATH: looks for `.env.example` then `.env` in cwd; if neither exists, generates a fresh `.env` from the environment's own variable keys (inverse of `add`) | | `inject` | `NAME [--shell TYPE] [--project] [--env]` | Prints shell assignment to stdout (safe for eval). Supported: pwsh, bash, zsh, sh. Prints verify hint to stderr | -| `list` | `[--project] [--env]` | List saved commands in a table | +| `list` | `[--project] [--env] [--scope-globals with\|without\|only]` | List saved commands in a table, with a SCOPE column (`linked`/`global`/`global+linked`). `--scope-globals` (default `with`) controls whether unlinked global commands are included | | `exec` | `NAME [ARGS] [--project] [--env]` | Execute a saved command by name | | `memory` | — | Save a command string interactively | -| `search` | `QUERY [--project] [--env]` | Search items by name/title within scope. Prints table of ID, TYPE, NAME, CATEGORIES. No values shown | +| `search` | `QUERY [--project] [--env] [--scope-globals with\|without\|only]` | Search items by name/title within scope. Prints table of ID, TYPE, NAME, SCOPE (`linked`/`global`/`global+linked`), CATEGORIES. `--scope-globals` (default `with`) controls whether unlinked global items are included. No values shown | | `set` | `NAME [--project] [--env]` | Print export/env assignment for a secret (stdout) | | `cmd` | `list/info/run [--project] [--env]` | Manage saved commands (list, get info, run) | | `share send` | `ITEM_IDS... [--project] [--env]` | Start LAN share as sender. Items must already be linked into the resolved environment. Polls for peer, shows fingerprint, prompts confirmation | @@ -155,6 +159,8 @@ A crafted environment name (created via the GUI or with the static MCP token — `doctor` no longer reports vault item count — `GET /health` stopped returning `item_count` (it leaked vault size to unauthenticated callers). +`cmd`/`exec` resolve a command by name against `GET /commands`, which now defaults to unioning in unlinked global commands (issue #13). When a linked command and a global command share the same name, the linked one wins and a one-line warning naming the shadowed global's id is printed to stderr — `crypt-env cmd`/`crypt-env exec` do not take `--scope-globals` themselves (they always resolve with the default union so a global command remains runnable from any project). + --- ## TUI @@ -216,9 +222,9 @@ Authentication: Automatic — MCP server reads the REST API session token from d | Tool | Required | Optional | Description | |------|----------|----------|-------------| -| `crypt_env_list_items` | `environment_id` or (`project`+`environment`) | `type`, `category` | List item metadata (no values), scoped to a project+environment (required — the underlying `GET /items` now enforces it). Filter by type or category on top | +| `crypt_env_list_items` | `environment_id` or (`project`+`environment`) | `type`, `category`, `include_global` | List item metadata (no values), scoped to a project+environment (required — the underlying `GET /items` now enforces it). `include_global` (`true`\|`false`\|`only`, default `true`) also lists reusable global secrets not yet linked into this environment — these appear with `linked: false` and will NOT be written by generate/inject/fill until linked. Filter by type or category on top | | `crypt_env_get_item` | `id` | — | Get single item metadata (no value). Unscoped — `GET /items/:id` was not changed by this migration, reachable by id regardless of project | -| `crypt_env_search_items` | `query`, `environment_id` or (`project`+`environment`) | — | Search items by name within scope. Returns metadata only | +| `crypt_env_search_items` | `query`, `environment_id` or (`project`+`environment`) | `include_global` | Search items by name within scope. Same `include_global` contract as `crypt_env_list_items`. Returns metadata only | | `crypt_env_add_item` | `type`, `name`, `environment_id` or (`project`+`environment`) | `value`, `category`, `notes`, `url`, `username`, `key` | Add item to vault, owned by the resolved project and linked into the resolved environment under `key` (defaults to `name`). Value passes through MCP → REST in plaintext | | `crypt_env_update_item` | `id` | `name`, `value`, `url`, `username`, `password`, `title`, `description`, `notes`, `content`, `command`, `shell`, `categories` | Update item. Omitted fields keep existing values server-side. Unscoped — `PUT /items/:id` was not changed by this migration | | `crypt_env_delete_item` | `id` | — | Permanently delete item. Unscoped — `DELETE /items/:id` was not changed by this migration | @@ -277,7 +283,7 @@ Authentication: Automatic — MCP server reads the REST API session token from d `crypt_env_inject_env_by_name` resolves by project directory + environment name. If a real environment is found, its paths are used. If not found, the tool now returns an error with next steps (pass an explicit `environment_id`, or `project`+`environment`) — the previous fallback to item-naming-convention matching (name prefix, category) was removed, since it relied on the now-scope-required `/items`/`/fill` endpoints in a way that could no longer work safely. `output_path` is accepted for backward compatibility but is currently unused. -Global items (`isGlobal: true`, not linked into the queried environment) are invisible to `crypt_env_list_items`, `crypt_env_search_items`, `crypt_env_generate_env`, and `crypt_env_inject_env` — there is currently no MCP tool that can discover a project's reusable global secrets; only items explicitly linked into the scoped environment are reachable. +Fixed (issue #13): `crypt_env_list_items` and `crypt_env_search_items` now default to `include_global=true`, unioning in reusable global items (`isGlobal: true`) that are not yet linked into the queried environment — each result carries `isGlobal` and `linked` so the agent can tell "exists and reusable" apart from "will actually be written". Pass `include_global=false` to see exactly the linked set, or `only` to see just the globals. The remaining gap is unchanged: `crypt_env_generate_env` and `crypt_env_inject_env` still resolve strictly by linkage (matching `/fill`'s/`/inject`'s materialization-only contract) — a global secret discovered via `crypt_env_list_items` still requires an explicit link into the environment (e.g. via `crypt_env_add_item`) before either of those tools can write it. `crypt_env_share_workspace_send` and `crypt_env_share_workspace_receive` are retained for backward compatibility — they share complete workspaces (definition + decrypted secrets) via relay, not individual items. diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index f4431b1..9a8405e 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -72,6 +72,19 @@ struct CommandDetail { placeholders: Vec, } +/// `/commands`-list-only wrapper adding the same `isGlobal`/`linked` +/// discriminators as `ScopedItem`, kept off the shared `CommandDetail` (used +/// unscoped by `GET /commands/:id`, which this change deliberately leaves +/// alone — see plan §3/§4). +#[derive(Serialize)] +struct ScopedCommand { + #[serde(flatten)] + detail: CommandDetail, + #[serde(rename = "isGlobal")] + is_global: bool, + linked: bool, +} + #[derive(Serialize)] struct RevealResponse { value: String, @@ -229,6 +242,120 @@ struct EnvScopeQuery { environment_id: Option, project: Option, environment: Option, + /// Only consumed by `handle_list_commands` — see `IncludeGlobal`. Present + /// here (rather than only on `ItemsQuery`) because `/commands` shares + /// this extractor; other handlers reusing `EnvScopeQuery` simply ignore + /// an unused query param, matching the existing per-handler duplication + /// style instead of refactoring the shared extractor in a bug-fix PR. + include_global: Option, +} + +/// Discovery-endpoint tri-state for whether globally-reusable, unlinked +/// items are unioned into the response. Materialization endpoints +/// (`/fill`, `/environments/:id/inject`, `/environments/:id/example`, +/// `/share/listen`) never consult this — they stay strictly linkage-based. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum IncludeGlobal { + With, + Without, + Only, +} + +impl IncludeGlobal { + /// `None` (param omitted) defaults to `With` — see plan §4.5: a + /// default-`false` fix is invisible to callers who don't know the + /// param exists, which is precisely the bug being fixed. + fn parse(raw: Option<&str>) -> Result { + match raw { + None | Some("true") | Some("with") => Ok(IncludeGlobal::With), + Some("false") | Some("without") => Ok(IncludeGlobal::Without), + Some("only") => Ok(IncludeGlobal::Only), + Some(_) => Err(err_validation( + "include_global", + "must be one of: true, false, only", + )), + } + } +} + +/// Union (or restriction) of `items` against the `linked` id set, per `mode`. +/// Pure function — no `ApiState`, no lock, no crypto — fully unit-testable +/// without a vault or an HTTP server. Stamps `linked` on every returned item. +fn scope_items(items: Vec, linked: &HashSet, mode: IncludeGlobal) -> Vec { + items + .into_iter() + .filter_map(|item| { + let is_linked = linked.contains(&item.id); + let is_global = item.is_global.unwrap_or(false); + let include = match mode { + IncludeGlobal::With => is_linked || is_global, + IncludeGlobal::Without => is_linked, + IncludeGlobal::Only => is_global, + }; + include.then(|| ScopedItem { linked: is_linked, item }) + }) + .collect() +} + +/// Applies the `/items` type/category/search filters over `ScopedItem`s. +/// `type_filter`/`cat_filter`/`search_filter` are expected pre-lowercased by +/// the caller, matching the pre-existing filter behaviour byte-for-byte. +fn filter_scoped_items( + items: Vec, + type_filter: Option<&str>, + cat_filter: Option<&str>, + search_filter: Option<&str>, +) -> Vec { + items + .into_iter() + .filter(|s| { + if let Some(t) = type_filter { + if s.item.item_type.to_lowercase() != t { + return false; + } + } + if let Some(cat) = cat_filter { + let found = s + .item + .categories + .iter() + .flatten() + .any(|c| c.to_lowercase() == cat); + if !found { + return false; + } + } + if let Some(q) = search_filter { + let name_match = s + .item + .name + .as_deref() + .map(|n| n.to_lowercase().contains(q)) + .unwrap_or(false); + let title_match = s + .item + .title + .as_deref() + .map(|t| t.to_lowercase().contains(q)) + .unwrap_or(false); + if !name_match && !title_match { + return false; + } + } + true + }) + .collect() +} + +/// API-response-only wrapper adding the `linked` discriminator on top of +/// `VaultItem`. MUST NOT be merged into `VaultItem` — that struct is what +/// gets AES-GCM encrypted (`vault::encrypt_item`), so a view-only field on +/// it risks being persisted into ciphertext by any round-trip write path. +#[derive(Serialize)] +struct ScopedItem { + #[serde(flatten)] + item: VaultItem, + linked: bool, } /// Resolves the environment for a scoped request, or a 422 response with a @@ -431,6 +558,8 @@ struct ItemsQuery { environment_id: Option, project: Option, environment: Option, + /// Tri-state `true|false|only`, default `true` — see `IncludeGlobal`. + include_global: Option, } /// The set of item ids linked into an environment's `environment_vars` — the @@ -466,6 +595,11 @@ async fn handle_list_items( }; let allowed_ids = environment_item_ids(&env); + let mode = match IncludeGlobal::parse(params.include_global.as_deref()) { + Ok(m) => m, + Err(resp) => return resp, + }; + let items = match decrypt_all_items(&state).await { Ok(i) => i, Err(StatusCode::FORBIDDEN) => { @@ -477,52 +611,24 @@ async fn handle_list_items( .into_response() } }; - let items = items.into_iter().filter(|item| allowed_ids.contains(&item.id)); + + // Union linked items with globals (per `mode`) before applying the + // type/category/search filters, so `search` also searches globals. + let scoped = scope_items(items, &allowed_ids, mode); let type_filter = params.item_type.as_deref().map(|s| s.to_lowercase()); let cat_filter = params.category.as_deref().map(|s| s.to_lowercase()); let search_filter = params.search.as_deref().map(|s| s.to_lowercase()); - let filtered: Vec = items - .into_iter() - .filter(|item| { - // Filtro por tipo - if let Some(ref t) = type_filter { - if item.item_type.to_lowercase() != *t { - return false; - } - } - // Filtro por categoría - if let Some(ref cat) = cat_filter { - let found = item - .categories - .iter() - .flatten() - .any(|c| c.to_lowercase() == *cat); - if !found { - return false; - } - } - // Filtro por búsqueda en nombre/título - if let Some(ref q) = search_filter { - let name_match = item - .name - .as_deref() - .map(|n| n.to_lowercase().contains(q.as_str())) - .unwrap_or(false); - let title_match = item - .title - .as_deref() - .map(|t| t.to_lowercase().contains(q.as_str())) - .unwrap_or(false); - if !name_match && !title_match { - return false; - } - } - true - }) - .map(redact_item) - .collect(); + let filtered: Vec = filter_scoped_items( + scoped, + type_filter.as_deref(), + cat_filter.as_deref(), + search_filter.as_deref(), + ) + .into_iter() + .map(|s| ScopedItem { item: redact_item(s.item), linked: s.linked }) + .collect(); (StatusCode::OK, Json(filtered)).into_response() } @@ -988,6 +1094,11 @@ async fn handle_list_commands( }; let allowed_ids = environment_item_ids(&env); + let mode = match IncludeGlobal::parse(scope.include_global.as_deref()) { + Ok(m) => m, + Err(resp) => return resp, + }; + let items = match decrypt_all_items(&state).await { Ok(i) => i, Err(StatusCode::FORBIDDEN) => { @@ -1000,19 +1111,26 @@ async fn handle_list_commands( } }; - let commands: Vec = items + let commands: Vec = scope_items(items, &allowed_ids, mode) .into_iter() - .filter(|item| item.item_type == "command" && allowed_ids.contains(&item.id)) - .map(|item| { + .filter(|s| s.item.item_type == "command") + .map(|s| { + let is_global = s.item.is_global.unwrap_or(false); + let linked = s.linked; + let item = s.item; let template = item.command.as_deref().unwrap_or(""); let placeholders = extract_placeholders(template); - CommandDetail { - id: item.id, - name: item.name.unwrap_or_default(), - description: item.description, - shell: item.shell, - command: item.command, - placeholders, + ScopedCommand { + detail: CommandDetail { + id: item.id, + name: item.name.unwrap_or_default(), + description: item.description, + shell: item.shell, + command: item.command, + placeholders, + }, + is_global, + linked, } }) .collect(); @@ -3120,3 +3238,160 @@ pub async fn start_server(vault: SharedState, app_data_dir: PathBuf) { eprintln!("[api] REST server error: {e}"); } } + +// ─── Tests: issue #13, global-item scoped visibility ───────────────────────── +// +// Pure-function tests: plain `VaultItem` values and a `project::Environment` +// with synthetic `vars`, no database, no key, no async. `VaultItem` +// deliberately has no `#[derive(Debug)]` (it holds decrypted plaintext +// secrets — CLAUDE.md forbids secrets in logs/errors, and a stray `{:?}` on +// assertion failure would leak one into CI output), so assertions below +// compare individual fields rather than whole structs. +#[cfg(test)] +mod scope_tests { + use super::*; + + /// Builds a minimal `VaultItem` fixture. `is_global` mirrors the + /// `Option` shape of the real field (`None` behaves like `false` + /// for scoping purposes, same as `unwrap_or(false)` in `scope_items`). + fn item(id: i64, name: &str, is_global: Option) -> VaultItem { + VaultItem { + id, + item_type: "secret".to_string(), + name: Some(name.to_string()), + value: None, + url: None, + username: None, + password: None, + title: None, + description: None, + command: None, + shell: None, + categories: None, + notes: None, + content: None, + created: "2026-01-01T00:00:00Z".to_string(), + is_global, + } + } + + fn env_with_vars(pairs: &[(&str, i64)]) -> project::Environment { + project::Environment { + id: 1, + project_id: 1, + name: "test".to_string(), + is_default: true, + paths: vec![], + vars: pairs + .iter() + .map(|(key, item_id)| project::EnvironmentVar { + id: 0, + key: key.to_string(), + item_id: *item_id, + }) + .collect(), + created: "2026-01-01T00:00:00Z".to_string(), + updated: "2026-01-01T00:00:00Z".to_string(), + } + } + + #[test] + fn global_unlinked_item_visible_by_default() { + let item_a = item(1, "A", Some(false)); // linked, non-global + let item_b = item(2, "B", Some(true)); // unlinked, global + let env = env_with_vars(&[("A_KEY", 1)]); + let linked = environment_item_ids(&env); + + let result = scope_items(vec![item_a, item_b], &linked, IncludeGlobal::With); + + assert_eq!(result.len(), 2, "default mode must return both linked and unlinked-global items"); + let b = result.iter().find(|s| s.item.id == 2).expect("item B must be present"); + assert_eq!(b.item.is_global, Some(true)); + assert!(!b.linked, "unlinked global item must report linked: false"); + } + + #[test] + fn linked_item_reports_linked_true() { + let item_a = item(1, "A", Some(false)); + let env = env_with_vars(&[("A_KEY", 1)]); + let linked = environment_item_ids(&env); + + let result = scope_items(vec![item_a], &linked, IncludeGlobal::With); + + assert_eq!(result.len(), 1); + assert!(result[0].linked, "linked item must report linked: true"); + assert_eq!(result[0].item.is_global, Some(false)); + } + + #[test] + fn global_and_linked_item_appears_once() { + let item_c = item(3, "C", Some(true)); // both global and linked + let env = env_with_vars(&[("C_KEY", 3)]); + let linked = environment_item_ids(&env); + + let result = scope_items(vec![item_c], &linked, IncludeGlobal::With); + + assert_eq!(result.len(), 1, "item that is both global and linked must appear exactly once (dedup guard)"); + assert!(result[0].linked); + assert_eq!(result[0].item.is_global, Some(true)); + } + + #[test] + fn include_global_false_matches_legacy_scope() { + let item_a = item(1, "A", Some(false)); // linked, non-global + let item_b = item(2, "B", Some(true)); // unlinked, global + let env = env_with_vars(&[("A_KEY", 1)]); + let linked = environment_item_ids(&env); + + let result = scope_items(vec![item_a, item_b], &linked, IncludeGlobal::Without); + + assert_eq!(result.len(), 1, "Without must return exactly the linked set, matching the pre-change filter"); + assert_eq!(result[0].item.id, 1); + assert!(result[0].linked); + } + + #[test] + fn include_global_only_returns_globals_regardless_of_link() { + let item_a = item(1, "A", Some(false)); // linked, non-global + let item_b = item(2, "B", Some(true)); // unlinked, global + let item_c = item(3, "C", Some(true)); // linked, global + let env = env_with_vars(&[("A_KEY", 1), ("C_KEY", 3)]); + let linked = environment_item_ids(&env); + + let result = scope_items(vec![item_a, item_b, item_c], &linked, IncludeGlobal::Only); + + let ids: HashSet = result.iter().map(|s| s.item.id).collect(); + assert_eq!(ids, HashSet::from([2, 3]), "Only must return all is_global items regardless of linkage, excluding non-global A"); + } + + #[test] + fn search_and_type_filters_apply_to_unioned_globals() { + let item_a = item(1, "DB_HOST", Some(false)); // linked + let item_b = item(2, "API_KEY", Some(true)); // unlinked, global + let env = env_with_vars(&[("DB_HOST", 1)]); + let linked = environment_item_ids(&env); + + let scoped = scope_items(vec![item_a, item_b], &linked, IncludeGlobal::With); + assert_eq!(scoped.len(), 2, "union must include both before filtering"); + + let filtered = filter_scoped_items(scoped, None, None, Some("api")); + + assert_eq!(filtered.len(), 1, "search must narrow within the union, including unioned globals"); + assert_eq!(filtered[0].item.id, 2); + } + + #[test] + fn invalid_include_global_value_is_rejected() { + // `axum::response::Response` (the `Err` side) implements neither + // `Debug` nor `PartialEq`, so `assert_eq!`/`unwrap()` on the whole + // `Result` won't compile — `matches!` pattern-matches without + // requiring either trait. + assert!(IncludeGlobal::parse(Some("bogus")).is_err()); + assert!(matches!(IncludeGlobal::parse(None), Ok(IncludeGlobal::With))); + assert!(matches!(IncludeGlobal::parse(Some("true")), Ok(IncludeGlobal::With))); + assert!(matches!(IncludeGlobal::parse(Some("with")), Ok(IncludeGlobal::With))); + assert!(matches!(IncludeGlobal::parse(Some("false")), Ok(IncludeGlobal::Without))); + assert!(matches!(IncludeGlobal::parse(Some("without")), Ok(IncludeGlobal::Without))); + assert!(matches!(IncludeGlobal::parse(Some("only")), Ok(IncludeGlobal::Only))); + } +} diff --git a/src-tauri/src/bin/crypt-env-mcp.rs b/src-tauri/src/bin/crypt-env-mcp.rs index be85509..08d4c9f 100644 --- a/src-tauri/src/bin/crypt-env-mcp.rs +++ b/src-tauri/src/bin/crypt-env-mcp.rs @@ -202,6 +202,7 @@ fn tool_definitions() -> serde_json::Value { "properties": { "type": { "type": "string", "description": "Filter by type: secret, credential, link, command, note" }, "category": { "type": "string", "description": "Filter by category name" }, + "include_global": { "type": "string", "enum": ["true", "false", "only"], "description": "true (default) also lists reusable global secrets not yet linked into this environment — these appear with `linked: false` and will NOT be written by generate/inject/fill until linked. false restricts to items actually linked into this environment (what fill/inject will materialize). only returns just the global secrets, ignoring linkage." }, "environment_id": { "type": "integer", "description": "Environment ID (scope). Provide this, or both 'project' and 'environment'." }, "project": { "type": "string", "description": "Project name (case-insensitive). Used with 'environment' when 'environment_id' is not given." }, "environment": { "type": "string", "description": "Environment name within the project (case-insensitive), e.g. production, local, test. Used with 'project'." } @@ -226,6 +227,7 @@ fn tool_definitions() -> serde_json::Value { "type": "object", "properties": { "query": { "type": "string", "description": "Search term to match against item names" }, + "include_global": { "type": "string", "enum": ["true", "false", "only"], "description": "true (default) also searches reusable global secrets not yet linked into this environment — these appear with `linked: false` and will NOT be written by generate/inject/fill until linked. false restricts to items actually linked into this environment (what fill/inject will materialize). only returns just the global secrets, ignoring linkage." }, "environment_id": { "type": "integer", "description": "Environment ID (scope). Provide this, or both 'project' and 'environment'." }, "project": { "type": "string", "description": "Project name (case-insensitive). Used with 'environment' when 'environment_id' is not given." }, "environment": { "type": "string", "description": "Environment name within the project (case-insensitive), e.g. production, local, test. Used with 'project'." } @@ -865,6 +867,10 @@ fn tool_list_items(args: &serde_json::Value, token: &str) -> serde_json::Value { url.push_str(&format!("{}category={}", sep, cat)); sep = '&'; } + if let Some(ig) = args.get("include_global").and_then(|v| v.as_str()) { + url.push_str(&format!("{}include_global={}", sep, urlencod(ig))); + sep = '&'; + } append_scope_params(&mut url, &mut sep, args); let resp = match vault_get(&url, token) { @@ -899,6 +905,10 @@ fn tool_search_items(args: &serde_json::Value, token: &str) -> serde_json::Value let mut url = format!("/items?search={}", urlencod(&query)); let mut sep = '&'; + if let Some(ig) = args.get("include_global").and_then(|v| v.as_str()) { + url.push_str(&format!("{}include_global={}", sep, urlencod(ig))); + sep = '&'; + } append_scope_params(&mut url, &mut sep, args); let resp = match vault_get(&url, token) { diff --git a/src-tauri/src/bin/crypt-env/client.rs b/src-tauri/src/bin/crypt-env/client.rs index f7e5d6e..be845b9 100644 --- a/src-tauri/src/bin/crypt-env/client.rs +++ b/src-tauri/src/bin/crypt-env/client.rs @@ -61,9 +61,17 @@ pub struct ItemSummary { pub title: Option, #[serde(default)] pub categories: Vec, + /// Present on `/items` responses since issue #13 (defaults to `false` + /// when absent, e.g. responses from before this change). + #[serde(default, rename = "isGlobal")] + pub is_global: bool, + /// `true` iff this item is linked into the queried environment. Also + /// new since issue #13 — defaults to `false` when absent. + #[serde(default)] + pub linked: bool, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] pub struct CommandDetail { pub id: i64, @@ -76,6 +84,43 @@ pub struct CommandDetail { pub command: Option, #[serde(default)] pub placeholders: Vec, + /// Present on `/commands` list responses since issue #13. `GET + /// /commands/:id` (unscoped, untouched by this change) omits it, so this + /// defaults to `false` there. + #[serde(default, rename = "isGlobal")] + pub is_global: bool, + /// `true` iff this command is linked into the queried environment. + /// Meaningless outside a scoped `/commands` list — defaults to `false`. + #[serde(default)] + pub linked: bool, +} + +/// Given the full `/commands` list for a scope, finds the command matching +/// `name` case-insensitively. When both a linked command and a global +/// (unlinked) command share the same name, the linked one wins — matching +/// `/fill`'s and `/inject`'s materialization-only semantics — and a warning +/// naming the shadowed global's id is printed to stderr. +pub fn resolve_command_by_name(commands: Vec, name: &str) -> Option { + let name_lower = name.to_lowercase(); + let mut matches: Vec = commands + .into_iter() + .filter(|c| c.name.to_lowercase() == name_lower) + .collect(); + + if matches.len() > 1 { + if let Some(linked_idx) = matches.iter().position(|c| c.linked) { + let linked = matches.remove(linked_idx); + for shadowed in matches.iter().filter(|c| !c.linked) { + eprintln!( + "warning: command '{}' also exists as a global item (id {}) — using the linked one", + name, shadowed.id + ); + } + return Some(linked); + } + } + + matches.into_iter().next() } #[derive(Deserialize, Debug)] diff --git a/src-tauri/src/bin/crypt-env/commands/cmd.rs b/src-tauri/src/bin/crypt-env/commands/cmd.rs index 04c5d0e..ff9ca22 100644 --- a/src-tauri/src/bin/crypt-env/commands/cmd.rs +++ b/src-tauri/src/bin/crypt-env/commands/cmd.rs @@ -84,11 +84,8 @@ fn command_info(name: &str, resolved_scope: &ResolvedScope) -> Result<(), CliErr } let commands: Vec = resp.json().map_err(|e| CliError::Api(e.to_string()))?; - let name_lower = name.to_lowercase(); - let found = commands - .into_iter() - .find(|c| c.name.to_lowercase() == name_lower); + let found = client::resolve_command_by_name(commands, name); let cmd_id = match found { Some(c) => c.id, @@ -133,11 +130,8 @@ fn run_command(name: &str, vars: &[String], resolved_scope: &ResolvedScope) -> R } let commands: Vec = resp.json().map_err(|e| CliError::Api(e.to_string()))?; - let name_lower = name.to_lowercase(); - let cmd = commands - .into_iter() - .find(|c| c.name.to_lowercase() == name_lower) + let cmd = client::resolve_command_by_name(commands, name) .ok_or_else(|| CliError::NotFound(name.to_string()))?; let mut template = cmd.command.unwrap_or_default(); diff --git a/src-tauri/src/bin/crypt-env/commands/exec.rs b/src-tauri/src/bin/crypt-env/commands/exec.rs index 0e58e90..dc3897f 100644 --- a/src-tauri/src/bin/crypt-env/commands/exec.rs +++ b/src-tauri/src/bin/crypt-env/commands/exec.rs @@ -39,11 +39,8 @@ pub fn run(args: ExecArgs) -> Result<(), CliError> { } let commands: Vec = resp.json().map_err(|e| CliError::Api(e.to_string()))?; - let name_lower = args.name.to_lowercase(); - let cmd = commands - .into_iter() - .find(|c| c.name.to_lowercase() == name_lower) + let cmd = client::resolve_command_by_name(commands, &args.name) .ok_or_else(|| CliError::NotFound(args.name.clone()))?; let mut template = cmd.command.unwrap_or_default(); diff --git a/src-tauri/src/bin/crypt-env/commands/list.rs b/src-tauri/src/bin/crypt-env/commands/list.rs index 94f03ea..e3a6bc2 100644 --- a/src-tauri/src/bin/crypt-env/commands/list.rs +++ b/src-tauri/src/bin/crypt-env/commands/list.rs @@ -21,16 +21,31 @@ pub struct ListArgs { /// Environment name (defaults to crypt-env.json or the project's default environment) #[arg(long = "env")] pub env: Option, + + /// Whether to include reusable global commands not linked into this + /// environment: `with` (default) unions them in, `without` matches + /// pre-issue-13 behaviour (linked commands only), `only` returns globals + /// regardless of linkage. + #[arg(long = "scope-globals", default_value = "with")] + pub scope_globals: String, } pub fn run(args: ListArgs) -> Result<(), CliError> { let resolved_scope = scope::resolve(args.project.as_deref(), args.env.as_deref(), false)?; - let url = resolved_scope.append_query(&format!("{}/commands", client::API_BASE)); + let url = resolved_scope.append_query(&format!( + "{}/commands?include_global={}", + client::API_BASE, + client::urlencod(&args.scope_globals) + )); let resp = client::authenticated_get(&url)?; if resp.status() == reqwest::StatusCode::FORBIDDEN { return Err(CliError::VaultLocked); } + if resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY { + let text = resp.text().unwrap_or_default(); + return Err(CliError::Api(format!("invalid --scope-globals value: {text}"))); + } if !resp.status().is_success() { return Err(CliError::Api(format!("HTTP {}", resp.status()))); } @@ -61,7 +76,7 @@ pub fn run(args: ListArgs) -> Result<(), CliError> { table .load_preset(UTF8_FULL) .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec!["Name", "Description", "Shell", "Placeholders"]); + .set_header(vec!["Name", "Description", "Shell", "Placeholders", "Scope"]); for cmd in &commands { let placeholders = cmd.placeholders.join(", "); @@ -70,9 +85,20 @@ pub fn run(args: ListArgs) -> Result<(), CliError> { cmd.description.as_deref().unwrap_or(""), cmd.shell.as_deref().unwrap_or(""), &placeholders, + scope_label(cmd.is_global, cmd.linked), ]); } println!("{table}"); Ok(()) } + +/// `linked`/`global`/`global+linked` discriminator for the Scope column. +fn scope_label(is_global: bool, linked: bool) -> &'static str { + match (linked, is_global) { + (true, true) => "global+linked", + (true, false) => "linked", + (false, true) => "global", + (false, false) => "", + } +} diff --git a/src-tauri/src/bin/crypt-env/commands/search.rs b/src-tauri/src/bin/crypt-env/commands/search.rs index 9438c10..b0fe35c 100644 --- a/src-tauri/src/bin/crypt-env/commands/search.rs +++ b/src-tauri/src/bin/crypt-env/commands/search.rs @@ -14,20 +14,32 @@ pub struct SearchArgs { /// Environment name (defaults to crypt-env.json or the project's default environment) #[arg(long = "env")] pub env: Option, + + /// Whether to include reusable global items not linked into this + /// environment: `with` (default) unions them in, `without` matches + /// pre-issue-13 behaviour (linked items only), `only` returns globals + /// regardless of linkage. + #[arg(long = "scope-globals", default_value = "with")] + pub scope_globals: String, } pub fn run(args: SearchArgs) -> Result<(), CliError> { let resolved_scope = scope::resolve(args.project.as_deref(), args.env.as_deref(), false)?; let url = resolved_scope.append_query(&format!( - "{}/items?search={}", + "{}/items?search={}&include_global={}", client::API_BASE, - client::urlencod(&args.query) + client::urlencod(&args.query), + client::urlencod(&args.scope_globals) )); let resp = client::authenticated_get(&url)?; if resp.status() == reqwest::StatusCode::FORBIDDEN { return Err(CliError::VaultLocked); } + if resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY { + let text = resp.text().unwrap_or_default(); + return Err(CliError::Api(format!("invalid --scope-globals value: {text}"))); + } if !resp.status().is_success() { let code = resp.status(); return Err(CliError::Api(format!("HTTP error {code}"))); @@ -40,8 +52,8 @@ pub fn run(args: SearchArgs) -> Result<(), CliError> { return Ok(()); } - println!("{:<6} {:<16} {:<32} CATEGORIES", "ID", "TYPE", "NAME/TITLE"); - println!("{}", "-".repeat(80)); + println!("{:<6} {:<16} {:<32} {:<16} CATEGORIES", "ID", "TYPE", "NAME/TITLE", "SCOPE"); + println!("{}", "-".repeat(96)); for item in &items { let display_name = item .name @@ -49,13 +61,24 @@ pub fn run(args: SearchArgs) -> Result<(), CliError> { .or(item.title.as_deref()) .unwrap_or(""); println!( - "{:<6} {:<16} {:<32} {}", + "{:<6} {:<16} {:<32} {:<16} {}", item.id, item.item_type, display_name, + scope_label(item.is_global, item.linked), item.categories.join(", ") ); } Ok(()) } + +/// `linked`/`global`/`global+linked` discriminator for the SCOPE column. +fn scope_label(is_global: bool, linked: bool) -> &'static str { + match (linked, is_global) { + (true, true) => "global+linked", + (true, false) => "linked", + (false, true) => "global", + (false, false) => "", + } +} diff --git a/src-tauri/tests/vault_integration.rs b/src-tauri/tests/vault_integration.rs index 190702a..53954ba 100644 --- a/src-tauri/tests/vault_integration.rs +++ b/src-tauri/tests/vault_integration.rs @@ -104,3 +104,50 @@ async fn test_db_init_vault_and_get_meta() { assert_eq!(salt, "deadbeef_salt"); assert_eq!(token, "deadbeef_token"); } + +// ─── Issue #13: global-item scoped visibility — data-path guards ───────────── +// +// The API's scope filter (src-tauri/src/api/mod.rs, `scope_items`) now reads +// `is_global` off every row returned by `list_items()` to decide whether an +// unlinked item should be unioned into a discovery response. These two tests +// guard the data path that decision depends on: the DB layer must round-trip +// `is_global` faithfully through both insert/update (`upsert_item`) and the +// dedicated toggle (`set_item_global`). + +#[tokio::test] +async fn test_db_list_items_preserves_is_global_flag() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("test.db").to_str().unwrap().to_string(); + let db = VaultDb::open(&db_path).await.unwrap(); + + let global_id = db.upsert_item(0, "secret", "global_data", "2026-01-01", true).await.unwrap(); + let non_global_id = db.upsert_item(0, "secret", "scoped_data", "2026-01-01", false).await.unwrap(); + + let items = db.list_items().await.unwrap(); + assert_eq!(items.len(), 2); + + let global_row = items.iter().find(|r| r.0 == global_id).expect("global item must be present"); + assert!(global_row.4, "tuple index 4 (is_global) must be true for the item created global"); + + let scoped_row = items.iter().find(|r| r.0 == non_global_id).expect("non-global item must be present"); + assert!(!scoped_row.4, "tuple index 4 (is_global) must be false for the item created non-global"); +} + +#[tokio::test] +async fn test_db_set_item_global_roundtrip() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("test.db").to_str().unwrap().to_string(); + let db = VaultDb::open(&db_path).await.unwrap(); + + let id = db.upsert_item(0, "secret", "data", "2026-01-01", false).await.unwrap(); + + db.set_item_global(id, true).await.unwrap(); + let items = db.list_items().await.unwrap(); + let row = items.iter().find(|r| r.0 == id).unwrap(); + assert!(row.4, "set_item_global(true) must flip is_global to true in list_items"); + + db.set_item_global(id, false).await.unwrap(); + let items = db.list_items().await.unwrap(); + let row = items.iter().find(|r| r.0 == id).unwrap(); + assert!(!row.4, "set_item_global(false) must flip is_global back to false in list_items"); +} From fc817a21ad7c0af3d7fc99df4fdd03913da9339c Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Tue, 4 Aug 2026 18:36:25 -0500 Subject: [PATCH 10/14] fix(security): contain path traversal via environment name (issue #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An environment `name` is stored, untrusted data reused by later requests — a name like `../../../tmp/pwned` created once via POST /environments (or the GUI/CLI/an imported template) would silently redirect a subsequent /fill's decrypted secrets, /example, or inject_environment's write outside the caller's own output_dir. create_dir_all on the interpolated join path was the amplifier: it materialized the `.env...` directory that gave the `..` segments something real to resolve against. Two independent layers, each sufficient alone: - project::validate_environment_name / validate_project_name reject hostile names at the single choke point (save_environment / save_project), so no new hostile name can ever be persisted regardless of caller (HTTP, Tauri command, imported .cryptenv-proj template, CLI). - New dependency-free fsguard::resolve_within(base, name) guarantees any name — including a legacy row that predates validation — resolves to a direct child of the canonicalized base or is rejected, with a symlink recheck for an already-existing target. Wired into all three sinks (/fill, /environments/:id/example, inject_environment's default-filename branch): the create_dir_all call that used to see the interpolated name is gone, replaced by fsguard's own base-only creation. /fill's target is now resolved and validated before any decryption happens, so a rejected request never produces plaintext. Both layers report 422 (VALIDATION_ERROR / PATH_NOT_CONTAINED) instead of 500, and neither echoes the offending name or resolved path. ProjectManager.tsx mirrors both name rules for inline UX feedback only — the server remains the sole enforcement point. New src-tauri/tests/path_containment.rs covers the malicious-name table (19+ entries: traversal, absolute/UNC/verbatim, reserved device names, NUL, Unicode look-alikes, over-length, etc.), the no-amplification invariant, the choke-point (save_environment with no HTTP involved), layer independence, the positive path, and legacy-row containment. Issue #11's HTTP harness hasn't landed on this branch, so the /fill-specific HTTP-level cases (secret-scan-on-rejection, error-shape-over-HTTP) are deferred to when it does; the non-HTTP cases already cover objectives 1, 2, 4 and 6 of the plan. Co-Authored-By: Claude Sonnet 5 --- docs/reference.md | 14 +- src-tauri/src/api/mod.rs | 162 ++++++++++--- src-tauri/src/fsguard/mod.rs | 313 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/project/mod.rs | 106 ++++++++- src-tauri/tests/path_containment.rs | 344 ++++++++++++++++++++++++++++ src/components/ProjectManager.tsx | 44 +++- 7 files changed, 934 insertions(+), 50 deletions(-) create mode 100644 src-tauri/src/fsguard/mod.rs create mode 100644 src-tauri/tests/path_containment.rs diff --git a/docs/reference.md b/docs/reference.md index b35d7fa..d8597bf 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -24,8 +24,8 @@ Authentication: Header `X-Vault-Token` containing either a session token (from P | GET | /commands/:id | token | Get single command with placeholders. Unscoped | | GET | /settings | token | Get auto_lock_timeout (minutes) and hotkey. Unscoped — settings are global | | PUT | /settings | token | Update auto_lock_timeout and/or hotkey | -| POST | /fill | token | Fill a .env template with real values, **scoped** (same query params as GET /items). Matches template keys against the resolved environment's `environment_vars.key` (not a vault-wide name search). A template key not found in scope has its **original line preserved unchanged** (not blanked) and is reported as a warning. `output_path` given: writes there with the RAII `TempEnvFile` guard, returns stats only — no secret in response. No `output_path` but `output_dir` given: writes `{output_dir}/.env.`. Neither: returns filled content inline | -| POST | /environments/:id/example | token | Generate a placeholder-only env file/content for the environment (`environment_id` in URL path, same convention as `/environments/:id/inject`) — `KEY=` for every linked var key, values always empty, explicitly safe to commit. Never decrypts or reads item values. Body `{output_path?, output_dir?}`: `output_path` writes there; `output_dir` writes `{output_dir}/.env.example.`; neither returns `{content, keys}` inline. 404 if the environment doesn't resolve | +| POST | /fill | token | Fill a .env template with real values, **scoped** (same query params as GET /items). Matches template keys against the resolved environment's `environment_vars.key` (not a vault-wide name search). A template key not found in scope has its **original line preserved unchanged** (not blanked) and is reported as a warning. `output_path` given: writes there with the RAII `TempEnvFile` guard, returns stats only — no secret in response. No `output_path` but `output_dir` given: the environment-name-derived filename is resolved via `fsguard::resolve_within` (issue #7) before any decryption happens — `422 PATH_NOT_CONTAINED` if it can't stay inside `output_dir`, `path` in the response is the resolved (post-canonicalization) path. Neither: returns filled content inline | +| POST | /environments/:id/example | token | Generate a placeholder-only env file/content for the environment (`environment_id` in URL path, same convention as `/environments/:id/inject`) — `KEY=` for every linked var key, values always empty, explicitly safe to commit. Never decrypts or reads item values. Body `{output_path?, output_dir?}`: `output_path` writes there; `output_dir` writes to the environment-name-derived filename, contained within `output_dir` via `fsguard::resolve_within` (issue #7, same as `/fill`) — `422 PATH_NOT_CONTAINED` on escape; neither returns `{content, keys}` inline. 404 if the environment doesn't resolve | | POST | /share/listen | token | Start LAN share session as sender, **scoped** (same query params as GET /items). Every id in `items` must already be linked into the resolved environment or the call 422s. Registers mDNS, returns `pairing_code` | | POST | /share/connect | token | Connect as receiver using pairing_code, **scoped** (same query params as GET /items). On successful transfer, received items are owned by the resolved project and linked into the resolved environment under the sender's item names — **except** where that name collides with a key already linked in the target environment, in which case the item is still imported/owned but the existing link is left untouched and the collision is reported (see `/share/status`'s `skipped_keys`, and Notes). Returns ECDH fingerprint | | POST | /share/confirm | token | Confirm (or reject) fingerprint. Both sides must call this | @@ -34,10 +34,10 @@ Authentication: Header `X-Vault-Token` containing either a session token (from P | POST | /share/export | token | Export items as AES-256-GCM encrypted `.vault` file. Returns passphrase in response. Unscoped (operates on item IDs directly) | | POST | /share/import | token | Import from `.vault` file using passphrase, **scoped** (same query params as GET /items). Imported items are owned by the resolved project and linked into the resolved environment, with the same collision-skip behavior as `/share/connect`. (The Tauri GUI command for this import path still passes no scope — items land ownerless/unlinked from the GUI, same pre-existing gap as before, not addressed this pass) | | GET | /projects | token | List all projects with their typed environments (name, template, paths, variable count) | -| POST | /projects | token | Create or update project. Returns project ID (upsert by id=0 for creation). `name` is unique case-insensitively at the DB level — creating with a name that already exists (any case) returns 409 CONFLICT instead of creating a duplicate | +| POST | /projects | token | Create or update project. Returns project ID (upsert by id=0 for creation). `name` is unique case-insensitively at the DB level — creating with a name that already exists (any case) returns 409 CONFLICT instead of creating a duplicate. `name` must also pass the filesystem-hostile deny-list (issue #7: no separators, control characters, NTFS-hostile characters, reserved device names, leading/trailing dot or whitespace; 128 chars max) — 422 `VALIDATION_ERROR` otherwise | | DELETE | /projects/:id | token | Delete project and all its environments. Returns impact summary | | GET | /projects/:id/preview-delete | token | Show what will be deleted (impact preview) without performing the deletion | -| POST | /environments | token | Create or update environment within a project. `projectId` is now required and validated to reference an existing project — 422 if missing/invalid. Returns environment ID (upsert by id=0 for creation) | +| POST | /environments | token | Create or update environment within a project. `projectId` is now required and validated to reference an existing project — 422 if missing/invalid. `name` must match `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (issue #7) — 422 `VALIDATION_ERROR` otherwise. Returns environment ID (upsert by id=0 for creation) | | DELETE | /environments/:id | token | Delete a single environment. Returns 204 | | POST | /environments/:id/inject | token | Inject environment's variables into its configured .env path(s). Takes a JSON body `{output_path?, output_dir?}` (previously bodyless — an empty `{}` body preserves the old behavior). `output_path`, if given, is added to (not a replacement for) the environment's configured `paths[]` — all get written. If `paths[]` is empty and no `output_path`, falls back to `{output_dir}/.env.`. Returns paths written and keys injected | | POST | /relay/send | token | Encrypt selected items with Argon2id-derived key and upload to Supabase relay. Returns code + passphrase. Requires relay_supabase_url and relay_supabase_anon_key in settings | @@ -69,7 +69,9 @@ Projects/environments model replaces the old workspaces. A Project contains mult `projects.name` has a case-insensitive UNIQUE index (`idx_projects_name_nocase`) — duplicate-name creation now returns 409 instead of silently succeeding. `environments.name` is only unique per-project under SQLite's default (case-sensitive) collation — two environments in the same project differing only by case (e.g. `Production`/`production`) can still coexist, and name-pair resolution (case-insensitive, picks the lowest-id match) will silently prefer one over the other with no ambiguity error. Known limitation, not fixed. -**Known, deferred issues** (found in review, not fixed in this pass): (1) a crafted environment `name` (only validated non-empty) combined with `output_dir` on `/fill`, `/environments/:id/inject`, or `/environments/:id/example` can path-traverse outside the intended directory, because `create_dir_all` on the joined path materializes the intermediate component that makes `..` segments resolve — reachable by anything holding the static MCP token via `POST /environments`. (2) `/fill`, `/environments/:id/inject`, and `/environments/:id/example` all write via a plain `std::fs::write` to `output_path` with no existence check — pointing one at a real, unrelated file truncates it. (3) `POST /items` on a key that already exists in the environment creates a new item row and repoints the link, orphaning (not deleting) the previous item — repeated `add`-equivalent calls grow the vault unboundedly and "rotating" a secret this way doesn't actually remove the old value. +**Environment/project name validation (issue #7, fixed):** `environment.name` must match `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (1-64 chars, starts with a letter or digit, no trailing `.` or `-`) — enforced in `project::save_environment` before the row is ever persisted, so it applies identically to `POST /environments`, the Tauri `environment_save` command, an imported `.cryptenv-proj` template, and the CLI. `project.name` gets a laxer deny-list instead (rejects separators, control characters, NTFS-hostile characters, reserved device names, leading/trailing dot or whitespace; allows spaces and non-ASCII letters; 128 chars max), enforced the same way in `project::save_project`. Rejection is `422 VALIDATION_ERROR`. Existing rows created before this validation shipped are **not** migrated or rejected at read time — they keep working (see the belt-and-braces containment below) and only get corrected the next time the row is edited. A second, independent layer (`fsguard::resolve_within`) additionally guarantees that no write derived from an environment name — validated or legacy — can land outside the caller-supplied `output_dir` on `/fill`, `/environments/:id/example`, or `project::inject_environment`'s default-filename branch; a name that somehow escapes both is a bug in this layer, not an accepted risk. + +**Known, deferred issues** (found in review, not fixed in this pass): (1) `/fill`, `/environments/:id/inject`, and `/environments/:id/example` all write via a plain `std::fs::write` to `output_path` with no existence check — pointing one at a real, unrelated file truncates it (issue #8). (2) `POST /items` on a key that already exists in the environment creates a new item row and repoints the link, orphaning (not deleting) the previous item — repeated `add`-equivalent calls grow the vault unboundedly and "rotating" a secret this way doesn't actually remove the old value. --- @@ -149,7 +151,7 @@ Every command that reads or writes vault items scoped to a project (`add`, `fill `add` on a key that already exists in the resolved environment creates a new item row and repoints the environment-var link to it, rather than updating the existing item in place — the superseded item is orphaned (still in the vault, still decryptable via `/items/:id`, included in exports/backups) rather than deleted. Not fixed this pass. -A crafted environment name (created via the GUI or with the static MCP token — CLI-driven `crypt-env.json`/cwd-derived names can't produce this) combined with `--project`/`--env` resolving to it can path-traverse `fill`'s/`project inject`'s `output_dir`-derived path outside the intended directory. Not fixed this pass. +A crafted environment name (created via the GUI or with the static MCP token — CLI-driven `crypt-env.json`/cwd-derived names can't produce this) combined with `--project`/`--env` resolving to it could previously path-traverse `fill`'s/`project inject`'s `output_dir`-derived path outside the intended directory — fixed by issue #7 (see the "Environment/project name validation" note above): the name charset is now enforced on write, and `fsguard::resolve_within` independently contains any legacy row that predates the check. `project list` shows all projects with nested environment details. Projects with no environments display "(none)" for environment and var count. diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index f4431b1..991226a 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -15,6 +15,7 @@ use zeroize::Zeroizing; use crate::crypto; use crate::db::{DbCategory, DbWorkspaceVar}; +use crate::fsguard; use crate::project::{self, EnvironmentInput, ProjectInput}; use crate::share::{ShareState, ShareSessionState}; use crate::share::relay; @@ -1338,6 +1339,51 @@ async fn handle_fill( Err(resp) => return resp, }; + // Resolve and validate the write target *before* any decryption happens + // (issue #7, objective 3): on rejection, no plaintext has been produced + // for this request, on disk or in memory. + // + // `output_path` is exact caller intent for *this* request and is passed + // through verbatim — validating it is issue #8's territory (clobber / + // no-clobber), not this one. `output_dir` only ever decides the + // *filename* inside it, and that filename is derived from the + // environment's `name` — untrusted, persisted data — so it goes through + // `fsguard::resolve_within`, which guarantees the result cannot land + // outside the caller-supplied directory. + let write_target: Option = if let Some(out) = body.output_path.as_deref() { + Some(PathBuf::from(out)) + } else if let Some(dir) = body.output_dir.as_deref() { + match fsguard::resolve_within(dir, &format!(".env.{}", env.name)) { + Ok(p) => Some(p), + Err(fsguard::ContainmentError::BaseUnusable(msg)) => { + return err_json( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("cannot use output directory: {msg}"), + "INTERNAL_ERROR", + ) + .into_response(); + } + Err(e) => { + // Never echo the environment name or the resolved path (see + // plan §4/D5) — the id is enough to identify the row, and a + // rejection firing at all means a hostile name reached a + // sink and layer 2 caught it. + eprintln!( + "[api] /fill rejected: environment id {} — output_dir not contained ({e:?})", + env.id + ); + return err_json( + StatusCode::UNPROCESSABLE_ENTITY, + "output_dir: environment name does not resolve to a path contained within the requested directory", + "PATH_NOT_CONTAINED", + ) + .into_response(); + } + } + } else { + None + }; + let items = match decrypt_all_items(&state).await { Ok(i) => i, Err(StatusCode::FORBIDDEN) => { @@ -1416,33 +1462,29 @@ async fn handle_fill( filled.push('\n'); } - // Explicit output_path: write to exactly that file. No output_path but an - // output_dir: write to the default-filename-with-environment-suffix - // convention inside it. Neither: return content inline (unchanged). - let write_target = if let Some(out) = body.output_path { - Some(out) - } else if let Some(dir) = body.output_dir { - let dir = dir.trim_end_matches(['/', '\\']); - Some(format!("{dir}/.env.{}", env.name)) - } else { - None - }; - // When writing to disk: write via RAII guard, return stats only (no - // secret content in the response). + // secret content in the response). `write_target` was already resolved + // and validated above, before decryption. // // The guard zeros and deletes the file if any error occurs before persist(). // On success, persist() disarms the guard so the caller can consume the file. - if let Some(out) = write_target { - let path = std::path::PathBuf::from(&out); - if let Some(parent) = path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - return err_json( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("cannot create directory: {e}"), - "INTERNAL_ERROR", - ) - .into_response(); + if let Some(path) = write_target { + // Only the explicit `output_path` branch needs a directory created + // here: it is exact caller intent with no interpolated name in it. + // The `output_dir` branch's base was already created inside + // `fsguard::resolve_within` above, on the caller-supplied directory + // alone (issue #7, objective 4 — no `create_dir_all` ever sees a + // path with an interpolated name in it). + if body.output_path.is_some() { + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + return err_json( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("cannot create directory: {e}"), + "INTERNAL_ERROR", + ) + .into_response(); + } } } let guard = match TempEnvFile::create(path, &filled) { @@ -1962,6 +2004,13 @@ async fn handle_save_project( err_json(StatusCode::CONFLICT, "a project with this name already exists", "CONFLICT") .into_response() } + // `project::save_project` runs `validate_project_name` as its first + // statement (issue #7's choke point) and prefixes its message with + // "name: " on failure — surface that as a caller error, not a + // server fault. + Err(e) if e.starts_with("name: ") => { + err_json(StatusCode::UNPROCESSABLE_ENTITY, &e, "VALIDATION_ERROR").into_response() + } Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, &e, "INTERNAL_ERROR").into_response(), } } @@ -2058,6 +2107,13 @@ async fn handle_save_environment( let status = if is_new { StatusCode::CREATED } else { StatusCode::OK }; (status, Json(serde_json::json!({ "id": id }))).into_response() } + // `project::save_environment` runs `validate_environment_name` as + // its first statement (issue #7's choke point — the same check the + // Tauri command, CLI, and an imported `.cryptenv-proj` template all + // go through) and prefixes its message with "name: " on failure. + Err(e) if e.starts_with("name: ") => { + err_json(StatusCode::UNPROCESSABLE_ENTITY, &e, "VALIDATION_ERROR").into_response() + } Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, &e, "INTERNAL_ERROR").into_response(), } } @@ -2192,26 +2248,55 @@ async fn handle_environment_example( // values are never read or decrypted here. let content = keys.iter().map(|k| format!("{k}=")).collect::>().join("\n") + "\n"; - let write_target = if let Some(p) = body.output_path { - Some(p) - } else if let Some(dir) = body.output_dir { - let dir = dir.trim_end_matches(['/', '\\']); - Some(format!("{dir}/.env.example.{}", env.name)) - } else { - None - }; - - if let Some(out) = write_target { - let path = std::path::PathBuf::from(&out); - if let Some(parent) = path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { + // Same containment split as `/fill` (issue #7): `output_path` is exact + // caller intent, passed through verbatim; `output_dir` only picks the + // filename inside it, and that filename is derived from the untrusted, + // persisted environment `name`, so it goes through `fsguard`. Content + // here is placeholder keys only (never decrypted), so there is no + // decrypt-ordering concern like `/fill`'s objective 3 — but the code + // shape is kept the same so the two sinks stay diff-comparable. + let write_target: Option = if let Some(p) = body.output_path.as_deref() { + Some(PathBuf::from(p)) + } else if let Some(dir) = body.output_dir.as_deref() { + match fsguard::resolve_within(dir, &format!(".env.example.{}", env.name)) { + Ok(p) => Some(p), + Err(fsguard::ContainmentError::BaseUnusable(msg)) => { return err_json( StatusCode::INTERNAL_SERVER_ERROR, - &format!("cannot create directory: {e}"), + &format!("cannot use output directory: {msg}"), "INTERNAL_ERROR", ) .into_response(); } + Err(e) => { + eprintln!( + "[api] /environments/{{id}}/example rejected: environment id {} — output_dir not contained ({e:?})", + env.id + ); + return err_json( + StatusCode::UNPROCESSABLE_ENTITY, + "output_dir: environment name does not resolve to a path contained within the requested directory", + "PATH_NOT_CONTAINED", + ) + .into_response(); + } + } + } else { + None + }; + + if let Some(path) = write_target { + if body.output_path.is_some() { + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + return err_json( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("cannot create directory: {e}"), + "INTERNAL_ERROR", + ) + .into_response(); + } + } } // Plain write is fine here (no RAII zero-wipe needed) — the content // contains only key names, never a secret value. @@ -2223,7 +2308,8 @@ async fn handle_environment_example( ) .into_response(); } - return (StatusCode::OK, Json(ExampleResponse { content: None, path: Some(out), keys })).into_response(); + let resolved = path.to_string_lossy().into_owned(); + return (StatusCode::OK, Json(ExampleResponse { content: None, path: Some(resolved), keys })).into_response(); } (StatusCode::OK, Json(ExampleResponse { content: Some(content), path: None, keys })).into_response() diff --git a/src-tauri/src/fsguard/mod.rs b/src-tauri/src/fsguard/mod.rs new file mode 100644 index 0000000..68c287c --- /dev/null +++ b/src-tauri/src/fsguard/mod.rs @@ -0,0 +1,313 @@ +//! Filesystem containment guard. +//! +//! Dependency-free leaf module (imports only `std`) — see issue #7 and +//! `docs/plans/issue-7-path-traversal-environment-name.md` §4/D7 for why this +//! lives on its own rather than inside `api` or `project`: it is needed by +//! both and has no dependencies of its own, so a leaf module is the only +//! shape that does not create a sideways dependency between peers. +//! +//! This module answers exactly one question: *given a caller-trusted base +//! directory and an untrusted single filename, what is the one real path +//! that filename is allowed to resolve to?* It never decides whether a write +//! to that path should proceed (see #8's `guarded_write`, which composes on +//! top of this). + +use std::fs; +use std::path::{Component, Path, PathBuf}; + +/// Why `resolve_within` refused to produce a path. `Display` on this type +/// describes the rule that was broken, never the input that broke it — the +/// input is attacker-controlled stored data (see plan §4/D5). +#[derive(Debug, PartialEq, Eq)] +pub enum ContainmentError { + /// `file_name` was empty or whitespace-only. + EmptyName, + /// `file_name` did not lexically resolve to exactly one `Normal` path + /// component — separators, `.`, `..`, an absolute/UNC/verbatim prefix, + /// or a Windows drive-relative form (`C:foo`) all land here. + NotASingleComponent, + /// `file_name` contained a NUL byte or another control character. + NulByte, + /// `file_name` is (ignoring an extension) a Windows reserved device + /// name: `CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`. + ReservedDeviceName, + /// `file_name` ends with a trailing `.` or space, contains `:` (NTFS + /// alternate data streams), or contains one of the other Win32-illegal + /// characters `< > " | ? *`. + TrailingDotOrSpace, + /// The base directory itself could not be created or canonicalized. + /// Carries the raw io error text — this is about the caller-supplied + /// base, never about the untrusted name, so it is safe to surface. + BaseUnusable(String), + /// The joined (or, for an existing target, re-canonicalized) path did + /// not stay under the canonicalized base. This is the belt-and-braces + /// check: reaching it means every lexical rule above already passed and + /// something else — most plausibly a pre-planted symlink — is trying to + /// redirect the write. + Escapes, +} + +impl std::fmt::Display for ContainmentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ContainmentError::EmptyName => { + write!(f, "file name must not be empty or whitespace-only") + } + ContainmentError::NotASingleComponent => write!( + f, + "file name must be a single path component: no separators, no '.', no '..', and no drive/UNC/verbatim prefix" + ), + ContainmentError::NulByte => { + write!(f, "file name must not contain a NUL byte or control characters") + } + ContainmentError::ReservedDeviceName => write!( + f, + "file name must not be a reserved device name (CON, PRN, AUX, NUL, COM1-9, LPT1-9)" + ), + ContainmentError::TrailingDotOrSpace => write!( + f, + "file name must not end with a trailing '.' or space, and must not contain ':', '<', '>', '\"', '|', '?', or '*'" + ), + ContainmentError::BaseUnusable(msg) => write!(f, "base directory is not usable: {msg}"), + ContainmentError::Escapes => write!(f, "resolved path escapes the base directory"), + } + } +} + +const RESERVED_DEVICE_NAMES: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + +fn is_reserved_device_name(file_name: &str) -> bool { + // Reserved names apply to the base name (before the first '.'), both + // bare and with any extension — `NUL`, `nul.txt` and `Nul.tar.gz` are + // all the same reserved device on Windows. + let base = file_name.split('.').next().unwrap_or(file_name); + RESERVED_DEVICE_NAMES.iter().any(|r| r.eq_ignore_ascii_case(base)) +} + +/// Resolves `file_name` as a direct child of `base_dir`, guaranteeing the +/// result cannot be anywhere else. `file_name` is treated as a literal +/// filename: it is never decoded, unescaped, or normalized. +/// +/// Steps, in order (see the plan for the full rationale of each): +/// 1. Reject empty/whitespace, NUL, control characters. +/// 2. Lexical single-component check (rejects `..`, `.`, absolute/UNC/ +/// verbatim/drive-relative forms) plus an explicit separator check that +/// also catches `\`-based traversal on platforms where `Path` does not +/// treat `\` as a separator. +/// 3. Windows-hostile-name check, applied on every platform: reserved +/// device names, trailing dot/space, NTFS alternate-data-stream `:`, and +/// the rest of the Win32-illegal character set. +/// 4. Create and canonicalize the base — the only `create_dir_all` call in +/// this function, and its argument is `base_dir` alone, never anything +/// with `file_name` interpolated into it. +/// 5. Join and assert the result is component-wise contained in the +/// canonicalized base. +/// 6. If the target already exists, re-canonicalize it and re-assert +/// containment — catches a pre-planted symlink. +pub fn resolve_within(base_dir: &str, file_name: &str) -> Result { + // 1. Empty / whitespace / NUL / control characters. + if file_name.trim().is_empty() { + return Err(ContainmentError::EmptyName); + } + if file_name.contains('\0') || file_name.chars().any(|c| c.is_control()) { + return Err(ContainmentError::NulByte); + } + + // 2. Lexical single-component check. + let components: Vec = Path::new(file_name).components().collect(); + let is_single_normal = components.len() == 1 && matches!(components[0], Component::Normal(_)); + if !is_single_normal { + return Err(ContainmentError::NotASingleComponent); + } + // Belt: on Unix, `Path` does not treat `\` as a separator, so + // `..\..\x` would otherwise parse as one `Normal` component. + if file_name.contains('/') || file_name.contains('\\') { + return Err(ContainmentError::NotASingleComponent); + } + + // 3. Windows-hostile-name check, applied on every platform. + if is_reserved_device_name(file_name) { + return Err(ContainmentError::ReservedDeviceName); + } + if file_name.ends_with('.') || file_name.ends_with(' ') { + return Err(ContainmentError::TrailingDotOrSpace); + } + if file_name.contains(':') || file_name.chars().any(|c| matches!(c, '<' | '>' | '"' | '|' | '?' | '*')) { + return Err(ContainmentError::TrailingDotOrSpace); + } + + // 4. Base resolution. This is the only `create_dir_all` in the whole + // flow, and it never sees `file_name`. + fs::create_dir_all(base_dir).map_err(|e| ContainmentError::BaseUnusable(e.to_string()))?; + let real_base = fs::canonicalize(base_dir).map_err(|e| ContainmentError::BaseUnusable(e.to_string()))?; + + // 5. Join and verify. `Path::starts_with` compares whole components, so + // it cannot be fooled by string-prefix tricks (`/base` vs `/base-evil`). + let target = real_base.join(file_name); + if !target.starts_with(&real_base) { + return Err(ContainmentError::Escapes); + } + + // 6. Symlink post-check: if something already sits at `target` (e.g. a + // pre-planted symlink from an earlier attack attempt), re-resolve it and + // re-assert containment. If it does not exist, step 4 already proved + // the parent directory is real and step 5's join is authoritative. + if target.exists() { + match fs::canonicalize(&target) { + Ok(resolved) if resolved.starts_with(&real_base) => {} + _ => return Err(ContainmentError::Escapes), + } + } + + Ok(target) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Pure-lexical cases only — no filesystem I/O. See + // `tests/path_containment.rs` for the filesystem-level invariants + // (base creation, symlink escapes, the malicious-name table, etc). + + #[test] + fn rejects_empty_and_whitespace() { + assert_eq!( + resolve_within("/tmp", "").unwrap_err(), + ContainmentError::EmptyName + ); + assert_eq!( + resolve_within("/tmp", " ").unwrap_err(), + ContainmentError::EmptyName + ); + } + + #[test] + fn rejects_nul_and_control_chars() { + assert_eq!( + resolve_within("/tmp", "prod\0evil").unwrap_err(), + ContainmentError::NulByte + ); + assert_eq!( + resolve_within("/tmp", "prod\nevil").unwrap_err(), + ContainmentError::NulByte + ); + } + + #[test] + fn rejects_parent_and_current_dir() { + assert_eq!( + resolve_within("/tmp", "..").unwrap_err(), + ContainmentError::NotASingleComponent + ); + assert_eq!( + resolve_within("/tmp", ".").unwrap_err(), + ContainmentError::NotASingleComponent + ); + assert_eq!( + resolve_within("/tmp", "../../../tmp/pwned").unwrap_err(), + ContainmentError::NotASingleComponent + ); + } + + #[test] + fn rejects_windows_traversal_even_on_unix() { + // `\` is not a separator to `Path` on Unix, so without the explicit + // belt check this would otherwise parse as one `Normal` component. + assert_eq!( + resolve_within("/tmp", "..\\..\\..\\Windows\\Temp\\pwned").unwrap_err(), + ContainmentError::NotASingleComponent + ); + } + + #[test] + fn rejects_absolute_paths() { + assert_eq!( + resolve_within("/tmp", "/etc/cron.d/pwned").unwrap_err(), + ContainmentError::NotASingleComponent + ); + } + + #[test] + fn rejects_windows_absolute_and_unc_and_verbatim() { + assert_eq!( + resolve_within("/tmp", "C:\\Windows\\Temp\\pwned").unwrap_err(), + ContainmentError::NotASingleComponent + ); + assert_eq!( + resolve_within("/tmp", "\\\\wsl.localhost\\Ubuntu\\home\\u\\pwned").unwrap_err(), + ContainmentError::NotASingleComponent + ); + assert_eq!( + resolve_within("/tmp", "\\\\?\\C:\\pwned").unwrap_err(), + ContainmentError::NotASingleComponent + ); + } + + #[test] + fn rejects_drive_relative() { + // On Unix this lexically parses as one `Normal` component (no + // separator, `Path` does not know about drive letters) — the + // Windows-hostile-name check's `:` rejection is what actually + // catches it there, which is why the assertion is just "is_err". + assert!(resolve_within("/tmp", "C:pwned").is_err()); + } + + #[test] + fn rejects_reserved_device_names() { + for name in ["CON", "NUL", "COM1", "LPT1", "con.txt", "Nul.tar.gz"] { + assert_eq!( + resolve_within("/tmp", name).unwrap_err(), + ContainmentError::ReservedDeviceName, + "expected {name} to be rejected as a reserved device name" + ); + } + } + + #[test] + fn rejects_trailing_dot_or_space() { + assert_eq!( + resolve_within("/tmp", "prod.").unwrap_err(), + ContainmentError::TrailingDotOrSpace + ); + assert_eq!( + resolve_within("/tmp", "prod ").unwrap_err(), + ContainmentError::TrailingDotOrSpace + ); + } + + #[test] + fn rejects_ads_and_illegal_chars() { + assert_eq!( + resolve_within("/tmp", "env:stream").unwrap_err(), + ContainmentError::TrailingDotOrSpace + ); + for name in ["ab", "a\"b", "a|b", "a?b", "a*b"] { + assert_eq!( + resolve_within("/tmp", name).unwrap_err(), + ContainmentError::TrailingDotOrSpace, + "expected {name} to be rejected" + ); + } + } + + #[test] + fn does_not_decode_percent_or_unicode_lookalikes() { + // These must be treated as literal filenames. They are not path + // separators or control characters, so — as documented in the + // plan's T1 — a single-component result contained under the base is + // an acceptable outcome for this layer; silently *decoding* them is + // the actually-forbidden behaviour, and that would show up as a + // `NotASingleComponent` rejection here, which none of these trigger. + for name in ["%2e%2e%2fpwned", "..%c0%af..%c0%afpwned", "../pwned"] { + let result = resolve_within("/tmp", name); + assert!( + result.is_err() || matches!(&result, Ok(p) if p.parent().is_some()), + "unexpected outcome for {name}: {result:?}" + ); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9792598..6fa13a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod biometric; pub mod cli; pub mod crypto; pub mod db; +pub mod fsguard; pub mod mcp; pub mod project; pub mod share; diff --git a/src-tauri/src/project/mod.rs b/src-tauri/src/project/mod.rs index 54be057..9720b67 100644 --- a/src-tauri/src/project/mod.rs +++ b/src-tauri/src/project/mod.rs @@ -142,9 +142,104 @@ async fn category_names_to_ids(db: &VaultDb, names: &[String]) -> Result Result<(), String> { + if name.trim().is_empty() { + return Err("must not be empty or whitespace-only".to_string()); + } + if name.contains('\0') || name.chars().any(|c| c.is_control()) { + return Err("must not contain control characters or a NUL byte".to_string()); + } + if name.contains('/') || name.contains('\\') { + return Err("must not contain '/' or '\\'".to_string()); + } + if name == "." || name == ".." { + return Err("must not be '.' or '..'".to_string()); + } + if name.contains(':') || name.chars().any(|c| matches!(c, '<' | '>' | '"' | '|' | '?' | '*')) { + return Err("must not contain ':', '<', '>', '\"', '|', '?', or '*'".to_string()); + } + if name.ends_with('.') || name.ends_with(' ') { + return Err("must not end with a trailing '.' or space".to_string()); + } + const RESERVED: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + ]; + let base = name.split('.').next().unwrap_or(name); + if RESERVED.iter().any(|r| r.eq_ignore_ascii_case(base)) { + return Err("must not be a reserved device name (CON, PRN, AUX, NUL, COM1-9, LPT1-9)".to_string()); + } + Ok(()) +} + +/// Strict allowlist: `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, plus an explicit +/// rejection of a trailing `.` or `-`. Environment names are machine +/// identifiers, land in a filename (`.env.`), and are the field with +/// the proven traversal exploit (issue #7) — the tight rule costs nothing +/// real for names like `production`, `local`, `staging-2`. +pub fn validate_environment_name(name: &str) -> Result<(), String> { + const RULE: &str = + "must be 1-64 chars, start with a letter or digit, and contain only letters, digits, '.', '_' or '-'"; + if reject_filesystem_hostile(name).is_err() { + return Err(format!("name: {RULE}")); + } + let starts_alnum = name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric()); + let charset_ok = name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); + let valid = !name.is_empty() + && name.len() <= 64 + && starts_alnum + && charset_ok + && !name.ends_with('.') + && !name.ends_with('-'); + if valid { + Ok(()) + } else { + Err(format!("name: {RULE}")) + } +} + +/// Deny-list, deliberately laxer than the environment rule: rejects the +/// filesystem-hostile core above, plus a length cap and leading dot/ +/// whitespace. Allows spaces and non-ASCII letters — project names are human +/// labels ("Mi Proyecto" is a perfectly normal edit to an existing project) +/// and an ASCII-only allowlist would reject real edits for no present +/// security gain (see plan §4/D2). +pub fn validate_project_name(name: &str) -> Result<(), String> { + if let Err(reason) = reject_filesystem_hostile(name) { + return Err(format!("name: {reason}")); + } + if name.chars().count() > 128 { + return Err("name: must be 128 characters or fewer".to_string()); + } + if name.starts_with('.') || name.starts_with(' ') { + return Err("name: must not start with '.' or whitespace".to_string()); + } + Ok(()) +} + /// Creates (id = 0) or updates (id > 0) a project's metadata. A newly created /// project always gets one 'default' environment so it's immediately usable. pub async fn save_project(db: &VaultDb, input: ProjectInput) -> Result { + validate_project_name(&input.name)?; let is_new = input.id == 0; let project_id = db .upsert_project(input.id, &input.name, input.description.as_deref(), &input.template) @@ -170,6 +265,7 @@ pub async fn project_delete_preview(db: &VaultDb, id: i64) -> Result Result { + validate_environment_name(&input.name)?; let env_id = db .upsert_environment(input.id, input.project_id, &input.name, input.is_default) .await?; @@ -284,8 +380,14 @@ pub async fn inject_environment( } } else if paths.is_empty() { if let Some(dir) = output_dir { - let dir = dir.trim_end_matches(['/', '\\']); - paths.push(format!("{dir}/.env.{}", env.name)); + // Contained resolution (issue #7): the environment name is + // stored, untrusted data — it may only pick the filename inside + // `dir`, never redirect the write elsewhere. No `create_dir_all` + // is added here beyond what `resolve_within` itself does on the + // caller-supplied base. + let target = crate::fsguard::resolve_within(&dir, &format!(".env.{}", env.name)) + .map_err(|e| format!("output_dir: {e}"))?; + paths.push(target.to_string_lossy().into_owned()); } } diff --git a/src-tauri/tests/path_containment.rs b/src-tauri/tests/path_containment.rs new file mode 100644 index 0000000..d22a2ff --- /dev/null +++ b/src-tauri/tests/path_containment.rs @@ -0,0 +1,344 @@ +// Integration tests for issue #7 — path traversal via environment name +// escapes `output_dir` on `/fill`, `/environments/{}/example` and inject. +// See docs/plans/issue-7-path-traversal-environment-name.md. +// +// Issue #11's HTTP test harness (`crate::test_support`, `TestVault`) has not +// landed on this branch, so the HTTP-level cases from the plan's §5 (T3's +// `/fill` secret non-exfiltration scan and T8's error-shape assertions) are +// not implemented here. Per the plan's own stated fallback: the non-HTTP +// cases below still cover objectives 1, 2, 4 and 6. `project::inject_environment` +// stands in as the one non-HTTP-reachable sink (`api::handle_fill` and +// `api::handle_environment_example` are private to the `api` module and only +// reachable through the axum router); all three sinks share the exact same +// `fsguard::resolve_within` call, which is exercised directly below. +// +// Run with: cargo test --test path_containment + +use crypt_env_lib::db::VaultDb; +use crypt_env_lib::fsguard; +use crypt_env_lib::project::{self, EnvironmentInput, EnvironmentVar}; +use std::fs; +use tempfile::tempdir; + +/// At least 17 malicious names (plan §5/T1). Covers `../` runs, `..\` runs, +/// bare `..`/`.`, POSIX/Windows absolute, UNC, verbatim prefix, drive- +/// relative, NUL byte, percent-encoded and overlong-UTF-8-looking, Unicode +/// look-alikes, Windows reserved device names, trailing dot/space, and NTFS +/// alternate-data-stream. Over-length and empty/whitespace are covered +/// separately below (not naturally `&'static str` table entries). +const MALICIOUS: &[&str] = &[ + "../../../tmp/pwned", // POSIX traversal (the issue's repro) + "..\\..\\..\\Windows\\Temp\\pwned", // Windows traversal + "..", // bare parent + ".", // bare current + "/etc/cron.d/pwned", // POSIX absolute + "C:\\Windows\\Temp\\pwned", // Windows absolute + "C:pwned", // Windows drive-relative + "\\\\wsl.localhost\\Ubuntu\\home\\u\\pwned", // UNC + "\\\\?\\C:\\pwned", // verbatim prefix + "prod\0evil", // NUL byte + "%2e%2e%2fpwned", // percent-encoded + "..%c0%af..%c0%afpwned", // overlong-UTF-8-looking + "‥/pwned", // Unicode look-alike (U+2025) + "../pwned", // Unicode look-alike (fullwidth) + "CON", // reserved device name + "NUL", // reserved device name + "COM1", // reserved device name + "LPT1", // reserved device name + "prod.", // trailing dot + "prod ", // trailing space + "env:stream", // NTFS ADS +]; + +fn overlength_name() -> String { + "a".repeat(65) +} + +// ─── T1 — the malicious table (objectives 1 and 2) ──────────────────────────── + +#[test] +fn t1_malicious_table_rejected_by_both_layers() { + let dir = tempdir().unwrap(); + let base = dir.path().to_str().unwrap(); + + for name in MALICIOUS { + assert!( + project::validate_environment_name(name).is_err(), + "layer 1 (validate_environment_name) should reject {name:?}" + ); + + // Note on the percent-encoded / overlong-UTF-8-looking / Unicode + // look-alike entries: these must be treated as literal filenames, + // never decoded. Both outcomes below are acceptable per the plan — + // rejection, or a path that resolves but stays a direct child of + // the canonicalized base. Silently *decoding* them into a real `..` + // is the only forbidden outcome, and that would surface as an + // escaped/non-child path, which the assertions below would catch. + match fsguard::resolve_within(base, &format!(".env.{name}")) { + Err(_) => {} + Ok(p) => { + let canon = fs::canonicalize(base).unwrap(); + assert!(p.starts_with(&canon), "escaped base for {name:?}: {p:?}"); + assert_eq!( + p.parent(), + Some(canon.as_path()), + "not a direct child of base for {name:?}: {p:?}" + ); + } + } + } + + // Over-length (>64), tested separately since `String` can't live in a + // `&'static [&str]` table. + let long = overlength_name(); + assert!(project::validate_environment_name(&long).is_err(), "layer 1 should reject over-length names"); + assert!( + fsguard::resolve_within(base, &format!(".env.{long}")).is_err() + || fsguard::resolve_within(base, &format!(".env.{long}")) + .unwrap() + .starts_with(fs::canonicalize(base).unwrap()), + "layer 2 must at least contain an over-length name if it doesn't reject it" + ); + + // Empty / whitespace-only. + for empty in ["", " "] { + assert!(project::validate_environment_name(empty).is_err(), "layer 1 should reject {empty:?}"); + assert!(fsguard::resolve_within(base, empty).is_err(), "layer 2 should reject {empty:?}"); + } +} + +// ─── T2 analog — filesystem invariant (objectives 1 and 4) ──────────────────── +// +// The plan's T2 runs the table through all three HTTP-reachable sinks; here +// we run it through `fsguard::resolve_within` directly (the shared primitive +// behind all three) plus the one non-HTTP sink, `project::inject_environment`. + +#[tokio::test] +async fn t2_filesystem_invariant_no_amplification() { + let root = tempdir().unwrap(); + let base_dir = root.path().join("base"); + let sibling_dir = root.path().join("sibling"); + fs::create_dir_all(&base_dir).unwrap(); + fs::create_dir_all(&sibling_dir).unwrap(); + let canary_path = sibling_dir.join("canary.txt"); + fs::write(&canary_path, b"canary-untouched").unwrap(); + + let base = base_dir.to_str().unwrap(); + for name in MALICIOUS { + let _ = fsguard::resolve_within(base, &format!(".env.{name}")); + } + + // Sibling directory (this issue's `/tmp/pwned` stand-in) byte-identical. + assert_eq!( + fs::read(&canary_path).unwrap(), + b"canary-untouched", + "a sibling of the base must never be touched" + ); + + // No directory named `.env...` (or any other attacker-named directory) + // was created under base — kills the `create_dir_all` amplification + // specifically (objective 4). Only the base itself may exist; + // `fsguard::resolve_within` never creates anything beyond it. + let created_dirs: Vec = fs::read_dir(&base_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + created_dirs.is_empty(), + "no directories should ever be created under base by a rejected name, found: {created_dirs:?}" + ); +} + +#[tokio::test] +async fn t2_inject_environment_sink_no_amplification() { + let db_dir = tempdir().unwrap(); + let db_path = db_dir.path().join("vault.db").to_str().unwrap().to_string(); + let db = VaultDb::open(&db_path).await.unwrap(); + let project_id = db.upsert_project(0, "t2-project", None, "generic").await.unwrap(); + + let root = tempdir().unwrap(); + let base_dir = root.path().join("base"); + fs::create_dir_all(&base_dir).unwrap(); + let vault_key = [0u8; 32]; + + for name in MALICIOUS { + // Bypass layer 1 (`db::upsert_environment` directly) to exercise + // layer 2 in isolation, same as a legacy row would. `inject_environment` + // builds the filename as `.env.` (same convention as + // `/fill` and `/example`), so the stored name is used as-is here — + // matching how T1 exercises `fsguard::resolve_within` directly. + let env_id = match db.upsert_environment(0, project_id, name, false).await { + Ok(id) => id, + Err(_) => continue, // e.g. embedded NUL byte rejected by the DB driver itself + }; + let _ = project::inject_environment( + &db, + &vault_key, + env_id, + None, + Some(base_dir.to_str().unwrap().to_string()), + ) + .await; + } + + let created_dirs: Vec = fs::read_dir(&base_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + created_dirs.is_empty(), + "inject_environment must never create a directory under base via a hostile name, found: {created_dirs:?}" + ); +} + +// ─── T4 — choke-point coverage (objective 6) ─────────────────────────────────── +// +// Calls `project::save_environment` directly with no HTTP involved — this is +// the test that would have caught the original bug, where the HTTP handler +// was the only gate and the Tauri command path had none. + +#[tokio::test] +async fn t4_choke_point_rejects_at_save_environment_directly() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("vault.db").to_str().unwrap().to_string(); + let db = VaultDb::open(&db_path).await.unwrap(); + let project_id = db.upsert_project(0, "t4-project", None, "generic").await.unwrap(); + + let input = EnvironmentInput { + id: 0, + project_id, + name: "../../../tmp/pwned".to_string(), + is_default: false, + paths: vec![], + vars: Vec::::new(), + }; + + let result = project::save_environment(&db, input).await; + assert!(result.is_err(), "save_environment must reject a traversal name with no HTTP involved"); + assert!( + result.unwrap_err().starts_with("name: "), + "rejection must be a layer-1 validation error, not a downstream failure" + ); +} + +// ─── T5 — layer independence (objective 2) ───────────────────────────────────── +// +// Disabling either layer must leave the other's tests green. Implemented as +// two test fns that call each layer directly and in isolation, rather than +// by mutating production code. + +#[test] +fn t5_layer2_alone_still_contains_every_malicious_name() { + // Simulates "layer 1 disabled": call `fsguard::resolve_within` directly + // (as a legacy row that bypassed `validate_environment_name` would), + // without ever consulting layer 1. + let dir = tempdir().unwrap(); + let base = dir.path().to_str().unwrap(); + for name in MALICIOUS { + match fsguard::resolve_within(base, &format!(".env.{name}")) { + Err(_) => {} + Ok(p) => { + let canon = fs::canonicalize(base).unwrap(); + assert!(p.starts_with(&canon), "layer 2 alone failed to contain {name:?}: {p:?}"); + } + } + } +} + +#[test] +fn t5_layer1_alone_still_rejects_every_malicious_name() { + // Simulates "layer 2 disabled": call `validate_environment_name` in + // isolation, without ever consulting `fsguard`. + for name in MALICIOUS { + assert!( + project::validate_environment_name(name).is_err(), + "layer 1 alone failed to reject {name:?}" + ); + } +} + +// ─── T6 — positive path, no over-rejection (objective 5) ────────────────────── + +#[test] +fn t6_valid_names_are_accepted_and_resolve_as_expected() { + for name in ["production", "local", "staging-2", "v1.2", "a"] { + assert!(project::validate_environment_name(name).is_ok(), "{name:?} should be a valid environment name"); + } + let sixty_four = "a".repeat(64); + assert!(project::validate_environment_name(&sixty_four).is_ok(), "64 chars should be accepted"); + + let dir = tempdir().unwrap(); + let base = dir.path().to_str().unwrap(); + let resolved = fsguard::resolve_within(base, ".env.production").unwrap(); + let expected = fs::canonicalize(base).unwrap().join(".env.production"); + assert_eq!(resolved, expected, "objective 5: resolved path must be the canonicalized base joined with the filename"); +} + +// ─── T7 — legacy-name containment (D2) ───────────────────────────────────────── +// +// Inserts a row with a hostile name directly through `db::upsert_environment`, +// bypassing validation — simulating a pre-fix vault. Asserts `inject_environment` +// (the one non-HTTP sink) rejects it at layer 2, and that the environment +// remains listable and deletable — no vault bricking, per plan §4/D2. + +#[tokio::test] +async fn t7_legacy_hostile_name_contained_and_vault_stays_usable() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("vault.db").to_str().unwrap().to_string(); + let db = VaultDb::open(&db_path).await.unwrap(); + let project_id = db.upsert_project(0, "legacy-project", None, "generic").await.unwrap(); + + // Bypasses `project::validate_environment_name` entirely. + let env_id = db + .upsert_environment(0, project_id, "../../../tmp/pwned", false) + .await + .unwrap(); + + let output_root = tempdir().unwrap(); + let base_dir = output_root.path().join("base"); + fs::create_dir_all(&base_dir).unwrap(); + let vault_key = [0u8; 32]; + + let result = project::inject_environment( + &db, + &vault_key, + env_id, + None, + Some(base_dir.to_str().unwrap().to_string()), + ) + .await; + assert!(result.is_err(), "a legacy hostile name must still be rejected at layer 2"); + + // No amplification directory under base. + let has_amplified_dir = fs::read_dir(&base_dir) + .unwrap() + .filter_map(|e| e.ok()) + .any(|e| e.path().is_dir()); + assert!(!has_amplified_dir, "create_dir_all must never see the interpolated legacy name"); + + // Vault stays usable: the environment is still listable and deletable. + let envs = db.list_environments(project_id).await.unwrap(); + assert!(envs.iter().any(|e| e.id == env_id), "legacy environment must remain listable"); + db.delete_environment(env_id).await.unwrap(); + let envs_after = db.list_environments(project_id).await.unwrap(); + assert!(!envs_after.iter().any(|e| e.id == env_id), "legacy environment must remain deletable"); +} + +// ─── Project-name validation (D2) ────────────────────────────────────────────── +// +// Not one of the plan's numbered T-cases, but covers `validate_project_name` +// directly since `save_project` shares the same choke-point pattern. + +#[test] +fn project_name_allows_human_labels_rejects_hostile_ones() { + for name in ["My App", "Café Backend", "a", &"a".repeat(128)] { + assert!(project::validate_project_name(name).is_ok(), "{name:?} should be a valid project name"); + } + for name in ["../../../tmp/pwned", "CON", "a/b", "a\\b", "..", ".", "trailing.", "trailing ", &"a".repeat(129)] { + assert!(project::validate_project_name(name).is_err(), "{name:?} should be rejected"); + } +} diff --git a/src/components/ProjectManager.tsx b/src/components/ProjectManager.tsx index 0d2a167..2d8ee12 100644 --- a/src/components/ProjectManager.tsx +++ b/src/components/ProjectManager.tsx @@ -39,6 +39,33 @@ const TEMPLATES: { id: ProjectTemplate; label: string; vars: string[] }[] = [ const ENV_PRESETS = ['production', 'local', 'test', 'staging']; +// ─── Name validation (UX mirror only — see issue #7) ────────────────────────── +// +// These mirror `validate_environment_name` / `validate_project_name` in +// `src-tauri/src/project/mod.rs` purely to avoid a pointless round-trip to +// the server. The server is the actual enforcement point (reachable from +// the GUI, HTTP API, CLI, and an imported `.cryptenv-proj` template) — this +// copy may drift from it over time and that is an accepted risk, not a bug. + +const ENV_NAME_RULE = + "must be 1-64 chars, start with a letter or digit, and contain only letters, digits, '.', '_' or '-'"; + +function isValidEnvironmentName(name: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) && !name.endsWith('.') && !name.endsWith('-'); +} + +const PROJECT_NAME_RULE = + 'must not contain \'/\', \'\\\', control characters, \':\', \'<\', \'>\', \'"\', \'|\', \'?\', \'*\', or start/end with \'.\' or whitespace (128 chars max)'; + +function isValidProjectName(name: string): boolean { + if (name.trim().length === 0 || name.length > 128) return false; + if (name === '.' || name === '..') return false; + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f/\\:<>"|?*]/.test(name)) return false; + if (name.endsWith('.') || name.endsWith(' ') || name.startsWith('.') || name.startsWith(' ')) return false; + return true; +} + function TemplateModal({ onSelect, onClose, @@ -638,7 +665,7 @@ export function ProjectManager() { }; const handleSaveProject = async () => { - if (!projName.trim()) { showToast('Name is required', 'error'); return; } + if (!isValidProjectName(projName.trim())) { showToast(`Name ${PROJECT_NAME_RULE}`, 'error'); return; } setSaving(true); try { const id = await saveProject({ @@ -772,7 +799,7 @@ export function ProjectManager() { const handleSaveEnvironment = async () => { if (!selectedProject) return; - if (!envName.trim()) { showToast('Name is required', 'error'); return; } + if (!isValidEnvironmentName(envName.trim())) { showToast(`Name ${ENV_NAME_RULE}`, 'error'); return; } setSaving(true); try { await saveEnvironment({ @@ -976,6 +1003,9 @@ export function ProjectManager() { placeholder="my-project" className="w-full bg-bg border border-bd2 text-tx font-mono text-[13px] rounded-[3px] px-3 py-[7px] outline-none focus:border-accent-d transition-colors" /> + {projName.length > 0 && !isValidProjectName(projName.trim()) && ( +
{PROJECT_NAME_RULE}
+ )}
DESCRIPTION
@@ -1004,7 +1034,7 @@ export function ProjectManager() {
DESCRIPTION
@@ -1134,6 +1167,9 @@ export function ProjectManager() { default
+ {envName.length > 0 && !isValidEnvironmentName(envName.trim()) && ( +
{ENV_NAME_RULE}
+ )} {/* Paths */}
@@ -1257,7 +1293,7 @@ export function ProjectManager() { )} +
+ + ); +} + +function BtnSecondary({ children, onClick, disabled }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean }) { + return ( + + ); +} + +function InlineError({ msg }: { msg: string }) { + return ( +
+ {msg} +
+ ); +} + +export function ProjectShareModal({ mode, project, items, onClose, onReceived }: ProjectShareModalProps) { + const itemsById = useMemo(() => new Map(items.map((i) => [i.id, i])), [items]); + + // ── Send state ── + const [selectedEnvIds, setSelectedEnvIds] = useState>(() => { + if (!project || project.environments.length === 0) return new Set(); + const def = project.environments.find((e) => e.isDefault); + return new Set([def ? def.id : project.environments[0].id]); + }); + const [sending, setSending] = useState(false); + const [sendResult, setSendResult] = useState(null); + + // ── Receive state ── + const [code, setCode] = useState(''); + const [passphrase, setPassphrase] = useState(''); + const [overrideName, setOverrideName] = useState(''); + const [showRename, setShowRename] = useState(false); + const [receiving, setReceiving] = useState(false); + const [receiveResult, setReceiveResult] = useState(null); + + const [error, setError] = useState(''); + + const toggleEnv = (id: number) => { + setSelectedEnvIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const handleSend = async () => { + if (!project || selectedEnvIds.size === 0) return; + setSending(true); + setError(''); + try { + const result = await invoke('project_relay_send', { + projectId: project.id, + environmentIds: Array.from(selectedEnvIds), + }); + setSendResult(result); + } catch (e) { + setError(String(e)); + } finally { + setSending(false); + } + }; + + const handleReceive = async () => { + if (!code || !passphrase) return; + setReceiving(true); + setError(''); + try { + const result = await invoke('project_relay_receive', { + code: code.toUpperCase(), + passphrase, + projectNameOverride: overrideName.trim() || null, + }); + setReceiveResult(result); + onReceived?.(); + } catch (e) { + const msg = String(e); + if (msg.includes('conflict:')) { + setShowRename(true); + const match = msg.match(/'([^']+)'/); + if (match && !overrideName) setOverrideName(`${match[1]}-received`); + setError('A project with this name already exists here. Choose a different name below and try again.'); + } else { + setError(msg); + } + } finally { + setReceiving(false); + } + }; + + function renderSend() { + if (!project) return null; + + if (sendResult) { + return ( + <> +
+
+ +
+
Uploaded successfully
+
+
+ {sendResult.environmentCount} environment{sendResult.environmentCount !== 1 ? 's' : ''}, {sendResult.itemCount} item{sendResult.itemCount !== 1 ? 's' : ''} +
+ +
+ DONE +
+ + ); + } + + if (project.environments.length === 0) { + return ( + <> + +
+ CLOSE +
+ + ); + } + + return ( + <> +
+ Select which environments of {project.name} to share. + Non-default environments start unchecked — over-sharing is unrecoverable, under-sharing just costs one more send. +
+ +
+ {project.environments.map((env) => { + const checked = selectedEnvIds.has(env.id); + const prodLike = looksProduction(env.name); + return ( + + ); + })} +
+ + {selectedEnvIds.size > 0 && ( +
+
KEYS THAT WILL LEAVE THIS MACHINE (never values)
+
+ {project.environments + .filter((e) => selectedEnvIds.has(e.id)) + .map((env) => ( +
+
{env.name.toUpperCase()}
+ {env.vars.length === 0 ? ( +
no variables
+ ) : ( + env.vars.map((v) => ( +
+ {v.key} + {'->'} + {displayName(itemsById.get(v.itemId), v.itemId)} +
+ )) + )} +
+ ))} +
+
+ )} + + {error && } + +
+ CANCEL + + {sending ? 'UPLOADING…' : 'SHARE PROJECT'} + +
+ + ); + } + + function renderReceive() { + if (receiveResult) { + return ( + <> +
+
+ +
+
Project received
+
+
+ {receiveResult.project} — {receiveResult.itemCount} item{receiveResult.itemCount !== 1 ? 's' : ''} +
+
+ {receiveResult.environments.map((name) => ( +
+ + {name} +
+ ))} +
+
+ Received items are owned by this project only (not global). Set paths on each environment before injecting. +
+
+ DONE +
+ + ); + } + + return ( + <> +
+ Enter the code and passphrase from the sender. This always creates a new project — it never merges into an existing one. +
+
+ + setCode(e.target.value.toUpperCase())} + placeholder="XXXX-XXXX" + maxLength={9} + className="w-full h-9 bg-raised border border-bd2 rounded-[3px] px-3 text-[15px] font-mono text-accent tracking-[0.2em] placeholder:text-tx3 outline-none focus:border-accent transition-colors" + /> +
+
+ + setPassphrase(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && !showRename) handleReceive(); }} + className="w-full h-9 bg-raised border border-bd2 rounded-[3px] px-3 text-[13px] font-mono text-tx placeholder:text-tx3 outline-none focus:border-accent transition-colors" + /> +
+ + {showRename && ( +
+ + setOverrideName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleReceive(); }} + className="w-full h-9 bg-raised border border-bd2 rounded-[3px] px-3 text-[13px] font-mono text-tx placeholder:text-tx3 outline-none focus:border-accent transition-colors" + /> +
+ )} + + {error && } + +
+ CANCEL + + {receiving ? 'DOWNLOADING…' : 'RECEIVE'} + +
+ + ); + } + + return ( +
+
+
+
+ + + {mode === 'send' ? 'SHARE PROJECT' : 'RECEIVE PROJECT'} + +
+ +
+ + {mode === 'send' ? renderSend() : renderReceive()} +
+
+ ); +} diff --git a/src/components/ShareModal.tsx b/src/components/ShareModal.tsx index 6d8ec89..04455b1 100644 --- a/src/components/ShareModal.tsx +++ b/src/components/ShareModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { Icon } from './ui/Icon'; +import { RelayCodeDisplay } from './ui/RelayCodeDisplay'; // --------------------------------------------------------------------------- // Types @@ -338,8 +339,6 @@ export function ShareModal({ selectedIds, onClose, onImportDone, onSendDone }: S const [relayRxPass, setRelayRxPass] = useState(''); const [relayRxLoading, setRelayRxLoading] = useState(false); const [relayRxNames, setRelayRxNames] = useState([]); - const [copiedRelayCode, setCopiedRelayCode] = useState(false); - const [copiedRelayPass, setCopiedRelayPass] = useState(false); // Error state const [error, setError] = useState(''); @@ -1051,8 +1050,6 @@ export function ShareModal({ selectedIds, onClose, onImportDone, onSendDone }: S } function renderInternetDoneSend() { - const copyCode = () => { navigator.clipboard.writeText(relayCode); setCopiedRelayCode(true); setTimeout(() => setCopiedRelayCode(false), 2000); }; - const copyPass = () => { navigator.clipboard.writeText(relayPassphrase); setCopiedRelayPass(true); setTimeout(() => setCopiedRelayPass(false), 2000); }; return ( <> @@ -1065,31 +1062,8 @@ export function ShareModal({ selectedIds, onClose, onImportDone, onSendDone }: S Send BOTH to your teammate via any channel -
-
CODE
-
- {relayCode} - -
-
- -
-
PASSPHRASE
-
- {relayPassphrase} - -
-
+ -

- The relay link expires in 24 hours and is destroyed after first use. Never share code + passphrase in the same message. -

{ onSendDone?.(); onClose(); }}>DONE diff --git a/src/components/ui/RelayCodeDisplay.tsx b/src/components/ui/RelayCodeDisplay.tsx new file mode 100644 index 0000000..cf23f7a --- /dev/null +++ b/src/components/ui/RelayCodeDisplay.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { Icon } from './Icon'; + +/** + * Two-box "code" + "passphrase" display with copy buttons, used after a + * successful relay upload. Extracted out of `ShareModal.tsx`'s internet-send + * "done" step so `ProjectShareModal.tsx` (whole-project relay share, issue + * #4) doesn't duplicate the same markup — behavior is unchanged from the + * original inline version. + */ +export function RelayCodeDisplay({ code, passphrase }: { code: string; passphrase: string }) { + const [copiedCode, setCopiedCode] = useState(false); + const [copiedPass, setCopiedPass] = useState(false); + + const copyCode = () => { + navigator.clipboard.writeText(code); + setCopiedCode(true); + setTimeout(() => setCopiedCode(false), 2000); + }; + const copyPass = () => { + navigator.clipboard.writeText(passphrase); + setCopiedPass(true); + setTimeout(() => setCopiedPass(false), 2000); + }; + + return ( + <> +
+
CODE
+
+ {code} + +
+
+ +
+
PASSPHRASE
+
+ {passphrase} + +
+
+ +

+ The relay link expires in 24 hours and is destroyed after first use. Never share code + passphrase in the same message. +

+ + ); +} From 350f8eef67dfe7d6f70082f60efe74d1f2a046f3 Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Wed, 5 Aug 2026 15:27:34 -0500 Subject: [PATCH 13/14] ci(test): run the integration suites under tests/, not just --lib --bins The five branches in this batch add four integration targets under src-tauri/tests/ (environment_naming, path_containment, project_relay, vault_integration). `cargo test --lib --bins` builds none of them, so they would have been invisible to CI while appearing to be covered. Refs #11 --- .github/workflows/test.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f45960b..f619ee3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,9 +31,13 @@ jobs: libsoup-3.0-dev \ build-essential - - name: Run tests (lib + bins) + # `--tests` is required, not cosmetic: the integration suites under + # src-tauri/tests/ (environment_naming, path_containment, + # project_relay, vault_integration) are separate targets that + # `--lib --bins` does not build or run. + - name: Run tests (lib + bins + integration) working-directory: src-tauri - run: cargo test --lib --bins --no-fail-fast + run: cargo test --lib --bins --tests --no-fail-fast - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov From c11ea74eae04cf32e34288bc464ec369ae0d9bc5 Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Wed, 5 Aug 2026 16:34:14 -0500 Subject: [PATCH 14/14] refactor(db): drop bare unwrap() in link_item_to_environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md forbids unwrap() in production code. The occupied-key branch re-unwrapped the `current` row to read is_global even though it had already been narrowed to `Some` via `current_item_id`. Carry (id, is_global) in one Option and match on that instead — same behaviour, no unwrap. Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/db/mod.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 11a634d..f87985a 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1720,7 +1720,13 @@ impl VaultDb { .await .map_err(|e| e.to_string())?; - let current_item_id = current.as_ref().map(|r| r.get::(0)); + // Carry `is_global` alongside the id so the occupied-key branch below + // reads it straight off the matched row instead of re-unwrapping + // `current` (bare `unwrap()` is forbidden in production code). + let current_row = current + .as_ref() + .map(|r| (r.get::(0), r.get::(1) != 0)); + let current_item_id = current_row.map(|(id, _)| id); if current_item_id != expected { tx.rollback().await.map_err(|e| e.to_string())?; @@ -1734,7 +1740,7 @@ impl VaultDb { let now = now_ts(); - match current_item_id { + match current_row { None => { // Free key → create, own, link. let res = sqlx::query( @@ -1770,9 +1776,7 @@ impl VaultDb { tx.commit().await.map_err(|e| e.to_string())?; Ok(LinkOutcome::Created { item_id: new_id, is_global: false }) } - Some(item_id) => { - let is_global_i: i64 = current.unwrap().get(1); - let is_global = is_global_i != 0; + Some((item_id, is_global)) => { match mode { LinkMode::Error => {