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`. 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");