From 4678acf7e7b327a072b758a6d74deb7305011f6c Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 29 Jul 2026 10:53:15 -0500 Subject: [PATCH 1/2] fix(ci): rebuild workflows for the post-reconstruction workspace The CI/release workflows still targeted the archived presentation shells (nuncio-gui, nuncio-tui, nuncio-mcp) and their Tauri/npm toolchain, so every run failed before the engine crates were even built. - ci.yml: run on dev in addition to main, drop the GTK/WebKit/Tauri apt packages (the engine only needs zbus-based keyring and bundled sqlite, both pure-Rust/cc), rely on rust-toolchain.toml instead of dtolnay/rust-toolchain@stable to avoid drift from the pinned 1.97.1 toolchain, and make the coverage job continue-on-error so it stays informational. - release.yml / release-nightly.yml: package only nuncio-cli and nunciod (the shells are gone), and drop the fabricated installer/ Homebrew release notes. - release-desktop.yml: deleted; the Tauri desktop GUI is being rebuilt as a separate client repo and no longer belongs here. --- .github/workflows/ci.yml | 46 +++++-------- .github/workflows/release-desktop.yml | 93 --------------------------- .github/workflows/release-nightly.yml | 4 -- .github/workflows/release.yml | 21 +++--- 4 files changed, 24 insertions(+), 140 deletions(-) delete mode 100644 .github/workflows/release-desktop.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f32a5c6..9316241 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI Quality Gates & Build Pipeline on: push: - branches: [ main ] + branches: [ main, dev ] pull_request: - branches: [ main ] + branches: [ main, dev ] env: CARGO_TERM_COLOR: always @@ -19,9 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt + # No toolchain-install action: rust-toolchain.toml pins 1.97.1 (+ rustfmt, + # clippy) and rustup auto-installs it on first cargo/rustc invocation, so + # CI always matches the local pinned toolchain instead of a drifting + # `stable`. - name: Check Formatting run: cargo fmt --all -- --check @@ -30,13 +31,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - name: Install Linux Desktop Dependencies - run: | - sudo apt-get update - sudo apt-get install -y libglib2.0-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev build-essential curl wget file libssl-dev - name: Cache Cargo Dependencies uses: Swatinem/rust-cache@v2 - name: Run Clippy Linter (all targets, matches local `cargo check-all`) @@ -47,36 +41,29 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - name: Install Linux Desktop Dependencies - run: | - sudo apt-get update - sudo apt-get install -y libglib2.0-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev build-essential curl wget file libssl-dev - name: Cache Cargo Dependencies uses: Swatinem/rust-cache@v2 - name: Run Workspace Tests - run: cargo test --workspace + run: cargo test-all coverage: - name: Enforce 100% Code Coverage Threshold + name: Code Coverage (informational) runs-on: ubuntu-latest + continue-on-error: true steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - name: Install Linux Desktop Dependencies - run: | - sudo apt-get update - sudo apt-get install -y libglib2.0-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev build-essential curl wget file libssl-dev + - name: Install llvm-tools component + run: rustup component add llvm-tools-preview - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - name: Cache Cargo Dependencies uses: Swatinem/rust-cache@v2 - # Coverage is informational for now. The former `--fail-under-lines 100` - # gate excluded all main.rs (the integration wiring) and incentivized - # stub+matching-test theater. A realistic threshold plus a required - # mock-server E2E gate lands with Phase 1.C.6 (GH #143). + # Coverage is informational only (matches the local `cargo cov` alias): + # no --fail-under-lines threshold, and the job is continue-on-error so a + # coverage regression or tooling hiccup never reds the pipeline. A + # realistic coverage gate is a separate roadmap story. - name: Measure Code Coverage (informational) - run: cargo llvm-cov --workspace --ignore-filename-regex "main\.rs" --summary-only + run: cargo cov build-matrix: name: Cross-Platform Build Matrix (${{ matrix.os }}) @@ -88,7 +75,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - name: Cache Cargo Dependencies uses: Swatinem/rust-cache@v2 - name: Build All Workspace Crates diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml deleted file mode 100644 index 69e76a0..0000000 --- a/.github/workflows/release-desktop.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Nuncio Desktop GUI Release Pipeline (MSI, DMG, AppImage, Deb) - -on: - # Desktop installers are built ONLY from version tags (per the release policy). - # Branch-push triggers were removed during the pre-alpha rebuild: they ran on - # every dev push and failed (the Tauri GUI is archived under _reference/). - push: - tags: - - 'v*' - workflow_dispatch: - -permissions: - contents: write - packages: write - -jobs: - build-tauri: - name: Build Desktop (${{ matrix.platform }}) - strategy: - fail-fast: false - matrix: - include: - - platform: 'macos-latest' - args: '--target aarch64-apple-darwin' - - platform: 'macos-13' - args: '--target x86_64-apple-darwin' - - platform: 'ubuntu-22.04' - args: '' - - platform: 'windows-latest' - args: '' - - runs-on: ${{ matrix.platform }} - steps: - - name: Checkout Code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: 20 - - - name: Install Rust Toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin' || matrix.platform == 'macos-13' && 'x86_64-apple-darwin' || '' }} - - - name: Install Linux Dependencies (Ubuntu) - if: matrix.platform == 'ubuntu-22.04' - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - - name: Install Frontend UI Dependencies - working-directory: crates/nuncio-gui/ui - run: npm install --legacy-peer-deps - - - name: Install Tauri CLI Dependencies - working-directory: crates/nuncio-gui/src-tauri - run: npm install --legacy-peer-deps - - - name: Determine Environment Channel & Tag - id: env_prep - shell: bash - run: | - if [[ "${{ github.ref }}" == refs/heads/dev ]]; then - echo "channel=dev" >> $GITHUB_OUTPUT - echo "tag_name=dev-latest" >> $GITHUB_OUTPUT - echo "release_name=Nuncio Desktop (Development Nightly)" >> $GITHUB_OUTPUT - echo "is_prerelease=true" >> $GITHUB_OUTPUT - elif [[ "${{ github.ref }}" == refs/heads/rc || "${{ github.ref }}" == refs/tags/*rc* ]]; then - echo "channel=rc" >> $GITHUB_OUTPUT - echo "tag_name=rc-latest" >> $GITHUB_OUTPUT - echo "release_name=Nuncio Desktop (Release Candidate)" >> $GITHUB_OUTPUT - echo "is_prerelease=true" >> $GITHUB_OUTPUT - else - echo "channel=prod" >> $GITHUB_OUTPUT - echo "tag_name=${{ github.ref_name }}" >> $GITHUB_OUTPUT - echo "release_name=Nuncio Desktop Release ${{ github.ref_name }}" >> $GITHUB_OUTPUT - echo "is_prerelease=false" >> $GITHUB_OUTPUT - fi - - - name: Build Tauri Desktop Installers (.msi, .dmg, .AppImage, .deb) - uses: tauri-apps/tauri-action@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - projectPath: 'crates/nuncio-gui/src-tauri' - tagName: ${{ steps.env_prep.outputs.tag_name }} - releaseName: ${{ steps.env_prep.outputs.release_name }} - releaseBody: 'Production Tauri v2 Desktop GUI installers (.msi, .dmg, .AppImage, .deb).' - releaseDraft: false - prerelease: ${{ steps.env_prep.outputs.is_prerelease == 'true' }} - args: ${{ matrix.args }} diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 0c83128..4e5490d 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -48,8 +48,6 @@ jobs: run: | mkdir -p dist cp target/${{ matrix.target }}/release/nuncio-cli dist/nuncio-cli - cp target/${{ matrix.target }}/release/nuncio-tui dist/nuncio-tui - cp target/${{ matrix.target }}/release/nuncio-mcp dist/nuncio-mcp cp target/${{ matrix.target }}/release/nunciod dist/nunciod tar -czf ${{ matrix.artifact_name }} -C dist . @@ -59,8 +57,6 @@ jobs: run: | New-Item -ItemType Directory -Path dist Copy-Item target/${{ matrix.target }}/release/nuncio-cli.exe dist/nuncio-cli.exe - Copy-Item target/${{ matrix.target }}/release/nuncio-tui.exe dist/nuncio-tui.exe - Copy-Item target/${{ matrix.target }}/release/nuncio-mcp.exe dist/nuncio-mcp.exe Copy-Item target/${{ matrix.target }}/release/nunciod.exe dist/nunciod.exe Compress-Archive -Path dist/* -DestinationPath ${{ matrix.artifact_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d94b03f..bbd97fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,8 +52,6 @@ jobs: run: | mkdir -p dist cp target/${{ matrix.target }}/release/nuncio-cli dist/nuncio-cli - cp target/${{ matrix.target }}/release/nuncio-tui dist/nuncio-tui - cp target/${{ matrix.target }}/release/nuncio-mcp dist/nuncio-mcp cp target/${{ matrix.target }}/release/nunciod dist/nunciod tar -czf ${{ matrix.artifact_name }} -C dist . @@ -63,8 +61,6 @@ jobs: run: | New-Item -ItemType Directory -Path dist Copy-Item target/${{ matrix.target }}/release/nuncio-cli.exe dist/nuncio-cli.exe - Copy-Item target/${{ matrix.target }}/release/nuncio-tui.exe dist/nuncio-tui.exe - Copy-Item target/${{ matrix.target }}/release/nuncio-mcp.exe dist/nuncio-mcp.exe Copy-Item target/${{ matrix.target }}/release/nunciod.exe dist/nunciod.exe Compress-Archive -Path dist/* -DestinationPath ${{ matrix.artifact_name }} @@ -122,17 +118,16 @@ jobs: prerelease: ${{ steps.prep.outputs.is_prerelease == 'true' }} files: dist-final/* body: | - ## Nuncio Sovereign Mail, Calendar & Contacts Suite + ## Nuncio Engine (nunciod + nuncio-cli) **Release Channel**: ${{ steps.prep.outputs.channel }} - ### 📦 Official Installer & Package Downloads - - **Windows Installer**: `Nuncio_x64.msi` (WiX Installer) / `Nuncio_x64-setup.exe` (NSIS) - - **macOS Disk Image**: `Nuncio_x64.dmg` (Intel) / `Nuncio_aarch64.dmg` (Apple Silicon) - - **Linux Packages**: `Nuncio_amd64.AppImage` / `Nuncio_amd64.deb` - - **CLI & Daemon Archives**: `nuncio-x86_64-unknown-linux-gnu.tar.gz`, `nuncio-aarch64-apple-darwin.tar.gz`, `nuncio-x86_64-apple-darwin.tar.gz`, `nuncio-x86_64-pc-windows-msvc.zip` + ### 📦 Daemon & CLI Archives + - `nuncio-x86_64-unknown-linux-gnu.tar.gz` + - `nuncio-aarch64-apple-darwin.tar.gz` + - `nuncio-x86_64-apple-darwin.tar.gz` + - `nuncio-x86_64-pc-windows-msvc.zip` - ### 🚀 Package Managers - - **Homebrew Tap**: `brew install koftwentytwo/tap/nuncio` - - **Cargo Source**: `cargo install --git https://github.com/KofTwentyTwo/nuncio.git nuncio-cli nuncio-tui nuncio-mcp nunciod` + ### 🚀 Cargo Source + - `cargo install --git https://github.com/KofTwentyTwo/nuncio.git nuncio-cli nunciod` Verify payload hashes against `SHA256SUMS.txt`. -- 2.54.0 From e89074663357c57a9fbe45f031eb730f3d43bf0a Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 29 Jul 2026 11:03:40 -0500 Subject: [PATCH 2/2] fix(nunciod): keep ephemeral test-db TempDir alive for its user's lifetime Several gRPC test helpers created an ephemeral sqlite-backed DatabaseEngine via connect_ephemeral() and then discarded the returned TempDir guard before returning the still-live db/server to the caller. Dropping the guard unlinks the directory backing the sqlite file; on Windows the still-open file handle keeps the data reachable (masking the bug locally), but on Linux the directory entry disappears immediately, so any pool connection opened afterward fails with "unable to open database file" -- exactly the flake CI hit in nunciod::grpc::tests::preview_rule_dry_runs_against_a_stored_message_without_persisting. Fixes the same pattern in nunciod::grpc's spawn_test_server and spawn_filters_test_server helpers, nuncio-cli's HeadlessRunner (ephemeral_with), and grpc_auth_test's start_server: each now returns (or stores) the TempDir so it stays alive for as long as its caller keeps using the database. --- Cargo.lock | 1 + crates/nuncio-cli/Cargo.toml | 1 + crates/nuncio-cli/src/runner.rs | 7 +- crates/nunciod/src/grpc.rs | 94 ++++++++++++++++---------- crates/nunciod/tests/grpc_auth_test.rs | 22 ++++-- 5 files changed, 81 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 33c67c4..06ce5bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2478,6 +2478,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "tempfile", "thiserror 1.0.69", "tokio", "tokio-stream", diff --git a/crates/nuncio-cli/Cargo.toml b/crates/nuncio-cli/Cargo.toml index 8ac39f6..8ebdfbc 100644 --- a/crates/nuncio-cli/Cargo.toml +++ b/crates/nuncio-cli/Cargo.toml @@ -29,6 +29,7 @@ tracing.workspace = true rpassword.workspace = true chrono.workspace = true hex.workspace = true +tempfile.workspace = true [dev-dependencies] tokio-stream = { workspace = true, features = ["net"] } diff --git a/crates/nuncio-cli/src/runner.rs b/crates/nuncio-cli/src/runner.rs index d59972d..d9ad7f1 100644 --- a/crates/nuncio-cli/src/runner.rs +++ b/crates/nuncio-cli/src/runner.rs @@ -134,6 +134,10 @@ pub struct HeadlessRunner { db: DatabaseEngine, secrets: Arc, grpc_addr: String, + // Kept only to hold the ephemeral database's backing directory open for + // the runner's lifetime: dropping it would unlink the directory out from + // under `db` while the pool may still need to open new connections. + _db_dir: tempfile::TempDir, } impl HeadlessRunner { @@ -159,7 +163,7 @@ impl HeadlessRunner { secrets: Arc, grpc_addr: String, ) -> Result { - let (db, _dir) = DatabaseEngine::connect_ephemeral() + let (db, db_dir) = DatabaseEngine::connect_ephemeral() .await .map_err(|e| RunnerError::InitFailed(e.to_string()))?; let event_bus = EventBus::new(); @@ -167,6 +171,7 @@ impl HeadlessRunner { event_bus, db, secrets, + _db_dir: db_dir, grpc_addr, }) } diff --git a/crates/nunciod/src/grpc.rs b/crates/nunciod/src/grpc.rs index 97cb383..a9a7a64 100644 --- a/crates/nunciod/src/grpc.rs +++ b/crates/nunciod/src/grpc.rs @@ -1264,13 +1264,25 @@ mod tests { async fn spawn_test_server( event_bus: Arc, token: &str, - ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { - let (db, _dir) = DatabaseEngine::connect_ephemeral() + ) -> ( + std::net::SocketAddr, + tokio::task::JoinHandle<()>, + tempfile::TempDir, + ) { + // The `TempDir` guard must outlive this function: the spawned server + // task keeps using `db` long after we return, and on Linux dropping + // the guard here unlinks the directory backing the sqlite file out + // from under it, so any connection the pool opens afterward fails + // with "unable to open database file". Return it to the caller so it + // stays alive for the test's duration. + let (db, dir) = DatabaseEngine::connect_ephemeral() .await .expect("connect ephemeral test db"); let secrets = Arc::new(SecretManager::mock()); let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")); - spawn_test_server_with(event_bus, Arc::new(db), filter_engine, secrets, token).await + let (addr, handle) = + spawn_test_server_with(event_bus, Arc::new(db), filter_engine, secrets, token).await; + (addr, handle, dir) } /// Spawns a test server on an ephemeral loopback port backed by the @@ -1356,7 +1368,7 @@ mod tests { #[tokio::test] async fn get_status_rejects_missing_bearer_token() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = SystemClient::connect(format!("http://{addr}")) .await @@ -1371,7 +1383,7 @@ mod tests { #[tokio::test] async fn get_status_rejects_wrong_bearer_token() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = SystemClient::connect(format!("http://{addr}")) .await @@ -1393,7 +1405,7 @@ mod tests { #[tokio::test] async fn get_status_rejects_malformed_authorization_header() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = SystemClient::connect(format!("http://{addr}")) .await @@ -1418,7 +1430,7 @@ mod tests { event_bus.process_command(CoreCommand::SyncAll); assert_eq!(event_bus.current_state().status, EngineStatus::Syncing); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = SystemClient::connect(format!("http://{addr}")) .await @@ -1503,7 +1515,7 @@ mod tests { #[tokio::test] async fn subscribe_streams_mapped_core_events_from_the_live_event_bus() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus.clone(), "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus.clone(), "correct-token").await; let mut client = SystemClient::connect(format!("http://{addr}")) .await @@ -1546,7 +1558,7 @@ mod tests { // `GetStatus` because both are defined on the single `System` // service (no separate, un-intercepted service). let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = SystemClient::connect(format!("http://{addr}")) .await @@ -1569,7 +1581,7 @@ mod tests { // `GetStatus` calls complete correctly and without corrupting one // another. let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus.clone(), "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus.clone(), "correct-token").await; // Open (and await) the subscription first, on its own connection, // so the server-side registration has deterministically happened @@ -1663,7 +1675,7 @@ mod tests { // Confirms `Accounts` is mounted behind its own `BearerAuthInterceptor` // exactly like `System` (no un-intercepted service). let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = AccountsClient::connect(format!("http://{addr}")) .await @@ -1684,7 +1696,7 @@ mod tests { #[tokio::test] async fn list_accounts_rejects_missing_bearer_token() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = AccountsClient::connect(format!("http://{addr}")) .await @@ -1699,7 +1711,7 @@ mod tests { #[tokio::test] async fn add_account_rejects_empty_password() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = AccountsClient::connect(format!("http://{addr}")) .await @@ -1717,7 +1729,7 @@ mod tests { #[tokio::test] async fn add_account_rejects_missing_config() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = AccountsClient::connect(format!("http://{addr}")) .await @@ -1944,7 +1956,7 @@ mod tests { // Confirms `Mail` is mounted behind its own `BearerAuthInterceptor` // exactly like `System` and `Accounts` (no un-intercepted service). let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = MailClient::connect(format!("http://{addr}")) .await @@ -2013,7 +2025,7 @@ mod tests { #[tokio::test] async fn get_message_reports_not_found_for_unknown_message_id() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = MailClient::connect(format!("http://{addr}")) .await @@ -2030,7 +2042,7 @@ mod tests { #[tokio::test] async fn mark_read_reports_not_found_for_unknown_message_id() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = MailClient::connect(format!("http://{addr}")) .await @@ -2048,7 +2060,7 @@ mod tests { #[tokio::test] async fn list_messages_rejects_empty_folder_id() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = MailClient::connect(format!("http://{addr}")) .await @@ -2215,7 +2227,7 @@ mod tests { #[tokio::test] async fn send_message_rejects_empty_to() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = MailClient::connect(format!("http://{addr}")) .await @@ -2232,7 +2244,7 @@ mod tests { #[tokio::test] async fn send_message_rejects_empty_subject() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = MailClient::connect(format!("http://{addr}")) .await @@ -2253,7 +2265,7 @@ mod tests { #[tokio::test] async fn send_message_reports_honest_error_when_no_account_configured() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = MailClient::connect(format!("http://{addr}")) .await @@ -2330,7 +2342,7 @@ mod tests { #[tokio::test] async fn sync_without_override_and_without_accounts_reports_zero_synced() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = MailClient::connect(format!("http://{addr}")) .await @@ -2350,7 +2362,7 @@ mod tests { #[tokio::test] async fn sync_without_override_reports_error_for_unknown_account() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = MailClient::connect(format!("http://{addr}")) .await @@ -2632,8 +2644,14 @@ mod tests { tokio::task::JoinHandle<()>, Arc, Arc, + tempfile::TempDir, ) { - let (db, _dir) = DatabaseEngine::connect_ephemeral() + // The `TempDir` guard must outlive this function: on Linux, dropping it + // here would unlink the directory backing the sqlite file while `db` + // is still in use, so any pool connection opened *after* this + // function returns fails with "unable to open database file". Return + // it to the caller instead so it stays alive for the test's duration. + let (db, dir) = DatabaseEngine::connect_ephemeral() .await .expect("connect ephemeral test db"); let db = Arc::new(db); @@ -2647,7 +2665,7 @@ mod tests { token, ) .await; - (addr, handle, db, filter_engine) + (addr, handle, db, filter_engine, dir) } #[tokio::test] @@ -2655,7 +2673,7 @@ mod tests { // Confirms `Filters` is mounted behind its own `BearerAuthInterceptor` // exactly like `System`/`Accounts`/`Mail` (no un-intercepted // service). - let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await; + let (addr, _handle, _db, _engine, _dir) = spawn_filters_test_server("correct-token").await; let mut client = FiltersClient::connect(format!("http://{addr}")) .await @@ -2711,7 +2729,8 @@ mod tests { /// back to empty. #[tokio::test] async fn create_list_and_delete_rule_round_trip_and_keep_the_live_engine_in_sync() { - let (addr, _handle, _db, filter_engine) = spawn_filters_test_server("correct-token").await; + let (addr, _handle, _db, filter_engine, _dir) = + spawn_filters_test_server("correct-token").await; let mut client = FiltersClient::connect(format!("http://{addr}")) .await @@ -2790,7 +2809,8 @@ mod tests { #[tokio::test] async fn create_rule_rejects_invalid_nsql_and_persists_nothing() { - let (addr, _handle, _db, filter_engine) = spawn_filters_test_server("correct-token").await; + let (addr, _handle, _db, filter_engine, _dir) = + spawn_filters_test_server("correct-token").await; let mut client = FiltersClient::connect(format!("http://{addr}")) .await @@ -2834,7 +2854,7 @@ mod tests { #[tokio::test] async fn delete_rule_rejects_empty_id() { - let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await; + let (addr, _handle, _db, _engine, _dir) = spawn_filters_test_server("correct-token").await; let mut client = FiltersClient::connect(format!("http://{addr}")) .await @@ -2854,7 +2874,7 @@ mod tests { /// doc comment). Also proves validation never persists anything. #[tokio::test] async fn validate_rule_reports_honest_ok_and_error_verdicts_without_persisting() { - let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await; + let (addr, _handle, _db, _engine, _dir) = spawn_filters_test_server("correct-token").await; let mut client = FiltersClient::connect(format!("http://{addr}")) .await @@ -2897,7 +2917,7 @@ mod tests { /// `Mail/GetMessage` reads) without ever persisting the rule itself. #[tokio::test] async fn preview_rule_dry_runs_against_a_stored_message_without_persisting() { - let (addr, _handle, db, _engine) = spawn_filters_test_server("correct-token").await; + let (addr, _handle, db, _engine, _dir) = spawn_filters_test_server("correct-token").await; db.save_email(&nuncio_core::model::Email { id: "msg-preview-1".to_string(), @@ -2963,7 +2983,7 @@ mod tests { /// message rather than failing the RPC. #[tokio::test] async fn preview_rule_falls_back_to_a_synthetic_message_when_none_is_stored() { - let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await; + let (addr, _handle, _db, _engine, _dir) = spawn_filters_test_server("correct-token").await; let mut client = FiltersClient::connect(format!("http://{addr}")) .await @@ -2994,7 +3014,7 @@ mod tests { #[tokio::test] async fn preview_rule_and_validate_rule_reject_invalid_nsql_syntax() { - let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await; + let (addr, _handle, _db, _engine, _dir) = spawn_filters_test_server("correct-token").await; let mut client = FiltersClient::connect(format!("http://{addr}")) .await @@ -3019,7 +3039,7 @@ mod tests { // Confirms `Export` is mounted behind its own `BearerAuthInterceptor` // exactly like every other service (no un-intercepted service). let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = ExportClient::connect(format!("http://{addr}")) .await @@ -3038,7 +3058,7 @@ mod tests { #[tokio::test] async fn export_mailbox_rejects_empty_output_path() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = ExportClient::connect(format!("http://{addr}")) .await @@ -3057,7 +3077,7 @@ mod tests { #[tokio::test] async fn export_mailbox_rejects_unspecified_format() { let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = ExportClient::connect(format!("http://{addr}")) .await @@ -3200,7 +3220,7 @@ mod tests { // Confirms `Audit` is mounted behind its own `BearerAuthInterceptor` // exactly like every other service (no un-intercepted service). let event_bus = Arc::new(EventBus::new()); - let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await; + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; let mut client = AuditClient::connect(format!("http://{addr}")) .await diff --git a/crates/nunciod/tests/grpc_auth_test.rs b/crates/nunciod/tests/grpc_auth_test.rs index 040cfa7..aaafaa7 100644 --- a/crates/nunciod/tests/grpc_auth_test.rs +++ b/crates/nunciod/tests/grpc_auth_test.rs @@ -32,12 +32,22 @@ use tonic::{Code, Request}; /// Backs the mounted `nuncio.v1.Accounts` service with its own fresh /// ephemeral database + mock keyring, since this test file only exercises /// `System` and doesn't care about account/keyring state. -async fn start_server(event_bus: Arc, token: String) -> std::net::SocketAddr { +/// +/// Also returns the ephemeral database's backing `TempDir` guard: the +/// spawned server task keeps using `db` long after this function returns, +/// and on Linux dropping the guard here would unlink the directory out from +/// under it, so any connection the pool opens afterward fails with "unable +/// to open database file". Callers must hold the guard for as long as they +/// keep talking to the server. +async fn start_server( + event_bus: Arc, + token: String, +) -> (std::net::SocketAddr, tempfile::TempDir) { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind ephemeral loopback port"); let addr = listener.local_addr().expect("listener has local addr"); - let (db, _dir) = DatabaseEngine::connect_ephemeral() + let (db, dir) = DatabaseEngine::connect_ephemeral() .await .expect("connect ephemeral test db"); let secrets = Arc::new(SecretManager::mock()); @@ -53,7 +63,7 @@ async fn start_server(event_bus: Arc, token: String) -> std::net::Sock ) .await; }); - addr + (addr, dir) } #[tokio::test] @@ -66,7 +76,7 @@ async fn rejects_calls_without_a_bearer_token() { .expect("token provisioned from mock vault"), ); - let addr = start_server(event_bus, token).await; + let (addr, _dir) = start_server(event_bus, token).await; let mut client = SystemClient::connect(format!("http://{addr}")) .await .expect("client connects"); @@ -88,7 +98,7 @@ async fn rejects_calls_with_an_invalid_bearer_token() { .expect("token provisioned from mock vault"), ); - let addr = start_server(event_bus, token).await; + let (addr, _dir) = start_server(event_bus, token).await; let mut client = SystemClient::connect(format!("http://{addr}")) .await .expect("client connects"); @@ -123,7 +133,7 @@ async fn accepts_correct_bearer_token_and_returns_real_engine_status() { .expect("token provisioned from mock vault"), ); - let addr = start_server(event_bus.clone(), token.clone()).await; + let (addr, _dir) = start_server(event_bus.clone(), token.clone()).await; let mut client = SystemClient::connect(format!("http://{addr}")) .await .expect("client connects"); -- 2.54.0