diff --git a/.config/nextest.toml b/.config/nextest.toml index cc860f397..d68308713 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -9,6 +9,10 @@ status-level = "slow" [profile.ci.junit] path = "junit.xml" +[profile.mutation] +inherits = "ci" +fail-fast = true + [test-groups] containers = { max-threads = 1 } e2e-servers = { max-threads = 4 } diff --git a/crates/peryx-driver/src/jobs/metrics.rs b/crates/peryx-driver/src/jobs/metrics.rs index de1406eb2..29a69ccf1 100644 --- a/crates/peryx-driver/src/jobs/metrics.rs +++ b/crates/peryx-driver/src/jobs/metrics.rs @@ -46,7 +46,7 @@ impl JobMetrics { pub(crate) fn started(&self, kind: &'static str) { self.with(kind, |counters| { - counters.started += 1; + counters.started = counters.started.saturating_add(1); counters.running += 1; }); } diff --git a/crates/peryx-driver/src/serving.rs b/crates/peryx-driver/src/serving.rs index e8068c58c..e98d86157 100644 --- a/crates/peryx-driver/src/serving.rs +++ b/crates/peryx-driver/src/serving.rs @@ -70,8 +70,6 @@ pub trait EcosystemRegistration: Send + Sync { } pub trait EcosystemAuth: Send + Sync { - fn fields(&self) -> &'static [&'static str]; - fn defaults(&self) -> toml::Table; /// # Errors /// Returns the ecosystem's configuration error. fn validate(&self, config: PluginAuthConfig<'_>) -> Result<(), String>; diff --git a/crates/peryx-driver/tests/unit/state/build/tests.rs b/crates/peryx-driver/tests/unit/state/build/tests.rs index 84e4883bd..6bef556ea 100644 --- a/crates/peryx-driver/tests/unit/state/build/tests.rs +++ b/crates/peryx-driver/tests/unit/state/build/tests.rs @@ -20,10 +20,18 @@ fn test_rate_limit_constructor_keeps_default_runtime_controls() { let state = AppState::with_rate_limits(meta, blobs, 60, Vec::new(), RateLimitConfig::default(), []); assert_eq!(state.serving.max_stale_secs, DEFAULT_MAX_STALE_SECS); - assert_eq!( - state.serving.cache.hot.policy().max_capacity(), - Some(DEFAULT_HOT_CACHE_BYTES) - ); + assert_eq!(DEFAULT_HOT_CACHE_BYTES, 268_435_456); + assert_eq!(state.serving.cache.hot.policy().max_capacity(), Some(268_435_456)); +} + +#[test] +fn test_default_clock_stamps_operations_with_unix_time() { + let (_dir, meta, blobs) = stores(); + let state = AppState::new(meta.clone(), blobs, 60, Vec::new()); + + state.serving.claim_admitted_write("clock"); + + assert!(meta.operation_outcome("clock").unwrap().unwrap().updated_at_unix > 1_700_000_000); } #[test] diff --git a/crates/peryx-driver/tests/unit/state/describe/tests.rs b/crates/peryx-driver/tests/unit/state/describe/tests.rs index 07da6fda3..fe4ddbc00 100644 --- a/crates/peryx-driver/tests/unit/state/describe/tests.rs +++ b/crates/peryx-driver/tests/unit/state/describe/tests.rs @@ -6,6 +6,7 @@ use peryx_identity::{Action, Glob, Grant, IndexAcl, NamedToken}; use peryx_index::{Index, IndexKind}; use peryx_policy::Policy; use peryx_upstream::{NamedUpstream, UpstreamClient, UpstreamRouter}; +use rstest::rstest; fn writer_acl(secret: impl Into) -> IndexAcl { IndexAcl { @@ -87,13 +88,38 @@ fn test_describe_indexes_preserves_input_order() { ); } -#[test] -fn test_hosted_index_reports_volatile_deletes_when_writable_and_volatile() { - let indexes = vec![index("store", IndexKind::Hosted { volatile: true }, writer_acl("s"))]; - let described = describe_index(&indexes, 0); - assert_eq!(described.kind, "hosted"); - assert!(described.volatile_deletes); - assert!(described.precedence.is_empty()); +#[rstest] +#[case::hosted_read_only_stable(false, false, false, false)] +#[case::hosted_read_only_volatile(false, false, true, false)] +#[case::hosted_writable_stable(false, true, false, false)] +#[case::hosted_writable_volatile(false, true, true, true)] +#[case::virtual_read_only_stable(true, false, false, false)] +#[case::virtual_read_only_volatile(true, false, true, false)] +#[case::virtual_writable_stable(true, true, false, false)] +#[case::virtual_writable_volatile(true, true, true, true)] +fn test_volatile_deletes_require_a_writable_volatile_target( + #[case] virtual_index: bool, + #[case] writable: bool, + #[case] volatile: bool, + #[case] expected: bool, +) { + let acl = if writable { writer_acl("s") } else { IndexAcl::default() }; + let mut indexes = vec![index("store", IndexKind::Hosted { volatile }, acl)]; + let position = if virtual_index { + indexes.push(index( + "virtual", + IndexKind::Virtual { + layers: vec![0], + write_target: Some(0), + }, + IndexAcl::default(), + )); + 1 + } else { + 0 + }; + + assert_eq!(describe_index(&indexes, position).volatile_deletes, expected); } #[test] @@ -133,7 +159,6 @@ fn test_virtual_upload_target_drives_uploads_and_volatile_deletes() { ]; let described = describe_index(&indexes, 1); assert!(described.uploads); - assert!(described.volatile_deletes); assert_eq!(described.upload_to.as_deref(), Some("store")); assert_eq!(described.precedence, vec![member("store", "hosted")]); } diff --git a/crates/peryx-driver/tests/unit/state/operation/tests.rs b/crates/peryx-driver/tests/unit/state/operation/tests.rs index c9f2460a4..5cbcd5066 100644 --- a/crates/peryx-driver/tests/unit/state/operation/tests.rs +++ b/crates/peryx-driver/tests/unit/state/operation/tests.rs @@ -22,14 +22,14 @@ fn test_claim_admitted_write_records_a_pending_write_with_a_retention_deadline() state.claim_admitted_write("op"); let stored = meta.operation_outcome("op").unwrap().unwrap(); assert_eq!(stored.state, OperationState::Pending); - assert_eq!(stored.expiry_unix, Some(NOW + OPERATION_RETENTION_SECS)); + assert_eq!(stored.expiry_unix, Some(NOW + 86_400)); } #[test] fn test_claim_admitted_write_that_never_finalizes_expires() { let (_dir, state, meta) = state(); state.claim_admitted_write("op"); - let health = meta.operation_outcome_health(NOW + OPERATION_RETENTION_SECS).unwrap(); + let health = meta.operation_outcome_health(NOW + 86_400).unwrap(); assert_eq!( health.expired, 1, "an unfinalized write reads expired past its deadline" diff --git a/crates/peryx-driver/tests/unit/state/registry/tests.rs b/crates/peryx-driver/tests/unit/state/registry/tests.rs index 57bb16cc0..62bc71920 100644 --- a/crates/peryx-driver/tests/unit/state/registry/tests.rs +++ b/crates/peryx-driver/tests/unit/state/registry/tests.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; +use std::time::Duration; use async_trait::async_trait; use axum::Router; @@ -11,7 +12,9 @@ use peryx_core::{Ecosystem, Lexicon}; use peryx_identity::IndexAcl; use peryx_index::{Index, IndexKind}; use peryx_policy::Policy; -use peryx_search::EmptyIndexer; +use peryx_search::{ + ContentSource, EmptyIndexer, IndexerCtx, SearchDocument, SearchDocumentProvider, SearchError, SearchParams, +}; use peryx_storage::blob::{BlobDurability, BlobStore}; use peryx_storage::meta::MetaStore; @@ -35,6 +38,25 @@ struct ReplacementDriver; struct Drainer; +struct MutableDocs(Arc>); + +impl SearchDocumentProvider for MutableDocs { + fn documents(&self, _ctx: &IndexerCtx<'_>) -> Result, SearchError> { + let text = self.0.lock().unwrap().clone(); + Ok(vec![SearchDocument { + display_label: "package".to_owned(), + resource_key: "package".to_owned(), + route: "root".to_owned(), + index: "root".to_owned(), + ecosystem: "indexed".to_owned(), + source: ContentSource::Cached, + available_locally: false, + summary: None, + text, + }]) + } +} + #[async_trait] impl peryx_ha::AuthorityDrainer for Drainer { async fn drain( @@ -940,3 +962,49 @@ fn test_registration_defaults_and_read_only_mutation_are_observable() { ); assert!(shared.read_only); } + +#[test] +fn test_read_only_retry_interval_is_observable() { + let (_dir, mut state) = state(); + + state.set_read_only_retry_after(Some(Duration::from_secs(17))).unwrap(); + + assert_eq!(state.serving.read_only_retry_after(), Some(Duration::from_secs(17))); +} + +#[test] +fn test_search_epoch_refreshes_the_published_index() { + let (_dir, mut state) = state(); + let text = Arc::new(Mutex::new("old".to_owned())); + state.register_lexicon(Ecosystem::new("indexed"), &Lexicon::NEUTRAL); + state + .register_protocol( + ProtocolDriver::Indexed(Arc::new(IndexedDriver)), + Arc::new(MutableDocs(text.clone())), + ) + .unwrap(); + let state = Arc::new(state); + let services = crate::http_services::HttpDomainServices::for_state(&state); + assert_eq!( + services.search().search(SearchParams::default(), None).unwrap().total, + 1 + ); + *text.lock().unwrap() = "new".to_owned(); + + state.serving.bump_search_epoch(); + + assert_eq!( + services + .search() + .search( + SearchParams { + query: "new".to_owned(), + ..SearchParams::default() + }, + None, + ) + .unwrap() + .total, + 1 + ); +} diff --git a/crates/peryx-ecosystem-oci/src/lib.rs b/crates/peryx-ecosystem-oci/src/lib.rs index 3af15121a..0abaaf962 100644 --- a/crates/peryx-ecosystem-oci/src/lib.rs +++ b/crates/peryx-ecosystem-oci/src/lib.rs @@ -174,14 +174,6 @@ impl EcosystemConfig for OciPlugin { } impl EcosystemAuth for OciPlugin { - fn fields(&self) -> &'static [&'static str] { - &[] - } - - fn defaults(&self) -> toml::Table { - toml::Table::new() - } - fn validate(&self, config: PluginAuthConfig<'_>) -> Result<(), String> { if config.signing_key_configured && config.token_ttl_secs < 60 @@ -282,7 +274,7 @@ pub fn registration() -> peryx_plugin_registry::PluginRegistration { rate_limit_principal: Some(&OciPlugin), client_discovery: Some(&OciPlugin), openapi: &OciPlugin, - auth: Some(&OciPlugin), + auth: Some(peryx_plugin_registry::PluginAuthRegistration::Shared(&OciPlugin)), browse: Some(&OciPlugin), snippets: None, metadata_migration: None, diff --git a/crates/peryx-ecosystem-oci/tests/unit/tests/plugin_contract_tests.rs b/crates/peryx-ecosystem-oci/tests/unit/tests/plugin_contract_tests.rs index 2de847748..19aa4874c 100644 --- a/crates/peryx-ecosystem-oci/tests/unit/tests/plugin_contract_tests.rs +++ b/crates/peryx-ecosystem-oci/tests/unit/tests/plugin_contract_tests.rs @@ -11,6 +11,7 @@ use rstest::rstest; use utoipa::openapi::PathsBuilder; use crate::{ECOSYSTEM, OciPlugin, registration}; +use peryx_plugin_registry::PluginAuthRegistration; fn state() -> (tempfile::TempDir, AppState) { let dir = tempfile::tempdir().unwrap(); @@ -32,6 +33,7 @@ fn plugin_exposes_its_contract() { let plugin = OciPlugin; assert_eq!(plugin.ecosystem(), ECOSYSTEM); + assert_eq!(plugin.absolute_prefixes(), &["/v2/"]); let protocol = plugin.driver(); let driver = protocol.absolute().unwrap(); assert_eq!(driver.prefixes(), &["/v2/"]); @@ -75,10 +77,7 @@ fn plugin_exposes_its_contract() { #[test] fn plugin_auth_uses_only_shared_settings() { - assert_eq!( - (OciPlugin.fields(), OciPlugin.defaults()), - (&[] as &[&str], toml::Table::new()) - ); + assert!(matches!(registration().auth, Some(PluginAuthRegistration::Shared(_)))); } #[test] diff --git a/crates/peryx-ecosystem-pypi/src/lib.rs b/crates/peryx-ecosystem-pypi/src/lib.rs index 2d1325760..d49afd36f 100644 --- a/crates/peryx-ecosystem-pypi/src/lib.rs +++ b/crates/peryx-ecosystem-pypi/src/lib.rs @@ -206,14 +206,6 @@ impl EcosystemRegistration for PypiPlugin { #[cfg(feature = "serving")] impl EcosystemAuth for PypiPlugin { - fn fields(&self) -> &'static [&'static str] { - trusted_publishing::AUTH_FIELDS - } - - fn defaults(&self) -> toml::Table { - trusted_publishing::auth_defaults() - } - fn validate(&self, config: peryx_driver::serving::PluginAuthConfig<'_>) -> Result<(), String> { trusted_publishing::validate(config) } @@ -392,7 +384,11 @@ pub fn registration() -> peryx_plugin_registry::PluginRegistration { rate_limit_principal: Some(&PypiPlugin), client_discovery: Some(&PypiPlugin), openapi: &PypiPlugin, - auth: Some(&PypiPlugin), + auth: Some(peryx_plugin_registry::PluginAuthRegistration::Extension { + auth: &PypiPlugin, + fields: trusted_publishing::AUTH_FIELDS, + defaults: trusted_publishing::auth_defaults, + }), browse: Some(&PypiPlugin), snippets: Some(&PypiPlugin), metadata_migration: Some(Arc::new(PypiPlugin)), diff --git a/crates/peryx-ecosystem-pypi/tests/unit/plugin_contract_tests.rs b/crates/peryx-ecosystem-pypi/tests/unit/plugin_contract_tests.rs index 68347fc4a..c7cfaa076 100644 --- a/crates/peryx-ecosystem-pypi/tests/unit/plugin_contract_tests.rs +++ b/crates/peryx-ecosystem-pypi/tests/unit/plugin_contract_tests.rs @@ -15,6 +15,7 @@ use peryx_driver::serving::{ use peryx_identity::IndexAcl; use peryx_index::{Index, IndexKind}; use peryx_plugin_registry::OperatorJobOptions; +use peryx_plugin_registry::PluginAuthRegistration; use peryx_policy::{ Policy, RetentionClass, RetentionConfig, RetentionDecision, RetentionOutcome, RetentionPolicy, RetentionSelector, RetentionVisibility, @@ -89,13 +90,10 @@ fn plugin_exposes_identity_defaults_and_driver() { #[test] fn plugin_exposes_trusted_publishing_auth_configuration() { - let plugin = PypiPlugin; - - assert_eq!(plugin.fields(), &["oidc_audience", "trusted_publisher"]); - assert_eq!( - plugin.defaults(), - toml::Table::from_iter([("oidc_audience".to_owned(), toml::Value::String("peryx".to_owned()),)]) - ); + assert!(matches!( + registration().auth, + Some(PluginAuthRegistration::Extension { .. }) + )); } #[test] diff --git a/crates/peryx-plugin-registry/src/lib.rs b/crates/peryx-plugin-registry/src/lib.rs index a2c8ae1cb..6ea8f7eea 100644 --- a/crates/peryx-plugin-registry/src/lib.rs +++ b/crates/peryx-plugin-registry/src/lib.rs @@ -32,7 +32,7 @@ pub struct PluginRegistration { pub rate_limit_principal: Option<&'static dyn RateLimitPrincipal>, pub client_discovery: Option<&'static dyn ClientDiscovery>, pub openapi: &'static dyn EcosystemOpenApi, - pub auth: Option<&'static dyn EcosystemAuth>, + pub auth: Option, pub browse: Option<&'static dyn EcosystemBrowse>, pub snippets: Option<&'static dyn EcosystemSnippet>, pub metadata_migration: Option>, @@ -40,6 +40,16 @@ pub struct PluginRegistration { pub priority: u16, } +#[derive(Clone, Copy)] +pub enum PluginAuthRegistration { + Shared(&'static dyn EcosystemAuth), + Extension { + auth: &'static dyn EcosystemAuth, + fields: &'static [&'static str], + defaults: fn() -> toml::Table, + }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct OperatorJobDefaults { pub item_limit: usize, @@ -205,7 +215,7 @@ impl PluginRegistry { let auth_fields = registrations .iter() .filter_map(|registration| registration.auth) - .flat_map(EcosystemAuth::fields) + .flat_map(PluginAuthRegistration::fields) .copied() .collect(); Self { @@ -432,7 +442,8 @@ impl PluginRegistry { self.registrations .iter() .filter_map(|registration| registration.auth) - .flat_map(EcosystemAuth::defaults) + .filter_map(PluginAuthRegistration::defaults) + .flatten() .collect() } @@ -449,9 +460,13 @@ impl PluginRegistry { return Err(format!("auth: unknown field `{field}`")); } let values = self.auth_extensions(values); - for auth in self.registrations.iter().filter_map(|registration| registration.auth) { + for registration in &self.registrations { + let Some(auth) = registration.auth else { + continue; + }; + let (auth, fields) = auth.auth_and_fields(); auth.validate(PluginAuthConfig { - values: &owned_fields(&values, auth.fields()), + values: &owned_fields(&values, fields), signing_key_configured, token_ttl_secs, indexes, @@ -468,8 +483,12 @@ impl PluginRegistry { values: &toml::Table, ) -> Result<(), String> { let values = self.auth_extensions(values); - for auth in self.registrations.iter().filter_map(|registration| registration.auth) { - auth.install(context, &owned_fields(&values, auth.fields()))?; + for registration in &self.registrations { + let Some(auth) = registration.auth else { + continue; + }; + let (auth, fields) = auth.auth_and_fields(); + auth.install(context, &owned_fields(&values, fields))?; } Ok(()) } @@ -645,7 +664,7 @@ fn validate_registrations(registrations: &[PluginRegistration]) -> Result<(), Re if let Some(field) = registration .auth .into_iter() - .flat_map(EcosystemAuth::fields) + .flat_map(PluginAuthRegistration::fields) .find(|field| !auth_fields.insert(**field)) { return Err(RegistryError::DuplicateAuthField(field)); @@ -668,6 +687,26 @@ fn validate_registrations(registrations: &[PluginRegistration]) -> Result<(), Re Ok(()) } +impl PluginAuthRegistration { + fn auth_and_fields(self) -> (&'static dyn EcosystemAuth, &'static [&'static str]) { + match self { + Self::Shared(auth) => (auth, &[]), + Self::Extension { auth, fields, .. } => (auth, fields), + } + } + + fn fields(self) -> &'static [&'static str] { + self.auth_and_fields().1 + } + + fn defaults(self) -> Option { + match self { + Self::Shared(_) => None, + Self::Extension { defaults, .. } => Some(defaults()), + } + } +} + fn path_prefix(prefix: &str, path: &str) -> bool { let mut path = path.split('/').filter(|segment| !segment.is_empty()); prefix diff --git a/crates/peryx-plugin-registry/tests/unit/registry.rs b/crates/peryx-plugin-registry/tests/unit/registry.rs index 97713ab94..13e89d443 100644 --- a/crates/peryx-plugin-registry/tests/unit/registry.rs +++ b/crates/peryx-plugin-registry/tests/unit/registry.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use crate::{OperatorJobDefaults, OperatorJobRequest, PluginRegistration, PluginRegistry, RegistryError}; +use crate::{ + OperatorJobDefaults, OperatorJobRequest, PluginAuthRegistration, PluginRegistration, PluginRegistry, RegistryError, +}; use axum::Router; use axum::body::Body; use axum::extract::Request; @@ -52,7 +54,13 @@ fn duplicate_registration_values_are_rejected(#[case] case: DuplicateCase, #[cas DuplicateCase::Ecosystem => registrations[1].registration = &SECONDARY_REGISTRATION, DuplicateCase::Priority => registrations[1].priority = registrations[0].priority, DuplicateCase::OperatorJob => registrations[0].operator_jobs = registrations[1].operator_jobs, - DuplicateCase::AuthField => registrations[1].auth = Some(&SECONDARY_AUTH), + DuplicateCase::AuthField => { + registrations[1].auth = Some(PluginAuthRegistration::Extension { + auth: &SECONDARY_AUTH, + fields: &["secondary"], + defaults: toml::Table::new, + }); + } } assert_eq!(PluginRegistry::new(registrations).err(), Some(expected)); } diff --git a/crates/peryx-plugin-registry/tests/unit/support.rs b/crates/peryx-plugin-registry/tests/unit/support.rs index 7b3389bb3..7a8b06cb3 100644 --- a/crates/peryx-plugin-registry/tests/unit/support.rs +++ b/crates/peryx-plugin-registry/tests/unit/support.rs @@ -1,7 +1,7 @@ use std::cell::Cell; use std::sync::Arc; -use crate::{OperatorJob, OperatorJobDefaults, OperatorJobOptions, PluginRegistration}; +use crate::{OperatorJob, OperatorJobDefaults, OperatorJobOptions, PluginAuthRegistration, PluginRegistration}; use axum::extract::Request; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; @@ -44,7 +44,11 @@ pub fn registrations() -> Vec { rate_limit_principal: Some(&SECONDARY_REGISTRATION), client_discovery: Some(&SECONDARY_REGISTRATION), openapi: &SECONDARY_OPEN_API, - auth: Some(&SECONDARY_AUTH), + auth: Some(PluginAuthRegistration::Extension { + auth: &SECONDARY_AUTH, + fields: &["secondary"], + defaults: secondary_auth_defaults, + }), browse: Some(&SECONDARY_BROWSE), snippets: Some(&SNIPPETS), metadata_migration: None, @@ -59,7 +63,11 @@ pub fn registrations() -> Vec { rate_limit_principal: Some(&PRIMARY_REGISTRATION), client_discovery: Some(&PRIMARY_REGISTRATION), openapi: &PRIMARY_OPEN_API, - auth: Some(&PRIMARY_AUTH), + auth: Some(PluginAuthRegistration::Extension { + auth: &PRIMARY_AUTH, + fields: &["primary"], + defaults: primary_auth_defaults, + }), browse: Some(&PRIMARY_BROWSE), snippets: Some(&SNIPPETS), metadata_migration: None, @@ -329,18 +337,6 @@ pub struct Auth(Ecosystem); pub(super) struct AuthInstallMarker(pub(super) Ecosystem); impl EcosystemAuth for Auth { - fn fields(&self) -> &'static [&'static str] { - if self.0 == PRIMARY { - &["primary"] - } else { - &["secondary"] - } - } - - fn defaults(&self) -> toml::Table { - toml::Table::from_iter([(self.fields()[0].to_owned(), toml::Value::Boolean(true))]) - } - fn validate(&self, config: PluginAuthConfig<'_>) -> Result<(), String> { if config .values @@ -363,6 +359,14 @@ impl EcosystemAuth for Auth { } } +fn primary_auth_defaults() -> toml::Table { + toml::Table::from_iter([("primary".to_owned(), toml::Value::Boolean(true))]) +} + +fn secondary_auth_defaults() -> toml::Table { + toml::Table::from_iter([("secondary".to_owned(), toml::Value::Boolean(true))]) +} + struct Browse(Ecosystem); #[async_trait::async_trait] diff --git a/justfile b/justfile index 9a971a710..425c877b3 100644 --- a/justfile +++ b/justfile @@ -195,7 +195,7 @@ mutation shard="0/1" in_place="false" jobs="2" baseline="run" timeout="500": tes {{ if in_place == "true" { "--in-place" } else { "--jobs " + jobs } }} \ --jobserver-tasks "{{ jobs }}" --baseline "{{ baseline }}" \ --timeout "{{ timeout }}" --build-timeout "{{ timeout }}" \ - -- --profile ci -E 'not(test(e2e_live))' + -- --profile mutation -E 'not(test(e2e_live))' # Run the mutation baseline suite. mutation-baseline: test-deps