Files

878 lines
28 KiB
Rust

//! Authored Rust -> PDK -> validated Component -> fresh Wasmtime Store -> OwnerHost.
//! The deterministic owner replaces storage/network, not the execution adapter;
//! transaction/lease/live policy guarantees are tested by the domain owner suites.
use async_trait::async_trait;
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
use server_api::*;
use std::{
collections::BTreeMap,
fs,
path::Path,
process::Command,
sync::{Arc, Mutex, OnceLock},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::time::Instant;
use yoi_plugin_runtime::{
host::{CancellationToken, Grants, InvocationContext},
owner::{OwnerHost, PluginHostOwner},
*,
};
fn component() -> &'static [u8] {
static COMPONENT: OnceLock<Vec<u8>> = OnceLock::new();
COMPONENT.get_or_init(|| {
let target =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/plugin-service-fixture/guest");
let output = Command::new(env!("CARGO"))
.args([
"build",
"--locked",
"--offline",
"--release",
"--target",
"wasm32-unknown-unknown",
"--manifest-path",
])
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/service-guest/Cargo.toml"))
.arg("--target-dir")
.arg(&target)
.output()
.unwrap();
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(
target.parent().unwrap().join("guest-build.log"),
&output.stderr,
)
.unwrap();
assert!(
output.status.success(),
"Service fixture requires wasm32 std + lld on PATH: {}",
String::from_utf8_lossy(&output.stderr)
);
let core = fs::read(target.join("wasm32-unknown-unknown/release/service_test_guest.wasm"))
.unwrap();
let bytes = wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap();
fs::write(
target.parent().unwrap().join("service.component.wasm"),
&bytes,
)
.unwrap();
bytes
})
}
const MANIFEST: &str = include_str!("service-guest/plugin.toml");
fn package(manifest: &str) -> Result<plugin::CanonicalPluginArtifact, plugin::PluginDiagnostic> {
package_bytes(manifest, component())
}
fn package_bytes(
manifest: &str,
bytes: &[u8],
) -> Result<plugin::CanonicalPluginArtifact, plugin::PluginDiagnostic> {
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/plugin-service-fixture");
fs::create_dir_all(&target).unwrap();
let dir = tempfile::tempdir_in(target).unwrap();
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
fs::write(dir.path().join("plugin.component.wasm"), bytes).unwrap();
let materialized = plugin::read_plugin_directory(
dir.path(),
PluginSourceKind::Explicit,
&PluginPackageLimits::default(),
)?;
plugin::pack_plugin_package(&materialized, &PluginPackageLimits::default())
}
fn compile(ex: &Executor) -> CompiledService {
let p = package(MANIFEST).unwrap();
ex.compile_service(
&p.bytes,
&p.package_digest,
&p.artifact_digest,
p.artifact_size,
)
.unwrap()
}
#[derive(Clone, Copy, Default)]
enum Fault {
#[default]
None,
HttpUnknown,
DataUnknown,
HttpWait,
DataWait,
LookupUnavailable,
LookupExpired,
ReadDenied,
}
#[derive(Default)]
struct State {
calls: Vec<PluginHostRequest>,
writes: usize,
sends: usize,
receipts: BTreeMap<String, PluginDataReceipt>,
http: BTreeMap<String, PluginHttpResponse>,
}
struct Owner {
state: Mutex<State>,
fault: Fault,
waiting: tokio::sync::Notify,
}
impl Owner {
fn new(fault: Fault) -> Arc<Self> {
Arc::new(Self {
state: Mutex::new(State::default()),
fault,
waiting: tokio::sync::Notify::new(),
})
}
}
fn rejected(code: PluginHostErrorCode, effect: PluginEffectObservation) -> PluginHostError {
PluginHostError {
code,
effect,
diagnostic_id: "test".into(),
}
}
#[async_trait]
impl PluginHostOwner for Owner {
async fn call(
&self,
request: PluginHostRequest,
) -> Result<PluginHostResponse, PluginHostError> {
if (matches!(self.fault, Fault::HttpWait)
&& matches!(request, PluginHostRequest::Send { .. }))
|| (matches!(self.fault, Fault::DataWait)
&& matches!(request, PluginHostRequest::DataMutate { .. }))
{
self.waiting.notify_one();
return std::future::pending().await;
}
let mut s = self.state.lock().unwrap();
s.calls.push(request.clone());
Ok(match request {
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
invocation_id: "stable-service-invocation".into(),
expires_at_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
+ 60_000,
},
PluginHostRequest::Authorize { .. } => PluginHostResponse::Authorized,
PluginHostRequest::Log { .. } => PluginHostResponse::Logged,
PluginHostRequest::ResolveTicket { ticket_ref, .. } => {
assert_eq!(ticket_ref, "T-796");
PluginHostResponse::TicketResolved {
ticket_id: "canonical-ticket".into(),
}
}
PluginHostRequest::ReadTicket { .. } => {
if matches!(self.fault, Fault::ReadDenied) {
return Err(rejected(
PluginHostErrorCode::Denied,
PluginEffectObservation::NotDispatched,
));
}
PluginHostResponse::TicketRead {
snapshot: PluginTicketSnapshot {
title: "note".into(),
case_state: PluginTicketCaseState::Open,
content_digest: "digest".into(),
body: "body".into(),
truncated: false,
},
}
}
PluginHostRequest::DataLookupMutation { operation_key, .. } => {
if matches!(self.fault, Fault::LookupUnavailable) {
return Err(rejected(
PluginHostErrorCode::Unavailable,
PluginEffectObservation::NotDispatched,
));
}
PluginHostResponse::DataMutationLookup {
outcome: if matches!(self.fault, Fault::LookupExpired) {
PluginDataMutationLookup::Expired
} else {
s.receipts
.get(&operation_key)
.cloned()
.map(PluginDataMutationLookup::Committed)
.unwrap_or(PluginDataMutationLookup::NotRecorded)
},
}
}
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
cell: PluginDataCell::NeverWritten,
},
PluginHostRequest::DataMutate { operation_key, .. } => {
if matches!(self.fault, Fault::DataUnknown) {
return Err(rejected(
PluginHostErrorCode::Indeterminate,
PluginEffectObservation::Indeterminate,
));
}
s.writes += 1;
let receipt = PluginDataReceipt {
mutation_id: "write-1".into(),
result_digest: "result".into(),
};
assert!(s.receipts.insert(operation_key, receipt.clone()).is_none());
PluginHostResponse::DataMutated { receipt }
}
PluginHostRequest::Open { .. } => PluginHostResponse::Opened,
PluginHostRequest::Send { request, .. } => {
if matches!(self.fault, Fault::HttpUnknown) {
return Err(rejected(
PluginHostErrorCode::Indeterminate,
PluginEffectObservation::Indeterminate,
));
}
let response = if let Some(response) = s.http.get(&request.effect_key) {
response.clone()
} else {
s.sends += 1;
let response = PluginHttpResponse {
status: 200,
headers: vec![],
body: b"ok".to_vec(),
};
s.http.insert(request.effect_key, response.clone());
response
};
PluginHostResponse::Response { response }
}
_ => {
return Err(rejected(
PluginHostErrorCode::Unsupported,
PluginEffectObservation::NotDispatched,
));
}
})
}
}
fn context(c: &CompiledService) -> InvocationContext {
let namespace = ("notes".into(), "note".into(), "1".into());
InvocationContext {
workspace: "workspace".into(),
installation: "installation".into(),
plan: "plan".into(),
subject: "worker".into(),
invocation_id: "ignored-before-begin".into(),
artifact: c.identity().clone(),
tool_name: "ticket_events".into(),
grants: Grants {
logging: true,
connection_slots: ["api".into()].into(),
ticket_events: true,
ticket_read: true,
ticket_data_read: [namespace.clone()].into(),
ticket_data_write: [namespace].into(),
},
deadline: Instant::now() + Duration::from_secs(10),
cancellation: CancellationToken::new(),
}
}
async fn host(c: &CompiledService, owner: Arc<Owner>) -> Arc<OwnerHost> {
Arc::new(
OwnerHost::begin(
owner,
context(c),
PluginConsumer::Consumer {
consumer_id: "consumer".into(),
event_id: "event".into(),
export_name: "ticket_events".into(),
},
)
.await
.unwrap(),
)
}
fn envelope() -> PluginTicketEventEnvelope {
PluginTicketEventEnvelope {
event_id: "event".into(),
event_type: PluginTicketEventType::Changed,
version: 1,
occurred_at: "2026-10-30T00:00:00Z".into(),
ticket_id: "T-796".into(),
domain_event_ref: "domain-event".into(),
content_digest: "digest".into(),
correlation_id: "correlation".into(),
causation_id: None,
attempt: 1,
}
}
#[tokio::test]
async fn authored_service_recovers_with_receipt_lookup_before_rebuilding_a_mutation() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let owner = Owner::new(Fault::None);
let host = host(&c, owner.clone()).await;
assert!(matches!(
ex.service_start(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
r#"{"mode":"normal"}"#
)
.await
.outcome,
Ok(ServiceOutput::Started)
));
for attempt in 1..=2 {
let mut e = envelope();
e.attempt = attempt;
let result = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
r#"{"mode":"normal"}"#,
)
.await;
assert!(
matches!(
result.outcome,
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
),
"{result:?}"
);
assert!(matches!(
result.effect,
host::errors::EffectObservation::ResponseObserved
));
}
assert!(matches!(
ex.service_stop(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
"normal"
)
.await
.outcome,
Ok(ServiceOutput::Stopped)
));
let state = owner.state.lock().unwrap();
assert_eq!(state.writes, 1);
assert_eq!(state.sends, 1);
assert_eq!(
state
.calls
.iter()
.filter(|r| matches!(r, PluginHostRequest::DataGet { .. }))
.count(),
1
);
assert_eq!(
state
.calls
.iter()
.filter(|r| matches!(r, PluginHostRequest::DataLookupMutation { .. }))
.count(),
2
);
}
#[tokio::test]
async fn lifecycle_hooks_allow_logging_but_never_open_resolve_read_data_or_http() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let owner = Owner::new(Fault::None);
let host = host(&c, owner.clone()).await;
for mode in ["open", "resolve", "read", "data", "http"] {
let config = format!(r#"{{"mode":"{mode}"}}"#);
let start = ex
.service_start(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&config,
)
.await;
let stop = ex
.service_stop(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
mode,
)
.await;
assert!(
matches!(start.outcome, Err(ExecutionError::Trap(_))),
"{mode}: {start:?}"
);
assert!(
matches!(stop.outcome, Err(ExecutionError::Trap(_))),
"{mode}: {stop:?}"
);
}
let s = owner.state.lock().unwrap();
assert!(s.calls.iter().all(|r| matches!(
r,
PluginHostRequest::Begin { .. } | PluginHostRequest::Authorize { .. }
)));
}
#[tokio::test]
async fn guest_ack_cannot_hide_unknown_http_data_or_failed_receipt_lookup() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
for fault in [
Fault::HttpUnknown,
Fault::DataUnknown,
Fault::LookupUnavailable,
Fault::LookupExpired,
] {
let host = host(&c, Owner::new(fault)).await;
let result = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"swallow-unknown"}"#,
)
.await;
assert!(
matches!(
result.outcome,
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
),
"{result:?}"
);
assert!(
matches!(
result.effect,
host::errors::EffectObservation::Indeterminate
),
"{result:?}"
);
}
}
#[tokio::test]
async fn cancellation_during_effect_wait_preserves_unknown_observation() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
for fault in [Fault::HttpWait, Fault::DataWait] {
let owner = Owner::new(fault);
let host = host(&c, owner.clone()).await;
let cancellation = host.context().cancellation.clone();
let e = envelope();
let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async {
tokio::join!(
ex.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
r#"{"mode":"normal"}"#
),
async {
owner.waiting.notified().await;
cancellation.cancel();
}
)
})
.await
.expect("effect was not dispatched");
assert!(
matches!(result.outcome, Err(ExecutionError::Cancelled)),
"{result:?}"
);
assert!(
matches!(
result.effect,
host::errors::EffectObservation::Indeterminate
),
"{result:?}"
);
}
}
#[tokio::test]
async fn deleted_metadata_never_resolves_a_ticket_and_live_read_denial_remains_a_failure() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let owner = Owner::new(Fault::ReadDenied);
let host = host(&c, owner.clone()).await;
let result = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"normal"}"#,
)
.await;
assert!(matches!(
result.outcome,
Ok(ServiceOutput::Event(
PluginServiceEventResult::RetryAfter { .. }
))
));
let before = owner.state.lock().unwrap().calls.len();
let mut e = envelope();
e.event_type = PluginTicketEventType::Deleted;
e.content_digest.clear();
let result = ex
.service_event(
&c,
host.context().clone(),
host,
"ticket_events",
&e,
r#"{"mode":"normal"}"#,
)
.await;
assert!(matches!(
result.outcome,
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
));
assert!(
owner.state.lock().unwrap().calls[before..]
.iter()
.all(|r| matches!(r, PluginHostRequest::Authorize { .. }))
);
}
#[test]
fn service_manifest_rejects_old_world_subscription_and_grant_mismatches() {
package(MANIFEST).unwrap();
for bad in [
MANIFEST.replace("@2.2.0", "@2.1.0"),
MANIFEST.replace("ticket_changed", "arbitrary_event"),
MANIFEST.replace("kind = \"ticket_events\"", "kind = \"ticket_read\""),
MANIFEST.replace(
"ticket_changed\", \"ticket_deleted",
"ticket_changed\", \"ticket_changed",
),
MANIFEST.replace("surfaces = [\"service\"]", "surfaces = [\"tool\"]"),
] {
assert!(package(&bad).is_err());
}
}
#[tokio::test]
async fn service_host_and_guest_limits_fail_before_publication() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let host = host(&c, Owner::new(Fault::None)).await;
let invalid = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"oversize-retry"}"#,
)
.await;
assert!(matches!(
invalid.outcome,
Err(ExecutionError::InvalidOutput(_))
));
let mut ctx = host.context().clone();
ctx.grants.ticket_events = false;
let denied = ex
.service_event(
&c,
ctx,
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"normal"}"#,
)
.await;
assert!(matches!(
denied.outcome,
Err(ExecutionError::InvalidInput(_))
));
let mut e = envelope();
e.version = 2;
assert!(matches!(
ex.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
"{}"
)
.await
.outcome,
Err(ExecutionError::InvalidInput(_))
));
e = envelope();
e.correlation_id = "x".repeat(65536);
assert!(matches!(
ex.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
"{}"
)
.await
.outcome,
Err(ExecutionError::InvalidInput(_))
));
let ctx = host.context().clone();
ctx.cancellation.cancel();
assert!(matches!(
ex.service_start(&c, ctx, host.clone(), "ticket_events", "{}")
.await
.outcome,
Err(ExecutionError::Cancelled)
));
}
fn startup_component(logging: bool) -> Vec<u8> {
let mut resolve = wit_parser::Resolve::default();
let (package, _) = resolve
.push_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources/plugin/wit-service"))
.unwrap();
let world = resolve
.select_world(&[package], Some("ticket-service-plugin"))
.unwrap();
let core = wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32);
let wat = wasmprinter::print_bytes(core).unwrap();
let init = wat
.lines()
.find(|l| l.contains("(export \"cm32p2_initialize\""))
.unwrap();
let index = init
.split("(func ")
.nth(1)
.unwrap()
.split(')')
.next()
.unwrap();
let needle = wat
.lines()
.find(|l| l.contains(&format!("(func (;{index};)")))
.unwrap();
assert!(needle.trim_end().ends_with("))"), "{needle}");
let import = wat
.lines()
.find(|l| {
l.contains(if logging {
"yoi:host/logging@2"
} else {
"yoi:host/connections@2"
}) && l.contains(if logging { "\"log\"" } else { "\"open\"" })
})
.unwrap();
let imported_index = import
.split("(func (;")
.nth(1)
.unwrap()
.split(';')
.next()
.unwrap();
let body = if logging {
"i32.const 0 ".repeat(7) + &format!("i32.const 128 call {imported_index}")
} else {
format!("i32.const 0 i32.const 0 i32.const 128 call {imported_index}")
};
let replacement = format!("{} {body})", needle.trim_end().strip_suffix(')').unwrap());
let wat = wat
.replace(needle, &replacement)
.replace("(memory (;0;) 0)", "(memory (;0;) 1)");
let mut core = wat::parse_str(wat).unwrap();
wit_component::embed_component_metadata(
&mut core,
&resolve,
world,
wit_component::StringEncoding::UTF8,
)
.unwrap();
wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap()
}
#[tokio::test]
async fn component_start_sections_never_receive_a_service_export_context() {
let ex = Executor::new(Limits::default()).unwrap();
for logging in [false, true] {
let p = package_bytes(MANIFEST, &startup_component(logging)).unwrap();
let c = ex
.compile_service(
&p.bytes,
&p.package_digest,
&p.artifact_digest,
p.artifact_size,
)
.unwrap();
let owner = Owner::new(Fault::None);
let host = host(&c, owner.clone()).await;
let result = ex
.service_start(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
r#"{"mode":"normal"}"#,
)
.await;
assert!(
matches!(result.outcome, Err(ExecutionError::Trap(_))),
"{result:?}"
);
assert!(owner.state.lock().unwrap().calls.iter().all(|r| matches!(
r,
PluginHostRequest::Begin { .. } | PluginHostRequest::Authorize { .. }
)));
}
}
#[tokio::test]
async fn artifact_delivery_binds_service_names_and_subscriptions_to_component_package() {
use delivery::{
ArtifactCache, ArtifactExpectation, ArtifactSource, ArtifactStream, CacheLimits,
DeliveryError,
};
struct Source {
bytes: Vec<u8>,
metadata: PluginArtifactDeliveryMetadata,
}
#[async_trait]
impl ArtifactSource for Source {
fn server_authority_identity(&self) -> &str {
"service-test-authority"
}
async fn authorize(
&self,
_: &PluginArtifactDeliveryRequest,
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError> {
Ok(self.metadata.clone())
}
async fn fetch(
&self,
_: &PluginArtifactDeliveryRequest,
) -> Result<ArtifactStream, DeliveryError> {
Ok(Box::pin(std::io::Cursor::new(self.bytes.clone())))
}
}
fn metadata<T: serde::Serialize, U: serde::de::DeserializeOwned>(value: &T) -> U {
serde_json::from_value(serde_json::to_value(value).unwrap()).unwrap()
}
let artifact = package(MANIFEST).unwrap();
let verified = plugin::verify_plugin_artifact(
&artifact.bytes,
&artifact.package_digest,
&artifact.artifact_digest,
artifact.artifact_size,
&PluginPackageLimits::default(),
)
.unwrap();
let m = &verified.package.manifest;
let schema = plugin::plugin_config_schema_identity(m);
let descriptor = PluginArtifact {
package_id: m.id.clone(),
name: m.name.clone(),
version: m.version.clone(),
description: m.description.clone(),
package_digest: artifact.package_digest.clone(),
artifact_digest: artifact.artifact_digest.clone(),
artifact_size: artifact.artifact_size.try_into().unwrap(),
schema_version: m.schema_version,
world: m.runtime.world.clone(),
required_imports: m.runtime.required_imports.clone(),
tools: metadata(&m.tools),
services: metadata(&m.services),
connections: metadata(&m.connections),
requested_grants: metadata(&m.requested_grants),
data_collections: metadata(&m.data_collections),
config_schema: PluginSchemaContribution {
identity: schema.identity,
version: schema.version,
digest: schema.digest,
namespace: schema.namespace,
dcdl: m.config_schema.clone(),
},
revoked: false,
revocation_reason: None,
};
assert_eq!(
descriptor.services[0].subscriptions,
["ticket_changed", "ticket_deleted"]
);
let request = PluginArtifactDeliveryRequest {
plan_id: "service-plan".into(),
plan_fingerprint: "service-plan-fingerprint".into(),
installation_id: "installation".into(),
artifact_digest: artifact.artifact_digest.clone(),
};
let directory = tempfile::tempdir().unwrap();
let cache = ArtifactCache::new(directory.path().join("cache"), CacheLimits::default()).unwrap();
let mut source = Source {
bytes: artifact.bytes.clone(),
metadata: PluginArtifactDeliveryMetadata {
workspace_id: "workspace".into(),
request: request.clone(),
artifact: descriptor.clone(),
},
};
let resolved = cache
.resolve(
&source,
"workspace",
&request,
&ArtifactExpectation::new(descriptor.clone()),
)
.await
.unwrap();
assert_eq!(resolved.bytes(), artifact.bytes);
let mut renamed = descriptor.clone();
renamed.services[0].name = "different_service".into();
let mut narrowed = descriptor.clone();
narrowed.services[0].subscriptions = vec!["ticket_changed".into()];
let mut unknown = descriptor.clone();
unknown.services[0].subscriptions = vec!["arbitrary_event".into()];
let mut missing = descriptor.clone();
missing.services.clear();
for tampered in [renamed, narrowed, unknown, missing] {
// Source metadata and the trusted descriptor agree; only verification
// against the actual package can detect this mismatch, including hits.
source.metadata.artifact = tampered.clone();
let expected = ArtifactExpectation::new(tampered);
assert!(matches!(
cache
.resolve(&source, "workspace", &request, &expected)
.await,
Err(DeliveryError::MetadataMismatch)
));
}
source.metadata.artifact = descriptor.clone();
assert_eq!(
cache
.resolve(
&source,
"workspace",
&request,
&ArtifactExpectation::new(descriptor)
)
.await
.unwrap()
.bytes(),
artifact.bytes
);
}