feat: enforce input_schema at call time (ADR-016 INVALID_INPUT leg)
- OperationSpec.input_schema was advertise-only: services/schema disclosed it but no dispatch entry point consulted it (the only enforced schema was publish_schema per-chunk on Pub ops, P-03). - compile input_schema once at registration, same fail-closed rule as publish_schema/CF-003: an un-compilable schema is a registration error, never a silently-skipped contract. Validator cache mirrored on fork and in OperationRegistryBuilder like the publish validators. - check after the ACL gate in all three dispatch entry points: invoke, invoke_streaming, invoke_sink (via resolve_sink_handler, preserving the P-08 single-source-of-truth property). Violations return INVALID_INPUT with the input echoed in details. - raw-JSON-Schema semantics (permissive on unknown keys); adapters wanting closed-by-default keep their own hardening (alkhttp's CompiledInputSchema composes unchanged). - motivated by alktty review #001 L1: the channels open-op wrapper hands the registry-checked input to the OpenHandler as the authoritative params, which requires the registry to validate it. Verification: cargo test 596 lib (6 new: invoke/streaming/sink enforcement, fail-closed registration, permissive-{} compile, fork-carries-validator); --all-features 613; clippy -D warnings clean; fmt clean; doc 0 warnings; publish --dry-run ok. alkhttp 438+16 tests pass against 0.3.1 (registry) — re-verify against the published 0.4.0 after upload.
This commit is contained in:
1 parent
ba94c70eb2
commit
574f58442a
4 files changed
+358
-9
No files matched your search
@@ -4,6 +4,45 @@ All notable changes to this crate are documented here. The format is
|
|||||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-09-05
|
||||||
|
|
||||||
|
`input_schema` is now enforced at call time (the advertise-vs-enforce
|
||||||
|
leg ADR-016 promised: `INVALID_INPUT` is the registry's
|
||||||
|
schema-mismatch error code). Minor bump — behavioral break for any
|
||||||
|
caller that was passing schema-violating inputs to ops declaring a
|
||||||
|
non-trivial `input_schema` and relying on the validation being
|
||||||
|
documentation-only.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`OperationSpec.input_schema` is enforced by the registry on every
|
||||||
|
dispatch.** Until now the schema was advertise-only: `services/schema`
|
||||||
|
disclosed it, the wire and gateway paths disclosed it, but no dispatch
|
||||||
|
entry point consulted it (the only schema alkcall actually enforced
|
||||||
|
was `publish_schema` per-chunk on Pub ops, P-03). The schema now
|
||||||
|
compiles once at registration (same fail-closed rule as
|
||||||
|
`publish_schema`/CF-003: an un-compilable schema is a registration
|
||||||
|
error, never a silently-skipped contract) and is checked after the
|
||||||
|
ACL gate in all three dispatch entry points — `invoke`,
|
||||||
|
`invoke_streaming`, and `invoke_sink` (via `resolve_sink_handler`,
|
||||||
|
preserving the P-08 single-source-of-truth property). Violations
|
||||||
|
return `CallError::invalid_input("input failed input_schema
|
||||||
|
validation")` with the input echoed in `details`. Raw-JSON-Schema
|
||||||
|
semantics (permissive on unknown keys) — adapters that want
|
||||||
|
closed-by-default enforcement keep doing their own hardening, as
|
||||||
|
alkhttp's `CompiledInputSchema` already does; the two checks compose.
|
||||||
|
Built-in ops are unaffected: alkcall's own specs declare the empty
|
||||||
|
object schema (accepts anything) and `services/schema` already
|
||||||
|
returned `INVALID_INPUT` for a missing `name` before its handler ran.
|
||||||
|
Motivated by alktty's code review #001 (L1): the channels open-op
|
||||||
|
wrapper hands the registry-checked `input` to the `OpenHandler` as
|
||||||
|
the authoritative params, which requires the registry to actually
|
||||||
|
validate it.
|
||||||
|
- `OperationRegistry` gained an `input_validator` cache (compiled at
|
||||||
|
registration, mirrored on `fork` and in `OperationRegistryBuilder`
|
||||||
|
like the publish validators); `input_validator()` is `pub(crate)` —
|
||||||
|
the public surface is unchanged.
|
||||||
|
|
||||||
## [0.3.1] - 2026-09-04
|
## [0.3.1] - 2026-09-04
|
||||||
|
|
||||||
Bug-fix release: `services/list-peers` can now list peer-announced
|
Bug-fix release: `services/list-peers` can now list peer-announced
|
||||||
|
|||||||
Generated
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alkcall"
|
name = "alkcall"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "alkcall"
|
name = "alkcall"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ impl HandlerRegistration {
|
|||||||
pub struct OperationRegistry {
|
pub struct OperationRegistry {
|
||||||
operations: RwLock<HashMap<String, HandlerRegistration>>,
|
operations: RwLock<HashMap<String, HandlerRegistration>>,
|
||||||
validators: RwLock<HashMap<String, Option<jsonschema::Validator>>>,
|
validators: RwLock<HashMap<String, Option<jsonschema::Validator>>>,
|
||||||
|
input_validators: RwLock<HashMap<String, Option<jsonschema::Validator>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OperationRegistry {
|
impl OperationRegistry {
|
||||||
@@ -116,23 +117,27 @@ impl OperationRegistry {
|
|||||||
Self {
|
Self {
|
||||||
operations: RwLock::new(HashMap::new()),
|
operations: RwLock::new(HashMap::new()),
|
||||||
validators: RwLock::new(HashMap::new()),
|
validators: RwLock::new(HashMap::new()),
|
||||||
|
input_validators: RwLock::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fork the registry: a deep copy of every registration (handlers
|
/// Fork the registry: a deep copy of every registration (handlers
|
||||||
/// and provenance included) plus the cached publish-schema
|
/// and provenance included) plus the cached publish-schema and
|
||||||
/// validators (review 004 F-02/F-03). The fork is an independent
|
/// input-schema validators (review 004 F-02/F-03). The fork is an
|
||||||
/// registry — later registrations on the original do not appear on
|
/// independent registry — later registrations on the original do
|
||||||
/// the fork and vice versa. It is the per-session composition seam
|
/// not appear on the fork and vice versa. It is the per-session
|
||||||
/// (ADR-047 §4 amendment): fork the base registry, register the
|
/// composition seam (ADR-047 §4 amendment): fork the base
|
||||||
/// per-session ops (generic channel ops, openables, bootstrap ops)
|
/// registry, register the per-session ops (generic channel ops,
|
||||||
/// against the fork, and dispatch the session over the fork.
|
/// openables, bootstrap ops) against the fork, and dispatch the
|
||||||
|
/// session over the fork.
|
||||||
pub fn fork(&self) -> OperationRegistry {
|
pub fn fork(&self) -> OperationRegistry {
|
||||||
let operations = self.operations.read().clone();
|
let operations = self.operations.read().clone();
|
||||||
let validators = self.validators.read().clone();
|
let validators = self.validators.read().clone();
|
||||||
|
let input_validators = self.input_validators.read().clone();
|
||||||
OperationRegistry {
|
OperationRegistry {
|
||||||
operations: RwLock::new(operations),
|
operations: RwLock::new(operations),
|
||||||
validators: RwLock::new(validators),
|
validators: RwLock::new(validators),
|
||||||
|
input_validators: RwLock::new(input_validators),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +168,36 @@ impl OperationRegistry {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compile a spec's `input_schema` for call-time enforcement
|
||||||
|
/// (ADR-016: `INVALID_INPUT` is the registry's schema-mismatch
|
||||||
|
/// error code; the advertise-vs-enforce leg every protocol crate
|
||||||
|
/// leans on — the channels open-op path passes the registry-checked
|
||||||
|
/// `input` to its `OpenHandler` as the authoritative params).
|
||||||
|
/// Un-compilable schemas are rejected at registration time — the
|
||||||
|
/// same fail-closed rule as `publish_schema` (CF-003): registering
|
||||||
|
/// an un-validatable input contract must never produce an operation
|
||||||
|
/// that silently skips validation. A compiled validator is cached
|
||||||
|
/// so the dispatch path does zero per-request work; `None` means
|
||||||
|
/// "no schema declared" (the empty-object schema alkcall's own ops
|
||||||
|
/// declare accepts anything, but it is still compiled so the
|
||||||
|
/// dispatch path never re-parses it).
|
||||||
|
fn validate_and_cache_input_schema(
|
||||||
|
input_validators: &mut HashMap<String, Option<jsonschema::Validator>>,
|
||||||
|
spec: &OperationSpec,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let compiled = match jsonschema::options().build(&spec.input_schema) {
|
||||||
|
Ok(v) => Some(v),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"input_schema for operation {:?} failed to compile: {e}",
|
||||||
|
spec.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input_validators.insert(spec.name.clone(), compiled);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn register(&self, registration: HandlerRegistration) -> Result<(), String> {
|
pub fn register(&self, registration: HandlerRegistration) -> Result<(), String> {
|
||||||
let expected = match registration.spec.op_type {
|
let expected = match registration.spec.op_type {
|
||||||
OperationType::Query | OperationType::Mutation => "Once",
|
OperationType::Query | OperationType::Mutation => "Once",
|
||||||
@@ -181,6 +216,10 @@ impl OperationRegistry {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
Self::validate_and_cache_publish_schema(&mut self.validators.write(), ®istration.spec)?;
|
Self::validate_and_cache_publish_schema(&mut self.validators.write(), ®istration.spec)?;
|
||||||
|
Self::validate_and_cache_input_schema(
|
||||||
|
&mut self.input_validators.write(),
|
||||||
|
®istration.spec,
|
||||||
|
)?;
|
||||||
self.operations
|
self.operations
|
||||||
.write()
|
.write()
|
||||||
.insert(registration.spec.name.clone(), registration);
|
.insert(registration.spec.name.clone(), registration);
|
||||||
@@ -199,6 +238,41 @@ impl OperationRegistry {
|
|||||||
self.validators.read().get(name).cloned().flatten()
|
self.validators.read().get(name).cloned().flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The compiled call-time validator for an op's `input_schema`
|
||||||
|
/// (ADR-016: `INVALID_INPUT` is the registry's schema-mismatch
|
||||||
|
/// error). Compiled at registration time (CF-003 fail-closed — an
|
||||||
|
/// un-compilable schema is a registration error, never a silent
|
||||||
|
/// skip). Every declared schema compiles to `Some`; the `Option`
|
||||||
|
/// exists for map-miss on an unknown op name.
|
||||||
|
pub(crate) fn input_validator(&self, name: &str) -> Option<jsonschema::Validator> {
|
||||||
|
self.input_validators.read().get(name).cloned().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate `input` against the op's compiled `input_schema`
|
||||||
|
/// (ADR-016). Returns the `INVALID_INPUT` envelope on violation.
|
||||||
|
/// Called by all three dispatch entry points (`invoke`,
|
||||||
|
/// `invoke_streaming`, `invoke_sink` via `resolve_sink_handler`)
|
||||||
|
/// after the ACL check and before the handler — the registry-level
|
||||||
|
/// gate every protocol crate's dispatch funnels through.
|
||||||
|
fn check_input_schema(
|
||||||
|
&self,
|
||||||
|
registration: &HandlerRegistration,
|
||||||
|
input: &Value,
|
||||||
|
request_id: &str,
|
||||||
|
) -> Result<(), ResponseEnvelope> {
|
||||||
|
let Some(validator) = self.input_validator(®istration.spec.name) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if validator.is_valid(input) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(ResponseEnvelope::error(
|
||||||
|
request_id.to_string(),
|
||||||
|
CallError::invalid_input("input failed input_schema validation")
|
||||||
|
.with_details(serde_json::json!({ "input": input })),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_operations(&self) -> Vec<OperationSpec> {
|
pub fn list_operations(&self) -> Vec<OperationSpec> {
|
||||||
self.operations
|
self.operations
|
||||||
.read()
|
.read()
|
||||||
@@ -248,6 +322,10 @@ impl OperationRegistry {
|
|||||||
return ResponseEnvelope::forbidden(request_id, message);
|
return ResponseEnvelope::forbidden(request_id, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(envelope) = self.check_input_schema(®istration, &input, &request_id) {
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
match ®istration.handler {
|
match ®istration.handler {
|
||||||
HandlerKind::Once(handler) => {
|
HandlerKind::Once(handler) => {
|
||||||
let handler = Arc::clone(handler);
|
let handler = Arc::clone(handler);
|
||||||
@@ -316,6 +394,10 @@ impl OperationRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(envelope) = self.check_input_schema(®istration, &input, &request_id) {
|
||||||
|
return Box::pin(stream::once(async move { envelope }));
|
||||||
|
}
|
||||||
|
|
||||||
let streaming_handler = match ®istration.handler {
|
let streaming_handler = match ®istration.handler {
|
||||||
HandlerKind::Stream(h) => Arc::clone(h),
|
HandlerKind::Stream(h) => Arc::clone(h),
|
||||||
HandlerKind::Once(_) => {
|
HandlerKind::Once(_) => {
|
||||||
@@ -395,6 +477,8 @@ impl OperationRegistry {
|
|||||||
return Err(ResponseEnvelope::forbidden(request_id, message));
|
return Err(ResponseEnvelope::forbidden(request_id, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.check_input_schema(®istration, input, &request_id)?;
|
||||||
|
|
||||||
match ®istration.handler {
|
match ®istration.handler {
|
||||||
HandlerKind::Sink(h) => Ok(Arc::clone(h)),
|
HandlerKind::Sink(h) => Ok(Arc::clone(h)),
|
||||||
HandlerKind::Once(_) => Err(ResponseEnvelope::error(
|
HandlerKind::Once(_) => Err(ResponseEnvelope::error(
|
||||||
@@ -441,6 +525,7 @@ impl Default for OperationRegistry {
|
|||||||
pub struct OperationRegistryBuilder {
|
pub struct OperationRegistryBuilder {
|
||||||
operations: HashMap<String, HandlerRegistration>,
|
operations: HashMap<String, HandlerRegistration>,
|
||||||
validators: HashMap<String, Option<jsonschema::Validator>>,
|
validators: HashMap<String, Option<jsonschema::Validator>>,
|
||||||
|
input_validators: HashMap<String, Option<jsonschema::Validator>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OperationRegistryBuilder {
|
impl OperationRegistryBuilder {
|
||||||
@@ -448,6 +533,7 @@ impl OperationRegistryBuilder {
|
|||||||
Self {
|
Self {
|
||||||
operations: HashMap::new(),
|
operations: HashMap::new(),
|
||||||
validators: HashMap::new(),
|
validators: HashMap::new(),
|
||||||
|
input_validators: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,6 +546,7 @@ impl OperationRegistryBuilder {
|
|||||||
Self {
|
Self {
|
||||||
operations: registry.operations.read().clone(),
|
operations: registry.operations.read().clone(),
|
||||||
validators: registry.validators.read().clone(),
|
validators: registry.validators.read().clone(),
|
||||||
|
input_validators: registry.input_validators.read().clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +555,10 @@ impl OperationRegistryBuilder {
|
|||||||
&mut self.validators,
|
&mut self.validators,
|
||||||
®istration.spec,
|
®istration.spec,
|
||||||
)?;
|
)?;
|
||||||
|
OperationRegistry::validate_and_cache_input_schema(
|
||||||
|
&mut self.input_validators,
|
||||||
|
®istration.spec,
|
||||||
|
)?;
|
||||||
let name = registration.spec.name.clone();
|
let name = registration.spec.name.clone();
|
||||||
self.operations.insert(name, registration);
|
self.operations.insert(name, registration);
|
||||||
Ok(self)
|
Ok(self)
|
||||||
@@ -477,6 +568,7 @@ impl OperationRegistryBuilder {
|
|||||||
OperationRegistry {
|
OperationRegistry {
|
||||||
operations: RwLock::new(self.operations),
|
operations: RwLock::new(self.operations),
|
||||||
validators: RwLock::new(self.validators),
|
validators: RwLock::new(self.validators),
|
||||||
|
input_validators: RwLock::new(self.input_validators),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2631,4 +2723,222 @@ mod tests {
|
|||||||
let response = registry.invoke("late/op", serde_json::json!({}), ctx).await;
|
let response = registry.invoke("late/op", serde_json::json!({}), ctx).await;
|
||||||
assert!(response.result.is_ok());
|
assert!(response.result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- input_schema call-time enforcement (ADR-016 INVALID_INPUT leg) ----
|
||||||
|
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
|
||||||
|
fn spec_with_input_schema(name: &str, op_type: OperationType, schema: Value) -> OperationSpec {
|
||||||
|
OperationSpec::new(
|
||||||
|
name,
|
||||||
|
op_type,
|
||||||
|
Visibility::External,
|
||||||
|
schema,
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
AccessControl::default(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invoke_rejects_input_failing_input_schema() {
|
||||||
|
let registry = OperationRegistry::new();
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
spec_with_input_schema(
|
||||||
|
"echo",
|
||||||
|
OperationType::Query,
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "n": { "type": "integer" } },
|
||||||
|
"required": ["n"]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HandlerKind::Once(echo_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = root_context("req-schema-1", None, None, false, ScopedPeerEnv::empty());
|
||||||
|
let response = registry.invoke("echo", serde_json::json!({}), ctx).await;
|
||||||
|
let err = response
|
||||||
|
.result
|
||||||
|
.expect_err("missing required field must be rejected");
|
||||||
|
assert_eq!(err.code, "INVALID_INPUT");
|
||||||
|
|
||||||
|
let ctx = root_context("req-schema-2", None, None, false, ScopedPeerEnv::empty());
|
||||||
|
let response = registry
|
||||||
|
.invoke("echo", serde_json::json!({"n": "not-an-int"}), ctx)
|
||||||
|
.await;
|
||||||
|
assert!(response.result.is_err(), "type mismatch must be rejected");
|
||||||
|
|
||||||
|
let ctx = root_context("req-schema-3", None, None, false, ScopedPeerEnv::empty());
|
||||||
|
let response = registry
|
||||||
|
.invoke("echo", serde_json::json!({"n": 1}), ctx)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.result.unwrap(), serde_json::json!({"n": 1}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invoke_streaming_rejects_input_failing_input_schema() {
|
||||||
|
let registry = OperationRegistry::new();
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
spec_with_input_schema(
|
||||||
|
"sub",
|
||||||
|
OperationType::Sub,
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "channel": { "type": "string" } },
|
||||||
|
"required": ["channel"]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HandlerKind::Stream(make_streaming_handler(|_input, ctx| {
|
||||||
|
Box::pin(futures::stream::once(async move {
|
||||||
|
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
|
||||||
|
})) as Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
|
||||||
|
})),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = root_context("req-sub-1", None, None, false, ScopedPeerEnv::empty());
|
||||||
|
let mut responses: Vec<_> = registry
|
||||||
|
.invoke_streaming("sub", serde_json::json!({}), ctx)
|
||||||
|
.collect()
|
||||||
|
.await;
|
||||||
|
assert_eq!(responses.len(), 1);
|
||||||
|
let response = responses.pop().expect("one envelope");
|
||||||
|
let err = response
|
||||||
|
.result
|
||||||
|
.expect_err("schema violation must be rejected");
|
||||||
|
assert_eq!(err.code, "INVALID_INPUT");
|
||||||
|
|
||||||
|
let ctx = root_context("req-sub-2", None, None, false, ScopedPeerEnv::empty());
|
||||||
|
let responses: Vec<_> = registry
|
||||||
|
.invoke_streaming("sub", serde_json::json!({"channel": "c"}), ctx)
|
||||||
|
.collect()
|
||||||
|
.await;
|
||||||
|
assert_eq!(responses.len(), 1);
|
||||||
|
assert!(responses[0].result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invoke_sink_rejects_input_failing_input_schema() {
|
||||||
|
let registry = OperationRegistry::new();
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
spec_with_input_schema(
|
||||||
|
"pub",
|
||||||
|
OperationType::Pub,
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "n": { "type": "integer" } },
|
||||||
|
"required": ["n"]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HandlerKind::Sink(collect_sink_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (chunk_tx, chunk_rx) = futures::channel::mpsc::channel::<Result<Value, CallError>>(8);
|
||||||
|
drop(chunk_tx);
|
||||||
|
let publish_stream: PublishStream = Box::pin(chunk_rx);
|
||||||
|
let ctx = root_context("req-pub-1", None, None, false, ScopedPeerEnv::empty());
|
||||||
|
let response = registry
|
||||||
|
.invoke_sink("pub", serde_json::json!({}), publish_stream, ctx)
|
||||||
|
.await;
|
||||||
|
let err = response
|
||||||
|
.result
|
||||||
|
.expect_err("schema violation must be rejected");
|
||||||
|
assert_eq!(err.code, "INVALID_INPUT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uncompilable_input_schema_is_rejected_at_registration() {
|
||||||
|
let registry = OperationRegistry::new();
|
||||||
|
let result = registry.register(HandlerRegistration::new(
|
||||||
|
spec_with_input_schema(
|
||||||
|
"broken",
|
||||||
|
OperationType::Query,
|
||||||
|
serde_json::json!({
|
||||||
|
"required": "n"
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HandlerKind::Once(echo_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"un-compilable input_schema must fail registration"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.unwrap_err().contains("input_schema"),
|
||||||
|
"error must name the failing schema"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_object_input_schema_compiles_and_accepts_anything() {
|
||||||
|
let registry = OperationRegistry::new();
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
spec_with_input_schema("echo", OperationType::Query, serde_json::json!({})),
|
||||||
|
HandlerKind::Once(echo_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
registry.input_validator("echo").is_some(),
|
||||||
|
"a declared schema compiles even when permissive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fork_carries_input_validator_and_enforces() {
|
||||||
|
let registry = OperationRegistry::new();
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
spec_with_input_schema(
|
||||||
|
"echo",
|
||||||
|
OperationType::Query,
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "n": { "type": "integer" } },
|
||||||
|
"required": ["n"]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HandlerKind::Once(echo_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let fork = registry.fork();
|
||||||
|
let ctx = root_context("req-fork-schema", None, None, false, ScopedPeerEnv::empty());
|
||||||
|
let response = fork.invoke("echo", serde_json::json!({}), ctx).await;
|
||||||
|
let err = response
|
||||||
|
.result
|
||||||
|
.expect_err("the fork must carry the compiled input validator");
|
||||||
|
assert_eq!(err.code, "INVALID_INPUT");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user