1use std::collections::HashMap;
49use std::fs::File;
50use std::io::Write;
51use std::path::{Path, PathBuf};
52use std::sync::{Arc, Mutex};
53
54use anyhow::anyhow;
55use async_trait::async_trait;
56use mlua::{Function, Lua, LuaSerdeExt, Table, Value, Variadic};
57use rsa::pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPublicKey, LineEnding};
58use rsa::{Pkcs1v15Sign, RsaPrivateKey, RsaPublicKey};
59use sha2::{Digest, Sha512};
60use tracing::debug;
61
62use crate::call_chain;
63use crate::catalogue_manager::{CatalogueEntryType, ResolvedCapability, resolve_capability};
64use crate::plugin_manager::lookup_plugin;
65use crate::plugin_models::{
66 PactPluginManifest, PactPluginRpc, PluginInitRequest, PluginInitResponse, PluginInstance,
67};
68use crate::proto::*;
69use crate::proto_v2;
70use crate::utils::{proto_struct_to_json, proto_value_to_json, to_proto_struct, to_proto_value};
71
72pub struct LuaPactPlugin {
81 runtime: Arc<tokio::sync::Mutex<Lua>>,
82 manifest: PactPluginManifest,
83 instance_id: String,
84 plugin_capabilities: Vec<String>,
85}
86
87impl std::fmt::Debug for LuaPactPlugin {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("LuaPactPlugin")
90 .field("manifest", &self.manifest)
91 .field("instance_id", &self.instance_id)
92 .field("plugin_capabilities", &self.plugin_capabilities)
93 .finish()
94 }
95}
96
97pub(crate) fn start_lua_plugin(
100 manifest: &PactPluginManifest,
101 instance_id: String,
102) -> anyhow::Result<LuaPactPlugin> {
103 let script_path = resolve_entry_point(manifest)?;
104 debug!("Loading Lua plugin {} from {:?}", manifest.name, script_path);
105
106 let log = Arc::new(LuaPluginLog::open(&manifest.name, &instance_id));
107 let lua = Lua::new();
108 set_package_path(&lua, manifest)?;
109 add_luarocks_path(&lua, manifest)?;
110 register_host_functions(&lua, &manifest.name, &log)?;
111 load_script(&lua, &script_path)?;
112
113 Ok(LuaPactPlugin {
114 runtime: Arc::new(tokio::sync::Mutex::new(lua)),
115 manifest: manifest.clone(),
116 instance_id,
117 plugin_capabilities: vec![],
118 })
119}
120
121impl LuaPactPlugin {
122 pub(crate) fn set_plugin_capabilities(&mut self, capabilities: Vec<String>) {
125 self.plugin_capabilities = capabilities;
126 }
127}
128
129struct LuaPluginLog {
137 file: Mutex<Option<File>>,
138}
139
140impl LuaPluginLog {
141 fn open(plugin_name: &str, instance_id: &str) -> Self {
142 LuaPluginLog {
143 file: Mutex::new(crate::child_process::open_plugin_log_file(plugin_name, instance_id)),
144 }
145 }
146
147 fn write_line(&self, line: &str) {
148 if let Ok(mut guard) = self.file.lock()
149 && let Some(file) = guard.as_mut() {
150 let _ = writeln!(file, "{}", line);
151 let _ = file.flush();
152 }
153 }
154}
155
156fn resolve_entry_point(manifest: &PactPluginManifest) -> anyhow::Result<PathBuf> {
157 let entry_point = PathBuf::from(&manifest.entry_point);
158 let path = if entry_point.is_absolute() && entry_point.exists() {
159 entry_point
160 } else {
161 PathBuf::from(&manifest.plugin_dir).join(&manifest.entry_point)
162 };
163 if !path.exists() {
164 return Err(anyhow!("Lua plugin entry point {:?} does not exist", path));
165 }
166 Ok(path)
167}
168
169fn set_package_path(lua: &Lua, manifest: &PactPluginManifest) -> anyhow::Result<()> {
173 let plugin_dir = PathBuf::from(&manifest.plugin_dir);
174 let package: Table = lua.globals().get("package")?;
175 let existing: String = package.get("path").unwrap_or_default();
176 let new_path = format!(
177 "{}/?.lua;{}/?/init.lua;{}",
178 plugin_dir.to_string_lossy(), plugin_dir.to_string_lossy(), existing
179 );
180 package.set("path", new_path)?;
181 Ok(())
182}
183
184const LUAROCKS_LUA_VERSION: &str = "5.4";
187
188fn add_luarocks_path(lua: &Lua, manifest: &PactPluginManifest) -> anyhow::Result<()> {
198 let configured = manifest.plugin_config.get("luaRocksDir").and_then(|v| v.as_str());
199 let rocks_dir = match configured {
200 Some(dir) => PathBuf::from(dir),
201 None => match home::home_dir() {
202 Some(home) => home.join(".luarocks"),
203 None => return Ok(()),
204 },
205 };
206
207 let lua_dir = rocks_dir.join("share").join("lua").join(LUAROCKS_LUA_VERSION);
208 if !lua_dir.exists() {
209 if configured.is_some() {
210 debug!(
211 "Configured luaRocksDir '{}' does not have a share/lua/{} directory, ignoring",
212 rocks_dir.display(), LUAROCKS_LUA_VERSION
213 );
214 }
215 return Ok(());
216 }
217
218 let package: Table = lua.globals().get("package")?;
219 let existing: String = package.get("path").unwrap_or_default();
220 let new_path = format!(
221 "{}/?.lua;{}/?/init.lua;{}",
222 lua_dir.to_string_lossy(), lua_dir.to_string_lossy(), existing
223 );
224 package.set("path", new_path)?;
225 debug!("Added LuaRocks path {:?} for plugin {}", lua_dir, manifest.name);
226 Ok(())
227}
228
229fn load_script(lua: &Lua, script_path: &Path) -> anyhow::Result<()> {
230 let script = std::fs::read_to_string(script_path)?;
231 lua
232 .load(script)
233 .set_name(script_path.to_string_lossy().to_string())
234 .exec()
235 .map_err(|err| anyhow!("Failed to load Lua plugin script {:?} - {}", script_path, err))?;
236 Ok(())
237}
238
239fn register_host_functions(lua: &Lua, plugin_name: &str, log: &Arc<LuaPluginLog>) -> anyhow::Result<()> {
242 let globals = lua.globals();
243
244 let name = plugin_name.to_string();
245 let logger_log = log.clone();
246 globals.set(
247 "logger",
248 lua.create_function(move |_, message: String| {
249 debug!(plugin = name.as_str(), "{}", message);
250 logger_log.write_line(&message);
251 Ok(())
252 })?,
253 )?;
254
255 let print_log = log.clone();
258 globals.set(
259 "print",
260 lua.create_function(move |lua, args: Variadic<Value>| {
261 let tostring: Function = lua.globals().get("tostring")?;
262 let mut parts = Vec::with_capacity(args.len());
263 for arg in args.iter() {
264 parts.push(tostring.call::<String>(arg.clone())?);
265 }
266 print_log.write_line(&parts.join("\t"));
267 Ok(())
268 })?,
269 )?;
270
271 globals.set(
272 "rsa_sign",
273 lua.create_function(|_, (data, key): (mlua::String, String)| {
274 let private_key = RsaPrivateKey::from_pkcs1_pem(&key).map_err(mlua::Error::external)?;
275 let digest = Sha512::digest(data.as_bytes().as_ref());
276 let signature = private_key
277 .sign(Pkcs1v15Sign::new::<Sha512>(), &digest)
278 .map_err(mlua::Error::external)?;
279 Ok(base64::Engine::encode(
280 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
281 signature,
282 ))
283 })?,
284 )?;
285
286 globals.set(
287 "rsa_public_key",
288 lua.create_function(|_, key: String| {
289 let private_key = RsaPrivateKey::from_pkcs1_pem(&key).map_err(mlua::Error::external)?;
290 let public_key = RsaPublicKey::from(&private_key);
291 let pem = public_key
292 .to_pkcs1_pem(LineEnding::LF)
293 .map_err(mlua::Error::external)?;
294 Ok(pem)
295 })?,
296 )?;
297
298 globals.set(
299 "rsa_validate",
300 lua.create_function(|_, (token_parts, algorithm, key): (Vec<String>, String, String)| {
301 if algorithm != "RS512" {
302 return Err(mlua::Error::RuntimeError(format!(
303 "Unsupported JWT algorithm '{}': only RS512 is supported",
304 algorithm
305 )));
306 }
307 if token_parts.len() != 3 {
308 return Err(mlua::Error::RuntimeError(
309 "Expected a 3 part JWT token (header, payload, signature)".to_string(),
310 ));
311 }
312
313 let public_key = match RsaPublicKey::from_pkcs1_pem(&key) {
314 Ok(key) => key,
315 Err(_) => return Ok(false),
316 };
317 let signature = match decode_base64_lenient(&token_parts[2]) {
318 Ok(bytes) => bytes,
319 Err(_) => return Ok(false),
320 };
321 let base_token = format!("{}.{}", token_parts[0], token_parts[1]);
322 let digest = Sha512::digest(base_token.as_bytes());
323 Ok(
324 public_key
325 .verify(Pkcs1v15Sign::new::<Sha512>(), &digest, &signature)
326 .is_ok(),
327 )
328 })?,
329 )?;
330
331 globals.set(
332 "b64_decode_no_pad",
333 lua.create_function(|lua, data: String| {
334 let bytes = decode_base64_lenient(&data).map_err(mlua::Error::external)?;
335 lua.create_string(&bytes)
336 })?,
337 )?;
338
339 globals.set(
351 "host_compare_contents",
352 lua.create_async_function(move |lua, (entry_key, request): (String, Table)| async move {
353 let request = lua_to_compare_request(&lua, request).map_err(mlua::Error::external)?;
354 let response = call_host_compare_contents(&entry_key, request).await.map_err(mlua::Error::external)?;
355 compare_response_to_lua(&lua, &response)
356 })?,
357 )?;
358
359 globals.set(
360 "host_generate_content",
361 lua.create_async_function(move |lua, (entry_key, contents, generators, test_mode): (String, Value, Option<Table>, Option<String>)| async move {
362 let request = lua_to_generate_request(&lua, contents, generators, test_mode).map_err(mlua::Error::external)?;
363 let response = call_host_generate_content(&entry_key, request).await.map_err(mlua::Error::external)?;
364 body_to_lua(&lua, &response.contents)
365 })?,
366 )?;
367
368 Ok(())
369}
370
371async fn call_host_compare_contents(
377 entry_key: &str,
378 request: CompareContentsRequest
379) -> anyhow::Result<CompareContentsResponse> {
380 match resolve_capability(entry_key, CatalogueEntryType::CONTENT_MATCHER)? {
381 ResolvedCapability::Core(core_key) => {
382 let handler = crate::core_capabilities::lookup_core_content_matcher(&core_key)
383 .ok_or_else(|| anyhow!("No core content matcher registered for '{}'", core_key))?;
384 handler.compare_contents(request).await
385 }
386 ResolvedCapability::Plugin(manifest) => {
387 let plugin = lookup_plugin(&manifest.as_dependency())
388 .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
389 let chain_id = call_chain::new_call_chain_id();
390 let deadline_ms = call_chain::default_deadline_ms();
391 plugin.compare_contents_with_chain(request, &chain_id, deadline_ms).await
392 }
393 }
394}
395
396async fn call_host_generate_content(
399 entry_key: &str,
400 request: GenerateContentRequest
401) -> anyhow::Result<GenerateContentResponse> {
402 match resolve_capability(entry_key, CatalogueEntryType::CONTENT_GENERATOR)? {
403 ResolvedCapability::Core(core_key) => {
404 let handler = crate::core_capabilities::lookup_core_content_generator(&core_key)
405 .ok_or_else(|| anyhow!("No core content generator registered for '{}'", core_key))?;
406 handler.generate_content(request).await
407 }
408 ResolvedCapability::Plugin(manifest) => {
409 let plugin = lookup_plugin(&manifest.as_dependency())
410 .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
411 let chain_id = call_chain::new_call_chain_id();
412 let deadline_ms = call_chain::default_deadline_ms();
413 plugin.generate_content_with_chain(request, &chain_id, deadline_ms).await
414 }
415 }
416}
417
418fn decode_base64_lenient(data: &str) -> anyhow::Result<Vec<u8>> {
420 use base64::Engine;
421 base64::engine::general_purpose::URL_SAFE
422 .decode(data)
423 .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(data))
424 .map_err(|err| anyhow!("Failed to base64 decode value - {}", err))
425}
426
427fn call_init(
428 lua: &Lua,
429 implementation: &str,
430 version: &str,
431) -> anyhow::Result<Vec<CatalogueEntry>> {
432 let init_fn: Function = lua
433 .globals()
434 .get("init")
435 .map_err(|_| anyhow!("Lua plugin does not define a global 'init' function"))?;
436 let result: Table = init_fn
437 .call((implementation.to_string(), version.to_string()))
438 .map_err(|err| anyhow!("Lua init() function failed - {}", err))?;
439 lua_table_to_catalogue_entries(result)
440}
441
442fn lua_table_to_catalogue_entries(table: Table) -> anyhow::Result<Vec<CatalogueEntry>> {
443 let mut entries = vec![];
444 for entry in table.sequence_values::<Table>() {
445 let entry = entry?;
446 let entry_type_str: String = entry.get("entryType")?;
447 let key: String = entry.get("key")?;
448 let values: Option<HashMap<String, String>> = entry.get("values")?;
449 let entry_type = catalogue_entry::EntryType::from_str_name(&entry_type_str)
450 .ok_or_else(|| anyhow!("Unknown catalogue entry type '{}'", entry_type_str))?;
451 entries.push(CatalogueEntry {
452 r#type: entry_type as i32,
453 key,
454 values: values.unwrap_or_default(),
455 });
456 }
457 Ok(entries)
458}
459
460fn content_type_hint_to_str(hint: i32) -> &'static str {
463 match body::ContentTypeHint::try_from(hint).unwrap_or(body::ContentTypeHint::Default) {
464 body::ContentTypeHint::Default => "DEFAULT",
465 body::ContentTypeHint::Text => "TEXT",
466 body::ContentTypeHint::Binary => "BINARY",
467 }
468}
469
470fn str_to_content_type_hint(hint: &str) -> i32 {
471 match hint {
472 "TEXT" => body::ContentTypeHint::Text as i32,
473 "BINARY" => body::ContentTypeHint::Binary as i32,
474 _ => body::ContentTypeHint::Default as i32,
475 }
476}
477
478fn body_to_lua(lua: &Lua, body: &Option<Body>) -> mlua::Result<Value> {
479 match body {
480 None => Ok(Value::Nil),
481 Some(body) => {
482 let table = lua.create_table()?;
483 table.set("content_type", body.content_type.clone())?;
484 match &body.content {
485 Some(bytes) => table.set("contents", lua.create_string(bytes)?)?,
486 None => table.set("contents", Value::Nil)?,
487 }
488 table.set("content_type_hint", content_type_hint_to_str(body.content_type_hint))?;
489 Ok(Value::Table(table))
490 }
491 }
492}
493
494fn lua_to_body(value: Value) -> anyhow::Result<Option<Body>> {
495 match value {
496 Value::Nil => Ok(None),
497 Value::Table(table) => {
498 let content_type: String = table.get("content_type")?;
499 let contents: Option<mlua::String> = table.get("contents")?;
500 let content_type_hint: Option<String> = table.get("content_type_hint")?;
501 Ok(Some(Body {
502 content_type,
503 content: contents.map(|s| s.as_bytes().to_vec()),
504 content_type_hint: content_type_hint
505 .map(|h| str_to_content_type_hint(&h))
506 .unwrap_or(body::ContentTypeHint::Default as i32),
507 }))
508 }
509 _ => Err(anyhow!("Expected a body table or nil from Lua, got {}", value.type_name())),
510 }
511}
512
513fn matching_rules_to_lua(lua: &Lua, rules: &HashMap<String, MatchingRules>) -> mlua::Result<Table> {
516 let table = lua.create_table()?;
517 for (path, rule_list) in rules {
518 let rules_table = lua.create_table()?;
519 for rule in &rule_list.rule {
520 let rule_table = lua.create_table()?;
521 rule_table.set("type", rule.r#type.clone())?;
522 if let Some(values) = &rule.values {
523 rule_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
524 }
525 rules_table.push(rule_table)?;
526 }
527 table.set(path.clone(), rules_table)?;
528 }
529 Ok(table)
530}
531
532fn lua_to_matching_rules(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MatchingRules>> {
535 let mut result = HashMap::new();
536 if let Some(table) = table {
537 for pair in table.pairs::<String, Table>() {
538 let (path, rules_table) = pair?;
539 let mut rule = vec![];
540 for rule_value in rules_table.sequence_values::<Table>() {
541 let rule_table = rule_value?;
542 let r#type: String = rule_table.get("type")?;
543 let values: Option<Value> = rule_table.get("values")?;
544 let values = match values {
545 Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
546 None => None,
547 };
548 rule.push(MatchingRule { r#type, values });
549 }
550 result.insert(path, MatchingRules { rule });
551 }
552 }
553 Ok(result)
554}
555
556fn plugin_configuration_to_lua(lua: &Lua, config: &Option<PluginConfiguration>) -> mlua::Result<Value> {
557 match config {
558 None => Ok(Value::Nil),
559 Some(config) => {
560 let table = lua.create_table()?;
561 if let Some(interaction_configuration) = &config.interaction_configuration {
562 table.set(
563 "interaction_configuration",
564 lua.to_value(&proto_struct_to_json(interaction_configuration))?,
565 )?;
566 }
567 if let Some(pact_configuration) = &config.pact_configuration {
568 table.set(
569 "pact_configuration",
570 lua.to_value(&proto_struct_to_json(pact_configuration))?,
571 )?;
572 }
573 Ok(Value::Table(table))
574 }
575 }
576}
577
578fn lua_to_plugin_configuration(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<PluginConfiguration>> {
579 match value {
580 None | Some(Value::Nil) => Ok(None),
581 Some(Value::Table(table)) => {
582 let interaction_configuration: Option<serde_json::Value> =
583 table.get::<Option<Value>>("interaction_configuration")?
584 .map(|v| lua.from_value(v))
585 .transpose()?;
586 let pact_configuration: Option<serde_json::Value> =
587 table.get::<Option<Value>>("pact_configuration")?
588 .map(|v| lua.from_value(v))
589 .transpose()?;
590 Ok(Some(PluginConfiguration {
591 interaction_configuration: interaction_configuration.map(|v| to_proto_struct(&as_json_map(v))),
592 pact_configuration: pact_configuration.map(|v| to_proto_struct(&as_json_map(v))),
593 }))
594 }
595 Some(other) => Err(anyhow!("Expected a plugin_config table or nil from Lua, got {}", other.type_name())),
596 }
597}
598
599fn as_json_map(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
600 match value {
601 serde_json::Value::Object(map) => map.into_iter().collect(),
602 _ => HashMap::new(),
603 }
604}
605
606fn compare_request_to_lua(lua: &Lua, request: &CompareContentsRequest) -> mlua::Result<Table> {
609 let table = lua.create_table()?;
610 table.set("expected", body_to_lua(lua, &request.expected)?)?;
611 table.set("actual", body_to_lua(lua, &request.actual)?)?;
612 table.set("allow_unexpected_keys", request.allow_unexpected_keys)?;
613 table.set("rules", matching_rules_to_lua(lua, &request.rules)?)?;
614 table.set(
615 "plugin_configuration",
616 plugin_configuration_to_lua(lua, &request.plugin_configuration)?,
617 )?;
618 Ok(table)
619}
620
621fn lua_to_compare_request(lua: &Lua, table: Table) -> anyhow::Result<CompareContentsRequest> {
625 let expected: Value = table.get("expected")?;
626 let actual: Value = table.get("actual")?;
627 let allow_unexpected_keys: Option<bool> = table.get("allow_unexpected_keys")?;
628 let rules: Option<Table> = table.get("rules")?;
629 let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
630 Ok(CompareContentsRequest {
631 expected: lua_to_body(expected)?,
632 actual: lua_to_body(actual)?,
633 allow_unexpected_keys: allow_unexpected_keys.unwrap_or(false),
634 rules: lua_to_matching_rules(lua, rules)?,
635 plugin_configuration: lua_to_plugin_configuration(lua, plugin_configuration)?,
636 })
637}
638
639fn lua_to_compare_response(table: Table) -> anyhow::Result<CompareContentsResponse> {
640 let error: Option<String> = table.get("error")?;
641 if let Some(error) = error {
642 return Ok(CompareContentsResponse {
643 error,
644 type_mismatch: None,
645 results: HashMap::new(),
646 });
647 }
648
649 let type_mismatch: Option<Table> = table.get("type-mismatch")?;
650 if let Some(type_mismatch) = type_mismatch {
651 let expected: String = type_mismatch.get("expected")?;
652 let actual: String = type_mismatch.get("actual")?;
653 return Ok(CompareContentsResponse {
654 error: String::new(),
655 type_mismatch: Some(ContentTypeMismatch { expected, actual }),
656 results: HashMap::new(),
657 });
658 }
659
660 let mismatches: Option<Table> = table.get("mismatches")?;
661 let mut results = HashMap::new();
662 if let Some(mismatches) = mismatches {
663 for pair in mismatches.pairs::<String, Value>() {
664 let (path, value) = pair?;
665 let mismatch_list = lua_value_to_content_mismatches(&path, value)?;
666 if !mismatch_list.is_empty() {
667 results.insert(path, ContentMismatches { mismatches: mismatch_list });
668 }
669 }
670 }
671
672 Ok(CompareContentsResponse {
673 error: String::new(),
674 type_mismatch: None,
675 results,
676 })
677}
678
679fn lua_scalar_to_string(value: Value) -> anyhow::Result<Option<String>> {
683 match value {
684 Value::Nil => Ok(None),
685 Value::Boolean(b) => Ok(Some(b.to_string())),
686 Value::Integer(i) => Ok(Some(i.to_string())),
687 Value::Number(n) => Ok(Some(n.to_string())),
688 Value::String(s) => Ok(Some(s.to_str()?.to_string())),
689 other => Err(anyhow!("Expected a scalar mismatch value from Lua, got {}", other.type_name())),
690 }
691}
692
693fn lua_value_to_content_mismatches(path: &str, value: Value) -> anyhow::Result<Vec<ContentMismatch>> {
694 match value {
695 Value::String(s) => Ok(vec![ContentMismatch {
696 expected: None,
697 actual: None,
698 mismatch: s.to_str()?.to_string(),
699 path: path.to_string(),
700 diff: String::new(),
701 mismatch_type: String::new(),
702 }]),
703 Value::Table(table) => {
704 let mismatch_field: Option<String> = table.get("mismatch")?;
706 if let Some(mismatch) = mismatch_field {
707 let expected = lua_scalar_to_string(table.get("expected")?)?;
710 let actual = lua_scalar_to_string(table.get("actual")?)?;
711 let path_override: Option<String> = table.get("path")?;
712 let diff: Option<String> = table.get("diff")?;
713 let mismatch_type: Option<String> = table.get("mismatch_type")?;
714 Ok(vec![ContentMismatch {
715 expected: expected.map(|s| s.into_bytes()),
716 actual: actual.map(|s| s.into_bytes()),
717 mismatch,
718 path: path_override.unwrap_or_else(|| path.to_string()),
719 diff: diff.unwrap_or_default(),
720 mismatch_type: mismatch_type.unwrap_or_default(),
721 }])
722 } else {
723 let mut result = vec![];
724 for entry in table.sequence_values::<Value>() {
725 result.extend(lua_value_to_content_mismatches(path, entry?)?);
726 }
727 Ok(result)
728 }
729 }
730 Value::Nil => Ok(vec![]),
731 other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
732 }
733}
734
735fn content_mismatches_to_lua(lua: &Lua, mismatches: &[ContentMismatch]) -> mlua::Result<Table> {
739 let list = lua.create_table()?;
740 for mismatch in mismatches {
741 let table = lua.create_table()?;
742 table.set("mismatch", mismatch.mismatch.clone())?;
743 if let Some(expected) = &mismatch.expected {
744 table.set("expected", lua.create_string(expected)?)?;
745 }
746 if let Some(actual) = &mismatch.actual {
747 table.set("actual", lua.create_string(actual)?)?;
748 }
749 table.set("path", mismatch.path.clone())?;
750 if !mismatch.diff.is_empty() {
751 table.set("diff", mismatch.diff.clone())?;
752 }
753 if !mismatch.mismatch_type.is_empty() {
754 table.set("mismatch_type", mismatch.mismatch_type.clone())?;
755 }
756 list.push(table)?;
757 }
758 Ok(list)
759}
760
761fn compare_response_to_lua(lua: &Lua, response: &CompareContentsResponse) -> mlua::Result<Table> {
765 let table = lua.create_table()?;
766 if !response.error.is_empty() {
767 table.set("error", response.error.clone())?;
768 return Ok(table);
769 }
770 if let Some(type_mismatch) = &response.type_mismatch {
771 let mismatch_table = lua.create_table()?;
772 mismatch_table.set("expected", type_mismatch.expected.clone())?;
773 mismatch_table.set("actual", type_mismatch.actual.clone())?;
774 table.set("type-mismatch", mismatch_table)?;
775 return Ok(table);
776 }
777 if !response.results.is_empty() {
778 let mismatches_table = lua.create_table()?;
779 for (path, content_mismatches) in &response.results {
780 mismatches_table.set(path.clone(), content_mismatches_to_lua(lua, &content_mismatches.mismatches)?)?;
781 }
782 table.set("mismatches", mismatches_table)?;
783 }
784 Ok(table)
785}
786
787fn lua_to_generate_request(
794 lua: &Lua,
795 contents: Value,
796 generators: Option<Table>,
797 test_mode: Option<String>
798) -> anyhow::Result<GenerateContentRequest> {
799 let mut generator_map = HashMap::new();
800 if let Some(generators) = generators {
801 for pair in generators.pairs::<String, Table>() {
802 let (path, generator_table) = pair?;
803 let r#type: String = generator_table.get("type")?;
804 let values: Option<Value> = generator_table.get("values")?;
805 let values = match values {
806 Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
807 None => None,
808 };
809 generator_map.insert(path, Generator { r#type, values });
810 }
811 }
812 let test_mode = match test_mode.as_deref() {
813 Some("Consumer") => generate_content_request::TestMode::Consumer,
814 Some("Provider") => generate_content_request::TestMode::Provider,
815 _ => generate_content_request::TestMode::Unknown,
816 };
817 Ok(GenerateContentRequest {
818 contents: lua_to_body(contents)?,
819 generators: generator_map,
820 test_mode: test_mode as i32,
821 .. GenerateContentRequest::default()
822 })
823}
824
825fn lua_to_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<InteractionResponse> {
831 let contents: Option<Value> = table.get("contents")?;
832 let body = match contents {
833 Some(value) => lua_to_body(value)?,
834 None => None,
835 };
836 let plugin_config: Option<Value> = table.get("plugin_config")?;
837 let part_name: Option<String> = table.get("part_name")?;
838 Ok(InteractionResponse {
839 contents: body,
840 rules: HashMap::new(),
841 generators: HashMap::new(),
842 message_metadata: None,
843 plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
844 interaction_markup: String::new(),
845 interaction_markup_type: 0,
846 part_name: part_name.unwrap_or_default(),
847 metadata_rules: HashMap::new(),
848 metadata_generators: HashMap::new(),
849 })
850}
851
852fn lua_to_configure_response(lua: &Lua, table: Table) -> anyhow::Result<ConfigureInteractionResponse> {
857 let mut interactions = vec![];
858 let items: Option<Table> = table.get("interactions")?;
859 if let Some(items) = items {
860 for entry in items.sequence_values::<Table>() {
861 interactions.push(lua_to_interaction_response(lua, entry?)?);
862 }
863 }
864
865 let plugin_config: Option<Value> = table.get("plugin_config")?;
866 Ok(ConfigureInteractionResponse {
867 error: String::new(),
868 interaction: interactions,
869 plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
870 })
871}
872
873fn struct_to_lua(lua: &Lua, value: &Option<prost_types::Struct>) -> mlua::Result<Value> {
877 match value {
878 Some(value) => lua.to_value(&proto_struct_to_json(value)),
879 None => Ok(Value::Nil),
880 }
881}
882
883fn interaction_contents_to_lua(lua: &Lua, contents: &proto_v2::InteractionContents) -> mlua::Result<Table> {
887 let table = lua.create_table()?;
888 table.set("interaction_type", contents.interaction_type.clone())?;
889 table.set("consumer", contents.consumer.clone())?;
890 table.set("provider", contents.provider.clone())?;
891 if let Some(plugin_configuration) = &contents.plugin_configuration {
892 let config_table = lua.create_table()?;
893 if let Some(interaction_configuration) = &plugin_configuration.interaction_configuration {
894 config_table.set("interaction_configuration", lua.to_value(&proto_struct_to_json(interaction_configuration))?)?;
895 }
896 if let Some(pact_configuration) = &plugin_configuration.pact_configuration {
897 config_table.set("pact_configuration", lua.to_value(&proto_struct_to_json(pact_configuration))?)?;
898 }
899 table.set("plugin_configuration", config_table)?;
900 }
901 Ok(table)
902}
903
904fn v2_interaction_data_to_v1(data: &proto_v2::InteractionData) -> anyhow::Result<InteractionData> {
912 use prost::Message;
913 InteractionData::decode(data.encode_to_vec().as_slice())
914 .map_err(|err| anyhow!("Failed to convert V2 InteractionData to V1 - {}", err))
915}
916
917fn metadata_to_lua(lua: &Lua, metadata: &HashMap<String, MetadataValue>) -> mlua::Result<Table> {
921 let table = lua.create_table()?;
922 for (key, value) in metadata {
923 let lua_value = match &value.value {
924 Some(metadata_value::Value::NonBinaryValue(value)) => lua.to_value(&proto_value_to_json(value))?,
925 Some(metadata_value::Value::BinaryValue(bytes)) => {
926 let wrapper = lua.create_table()?;
927 wrapper.set("binary", lua.create_string(bytes)?)?;
928 Value::Table(wrapper)
929 }
930 None => Value::Nil,
931 };
932 table.set(key.clone(), lua_value)?;
933 }
934 Ok(table)
935}
936
937fn lua_to_metadata(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MetadataValue>> {
939 let mut metadata = HashMap::new();
940 if let Some(table) = table {
941 for pair in table.pairs::<String, Value>() {
942 let (key, value) = pair?;
943 let binary: Option<mlua::String> = match &value {
944 Value::Table(wrapper) => wrapper.get("binary")?,
945 _ => None,
946 };
947 let metadata_value = if let Some(binary) = binary {
948 metadata_value::Value::BinaryValue(binary.as_bytes().to_vec())
949 } else {
950 let json: serde_json::Value = lua.from_value(value)?;
951 metadata_value::Value::NonBinaryValue(to_proto_value(&json))
952 };
953 metadata.insert(key, MetadataValue { value: Some(metadata_value) });
954 }
955 }
956 Ok(metadata)
957}
958
959fn interaction_data_to_lua(lua: &Lua, data: &Option<InteractionData>) -> mlua::Result<Value> {
962 match data {
963 None => Ok(Value::Nil),
964 Some(data) => {
965 let table = lua.create_table()?;
966 table.set("body", body_to_lua(lua, &data.body)?)?;
967 table.set("metadata", metadata_to_lua(lua, &data.metadata)?)?;
968 Ok(Value::Table(table))
969 }
970 }
971}
972
973fn lua_to_interaction_data(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<InteractionData>> {
976 match value {
977 None | Some(Value::Nil) => Ok(None),
978 Some(Value::Table(table)) => {
979 let body: Option<Value> = table.get("body")?;
980 let body = match body {
981 Some(value) => lua_to_body(value)?,
982 None => None,
983 };
984 let metadata_table: Option<Table> = table.get("metadata")?;
985 Ok(Some(InteractionData {
986 body,
987 metadata: lua_to_metadata(lua, metadata_table)?,
988 }))
989 }
990 Some(other) => Err(anyhow!("Expected an interaction data table or nil from Lua, got {}", other.type_name())),
991 }
992}
993
994fn lua_to_start_mock_server_response(table: Table) -> anyhow::Result<StartMockServerResponse> {
997 let error: Option<String> = table.get("error")?;
998 if let Some(error) = error {
999 return Ok(StartMockServerResponse {
1000 response: Some(start_mock_server_response::Response::Error(error)),
1001 });
1002 }
1003
1004 let details: Option<Table> = table.get("details")?;
1005 let details = details.ok_or_else(|| {
1006 anyhow!("Lua start_mock_server() must return either an 'error' or 'details' field")
1007 })?;
1008 Ok(StartMockServerResponse {
1009 response: Some(start_mock_server_response::Response::Details(MockServerDetails {
1010 key: details.get("key")?,
1011 port: details.get("port")?,
1012 address: details.get("address")?,
1013 })),
1014 })
1015}
1016
1017fn lua_to_mock_server_results(table: Table) -> anyhow::Result<MockServerResults> {
1022 let ok: bool = table.get::<Option<bool>>("ok")?.unwrap_or(true);
1023 let mut results = vec![];
1024 let results_table: Option<Table> = table.get("results")?;
1025 if let Some(results_table) = results_table {
1026 for entry in results_table.sequence_values::<Table>() {
1027 let entry = entry?;
1028 let path: String = entry.get::<Option<String>>("path")?.unwrap_or_default();
1029 let error: String = entry.get::<Option<String>>("error")?.unwrap_or_default();
1030 let mismatches_value: Value = entry.get("mismatches")?;
1031 results.push(MockServerResult {
1032 path: path.clone(),
1033 error,
1034 mismatches: lua_value_to_content_mismatches(&path, mismatches_value)?,
1035 });
1036 }
1037 }
1038 Ok(MockServerResults { ok, results })
1039}
1040
1041fn lua_to_verification_preparation_response(
1045 lua: &Lua,
1046 table: Table,
1047) -> anyhow::Result<VerificationPreparationResponse> {
1048 let error: Option<String> = table.get("error")?;
1049 if let Some(error) = error {
1050 return Ok(VerificationPreparationResponse {
1051 response: Some(verification_preparation_response::Response::Error(error)),
1052 });
1053 }
1054
1055 let data: Option<Value> = table.get("interaction_data")?;
1056 let data = data.ok_or_else(|| {
1057 anyhow!("Lua prepare_interaction_for_verification() must return either an 'error' or 'interaction_data' field")
1058 })?;
1059 let interaction_data = lua_to_interaction_data(lua, Some(data))?
1060 .unwrap_or_else(|| InteractionData { body: None, metadata: HashMap::new() });
1061 Ok(VerificationPreparationResponse {
1062 response: Some(verification_preparation_response::Response::InteractionData(interaction_data)),
1063 })
1064}
1065
1066fn lua_to_verification_result_item(value: Value) -> anyhow::Result<VerificationResultItem> {
1069 match value {
1070 Value::String(s) => Ok(VerificationResultItem {
1071 result: Some(verification_result_item::Result::Error(s.to_str()?.to_string())),
1072 }),
1073 Value::Table(table) => {
1074 let mismatch: Option<String> = table.get("mismatch")?;
1075 let path: Option<String> = table.get("path")?;
1076 let expected = lua_scalar_to_string(table.get("expected")?)?;
1077 let actual = lua_scalar_to_string(table.get("actual")?)?;
1078 let diff: Option<String> = table.get("diff")?;
1079 let mismatch_type: Option<String> = table.get("mismatch_type")?;
1080 Ok(VerificationResultItem {
1081 result: Some(verification_result_item::Result::Mismatch(ContentMismatch {
1082 expected: expected.map(|s| s.into_bytes()),
1083 actual: actual.map(|s| s.into_bytes()),
1084 mismatch: mismatch.unwrap_or_default(),
1085 path: path.unwrap_or_default(),
1086 diff: diff.unwrap_or_default(),
1087 mismatch_type: mismatch_type.unwrap_or_default(),
1088 })),
1089 })
1090 }
1091 other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
1092 }
1093}
1094
1095fn lua_to_verify_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<VerifyInteractionResponse> {
1100 let error: Option<String> = table.get("error")?;
1101 if let Some(error) = error {
1102 return Ok(VerifyInteractionResponse {
1103 response: Some(verify_interaction_response::Response::Error(error)),
1104 });
1105 }
1106
1107 let result_table: Option<Table> = table.get("result")?;
1108 let result_table = result_table
1109 .ok_or_else(|| anyhow!("Lua verify_interaction() must return either an 'error' or 'result' field"))?;
1110
1111 let success: bool = result_table.get::<Option<bool>>("success")?.unwrap_or(false);
1112 let response_data: Option<Value> = result_table.get("response_data")?;
1113 let response_data = lua_to_interaction_data(lua, response_data)?;
1114
1115 let mut mismatches = vec![];
1116 let mismatches_value: Option<Value> = result_table.get("mismatches")?;
1117 if let Some(Value::Table(mismatches_table)) = mismatches_value {
1118 for entry in mismatches_table.sequence_values::<Value>() {
1119 mismatches.push(lua_to_verification_result_item(entry?)?);
1120 }
1121 }
1122
1123 let output: Option<Vec<String>> = result_table.get("output")?;
1124
1125 Ok(VerifyInteractionResponse {
1126 response: Some(verify_interaction_response::Response::Result(VerificationResult {
1127 success,
1128 response_data,
1129 mismatches,
1130 output: output.unwrap_or_default(),
1131 })),
1132 })
1133}
1134
1135#[async_trait]
1136impl PactPluginRpc for LuaPactPlugin {
1137 async fn init_plugin(&mut self, request: PluginInitRequest) -> anyhow::Result<PluginInitResponse> {
1138 let lua = self.runtime.lock().await;
1139 let catalogue = call_init(&lua, &request.implementation, &request.version)?;
1140 Ok(PluginInitResponse {
1141 catalogue,
1142 plugin_capabilities: vec![],
1143 })
1144 }
1145}
1146
1147#[async_trait]
1148impl PluginInstance for LuaPactPlugin {
1149 fn manifest(&self) -> &PactPluginManifest {
1150 &self.manifest
1151 }
1152
1153 fn instance_id(&self) -> &str {
1154 &self.instance_id
1155 }
1156
1157 fn has_capability(&self, capability: &str) -> bool {
1158 self.plugin_capabilities.iter().any(|c| c == capability)
1159 }
1160
1161 async fn compare_contents(
1162 &self,
1163 request: CompareContentsRequest,
1164 ) -> anyhow::Result<CompareContentsResponse> {
1165 let lua = self.runtime.lock().await;
1166 let match_fn: Function = lua
1167 .globals()
1168 .get("match_contents")
1169 .map_err(|_| anyhow!("Lua plugin does not define a global 'match_contents' function"))?;
1170 let request_table = compare_request_to_lua(&lua, &request)?;
1171 let result: Table = match_fn
1172 .call_async(request_table)
1173 .await
1174 .map_err(|err| anyhow!("Lua match_contents() function failed - {}", err))?;
1175 lua_to_compare_response(result)
1176 }
1177
1178 async fn configure_interaction(
1179 &self,
1180 request: ConfigureInteractionRequest,
1181 ) -> anyhow::Result<ConfigureInteractionResponse> {
1182 let lua = self.runtime.lock().await;
1183 let configure_fn: Function = lua
1184 .globals()
1185 .get("configure_interaction")
1186 .map_err(|_| anyhow!("Lua plugin does not define a global 'configure_interaction' function"))?;
1187 let config: Value = match &request.contents_config {
1188 Some(config) => lua.to_value(&proto_struct_to_json(config))?,
1189 None => Value::Nil,
1190 };
1191 let result: Table = configure_fn
1192 .call((request.content_type.clone(), config))
1193 .map_err(|err| anyhow!("Lua configure_interaction() function failed - {}", err))?;
1194 lua_to_configure_response(&lua, result)
1195 }
1196
1197 async fn generate_content(
1198 &self,
1199 request: GenerateContentRequest,
1200 ) -> anyhow::Result<GenerateContentResponse> {
1201 let lua = self.runtime.lock().await;
1202 let generate_fn: Option<Function> = lua.globals().get("generate_content")?;
1203 match generate_fn {
1204 None => Ok(GenerateContentResponse {
1205 contents: request.contents,
1206 }),
1207 Some(generate_fn) => {
1208 let contents = body_to_lua(&lua, &request.contents)?;
1209 let generators = lua.create_table()?;
1210 for (path, generator) in &request.generators {
1211 let generator_table = lua.create_table()?;
1212 generator_table.set("type", generator.r#type.clone())?;
1213 if let Some(values) = &generator.values {
1214 generator_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
1215 }
1216 generators.set(path.clone(), generator_table)?;
1217 }
1218 let test_mode = match generate_content_request::TestMode::try_from(request.test_mode)
1219 .unwrap_or(generate_content_request::TestMode::Unknown)
1220 {
1221 generate_content_request::TestMode::Consumer => "Consumer",
1222 generate_content_request::TestMode::Provider => "Provider",
1223 generate_content_request::TestMode::Unknown => "Unknown",
1224 };
1225 let result: Value = generate_fn
1226 .call_async((contents, generators, test_mode))
1227 .await
1228 .map_err(|err| anyhow!("Lua generate_content() function failed - {}", err))?;
1229 Ok(GenerateContentResponse {
1230 contents: lua_to_body(result)?,
1231 })
1232 }
1233 }
1234 }
1235
1236 async fn start_mock_server(
1237 &self,
1238 request: StartMockServerRequest,
1239 ) -> anyhow::Result<StartMockServerResponse> {
1240 let lua = self.runtime.lock().await;
1241 let start_fn: Function = lua
1242 .globals()
1243 .get("start_mock_server")
1244 .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1245 let request_table = lua.create_table()?;
1246 request_table.set("host_interface", request.host_interface)?;
1247 request_table.set("port", request.port)?;
1248 request_table.set("tls", request.tls)?;
1249 request_table.set("pact", request.pact)?;
1250 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1251 let result: Table = start_fn
1252 .call(request_table)
1253 .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1254 lua_to_start_mock_server_response(result)
1255 }
1256
1257 async fn start_mock_server_v2(
1258 &self,
1259 request: proto_v2::StartMockServerRequest,
1260 ) -> anyhow::Result<StartMockServerResponse> {
1261 let lua = self.runtime.lock().await;
1262 let start_fn: Function = lua
1263 .globals()
1264 .get("start_mock_server")
1265 .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1266 let request_table = lua.create_table()?;
1267 request_table.set("host_interface", request.host_interface)?;
1268 request_table.set("port", request.port)?;
1269 request_table.set("tls", request.tls)?;
1270 let interactions_table = lua.create_table()?;
1271 for interaction in &request.interactions {
1272 interactions_table.push(interaction_contents_to_lua(&lua, interaction)?)?;
1273 }
1274 request_table.set("interactions", interactions_table)?;
1275 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1276 let result: Table = start_fn
1277 .call(request_table)
1278 .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1279 lua_to_start_mock_server_response(result)
1280 }
1281
1282 async fn shutdown_mock_server(
1283 &self,
1284 request: ShutdownMockServerRequest,
1285 ) -> anyhow::Result<ShutdownMockServerResponse> {
1286 let lua = self.runtime.lock().await;
1287 let shutdown_fn: Function = lua
1288 .globals()
1289 .get("shutdown_mock_server")
1290 .map_err(|_| anyhow!("Lua plugin does not define a global 'shutdown_mock_server' function"))?;
1291 let result: Table = shutdown_fn
1292 .call(request.server_key)
1293 .map_err(|err| anyhow!("Lua shutdown_mock_server() function failed - {}", err))?;
1294 let results = lua_to_mock_server_results(result)?;
1295 Ok(ShutdownMockServerResponse {
1296 ok: results.ok,
1297 results: results.results,
1298 })
1299 }
1300
1301 async fn get_mock_server_results(
1302 &self,
1303 request: MockServerRequest,
1304 ) -> anyhow::Result<MockServerResults> {
1305 let lua = self.runtime.lock().await;
1306 let results_fn: Function = lua
1307 .globals()
1308 .get("get_mock_server_results")
1309 .map_err(|_| anyhow!("Lua plugin does not define a global 'get_mock_server_results' function"))?;
1310 let result: Table = results_fn
1311 .call(request.server_key)
1312 .map_err(|err| anyhow!("Lua get_mock_server_results() function failed - {}", err))?;
1313 lua_to_mock_server_results(result)
1314 }
1315
1316 async fn prepare_interaction_for_verification(
1317 &self,
1318 request: VerificationPreparationRequest,
1319 ) -> anyhow::Result<VerificationPreparationResponse> {
1320 let lua = self.runtime.lock().await;
1321 let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1322 anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1323 })?;
1324 let request_table = lua.create_table()?;
1325 request_table.set("pact", request.pact)?;
1326 request_table.set("interaction_key", request.interaction_key)?;
1327 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1328 let result: Table = prepare_fn
1329 .call(request_table)
1330 .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1331 lua_to_verification_preparation_response(&lua, result)
1332 }
1333
1334 async fn prepare_interaction_for_verification_v2(
1335 &self,
1336 request: proto_v2::VerificationPreparationRequest,
1337 ) -> anyhow::Result<VerificationPreparationResponse> {
1338 let lua = self.runtime.lock().await;
1339 let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1340 anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1341 })?;
1342 let request_table = lua.create_table()?;
1343 if let Some(interaction_contents) = &request.interaction_contents {
1344 request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1345 }
1346 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1347 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1348 let result: Table = prepare_fn
1349 .call(request_table)
1350 .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1351 lua_to_verification_preparation_response(&lua, result)
1352 }
1353
1354 async fn verify_interaction(
1355 &self,
1356 request: VerifyInteractionRequest,
1357 ) -> anyhow::Result<VerifyInteractionResponse> {
1358 let lua = self.runtime.lock().await;
1359 let verify_fn: Function = lua
1360 .globals()
1361 .get("verify_interaction")
1362 .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1363 let request_table = lua.create_table()?;
1364 request_table.set("interaction_data", interaction_data_to_lua(&lua, &request.interaction_data)?)?;
1365 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1366 request_table.set("pact", request.pact)?;
1367 request_table.set("interaction_key", request.interaction_key)?;
1368 let result: Table = verify_fn
1369 .call(request_table)
1370 .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1371 lua_to_verify_interaction_response(&lua, result)
1372 }
1373
1374 async fn verify_interaction_v2(
1375 &self,
1376 request: proto_v2::VerifyInteractionRequest,
1377 ) -> anyhow::Result<VerifyInteractionResponse> {
1378 let lua = self.runtime.lock().await;
1379 let verify_fn: Function = lua
1380 .globals()
1381 .get("verify_interaction")
1382 .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1383 let request_table = lua.create_table()?;
1384 let interaction_data = request.interaction_data.as_ref()
1385 .map(v2_interaction_data_to_v1)
1386 .transpose()?;
1387 request_table.set("interaction_data", interaction_data_to_lua(&lua, &interaction_data)?)?;
1388 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1389 if let Some(interaction_contents) = &request.interaction_contents {
1390 request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1391 }
1392 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1393 let result: Table = verify_fn
1394 .call(request_table)
1395 .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1396 lua_to_verify_interaction_response(&lua, result)
1397 }
1398
1399 async fn update_catalogue(&self, request: Catalogue) -> anyhow::Result<()> {
1400 let lua = self.runtime.lock().await;
1401 let update_fn: Option<Function> = lua.globals().get("update_catalogue")?;
1402 if let Some(update_fn) = update_fn {
1403 let table = lua.create_table()?;
1404 for entry in &request.catalogue {
1405 let entry_table = lua.create_table()?;
1406 let entry_type = catalogue_entry::EntryType::try_from(entry.r#type)
1407 .unwrap_or(catalogue_entry::EntryType::ContentMatcher);
1408 entry_table.set("entryType", entry_type.as_str_name())?;
1409 entry_table.set("key", entry.key.clone())?;
1410 entry_table.set("values", entry.values.clone())?;
1411 table.push(entry_table)?;
1412 }
1413 update_fn
1414 .call::<()>(table)
1415 .map_err(|err| anyhow!("Lua update_catalogue() function failed - {}", err))?;
1416 }
1417 Ok(())
1418 }
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423 use std::collections::HashMap;
1424
1425 use super::*;
1426
1427 fn jwt_manifest() -> PactPluginManifest {
1428 let plugin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../plugins/jwt");
1434 assert!(plugin_dir.exists(), "plugins/jwt directory should exist at {:?}", plugin_dir);
1435 PactPluginManifest {
1436 plugin_dir: plugin_dir.to_string_lossy().to_string(),
1437 plugin_interface_version: 1,
1438 name: "jwt".to_string(),
1439 version: "0.0.0".to_string(),
1440 executable_type: "lua".to_string(),
1441 minimum_required_version: None,
1442 entry_point: "plugin.lua".to_string(),
1443 entry_points: HashMap::new(),
1444 args: None,
1445 dependencies: None,
1446 plugin_config: HashMap::new(),
1447 }
1448 }
1449
1450 const PRIVATE_KEY: &str = include_str!("../tests/fixtures/jwt-test-key.pem");
1451
1452 #[test]
1453 fn loads_pure_lua_packages_from_a_configured_luarocks_directory() {
1454 let rocks_root = tempdir::TempDir::new("luarocks-test").unwrap();
1455 let lua_dir = rocks_root.path().join("share").join("lua").join(LUAROCKS_LUA_VERSION);
1456 std::fs::create_dir_all(&lua_dir).unwrap();
1457 std::fs::write(
1458 lua_dir.join("greeter.lua"),
1459 r#"return { hello = function() return "hello from luarocks" end }"#,
1460 ).unwrap();
1461
1462 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1463 std::fs::write(
1464 plugin_dir.path().join("entry.lua"),
1465 r#"
1466 local greeter = require "greeter"
1467 GREETER_RESULT = greeter.hello()
1468 "#,
1469 ).unwrap();
1470
1471 let mut plugin_config = HashMap::new();
1472 plugin_config.insert(
1473 "luaRocksDir".to_string(),
1474 serde_json::Value::String(rocks_root.path().to_string_lossy().to_string()),
1475 );
1476
1477 let manifest = PactPluginManifest {
1478 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1479 plugin_interface_version: 1,
1480 name: "luarocks-test".to_string(),
1481 version: "0.0.0".to_string(),
1482 executable_type: "lua".to_string(),
1483 minimum_required_version: None,
1484 entry_point: "entry.lua".to_string(),
1485 entry_points: HashMap::new(),
1486 args: None,
1487 dependencies: None,
1488 plugin_config,
1489 };
1490
1491 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1492 let lua = plugin.runtime.blocking_lock();
1493 let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1494 assert_eq!(result, "hello from luarocks");
1495 }
1496
1497 #[test]
1498 fn loads_a_vendored_directory_style_module_from_the_plugin_directory() {
1499 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1500 let module_dir = plugin_dir.path().join("greeter");
1501 std::fs::create_dir_all(&module_dir).unwrap();
1502 std::fs::write(
1503 module_dir.join("init.lua"),
1504 r#"return { hello = function() return "hello from a vendored module" end }"#,
1505 ).unwrap();
1506 std::fs::write(
1507 plugin_dir.path().join("entry.lua"),
1508 r#"
1509 local greeter = require "greeter"
1510 GREETER_RESULT = greeter.hello()
1511 "#,
1512 ).unwrap();
1513
1514 let manifest = PactPluginManifest {
1515 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1516 plugin_interface_version: 1,
1517 name: "vendored-module-test".to_string(),
1518 version: "0.0.0".to_string(),
1519 executable_type: "lua".to_string(),
1520 minimum_required_version: None,
1521 entry_point: "entry.lua".to_string(),
1522 entry_points: HashMap::new(),
1523 args: None,
1524 dependencies: None,
1525 plugin_config: HashMap::new(),
1526 };
1527
1528 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1529 let lua = plugin.runtime.blocking_lock();
1530 let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1531 assert_eq!(result, "hello from a vendored module");
1532 }
1533
1534 #[test]
1535 fn loads_a_vendored_module_when_the_entry_point_is_in_a_subdirectory() {
1536 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1540 std::fs::write(
1541 plugin_dir.path().join("greeter.lua"),
1542 r#"return { hello = function() return "hello from the plugin root" end }"#,
1543 ).unwrap();
1544 let src_dir = plugin_dir.path().join("src");
1545 std::fs::create_dir_all(&src_dir).unwrap();
1546 std::fs::write(
1547 src_dir.join("entry.lua"),
1548 r#"
1549 local greeter = require "greeter"
1550 GREETER_RESULT = greeter.hello()
1551 "#,
1552 ).unwrap();
1553
1554 let manifest = PactPluginManifest {
1555 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1556 plugin_interface_version: 1,
1557 name: "nested-entry-point-test".to_string(),
1558 version: "0.0.0".to_string(),
1559 executable_type: "lua".to_string(),
1560 minimum_required_version: None,
1561 entry_point: "src/entry.lua".to_string(),
1562 entry_points: HashMap::new(),
1563 args: None,
1564 dependencies: None,
1565 plugin_config: HashMap::new(),
1566 };
1567
1568 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1569 let lua = plugin.runtime.blocking_lock();
1570 let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1571 assert_eq!(result, "hello from the plugin root");
1572 }
1573
1574 #[test]
1575 fn ignores_a_missing_luarocks_directory_instead_of_failing() {
1576 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1577 std::fs::write(plugin_dir.path().join("entry.lua"), "-- no-op").unwrap();
1578
1579 let mut plugin_config = HashMap::new();
1580 plugin_config.insert(
1581 "luaRocksDir".to_string(),
1582 serde_json::Value::String("/no/such/directory".to_string()),
1583 );
1584
1585 let manifest = PactPluginManifest {
1586 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1587 plugin_interface_version: 1,
1588 name: "luarocks-test".to_string(),
1589 version: "0.0.0".to_string(),
1590 executable_type: "lua".to_string(),
1591 minimum_required_version: None,
1592 entry_point: "entry.lua".to_string(),
1593 entry_points: HashMap::new(),
1594 args: None,
1595 dependencies: None,
1596 plugin_config,
1597 };
1598
1599 start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1600 }
1601
1602 #[test]
1603 fn captures_print_and_logger_output_into_the_per_instance_log_file() {
1604 let output_dir = tempdir::TempDir::new("lua-plugin-log-test").unwrap();
1605 unsafe { std::env::set_var("PACT_OUTPUT_DIR", output_dir.path()); }
1608
1609 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1610 std::fs::write(
1611 plugin_dir.path().join("entry.lua"),
1612 r#"
1613 print("hello", "world", 42)
1614 logger("a logger message")
1615 "#,
1616 ).unwrap();
1617
1618 let manifest = PactPluginManifest {
1619 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1620 plugin_interface_version: 1,
1621 name: "log-test".to_string(),
1622 version: "0.0.0".to_string(),
1623 executable_type: "lua".to_string(),
1624 minimum_required_version: None,
1625 entry_point: "entry.lua".to_string(),
1626 entry_points: HashMap::new(),
1627 args: None,
1628 dependencies: None,
1629 plugin_config: HashMap::new(),
1630 };
1631
1632 start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1633 unsafe { std::env::remove_var("PACT_OUTPUT_DIR"); }
1634
1635 let log_path = output_dir.path().join("logs").join("pact-plugin-log-test-test-instance.log");
1636 let contents = std::fs::read_to_string(&log_path)
1637 .unwrap_or_else(|err| panic!("Expected a log file at {:?} - {}", log_path, err));
1638 assert_eq!(contents, "hello\tworld\t42\na logger message\n");
1639 }
1640
1641 #[tokio::test]
1642 async fn loads_the_jwt_plugin_and_runs_the_init_function() {
1643 let manifest = jwt_manifest();
1644 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1645 let lua = plugin.runtime.lock().await;
1646 let entries = call_init(&lua, "test", "0.0.0").unwrap();
1647 assert_eq!(entries.len(), 2);
1648 assert_eq!(entries[0].key, "jwt");
1649 assert_eq!(entries[0].r#type, catalogue_entry::EntryType::ContentMatcher as i32);
1650 assert_eq!(entries[1].r#type, catalogue_entry::EntryType::ContentGenerator as i32);
1651 }
1652
1653 #[tokio::test]
1654 async fn configure_interaction_then_match_contents_round_trip() {
1655 let manifest = jwt_manifest();
1656 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1657
1658 let mut config_fields = HashMap::new();
1659 config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
1660 config_fields.insert("subject".to_string(), serde_json::Value::String("test-subject".to_string()));
1661 config_fields.insert("issuer".to_string(), serde_json::Value::String("test-issuer".to_string()));
1662 config_fields.insert("audience".to_string(), serde_json::Value::String("test-audience".to_string()));
1663 config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
1664
1665 let configure_request = ConfigureInteractionRequest {
1666 content_type: "application/jwt+json".to_string(),
1667 contents_config: Some(to_proto_struct(&config_fields)),
1668 };
1669 let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
1670 assert_eq!(configure_response.error, "");
1671 assert_eq!(configure_response.interaction.len(), 1);
1672
1673 let interaction = &configure_response.interaction[0];
1674 let body = interaction.contents.clone().expect("expected a body");
1675 assert_eq!(body.content_type, "application/jwt+json");
1676 let token = String::from_utf8(body.content.clone().unwrap()).unwrap();
1677 assert_eq!(token.split('.').count(), 3);
1678
1679 let compare_request = CompareContentsRequest {
1680 expected: Some(body.clone()),
1681 actual: Some(body),
1682 allow_unexpected_keys: false,
1683 rules: HashMap::new(),
1684 plugin_configuration: interaction.plugin_configuration.clone(),
1685 };
1686 let compare_response = plugin.compare_contents(compare_request).await.unwrap();
1687 assert_eq!(compare_response.error, "");
1688 assert!(compare_response.type_mismatch.is_none());
1689 assert!(
1690 compare_response.results.is_empty(),
1691 "expected no mismatches, got {:?}",
1692 compare_response.results
1693 );
1694 }
1695
1696 #[tokio::test]
1697 async fn match_contents_detects_a_tampered_token() {
1698 let manifest = jwt_manifest();
1699 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1700
1701 let mut config_fields = HashMap::new();
1702 config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
1703 config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
1704
1705 let configure_request = ConfigureInteractionRequest {
1706 content_type: "application/jwt+json".to_string(),
1707 contents_config: Some(to_proto_struct(&config_fields)),
1708 };
1709 let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
1710 let interaction = &configure_response.interaction[0];
1711 let expected_body = interaction.contents.clone().unwrap();
1712
1713 let mut actual_body = expected_body.clone();
1714 let mut token = String::from_utf8(actual_body.content.clone().unwrap()).unwrap();
1715 token.push('x'); actual_body.content = Some(token.into_bytes());
1717
1718 let compare_request = CompareContentsRequest {
1719 expected: Some(expected_body),
1720 actual: Some(actual_body),
1721 allow_unexpected_keys: false,
1722 rules: HashMap::new(),
1723 plugin_configuration: interaction.plugin_configuration.clone(),
1724 };
1725 let compare_response = plugin.compare_contents(compare_request).await.unwrap();
1726 assert!(!compare_response.results.is_empty(), "expected a mismatch to be detected");
1727 }
1728
1729 #[tokio::test]
1730 async fn compare_contents_handles_non_string_mismatch_values() {
1731 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1732 std::fs::write(
1733 plugin_dir.path().join("entry.lua"),
1734 r#"
1735 function match_contents(request)
1736 return {
1737 mismatches = {
1738 ["claims:exp"] = { expected = 123, actual = 456, mismatch = "exp differs", path = "claims:exp" },
1739 ["claims:verified"] = { expected = true, actual = false, mismatch = "verified differs", path = "claims:verified" }
1740 }
1741 }
1742 end
1743 "#,
1744 ).unwrap();
1745
1746 let manifest = PactPluginManifest {
1747 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1748 plugin_interface_version: 1,
1749 name: "scalar-mismatch-test".to_string(),
1750 version: "0.0.0".to_string(),
1751 executable_type: "lua".to_string(),
1752 minimum_required_version: None,
1753 entry_point: "entry.lua".to_string(),
1754 entry_points: HashMap::new(),
1755 args: None,
1756 dependencies: None,
1757 plugin_config: HashMap::new(),
1758 };
1759 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1760
1761 let compare_request = CompareContentsRequest {
1762 expected: None,
1763 actual: None,
1764 allow_unexpected_keys: false,
1765 rules: HashMap::new(),
1766 plugin_configuration: None,
1767 };
1768 let response = plugin.compare_contents(compare_request).await.unwrap();
1769
1770 let exp_mismatch = &response.results["claims:exp"].mismatches[0];
1771 assert_eq!(exp_mismatch.expected.as_deref(), Some("123".as_bytes()));
1772 assert_eq!(exp_mismatch.actual.as_deref(), Some("456".as_bytes()));
1773
1774 let verified_mismatch = &response.results["claims:verified"].mismatches[0];
1775 assert_eq!(verified_mismatch.expected.as_deref(), Some("true".as_bytes()));
1776 assert_eq!(verified_mismatch.actual.as_deref(), Some("false".as_bytes()));
1777 }
1778
1779 fn lua_manifest(plugin_dir: &std::path::Path, name: &str) -> PactPluginManifest {
1780 PactPluginManifest {
1781 plugin_dir: plugin_dir.to_string_lossy().to_string(),
1782 plugin_interface_version: 1,
1783 name: name.to_string(),
1784 version: "0.0.0".to_string(),
1785 executable_type: "lua".to_string(),
1786 minimum_required_version: None,
1787 entry_point: "entry.lua".to_string(),
1788 entry_points: HashMap::new(),
1789 args: None,
1790 dependencies: None,
1791 plugin_config: HashMap::new(),
1792 }
1793 }
1794
1795 fn core_matcher_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
1796 crate::catalogue_manager::CatalogueEntry {
1797 entry_type: crate::catalogue_manager::CatalogueEntryType::CONTENT_MATCHER,
1798 provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
1799 plugin: None,
1800 key: key.to_string(),
1801 values: HashMap::new()
1802 }
1803 }
1804
1805 fn core_generator_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
1806 crate::catalogue_manager::CatalogueEntry {
1807 entry_type: crate::catalogue_manager::CatalogueEntryType::CONTENT_GENERATOR,
1808 provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
1809 plugin: None,
1810 key: key.to_string(),
1811 values: HashMap::new()
1812 }
1813 }
1814
1815 struct FixedErrorCoreMatcher;
1816
1817 #[async_trait]
1818 impl crate::core_capabilities::CoreContentMatcher for FixedErrorCoreMatcher {
1819 async fn compare_contents(&self, _request: CompareContentsRequest) -> anyhow::Result<CompareContentsResponse> {
1820 Ok(CompareContentsResponse {
1821 error: "core matcher says no".to_string(),
1822 type_mismatch: None,
1823 results: HashMap::new(),
1824 })
1825 }
1826 }
1827
1828 #[tokio::test]
1829 async fn match_contents_calls_host_compare_contents_for_a_registered_core_capability() {
1830 let key = "match_contents_calls_host_compare_contents_for_a_registered_core_capability";
1831 crate::catalogue_manager::register_core_entries(&vec![core_matcher_entry(key)]);
1832 crate::core_capabilities::register_core_content_matcher(key, Arc::new(FixedErrorCoreMatcher));
1833
1834 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1835 std::fs::write(
1836 plugin_dir.path().join("entry.lua"),
1837 format!(r#"
1838 function match_contents(request)
1839 return host_compare_contents("{key}", request)
1840 end
1841 "#, key = key),
1842 ).unwrap();
1843 let manifest = lua_manifest(plugin_dir.path(), "host-compare-contents-test");
1844 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1845
1846 let compare_request = CompareContentsRequest {
1847 expected: None,
1848 actual: None,
1849 allow_unexpected_keys: false,
1850 rules: HashMap::new(),
1851 plugin_configuration: None,
1852 };
1853 let response = plugin.compare_contents(compare_request).await.unwrap();
1854
1855 crate::core_capabilities::deregister_core_content_matcher(key);
1856
1857 assert_eq!(response.error, "core matcher says no");
1858 }
1859
1860 #[tokio::test]
1861 async fn match_contents_surfaces_a_clear_error_when_host_compare_contents_targets_an_unregistered_entry() {
1862 let key = "match_contents_surfaces_a_clear_error_when_host_compare_contents_targets_an_unregistered_entry";
1863 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1864 std::fs::write(
1865 plugin_dir.path().join("entry.lua"),
1866 format!(r#"
1867 function match_contents(request)
1868 return host_compare_contents("{key}", request)
1869 end
1870 "#, key = key),
1871 ).unwrap();
1872 let manifest = lua_manifest(plugin_dir.path(), "host-compare-contents-missing-test");
1873 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1874
1875 let compare_request = CompareContentsRequest {
1876 expected: None,
1877 actual: None,
1878 allow_unexpected_keys: false,
1879 rules: HashMap::new(),
1880 plugin_configuration: None,
1881 };
1882 let err = plugin.compare_contents(compare_request).await
1883 .expect_err("expected an error when the target entry is not registered");
1884 assert!(
1885 err.to_string().contains("No catalogue entry found"),
1886 "unexpected error message: {}", err
1887 );
1888 }
1889
1890 struct FixedCoreGenerator;
1891
1892 #[async_trait]
1893 impl crate::core_capabilities::CoreContentGenerator for FixedCoreGenerator {
1894 async fn generate_content(&self, _request: GenerateContentRequest) -> anyhow::Result<GenerateContentResponse> {
1895 Ok(GenerateContentResponse {
1896 contents: Some(Body {
1897 content_type: "text/plain".to_string(),
1898 content: Some(b"generated by the host".to_vec()),
1899 content_type_hint: body::ContentTypeHint::Default as i32,
1900 }),
1901 })
1902 }
1903 }
1904
1905 #[tokio::test]
1906 async fn generate_content_calls_host_generate_content_for_a_registered_core_capability() {
1907 let key = "generate_content_calls_host_generate_content_for_a_registered_core_capability";
1908 crate::catalogue_manager::register_core_entries(&vec![core_generator_entry(key)]);
1909 crate::core_capabilities::register_core_content_generator(key, Arc::new(FixedCoreGenerator));
1910
1911 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1912 std::fs::write(
1913 plugin_dir.path().join("entry.lua"),
1914 format!(r#"
1915 function generate_content(contents, generators, test_mode)
1916 return host_generate_content("{key}", contents, generators, test_mode)
1917 end
1918 "#, key = key),
1919 ).unwrap();
1920 let manifest = lua_manifest(plugin_dir.path(), "host-generate-content-test");
1921 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1922
1923 let request = GenerateContentRequest {
1924 contents: Some(Body {
1925 content_type: "text/plain".to_string(),
1926 content: Some(b"original".to_vec()),
1927 content_type_hint: body::ContentTypeHint::Default as i32,
1928 }),
1929 .. GenerateContentRequest::default()
1930 };
1931 let response = plugin.generate_content(request).await.unwrap();
1932
1933 crate::core_capabilities::deregister_core_content_generator(key);
1934
1935 assert_eq!(response.contents.unwrap().content, Some(b"generated by the host".to_vec()));
1936 }
1937
1938 fn transport_manifest(plugin_dir: &std::path::Path, plugin_interface_version: u8) -> PactPluginManifest {
1939 PactPluginManifest {
1940 plugin_dir: plugin_dir.to_string_lossy().to_string(),
1941 plugin_interface_version,
1942 name: "transport-test".to_string(),
1943 version: "0.0.0".to_string(),
1944 executable_type: "lua".to_string(),
1945 minimum_required_version: None,
1946 entry_point: "entry.lua".to_string(),
1947 entry_points: HashMap::new(),
1948 args: None,
1949 dependencies: None,
1950 plugin_config: HashMap::new(),
1951 }
1952 }
1953
1954 const TRANSPORT_PLUGIN_SCRIPT: &str = r#"
1955 function start_mock_server(request)
1956 START_MOCK_SERVER_REQUEST = request
1957 if request.port == 0 then
1958 return { error = "could not bind a mock server" }
1959 end
1960 return { details = { key = "mock-server-1", port = 12345, address = "127.0.0.1:12345" } }
1961 end
1962
1963 function shutdown_mock_server(server_key)
1964 SHUTDOWN_SERVER_KEY = server_key
1965 return {
1966 ok = false,
1967 results = { { path = "/foo", error = "did not match", mismatches = { "simple string mismatch" } } }
1968 }
1969 end
1970
1971 function get_mock_server_results(server_key)
1972 GET_RESULTS_SERVER_KEY = server_key
1973 return { ok = true, results = {} }
1974 end
1975
1976 function prepare_interaction_for_verification(request)
1977 PREPARE_REQUEST = request
1978 return {
1979 interaction_data = {
1980 body = { content_type = "application/json", contents = "prepared-body", content_type_hint = "TEXT" },
1981 metadata = { path = "/foo", tag = { binary = "raw-bytes" } }
1982 }
1983 }
1984 end
1985
1986 function verify_interaction(request)
1987 VERIFY_REQUEST = request
1988 if request.config ~= nil and request.config.fail == true then
1989 return { error = "verification failed" }
1990 end
1991 return {
1992 result = {
1993 success = true,
1994 response_data = { body = { content_type = "application/json", contents = "response-body" }, metadata = {} },
1995 mismatches = { "a plain mismatch", { mismatch = "a table mismatch", path = "$.foo", expected = 1, actual = 2 } },
1996 output = { "POST /foo", "200 OK" }
1997 }
1998 }
1999 end
2000 "#;
2001
2002 fn start_transport_plugin(plugin_interface_version: u8) -> LuaPactPlugin {
2003 let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2004 std::fs::write(plugin_dir.path().join("entry.lua"), TRANSPORT_PLUGIN_SCRIPT).unwrap();
2005 let manifest = transport_manifest(plugin_dir.path(), plugin_interface_version);
2006 start_lua_plugin(&manifest, "test-instance".to_string()).unwrap()
2009 }
2010
2011 #[tokio::test]
2012 async fn start_mock_server_v1_round_trip() {
2013 let plugin = start_transport_plugin(1);
2014 let response = plugin.start_mock_server(StartMockServerRequest {
2015 host_interface: "127.0.0.1".to_string(),
2016 port: 8080,
2017 tls: false,
2018 pact: "{\"consumer\":{}}".to_string(),
2019 test_context: None,
2020 }).await.unwrap();
2021 match response.response.unwrap() {
2022 start_mock_server_response::Response::Details(details) => {
2023 assert_eq!(details.key, "mock-server-1");
2024 assert_eq!(details.port, 12345);
2025 assert_eq!(details.address, "127.0.0.1:12345");
2026 }
2027 other => panic!("expected mock server details, got {:?}", other),
2028 }
2029 }
2030
2031 #[tokio::test]
2032 async fn start_mock_server_v1_returns_the_lua_error() {
2033 let plugin = start_transport_plugin(1);
2034 let response = plugin.start_mock_server(StartMockServerRequest {
2035 host_interface: "127.0.0.1".to_string(),
2036 port: 0,
2037 tls: false,
2038 pact: "{}".to_string(),
2039 test_context: None,
2040 }).await.unwrap();
2041 match response.response.unwrap() {
2042 start_mock_server_response::Response::Error(err) => assert_eq!(err, "could not bind a mock server"),
2043 other => panic!("expected an error response, got {:?}", other),
2044 }
2045 }
2046
2047 #[tokio::test]
2048 async fn start_mock_server_v2_passes_structured_interactions() {
2049 let plugin = start_transport_plugin(2);
2050 let request = proto_v2::StartMockServerRequest {
2051 host_interface: "127.0.0.1".to_string(),
2052 port: 8080,
2053 tls: false,
2054 interactions: vec![proto_v2::InteractionContents {
2055 interaction_type: "Synchronous/HTTP".to_string(),
2056 plugin_configuration: None,
2057 consumer: "test-consumer".to_string(),
2058 provider: "test-provider".to_string(),
2059 }],
2060 test_context: None,
2061 };
2062 let response = plugin.start_mock_server_v2(request).await.unwrap();
2063 assert!(matches!(response.response.unwrap(), start_mock_server_response::Response::Details(_)));
2064
2065 let lua = plugin.runtime.lock().await;
2066 let captured: Table = lua.globals().get("START_MOCK_SERVER_REQUEST").unwrap();
2067 let interactions: Table = captured.get("interactions").unwrap();
2068 let first: Table = interactions.get(1).unwrap();
2069 assert_eq!(first.get::<String>("interaction_type").unwrap(), "Synchronous/HTTP");
2070 assert_eq!(first.get::<String>("consumer").unwrap(), "test-consumer");
2071 }
2072
2073 #[tokio::test]
2074 async fn shutdown_and_get_mock_server_results_parse_mismatches() {
2075 let plugin = start_transport_plugin(1);
2076
2077 let shutdown_response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2078 server_key: "mock-server-1".to_string(),
2079 }).await.unwrap();
2080 assert!(!shutdown_response.ok);
2081 assert_eq!(shutdown_response.results.len(), 1);
2082 assert_eq!(shutdown_response.results[0].path, "/foo");
2083 assert_eq!(shutdown_response.results[0].mismatches[0].mismatch, "simple string mismatch");
2084
2085 let results_response = plugin.get_mock_server_results(MockServerRequest {
2086 server_key: "mock-server-1".to_string(),
2087 }).await.unwrap();
2088 assert!(results_response.ok);
2089 assert!(results_response.results.is_empty());
2090 }
2091
2092 #[tokio::test]
2093 async fn prepare_interaction_for_verification_v1_round_trip() {
2094 let plugin = start_transport_plugin(1);
2095 let response = plugin.prepare_interaction_for_verification(VerificationPreparationRequest {
2096 pact: "{}".to_string(),
2097 interaction_key: "interaction-1".to_string(),
2098 config: None,
2099 }).await.unwrap();
2100
2101 match response.response.unwrap() {
2102 verification_preparation_response::Response::InteractionData(data) => {
2103 let body = data.body.unwrap();
2104 assert_eq!(body.content, Some("prepared-body".as_bytes().to_vec()));
2105 let metadata = data.metadata;
2106 assert!(matches!(
2107 metadata["path"].value,
2108 Some(metadata_value::Value::NonBinaryValue(_))
2109 ));
2110 match &metadata["tag"].value {
2111 Some(metadata_value::Value::BinaryValue(bytes)) => assert_eq!(bytes, b"raw-bytes"),
2112 other => panic!("expected a binary metadata value, got {:?}", other),
2113 }
2114 }
2115 other => panic!("expected interaction data, got {:?}", other),
2116 }
2117 }
2118
2119 #[tokio::test]
2120 async fn prepare_interaction_for_verification_v2_passes_interaction_contents() {
2121 let plugin = start_transport_plugin(2);
2122 let request = proto_v2::VerificationPreparationRequest {
2123 interaction_contents: Some(proto_v2::InteractionContents {
2124 interaction_type: "Synchronous/HTTP".to_string(),
2125 plugin_configuration: None,
2126 consumer: "test-consumer".to_string(),
2127 provider: "test-provider".to_string(),
2128 }),
2129 config: None,
2130 test_context: None,
2131 };
2132 let response = plugin.prepare_interaction_for_verification_v2(request).await.unwrap();
2133 assert!(matches!(
2134 response.response.unwrap(),
2135 verification_preparation_response::Response::InteractionData(_)
2136 ));
2137
2138 let lua = plugin.runtime.lock().await;
2139 let captured: Table = lua.globals().get("PREPARE_REQUEST").unwrap();
2140 let interaction_contents: Table = captured.get("interaction_contents").unwrap();
2141 assert_eq!(interaction_contents.get::<String>("provider").unwrap(), "test-provider");
2142 }
2143
2144 #[tokio::test]
2145 async fn verify_interaction_v1_round_trip() {
2146 let plugin = start_transport_plugin(1);
2147 let mut metadata = HashMap::new();
2148 metadata.insert("path".to_string(), MetadataValue {
2149 value: Some(metadata_value::Value::NonBinaryValue(prost_types::Value {
2150 kind: Some(prost_types::value::Kind::StringValue("/foo".to_string())),
2151 })),
2152 });
2153 let response = plugin.verify_interaction(VerifyInteractionRequest {
2154 interaction_data: Some(InteractionData {
2155 body: Some(Body {
2156 content_type: "application/json".to_string(),
2157 content: Some("request-body".as_bytes().to_vec()),
2158 content_type_hint: body::ContentTypeHint::Text as i32,
2159 }),
2160 metadata,
2161 }),
2162 config: None,
2163 pact: "{}".to_string(),
2164 interaction_key: "interaction-1".to_string(),
2165 }).await.unwrap();
2166
2167 match response.response.unwrap() {
2168 verify_interaction_response::Response::Result(result) => {
2169 assert!(result.success);
2170 assert_eq!(result.output, vec!["POST /foo".to_string(), "200 OK".to_string()]);
2171 assert_eq!(result.mismatches.len(), 2);
2172 match &result.mismatches[0].result {
2173 Some(verification_result_item::Result::Error(err)) => assert_eq!(err, "a plain mismatch"),
2174 other => panic!("expected an error mismatch, got {:?}", other),
2175 }
2176 match &result.mismatches[1].result {
2177 Some(verification_result_item::Result::Mismatch(mismatch)) => {
2178 assert_eq!(mismatch.mismatch, "a table mismatch");
2179 assert_eq!(mismatch.expected, Some(b"1".to_vec()));
2180 }
2181 other => panic!("expected a mismatch, got {:?}", other),
2182 }
2183 }
2184 other => panic!("expected a verification result, got {:?}", other),
2185 }
2186
2187 let lua = plugin.runtime.lock().await;
2188 let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
2189 let interaction_data: Table = captured.get("interaction_data").unwrap();
2190 let metadata: Table = interaction_data.get("metadata").unwrap();
2191 assert_eq!(metadata.get::<String>("path").unwrap(), "/foo");
2192 }
2193
2194 #[tokio::test]
2195 async fn verify_interaction_v1_returns_the_lua_error() {
2196 let plugin = start_transport_plugin(1);
2197 let mut config = HashMap::new();
2198 config.insert("fail".to_string(), serde_json::Value::Bool(true));
2199 let response = plugin.verify_interaction(VerifyInteractionRequest {
2200 interaction_data: None,
2201 config: Some(to_proto_struct(&config)),
2202 pact: "{}".to_string(),
2203 interaction_key: "interaction-1".to_string(),
2204 }).await.unwrap();
2205 match response.response.unwrap() {
2206 verify_interaction_response::Response::Error(err) => assert_eq!(err, "verification failed"),
2207 other => panic!("expected an error response, got {:?}", other),
2208 }
2209 }
2210
2211 #[tokio::test]
2212 async fn verify_interaction_v2_converts_the_v2_interaction_data_and_contents() {
2213 let plugin = start_transport_plugin(2);
2214 let request = proto_v2::VerifyInteractionRequest {
2215 interaction_data: Some(proto_v2::InteractionData {
2216 body: Some(proto_v2::Body {
2217 content_type: "application/json".to_string(),
2218 content: Some("request-body".as_bytes().to_vec()),
2219 content_type_hint: 0,
2220 }),
2221 metadata: HashMap::new(),
2222 }),
2223 config: None,
2224 interaction_contents: Some(proto_v2::InteractionContents {
2225 interaction_type: "Synchronous/HTTP".to_string(),
2226 plugin_configuration: None,
2227 consumer: "test-consumer".to_string(),
2228 provider: "test-provider".to_string(),
2229 }),
2230 test_context: None,
2231 };
2232 let response = plugin.verify_interaction_v2(request).await.unwrap();
2233 assert!(matches!(
2234 response.response.unwrap(),
2235 verify_interaction_response::Response::Result(_)
2236 ));
2237
2238 let lua = plugin.runtime.lock().await;
2239 let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
2240 let interaction_data: Table = captured.get("interaction_data").unwrap();
2241 let body: Table = interaction_data.get("body").unwrap();
2242 assert_eq!(body.get::<mlua::String>("contents").unwrap().to_str().unwrap(), "request-body");
2243 let interaction_contents: Table = captured.get("interaction_contents").unwrap();
2244 assert_eq!(interaction_contents.get::<String>("consumer").unwrap(), "test-consumer");
2245 }
2246
2247 #[tokio::test]
2248 async fn shutdown_mock_server_defaults_ok_to_true_when_the_field_is_absent() {
2249 let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2257 std::fs::write(
2258 plugin_dir.path().join("entry.lua"),
2259 r#"
2260 function shutdown_mock_server(server_key)
2261 return { results = {} }
2262 end
2263 "#,
2264 ).unwrap();
2265 let manifest = transport_manifest(plugin_dir.path(), 1);
2266 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2267
2268 let response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2269 server_key: "mock-server-1".to_string(),
2270 }).await.unwrap();
2271 assert!(response.ok, "expected 'ok' to default to true when the Lua script doesn't set it");
2272 }
2273
2274 #[tokio::test]
2275 async fn shutdown_mock_server_errors_on_a_wrong_typed_path_field() {
2276 let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2277 std::fs::write(
2278 plugin_dir.path().join("entry.lua"),
2279 r#"
2280 function shutdown_mock_server(server_key)
2281 return { ok = false, results = { { path = {}, error = "boom", mismatches = {} } } }
2282 end
2283 "#,
2284 ).unwrap();
2285 let manifest = transport_manifest(plugin_dir.path(), 1);
2286 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2287
2288 let result = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2289 server_key: "mock-server-1".to_string(),
2290 }).await;
2291 assert!(
2292 result.is_err(),
2293 "expected a wrong-typed 'path' field (a table, not a string) to be a hard error, not silently default, got {:?}",
2294 result
2295 );
2296 }
2297}