diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index d962afadb..0f0cdad6e 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -34,6 +34,7 @@ on: - tests/Unit/ServerParityWorkerListingContractTest.mjs - tests/Unit/ServerParityWorkerReplacementContractTest.mjs - tests/Unit/ServerParityOperatorMetricsContractTest.mjs + - tests/Unit/ServerParityServiceCatalogContractTest.mjs - tests/Unit/ServerParityWaitingHistoryContractTest.mjs - tests/Unit/ServerParityTaskRetryContractTest.mjs - tests/Unit/ServerParityPublishedPatchContractTest.mjs @@ -81,6 +82,7 @@ on: - tests/Unit/ServerParityWorkerListingContractTest.mjs - tests/Unit/ServerParityWorkerReplacementContractTest.mjs - tests/Unit/ServerParityOperatorMetricsContractTest.mjs + - tests/Unit/ServerParityServiceCatalogContractTest.mjs - tests/Unit/ServerParityWaitingHistoryContractTest.mjs - tests/Unit/ServerParityTaskRetryContractTest.mjs - tests/Unit/ServerParityPublishedPatchContractTest.mjs @@ -1498,7 +1500,7 @@ jobs: -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \ - "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs > parity-evidence/comparator-tests.txt + "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs > parity-evidence/comparator-tests.txt docker run --rm --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ --network=none --entrypoint node -v "$PWD:/source:ro" -w /source \ "$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-tests.txt diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 25652b850..2843723cb 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -120,7 +120,9 @@ These published PHP routes on this tree have no native handler yet, by family. - **Other families.** - Message streams. - Durable workflow stream items and close. - - Service endpoints and the service catalog. + - Service endpoints. (The service catalog reads from Server 2.11 are served: + native never holds catalog rows, so every list is PHP's empty page and every + identity its not-found answer; the `service-catalog` fixture checks 37 cases.) - Nexus operations. - Deployments. - Bridge adapters. diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 40999546b..bb2e15555 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -216,6 +216,8 @@ pub fn router(runtime: Runtime) -> Router { .route("/api/system/activity-timeouts/pass", post(activity_timeout_pass)) .route("/api/workers", get(list_workers)) .route("/api/task-queues", get(list_task_queues)) + .route("/api/service-catalog/{catalog}", get(service_catalog_list)) + .route("/api/service-catalog/{catalog}/{id}", get(service_catalog_show)) .route("/api/task-queues/{task_queue}", get(describe_task_queue)) .route("/api/task-queues/{task_queue}/build-ids", get(task_queue_build_ids)) .route( @@ -570,6 +572,10 @@ fn named_namespace_operation(method: &str, path: &str) -> bool { if path.starts_with("/api/external-payloads/v1/") { return method == "GET"; } + // The service catalog is namespace-scoped and, in native, always empty. + if path.starts_with("/api/service-catalog/") { + return method == "GET"; + } // Schedules are namespace-scoped rows, as in PHP. if path == "/api/schedules" || path.starts_with("/api/schedules/") { return true; @@ -1858,6 +1864,29 @@ async fn fail_query( ) -> Result> { runtime.finish_query(&task_id, body, true).await.map(Json) } +/// `GET /api/service-catalog/{catalog}`: endpoints, services, operations or calls. +async fn service_catalog_list( + Extension(runtime): Extension, + Path(catalog): Path, + Query(query): Query>, +) -> Response { + match super::service_catalog::Catalog::from_path(&catalog) { + Some(catalog) => super::service_catalog::list(&query, catalog, runtime.namespace()).map(Json).into_response(), + None => unavailable().await, + } +} + +/// `GET /api/service-catalog/{catalog}/{id}`. +async fn service_catalog_show( + Path((catalog, _id)): Path<(String, String)>, + Query(query): Query>, +) -> Response { + match super::service_catalog::Catalog::from_path(&catalog) { + Some(catalog) => super::service_catalog::show(&query, catalog).map(Json).into_response(), + None => unavailable().await, + } +} + async fn unavailable() -> Response { refuse( StatusCode::NOT_IMPLEMENTED, diff --git a/rust/src/runtime/laravel_rules.rs b/rust/src/runtime/laravel_rules.rs index 7c32062cb..28b3b471e 100644 --- a/rust/src/runtime/laravel_rules.rs +++ b/rust/src/runtime/laravel_rules.rs @@ -33,6 +33,8 @@ pub(crate) enum Rule { /// A size floor: a number, string length or item count, by type rule. Min(i64), Max(i64), + /// `between:min,max`: a size within both bounds, inclusive. + Between(i64, i64), In(&'static [&'static str]), /// `date`, for the ISO-8601 shapes `schedule_backfill::php_date` reads. Date, @@ -209,6 +211,11 @@ fn message(rule: Rule, attribute: &str, rules: &[Rule], value: Option<&Value>) - "array" => format!("The {attribute} field must not have more than {limit} items."), _ => format!("The {attribute} field must not be greater than {limit} characters."), }, + Rule::Between(min, max) => match kind { + "numeric" => format!("The {attribute} field must be between {min} and {max}."), + "array" => format!("The {attribute} field must have between {min} and {max} items."), + _ => format!("The {attribute} field must be between {min} and {max} characters."), + }, Rule::NotBlank | Rule::Pattern(_) => format!("The {attribute} field format is invalid."), Rule::Date => format!("The {attribute} field must be a valid date."), Rule::Nullable | Rule::Sometimes => String::new(), @@ -274,6 +281,9 @@ fn passes(rule: Rule, data: &Value, value: Option<&Value>, rules: &[Rule]) -> bo Rule::Max(limit) => value .and_then(|value| size(value, rules)) .is_some_and(|size| size <= limit as f64), + Rule::Between(min, max) => value + .and_then(|value| size(value, rules)) + .is_some_and(|size| size >= min as f64 && size <= max as f64), } } diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index a4dc48bf7..aa16fc797 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -79,6 +79,7 @@ mod search_attribute_upserts; mod search_attributes; mod selection_summary; mod selections; +mod service_catalog; mod sqlite; mod start_definition; mod start_request; diff --git a/rust/src/runtime/service_catalog.rs b/rust/src/runtime/service_catalog.rs new file mode 100644 index 000000000..8596c3ffa --- /dev/null +++ b/rust/src/runtime/service_catalog.rs @@ -0,0 +1,195 @@ +//! Service catalog reads, as published Server 2.11 serves them +//! (`ServiceCatalogReadController`): endpoints, services, operations and +//! service calls, each listed by page or read by identity. +//! +//! Native never holds catalog rows. Takeover refuses a PHP database with +//! services, endpoints, operations or service calls, and native has no path +//! that writes them. Every list is therefore PHP's empty page and every +//! identity PHP's not-found answer, after PHP's validation of the filters. +use super::laravel_rules::{self, Rule}; +use super::laravel_validation; +use super::{Result, RuntimeError}; +use axum::http::StatusCode; +use serde_json::{Map, Value, json}; + +const DEFAULT_PAGE_SIZE: i64 = 50; +const SCOPES: &[&str] = &["relevant", "owned", "caller", "target"]; +const STATUSES: &[&str] = &["pending", "accepted", "started", "completed", "failed", "cancelled"]; +const OUTCOMES: &[&str] = &[ + "accepted", "completed", "cancelled", "timed_out", "rejected_not_found", "rejected_forbidden", + "rejected_throttled", "rejected_concurrency_limited", "rejected_circuit_open", "degraded", "handler_failed", +]; +const STATUS_BUCKETS: &[&str] = &["open", "completed", "failed", "cancelled"]; +const OUTCOME_BUCKETS: &[&str] = &["open", "completed", "failed", "cancelled", "policy"]; + +const NAMESPACE: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::Max(191)]; +const PAGE: &[Rule] = &[Rule::Sometimes, Rule::Integer, Rule::Min(1)]; +const PER_PAGE: &[Rule] = &[Rule::Sometimes, Rule::Integer, Rule::Between(1, 100)]; +const IDENTITY: &[Rule] = &[Rule::Sometimes, Rule::Required, Rule::String, Rule::Max(191)]; +const SCOPE: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(SCOPES)]; +const STATUS: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(STATUSES)]; +const STATUS_BUCKET: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(STATUS_BUCKETS)]; +const OUTCOME: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(OUTCOMES)]; +const OUTCOME_BUCKET: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(OUTCOME_BUCKETS)]; + +/// The catalog a route reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Catalog { + Endpoints, + Services, + Operations, + Calls, +} + +impl Catalog { + pub(crate) fn from_path(segment: &str) -> Option { + match segment { + "endpoints" => Some(Self::Endpoints), + "services" => Some(Self::Services), + "operations" => Some(Self::Operations), + "calls" => Some(Self::Calls), + _ => None, + } + } + + /// The identity filters a list accepts, in PHP's order. + fn identities(self) -> &'static [&'static str] { + match self { + Self::Endpoints => &[], + Self::Services => &["endpoint_id"], + Self::Operations => &["endpoint_id", "service_id"], + Self::Calls => &["endpoint_id", "service_id", "operation_id"], + } + } +} + +/// PHP's `filters`: unknown keys first, then each rule in its order, then +/// the bucket agreement. Returns the normalized query. +fn filters(query: &[(String, String)], catalog: Catalog, detail: bool) -> Result { + let mut rules: Vec<(&'static str, &'static [Rule])> = vec![("namespace", NAMESPACE)]; + if !detail { + rules.push(("page", PAGE)); + rules.push(("per_page", PER_PAGE)); + rules.extend(catalog.identities().iter().map(|id| (*id, IDENTITY))); + if catalog == Catalog::Calls { + rules.extend([("scope", SCOPE), ("status", STATUS), ("status_bucket", STATUS_BUCKET), + ("bucket", STATUS_BUCKET), ("outcome", OUTCOME), ("outcome_bucket", OUTCOME_BUCKET)]); + } + } + let mut unknown: Vec<(String, Vec)> = Vec::new(); + for (key, _) in query { + if !rules.iter().any(|(name, _)| name == key) && !unknown.iter().any(|(seen, _)| seen == key) { + unknown.push((key.clone(), vec!["Unsupported catalog filter.".into()])); + } + } + if let Some(error) = laravel_validation::failure_messages(unknown) { + return Err(error); + } + // A repeated key keeps its last value, as PHP's query parser does. + let mut body = Map::new(); + for (key, value) in query { + body.insert(key.clone(), json!(value)); + } + let body = laravel_rules::normalize(&Value::Object(body)); + laravel_rules::validate(&body, &rules)?; + if let (Some(bucket), Some(status_bucket)) = (body["bucket"].as_str(), body["status_bucket"].as_str()) + && bucket != status_bucket + { + return Err(laravel_validation::failure(vec![("bucket", vec!["Must match status_bucket when both are supplied."])]) + .expect("a message")); + } + Ok(body) +} + +fn whole(value: &Value, default: i64) -> i64 { + value.as_str().and_then(|text| text.parse().ok()).unwrap_or(default) +} + +/// A page of the catalog: always empty in native. +pub(crate) fn list(query: &[(String, String)], catalog: Catalog, namespace: &str) -> Result { + let filters = filters(query, catalog, false)?; + let mut page = json!({"data": [], "current_page": whole(&filters["page"], 1), + "per_page": whole(&filters["per_page"], DEFAULT_PAGE_SIZE), "total": 0, "last_page": 1, "namespace": namespace}); + if catalog == Catalog::Calls { + let status_bucket = match &filters["status_bucket"] { + Value::Null => filters["bucket"].clone(), + bucket => bucket.clone(), + }; + let buckets = |names: &[&str], members: &[&[&str]]| -> Value { + Value::Object(names.iter().zip(members).map(|(name, values)| ((*name).to_owned(), json!(values))).collect()) + }; + let extra = json!({ + "scope": if filters["scope"].is_null() { json!("relevant") } else { filters["scope"].clone() }, + "bucket": status_bucket, "status_bucket": status_bucket, + "outcome": filters["outcome"], "outcome_bucket": filters["outcome_bucket"], + "statuses": STATUSES, "outcomes": OUTCOMES, + "status_buckets": buckets(STATUS_BUCKETS, &[&["pending", "accepted", "started"], &["completed"], &["failed"], &["cancelled"]]), + "outcome_buckets": buckets(OUTCOME_BUCKETS, &[&["accepted"], &["completed", "degraded"], &["timed_out", "handler_failed"], + &["cancelled"], &["rejected_not_found", "rejected_forbidden", "rejected_throttled", + "rejected_concurrency_limited", "rejected_circuit_open"]]), + }); + page.as_object_mut().expect("page").extend(extra.as_object().expect("extra").clone()); + } + Ok(page) +} + +/// One catalog object by identity: never present in native. +pub(crate) fn show(query: &[(String, String)], catalog: Catalog) -> Result { + filters(query, catalog, true)?; + Err(RuntimeError::Protocol { + status: StatusCode::NOT_FOUND, + response: json!({"message": "Catalog object not found.", "reason": "catalog_object_not_found"}), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn query(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs.iter().map(|(key, value)| ((*key).to_owned(), (*value).to_owned())).collect() + } + + fn refusal(result: Result) -> Value { + match result { + Err(RuntimeError::Protocol { status, response }) => json!([status.as_u16(), response]), + other => panic!("expected a refusal, got {other:?}"), + } + } + + #[test] + fn lists_are_empty_pages_with_the_requested_paging() { + assert_eq!(list(&query(&[("page", "2"), ("per_page", "2")]), Catalog::Endpoints, "default").unwrap(), + json!({"data":[],"current_page":2,"per_page":2,"total":0,"last_page":1,"namespace":"default"})); + let calls = list(&query(&[("bucket", "open")]), Catalog::Calls, "second").unwrap(); + assert_eq!([&calls["scope"], &calls["bucket"], &calls["status_bucket"], &calls["outcome"], &calls["namespace"]], + [&json!("relevant"), &json!("open"), &json!("open"), &Value::Null, &json!("second")]); + assert_eq!(calls["outcome_buckets"]["policy"].as_array().unwrap().len(), 5); + } + + #[test] + fn filters_fail_like_laravel() { + assert_eq!(refusal(list(&query(&[("per_page", "0")]), Catalog::Endpoints, "default"))[1]["message"], + "The per page field must be between 1 and 100."); + assert_eq!(refusal(list(&query(&[("page", "x")]), Catalog::Endpoints, "default"))[1]["message"], + "The page field must be an integer."); + let unknown = refusal(list(&query(&[("bogus", "1"), ("other", "2")]), Catalog::Endpoints, "default")); + assert_eq!(unknown[1]["message"], "Unsupported catalog filter. (and 1 more error)"); + assert_eq!(refusal(list(&query(&[("endpoint_id", "x")]), Catalog::Endpoints, "default"))[1]["errors"], + json!({"endpoint_id":["Unsupported catalog filter."]})); + assert_eq!(refusal(list(&query(&[("endpoint_id", "")]), Catalog::Services, "default"))[1]["message"], + "The endpoint id field is required."); + assert_eq!(refusal(list(&query(&[("bucket", "open"), ("status_bucket", "failed")]), Catalog::Calls, "default"))[1], + json!({"message":"Must match status_bucket when both are supplied.","reason":"validation_failed", + "errors":{"bucket":["Must match status_bucket when both are supplied."]}, + "validation_errors":{"bucket":["Must match status_bucket when both are supplied."]}})); + assert_eq!(refusal(show(&query(&[("page", "2")]), Catalog::Endpoints))[1]["errors"], + json!({"page":["Unsupported catalog filter."]})); + } + + #[test] + fn identities_are_not_found() { + assert_eq!(refusal(show(&query(&[("namespace", "default")]), Catalog::Calls)), + json!([404, {"message":"Catalog object not found.","reason":"catalog_object_not_found"}])); + } +} diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index a181ab10d..3ddc09c83 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -12,6 +12,7 @@ import {checkWorkerDeregistration} from './worker-deregistration-contract.mjs'; import {checkLateWorkflowCompletion} from './late-workflow-completion-contract.mjs'; import {checkSearchAttributes} from './search-attribute-contract.mjs'; import {checkStorageTest} from './storage-test-contract.mjs'; +import {checkServiceCatalog} from './service-catalog-contract.mjs'; import {checkSchedulePolicies} from './schedule-policies-contract.mjs'; import {checkScheduleCatalog} from './schedule-catalog-contract.mjs'; import {checkSignalsBeforeWorker} from './signals-before-worker-contract.mjs'; @@ -141,6 +142,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { const lateWorkflowCompletion = fixture.late_workflow_completion ? checkLateWorkflowCompletion(fixture, observation, workflowId) : null; const searchAttributes = fixture.search_attributes ? checkSearchAttributes(fixture, observation, workflowId) : null; const storageTest = fixture.storage_test ? checkStorageTest(fixture, observation, workflowId) : null; + const serviceCatalog = fixture.service_catalog ? checkServiceCatalog(fixture, observation, workflowId) : null; const schedulePolicies = fixture.schedule_policies ? checkSchedulePolicies(fixture, observation, workflowId) : null; const scheduleCatalog = fixture.schedule_catalog ? checkScheduleCatalog(fixture, observation, workflowId) : null; const signalsBeforeWorker = fixture.signals_before_worker ? checkSignalsBeforeWorker(fixture, observation, workflowId) : null; @@ -687,6 +689,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(lateWorkflowCompletion ? {late_workflow_completion: lateWorkflowCompletion} : {}), ...(searchAttributes ? {search_attributes: searchAttributes} : {}), ...(storageTest ? {storage_test: storageTest} : {}), + ...(serviceCatalog ? {service_catalog: serviceCatalog} : {}), ...(schedulePolicies ? {schedule_policies: schedulePolicies} : {}), ...(scheduleCatalog ? {schedule_catalog: scheduleCatalog} : {}), ...(signalsBeforeWorker ? {signals_before_worker: signalsBeforeWorker} : {}), diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index 0b40d67a2..5eb227a9b 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -430,6 +430,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, $lateWorkflowCompletion = isset($fixture['late_workflow_completion']) ? finishHttpLateWorkflowCompletion($client, $fixture, $workflowId) : null; $searchAttributes = isset($fixture['search_attributes']) ? finishHttpSearchAttributes($client, $fixture, $workflowId, $url) : null; $storageTest = isset($fixture['storage_test']) ? finishHttpStorageTest($client, $fixture, $workflowId, $url) : null; + $serviceCatalog = isset($fixture['service_catalog']) ? finishHttpServiceCatalog($client, $fixture, $workflowId, $url) : null; $schedulePolicies = isset($fixture['schedule_policies']) ? finishHttpSchedulePolicies($client, $fixture, $workflowId, $url) : null; $scheduleCatalog = isset($fixture['schedule_catalog']) ? finishHttpSchedulePolicies($client, $fixture, $workflowId, $url, 'schedule_catalog') @@ -529,6 +530,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'late_workflow_completion' => $lateWorkflowCompletion, 'search_attributes' => $searchAttributes, 'storage_test' => $storageTest, + 'service_catalog' => $serviceCatalog, 'schedule_policies' => $schedulePolicies, 'schedule_catalog' => $scheduleCatalog, 'signals_before_worker' => $signalsBeforeWorker, @@ -832,6 +834,7 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/namespace-probe.php'; require __DIR__.'/search-attribute-probe.php'; require __DIR__.'/storage-test-probe.php'; + require __DIR__.'/service-catalog-probe.php'; require __DIR__.'/schedule-policies-probe.php'; require __DIR__.'/signals-before-worker-probe.php'; require __DIR__.'/worker-deregistration-probe.php'; @@ -990,6 +993,9 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['storage_test'])) { $observation['storage_test'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_storage_diagnostic']; } + if ($mode === 'embedded' && isset($fixture['service_catalog'])) { + $observation['service_catalog'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_service_catalog']; + } if ($mode === 'embedded' && isset($fixture['schedule_policies'])) { $observation['schedule_policies'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_schedules']; } diff --git a/scripts/conformance/server-parity/service-catalog-contract.mjs b/scripts/conformance/server-parity/service-catalog-contract.mjs new file mode 100644 index 000000000..d53d72bb6 --- /dev/null +++ b/scripts/conformance/server-parity/service-catalog-contract.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; + +// Reviewed service catalog reads (Server 2.11's ServiceCatalogReadController) +// in a namespace with no catalog objects. Every response is exact; "@own" +// stands for the fixture's own namespace. Embedded applications have no HTTP +// catalog. +export function checkServiceCatalog(fixture, observation, workflowId) { + const definition = fixture.service_catalog; + const state = observation.service_catalog; + const projection = {scope: 'service_catalog', cases: definition.requests.length}; + assert.ok(state && typeof state === 'object', 'actual service catalog observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_has_no_http_service_catalog'}, + 'embedded service catalog explicitly inapplicable'); + return projection; + } + const own = `${workflowId}-catalog`.toLowerCase(); + assert.deepStrictEqual([state.applicable, state.namespace], [true, own], 'catalog namespace'); + const labels = definition.requests.map(request => request.label); + assert.deepStrictEqual(Object.keys(state.responses ?? {}).sort(), [...labels].sort(), 'every catalog request answered'); + const substitute = value => JSON.parse(JSON.stringify(value).split('"@own"').join(JSON.stringify(own))); + for (const label of labels) { + assert.deepStrictEqual(state.responses[label], substitute(definition.expected[label]), label); + } + return projection; +} diff --git a/scripts/conformance/server-parity/service-catalog-probe.php b/scripts/conformance/server-parity/service-catalog-probe.php new file mode 100644 index 000000000..e46ee0942 --- /dev/null +++ b/scripts/conformance/server-parity/service-catalog-probe.php @@ -0,0 +1,36 @@ +createNamespace($own, 'service catalog λ', 30); + $token = getenv('DW_PARITY_TOKEN') ?: null; + $http = new ServiceCatalogHttp(['base_uri' => rtrim($url, '/').'/api/', 'http_errors' => false, + 'connect_timeout' => 1, 'timeout' => 30]); + $state = ['applicable' => true, 'namespace' => $own, 'responses' => []]; + foreach ($definition['requests'] as $request) { + $namespace = match ($request['namespace']) { + '@own' => $own, + '@OWN' => strtoupper($own), + default => $request['namespace'], + }; + $response = $http->get(str_replace('@own', $own, $request['path']), ['headers' => array_filter([ + 'Authorization' => $token === null ? null : 'Bearer '.$token, + 'Accept' => 'application/json', 'X-Namespace' => $namespace, + 'X-Durable-Workflow-Control-Plane-Version' => $request['version'] ? '2' : null, + ], static fn (?string $value): bool => $value !== null)]); + $state['responses'][$request['label']] = [$response->getStatusCode(), + json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)]; + } + + return $state; +} diff --git a/tests/Fixtures/ServerParity/service-catalog.json b/tests/Fixtures/ServerParity/service-catalog.json new file mode 100644 index 000000000..8d4ed7fb1 --- /dev/null +++ b/tests/Fixtures/ServerParity/service-catalog.json @@ -0,0 +1,1076 @@ +{ + "schema": "durable-workflow.server-parity-fixture/v1", + "id": "service-catalog", + "review_state": "reviewed", + "workflow_type": "parity.v1.echo", + "input": { + "message": "service catalog λ", + "count": 9007199254740993, + "optional": null + }, + "typed_value": { + "type": "map", + "value": { + "message": { + "type": "string", + "value": "service catalog λ" + }, + "count": { + "type": "int64", + "value": "9007199254740993" + }, + "optional": { + "type": "null", + "value": null + } + } + }, + "activity": false, + "expected_events": [ + "StartAccepted", + "WorkflowStarted", + "WorkflowCompleted" + ], + "service_catalog": { + "requests": [ + { + "label": "endpoints list", + "path": "service-catalog/endpoints", + "namespace": "default", + "version": true + }, + { + "label": "services list", + "path": "service-catalog/services", + "namespace": "default", + "version": true + }, + { + "label": "operations list", + "path": "service-catalog/operations", + "namespace": "default", + "version": true + }, + { + "label": "calls list", + "path": "service-catalog/calls", + "namespace": "default", + "version": true + }, + { + "label": "endpoints show", + "path": "service-catalog/endpoints/missing-id", + "namespace": "default", + "version": true + }, + { + "label": "services show", + "path": "service-catalog/services/missing-id", + "namespace": "default", + "version": true + }, + { + "label": "operations show", + "path": "service-catalog/operations/missing-id", + "namespace": "default", + "version": true + }, + { + "label": "calls show", + "path": "service-catalog/calls/missing-id", + "namespace": "default", + "version": true + }, + { + "label": "endpoints page2 per2", + "path": "service-catalog/endpoints?page=2&per_page=2", + "namespace": "default", + "version": true + }, + { + "label": "endpoints per0", + "path": "service-catalog/endpoints?per_page=0", + "namespace": "default", + "version": true + }, + { + "label": "endpoints per_max+1", + "path": "service-catalog/endpoints?per_page=1000", + "namespace": "default", + "version": true + }, + { + "label": "endpoints page0", + "path": "service-catalog/endpoints?page=0", + "namespace": "default", + "version": true + }, + { + "label": "endpoints pagex", + "path": "service-catalog/endpoints?page=x", + "namespace": "default", + "version": true + }, + { + "label": "endpoints unknown", + "path": "service-catalog/endpoints?bogus=1&other=2", + "namespace": "default", + "version": true + }, + { + "label": "endpoints namespace", + "path": "service-catalog/endpoints?namespace=default", + "namespace": "default", + "version": true + }, + { + "label": "endpoints endpoint_id", + "path": "service-catalog/endpoints?endpoint_id=x", + "namespace": "default", + "version": true + }, + { + "label": "services endpoint_id", + "path": "service-catalog/services?endpoint_id=e1", + "namespace": "default", + "version": true + }, + { + "label": "services endpoint_id empty", + "path": "service-catalog/services?endpoint_id=", + "namespace": "default", + "version": true + }, + { + "label": "operations ids", + "path": "service-catalog/operations?endpoint_id=e&service_id=s", + "namespace": "default", + "version": true + }, + { + "label": "calls filters", + "path": "service-catalog/calls?scope=owned&status=completed&outcome=completed&status_bucket=completed&outcome_bucket=completed", + "namespace": "default", + "version": true + }, + { + "label": "calls bad enums", + "path": "service-catalog/calls?scope=x&status=y&outcome=z&status_bucket=q&outcome_bucket=w", + "namespace": "default", + "version": true + }, + { + "label": "calls bucket mismatch", + "path": "service-catalog/calls?bucket=open&status_bucket=failed", + "namespace": "default", + "version": true + }, + { + "label": "calls bucket", + "path": "service-catalog/calls?bucket=open", + "namespace": "default", + "version": true + }, + { + "label": "show with page", + "path": "service-catalog/endpoints/x?page=2", + "namespace": "default", + "version": true + }, + { + "label": "show with namespace", + "path": "service-catalog/calls/x?namespace=default", + "namespace": "default", + "version": true + }, + { + "label": "no cpv header", + "path": "service-catalog/endpoints", + "namespace": "default", + "version": false + }, + { + "label": "list per_page max", + "path": "service-catalog/calls?per_page=100&page=3", + "namespace": "default", + "version": true + }, + { + "label": "header default, query second", + "path": "service-catalog/endpoints?namespace=@own", + "namespace": "default", + "version": true + }, + { + "label": "header second, query default", + "path": "service-catalog/endpoints?namespace=default", + "namespace": "@own", + "version": true + }, + { + "label": "no header, query second", + "path": "service-catalog/endpoints?namespace=@own", + "namespace": null, + "version": true + }, + { + "label": "no header, no query", + "path": "service-catalog/endpoints", + "namespace": null, + "version": true + }, + { + "label": "header missing", + "path": "service-catalog/endpoints", + "namespace": "missing", + "version": true + }, + { + "label": "header upper", + "path": "service-catalog/calls", + "namespace": "@OWN", + "version": true + }, + { + "label": "namespace too long", + "path": "service-catalog/calls/x?namespace=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "namespace": "default", + "version": true + }, + { + "label": "per_page decimal", + "path": "service-catalog/endpoints?per_page=2.5", + "namespace": "default", + "version": true + }, + { + "label": "page negative", + "path": "service-catalog/endpoints?page=-1", + "namespace": "default", + "version": true + }, + { + "label": "operation show", + "path": "service-catalog/operations/op-1", + "namespace": "@own", + "version": true + } + ], + "expected": { + "endpoints list": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default" + } + ], + "services list": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default" + } + ], + "operations list": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default" + } + ], + "calls list": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default", + "scope": "relevant", + "bucket": null, + "status_bucket": null, + "outcome": null, + "outcome_bucket": null, + "statuses": [ + "pending", + "accepted", + "started", + "completed", + "failed", + "cancelled" + ], + "outcomes": [ + "accepted", + "completed", + "cancelled", + "timed_out", + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open", + "degraded", + "handler_failed" + ], + "status_buckets": { + "open": [ + "pending", + "accepted", + "started" + ], + "completed": [ + "completed" + ], + "failed": [ + "failed" + ], + "cancelled": [ + "cancelled" + ] + }, + "outcome_buckets": { + "open": [ + "accepted" + ], + "completed": [ + "completed", + "degraded" + ], + "failed": [ + "timed_out", + "handler_failed" + ], + "cancelled": [ + "cancelled" + ], + "policy": [ + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open" + ] + } + } + ], + "endpoints show": [ + 404, + { + "message": "Catalog object not found.", + "reason": "catalog_object_not_found" + } + ], + "services show": [ + 404, + { + "message": "Catalog object not found.", + "reason": "catalog_object_not_found" + } + ], + "operations show": [ + 404, + { + "message": "Catalog object not found.", + "reason": "catalog_object_not_found" + } + ], + "calls show": [ + 404, + { + "message": "Catalog object not found.", + "reason": "catalog_object_not_found" + } + ], + "endpoints page2 per2": [ + 200, + { + "data": [], + "current_page": 2, + "per_page": 2, + "total": 0, + "last_page": 1, + "namespace": "default" + } + ], + "endpoints per0": [ + 422, + { + "message": "The per page field must be between 1 and 100.", + "reason": "validation_failed", + "errors": { + "per_page": [ + "The per page field must be between 1 and 100." + ] + }, + "validation_errors": { + "per_page": [ + "The per page field must be between 1 and 100." + ] + } + } + ], + "endpoints per_max+1": [ + 422, + { + "message": "The per page field must be between 1 and 100.", + "reason": "validation_failed", + "errors": { + "per_page": [ + "The per page field must be between 1 and 100." + ] + }, + "validation_errors": { + "per_page": [ + "The per page field must be between 1 and 100." + ] + } + } + ], + "endpoints page0": [ + 422, + { + "message": "The page field must be at least 1.", + "reason": "validation_failed", + "errors": { + "page": [ + "The page field must be at least 1." + ] + }, + "validation_errors": { + "page": [ + "The page field must be at least 1." + ] + } + } + ], + "endpoints pagex": [ + 422, + { + "message": "The page field must be an integer.", + "reason": "validation_failed", + "errors": { + "page": [ + "The page field must be an integer." + ] + }, + "validation_errors": { + "page": [ + "The page field must be an integer." + ] + } + } + ], + "endpoints unknown": [ + 422, + { + "message": "Unsupported catalog filter. (and 1 more error)", + "reason": "validation_failed", + "errors": { + "bogus": [ + "Unsupported catalog filter." + ], + "other": [ + "Unsupported catalog filter." + ] + }, + "validation_errors": { + "bogus": [ + "Unsupported catalog filter." + ], + "other": [ + "Unsupported catalog filter." + ] + } + } + ], + "endpoints namespace": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default" + } + ], + "endpoints endpoint_id": [ + 422, + { + "message": "Unsupported catalog filter.", + "reason": "validation_failed", + "errors": { + "endpoint_id": [ + "Unsupported catalog filter." + ] + }, + "validation_errors": { + "endpoint_id": [ + "Unsupported catalog filter." + ] + } + } + ], + "services endpoint_id": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default" + } + ], + "services endpoint_id empty": [ + 422, + { + "message": "The endpoint id field is required.", + "reason": "validation_failed", + "errors": { + "endpoint_id": [ + "The endpoint id field is required." + ] + }, + "validation_errors": { + "endpoint_id": [ + "The endpoint id field is required." + ] + } + } + ], + "operations ids": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default" + } + ], + "calls filters": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default", + "scope": "owned", + "bucket": "completed", + "status_bucket": "completed", + "outcome": "completed", + "outcome_bucket": "completed", + "statuses": [ + "pending", + "accepted", + "started", + "completed", + "failed", + "cancelled" + ], + "outcomes": [ + "accepted", + "completed", + "cancelled", + "timed_out", + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open", + "degraded", + "handler_failed" + ], + "status_buckets": { + "open": [ + "pending", + "accepted", + "started" + ], + "completed": [ + "completed" + ], + "failed": [ + "failed" + ], + "cancelled": [ + "cancelled" + ] + }, + "outcome_buckets": { + "open": [ + "accepted" + ], + "completed": [ + "completed", + "degraded" + ], + "failed": [ + "timed_out", + "handler_failed" + ], + "cancelled": [ + "cancelled" + ], + "policy": [ + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open" + ] + } + } + ], + "calls bad enums": [ + 422, + { + "message": "The selected scope is invalid. (and 4 more errors)", + "reason": "validation_failed", + "errors": { + "scope": [ + "The selected scope is invalid." + ], + "status": [ + "The selected status is invalid." + ], + "status_bucket": [ + "The selected status bucket is invalid." + ], + "outcome": [ + "The selected outcome is invalid." + ], + "outcome_bucket": [ + "The selected outcome bucket is invalid." + ] + }, + "validation_errors": { + "scope": [ + "The selected scope is invalid." + ], + "status": [ + "The selected status is invalid." + ], + "status_bucket": [ + "The selected status bucket is invalid." + ], + "outcome": [ + "The selected outcome is invalid." + ], + "outcome_bucket": [ + "The selected outcome bucket is invalid." + ] + } + } + ], + "calls bucket mismatch": [ + 422, + { + "message": "Must match status_bucket when both are supplied.", + "reason": "validation_failed", + "errors": { + "bucket": [ + "Must match status_bucket when both are supplied." + ] + }, + "validation_errors": { + "bucket": [ + "Must match status_bucket when both are supplied." + ] + } + } + ], + "calls bucket": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default", + "scope": "relevant", + "bucket": "open", + "status_bucket": "open", + "outcome": null, + "outcome_bucket": null, + "statuses": [ + "pending", + "accepted", + "started", + "completed", + "failed", + "cancelled" + ], + "outcomes": [ + "accepted", + "completed", + "cancelled", + "timed_out", + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open", + "degraded", + "handler_failed" + ], + "status_buckets": { + "open": [ + "pending", + "accepted", + "started" + ], + "completed": [ + "completed" + ], + "failed": [ + "failed" + ], + "cancelled": [ + "cancelled" + ] + }, + "outcome_buckets": { + "open": [ + "accepted" + ], + "completed": [ + "completed", + "degraded" + ], + "failed": [ + "timed_out", + "handler_failed" + ], + "cancelled": [ + "cancelled" + ], + "policy": [ + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open" + ] + } + } + ], + "show with page": [ + 422, + { + "message": "Unsupported catalog filter.", + "reason": "validation_failed", + "errors": { + "page": [ + "Unsupported catalog filter." + ] + }, + "validation_errors": { + "page": [ + "Unsupported catalog filter." + ] + } + } + ], + "show with namespace": [ + 404, + { + "message": "Catalog object not found.", + "reason": "catalog_object_not_found" + } + ], + "no cpv header": [ + 400, + { + "message": "Missing control-plane version header.", + "reason": "missing_control_plane_version", + "supported_version": "2", + "requested_version": null, + "remediation": "Send the X-Durable-Workflow-Control-Plane-Version: 2 header on control-plane requests." + } + ], + "list per_page max": [ + 200, + { + "data": [], + "current_page": 3, + "per_page": 100, + "total": 0, + "last_page": 1, + "namespace": "default", + "scope": "relevant", + "bucket": null, + "status_bucket": null, + "outcome": null, + "outcome_bucket": null, + "statuses": [ + "pending", + "accepted", + "started", + "completed", + "failed", + "cancelled" + ], + "outcomes": [ + "accepted", + "completed", + "cancelled", + "timed_out", + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open", + "degraded", + "handler_failed" + ], + "status_buckets": { + "open": [ + "pending", + "accepted", + "started" + ], + "completed": [ + "completed" + ], + "failed": [ + "failed" + ], + "cancelled": [ + "cancelled" + ] + }, + "outcome_buckets": { + "open": [ + "accepted" + ], + "completed": [ + "completed", + "degraded" + ], + "failed": [ + "timed_out", + "handler_failed" + ], + "cancelled": [ + "cancelled" + ], + "policy": [ + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open" + ] + } + } + ], + "header default, query second": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default" + } + ], + "header second, query default": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "@own" + } + ], + "no header, query second": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "@own" + } + ], + "no header, no query": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "default" + } + ], + "header missing": [ + 404, + { + "message": "Namespace 'missing' does not exist.", + "reason": "namespace_not_found", + "namespace": "missing", + "remediation": "Register the namespace via POST /api/namespaces, or send an X-Namespace header naming an existing namespace." + } + ], + "header upper": [ + 200, + { + "data": [], + "current_page": 1, + "per_page": 50, + "total": 0, + "last_page": 1, + "namespace": "@own", + "scope": "relevant", + "bucket": null, + "status_bucket": null, + "outcome": null, + "outcome_bucket": null, + "statuses": [ + "pending", + "accepted", + "started", + "completed", + "failed", + "cancelled" + ], + "outcomes": [ + "accepted", + "completed", + "cancelled", + "timed_out", + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open", + "degraded", + "handler_failed" + ], + "status_buckets": { + "open": [ + "pending", + "accepted", + "started" + ], + "completed": [ + "completed" + ], + "failed": [ + "failed" + ], + "cancelled": [ + "cancelled" + ] + }, + "outcome_buckets": { + "open": [ + "accepted" + ], + "completed": [ + "completed", + "degraded" + ], + "failed": [ + "timed_out", + "handler_failed" + ], + "cancelled": [ + "cancelled" + ], + "policy": [ + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open" + ] + } + } + ], + "namespace too long": [ + 422, + { + "message": "The namespace field must not be greater than 191 characters.", + "reason": "validation_failed", + "errors": { + "namespace": [ + "The namespace field must not be greater than 191 characters." + ] + }, + "validation_errors": { + "namespace": [ + "The namespace field must not be greater than 191 characters." + ] + } + } + ], + "per_page decimal": [ + 422, + { + "message": "The per page field must be an integer.", + "reason": "validation_failed", + "errors": { + "per_page": [ + "The per page field must be an integer." + ] + }, + "validation_errors": { + "per_page": [ + "The per page field must be an integer." + ] + } + } + ], + "page negative": [ + 422, + { + "message": "The page field must be at least 1.", + "reason": "validation_failed", + "errors": { + "page": [ + "The page field must be at least 1." + ] + }, + "validation_errors": { + "page": [ + "The page field must be at least 1." + ] + } + } + ], + "operation show": [ + 404, + { + "message": "Catalog object not found.", + "reason": "catalog_object_not_found" + } + ] + } + }, + "contract": "GET /api/service-catalog/{endpoints|services|operations|calls}[/{id}] (published Server 2.11) reads a namespace's service catalog. This fixture's namespace holds no catalog objects: every list is a 200 page with empty data, the requested page and page size (default 1 and 50), total 0, last page 1 and the resolved namespace; calls also report their scope (default relevant), buckets, outcome and the status and outcome vocabularies. Every identity is a 404 catalog_object_not_found. Filters are validated like Laravel: an unsupported filter, a non-integer or out-of-range page or page size, an empty identity, an unknown scope, status, outcome or bucket, and a bucket that disagrees with status_bucket are 422 validation_failed. The namespace comes from X-Namespace, else the namespace query, else default, case-folded; an unknown namespace is 404 namespace_not_found and a missing control-plane version 400. @own is the fixture's own namespace. Embedded applications have no HTTP catalog." +} diff --git a/tests/Unit/ServerParityServiceCatalogContractTest.mjs b/tests/Unit/ServerParityServiceCatalogContractTest.mjs new file mode 100644 index 000000000..23f4e6f98 --- /dev/null +++ b/tests/Unit/ServerParityServiceCatalogContractTest.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import test from 'node:test'; +import {checkServiceCatalog} from '../../scripts/conformance/server-parity/service-catalog-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/service-catalog.json', import.meta.url))); +const id = 'parity-v1-service-catalog'; +const own = `${id}-catalog`; +const observation = () => { + const responses = {}; + for (const {label} of fixture.service_catalog.requests) { + responses[label] = JSON.parse(JSON.stringify(fixture.service_catalog.expected[label]).split('"@own"').join(JSON.stringify(own))); + } + return {mode: 'http', service_catalog: {applicable: true, namespace: own, responses}}; +}; + +test('the service catalog accepts the published answers', () => { + assert.deepStrictEqual(checkServiceCatalog(fixture, observation(), id), {scope: 'service_catalog', cases: 37}); +}); + +test('the fixture covers lists, identities, filters and namespace selection', () => { + const {expected} = fixture.service_catalog; + const statuses = Object.values(expected).map(([status]) => status); + for (const status of [200, 400, 404, 422]) { + assert.ok(statuses.includes(status), `a ${status} case`); + } + assert.deepStrictEqual(expected['header second, query default'][1].namespace, '@own'); + assert.deepStrictEqual(expected['calls list'][1].outcome_buckets.policy.length, 5); +}); + +test('the embedded service catalog is explicitly inapplicable', () => { + checkServiceCatalog(fixture, {mode: 'embedded', service_catalog: {applicable: false, reason: 'embedded_has_no_http_service_catalog'}}, id); + assert.throws(() => checkServiceCatalog(fixture, {mode: 'embedded', service_catalog: {applicable: true}}, id)); +}); + +for (const [label, mutate] of [ + ['a missing response', responses => { delete responses['calls list']; }], + ['an extra response', responses => { responses.extra = [200, {}]; }], + ['a route that is not implemented', responses => { responses['endpoints list'] = [501, {reason: 'rust_capability_not_implemented'}]; }], + ['a non-empty page', responses => { responses['services list'][1].total = 1; }], + ['a default page size other than 50', responses => { responses['operations list'][1].per_page = 25; }], + ['the header namespace losing to the query', responses => { responses['header default, query second'][1].namespace = own; }], + ['an uncased namespace', responses => { responses['header upper'][1].namespace = own.toUpperCase(); }], + ['an identity found', responses => { responses['calls show'] = [200, {}]; }], + ['a generic not found', responses => { responses['endpoints show'][1].reason = 'not_found'; }], + ['a min message for per_page', responses => { responses['endpoints per0'][1].message = 'The per page field must be at least 1.'; }], + ['an accepted unknown filter', responses => { responses['endpoints unknown'] = [200, {}]; }], + ['an accepted page on a detail', responses => { responses['show with page'] = [404, {}]; }], + ['an unchecked bucket disagreement', responses => { responses['calls bucket mismatch'] = [200, {}]; }], + ['a bucket not echoed as status_bucket', responses => { responses['calls bucket'][1].status_bucket = null; }], + ['a missing outcome vocabulary', responses => { delete responses['calls filters'][1].outcomes; }], +]) { + test(`the service catalog rejects ${label}`, () => { + const value = observation(); + mutate(value.service_catalog.responses); + assert.throws(() => checkServiceCatalog(fixture, value, id)); + }); +}