diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b38c63e..cfa48a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,29 +22,20 @@ jobs: --health-interval 5s --health-timeout 3s --health-retries 20 - spicedb: - # ReBAC engine (SPEC §7a). Same image + preshared key as - # deploy/docker-compose.yml so VERITY_SPICEDB_KEY matches. The v0.1 - # Rust client speaks the HTTP gateway (8443); SPICEDB_HTTP_ENABLED turns - # it on. In-memory datastore (default) — ephemeral is correct for CI; - # every soundness test writes its own schema via ensure_schema(). - # GitHub Actions service containers cannot override the image command, - # so serve flags are passed as env: SPICEDB_GRPC_PRESHARED_KEY - # (== --grpc-preshared-key), SPICEDB_HTTP_ENABLED (== --http-enabled). - image: authzed/spicedb - env: - SPICEDB_GRPC_PRESHARED_KEY: verity-dev-key - SPICEDB_HTTP_ENABLED: "true" - ports: - - 8443:8443 - - 50051:50051 - options: >- - --health-cmd "grpc_health_probe -addr=localhost:50051" - --health-interval 5s - --health-timeout 3s - --health-retries 20 + # SpiceDB is NOT a service container: GitHub Actions can't override a + # service image's command, and the authzed/spicedb image no longer + # defaults to `serve` (a `:latest` drift that silently broke this job), + # so the env-var approach printed help and the container died at init. + # It's started as an explicit `docker run ... serve` step below instead, + # mirroring deploy/docker-compose.yml exactly. steps: - uses: actions/checkout@v4 + - name: Start SpiceDB (explicit serve; runs during the build below) + run: | + docker run -d --name spicedb \ + -p 8443:8443 -p 50051:50051 \ + authzed/spicedb serve \ + --grpc-preshared-key verity-dev-key --http-enabled - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -58,6 +49,21 @@ jobs: # resolution_precision_gate (pure, ≥0.99 precision / 0.0 false-merge-rate) # and resolution_scope_fuzz (DSN; a mis-linked entity surfacing across # scope handles fails the build). + - name: Wait for SpiceDB HTTP gateway + run: | + for i in $(seq 1 40); do + code=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST http://localhost:8443/v1/schema/read \ + -H "authorization: bearer verity-dev-key" \ + -H "content-type: application/json" -d '{}' || true) + # Any HTTP status (200 or a 4xx "no schema written yet") means it is + # serving; only 000 (connection refused) means not up yet. + if [ -n "$code" ] && [ "$code" != "000" ]; then + echo "SpiceDB responding (HTTP $code)"; exit 0 + fi + sleep 2 + done + echo "SpiceDB did not become healthy:"; docker logs spicedb || true; exit 1 - run: cargo test --workspace env: VERITY_TEST_DSN: postgres://verity:verity@localhost:5433/verity diff --git a/crates/verity-server/src/connector_worker.rs b/crates/verity-server/src/connector_worker.rs index b48d23b..2fea394 100644 --- a/crates/verity-server/src/connector_worker.rs +++ b/crates/verity-server/src/connector_worker.rs @@ -1484,8 +1484,7 @@ mod tests { None, None, ) - .err() - .expect("gmail must fail without subject"); + .expect_err("gmail must fail without subject"); assert!(matches!(err, SpawnError::NoConfig(_))); // Blank/whitespace subject counts as absent (matches connector abort). assert!(matches!( @@ -1574,8 +1573,7 @@ mod tests { Some(cred), None, ) - .err() - .expect("no visibility must fail"); + .expect_err("no visibility must fail"); assert!(matches!(err, SpawnError::NoConfig(_))); // Empty visibility is treated as absent. let err = assemble_spec( @@ -1588,8 +1586,7 @@ mod tests { Some(cred), None, ) - .err() - .expect("empty visibility must fail"); + .expect_err("empty visibility must fail"); assert!(matches!(err, SpawnError::NoConfig(_))); // No credential-file path → NoConfig. let err = assemble_spec( @@ -1602,8 +1599,7 @@ mod tests { None, None, ) - .err() - .expect("no credential-file must fail"); + .expect_err("no credential-file must fail"); assert!(matches!(err, SpawnError::NoConfig(_))); } @@ -1865,8 +1861,7 @@ mod tests { Some(cred), Some(cursor), ) - .err() - .expect("no visibility must fail"); + .expect_err("no visibility must fail"); assert!(matches!(err, SpawnError::NoConfig(_))); // Empty visibility is treated as absent. let err = assemble_spec( @@ -1879,8 +1874,7 @@ mod tests { Some(cred), Some(cursor), ) - .err() - .expect("empty visibility must fail"); + .expect_err("empty visibility must fail"); assert!(matches!(err, SpawnError::NoConfig(_))); // No credential-file path → NoConfig. let err = assemble_spec( @@ -1893,8 +1887,7 @@ mod tests { None, Some(cursor), ) - .err() - .expect("no credential-file must fail"); + .expect_err("no credential-file must fail"); assert!(matches!(err, SpawnError::NoConfig(_))); } diff --git a/crates/verity-server/src/connectors_admin.rs b/crates/verity-server/src/connectors_admin.rs index dc9fdba..7cdac91 100644 --- a/crates/verity-server/src/connectors_admin.rs +++ b/crates/verity-server/src/connectors_admin.rs @@ -2447,17 +2447,14 @@ mod tests { fn resolve_both_present_is_ambiguous_409() { let cred = stored_path(None); let err = resolve_backfill("gdrive", Some(&cred), Some(Path::new("/env/sa.json"))) - .err() - .expect("both present must 409"); + .expect_err("both present must 409"); assert!(matches!(err, BackfillReject::Ambiguous(_))); assert_eq!(err.status(), StatusCode::CONFLICT); } #[test] fn resolve_neither_present_is_no_credential_422() { - let err = resolve_backfill("gdrive", None, None) - .err() - .expect("neither present must 422"); + let err = resolve_backfill("gdrive", None, None).expect_err("neither present must 422"); assert!(matches!(err, BackfillReject::NoCredential(_))); assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); } @@ -2466,8 +2463,7 @@ mod tests { fn gmail_requires_a_stored_subject() { // Path present (env), but gmail has no stored subject → 422. let err = resolve_backfill("gmail", None, Some(Path::new("/env/sa.json"))) - .err() - .expect("gmail without subject must 422"); + .expect_err("gmail without subject must 422"); assert!(matches!(err, BackfillReject::SubjectMissing(_))); assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); // A blank stored subject is treated as absent. @@ -2686,6 +2682,11 @@ mod tests { } } + // These assertions deliberately pin relationships between COMPILE-TIME + // constants (the sync interval floor and default). clippy sees them as + // constant-valued, which is exactly the point: the test exists to fail if + // someone changes either constant out from under the handler's floor check. + #[allow(clippy::assertions_on_constants)] #[test] fn interval_floor_is_below_the_default() { // The DB floor (60) must be <= the default the toggle applies (300), so diff --git a/crates/verity-server/src/extract.rs b/crates/verity-server/src/extract.rs index a8fbbf7..d6ad102 100644 --- a/crates/verity-server/src/extract.rs +++ b/crates/verity-server/src/extract.rs @@ -1580,9 +1580,8 @@ mod tests { #[test] fn scanned_pdf_ocr_honors_the_page_cap() { let jpeg = fixtures::jpeg_bytes(8, 8); - let pages: Vec<&[u8]> = std::iter::repeat(jpeg.as_slice()) - .take(crate::ocr::MAX_OCR_PAGES + 2) - .collect(); + let pages: Vec<&[u8]> = + std::iter::repeat_n(jpeg.as_slice(), crate::ocr::MAX_OCR_PAGES + 2).collect(); let bytes = fixtures::scanned_pdf_with_jpegs(&pages); let ex = expect_extracted(extract_with_ocr( &bytes, diff --git a/crates/verity-server/src/folder_watch.rs b/crates/verity-server/src/folder_watch.rs index 04cf5f4..dde5570 100644 --- a/crates/verity-server/src/folder_watch.rs +++ b/crates/verity-server/src/folder_watch.rs @@ -1948,15 +1948,24 @@ mod tests { assert!(none_hits.is_empty(), "empty scope reads nothing"); // Source registered live in Sources & Freshness as folder:. - let sources = crate::connectors::list_status_rows(state.pool(), tenant) - .await - .expect("status"); - assert!( - sources - .iter() - .any(|s| s["source"] == format!("folder:{folder}")), - "the watch registers as a live source" - ); + // The status row is bumped per-file inside the async ingest task, which + // can lag chunk visibility, so poll for it (as the recall above does) + // rather than assume it lands synchronously with the recalled chunk. + let folder_src = format!("folder:{folder}"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + let registered = loop { + let sources = crate::connectors::list_status_rows(state.pool(), tenant) + .await + .expect("status"); + if sources.iter().any(|s| s["source"] == folder_src) { + break true; + } + if std::time::Instant::now() >= deadline { + break false; + } + tokio::time::sleep(Duration::from_millis(150)).await; + }; + assert!(registered, "the watch registers as a live source"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/verity-server/src/seq_tail_tests.rs b/crates/verity-server/src/seq_tail_tests.rs index af24179..99bce56 100644 --- a/crates/verity-server/src/seq_tail_tests.rs +++ b/crates/verity-server/src/seq_tail_tests.rs @@ -9,7 +9,7 @@ //! these are enforcement-soundness tests — a missing database is a //! misconfiguration to surface loudly, never a class of test to silently no-op. -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Duration, SubsecRound, Utc}; use serde_json::json; use sqlx::Row; @@ -119,7 +119,10 @@ fn scope(tenant: TenantId) -> Scope { #[tokio::test] async fn shrink_redelivery_closes_the_stale_tail() { let (storage, tenant) = tail_state().await; - let t0 = Utc::now() - Duration::seconds(60); + // Microsecond resolution: Postgres stores timestamptz at µs, so an + // in-memory nanosecond `Utc::now()` (Linux) would not equal its own + // read-back and the lineage assertions below would fail by sub-µs digits. + let t0 = Utc::now().trunc_subsecs(6) - Duration::seconds(60); let t1 = t0 + Duration::seconds(30); let tag = "account:shrink"; @@ -154,7 +157,10 @@ async fn shrink_redelivery_closes_the_stale_tail() { #[tokio::test] async fn shrink_replay_is_idempotent() { let (storage, tenant) = tail_state().await; - let t0 = Utc::now() - Duration::seconds(60); + // Microsecond resolution: Postgres stores timestamptz at µs, so an + // in-memory nanosecond `Utc::now()` (Linux) would not equal its own + // read-back and the lineage assertions below would fail by sub-µs digits. + let t0 = Utc::now().trunc_subsecs(6) - Duration::seconds(60); let t1 = t0 + Duration::seconds(30); let tag = "account:replay"; @@ -180,7 +186,10 @@ async fn shrink_replay_is_idempotent() { #[tokio::test] async fn tail_close_is_scoped_to_its_document_and_source() { let (storage, tenant) = tail_state().await; - let t0 = Utc::now() - Duration::seconds(60); + // Microsecond resolution: Postgres stores timestamptz at µs, so an + // in-memory nanosecond `Utc::now()` (Linux) would not equal its own + // read-back and the lineage assertions below would fail by sub-µs digits. + let t0 = Utc::now().trunc_subsecs(6) - Duration::seconds(60); let t1 = t0 + Duration::seconds(30); // The shrink target, a NEIGHBOR document in the same source, and the SAME @@ -251,7 +260,10 @@ async fn tail_close_is_scoped_to_its_document_and_source() { #[tokio::test] async fn growth_redelivery_is_unaffected() { let (storage, tenant) = tail_state().await; - let t0 = Utc::now() - Duration::seconds(60); + // Microsecond resolution: Postgres stores timestamptz at µs, so an + // in-memory nanosecond `Utc::now()` (Linux) would not equal its own + // read-back and the lineage assertions below would fail by sub-µs digits. + let t0 = Utc::now().trunc_subsecs(6) - Duration::seconds(60); let t1 = t0 + Duration::seconds(30); let tag = "account:growth"; diff --git a/crates/verity-storage-qdrant/tests/bitemporal.rs b/crates/verity-storage-qdrant/tests/bitemporal.rs index f032096..3f77379 100644 --- a/crates/verity-storage-qdrant/tests/bitemporal.rs +++ b/crates/verity-storage-qdrant/tests/bitemporal.rs @@ -4,7 +4,7 @@ //! leg), plus a retire_entity case for this profile's chunk-retirement //! superset. Requires VERITY_TEST_DSN + VERITY_QDRANT_URL; skips when absent. -use chrono::{Duration, Utc}; +use chrono::{Duration, SubsecRound, Utc}; use rand::Rng; use serde_json::json; @@ -70,7 +70,9 @@ async fn supersession_lifecycle() { eprintln!("VERITY_TEST_DSN / VERITY_QDRANT_URL not set; skipping"); return; }; - let t0 = Utc::now() - Duration::minutes(10); + // µs resolution: Postgres timestamptz stores µs, so the valid_to equality + // checks below must not carry sub-µs digits that never survive the round-trip. + let t0 = Utc::now().trunc_subsecs(6) - Duration::minutes(10); let write = |value: serde_json::Value, at| FactWrite { tenant_id: tenant, key: key(), diff --git a/crates/verity-storage/tests/bitemporal.rs b/crates/verity-storage/tests/bitemporal.rs index a0f01cf..c10ab49 100644 --- a/crates/verity-storage/tests/bitemporal.rs +++ b/crates/verity-storage/tests/bitemporal.rs @@ -5,7 +5,7 @@ //! and `recall_fails_closed` is a direct empty-principal fail-closed assertion — //! a soundness gate that silently skips is worse than no gate. -use chrono::{Duration, Utc}; +use chrono::{Duration, SubsecRound, Utc}; use serde_json::json; use verity_core::adapter::StorageAdapter; @@ -63,7 +63,9 @@ fn read_scope(tenant: TenantId) -> Scope { #[tokio::test] async fn supersession_lifecycle() { let (adapter, tenant, episode) = test_adapter().await; - let t0 = Utc::now() - Duration::minutes(10); + // µs resolution: Postgres timestamptz stores µs, so the valid_to equality + // checks below must not carry sub-µs digits that never survive the round-trip. + let t0 = Utc::now().trunc_subsecs(6) - Duration::minutes(10); let write = |value: serde_json::Value, at| FactWrite { tenant_id: tenant, key: key(),