From 6cb60ba2c7a4e432ee7e1a0d68fb0220b4b3e868 Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Tue, 4 Aug 2026 17:11:47 -0500 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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 350f8eef67dfe7d6f70082f60efe74d1f2a046f3 Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Wed, 5 Aug 2026 15:27:34 -0500 Subject: [PATCH 09/10] 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 30defcf24ec62dda48987009ff157ccea92b48b5 Mon Sep 17 00:00:00 2001 From: Mao Suarez Date: Wed, 5 Aug 2026 19:16:45 -0500 Subject: [PATCH 10/10] fix(mcp): restore content dropped by faulty conflict-resolution script A regex-based bulk merge-conflict resolution (main -> tier2) swallowed content between adjacent conflict hunks in two spots: the duplicate `mod test_support` declaration in lib.rs, and the doc-comment + pick_environment_id signature plus the entire append_scope_params/ is_safe_env_key/pick_environment_id test block in crypt-env-mcp.rs. Restored both so the merge commit actually compiles and tests pass. --- src-tauri/src/bin/crypt-env-mcp.rs | 85 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 3 -- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/bin/crypt-env-mcp.rs b/src-tauri/src/bin/crypt-env-mcp.rs index 7f2aa11..348d6fd 100644 --- a/src-tauri/src/bin/crypt-env-mcp.rs +++ b/src-tauri/src/bin/crypt-env-mcp.rs @@ -1896,6 +1896,15 @@ fn missing_scope_err() -> serde_json::Value { tool_err("required: 'environment_id' (environment id), or 'project' + 'environment' (names)") } +/// 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()), @@ -3202,6 +3211,82 @@ fn main() { // ─── Tests (issues #10, #11 — MCP unit coverage) ────────────────────────────── +#[cfg(test)] +mod tests { + use super::*; + + // ─── append_scope_params ──────────────────────────────────────────────── + + #[test] + fn append_scope_params_uses_environment_id_alone_when_present() { + let mut url = "/items".to_string(); + let mut sep = '?'; + let args = serde_json::json!({ "environment_id": 42, "project": "demo", "environment": "production" }); + append_scope_params(&mut url, &mut sep, &args); + assert_eq!(url, "/items?environment_id=42"); + } + + #[test] + fn append_scope_params_uses_project_and_environment_when_no_id() { + let mut url = "/items".to_string(); + let mut sep = '?'; + let args = serde_json::json!({ "project": "demo", "environment": "production" }); + append_scope_params(&mut url, &mut sep, &args); + assert_eq!(url, "/items?project=demo&environment=production"); + } + + #[test] + fn append_scope_params_is_a_noop_when_nothing_present() { + let mut url = "/items".to_string(); + let mut sep = '?'; + let args = serde_json::json!({}); + append_scope_params(&mut url, &mut sep, &args); + assert_eq!(url, "/items"); + } + + // ─── is_safe_env_key ──────────────────────────────────────────────────── + + #[test] + fn is_safe_env_key_accepts_a_normal_uppercase_key() { + assert!(is_safe_env_key("DB_HOST")); + } + + #[test] + fn is_safe_env_key_rejects_blocked_system_variables() { + assert!(!is_safe_env_key("PATH")); + assert!(!is_safe_env_key("LD_PRELOAD")); + assert!(!is_safe_env_key("LD_ANYTHING")); + } + + #[test] + fn is_safe_env_key_rejects_lowercase_and_invalid_leading_chars() { + assert!(!is_safe_env_key("db_host")); + assert!(!is_safe_env_key("1KEY")); + assert!(!is_safe_env_key("")); + } + + // ─── pick_environment_id ──────────────────────────────────────────────── + + fn sample_projects() -> 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 resolved = pick_environment_id(&args, &sample_projects()).unwrap(); + assert_eq!(resolved.id, 10); + // Name-pair resolution is never the deprecated `id` alias path. + assert!(!resolved.via_deprecated_id); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b2ad28..009c6e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,9 +17,6 @@ pub mod wsl; #[cfg(test)] mod test_support; -#[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,