1use std::collections::HashMap;
35use std::fs::File;
36use std::io::Write;
37use std::path::{Path, PathBuf};
38use std::sync::{Arc, Mutex};
39
40use anyhow::anyhow;
41use async_trait::async_trait;
42use mlua::{Function, Lua, LuaSerdeExt, Table, Value, Variadic};
43use rsa::pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPublicKey, LineEnding};
44use rsa::{Pkcs1v15Sign, RsaPrivateKey, RsaPublicKey};
45use sha2::{Digest, Sha512};
46use tracing::debug;
47
48use crate::plugin_models::{
49 PactPluginManifest, PactPluginRpc, PluginInitRequest, PluginInitResponse, PluginInstance,
50};
51use crate::proto::*;
52use crate::proto_v2;
53use crate::utils::{proto_struct_to_json, proto_value_to_json, to_proto_struct, to_proto_value};
54
55pub struct LuaPactPlugin {
57 runtime: Arc<Mutex<Lua>>,
58 manifest: PactPluginManifest,
59 instance_id: String,
60 plugin_capabilities: Vec<String>,
61}
62
63impl std::fmt::Debug for LuaPactPlugin {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("LuaPactPlugin")
66 .field("manifest", &self.manifest)
67 .field("instance_id", &self.instance_id)
68 .field("plugin_capabilities", &self.plugin_capabilities)
69 .finish()
70 }
71}
72
73pub(crate) fn start_lua_plugin(
76 manifest: &PactPluginManifest,
77 instance_id: String,
78) -> anyhow::Result<LuaPactPlugin> {
79 let script_path = resolve_entry_point(manifest)?;
80 debug!("Loading Lua plugin {} from {:?}", manifest.name, script_path);
81
82 let log = Arc::new(LuaPluginLog::open(&manifest.name, &instance_id));
83 let lua = Lua::new();
84 set_package_path(&lua, manifest)?;
85 add_luarocks_path(&lua, manifest)?;
86 register_host_functions(&lua, &manifest.name, &log)?;
87 load_script(&lua, &script_path)?;
88
89 Ok(LuaPactPlugin {
90 runtime: Arc::new(Mutex::new(lua)),
91 manifest: manifest.clone(),
92 instance_id,
93 plugin_capabilities: vec![],
94 })
95}
96
97impl LuaPactPlugin {
98 pub(crate) fn set_plugin_capabilities(&mut self, capabilities: Vec<String>) {
101 self.plugin_capabilities = capabilities;
102 }
103}
104
105struct LuaPluginLog {
113 file: Mutex<Option<File>>,
114}
115
116impl LuaPluginLog {
117 fn open(plugin_name: &str, instance_id: &str) -> Self {
118 LuaPluginLog {
119 file: Mutex::new(crate::child_process::open_plugin_log_file(plugin_name, instance_id)),
120 }
121 }
122
123 fn write_line(&self, line: &str) {
124 if let Ok(mut guard) = self.file.lock()
125 && let Some(file) = guard.as_mut() {
126 let _ = writeln!(file, "{}", line);
127 let _ = file.flush();
128 }
129 }
130}
131
132fn resolve_entry_point(manifest: &PactPluginManifest) -> anyhow::Result<PathBuf> {
133 let entry_point = PathBuf::from(&manifest.entry_point);
134 let path = if entry_point.is_absolute() && entry_point.exists() {
135 entry_point
136 } else {
137 PathBuf::from(&manifest.plugin_dir).join(&manifest.entry_point)
138 };
139 if !path.exists() {
140 return Err(anyhow!("Lua plugin entry point {:?} does not exist", path));
141 }
142 Ok(path)
143}
144
145fn set_package_path(lua: &Lua, manifest: &PactPluginManifest) -> anyhow::Result<()> {
149 let plugin_dir = PathBuf::from(&manifest.plugin_dir);
150 let package: Table = lua.globals().get("package")?;
151 let existing: String = package.get("path").unwrap_or_default();
152 let new_path = format!(
153 "{}/?.lua;{}/?/init.lua;{}",
154 plugin_dir.to_string_lossy(), plugin_dir.to_string_lossy(), existing
155 );
156 package.set("path", new_path)?;
157 Ok(())
158}
159
160const LUAROCKS_LUA_VERSION: &str = "5.4";
163
164fn add_luarocks_path(lua: &Lua, manifest: &PactPluginManifest) -> anyhow::Result<()> {
174 let configured = manifest.plugin_config.get("luaRocksDir").and_then(|v| v.as_str());
175 let rocks_dir = match configured {
176 Some(dir) => PathBuf::from(dir),
177 None => match home::home_dir() {
178 Some(home) => home.join(".luarocks"),
179 None => return Ok(()),
180 },
181 };
182
183 let lua_dir = rocks_dir.join("share").join("lua").join(LUAROCKS_LUA_VERSION);
184 if !lua_dir.exists() {
185 if configured.is_some() {
186 debug!(
187 "Configured luaRocksDir '{}' does not have a share/lua/{} directory, ignoring",
188 rocks_dir.display(), LUAROCKS_LUA_VERSION
189 );
190 }
191 return Ok(());
192 }
193
194 let package: Table = lua.globals().get("package")?;
195 let existing: String = package.get("path").unwrap_or_default();
196 let new_path = format!(
197 "{}/?.lua;{}/?/init.lua;{}",
198 lua_dir.to_string_lossy(), lua_dir.to_string_lossy(), existing
199 );
200 package.set("path", new_path)?;
201 debug!("Added LuaRocks path {:?} for plugin {}", lua_dir, manifest.name);
202 Ok(())
203}
204
205fn load_script(lua: &Lua, script_path: &Path) -> anyhow::Result<()> {
206 let script = std::fs::read_to_string(script_path)?;
207 lua
208 .load(script)
209 .set_name(script_path.to_string_lossy().to_string())
210 .exec()
211 .map_err(|err| anyhow!("Failed to load Lua plugin script {:?} - {}", script_path, err))?;
212 Ok(())
213}
214
215fn register_host_functions(lua: &Lua, plugin_name: &str, log: &Arc<LuaPluginLog>) -> anyhow::Result<()> {
218 let globals = lua.globals();
219
220 let name = plugin_name.to_string();
221 let logger_log = log.clone();
222 globals.set(
223 "logger",
224 lua.create_function(move |_, message: String| {
225 debug!(plugin = name.as_str(), "{}", message);
226 logger_log.write_line(&message);
227 Ok(())
228 })?,
229 )?;
230
231 let print_log = log.clone();
234 globals.set(
235 "print",
236 lua.create_function(move |lua, args: Variadic<Value>| {
237 let tostring: Function = lua.globals().get("tostring")?;
238 let mut parts = Vec::with_capacity(args.len());
239 for arg in args.iter() {
240 parts.push(tostring.call::<String>(arg.clone())?);
241 }
242 print_log.write_line(&parts.join("\t"));
243 Ok(())
244 })?,
245 )?;
246
247 globals.set(
248 "rsa_sign",
249 lua.create_function(|_, (data, key): (mlua::String, String)| {
250 let private_key = RsaPrivateKey::from_pkcs1_pem(&key).map_err(mlua::Error::external)?;
251 let digest = Sha512::digest(data.as_bytes().as_ref());
252 let signature = private_key
253 .sign(Pkcs1v15Sign::new::<Sha512>(), &digest)
254 .map_err(mlua::Error::external)?;
255 Ok(base64::Engine::encode(
256 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
257 signature,
258 ))
259 })?,
260 )?;
261
262 globals.set(
263 "rsa_public_key",
264 lua.create_function(|_, key: String| {
265 let private_key = RsaPrivateKey::from_pkcs1_pem(&key).map_err(mlua::Error::external)?;
266 let public_key = RsaPublicKey::from(&private_key);
267 let pem = public_key
268 .to_pkcs1_pem(LineEnding::LF)
269 .map_err(mlua::Error::external)?;
270 Ok(pem)
271 })?,
272 )?;
273
274 globals.set(
275 "rsa_validate",
276 lua.create_function(|_, (token_parts, algorithm, key): (Vec<String>, String, String)| {
277 if algorithm != "RS512" {
278 return Err(mlua::Error::RuntimeError(format!(
279 "Unsupported JWT algorithm '{}': only RS512 is supported",
280 algorithm
281 )));
282 }
283 if token_parts.len() != 3 {
284 return Err(mlua::Error::RuntimeError(
285 "Expected a 3 part JWT token (header, payload, signature)".to_string(),
286 ));
287 }
288
289 let public_key = match RsaPublicKey::from_pkcs1_pem(&key) {
290 Ok(key) => key,
291 Err(_) => return Ok(false),
292 };
293 let signature = match decode_base64_lenient(&token_parts[2]) {
294 Ok(bytes) => bytes,
295 Err(_) => return Ok(false),
296 };
297 let base_token = format!("{}.{}", token_parts[0], token_parts[1]);
298 let digest = Sha512::digest(base_token.as_bytes());
299 Ok(
300 public_key
301 .verify(Pkcs1v15Sign::new::<Sha512>(), &digest, &signature)
302 .is_ok(),
303 )
304 })?,
305 )?;
306
307 globals.set(
308 "b64_decode_no_pad",
309 lua.create_function(|lua, data: String| {
310 let bytes = decode_base64_lenient(&data).map_err(mlua::Error::external)?;
311 lua.create_string(&bytes)
312 })?,
313 )?;
314
315 Ok(())
316}
317
318fn decode_base64_lenient(data: &str) -> anyhow::Result<Vec<u8>> {
320 use base64::Engine;
321 base64::engine::general_purpose::URL_SAFE
322 .decode(data)
323 .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(data))
324 .map_err(|err| anyhow!("Failed to base64 decode value - {}", err))
325}
326
327fn call_init(
328 lua: &Lua,
329 implementation: &str,
330 version: &str,
331) -> anyhow::Result<Vec<CatalogueEntry>> {
332 let init_fn: Function = lua
333 .globals()
334 .get("init")
335 .map_err(|_| anyhow!("Lua plugin does not define a global 'init' function"))?;
336 let result: Table = init_fn
337 .call((implementation.to_string(), version.to_string()))
338 .map_err(|err| anyhow!("Lua init() function failed - {}", err))?;
339 lua_table_to_catalogue_entries(result)
340}
341
342fn lua_table_to_catalogue_entries(table: Table) -> anyhow::Result<Vec<CatalogueEntry>> {
343 let mut entries = vec![];
344 for entry in table.sequence_values::<Table>() {
345 let entry = entry?;
346 let entry_type_str: String = entry.get("entryType")?;
347 let key: String = entry.get("key")?;
348 let values: Option<HashMap<String, String>> = entry.get("values")?;
349 let entry_type = catalogue_entry::EntryType::from_str_name(&entry_type_str)
350 .ok_or_else(|| anyhow!("Unknown catalogue entry type '{}'", entry_type_str))?;
351 entries.push(CatalogueEntry {
352 r#type: entry_type as i32,
353 key,
354 values: values.unwrap_or_default(),
355 });
356 }
357 Ok(entries)
358}
359
360fn content_type_hint_to_str(hint: i32) -> &'static str {
363 match body::ContentTypeHint::try_from(hint).unwrap_or(body::ContentTypeHint::Default) {
364 body::ContentTypeHint::Default => "DEFAULT",
365 body::ContentTypeHint::Text => "TEXT",
366 body::ContentTypeHint::Binary => "BINARY",
367 }
368}
369
370fn str_to_content_type_hint(hint: &str) -> i32 {
371 match hint {
372 "TEXT" => body::ContentTypeHint::Text as i32,
373 "BINARY" => body::ContentTypeHint::Binary as i32,
374 _ => body::ContentTypeHint::Default as i32,
375 }
376}
377
378fn body_to_lua(lua: &Lua, body: &Option<Body>) -> mlua::Result<Value> {
379 match body {
380 None => Ok(Value::Nil),
381 Some(body) => {
382 let table = lua.create_table()?;
383 table.set("content_type", body.content_type.clone())?;
384 match &body.content {
385 Some(bytes) => table.set("contents", lua.create_string(bytes)?)?,
386 None => table.set("contents", Value::Nil)?,
387 }
388 table.set("content_type_hint", content_type_hint_to_str(body.content_type_hint))?;
389 Ok(Value::Table(table))
390 }
391 }
392}
393
394fn lua_to_body(value: Value) -> anyhow::Result<Option<Body>> {
395 match value {
396 Value::Nil => Ok(None),
397 Value::Table(table) => {
398 let content_type: String = table.get("content_type")?;
399 let contents: Option<mlua::String> = table.get("contents")?;
400 let content_type_hint: Option<String> = table.get("content_type_hint")?;
401 Ok(Some(Body {
402 content_type,
403 content: contents.map(|s| s.as_bytes().to_vec()),
404 content_type_hint: content_type_hint
405 .map(|h| str_to_content_type_hint(&h))
406 .unwrap_or(body::ContentTypeHint::Default as i32),
407 }))
408 }
409 _ => Err(anyhow!("Expected a body table or nil from Lua, got {}", value.type_name())),
410 }
411}
412
413fn matching_rules_to_lua(lua: &Lua, rules: &HashMap<String, MatchingRules>) -> mlua::Result<Table> {
416 let table = lua.create_table()?;
417 for (path, rule_list) in rules {
418 let rules_table = lua.create_table()?;
419 for rule in &rule_list.rule {
420 let rule_table = lua.create_table()?;
421 rule_table.set("type", rule.r#type.clone())?;
422 if let Some(values) = &rule.values {
423 rule_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
424 }
425 rules_table.push(rule_table)?;
426 }
427 table.set(path.clone(), rules_table)?;
428 }
429 Ok(table)
430}
431
432fn plugin_configuration_to_lua(lua: &Lua, config: &Option<PluginConfiguration>) -> mlua::Result<Value> {
433 match config {
434 None => Ok(Value::Nil),
435 Some(config) => {
436 let table = lua.create_table()?;
437 if let Some(interaction_configuration) = &config.interaction_configuration {
438 table.set(
439 "interaction_configuration",
440 lua.to_value(&proto_struct_to_json(interaction_configuration))?,
441 )?;
442 }
443 if let Some(pact_configuration) = &config.pact_configuration {
444 table.set(
445 "pact_configuration",
446 lua.to_value(&proto_struct_to_json(pact_configuration))?,
447 )?;
448 }
449 Ok(Value::Table(table))
450 }
451 }
452}
453
454fn lua_to_plugin_configuration(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<PluginConfiguration>> {
455 match value {
456 None | Some(Value::Nil) => Ok(None),
457 Some(Value::Table(table)) => {
458 let interaction_configuration: Option<serde_json::Value> =
459 table.get::<Option<Value>>("interaction_configuration")?
460 .map(|v| lua.from_value(v))
461 .transpose()?;
462 let pact_configuration: Option<serde_json::Value> =
463 table.get::<Option<Value>>("pact_configuration")?
464 .map(|v| lua.from_value(v))
465 .transpose()?;
466 Ok(Some(PluginConfiguration {
467 interaction_configuration: interaction_configuration.map(|v| to_proto_struct(&as_json_map(v))),
468 pact_configuration: pact_configuration.map(|v| to_proto_struct(&as_json_map(v))),
469 }))
470 }
471 Some(other) => Err(anyhow!("Expected a plugin_config table or nil from Lua, got {}", other.type_name())),
472 }
473}
474
475fn as_json_map(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
476 match value {
477 serde_json::Value::Object(map) => map.into_iter().collect(),
478 _ => HashMap::new(),
479 }
480}
481
482fn compare_request_to_lua(lua: &Lua, request: &CompareContentsRequest) -> mlua::Result<Table> {
485 let table = lua.create_table()?;
486 table.set("expected", body_to_lua(lua, &request.expected)?)?;
487 table.set("actual", body_to_lua(lua, &request.actual)?)?;
488 table.set("allow_unexpected_keys", request.allow_unexpected_keys)?;
489 table.set("rules", matching_rules_to_lua(lua, &request.rules)?)?;
490 table.set(
491 "plugin_configuration",
492 plugin_configuration_to_lua(lua, &request.plugin_configuration)?,
493 )?;
494 Ok(table)
495}
496
497fn lua_to_compare_response(table: Table) -> anyhow::Result<CompareContentsResponse> {
498 let error: Option<String> = table.get("error")?;
499 if let Some(error) = error {
500 return Ok(CompareContentsResponse {
501 error,
502 type_mismatch: None,
503 results: HashMap::new(),
504 });
505 }
506
507 let type_mismatch: Option<Table> = table.get("type-mismatch")?;
508 if let Some(type_mismatch) = type_mismatch {
509 let expected: String = type_mismatch.get("expected")?;
510 let actual: String = type_mismatch.get("actual")?;
511 return Ok(CompareContentsResponse {
512 error: String::new(),
513 type_mismatch: Some(ContentTypeMismatch { expected, actual }),
514 results: HashMap::new(),
515 });
516 }
517
518 let mismatches: Option<Table> = table.get("mismatches")?;
519 let mut results = HashMap::new();
520 if let Some(mismatches) = mismatches {
521 for pair in mismatches.pairs::<String, Value>() {
522 let (path, value) = pair?;
523 let mismatch_list = lua_value_to_content_mismatches(&path, value)?;
524 if !mismatch_list.is_empty() {
525 results.insert(path, ContentMismatches { mismatches: mismatch_list });
526 }
527 }
528 }
529
530 Ok(CompareContentsResponse {
531 error: String::new(),
532 type_mismatch: None,
533 results,
534 })
535}
536
537fn lua_scalar_to_string(value: Value) -> anyhow::Result<Option<String>> {
541 match value {
542 Value::Nil => Ok(None),
543 Value::Boolean(b) => Ok(Some(b.to_string())),
544 Value::Integer(i) => Ok(Some(i.to_string())),
545 Value::Number(n) => Ok(Some(n.to_string())),
546 Value::String(s) => Ok(Some(s.to_str()?.to_string())),
547 other => Err(anyhow!("Expected a scalar mismatch value from Lua, got {}", other.type_name())),
548 }
549}
550
551fn lua_value_to_content_mismatches(path: &str, value: Value) -> anyhow::Result<Vec<ContentMismatch>> {
552 match value {
553 Value::String(s) => Ok(vec![ContentMismatch {
554 expected: None,
555 actual: None,
556 mismatch: s.to_str()?.to_string(),
557 path: path.to_string(),
558 diff: String::new(),
559 mismatch_type: String::new(),
560 }]),
561 Value::Table(table) => {
562 let mismatch_field: Option<String> = table.get("mismatch")?;
564 if let Some(mismatch) = mismatch_field {
565 let expected = lua_scalar_to_string(table.get("expected")?)?;
568 let actual = lua_scalar_to_string(table.get("actual")?)?;
569 let path_override: Option<String> = table.get("path")?;
570 let diff: Option<String> = table.get("diff")?;
571 let mismatch_type: Option<String> = table.get("mismatch_type")?;
572 Ok(vec![ContentMismatch {
573 expected: expected.map(|s| s.into_bytes()),
574 actual: actual.map(|s| s.into_bytes()),
575 mismatch,
576 path: path_override.unwrap_or_else(|| path.to_string()),
577 diff: diff.unwrap_or_default(),
578 mismatch_type: mismatch_type.unwrap_or_default(),
579 }])
580 } else {
581 let mut result = vec![];
582 for entry in table.sequence_values::<Value>() {
583 result.extend(lua_value_to_content_mismatches(path, entry?)?);
584 }
585 Ok(result)
586 }
587 }
588 Value::Nil => Ok(vec![]),
589 other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
590 }
591}
592
593fn lua_to_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<InteractionResponse> {
599 let contents: Option<Value> = table.get("contents")?;
600 let body = match contents {
601 Some(value) => lua_to_body(value)?,
602 None => None,
603 };
604 let plugin_config: Option<Value> = table.get("plugin_config")?;
605 let part_name: Option<String> = table.get("part_name")?;
606 Ok(InteractionResponse {
607 contents: body,
608 rules: HashMap::new(),
609 generators: HashMap::new(),
610 message_metadata: None,
611 plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
612 interaction_markup: String::new(),
613 interaction_markup_type: 0,
614 part_name: part_name.unwrap_or_default(),
615 metadata_rules: HashMap::new(),
616 metadata_generators: HashMap::new(),
617 })
618}
619
620fn lua_to_configure_response(lua: &Lua, table: Table) -> anyhow::Result<ConfigureInteractionResponse> {
625 let mut interactions = vec![];
626 let items: Option<Table> = table.get("interactions")?;
627 if let Some(items) = items {
628 for entry in items.sequence_values::<Table>() {
629 interactions.push(lua_to_interaction_response(lua, entry?)?);
630 }
631 }
632
633 let plugin_config: Option<Value> = table.get("plugin_config")?;
634 Ok(ConfigureInteractionResponse {
635 error: String::new(),
636 interaction: interactions,
637 plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
638 })
639}
640
641fn struct_to_lua(lua: &Lua, value: &Option<prost_types::Struct>) -> mlua::Result<Value> {
645 match value {
646 Some(value) => lua.to_value(&proto_struct_to_json(value)),
647 None => Ok(Value::Nil),
648 }
649}
650
651fn interaction_contents_to_lua(lua: &Lua, contents: &proto_v2::InteractionContents) -> mlua::Result<Table> {
655 let table = lua.create_table()?;
656 table.set("interaction_type", contents.interaction_type.clone())?;
657 table.set("consumer", contents.consumer.clone())?;
658 table.set("provider", contents.provider.clone())?;
659 if let Some(plugin_configuration) = &contents.plugin_configuration {
660 let config_table = lua.create_table()?;
661 if let Some(interaction_configuration) = &plugin_configuration.interaction_configuration {
662 config_table.set("interaction_configuration", lua.to_value(&proto_struct_to_json(interaction_configuration))?)?;
663 }
664 if let Some(pact_configuration) = &plugin_configuration.pact_configuration {
665 config_table.set("pact_configuration", lua.to_value(&proto_struct_to_json(pact_configuration))?)?;
666 }
667 table.set("plugin_configuration", config_table)?;
668 }
669 Ok(table)
670}
671
672fn v2_interaction_data_to_v1(data: &proto_v2::InteractionData) -> anyhow::Result<InteractionData> {
680 use prost::Message;
681 InteractionData::decode(data.encode_to_vec().as_slice())
682 .map_err(|err| anyhow!("Failed to convert V2 InteractionData to V1 - {}", err))
683}
684
685fn metadata_to_lua(lua: &Lua, metadata: &HashMap<String, MetadataValue>) -> mlua::Result<Table> {
689 let table = lua.create_table()?;
690 for (key, value) in metadata {
691 let lua_value = match &value.value {
692 Some(metadata_value::Value::NonBinaryValue(value)) => lua.to_value(&proto_value_to_json(value))?,
693 Some(metadata_value::Value::BinaryValue(bytes)) => {
694 let wrapper = lua.create_table()?;
695 wrapper.set("binary", lua.create_string(bytes)?)?;
696 Value::Table(wrapper)
697 }
698 None => Value::Nil,
699 };
700 table.set(key.clone(), lua_value)?;
701 }
702 Ok(table)
703}
704
705fn lua_to_metadata(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MetadataValue>> {
707 let mut metadata = HashMap::new();
708 if let Some(table) = table {
709 for pair in table.pairs::<String, Value>() {
710 let (key, value) = pair?;
711 let binary: Option<mlua::String> = match &value {
712 Value::Table(wrapper) => wrapper.get("binary")?,
713 _ => None,
714 };
715 let metadata_value = if let Some(binary) = binary {
716 metadata_value::Value::BinaryValue(binary.as_bytes().to_vec())
717 } else {
718 let json: serde_json::Value = lua.from_value(value)?;
719 metadata_value::Value::NonBinaryValue(to_proto_value(&json))
720 };
721 metadata.insert(key, MetadataValue { value: Some(metadata_value) });
722 }
723 }
724 Ok(metadata)
725}
726
727fn interaction_data_to_lua(lua: &Lua, data: &Option<InteractionData>) -> mlua::Result<Value> {
730 match data {
731 None => Ok(Value::Nil),
732 Some(data) => {
733 let table = lua.create_table()?;
734 table.set("body", body_to_lua(lua, &data.body)?)?;
735 table.set("metadata", metadata_to_lua(lua, &data.metadata)?)?;
736 Ok(Value::Table(table))
737 }
738 }
739}
740
741fn lua_to_interaction_data(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<InteractionData>> {
744 match value {
745 None | Some(Value::Nil) => Ok(None),
746 Some(Value::Table(table)) => {
747 let body: Option<Value> = table.get("body")?;
748 let body = match body {
749 Some(value) => lua_to_body(value)?,
750 None => None,
751 };
752 let metadata_table: Option<Table> = table.get("metadata")?;
753 Ok(Some(InteractionData {
754 body,
755 metadata: lua_to_metadata(lua, metadata_table)?,
756 }))
757 }
758 Some(other) => Err(anyhow!("Expected an interaction data table or nil from Lua, got {}", other.type_name())),
759 }
760}
761
762fn lua_to_start_mock_server_response(table: Table) -> anyhow::Result<StartMockServerResponse> {
765 let error: Option<String> = table.get("error")?;
766 if let Some(error) = error {
767 return Ok(StartMockServerResponse {
768 response: Some(start_mock_server_response::Response::Error(error)),
769 });
770 }
771
772 let details: Option<Table> = table.get("details")?;
773 let details = details.ok_or_else(|| {
774 anyhow!("Lua start_mock_server() must return either an 'error' or 'details' field")
775 })?;
776 Ok(StartMockServerResponse {
777 response: Some(start_mock_server_response::Response::Details(MockServerDetails {
778 key: details.get("key")?,
779 port: details.get("port")?,
780 address: details.get("address")?,
781 })),
782 })
783}
784
785fn lua_to_mock_server_results(table: Table) -> anyhow::Result<MockServerResults> {
790 let ok: bool = table.get::<Option<bool>>("ok")?.unwrap_or(true);
791 let mut results = vec![];
792 let results_table: Option<Table> = table.get("results")?;
793 if let Some(results_table) = results_table {
794 for entry in results_table.sequence_values::<Table>() {
795 let entry = entry?;
796 let path: String = entry.get::<Option<String>>("path")?.unwrap_or_default();
797 let error: String = entry.get::<Option<String>>("error")?.unwrap_or_default();
798 let mismatches_value: Value = entry.get("mismatches")?;
799 results.push(MockServerResult {
800 path: path.clone(),
801 error,
802 mismatches: lua_value_to_content_mismatches(&path, mismatches_value)?,
803 });
804 }
805 }
806 Ok(MockServerResults { ok, results })
807}
808
809fn lua_to_verification_preparation_response(
813 lua: &Lua,
814 table: Table,
815) -> anyhow::Result<VerificationPreparationResponse> {
816 let error: Option<String> = table.get("error")?;
817 if let Some(error) = error {
818 return Ok(VerificationPreparationResponse {
819 response: Some(verification_preparation_response::Response::Error(error)),
820 });
821 }
822
823 let data: Option<Value> = table.get("interaction_data")?;
824 let data = data.ok_or_else(|| {
825 anyhow!("Lua prepare_interaction_for_verification() must return either an 'error' or 'interaction_data' field")
826 })?;
827 let interaction_data = lua_to_interaction_data(lua, Some(data))?
828 .unwrap_or_else(|| InteractionData { body: None, metadata: HashMap::new() });
829 Ok(VerificationPreparationResponse {
830 response: Some(verification_preparation_response::Response::InteractionData(interaction_data)),
831 })
832}
833
834fn lua_to_verification_result_item(value: Value) -> anyhow::Result<VerificationResultItem> {
837 match value {
838 Value::String(s) => Ok(VerificationResultItem {
839 result: Some(verification_result_item::Result::Error(s.to_str()?.to_string())),
840 }),
841 Value::Table(table) => {
842 let mismatch: Option<String> = table.get("mismatch")?;
843 let path: Option<String> = table.get("path")?;
844 let expected = lua_scalar_to_string(table.get("expected")?)?;
845 let actual = lua_scalar_to_string(table.get("actual")?)?;
846 let diff: Option<String> = table.get("diff")?;
847 let mismatch_type: Option<String> = table.get("mismatch_type")?;
848 Ok(VerificationResultItem {
849 result: Some(verification_result_item::Result::Mismatch(ContentMismatch {
850 expected: expected.map(|s| s.into_bytes()),
851 actual: actual.map(|s| s.into_bytes()),
852 mismatch: mismatch.unwrap_or_default(),
853 path: path.unwrap_or_default(),
854 diff: diff.unwrap_or_default(),
855 mismatch_type: mismatch_type.unwrap_or_default(),
856 })),
857 })
858 }
859 other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
860 }
861}
862
863fn lua_to_verify_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<VerifyInteractionResponse> {
868 let error: Option<String> = table.get("error")?;
869 if let Some(error) = error {
870 return Ok(VerifyInteractionResponse {
871 response: Some(verify_interaction_response::Response::Error(error)),
872 });
873 }
874
875 let result_table: Option<Table> = table.get("result")?;
876 let result_table = result_table
877 .ok_or_else(|| anyhow!("Lua verify_interaction() must return either an 'error' or 'result' field"))?;
878
879 let success: bool = result_table.get::<Option<bool>>("success")?.unwrap_or(false);
880 let response_data: Option<Value> = result_table.get("response_data")?;
881 let response_data = lua_to_interaction_data(lua, response_data)?;
882
883 let mut mismatches = vec![];
884 let mismatches_value: Option<Value> = result_table.get("mismatches")?;
885 if let Some(Value::Table(mismatches_table)) = mismatches_value {
886 for entry in mismatches_table.sequence_values::<Value>() {
887 mismatches.push(lua_to_verification_result_item(entry?)?);
888 }
889 }
890
891 let output: Option<Vec<String>> = result_table.get("output")?;
892
893 Ok(VerifyInteractionResponse {
894 response: Some(verify_interaction_response::Response::Result(VerificationResult {
895 success,
896 response_data,
897 mismatches,
898 output: output.unwrap_or_default(),
899 })),
900 })
901}
902
903#[async_trait]
904impl PactPluginRpc for LuaPactPlugin {
905 async fn init_plugin(&mut self, request: PluginInitRequest) -> anyhow::Result<PluginInitResponse> {
906 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
907 let catalogue = call_init(&lua, &request.implementation, &request.version)?;
908 Ok(PluginInitResponse {
909 catalogue,
910 plugin_capabilities: vec![],
911 })
912 }
913}
914
915#[async_trait]
916impl PluginInstance for LuaPactPlugin {
917 fn manifest(&self) -> &PactPluginManifest {
918 &self.manifest
919 }
920
921 fn instance_id(&self) -> &str {
922 &self.instance_id
923 }
924
925 fn has_capability(&self, capability: &str) -> bool {
926 self.plugin_capabilities.iter().any(|c| c == capability)
927 }
928
929 async fn compare_contents(
930 &self,
931 request: CompareContentsRequest,
932 ) -> anyhow::Result<CompareContentsResponse> {
933 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
934 let match_fn: Function = lua
935 .globals()
936 .get("match_contents")
937 .map_err(|_| anyhow!("Lua plugin does not define a global 'match_contents' function"))?;
938 let request_table = compare_request_to_lua(&lua, &request)?;
939 let result: Table = match_fn
940 .call(request_table)
941 .map_err(|err| anyhow!("Lua match_contents() function failed - {}", err))?;
942 lua_to_compare_response(result)
943 }
944
945 async fn configure_interaction(
946 &self,
947 request: ConfigureInteractionRequest,
948 ) -> anyhow::Result<ConfigureInteractionResponse> {
949 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
950 let configure_fn: Function = lua
951 .globals()
952 .get("configure_interaction")
953 .map_err(|_| anyhow!("Lua plugin does not define a global 'configure_interaction' function"))?;
954 let config: Value = match &request.contents_config {
955 Some(config) => lua.to_value(&proto_struct_to_json(config))?,
956 None => Value::Nil,
957 };
958 let result: Table = configure_fn
959 .call((request.content_type.clone(), config))
960 .map_err(|err| anyhow!("Lua configure_interaction() function failed - {}", err))?;
961 lua_to_configure_response(&lua, result)
962 }
963
964 async fn generate_content(
965 &self,
966 request: GenerateContentRequest,
967 ) -> anyhow::Result<GenerateContentResponse> {
968 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
969 let generate_fn: Option<Function> = lua.globals().get("generate_content")?;
970 match generate_fn {
971 None => Ok(GenerateContentResponse {
972 contents: request.contents,
973 }),
974 Some(generate_fn) => {
975 let contents = body_to_lua(&lua, &request.contents)?;
976 let generators = lua.create_table()?;
977 for (path, generator) in &request.generators {
978 let generator_table = lua.create_table()?;
979 generator_table.set("type", generator.r#type.clone())?;
980 if let Some(values) = &generator.values {
981 generator_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
982 }
983 generators.set(path.clone(), generator_table)?;
984 }
985 let test_mode = match generate_content_request::TestMode::try_from(request.test_mode)
986 .unwrap_or(generate_content_request::TestMode::Unknown)
987 {
988 generate_content_request::TestMode::Consumer => "Consumer",
989 generate_content_request::TestMode::Provider => "Provider",
990 generate_content_request::TestMode::Unknown => "Unknown",
991 };
992 let result: Value = generate_fn
993 .call((contents, generators, test_mode))
994 .map_err(|err| anyhow!("Lua generate_content() function failed - {}", err))?;
995 Ok(GenerateContentResponse {
996 contents: lua_to_body(result)?,
997 })
998 }
999 }
1000 }
1001
1002 async fn start_mock_server(
1003 &self,
1004 request: StartMockServerRequest,
1005 ) -> anyhow::Result<StartMockServerResponse> {
1006 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1007 let start_fn: Function = lua
1008 .globals()
1009 .get("start_mock_server")
1010 .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1011 let request_table = lua.create_table()?;
1012 request_table.set("host_interface", request.host_interface)?;
1013 request_table.set("port", request.port)?;
1014 request_table.set("tls", request.tls)?;
1015 request_table.set("pact", request.pact)?;
1016 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1017 let result: Table = start_fn
1018 .call(request_table)
1019 .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1020 lua_to_start_mock_server_response(result)
1021 }
1022
1023 async fn start_mock_server_v2(
1024 &self,
1025 request: proto_v2::StartMockServerRequest,
1026 ) -> anyhow::Result<StartMockServerResponse> {
1027 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1028 let start_fn: Function = lua
1029 .globals()
1030 .get("start_mock_server")
1031 .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1032 let request_table = lua.create_table()?;
1033 request_table.set("host_interface", request.host_interface)?;
1034 request_table.set("port", request.port)?;
1035 request_table.set("tls", request.tls)?;
1036 let interactions_table = lua.create_table()?;
1037 for interaction in &request.interactions {
1038 interactions_table.push(interaction_contents_to_lua(&lua, interaction)?)?;
1039 }
1040 request_table.set("interactions", interactions_table)?;
1041 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1042 let result: Table = start_fn
1043 .call(request_table)
1044 .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1045 lua_to_start_mock_server_response(result)
1046 }
1047
1048 async fn shutdown_mock_server(
1049 &self,
1050 request: ShutdownMockServerRequest,
1051 ) -> anyhow::Result<ShutdownMockServerResponse> {
1052 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1053 let shutdown_fn: Function = lua
1054 .globals()
1055 .get("shutdown_mock_server")
1056 .map_err(|_| anyhow!("Lua plugin does not define a global 'shutdown_mock_server' function"))?;
1057 let result: Table = shutdown_fn
1058 .call(request.server_key)
1059 .map_err(|err| anyhow!("Lua shutdown_mock_server() function failed - {}", err))?;
1060 let results = lua_to_mock_server_results(result)?;
1061 Ok(ShutdownMockServerResponse {
1062 ok: results.ok,
1063 results: results.results,
1064 })
1065 }
1066
1067 async fn get_mock_server_results(
1068 &self,
1069 request: MockServerRequest,
1070 ) -> anyhow::Result<MockServerResults> {
1071 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1072 let results_fn: Function = lua
1073 .globals()
1074 .get("get_mock_server_results")
1075 .map_err(|_| anyhow!("Lua plugin does not define a global 'get_mock_server_results' function"))?;
1076 let result: Table = results_fn
1077 .call(request.server_key)
1078 .map_err(|err| anyhow!("Lua get_mock_server_results() function failed - {}", err))?;
1079 lua_to_mock_server_results(result)
1080 }
1081
1082 async fn prepare_interaction_for_verification(
1083 &self,
1084 request: VerificationPreparationRequest,
1085 ) -> anyhow::Result<VerificationPreparationResponse> {
1086 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1087 let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1088 anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1089 })?;
1090 let request_table = lua.create_table()?;
1091 request_table.set("pact", request.pact)?;
1092 request_table.set("interaction_key", request.interaction_key)?;
1093 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1094 let result: Table = prepare_fn
1095 .call(request_table)
1096 .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1097 lua_to_verification_preparation_response(&lua, result)
1098 }
1099
1100 async fn prepare_interaction_for_verification_v2(
1101 &self,
1102 request: proto_v2::VerificationPreparationRequest,
1103 ) -> anyhow::Result<VerificationPreparationResponse> {
1104 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1105 let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1106 anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1107 })?;
1108 let request_table = lua.create_table()?;
1109 if let Some(interaction_contents) = &request.interaction_contents {
1110 request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1111 }
1112 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1113 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1114 let result: Table = prepare_fn
1115 .call(request_table)
1116 .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1117 lua_to_verification_preparation_response(&lua, result)
1118 }
1119
1120 async fn verify_interaction(
1121 &self,
1122 request: VerifyInteractionRequest,
1123 ) -> anyhow::Result<VerifyInteractionResponse> {
1124 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1125 let verify_fn: Function = lua
1126 .globals()
1127 .get("verify_interaction")
1128 .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1129 let request_table = lua.create_table()?;
1130 request_table.set("interaction_data", interaction_data_to_lua(&lua, &request.interaction_data)?)?;
1131 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1132 request_table.set("pact", request.pact)?;
1133 request_table.set("interaction_key", request.interaction_key)?;
1134 let result: Table = verify_fn
1135 .call(request_table)
1136 .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1137 lua_to_verify_interaction_response(&lua, result)
1138 }
1139
1140 async fn verify_interaction_v2(
1141 &self,
1142 request: proto_v2::VerifyInteractionRequest,
1143 ) -> anyhow::Result<VerifyInteractionResponse> {
1144 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1145 let verify_fn: Function = lua
1146 .globals()
1147 .get("verify_interaction")
1148 .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1149 let request_table = lua.create_table()?;
1150 let interaction_data = request.interaction_data.as_ref()
1151 .map(v2_interaction_data_to_v1)
1152 .transpose()?;
1153 request_table.set("interaction_data", interaction_data_to_lua(&lua, &interaction_data)?)?;
1154 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1155 if let Some(interaction_contents) = &request.interaction_contents {
1156 request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1157 }
1158 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1159 let result: Table = verify_fn
1160 .call(request_table)
1161 .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1162 lua_to_verify_interaction_response(&lua, result)
1163 }
1164
1165 async fn update_catalogue(&self, request: Catalogue) -> anyhow::Result<()> {
1166 let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1167 let update_fn: Option<Function> = lua.globals().get("update_catalogue")?;
1168 if let Some(update_fn) = update_fn {
1169 let table = lua.create_table()?;
1170 for entry in &request.catalogue {
1171 let entry_table = lua.create_table()?;
1172 let entry_type = catalogue_entry::EntryType::try_from(entry.r#type)
1173 .unwrap_or(catalogue_entry::EntryType::ContentMatcher);
1174 entry_table.set("entryType", entry_type.as_str_name())?;
1175 entry_table.set("key", entry.key.clone())?;
1176 entry_table.set("values", entry.values.clone())?;
1177 table.push(entry_table)?;
1178 }
1179 update_fn
1180 .call::<()>(table)
1181 .map_err(|err| anyhow!("Lua update_catalogue() function failed - {}", err))?;
1182 }
1183 Ok(())
1184 }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use std::collections::HashMap;
1190
1191 use super::*;
1192
1193 fn jwt_manifest() -> PactPluginManifest {
1194 let plugin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../plugins/jwt");
1200 assert!(plugin_dir.exists(), "plugins/jwt directory should exist at {:?}", plugin_dir);
1201 PactPluginManifest {
1202 plugin_dir: plugin_dir.to_string_lossy().to_string(),
1203 plugin_interface_version: 1,
1204 name: "jwt".to_string(),
1205 version: "0.0.0".to_string(),
1206 executable_type: "lua".to_string(),
1207 minimum_required_version: None,
1208 entry_point: "plugin.lua".to_string(),
1209 entry_points: HashMap::new(),
1210 args: None,
1211 dependencies: None,
1212 plugin_config: HashMap::new(),
1213 }
1214 }
1215
1216 const PRIVATE_KEY: &str = include_str!("../tests/fixtures/jwt-test-key.pem");
1217
1218 #[test]
1219 fn loads_pure_lua_packages_from_a_configured_luarocks_directory() {
1220 let rocks_root = tempdir::TempDir::new("luarocks-test").unwrap();
1221 let lua_dir = rocks_root.path().join("share").join("lua").join(LUAROCKS_LUA_VERSION);
1222 std::fs::create_dir_all(&lua_dir).unwrap();
1223 std::fs::write(
1224 lua_dir.join("greeter.lua"),
1225 r#"return { hello = function() return "hello from luarocks" end }"#,
1226 ).unwrap();
1227
1228 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1229 std::fs::write(
1230 plugin_dir.path().join("entry.lua"),
1231 r#"
1232 local greeter = require "greeter"
1233 GREETER_RESULT = greeter.hello()
1234 "#,
1235 ).unwrap();
1236
1237 let mut plugin_config = HashMap::new();
1238 plugin_config.insert(
1239 "luaRocksDir".to_string(),
1240 serde_json::Value::String(rocks_root.path().to_string_lossy().to_string()),
1241 );
1242
1243 let manifest = PactPluginManifest {
1244 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1245 plugin_interface_version: 1,
1246 name: "luarocks-test".to_string(),
1247 version: "0.0.0".to_string(),
1248 executable_type: "lua".to_string(),
1249 minimum_required_version: None,
1250 entry_point: "entry.lua".to_string(),
1251 entry_points: HashMap::new(),
1252 args: None,
1253 dependencies: None,
1254 plugin_config,
1255 };
1256
1257 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1258 let lua = plugin.runtime.lock().unwrap();
1259 let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1260 assert_eq!(result, "hello from luarocks");
1261 }
1262
1263 #[test]
1264 fn loads_a_vendored_directory_style_module_from_the_plugin_directory() {
1265 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1266 let module_dir = plugin_dir.path().join("greeter");
1267 std::fs::create_dir_all(&module_dir).unwrap();
1268 std::fs::write(
1269 module_dir.join("init.lua"),
1270 r#"return { hello = function() return "hello from a vendored module" end }"#,
1271 ).unwrap();
1272 std::fs::write(
1273 plugin_dir.path().join("entry.lua"),
1274 r#"
1275 local greeter = require "greeter"
1276 GREETER_RESULT = greeter.hello()
1277 "#,
1278 ).unwrap();
1279
1280 let manifest = PactPluginManifest {
1281 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1282 plugin_interface_version: 1,
1283 name: "vendored-module-test".to_string(),
1284 version: "0.0.0".to_string(),
1285 executable_type: "lua".to_string(),
1286 minimum_required_version: None,
1287 entry_point: "entry.lua".to_string(),
1288 entry_points: HashMap::new(),
1289 args: None,
1290 dependencies: None,
1291 plugin_config: HashMap::new(),
1292 };
1293
1294 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1295 let lua = plugin.runtime.lock().unwrap();
1296 let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1297 assert_eq!(result, "hello from a vendored module");
1298 }
1299
1300 #[test]
1301 fn loads_a_vendored_module_when_the_entry_point_is_in_a_subdirectory() {
1302 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1306 std::fs::write(
1307 plugin_dir.path().join("greeter.lua"),
1308 r#"return { hello = function() return "hello from the plugin root" end }"#,
1309 ).unwrap();
1310 let src_dir = plugin_dir.path().join("src");
1311 std::fs::create_dir_all(&src_dir).unwrap();
1312 std::fs::write(
1313 src_dir.join("entry.lua"),
1314 r#"
1315 local greeter = require "greeter"
1316 GREETER_RESULT = greeter.hello()
1317 "#,
1318 ).unwrap();
1319
1320 let manifest = PactPluginManifest {
1321 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1322 plugin_interface_version: 1,
1323 name: "nested-entry-point-test".to_string(),
1324 version: "0.0.0".to_string(),
1325 executable_type: "lua".to_string(),
1326 minimum_required_version: None,
1327 entry_point: "src/entry.lua".to_string(),
1328 entry_points: HashMap::new(),
1329 args: None,
1330 dependencies: None,
1331 plugin_config: HashMap::new(),
1332 };
1333
1334 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1335 let lua = plugin.runtime.lock().unwrap();
1336 let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1337 assert_eq!(result, "hello from the plugin root");
1338 }
1339
1340 #[test]
1341 fn ignores_a_missing_luarocks_directory_instead_of_failing() {
1342 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1343 std::fs::write(plugin_dir.path().join("entry.lua"), "-- no-op").unwrap();
1344
1345 let mut plugin_config = HashMap::new();
1346 plugin_config.insert(
1347 "luaRocksDir".to_string(),
1348 serde_json::Value::String("/no/such/directory".to_string()),
1349 );
1350
1351 let manifest = PactPluginManifest {
1352 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1353 plugin_interface_version: 1,
1354 name: "luarocks-test".to_string(),
1355 version: "0.0.0".to_string(),
1356 executable_type: "lua".to_string(),
1357 minimum_required_version: None,
1358 entry_point: "entry.lua".to_string(),
1359 entry_points: HashMap::new(),
1360 args: None,
1361 dependencies: None,
1362 plugin_config,
1363 };
1364
1365 start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1366 }
1367
1368 #[test]
1369 fn captures_print_and_logger_output_into_the_per_instance_log_file() {
1370 let output_dir = tempdir::TempDir::new("lua-plugin-log-test").unwrap();
1371 unsafe { std::env::set_var("PACT_OUTPUT_DIR", output_dir.path()); }
1374
1375 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1376 std::fs::write(
1377 plugin_dir.path().join("entry.lua"),
1378 r#"
1379 print("hello", "world", 42)
1380 logger("a logger message")
1381 "#,
1382 ).unwrap();
1383
1384 let manifest = PactPluginManifest {
1385 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1386 plugin_interface_version: 1,
1387 name: "log-test".to_string(),
1388 version: "0.0.0".to_string(),
1389 executable_type: "lua".to_string(),
1390 minimum_required_version: None,
1391 entry_point: "entry.lua".to_string(),
1392 entry_points: HashMap::new(),
1393 args: None,
1394 dependencies: None,
1395 plugin_config: HashMap::new(),
1396 };
1397
1398 start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1399 unsafe { std::env::remove_var("PACT_OUTPUT_DIR"); }
1400
1401 let log_path = output_dir.path().join("logs").join("pact-plugin-log-test-test-instance.log");
1402 let contents = std::fs::read_to_string(&log_path)
1403 .unwrap_or_else(|err| panic!("Expected a log file at {:?} - {}", log_path, err));
1404 assert_eq!(contents, "hello\tworld\t42\na logger message\n");
1405 }
1406
1407 #[tokio::test]
1408 async fn loads_the_jwt_plugin_and_runs_the_init_function() {
1409 let manifest = jwt_manifest();
1410 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1411 let lua = plugin.runtime.lock().unwrap();
1412 let entries = call_init(&lua, "test", "0.0.0").unwrap();
1413 assert_eq!(entries.len(), 2);
1414 assert_eq!(entries[0].key, "jwt");
1415 assert_eq!(entries[0].r#type, catalogue_entry::EntryType::ContentMatcher as i32);
1416 assert_eq!(entries[1].r#type, catalogue_entry::EntryType::ContentGenerator as i32);
1417 }
1418
1419 #[tokio::test]
1420 async fn configure_interaction_then_match_contents_round_trip() {
1421 let manifest = jwt_manifest();
1422 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1423
1424 let mut config_fields = HashMap::new();
1425 config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
1426 config_fields.insert("subject".to_string(), serde_json::Value::String("test-subject".to_string()));
1427 config_fields.insert("issuer".to_string(), serde_json::Value::String("test-issuer".to_string()));
1428 config_fields.insert("audience".to_string(), serde_json::Value::String("test-audience".to_string()));
1429 config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
1430
1431 let configure_request = ConfigureInteractionRequest {
1432 content_type: "application/jwt+json".to_string(),
1433 contents_config: Some(to_proto_struct(&config_fields)),
1434 };
1435 let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
1436 assert_eq!(configure_response.error, "");
1437 assert_eq!(configure_response.interaction.len(), 1);
1438
1439 let interaction = &configure_response.interaction[0];
1440 let body = interaction.contents.clone().expect("expected a body");
1441 assert_eq!(body.content_type, "application/jwt+json");
1442 let token = String::from_utf8(body.content.clone().unwrap()).unwrap();
1443 assert_eq!(token.split('.').count(), 3);
1444
1445 let compare_request = CompareContentsRequest {
1446 expected: Some(body.clone()),
1447 actual: Some(body),
1448 allow_unexpected_keys: false,
1449 rules: HashMap::new(),
1450 plugin_configuration: interaction.plugin_configuration.clone(),
1451 };
1452 let compare_response = plugin.compare_contents(compare_request).await.unwrap();
1453 assert_eq!(compare_response.error, "");
1454 assert!(compare_response.type_mismatch.is_none());
1455 assert!(
1456 compare_response.results.is_empty(),
1457 "expected no mismatches, got {:?}",
1458 compare_response.results
1459 );
1460 }
1461
1462 #[tokio::test]
1463 async fn match_contents_detects_a_tampered_token() {
1464 let manifest = jwt_manifest();
1465 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1466
1467 let mut config_fields = HashMap::new();
1468 config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
1469 config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
1470
1471 let configure_request = ConfigureInteractionRequest {
1472 content_type: "application/jwt+json".to_string(),
1473 contents_config: Some(to_proto_struct(&config_fields)),
1474 };
1475 let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
1476 let interaction = &configure_response.interaction[0];
1477 let expected_body = interaction.contents.clone().unwrap();
1478
1479 let mut actual_body = expected_body.clone();
1480 let mut token = String::from_utf8(actual_body.content.clone().unwrap()).unwrap();
1481 token.push('x'); actual_body.content = Some(token.into_bytes());
1483
1484 let compare_request = CompareContentsRequest {
1485 expected: Some(expected_body),
1486 actual: Some(actual_body),
1487 allow_unexpected_keys: false,
1488 rules: HashMap::new(),
1489 plugin_configuration: interaction.plugin_configuration.clone(),
1490 };
1491 let compare_response = plugin.compare_contents(compare_request).await.unwrap();
1492 assert!(!compare_response.results.is_empty(), "expected a mismatch to be detected");
1493 }
1494
1495 #[tokio::test]
1496 async fn compare_contents_handles_non_string_mismatch_values() {
1497 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1498 std::fs::write(
1499 plugin_dir.path().join("entry.lua"),
1500 r#"
1501 function match_contents(request)
1502 return {
1503 mismatches = {
1504 ["claims:exp"] = { expected = 123, actual = 456, mismatch = "exp differs", path = "claims:exp" },
1505 ["claims:verified"] = { expected = true, actual = false, mismatch = "verified differs", path = "claims:verified" }
1506 }
1507 }
1508 end
1509 "#,
1510 ).unwrap();
1511
1512 let manifest = PactPluginManifest {
1513 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1514 plugin_interface_version: 1,
1515 name: "scalar-mismatch-test".to_string(),
1516 version: "0.0.0".to_string(),
1517 executable_type: "lua".to_string(),
1518 minimum_required_version: None,
1519 entry_point: "entry.lua".to_string(),
1520 entry_points: HashMap::new(),
1521 args: None,
1522 dependencies: None,
1523 plugin_config: HashMap::new(),
1524 };
1525 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1526
1527 let compare_request = CompareContentsRequest {
1528 expected: None,
1529 actual: None,
1530 allow_unexpected_keys: false,
1531 rules: HashMap::new(),
1532 plugin_configuration: None,
1533 };
1534 let response = plugin.compare_contents(compare_request).await.unwrap();
1535
1536 let exp_mismatch = &response.results["claims:exp"].mismatches[0];
1537 assert_eq!(exp_mismatch.expected.as_deref(), Some("123".as_bytes()));
1538 assert_eq!(exp_mismatch.actual.as_deref(), Some("456".as_bytes()));
1539
1540 let verified_mismatch = &response.results["claims:verified"].mismatches[0];
1541 assert_eq!(verified_mismatch.expected.as_deref(), Some("true".as_bytes()));
1542 assert_eq!(verified_mismatch.actual.as_deref(), Some("false".as_bytes()));
1543 }
1544
1545 fn transport_manifest(plugin_dir: &std::path::Path, plugin_interface_version: u8) -> PactPluginManifest {
1546 PactPluginManifest {
1547 plugin_dir: plugin_dir.to_string_lossy().to_string(),
1548 plugin_interface_version,
1549 name: "transport-test".to_string(),
1550 version: "0.0.0".to_string(),
1551 executable_type: "lua".to_string(),
1552 minimum_required_version: None,
1553 entry_point: "entry.lua".to_string(),
1554 entry_points: HashMap::new(),
1555 args: None,
1556 dependencies: None,
1557 plugin_config: HashMap::new(),
1558 }
1559 }
1560
1561 const TRANSPORT_PLUGIN_SCRIPT: &str = r#"
1562 function start_mock_server(request)
1563 START_MOCK_SERVER_REQUEST = request
1564 if request.port == 0 then
1565 return { error = "could not bind a mock server" }
1566 end
1567 return { details = { key = "mock-server-1", port = 12345, address = "127.0.0.1:12345" } }
1568 end
1569
1570 function shutdown_mock_server(server_key)
1571 SHUTDOWN_SERVER_KEY = server_key
1572 return {
1573 ok = false,
1574 results = { { path = "/foo", error = "did not match", mismatches = { "simple string mismatch" } } }
1575 }
1576 end
1577
1578 function get_mock_server_results(server_key)
1579 GET_RESULTS_SERVER_KEY = server_key
1580 return { ok = true, results = {} }
1581 end
1582
1583 function prepare_interaction_for_verification(request)
1584 PREPARE_REQUEST = request
1585 return {
1586 interaction_data = {
1587 body = { content_type = "application/json", contents = "prepared-body", content_type_hint = "TEXT" },
1588 metadata = { path = "/foo", tag = { binary = "raw-bytes" } }
1589 }
1590 }
1591 end
1592
1593 function verify_interaction(request)
1594 VERIFY_REQUEST = request
1595 if request.config ~= nil and request.config.fail == true then
1596 return { error = "verification failed" }
1597 end
1598 return {
1599 result = {
1600 success = true,
1601 response_data = { body = { content_type = "application/json", contents = "response-body" }, metadata = {} },
1602 mismatches = { "a plain mismatch", { mismatch = "a table mismatch", path = "$.foo", expected = 1, actual = 2 } },
1603 output = { "POST /foo", "200 OK" }
1604 }
1605 }
1606 end
1607 "#;
1608
1609 fn start_transport_plugin(plugin_interface_version: u8) -> LuaPactPlugin {
1610 let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
1611 std::fs::write(plugin_dir.path().join("entry.lua"), TRANSPORT_PLUGIN_SCRIPT).unwrap();
1612 let manifest = transport_manifest(plugin_dir.path(), plugin_interface_version);
1613 start_lua_plugin(&manifest, "test-instance".to_string()).unwrap()
1616 }
1617
1618 #[tokio::test]
1619 async fn start_mock_server_v1_round_trip() {
1620 let plugin = start_transport_plugin(1);
1621 let response = plugin.start_mock_server(StartMockServerRequest {
1622 host_interface: "127.0.0.1".to_string(),
1623 port: 8080,
1624 tls: false,
1625 pact: "{\"consumer\":{}}".to_string(),
1626 test_context: None,
1627 }).await.unwrap();
1628 match response.response.unwrap() {
1629 start_mock_server_response::Response::Details(details) => {
1630 assert_eq!(details.key, "mock-server-1");
1631 assert_eq!(details.port, 12345);
1632 assert_eq!(details.address, "127.0.0.1:12345");
1633 }
1634 other => panic!("expected mock server details, got {:?}", other),
1635 }
1636 }
1637
1638 #[tokio::test]
1639 async fn start_mock_server_v1_returns_the_lua_error() {
1640 let plugin = start_transport_plugin(1);
1641 let response = plugin.start_mock_server(StartMockServerRequest {
1642 host_interface: "127.0.0.1".to_string(),
1643 port: 0,
1644 tls: false,
1645 pact: "{}".to_string(),
1646 test_context: None,
1647 }).await.unwrap();
1648 match response.response.unwrap() {
1649 start_mock_server_response::Response::Error(err) => assert_eq!(err, "could not bind a mock server"),
1650 other => panic!("expected an error response, got {:?}", other),
1651 }
1652 }
1653
1654 #[tokio::test]
1655 async fn start_mock_server_v2_passes_structured_interactions() {
1656 let plugin = start_transport_plugin(2);
1657 let request = proto_v2::StartMockServerRequest {
1658 host_interface: "127.0.0.1".to_string(),
1659 port: 8080,
1660 tls: false,
1661 interactions: vec![proto_v2::InteractionContents {
1662 interaction_type: "Synchronous/HTTP".to_string(),
1663 plugin_configuration: None,
1664 consumer: "test-consumer".to_string(),
1665 provider: "test-provider".to_string(),
1666 }],
1667 test_context: None,
1668 };
1669 let response = plugin.start_mock_server_v2(request).await.unwrap();
1670 assert!(matches!(response.response.unwrap(), start_mock_server_response::Response::Details(_)));
1671
1672 let lua = plugin.runtime.lock().unwrap();
1673 let captured: Table = lua.globals().get("START_MOCK_SERVER_REQUEST").unwrap();
1674 let interactions: Table = captured.get("interactions").unwrap();
1675 let first: Table = interactions.get(1).unwrap();
1676 assert_eq!(first.get::<String>("interaction_type").unwrap(), "Synchronous/HTTP");
1677 assert_eq!(first.get::<String>("consumer").unwrap(), "test-consumer");
1678 }
1679
1680 #[tokio::test]
1681 async fn shutdown_and_get_mock_server_results_parse_mismatches() {
1682 let plugin = start_transport_plugin(1);
1683
1684 let shutdown_response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
1685 server_key: "mock-server-1".to_string(),
1686 }).await.unwrap();
1687 assert!(!shutdown_response.ok);
1688 assert_eq!(shutdown_response.results.len(), 1);
1689 assert_eq!(shutdown_response.results[0].path, "/foo");
1690 assert_eq!(shutdown_response.results[0].mismatches[0].mismatch, "simple string mismatch");
1691
1692 let results_response = plugin.get_mock_server_results(MockServerRequest {
1693 server_key: "mock-server-1".to_string(),
1694 }).await.unwrap();
1695 assert!(results_response.ok);
1696 assert!(results_response.results.is_empty());
1697 }
1698
1699 #[tokio::test]
1700 async fn prepare_interaction_for_verification_v1_round_trip() {
1701 let plugin = start_transport_plugin(1);
1702 let response = plugin.prepare_interaction_for_verification(VerificationPreparationRequest {
1703 pact: "{}".to_string(),
1704 interaction_key: "interaction-1".to_string(),
1705 config: None,
1706 }).await.unwrap();
1707
1708 match response.response.unwrap() {
1709 verification_preparation_response::Response::InteractionData(data) => {
1710 let body = data.body.unwrap();
1711 assert_eq!(body.content, Some("prepared-body".as_bytes().to_vec()));
1712 let metadata = data.metadata;
1713 assert!(matches!(
1714 metadata["path"].value,
1715 Some(metadata_value::Value::NonBinaryValue(_))
1716 ));
1717 match &metadata["tag"].value {
1718 Some(metadata_value::Value::BinaryValue(bytes)) => assert_eq!(bytes, b"raw-bytes"),
1719 other => panic!("expected a binary metadata value, got {:?}", other),
1720 }
1721 }
1722 other => panic!("expected interaction data, got {:?}", other),
1723 }
1724 }
1725
1726 #[tokio::test]
1727 async fn prepare_interaction_for_verification_v2_passes_interaction_contents() {
1728 let plugin = start_transport_plugin(2);
1729 let request = proto_v2::VerificationPreparationRequest {
1730 interaction_contents: Some(proto_v2::InteractionContents {
1731 interaction_type: "Synchronous/HTTP".to_string(),
1732 plugin_configuration: None,
1733 consumer: "test-consumer".to_string(),
1734 provider: "test-provider".to_string(),
1735 }),
1736 config: None,
1737 test_context: None,
1738 };
1739 let response = plugin.prepare_interaction_for_verification_v2(request).await.unwrap();
1740 assert!(matches!(
1741 response.response.unwrap(),
1742 verification_preparation_response::Response::InteractionData(_)
1743 ));
1744
1745 let lua = plugin.runtime.lock().unwrap();
1746 let captured: Table = lua.globals().get("PREPARE_REQUEST").unwrap();
1747 let interaction_contents: Table = captured.get("interaction_contents").unwrap();
1748 assert_eq!(interaction_contents.get::<String>("provider").unwrap(), "test-provider");
1749 }
1750
1751 #[tokio::test]
1752 async fn verify_interaction_v1_round_trip() {
1753 let plugin = start_transport_plugin(1);
1754 let mut metadata = HashMap::new();
1755 metadata.insert("path".to_string(), MetadataValue {
1756 value: Some(metadata_value::Value::NonBinaryValue(prost_types::Value {
1757 kind: Some(prost_types::value::Kind::StringValue("/foo".to_string())),
1758 })),
1759 });
1760 let response = plugin.verify_interaction(VerifyInteractionRequest {
1761 interaction_data: Some(InteractionData {
1762 body: Some(Body {
1763 content_type: "application/json".to_string(),
1764 content: Some("request-body".as_bytes().to_vec()),
1765 content_type_hint: body::ContentTypeHint::Text as i32,
1766 }),
1767 metadata,
1768 }),
1769 config: None,
1770 pact: "{}".to_string(),
1771 interaction_key: "interaction-1".to_string(),
1772 }).await.unwrap();
1773
1774 match response.response.unwrap() {
1775 verify_interaction_response::Response::Result(result) => {
1776 assert!(result.success);
1777 assert_eq!(result.output, vec!["POST /foo".to_string(), "200 OK".to_string()]);
1778 assert_eq!(result.mismatches.len(), 2);
1779 match &result.mismatches[0].result {
1780 Some(verification_result_item::Result::Error(err)) => assert_eq!(err, "a plain mismatch"),
1781 other => panic!("expected an error mismatch, got {:?}", other),
1782 }
1783 match &result.mismatches[1].result {
1784 Some(verification_result_item::Result::Mismatch(mismatch)) => {
1785 assert_eq!(mismatch.mismatch, "a table mismatch");
1786 assert_eq!(mismatch.expected, Some(b"1".to_vec()));
1787 }
1788 other => panic!("expected a mismatch, got {:?}", other),
1789 }
1790 }
1791 other => panic!("expected a verification result, got {:?}", other),
1792 }
1793
1794 let lua = plugin.runtime.lock().unwrap();
1795 let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
1796 let interaction_data: Table = captured.get("interaction_data").unwrap();
1797 let metadata: Table = interaction_data.get("metadata").unwrap();
1798 assert_eq!(metadata.get::<String>("path").unwrap(), "/foo");
1799 }
1800
1801 #[tokio::test]
1802 async fn verify_interaction_v1_returns_the_lua_error() {
1803 let plugin = start_transport_plugin(1);
1804 let mut config = HashMap::new();
1805 config.insert("fail".to_string(), serde_json::Value::Bool(true));
1806 let response = plugin.verify_interaction(VerifyInteractionRequest {
1807 interaction_data: None,
1808 config: Some(to_proto_struct(&config)),
1809 pact: "{}".to_string(),
1810 interaction_key: "interaction-1".to_string(),
1811 }).await.unwrap();
1812 match response.response.unwrap() {
1813 verify_interaction_response::Response::Error(err) => assert_eq!(err, "verification failed"),
1814 other => panic!("expected an error response, got {:?}", other),
1815 }
1816 }
1817
1818 #[tokio::test]
1819 async fn verify_interaction_v2_converts_the_v2_interaction_data_and_contents() {
1820 let plugin = start_transport_plugin(2);
1821 let request = proto_v2::VerifyInteractionRequest {
1822 interaction_data: Some(proto_v2::InteractionData {
1823 body: Some(proto_v2::Body {
1824 content_type: "application/json".to_string(),
1825 content: Some("request-body".as_bytes().to_vec()),
1826 content_type_hint: 0,
1827 }),
1828 metadata: HashMap::new(),
1829 }),
1830 config: None,
1831 interaction_contents: Some(proto_v2::InteractionContents {
1832 interaction_type: "Synchronous/HTTP".to_string(),
1833 plugin_configuration: None,
1834 consumer: "test-consumer".to_string(),
1835 provider: "test-provider".to_string(),
1836 }),
1837 test_context: None,
1838 };
1839 let response = plugin.verify_interaction_v2(request).await.unwrap();
1840 assert!(matches!(
1841 response.response.unwrap(),
1842 verify_interaction_response::Response::Result(_)
1843 ));
1844
1845 let lua = plugin.runtime.lock().unwrap();
1846 let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
1847 let interaction_data: Table = captured.get("interaction_data").unwrap();
1848 let body: Table = interaction_data.get("body").unwrap();
1849 assert_eq!(body.get::<mlua::String>("contents").unwrap().to_str().unwrap(), "request-body");
1850 let interaction_contents: Table = captured.get("interaction_contents").unwrap();
1851 assert_eq!(interaction_contents.get::<String>("consumer").unwrap(), "test-consumer");
1852 }
1853
1854 #[tokio::test]
1855 async fn shutdown_mock_server_defaults_ok_to_true_when_the_field_is_absent() {
1856 let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
1864 std::fs::write(
1865 plugin_dir.path().join("entry.lua"),
1866 r#"
1867 function shutdown_mock_server(server_key)
1868 return { results = {} }
1869 end
1870 "#,
1871 ).unwrap();
1872 let manifest = transport_manifest(plugin_dir.path(), 1);
1873 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1874
1875 let response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
1876 server_key: "mock-server-1".to_string(),
1877 }).await.unwrap();
1878 assert!(response.ok, "expected 'ok' to default to true when the Lua script doesn't set it");
1879 }
1880
1881 #[tokio::test]
1882 async fn shutdown_mock_server_errors_on_a_wrong_typed_path_field() {
1883 let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
1884 std::fs::write(
1885 plugin_dir.path().join("entry.lua"),
1886 r#"
1887 function shutdown_mock_server(server_key)
1888 return { ok = false, results = { { path = {}, error = "boom", mismatches = {} } } }
1889 end
1890 "#,
1891 ).unwrap();
1892 let manifest = transport_manifest(plugin_dir.path(), 1);
1893 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1894
1895 let result = plugin.shutdown_mock_server(ShutdownMockServerRequest {
1896 server_key: "mock-server-1".to_string(),
1897 }).await;
1898 assert!(
1899 result.is_err(),
1900 "expected a wrong-typed 'path' field (a table, not a string) to be a hard error, not silently default, got {:?}",
1901 result
1902 );
1903 }
1904}