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, warn};
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::LuaString, 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 globals.set(
372 "host_match_field",
373 lua.create_async_function(move |lua, (entry_key, request): (String, Table)| async move {
374 let request = lua_to_match_field_request(&lua, request).map_err(mlua::Error::external)?;
375 let response = call_host_match_field(&entry_key, request).await.map_err(mlua::Error::external)?;
376 match_field_response_to_lua(&lua, &response)
377 })?,
378 )?;
379
380 globals.set(
381 "host_generate_field",
382 lua.create_async_function(move |lua, (entry_key, request): (String, Table)| async move {
383 let request = lua_to_generate_field_request(&lua, request).map_err(mlua::Error::external)?;
384 let response = call_host_generate_field(&entry_key, request).await.map_err(mlua::Error::external)?;
385 generate_field_response_to_lua(&lua, &response)
386 })?,
387 )?;
388
389 Ok(())
390}
391
392async fn call_host_compare_contents(
398 entry_key: &str,
399 request: CompareContentsRequest
400) -> anyhow::Result<CompareContentsResponse> {
401 match resolve_capability(entry_key, CatalogueEntryType::CONTENT_MATCHER)? {
402 ResolvedCapability::Core(core_key) => {
403 let handler = crate::core_capabilities::lookup_core_content_matcher(&core_key)
404 .ok_or_else(|| anyhow!("No core content matcher registered for '{}'", core_key))?;
405 handler.compare_contents(request).await
406 }
407 ResolvedCapability::Plugin(manifest) => {
408 let plugin = lookup_plugin(&manifest.as_dependency())
409 .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
410 let chain_id = call_chain::new_call_chain_id();
411 let deadline_ms = call_chain::default_deadline_ms();
412 plugin.compare_contents_with_chain(request, &chain_id, deadline_ms).await
413 }
414 }
415}
416
417async fn call_host_generate_content(
420 entry_key: &str,
421 request: GenerateContentRequest
422) -> anyhow::Result<GenerateContentResponse> {
423 match resolve_capability(entry_key, CatalogueEntryType::CONTENT_GENERATOR)? {
424 ResolvedCapability::Core(core_key) => {
425 let handler = crate::core_capabilities::lookup_core_content_generator(&core_key)
426 .ok_or_else(|| anyhow!("No core content generator registered for '{}'", core_key))?;
427 handler.generate_content(request).await
428 }
429 ResolvedCapability::Plugin(manifest) => {
430 let plugin = lookup_plugin(&manifest.as_dependency())
431 .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
432 let chain_id = call_chain::new_call_chain_id();
433 let deadline_ms = call_chain::default_deadline_ms();
434 plugin.generate_content_with_chain(request, &chain_id, deadline_ms).await
435 }
436 }
437}
438
439async fn call_host_match_field(
442 entry_key: &str,
443 request: proto_v2::MatchFieldRequest
444) -> anyhow::Result<proto_v2::MatchFieldResponse> {
445 match resolve_capability(entry_key, CatalogueEntryType::MATCHER)? {
446 ResolvedCapability::Core(core_key) => {
447 let handler = crate::core_capabilities::lookup_core_field_matcher(&core_key)
448 .ok_or_else(|| anyhow!("No core field matcher registered for '{}'", core_key))?;
449 handler.match_field(request).await
450 }
451 ResolvedCapability::Plugin(manifest) => {
452 let plugin = lookup_plugin(&manifest.as_dependency())
453 .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
454 let chain_id = call_chain::new_call_chain_id();
455 let deadline_ms = call_chain::default_deadline_ms();
456 plugin.match_field_with_chain(request, &chain_id, deadline_ms).await
457 }
458 }
459}
460
461async fn call_host_generate_field(
464 entry_key: &str,
465 request: proto_v2::GenerateFieldRequest
466) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
467 match resolve_capability(entry_key, CatalogueEntryType::GENERATOR)? {
468 ResolvedCapability::Core(core_key) => {
469 let handler = crate::core_capabilities::lookup_core_field_generator(&core_key)
470 .ok_or_else(|| anyhow!("No core field generator registered for '{}'", core_key))?;
471 handler.generate_field(request).await
472 }
473 ResolvedCapability::Plugin(manifest) => {
474 let plugin = lookup_plugin(&manifest.as_dependency())
475 .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
476 let chain_id = call_chain::new_call_chain_id();
477 let deadline_ms = call_chain::default_deadline_ms();
478 plugin.generate_field_with_chain(request, &chain_id, deadline_ms).await
479 }
480 }
481}
482
483fn decode_base64_lenient(data: &str) -> anyhow::Result<Vec<u8>> {
485 use base64::Engine;
486 base64::engine::general_purpose::URL_SAFE
487 .decode(data)
488 .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(data))
489 .map_err(|err| anyhow!("Failed to base64 decode value - {}", err))
490}
491
492fn call_init(
493 lua: &Lua,
494 implementation: &str,
495 version: &str,
496) -> anyhow::Result<Vec<CatalogueEntry>> {
497 let init_fn: Function = lua
498 .globals()
499 .get("init")
500 .map_err(|_| anyhow!("Lua plugin does not define a global 'init' function"))?;
501 let result: Table = init_fn
502 .call((implementation.to_string(), version.to_string()))
503 .map_err(|err| anyhow!("Lua init() function failed - {}", err))?;
504 lua_table_to_catalogue_entries(result)
505}
506
507fn lua_table_to_catalogue_entries(table: Table) -> anyhow::Result<Vec<CatalogueEntry>> {
508 let mut entries = vec![];
509 for entry in table.sequence_values::<Table>() {
510 let entry = entry?;
511 let entry_type_str: String = entry.get("entryType")?;
512 let key: String = entry.get("key")?;
513 let values: Option<HashMap<String, String>> = entry.get("values")?;
514 let entry_type = CatalogueEntryType::from_proto_name(&entry_type_str)
515 .ok_or_else(|| anyhow!("Unknown catalogue entry type '{}'", entry_type_str))?;
516 entries.push(CatalogueEntry {
517 r#type: entry_type.to_proto_value(),
518 key,
519 values: values.unwrap_or_default(),
520 });
521 }
522 Ok(entries)
523}
524
525fn content_type_hint_to_str(hint: i32) -> &'static str {
528 match body::ContentTypeHint::try_from(hint).unwrap_or(body::ContentTypeHint::Default) {
529 body::ContentTypeHint::Default => "DEFAULT",
530 body::ContentTypeHint::Text => "TEXT",
531 body::ContentTypeHint::Binary => "BINARY",
532 }
533}
534
535fn str_to_content_type_hint(hint: &str) -> i32 {
536 match hint {
537 "TEXT" => body::ContentTypeHint::Text as i32,
538 "BINARY" => body::ContentTypeHint::Binary as i32,
539 _ => body::ContentTypeHint::Default as i32,
540 }
541}
542
543fn body_to_lua(lua: &Lua, body: &Option<Body>) -> mlua::Result<Value> {
544 match body {
545 None => Ok(Value::Nil),
546 Some(body) => {
547 let table = lua.create_table()?;
548 table.set("content_type", body.content_type.clone())?;
549 match &body.content {
550 Some(bytes) => table.set("contents", lua.create_string(bytes)?)?,
551 None => table.set("contents", Value::Nil)?,
552 }
553 table.set("content_type_hint", content_type_hint_to_str(body.content_type_hint))?;
554 Ok(Value::Table(table))
555 }
556 }
557}
558
559fn lua_to_body(value: Value) -> anyhow::Result<Option<Body>> {
560 match value {
561 Value::Nil => Ok(None),
562 Value::Table(table) => {
563 let content_type: String = table.get("content_type")?;
564 let contents: Option<mlua::LuaString> = table.get("contents")?;
565 let content_type_hint: Option<String> = table.get("content_type_hint")?;
566 Ok(Some(Body {
567 content_type,
568 content: contents.map(|s| s.as_bytes().to_vec()),
569 content_type_hint: content_type_hint
570 .map(|h| str_to_content_type_hint(&h))
571 .unwrap_or(body::ContentTypeHint::Default as i32),
572 }))
573 }
574 _ => Err(anyhow!("Expected a body table or nil from Lua, got {}", value.type_name())),
575 }
576}
577
578fn matching_rules_to_lua(lua: &Lua, rules: &HashMap<String, MatchingRules>) -> mlua::Result<Table> {
581 let table = lua.create_table()?;
582 for (path, rule_list) in rules {
583 let rules_table = lua.create_table()?;
584 for rule in &rule_list.rule {
585 let rule_table = lua.create_table()?;
586 rule_table.set("type", rule.r#type.clone())?;
587 if let Some(values) = &rule.values {
588 rule_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
589 }
590 rules_table.push(rule_table)?;
591 }
592 table.set(path.clone(), rules_table)?;
593 }
594 Ok(table)
595}
596
597fn lua_to_matching_rules(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MatchingRules>> {
600 let mut result = HashMap::new();
601 if let Some(table) = table {
602 for pair in table.pairs::<String, Table>() {
603 let (path, rules_table) = pair?;
604 let mut rule = vec![];
605 for rule_value in rules_table.sequence_values::<Table>() {
606 let rule_table = rule_value?;
607 let r#type: String = rule_table.get("type")?;
608 let values: Option<Value> = rule_table.get("values")?;
609 let values = match values {
610 Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
611 None => None,
612 };
613 rule.push(MatchingRule { r#type, values });
614 }
615 result.insert(path, MatchingRules { rule });
616 }
617 }
618 Ok(result)
619}
620
621fn plugin_configuration_to_lua(lua: &Lua, config: &Option<PluginConfiguration>) -> mlua::Result<Value> {
622 match config {
623 None => Ok(Value::Nil),
624 Some(config) => {
625 let table = lua.create_table()?;
626 if let Some(interaction_configuration) = &config.interaction_configuration {
627 table.set(
628 "interaction_configuration",
629 lua.to_value(&proto_struct_to_json(interaction_configuration))?,
630 )?;
631 }
632 if let Some(pact_configuration) = &config.pact_configuration {
633 table.set(
634 "pact_configuration",
635 lua.to_value(&proto_struct_to_json(pact_configuration))?,
636 )?;
637 }
638 Ok(Value::Table(table))
639 }
640 }
641}
642
643fn lua_to_plugin_configuration(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<PluginConfiguration>> {
644 match value {
645 None | Some(Value::Nil) => Ok(None),
646 Some(Value::Table(table)) => {
647 let interaction_configuration: Option<serde_json::Value> =
648 table.get::<Option<Value>>("interaction_configuration")?
649 .map(|v| lua.from_value(v))
650 .transpose()?;
651 let pact_configuration: Option<serde_json::Value> =
652 table.get::<Option<Value>>("pact_configuration")?
653 .map(|v| lua.from_value(v))
654 .transpose()?;
655 Ok(Some(PluginConfiguration {
656 interaction_configuration: interaction_configuration.map(|v| to_proto_struct(&as_json_map(v))),
657 pact_configuration: pact_configuration.map(|v| to_proto_struct(&as_json_map(v))),
658 }))
659 }
660 Some(other) => Err(anyhow!("Expected a plugin_config table or nil from Lua, got {}", other.type_name())),
661 }
662}
663
664fn as_json_map(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
665 match value {
666 serde_json::Value::Object(map) => map.into_iter().collect(),
667 _ => HashMap::new(),
668 }
669}
670
671fn compare_request_to_lua(lua: &Lua, request: &CompareContentsRequest) -> mlua::Result<Table> {
674 let table = lua.create_table()?;
675 table.set("expected", body_to_lua(lua, &request.expected)?)?;
676 table.set("actual", body_to_lua(lua, &request.actual)?)?;
677 table.set("allow_unexpected_keys", request.allow_unexpected_keys)?;
678 table.set("rules", matching_rules_to_lua(lua, &request.rules)?)?;
679 table.set(
680 "plugin_configuration",
681 plugin_configuration_to_lua(lua, &request.plugin_configuration)?,
682 )?;
683 Ok(table)
684}
685
686fn lua_to_compare_request(lua: &Lua, table: Table) -> anyhow::Result<CompareContentsRequest> {
690 let expected: Value = table.get("expected")?;
691 let actual: Value = table.get("actual")?;
692 let allow_unexpected_keys: Option<bool> = table.get("allow_unexpected_keys")?;
693 let rules: Option<Table> = table.get("rules")?;
694 let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
695 Ok(CompareContentsRequest {
696 expected: lua_to_body(expected)?,
697 actual: lua_to_body(actual)?,
698 allow_unexpected_keys: allow_unexpected_keys.unwrap_or(false),
699 rules: lua_to_matching_rules(lua, rules)?,
700 plugin_configuration: lua_to_plugin_configuration(lua, plugin_configuration)?,
701 })
702}
703
704fn lua_to_compare_response(table: Table) -> anyhow::Result<CompareContentsResponse> {
705 let error: Option<String> = table.get("error")?;
706 if let Some(error) = error {
707 return Ok(CompareContentsResponse {
708 error,
709 type_mismatch: None,
710 results: HashMap::new(),
711 });
712 }
713
714 let type_mismatch: Option<Table> = table.get("type-mismatch")?;
715 if let Some(type_mismatch) = type_mismatch {
716 let expected: String = type_mismatch.get("expected")?;
717 let actual: String = type_mismatch.get("actual")?;
718 return Ok(CompareContentsResponse {
719 error: String::new(),
720 type_mismatch: Some(ContentTypeMismatch { expected, actual }),
721 results: HashMap::new(),
722 });
723 }
724
725 let mismatches: Option<Table> = table.get("mismatches")?;
726 let mut results = HashMap::new();
727 if let Some(mismatches) = mismatches {
728 for pair in mismatches.pairs::<String, Value>() {
729 let (path, value) = pair?;
730 let mismatch_list = lua_value_to_content_mismatches(&path, value)?;
731 if !mismatch_list.is_empty() {
732 results.insert(path, ContentMismatches { mismatches: mismatch_list });
733 }
734 }
735 }
736
737 Ok(CompareContentsResponse {
738 error: String::new(),
739 type_mismatch: None,
740 results,
741 })
742}
743
744fn lua_scalar_to_string(value: Value) -> anyhow::Result<Option<String>> {
748 match value {
749 Value::Nil => Ok(None),
750 Value::Boolean(b) => Ok(Some(b.to_string())),
751 Value::Integer(i) => Ok(Some(i.to_string())),
752 Value::Number(n) => Ok(Some(n.to_string())),
753 Value::String(s) => Ok(Some(s.to_str()?.to_string())),
754 other => Err(anyhow!("Expected a scalar mismatch value from Lua, got {}", other.type_name())),
755 }
756}
757
758fn lua_value_to_content_mismatches(path: &str, value: Value) -> anyhow::Result<Vec<ContentMismatch>> {
759 match value {
760 Value::String(s) => Ok(vec![ContentMismatch {
761 expected: None,
762 actual: None,
763 mismatch: s.to_str()?.to_string(),
764 path: path.to_string(),
765 diff: String::new(),
766 mismatch_type: String::new(),
767 }]),
768 Value::Table(table) => {
769 let mismatch_field: Option<String> = table.get("mismatch")?;
771 if let Some(mismatch) = mismatch_field {
772 let expected = lua_scalar_to_string(table.get("expected")?)?;
775 let actual = lua_scalar_to_string(table.get("actual")?)?;
776 let path_override: Option<String> = table.get("path")?;
777 let diff: Option<String> = table.get("diff")?;
778 let mismatch_type: Option<String> = table.get("mismatch_type")?;
779 Ok(vec![ContentMismatch {
780 expected: expected.map(|s| s.into_bytes()),
781 actual: actual.map(|s| s.into_bytes()),
782 mismatch,
783 path: path_override.unwrap_or_else(|| path.to_string()),
784 diff: diff.unwrap_or_default(),
785 mismatch_type: mismatch_type.unwrap_or_default(),
786 }])
787 } else {
788 let mut result = vec![];
789 for entry in table.sequence_values::<Value>() {
790 result.extend(lua_value_to_content_mismatches(path, entry?)?);
791 }
792 Ok(result)
793 }
794 }
795 Value::Nil => Ok(vec![]),
796 other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
797 }
798}
799
800fn content_mismatches_to_lua(lua: &Lua, mismatches: &[ContentMismatch]) -> mlua::Result<Table> {
804 let list = lua.create_table()?;
805 for mismatch in mismatches {
806 let table = lua.create_table()?;
807 table.set("mismatch", mismatch.mismatch.clone())?;
808 if let Some(expected) = &mismatch.expected {
809 table.set("expected", lua.create_string(expected)?)?;
810 }
811 if let Some(actual) = &mismatch.actual {
812 table.set("actual", lua.create_string(actual)?)?;
813 }
814 table.set("path", mismatch.path.clone())?;
815 if !mismatch.diff.is_empty() {
816 table.set("diff", mismatch.diff.clone())?;
817 }
818 if !mismatch.mismatch_type.is_empty() {
819 table.set("mismatch_type", mismatch.mismatch_type.clone())?;
820 }
821 list.push(table)?;
822 }
823 Ok(list)
824}
825
826fn compare_response_to_lua(lua: &Lua, response: &CompareContentsResponse) -> mlua::Result<Table> {
830 let table = lua.create_table()?;
831 if !response.error.is_empty() {
832 table.set("error", response.error.clone())?;
833 return Ok(table);
834 }
835 if let Some(type_mismatch) = &response.type_mismatch {
836 let mismatch_table = lua.create_table()?;
837 mismatch_table.set("expected", type_mismatch.expected.clone())?;
838 mismatch_table.set("actual", type_mismatch.actual.clone())?;
839 table.set("type-mismatch", mismatch_table)?;
840 return Ok(table);
841 }
842 if !response.results.is_empty() {
843 let mismatches_table = lua.create_table()?;
844 for (path, content_mismatches) in &response.results {
845 mismatches_table.set(path.clone(), content_mismatches_to_lua(lua, &content_mismatches.mismatches)?)?;
846 }
847 table.set("mismatches", mismatches_table)?;
848 }
849 Ok(table)
850}
851
852fn lua_to_generate_request(
859 lua: &Lua,
860 contents: Value,
861 generators: Option<Table>,
862 test_mode: Option<String>
863) -> anyhow::Result<GenerateContentRequest> {
864 let mut generator_map = HashMap::new();
865 if let Some(generators) = generators {
866 for pair in generators.pairs::<String, Table>() {
867 let (path, generator_table) = pair?;
868 let r#type: String = generator_table.get("type")?;
869 let values: Option<Value> = generator_table.get("values")?;
870 let values = match values {
871 Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
872 None => None,
873 };
874 generator_map.insert(path, Generator { r#type, values });
875 }
876 }
877 Ok(GenerateContentRequest {
878 contents: lua_to_body(contents)?,
879 generators: generator_map,
880 test_mode: str_to_test_mode(test_mode.as_deref()),
881 .. GenerateContentRequest::default()
882 })
883}
884
885fn test_mode_to_str(test_mode: i32) -> &'static str {
889 match generate_content_request::TestMode::try_from(test_mode)
890 .unwrap_or(generate_content_request::TestMode::Unknown)
891 {
892 generate_content_request::TestMode::Consumer => "Consumer",
893 generate_content_request::TestMode::Provider => "Provider",
894 generate_content_request::TestMode::Unknown => "Unknown",
895 }
896}
897
898fn str_to_test_mode(test_mode: Option<&str>) -> i32 {
901 match test_mode {
902 Some("Consumer") => generate_content_request::TestMode::Consumer as i32,
903 Some("Provider") => generate_content_request::TestMode::Provider as i32,
904 _ => generate_content_request::TestMode::Unknown as i32,
905 }
906}
907
908fn field_value_to_lua(lua: &Lua, value: &Option<proto_v2::FieldValue>) -> mlua::Result<Value> {
920 use proto_v2::field_value::Value as FieldValue;
921 match value.as_ref().and_then(|value| value.value.as_ref()) {
922 None | Some(FieldValue::NullValue(_)) => Ok(Value::Nil),
923 Some(FieldValue::BooleanValue(value)) => Ok(Value::Boolean(*value)),
924 Some(FieldValue::StringValue(value)) => Ok(Value::String(lua.create_string(value)?)),
925 Some(FieldValue::IntegerValue(value)) => Ok(Value::Integer(*value)),
926 Some(FieldValue::DecimalValue(value)) => Ok(Value::Number(*value)),
927 Some(FieldValue::BinaryValue(bytes)) => {
928 let wrapper = lua.create_table()?;
929 wrapper.set("binary", lua.create_string(bytes)?)?;
930 Ok(Value::Table(wrapper))
931 }
932 Some(FieldValue::StructuredValue(value)) => lua.to_value(&proto_value_to_json(value)),
933 }
934}
935
936fn lua_to_field_value(lua: &Lua, value: Value) -> anyhow::Result<proto_v2::FieldValue> {
939 use proto_v2::field_value::Value as FieldValue;
940 let value = match value {
941 Value::Nil => FieldValue::NullValue(0),
942 Value::Boolean(value) => FieldValue::BooleanValue(value),
943 Value::Integer(value) => FieldValue::IntegerValue(value),
944 Value::Number(value) => FieldValue::DecimalValue(value),
945 Value::String(value) => FieldValue::StringValue(value.to_str()?.to_string()),
946 Value::Table(table) => match table.get::<Option<mlua::LuaString>>("binary")? {
947 Some(binary) => FieldValue::BinaryValue(binary.as_bytes().to_vec()),
948 None => FieldValue::StructuredValue(to_proto_value(&lua.from_value(Value::Table(table))?)),
949 },
950 other => return Err(anyhow!("Expected a field value from Lua, got {}", other.type_name())),
951 };
952 Ok(proto_v2::FieldValue { value: Some(value) })
953}
954
955fn typed_values_to_lua(
960 lua: &Lua,
961 r#type: &str,
962 values: &Option<prost_types::Struct>
963) -> mlua::Result<Table> {
964 let table = lua.create_table()?;
965 table.set("type", r#type.to_string())?;
966 if let Some(values) = values {
967 table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
968 }
969 Ok(table)
970}
971
972fn lua_to_typed_values(
975 lua: &Lua,
976 table: Option<Table>,
977 what: &str
978) -> anyhow::Result<(String, Option<prost_types::Struct>)> {
979 let table = table.ok_or_else(|| anyhow!("Expected a '{}' table from Lua", what))?;
980 let r#type: String = table.get("type")
981 .map_err(|_| anyhow!("Expected the '{}' table from Lua to have a 'type'", what))?;
982 let values: Option<Value> = table.get("values")?;
983 let values = match values {
984 Some(Value::Nil) | None => None,
985 Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
986 };
987 Ok((r#type, values))
988}
989
990fn v2_plugin_configuration_to_v1(
995 config: &Option<proto_v2::PluginConfiguration>
996) -> Option<PluginConfiguration> {
997 config.as_ref().map(|config| PluginConfiguration {
998 interaction_configuration: config.interaction_configuration.clone(),
999 pact_configuration: config.pact_configuration.clone(),
1000 })
1001}
1002
1003fn v1_plugin_configuration_to_v2(
1005 config: Option<PluginConfiguration>
1006) -> Option<proto_v2::PluginConfiguration> {
1007 config.map(|config| proto_v2::PluginConfiguration {
1008 interaction_configuration: config.interaction_configuration,
1009 pact_configuration: config.pact_configuration,
1010 })
1011}
1012
1013fn v1_content_mismatch_to_v2(mismatch: ContentMismatch) -> proto_v2::ContentMismatch {
1016 proto_v2::ContentMismatch {
1017 expected: mismatch.expected,
1018 actual: mismatch.actual,
1019 mismatch: mismatch.mismatch,
1020 path: mismatch.path,
1021 diff: mismatch.diff,
1022 mismatch_type: mismatch.mismatch_type,
1023 }
1024}
1025
1026fn v2_content_mismatch_to_v1(mismatch: &proto_v2::ContentMismatch) -> ContentMismatch {
1028 ContentMismatch {
1029 expected: mismatch.expected.clone(),
1030 actual: mismatch.actual.clone(),
1031 mismatch: mismatch.mismatch.clone(),
1032 path: mismatch.path.clone(),
1033 diff: mismatch.diff.clone(),
1034 mismatch_type: mismatch.mismatch_type.clone(),
1035 }
1036}
1037
1038fn match_field_request_to_lua(lua: &Lua, request: &proto_v2::MatchFieldRequest) -> mlua::Result<Table> {
1040 let table = lua.create_table()?;
1041 table.set("key", request.key.clone())?;
1042 let rule = request.rule.clone().unwrap_or_default();
1043 table.set("rule", typed_values_to_lua(lua, &rule.r#type, &rule.values)?)?;
1044 table.set("path", request.path.clone())?;
1045 table.set("mismatch_type", request.mismatch_type.clone())?;
1046 table.set("expected", field_value_to_lua(lua, &request.expected)?)?;
1047 table.set("actual", field_value_to_lua(lua, &request.actual)?)?;
1048 table.set(
1049 "plugin_configuration",
1050 plugin_configuration_to_lua(lua, &v2_plugin_configuration_to_v1(&request.plugin_configuration))?,
1051 )?;
1052 table.set("test_context", struct_to_lua(lua, &request.test_context)?)?;
1053 Ok(table)
1054}
1055
1056fn lua_to_match_field_request(lua: &Lua, table: Table) -> anyhow::Result<proto_v2::MatchFieldRequest> {
1060 let (r#type, values) = lua_to_typed_values(lua, table.get("rule")?, "rule")?;
1061 let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
1062 let test_context: Option<Value> = table.get("test_context")?;
1063 Ok(proto_v2::MatchFieldRequest {
1064 key: table.get::<Option<String>>("key")?.unwrap_or_default(),
1065 rule: Some(proto_v2::MatchingRule { r#type, values }),
1066 path: table.get::<Option<String>>("path")?.unwrap_or_default(),
1067 mismatch_type: table.get::<Option<String>>("mismatch_type")?.unwrap_or_default(),
1068 expected: Some(lua_to_field_value(lua, table.get("expected")?)?),
1069 actual: Some(lua_to_field_value(lua, table.get("actual")?)?),
1070 plugin_configuration: v1_plugin_configuration_to_v2(
1071 lua_to_plugin_configuration(lua, plugin_configuration)?
1072 ),
1073 test_context: lua_to_struct(lua, test_context)?,
1074 })
1075}
1076
1077fn lua_to_match_field_response(table: Table, path: &str) -> anyhow::Result<proto_v2::MatchFieldResponse> {
1082 let error: Option<String> = table.get("error")?;
1083 if let Some(error) = error {
1084 return Ok(proto_v2::MatchFieldResponse { error, mismatches: vec![] });
1085 }
1086
1087 let mismatches: Option<Value> = table.get("mismatches")?;
1088 let mismatches = match mismatches {
1089 Some(value) => lua_value_to_content_mismatches(path, value)?
1090 .into_iter()
1091 .map(v1_content_mismatch_to_v2)
1092 .collect(),
1093 None => vec![],
1094 };
1095 Ok(proto_v2::MatchFieldResponse { error: String::new(), mismatches })
1096}
1097
1098fn match_field_response_to_lua(lua: &Lua, response: &proto_v2::MatchFieldResponse) -> mlua::Result<Table> {
1101 let table = lua.create_table()?;
1102 if !response.error.is_empty() {
1103 table.set("error", response.error.clone())?;
1104 return Ok(table);
1105 }
1106 let mismatches: Vec<ContentMismatch> = response.mismatches.iter()
1107 .map(v2_content_mismatch_to_v1)
1108 .collect();
1109 table.set("mismatches", content_mismatches_to_lua(lua, &mismatches)?)?;
1110 Ok(table)
1111}
1112
1113fn generate_field_request_to_lua(lua: &Lua, request: &proto_v2::GenerateFieldRequest) -> mlua::Result<Table> {
1115 let table = lua.create_table()?;
1116 table.set("key", request.key.clone())?;
1117 let generator = request.generator.clone().unwrap_or_default();
1118 table.set("generator", typed_values_to_lua(lua, &generator.r#type, &generator.values)?)?;
1119 table.set("path", request.path.clone())?;
1120 table.set("example_value", field_value_to_lua(lua, &request.example_value)?)?;
1121 table.set(
1122 "plugin_configuration",
1123 plugin_configuration_to_lua(lua, &v2_plugin_configuration_to_v1(&request.plugin_configuration))?,
1124 )?;
1125 table.set("test_context", struct_to_lua(lua, &request.test_context)?)?;
1126 table.set("test_mode", test_mode_to_str(request.test_mode))?;
1127 Ok(table)
1128}
1129
1130fn lua_to_generate_field_request(lua: &Lua, table: Table) -> anyhow::Result<proto_v2::GenerateFieldRequest> {
1133 let (r#type, values) = lua_to_typed_values(lua, table.get("generator")?, "generator")?;
1134 let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
1135 let test_context: Option<Value> = table.get("test_context")?;
1136 let test_mode: Option<String> = table.get("test_mode")?;
1137 Ok(proto_v2::GenerateFieldRequest {
1138 key: table.get::<Option<String>>("key")?.unwrap_or_default(),
1139 generator: Some(proto_v2::Generator { r#type, values }),
1140 path: table.get::<Option<String>>("path")?.unwrap_or_default(),
1141 example_value: Some(lua_to_field_value(lua, table.get("example_value")?)?),
1142 plugin_configuration: v1_plugin_configuration_to_v2(
1143 lua_to_plugin_configuration(lua, plugin_configuration)?
1144 ),
1145 test_context: lua_to_struct(lua, test_context)?,
1146 test_mode: str_to_test_mode(test_mode.as_deref()),
1147 })
1148}
1149
1150fn lua_to_generate_field_response(lua: &Lua, table: Table) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
1153 let error: Option<String> = table.get("error")?;
1154 if let Some(error) = error {
1155 return Ok(proto_v2::GenerateFieldResponse { error, value: None });
1156 }
1157 Ok(proto_v2::GenerateFieldResponse {
1158 error: String::new(),
1159 value: Some(lua_to_field_value(lua, table.get("value")?)?),
1160 })
1161}
1162
1163fn generate_field_response_to_lua(lua: &Lua, response: &proto_v2::GenerateFieldResponse) -> mlua::Result<Table> {
1166 let table = lua.create_table()?;
1167 if !response.error.is_empty() {
1168 table.set("error", response.error.clone())?;
1169 } else {
1170 table.set("value", field_value_to_lua(lua, &response.value)?)?;
1171 }
1172 Ok(table)
1173}
1174
1175fn lua_to_struct(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<prost_types::Struct>> {
1179 match value {
1180 None | Some(Value::Nil) => Ok(None),
1181 Some(value) => Ok(Some(to_proto_struct(&as_json_map(lua.from_value(value)?)))),
1182 }
1183}
1184
1185fn lua_to_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<InteractionResponse> {
1198 let contents: Option<Value> = table.get("contents")?;
1199 let body = match contents {
1200 Some(value) => lua_to_body(value)?,
1201 None => None,
1202 };
1203 let rules: Option<Table> = table.get("rules")?;
1204 let plugin_config: Option<Value> = table.get("plugin_config")?;
1205 let part_name: Option<String> = table.get("part_name")?;
1206 Ok(InteractionResponse {
1207 contents: body,
1208 rules: lua_to_matching_rules(lua, rules)?,
1209 generators: HashMap::new(),
1210 message_metadata: None,
1211 plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
1212 interaction_markup: String::new(),
1213 interaction_markup_type: 0,
1214 part_name: part_name.unwrap_or_default(),
1215 metadata_rules: HashMap::new(),
1216 metadata_generators: HashMap::new(),
1217 })
1218}
1219
1220fn lua_to_configure_response(lua: &Lua, table: Table) -> anyhow::Result<ConfigureInteractionResponse> {
1225 let mut interactions = vec![];
1226 let items: Option<Table> = table.get("interactions")?;
1227 if let Some(items) = items {
1228 for entry in items.sequence_values::<Table>() {
1229 interactions.push(lua_to_interaction_response(lua, entry?)?);
1230 }
1231 }
1232
1233 let plugin_config: Option<Value> = table.get("plugin_config")?;
1234 Ok(ConfigureInteractionResponse {
1235 error: String::new(),
1236 interaction: interactions,
1237 plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
1238 })
1239}
1240
1241fn struct_to_lua(lua: &Lua, value: &Option<prost_types::Struct>) -> mlua::Result<Value> {
1245 match value {
1246 Some(value) => lua.to_value(&proto_struct_to_json(value)),
1247 None => Ok(Value::Nil),
1248 }
1249}
1250
1251fn interaction_contents_to_lua(lua: &Lua, contents: &proto_v2::InteractionContents) -> mlua::Result<Table> {
1255 let table = lua.create_table()?;
1256 table.set("interaction_type", contents.interaction_type.clone())?;
1257 table.set("consumer", contents.consumer.clone())?;
1258 table.set("provider", contents.provider.clone())?;
1259 if let Some(plugin_configuration) = &contents.plugin_configuration {
1260 let config_table = lua.create_table()?;
1261 if let Some(interaction_configuration) = &plugin_configuration.interaction_configuration {
1262 config_table.set("interaction_configuration", lua.to_value(&proto_struct_to_json(interaction_configuration))?)?;
1263 }
1264 if let Some(pact_configuration) = &plugin_configuration.pact_configuration {
1265 config_table.set("pact_configuration", lua.to_value(&proto_struct_to_json(pact_configuration))?)?;
1266 }
1267 table.set("plugin_configuration", config_table)?;
1268 }
1269 Ok(table)
1270}
1271
1272fn v2_interaction_data_to_v1(data: &proto_v2::InteractionData) -> anyhow::Result<InteractionData> {
1280 use prost::Message;
1281 InteractionData::decode(data.encode_to_vec().as_slice())
1282 .map_err(|err| anyhow!("Failed to convert V2 InteractionData to V1 - {}", err))
1283}
1284
1285fn metadata_to_lua(lua: &Lua, metadata: &HashMap<String, MetadataValue>) -> mlua::Result<Table> {
1289 let table = lua.create_table()?;
1290 for (key, value) in metadata {
1291 let lua_value = match &value.value {
1292 Some(metadata_value::Value::NonBinaryValue(value)) => lua.to_value(&proto_value_to_json(value))?,
1293 Some(metadata_value::Value::BinaryValue(bytes)) => {
1294 let wrapper = lua.create_table()?;
1295 wrapper.set("binary", lua.create_string(bytes)?)?;
1296 Value::Table(wrapper)
1297 }
1298 None => Value::Nil,
1299 };
1300 table.set(key.clone(), lua_value)?;
1301 }
1302 Ok(table)
1303}
1304
1305fn lua_to_metadata(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MetadataValue>> {
1307 let mut metadata = HashMap::new();
1308 if let Some(table) = table {
1309 for pair in table.pairs::<String, Value>() {
1310 let (key, value) = pair?;
1311 let binary: Option<mlua::LuaString> = match &value {
1312 Value::Table(wrapper) => wrapper.get("binary")?,
1313 _ => None,
1314 };
1315 let metadata_value = if let Some(binary) = binary {
1316 metadata_value::Value::BinaryValue(binary.as_bytes().to_vec())
1317 } else {
1318 let json: serde_json::Value = lua.from_value(value)?;
1319 metadata_value::Value::NonBinaryValue(to_proto_value(&json))
1320 };
1321 metadata.insert(key, MetadataValue { value: Some(metadata_value) });
1322 }
1323 }
1324 Ok(metadata)
1325}
1326
1327fn interaction_data_to_lua(lua: &Lua, data: &Option<InteractionData>) -> mlua::Result<Value> {
1330 match data {
1331 None => Ok(Value::Nil),
1332 Some(data) => {
1333 let table = lua.create_table()?;
1334 table.set("body", body_to_lua(lua, &data.body)?)?;
1335 table.set("metadata", metadata_to_lua(lua, &data.metadata)?)?;
1336 Ok(Value::Table(table))
1337 }
1338 }
1339}
1340
1341fn lua_to_interaction_data(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<InteractionData>> {
1344 match value {
1345 None | Some(Value::Nil) => Ok(None),
1346 Some(Value::Table(table)) => {
1347 let body: Option<Value> = table.get("body")?;
1348 let body = match body {
1349 Some(value) => lua_to_body(value)?,
1350 None => None,
1351 };
1352 let metadata_table: Option<Table> = table.get("metadata")?;
1353 Ok(Some(InteractionData {
1354 body,
1355 metadata: lua_to_metadata(lua, metadata_table)?,
1356 }))
1357 }
1358 Some(other) => Err(anyhow!("Expected an interaction data table or nil from Lua, got {}", other.type_name())),
1359 }
1360}
1361
1362fn lua_to_start_mock_server_response(table: Table) -> anyhow::Result<StartMockServerResponse> {
1365 let error: Option<String> = table.get("error")?;
1366 if let Some(error) = error {
1367 return Ok(StartMockServerResponse {
1368 response: Some(start_mock_server_response::Response::Error(error)),
1369 });
1370 }
1371
1372 let details: Option<Table> = table.get("details")?;
1373 let details = details.ok_or_else(|| {
1374 anyhow!("Lua start_mock_server() must return either an 'error' or 'details' field")
1375 })?;
1376 Ok(StartMockServerResponse {
1377 response: Some(start_mock_server_response::Response::Details(MockServerDetails {
1378 key: details.get("key")?,
1379 port: details.get("port")?,
1380 address: details.get("address")?,
1381 })),
1382 })
1383}
1384
1385fn lua_to_mock_server_results(table: Table) -> anyhow::Result<MockServerResults> {
1390 let ok: bool = table.get::<Option<bool>>("ok")?.unwrap_or(true);
1391 let mut results = vec![];
1392 let results_table: Option<Table> = table.get("results")?;
1393 if let Some(results_table) = results_table {
1394 for entry in results_table.sequence_values::<Table>() {
1395 let entry = entry?;
1396 let path: String = entry.get::<Option<String>>("path")?.unwrap_or_default();
1397 let error: String = entry.get::<Option<String>>("error")?.unwrap_or_default();
1398 let mismatches_value: Value = entry.get("mismatches")?;
1399 results.push(MockServerResult {
1400 path: path.clone(),
1401 error,
1402 mismatches: lua_value_to_content_mismatches(&path, mismatches_value)?,
1403 });
1404 }
1405 }
1406 Ok(MockServerResults { ok, results })
1407}
1408
1409fn lua_to_verification_preparation_response(
1413 lua: &Lua,
1414 table: Table,
1415) -> anyhow::Result<VerificationPreparationResponse> {
1416 let error: Option<String> = table.get("error")?;
1417 if let Some(error) = error {
1418 return Ok(VerificationPreparationResponse {
1419 response: Some(verification_preparation_response::Response::Error(error)),
1420 });
1421 }
1422
1423 let data: Option<Value> = table.get("interaction_data")?;
1424 let data = data.ok_or_else(|| {
1425 anyhow!("Lua prepare_interaction_for_verification() must return either an 'error' or 'interaction_data' field")
1426 })?;
1427 let interaction_data = lua_to_interaction_data(lua, Some(data))?
1428 .unwrap_or_else(|| InteractionData { body: None, metadata: HashMap::new() });
1429 Ok(VerificationPreparationResponse {
1430 response: Some(verification_preparation_response::Response::InteractionData(interaction_data)),
1431 })
1432}
1433
1434fn lua_to_verification_result_item(value: Value) -> anyhow::Result<VerificationResultItem> {
1437 match value {
1438 Value::String(s) => Ok(VerificationResultItem {
1439 result: Some(verification_result_item::Result::Error(s.to_str()?.to_string())),
1440 }),
1441 Value::Table(table) => {
1442 let mismatch: Option<String> = table.get("mismatch")?;
1443 let path: Option<String> = table.get("path")?;
1444 let expected = lua_scalar_to_string(table.get("expected")?)?;
1445 let actual = lua_scalar_to_string(table.get("actual")?)?;
1446 let diff: Option<String> = table.get("diff")?;
1447 let mismatch_type: Option<String> = table.get("mismatch_type")?;
1448 Ok(VerificationResultItem {
1449 result: Some(verification_result_item::Result::Mismatch(ContentMismatch {
1450 expected: expected.map(|s| s.into_bytes()),
1451 actual: actual.map(|s| s.into_bytes()),
1452 mismatch: mismatch.unwrap_or_default(),
1453 path: path.unwrap_or_default(),
1454 diff: diff.unwrap_or_default(),
1455 mismatch_type: mismatch_type.unwrap_or_default(),
1456 })),
1457 })
1458 }
1459 other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
1460 }
1461}
1462
1463fn lua_to_verify_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<VerifyInteractionResponse> {
1468 let error: Option<String> = table.get("error")?;
1469 if let Some(error) = error {
1470 return Ok(VerifyInteractionResponse {
1471 response: Some(verify_interaction_response::Response::Error(error)),
1472 });
1473 }
1474
1475 let result_table: Option<Table> = table.get("result")?;
1476 let result_table = result_table
1477 .ok_or_else(|| anyhow!("Lua verify_interaction() must return either an 'error' or 'result' field"))?;
1478
1479 let success: bool = result_table.get::<Option<bool>>("success")?.unwrap_or(false);
1480 let response_data: Option<Value> = result_table.get("response_data")?;
1481 let response_data = lua_to_interaction_data(lua, response_data)?;
1482
1483 let mut mismatches = vec![];
1484 let mismatches_value: Option<Value> = result_table.get("mismatches")?;
1485 if let Some(Value::Table(mismatches_table)) = mismatches_value {
1486 for entry in mismatches_table.sequence_values::<Value>() {
1487 mismatches.push(lua_to_verification_result_item(entry?)?);
1488 }
1489 }
1490
1491 let output: Option<Vec<String>> = result_table.get("output")?;
1492
1493 Ok(VerifyInteractionResponse {
1494 response: Some(verify_interaction_response::Response::Result(VerificationResult {
1495 success,
1496 response_data,
1497 mismatches,
1498 output: output.unwrap_or_default(),
1499 })),
1500 })
1501}
1502
1503#[async_trait]
1504impl PactPluginRpc for LuaPactPlugin {
1505 async fn init_plugin(&mut self, request: PluginInitRequest) -> anyhow::Result<PluginInitResponse> {
1506 let lua = self.runtime.lock().await;
1507 let catalogue = call_init(&lua, &request.implementation, &request.version)?;
1508 Ok(PluginInitResponse {
1509 catalogue,
1510 plugin_capabilities: vec![],
1511 })
1512 }
1513}
1514
1515#[async_trait]
1516impl PluginInstance for LuaPactPlugin {
1517 fn manifest(&self) -> &PactPluginManifest {
1518 &self.manifest
1519 }
1520
1521 fn instance_id(&self) -> &str {
1522 &self.instance_id
1523 }
1524
1525 fn has_capability(&self, capability: &str) -> bool {
1526 self.plugin_capabilities.iter().any(|c| c == capability)
1527 }
1528
1529 async fn compare_contents(
1530 &self,
1531 request: CompareContentsRequest,
1532 ) -> anyhow::Result<CompareContentsResponse> {
1533 let lua = self.runtime.lock().await;
1534 let match_fn: Function = lua
1535 .globals()
1536 .get("match_contents")
1537 .map_err(|_| anyhow!("Lua plugin does not define a global 'match_contents' function"))?;
1538 let request_table = compare_request_to_lua(&lua, &request)?;
1539 let result: Table = match_fn
1540 .call_async(request_table)
1541 .await
1542 .map_err(|err| anyhow!("Lua match_contents() function failed - {}", err))?;
1543 lua_to_compare_response(result)
1544 }
1545
1546 async fn configure_interaction(
1547 &self,
1548 request: ConfigureInteractionRequest,
1549 ) -> anyhow::Result<ConfigureInteractionResponse> {
1550 let lua = self.runtime.lock().await;
1551 let configure_fn: Function = lua
1552 .globals()
1553 .get("configure_interaction")
1554 .map_err(|_| anyhow!("Lua plugin does not define a global 'configure_interaction' function"))?;
1555 let config: Value = match &request.contents_config {
1556 Some(config) => lua.to_value(&proto_struct_to_json(config))?,
1557 None => Value::Nil,
1558 };
1559 let result: Table = configure_fn
1560 .call((request.content_type.clone(), config))
1561 .map_err(|err| anyhow!("Lua configure_interaction() function failed - {}", err))?;
1562 lua_to_configure_response(&lua, result)
1563 }
1564
1565 async fn generate_content(
1566 &self,
1567 request: GenerateContentRequest,
1568 ) -> anyhow::Result<GenerateContentResponse> {
1569 let lua = self.runtime.lock().await;
1570 let generate_fn: Option<Function> = lua.globals().get("generate_content")?;
1571 match generate_fn {
1572 None => Ok(GenerateContentResponse {
1573 contents: request.contents,
1574 }),
1575 Some(generate_fn) => {
1576 let contents = body_to_lua(&lua, &request.contents)?;
1577 let generators = lua.create_table()?;
1578 for (path, generator) in &request.generators {
1579 let generator_table = lua.create_table()?;
1580 generator_table.set("type", generator.r#type.clone())?;
1581 if let Some(values) = &generator.values {
1582 generator_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
1583 }
1584 generators.set(path.clone(), generator_table)?;
1585 }
1586 let test_mode = test_mode_to_str(request.test_mode);
1587 let result: Value = generate_fn
1588 .call_async((contents, generators, test_mode))
1589 .await
1590 .map_err(|err| anyhow!("Lua generate_content() function failed - {}", err))?;
1591 Ok(GenerateContentResponse {
1592 contents: lua_to_body(result)?,
1593 })
1594 }
1595 }
1596 }
1597
1598 async fn match_field(
1607 &self,
1608 request: proto_v2::MatchFieldRequest,
1609 ) -> anyhow::Result<proto_v2::MatchFieldResponse> {
1610 let lua = self.runtime.lock().await;
1611 let match_fn: Function = lua
1612 .globals()
1613 .get("match_field")
1614 .map_err(|_| anyhow!("Lua plugin does not define a global 'match_field' function"))?;
1615 let path = request.path.clone();
1616 let request_table = match_field_request_to_lua(&lua, &request)?;
1617 let result: Table = match_fn
1618 .call_async(request_table)
1619 .await
1620 .map_err(|err| anyhow!("Lua match_field() function failed - {}", err))?;
1621 lua_to_match_field_response(result, &path)
1622 }
1623
1624 async fn generate_field(
1626 &self,
1627 request: proto_v2::GenerateFieldRequest,
1628 ) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
1629 let lua = self.runtime.lock().await;
1630 let generate_fn: Function = lua
1631 .globals()
1632 .get("generate_field")
1633 .map_err(|_| anyhow!("Lua plugin does not define a global 'generate_field' function"))?;
1634 let request_table = generate_field_request_to_lua(&lua, &request)?;
1635 let result: Table = generate_fn
1636 .call_async(request_table)
1637 .await
1638 .map_err(|err| anyhow!("Lua generate_field() function failed - {}", err))?;
1639 lua_to_generate_field_response(&lua, result)
1640 }
1641
1642 async fn start_mock_server(
1643 &self,
1644 request: StartMockServerRequest,
1645 ) -> anyhow::Result<StartMockServerResponse> {
1646 let lua = self.runtime.lock().await;
1647 let start_fn: Function = lua
1648 .globals()
1649 .get("start_mock_server")
1650 .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1651 let request_table = lua.create_table()?;
1652 request_table.set("host_interface", request.host_interface)?;
1653 request_table.set("port", request.port)?;
1654 request_table.set("tls", request.tls)?;
1655 request_table.set("pact", request.pact)?;
1656 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1657 let result: Table = start_fn
1658 .call(request_table)
1659 .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1660 lua_to_start_mock_server_response(result)
1661 }
1662
1663 async fn start_mock_server_v2(
1664 &self,
1665 request: proto_v2::StartMockServerRequest,
1666 ) -> anyhow::Result<StartMockServerResponse> {
1667 let lua = self.runtime.lock().await;
1668 let start_fn: Function = lua
1669 .globals()
1670 .get("start_mock_server")
1671 .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1672 let request_table = lua.create_table()?;
1673 request_table.set("host_interface", request.host_interface)?;
1674 request_table.set("port", request.port)?;
1675 request_table.set("tls", request.tls)?;
1676 let interactions_table = lua.create_table()?;
1677 for interaction in &request.interactions {
1678 interactions_table.push(interaction_contents_to_lua(&lua, interaction)?)?;
1679 }
1680 request_table.set("interactions", interactions_table)?;
1681 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1682 let result: Table = start_fn
1683 .call(request_table)
1684 .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1685 lua_to_start_mock_server_response(result)
1686 }
1687
1688 async fn shutdown_mock_server(
1689 &self,
1690 request: ShutdownMockServerRequest,
1691 ) -> anyhow::Result<ShutdownMockServerResponse> {
1692 let lua = self.runtime.lock().await;
1693 let shutdown_fn: Function = lua
1694 .globals()
1695 .get("shutdown_mock_server")
1696 .map_err(|_| anyhow!("Lua plugin does not define a global 'shutdown_mock_server' function"))?;
1697 let result: Table = shutdown_fn
1698 .call(request.server_key)
1699 .map_err(|err| anyhow!("Lua shutdown_mock_server() function failed - {}", err))?;
1700 let results = lua_to_mock_server_results(result)?;
1701 Ok(ShutdownMockServerResponse {
1702 ok: results.ok,
1703 results: results.results,
1704 })
1705 }
1706
1707 async fn get_mock_server_results(
1708 &self,
1709 request: MockServerRequest,
1710 ) -> anyhow::Result<MockServerResults> {
1711 let lua = self.runtime.lock().await;
1712 let results_fn: Function = lua
1713 .globals()
1714 .get("get_mock_server_results")
1715 .map_err(|_| anyhow!("Lua plugin does not define a global 'get_mock_server_results' function"))?;
1716 let result: Table = results_fn
1717 .call(request.server_key)
1718 .map_err(|err| anyhow!("Lua get_mock_server_results() function failed - {}", err))?;
1719 lua_to_mock_server_results(result)
1720 }
1721
1722 async fn prepare_interaction_for_verification(
1723 &self,
1724 request: VerificationPreparationRequest,
1725 ) -> anyhow::Result<VerificationPreparationResponse> {
1726 let lua = self.runtime.lock().await;
1727 let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1728 anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1729 })?;
1730 let request_table = lua.create_table()?;
1731 request_table.set("pact", request.pact)?;
1732 request_table.set("interaction_key", request.interaction_key)?;
1733 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1734 let result: Table = prepare_fn
1735 .call(request_table)
1736 .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1737 lua_to_verification_preparation_response(&lua, result)
1738 }
1739
1740 async fn prepare_interaction_for_verification_v2(
1741 &self,
1742 request: proto_v2::VerificationPreparationRequest,
1743 ) -> anyhow::Result<VerificationPreparationResponse> {
1744 let lua = self.runtime.lock().await;
1745 let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1746 anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1747 })?;
1748 let request_table = lua.create_table()?;
1749 if let Some(interaction_contents) = &request.interaction_contents {
1750 request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1751 }
1752 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1753 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1754 let result: Table = prepare_fn
1755 .call(request_table)
1756 .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1757 lua_to_verification_preparation_response(&lua, result)
1758 }
1759
1760 async fn verify_interaction(
1761 &self,
1762 request: VerifyInteractionRequest,
1763 ) -> anyhow::Result<VerifyInteractionResponse> {
1764 let lua = self.runtime.lock().await;
1765 let verify_fn: Function = lua
1766 .globals()
1767 .get("verify_interaction")
1768 .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1769 let request_table = lua.create_table()?;
1770 request_table.set("interaction_data", interaction_data_to_lua(&lua, &request.interaction_data)?)?;
1771 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1772 request_table.set("pact", request.pact)?;
1773 request_table.set("interaction_key", request.interaction_key)?;
1774 let result: Table = verify_fn
1775 .call(request_table)
1776 .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1777 lua_to_verify_interaction_response(&lua, result)
1778 }
1779
1780 async fn verify_interaction_v2(
1781 &self,
1782 request: proto_v2::VerifyInteractionRequest,
1783 ) -> anyhow::Result<VerifyInteractionResponse> {
1784 let lua = self.runtime.lock().await;
1785 let verify_fn: Function = lua
1786 .globals()
1787 .get("verify_interaction")
1788 .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1789 let request_table = lua.create_table()?;
1790 let interaction_data = request.interaction_data.as_ref()
1791 .map(v2_interaction_data_to_v1)
1792 .transpose()?;
1793 request_table.set("interaction_data", interaction_data_to_lua(&lua, &interaction_data)?)?;
1794 request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1795 if let Some(interaction_contents) = &request.interaction_contents {
1796 request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1797 }
1798 request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1799 let result: Table = verify_fn
1800 .call(request_table)
1801 .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1802 lua_to_verify_interaction_response(&lua, result)
1803 }
1804
1805 async fn update_catalogue(&self, request: Catalogue) -> anyhow::Result<()> {
1806 let lua = self.runtime.lock().await;
1807 let update_fn: Option<Function> = lua.globals().get("update_catalogue")?;
1808 if let Some(update_fn) = update_fn {
1809 let table = lua.create_table()?;
1810 for entry in &request.catalogue {
1811 let entry_table = lua.create_table()?;
1812 let Some(entry_type) = CatalogueEntryType::from_proto_value(entry.r#type) else {
1815 warn!("Not passing catalogue entry '{}' to the plugin: {} is not a catalogue entry type this driver understands",
1816 entry.key, entry.r#type);
1817 continue;
1818 };
1819 entry_table.set("entryType", entry_type.as_proto_name())?;
1820 entry_table.set("key", entry.key.clone())?;
1821 entry_table.set("values", entry.values.clone())?;
1822 table.push(entry_table)?;
1823 }
1824 update_fn
1825 .call::<()>(table)
1826 .map_err(|err| anyhow!("Lua update_catalogue() function failed - {}", err))?;
1827 }
1828 Ok(())
1829 }
1830}
1831
1832#[cfg(test)]
1833mod tests {
1834 use std::collections::HashMap;
1835
1836 use maplit::hashmap;
1837 use crate::catalogue_manager::{CatalogueEntry, CatalogueEntryProviderType};
1838 use crate::field::FieldValue;
1839 use crate::utils::proto_struct_to_json;
1840
1841 use super::*;
1842
1843 fn jwt_manifest() -> PactPluginManifest {
1844 let plugin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../plugins/jwt");
1850 assert!(plugin_dir.exists(), "plugins/jwt directory should exist at {:?}", plugin_dir);
1851 PactPluginManifest {
1852 plugin_dir: plugin_dir.to_string_lossy().to_string(),
1853 plugin_interface_version: 1,
1854 name: "jwt".to_string(),
1855 version: "0.0.0".to_string(),
1856 executable_type: "lua".to_string(),
1857 minimum_required_version: None,
1858 entry_point: "plugin.lua".to_string(),
1859 entry_points: HashMap::new(),
1860 args: None,
1861 dependencies: None,
1862 plugin_config: HashMap::new(),
1863 }
1864 }
1865
1866 const PRIVATE_KEY: &str = include_str!("../tests/fixtures/jwt-test-key.pem");
1867
1868 #[test]
1869 fn loads_pure_lua_packages_from_a_configured_luarocks_directory() {
1870 let rocks_root = tempdir::TempDir::new("luarocks-test").unwrap();
1871 let lua_dir = rocks_root.path().join("share").join("lua").join(LUAROCKS_LUA_VERSION);
1872 std::fs::create_dir_all(&lua_dir).unwrap();
1873 std::fs::write(
1874 lua_dir.join("greeter.lua"),
1875 r#"return { hello = function() return "hello from luarocks" end }"#,
1876 ).unwrap();
1877
1878 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1879 std::fs::write(
1880 plugin_dir.path().join("entry.lua"),
1881 r#"
1882 local greeter = require "greeter"
1883 GREETER_RESULT = greeter.hello()
1884 "#,
1885 ).unwrap();
1886
1887 let mut plugin_config = HashMap::new();
1888 plugin_config.insert(
1889 "luaRocksDir".to_string(),
1890 serde_json::Value::String(rocks_root.path().to_string_lossy().to_string()),
1891 );
1892
1893 let manifest = PactPluginManifest {
1894 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1895 plugin_interface_version: 1,
1896 name: "luarocks-test".to_string(),
1897 version: "0.0.0".to_string(),
1898 executable_type: "lua".to_string(),
1899 minimum_required_version: None,
1900 entry_point: "entry.lua".to_string(),
1901 entry_points: HashMap::new(),
1902 args: None,
1903 dependencies: None,
1904 plugin_config,
1905 };
1906
1907 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1908 let lua = plugin.runtime.blocking_lock();
1909 let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1910 assert_eq!(result, "hello from luarocks");
1911 }
1912
1913 #[test]
1914 fn loads_a_vendored_directory_style_module_from_the_plugin_directory() {
1915 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1916 let module_dir = plugin_dir.path().join("greeter");
1917 std::fs::create_dir_all(&module_dir).unwrap();
1918 std::fs::write(
1919 module_dir.join("init.lua"),
1920 r#"return { hello = function() return "hello from a vendored module" end }"#,
1921 ).unwrap();
1922 std::fs::write(
1923 plugin_dir.path().join("entry.lua"),
1924 r#"
1925 local greeter = require "greeter"
1926 GREETER_RESULT = greeter.hello()
1927 "#,
1928 ).unwrap();
1929
1930 let manifest = PactPluginManifest {
1931 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1932 plugin_interface_version: 1,
1933 name: "vendored-module-test".to_string(),
1934 version: "0.0.0".to_string(),
1935 executable_type: "lua".to_string(),
1936 minimum_required_version: None,
1937 entry_point: "entry.lua".to_string(),
1938 entry_points: HashMap::new(),
1939 args: None,
1940 dependencies: None,
1941 plugin_config: HashMap::new(),
1942 };
1943
1944 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1945 let lua = plugin.runtime.blocking_lock();
1946 let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1947 assert_eq!(result, "hello from a vendored module");
1948 }
1949
1950 #[test]
1951 fn loads_a_vendored_module_when_the_entry_point_is_in_a_subdirectory() {
1952 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1956 std::fs::write(
1957 plugin_dir.path().join("greeter.lua"),
1958 r#"return { hello = function() return "hello from the plugin root" end }"#,
1959 ).unwrap();
1960 let src_dir = plugin_dir.path().join("src");
1961 std::fs::create_dir_all(&src_dir).unwrap();
1962 std::fs::write(
1963 src_dir.join("entry.lua"),
1964 r#"
1965 local greeter = require "greeter"
1966 GREETER_RESULT = greeter.hello()
1967 "#,
1968 ).unwrap();
1969
1970 let manifest = PactPluginManifest {
1971 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1972 plugin_interface_version: 1,
1973 name: "nested-entry-point-test".to_string(),
1974 version: "0.0.0".to_string(),
1975 executable_type: "lua".to_string(),
1976 minimum_required_version: None,
1977 entry_point: "src/entry.lua".to_string(),
1978 entry_points: HashMap::new(),
1979 args: None,
1980 dependencies: None,
1981 plugin_config: HashMap::new(),
1982 };
1983
1984 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1985 let lua = plugin.runtime.blocking_lock();
1986 let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1987 assert_eq!(result, "hello from the plugin root");
1988 }
1989
1990 #[test]
1991 fn ignores_a_missing_luarocks_directory_instead_of_failing() {
1992 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1993 std::fs::write(plugin_dir.path().join("entry.lua"), "-- no-op").unwrap();
1994
1995 let mut plugin_config = HashMap::new();
1996 plugin_config.insert(
1997 "luaRocksDir".to_string(),
1998 serde_json::Value::String("/no/such/directory".to_string()),
1999 );
2000
2001 let manifest = PactPluginManifest {
2002 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
2003 plugin_interface_version: 1,
2004 name: "luarocks-test".to_string(),
2005 version: "0.0.0".to_string(),
2006 executable_type: "lua".to_string(),
2007 minimum_required_version: None,
2008 entry_point: "entry.lua".to_string(),
2009 entry_points: HashMap::new(),
2010 args: None,
2011 dependencies: None,
2012 plugin_config,
2013 };
2014
2015 start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2016 }
2017
2018 #[test]
2019 fn captures_print_and_logger_output_into_the_per_instance_log_file() {
2020 let output_dir = tempdir::TempDir::new("lua-plugin-log-test").unwrap();
2021 unsafe { std::env::set_var("PACT_OUTPUT_DIR", output_dir.path()); }
2024
2025 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2026 std::fs::write(
2027 plugin_dir.path().join("entry.lua"),
2028 r#"
2029 print("hello", "world", 42)
2030 logger("a logger message")
2031 "#,
2032 ).unwrap();
2033
2034 let manifest = PactPluginManifest {
2035 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
2036 plugin_interface_version: 1,
2037 name: "log-test".to_string(),
2038 version: "0.0.0".to_string(),
2039 executable_type: "lua".to_string(),
2040 minimum_required_version: None,
2041 entry_point: "entry.lua".to_string(),
2042 entry_points: HashMap::new(),
2043 args: None,
2044 dependencies: None,
2045 plugin_config: HashMap::new(),
2046 };
2047
2048 start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2049 unsafe { std::env::remove_var("PACT_OUTPUT_DIR"); }
2050
2051 let log_path = output_dir.path().join("logs").join("pact-plugin-log-test-test-instance.log");
2052 let contents = std::fs::read_to_string(&log_path)
2053 .unwrap_or_else(|err| panic!("Expected a log file at {:?} - {}", log_path, err));
2054 assert_eq!(contents, "hello\tworld\t42\na logger message\n");
2055 }
2056
2057 #[tokio::test]
2058 async fn loads_the_jwt_plugin_and_runs_the_init_function() {
2059 let manifest = jwt_manifest();
2060 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2061 let lua = plugin.runtime.lock().await;
2062 let entries = call_init(&lua, "test", "0.0.0").unwrap();
2063 assert_eq!(entries.len(), 2);
2064 assert_eq!(entries[0].key, "jwt");
2065 assert_eq!(entries[0].r#type, catalogue_entry::EntryType::ContentMatcher as i32);
2066 assert_eq!(entries[1].r#type, catalogue_entry::EntryType::ContentGenerator as i32);
2067 }
2068
2069 #[tokio::test]
2070 async fn init_accepts_matcher_and_generator_catalogue_entries() {
2071 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2075 std::fs::write(
2076 plugin_dir.path().join("entry.lua"),
2077 r#"
2078 function init(implementation, version)
2079 return {
2080 { entryType = "MATCHER", key = "creditcard", values = { ["config-key"] = "brand" } },
2081 { entryType = "GENERATOR", key = "creditcard" }
2082 }
2083 end
2084 "#,
2085 ).unwrap();
2086
2087 let manifest = lua_manifest(plugin_dir.path(), "field-entries-test");
2088 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2089 let lua = plugin.runtime.lock().await;
2090 let entries = call_init(&lua, "test", "0.0.0").unwrap();
2091
2092 assert_eq!(entries.len(), 2);
2093 assert_eq!(entries[0].key, "creditcard");
2094 assert_eq!(entries[0].r#type, CatalogueEntryType::MATCHER.to_proto_value());
2095 assert_eq!(entries[0].values.get("config-key"), Some(&"brand".to_string()));
2096 assert_eq!(entries[1].r#type, CatalogueEntryType::GENERATOR.to_proto_value());
2097 }
2098
2099 #[tokio::test]
2100 async fn init_rejects_an_unknown_catalogue_entry_type() {
2101 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2102 std::fs::write(
2103 plugin_dir.path().join("entry.lua"),
2104 r#"
2105 function init(implementation, version)
2106 return { { entryType = "NOT_AN_ENTRY_TYPE", key = "nope" } }
2107 end
2108 "#,
2109 ).unwrap();
2110
2111 let manifest = lua_manifest(plugin_dir.path(), "bad-entry-type-test");
2112 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2113 let lua = plugin.runtime.lock().await;
2114
2115 let error = call_init(&lua, "test", "0.0.0").unwrap_err().to_string();
2116 assert!(error.contains("NOT_AN_ENTRY_TYPE"), "unexpected error: {}", error);
2117 }
2118
2119 #[tokio::test]
2120 async fn configure_interaction_then_match_contents_round_trip() {
2121 let manifest = jwt_manifest();
2122 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2123
2124 let mut config_fields = HashMap::new();
2125 config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
2126 config_fields.insert("subject".to_string(), serde_json::Value::String("test-subject".to_string()));
2127 config_fields.insert("issuer".to_string(), serde_json::Value::String("test-issuer".to_string()));
2128 config_fields.insert("audience".to_string(), serde_json::Value::String("test-audience".to_string()));
2129 config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
2130
2131 let configure_request = ConfigureInteractionRequest {
2132 content_type: "application/jwt+json".to_string(),
2133 contents_config: Some(to_proto_struct(&config_fields)),
2134 };
2135 let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
2136 assert_eq!(configure_response.error, "");
2137 assert_eq!(configure_response.interaction.len(), 1);
2138
2139 let interaction = &configure_response.interaction[0];
2140 let body = interaction.contents.clone().expect("expected a body");
2141 assert_eq!(body.content_type, "application/jwt+json");
2142 let token = String::from_utf8(body.content.clone().unwrap()).unwrap();
2143 assert_eq!(token.split('.').count(), 3);
2144
2145 let compare_request = CompareContentsRequest {
2146 expected: Some(body.clone()),
2147 actual: Some(body),
2148 allow_unexpected_keys: false,
2149 rules: HashMap::new(),
2150 plugin_configuration: interaction.plugin_configuration.clone(),
2151 };
2152 let compare_response = plugin.compare_contents(compare_request).await.unwrap();
2153 assert_eq!(compare_response.error, "");
2154 assert!(compare_response.type_mismatch.is_none());
2155 assert!(
2156 compare_response.results.is_empty(),
2157 "expected no mismatches, got {:?}",
2158 compare_response.results
2159 );
2160 }
2161
2162 #[tokio::test]
2163 async fn match_contents_detects_a_tampered_token() {
2164 let manifest = jwt_manifest();
2165 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2166
2167 let mut config_fields = HashMap::new();
2168 config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
2169 config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
2170
2171 let configure_request = ConfigureInteractionRequest {
2172 content_type: "application/jwt+json".to_string(),
2173 contents_config: Some(to_proto_struct(&config_fields)),
2174 };
2175 let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
2176 let interaction = &configure_response.interaction[0];
2177 let expected_body = interaction.contents.clone().unwrap();
2178
2179 let mut actual_body = expected_body.clone();
2180 let mut token = String::from_utf8(actual_body.content.clone().unwrap()).unwrap();
2181 token.push('x'); actual_body.content = Some(token.into_bytes());
2183
2184 let compare_request = CompareContentsRequest {
2185 expected: Some(expected_body),
2186 actual: Some(actual_body),
2187 allow_unexpected_keys: false,
2188 rules: HashMap::new(),
2189 plugin_configuration: interaction.plugin_configuration.clone(),
2190 };
2191 let compare_response = plugin.compare_contents(compare_request).await.unwrap();
2192 assert!(!compare_response.results.is_empty(), "expected a mismatch to be detected");
2193 }
2194
2195 #[derive(Debug)]
2201 struct StubHostRule {
2202 requests: Arc<Mutex<Vec<proto_v2::MatchFieldRequest>>>,
2203 mismatches: Vec<proto_v2::ContentMismatch>
2204 }
2205
2206 #[async_trait]
2207 impl crate::core_capabilities::CoreFieldMatcher for StubHostRule {
2208 async fn match_field(&self, request: proto_v2::MatchFieldRequest) -> anyhow::Result<proto_v2::MatchFieldResponse> {
2209 self.requests.lock().unwrap().push(request);
2210 Ok(proto_v2::MatchFieldResponse {
2211 error: String::default(),
2212 mismatches: self.mismatches.clone()
2213 })
2214 }
2215 }
2216
2217 async fn configure_jwt(plugin: &LuaPactPlugin, claim: serde_json::Value) -> InteractionResponse {
2221 let mut config_fields = HashMap::new();
2222 config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
2223 config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
2224 config_fields.insert("subject".to_string(), serde_json::Value::String("test-subject".to_string()));
2225 config_fields.insert("issuer".to_string(), serde_json::Value::String("test-issuer".to_string()));
2226 config_fields.insert("audience".to_string(), serde_json::Value::String("test-audience".to_string()));
2227 config_fields.insert("customer_id".to_string(), claim);
2228
2229 let response = plugin.configure_interaction(ConfigureInteractionRequest {
2230 content_type: "application/jwt+json".to_string(),
2231 contents_config: Some(to_proto_struct(&config_fields)),
2232 }).await.unwrap();
2233 assert_eq!(response.error, "");
2234 response.interaction[0].clone()
2235 }
2236
2237 fn rule_claim() -> serde_json::Value {
2238 serde_json::json!({
2239 "pact:matcher:type": "regex",
2240 "regex": "CUST-\\d{6}",
2241 "value": "CUST-123456"
2242 })
2243 }
2244
2245 #[tokio::test]
2246 async fn match_contents_delegates_a_claim_rule_to_the_host() {
2247 let key = "regex";
2248 let requests = Arc::new(Mutex::new(vec![]));
2249 crate::core_capabilities::register_core_field_matcher(key, Arc::new(StubHostRule {
2250 requests: requests.clone(),
2251 mismatches: vec![]
2252 }));
2253 crate::catalogue_manager::register_core_entries(&vec![CatalogueEntry {
2254 entry_type: CatalogueEntryType::MATCHER,
2255 provider_type: CatalogueEntryProviderType::CORE,
2256 plugin: None,
2257 key: key.to_string(),
2258 values: hashmap!{}
2259 }]);
2260
2261 let manifest = jwt_manifest();
2262 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2263 let expected = configure_jwt(&plugin, rule_claim()).await;
2266 let actual = configure_jwt(&plugin, serde_json::json!("CUST-999999")).await;
2267
2268 let rule_paths: Vec<&String> = expected.rules.keys().collect();
2270 assert_eq!(rule_paths, vec!["$.claims.customer_id"]);
2271
2272 let response = plugin.compare_contents(CompareContentsRequest {
2273 expected: expected.contents.clone(),
2274 actual: actual.contents.clone(),
2275 allow_unexpected_keys: false,
2276 rules: expected.rules.clone(),
2277 plugin_configuration: expected.plugin_configuration.clone()
2278 }).await.unwrap();
2279
2280 crate::core_capabilities::deregister_core_field_matcher(key);
2281
2282 assert_eq!(response.error, "");
2283 assert!(response.results.is_empty(), "expected the host rule to accept the claim, got {:?}",
2284 response.results);
2285
2286 let requests = requests.lock().unwrap();
2287 assert_eq!(requests.len(), 1, "expected exactly one callback for the one claim with a rule");
2288 let request = &requests[0];
2289 let rule = request.rule.clone().expect("expected the rule to be sent");
2290 assert_eq!(rule.r#type, "regex");
2291 assert_eq!(
2292 proto_struct_to_json(&rule.values.unwrap()).get("regex").and_then(|v| v.as_str()),
2293 Some("CUST-\\d{6}")
2294 );
2295 assert_eq!(request.path, "$.customer_id");
2296 assert_eq!(request.mismatch_type, "body");
2297 assert_eq!(
2298 FieldValue::from_proto(&request.expected.clone().unwrap()),
2299 FieldValue::Json(serde_json::Value::String("CUST-123456".to_string()))
2300 );
2301 assert_eq!(
2302 FieldValue::from_proto(&request.actual.clone().unwrap()),
2303 FieldValue::Json(serde_json::Value::String("CUST-999999".to_string()))
2304 );
2305 }
2306
2307 #[tokio::test]
2311 async fn a_claim_without_a_rule_is_still_compared_for_equality() {
2312 let manifest = jwt_manifest();
2313 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2314 let expected = configure_jwt(&plugin, serde_json::json!("CUST-123456")).await;
2315 let actual = configure_jwt(&plugin, serde_json::json!("CUST-999999")).await;
2316
2317 assert!(expected.rules.is_empty(), "a claim given as a plain value has no rule");
2318
2319 let response = plugin.compare_contents(CompareContentsRequest {
2320 expected: expected.contents.clone(),
2321 actual: actual.contents.clone(),
2322 allow_unexpected_keys: false,
2323 rules: expected.rules.clone(),
2324 plugin_configuration: expected.plugin_configuration.clone()
2325 }).await.unwrap();
2326
2327 assert!(
2328 response.results.contains_key("claims:customer_id"),
2329 "expected the differing claim to mismatch, got {:?}",
2330 response.results
2331 );
2332 }
2333
2334 #[tokio::test]
2335 async fn match_contents_reports_a_mismatch_the_host_rule_found() {
2336 let key = "regex-mismatching";
2337 crate::core_capabilities::register_core_field_matcher(key, Arc::new(StubHostRule {
2338 requests: Arc::new(Mutex::new(vec![])),
2339 mismatches: vec![proto_v2::ContentMismatch {
2340 mismatch: "Expected 'CUST-999999' to match 'CUST-\\d{6}'".to_string(),
2341 .. proto_v2::ContentMismatch::default()
2342 }]
2343 }));
2344 crate::catalogue_manager::register_core_entries(&vec![CatalogueEntry {
2345 entry_type: CatalogueEntryType::MATCHER,
2346 provider_type: CatalogueEntryProviderType::CORE,
2347 plugin: None,
2348 key: key.to_string(),
2349 values: hashmap!{}
2350 }]);
2351
2352 let manifest = jwt_manifest();
2353 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2354 let mut claim = rule_claim();
2355 claim["pact:matcher:type"] = serde_json::Value::String(key.to_string());
2356 let expected = configure_jwt(&plugin, claim).await;
2357 let actual = configure_jwt(&plugin, serde_json::json!("CUST-999999")).await;
2358
2359 let response = plugin.compare_contents(CompareContentsRequest {
2360 expected: expected.contents.clone(),
2361 actual: actual.contents.clone(),
2362 allow_unexpected_keys: false,
2363 rules: expected.rules.clone(),
2364 plugin_configuration: expected.plugin_configuration.clone()
2365 }).await.unwrap();
2366
2367 crate::core_capabilities::deregister_core_field_matcher(key);
2368
2369 assert_eq!(response.error, "");
2370 let mismatches = response.results.get("claims:customer_id")
2371 .unwrap_or_else(|| panic!("expected a mismatch for the claim, got {:?}", response.results));
2372 assert_eq!(mismatches.mismatches.len(), 1);
2373 assert!(
2374 mismatches.mismatches[0].mismatch.contains("to match"),
2375 "expected the host's mismatch description to be reported, got {:?}",
2376 mismatches.mismatches[0].mismatch
2377 );
2378 }
2379
2380 #[tokio::test]
2384 async fn configure_interaction_carries_matching_rules_from_the_plugin() {
2385 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2386 std::fs::write(
2387 plugin_dir.path().join("entry.lua"),
2388 r#"
2389 function configure_interaction(content_type, config)
2390 return {
2391 interactions = {
2392 {
2393 contents = { contents = "a-body", content_type = content_type },
2394 rules = {
2395 ["$.one"] = { { type = "regex", values = { regex = "\\d+" } } },
2396 ["$.two"] = { { type = "type" } }
2397 }
2398 }
2399 }
2400 }
2401 end
2402 "#,
2403 ).unwrap();
2404
2405 let manifest = PactPluginManifest {
2406 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
2407 plugin_interface_version: 1,
2408 name: "configure-rules-test".to_string(),
2409 version: "0.0.0".to_string(),
2410 executable_type: "lua".to_string(),
2411 minimum_required_version: None,
2412 entry_point: "entry.lua".to_string(),
2413 entry_points: HashMap::new(),
2414 args: None,
2415 dependencies: None,
2416 plugin_config: HashMap::new(),
2417 };
2418 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2419
2420 let response = plugin.configure_interaction(ConfigureInteractionRequest {
2421 content_type: "application/x-test".to_string(),
2422 contents_config: None,
2423 }).await.unwrap();
2424
2425 let interaction = &response.interaction[0];
2426 let regex_rule = &interaction.rules.get("$.one").expect("expected a rule at $.one").rule[0];
2427 assert_eq!(regex_rule.r#type, "regex");
2428 assert_eq!(
2429 proto_struct_to_json(regex_rule.values.as_ref().unwrap()).get("regex").and_then(|v| v.as_str()),
2430 Some("\\d+")
2431 );
2432 let type_rule = &interaction.rules.get("$.two").expect("expected a rule at $.two").rule[0];
2433 assert_eq!(type_rule.r#type, "type");
2434 }
2435
2436 #[tokio::test]
2437 async fn compare_contents_handles_non_string_mismatch_values() {
2438 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2439 std::fs::write(
2440 plugin_dir.path().join("entry.lua"),
2441 r#"
2442 function match_contents(request)
2443 return {
2444 mismatches = {
2445 ["claims:exp"] = { expected = 123, actual = 456, mismatch = "exp differs", path = "claims:exp" },
2446 ["claims:verified"] = { expected = true, actual = false, mismatch = "verified differs", path = "claims:verified" }
2447 }
2448 }
2449 end
2450 "#,
2451 ).unwrap();
2452
2453 let manifest = PactPluginManifest {
2454 plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
2455 plugin_interface_version: 1,
2456 name: "scalar-mismatch-test".to_string(),
2457 version: "0.0.0".to_string(),
2458 executable_type: "lua".to_string(),
2459 minimum_required_version: None,
2460 entry_point: "entry.lua".to_string(),
2461 entry_points: HashMap::new(),
2462 args: None,
2463 dependencies: None,
2464 plugin_config: HashMap::new(),
2465 };
2466 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2467
2468 let compare_request = CompareContentsRequest {
2469 expected: None,
2470 actual: None,
2471 allow_unexpected_keys: false,
2472 rules: HashMap::new(),
2473 plugin_configuration: None,
2474 };
2475 let response = plugin.compare_contents(compare_request).await.unwrap();
2476
2477 let exp_mismatch = &response.results["claims:exp"].mismatches[0];
2478 assert_eq!(exp_mismatch.expected.as_deref(), Some("123".as_bytes()));
2479 assert_eq!(exp_mismatch.actual.as_deref(), Some("456".as_bytes()));
2480
2481 let verified_mismatch = &response.results["claims:verified"].mismatches[0];
2482 assert_eq!(verified_mismatch.expected.as_deref(), Some("true".as_bytes()));
2483 assert_eq!(verified_mismatch.actual.as_deref(), Some("false".as_bytes()));
2484 }
2485
2486 fn lua_manifest(plugin_dir: &std::path::Path, name: &str) -> PactPluginManifest {
2487 PactPluginManifest {
2488 plugin_dir: plugin_dir.to_string_lossy().to_string(),
2489 plugin_interface_version: 1,
2490 name: name.to_string(),
2491 version: "0.0.0".to_string(),
2492 executable_type: "lua".to_string(),
2493 minimum_required_version: None,
2494 entry_point: "entry.lua".to_string(),
2495 entry_points: HashMap::new(),
2496 args: None,
2497 dependencies: None,
2498 plugin_config: HashMap::new(),
2499 }
2500 }
2501
2502 fn core_matcher_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
2503 crate::catalogue_manager::CatalogueEntry {
2504 entry_type: crate::catalogue_manager::CatalogueEntryType::CONTENT_MATCHER,
2505 provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
2506 plugin: None,
2507 key: key.to_string(),
2508 values: HashMap::new()
2509 }
2510 }
2511
2512 fn core_generator_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
2513 crate::catalogue_manager::CatalogueEntry {
2514 entry_type: crate::catalogue_manager::CatalogueEntryType::CONTENT_GENERATOR,
2515 provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
2516 plugin: None,
2517 key: key.to_string(),
2518 values: HashMap::new()
2519 }
2520 }
2521
2522 struct FixedErrorCoreMatcher;
2523
2524 #[async_trait]
2525 impl crate::core_capabilities::CoreContentMatcher for FixedErrorCoreMatcher {
2526 async fn compare_contents(&self, _request: CompareContentsRequest) -> anyhow::Result<CompareContentsResponse> {
2527 Ok(CompareContentsResponse {
2528 error: "core matcher says no".to_string(),
2529 type_mismatch: None,
2530 results: HashMap::new(),
2531 })
2532 }
2533 }
2534
2535 #[tokio::test]
2536 async fn match_contents_calls_host_compare_contents_for_a_registered_core_capability() {
2537 let key = "match_contents_calls_host_compare_contents_for_a_registered_core_capability";
2538 crate::catalogue_manager::register_core_entries(&vec![core_matcher_entry(key)]);
2539 crate::core_capabilities::register_core_content_matcher(key, Arc::new(FixedErrorCoreMatcher));
2540
2541 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2542 std::fs::write(
2543 plugin_dir.path().join("entry.lua"),
2544 format!(r#"
2545 function match_contents(request)
2546 return host_compare_contents("{key}", request)
2547 end
2548 "#, key = key),
2549 ).unwrap();
2550 let manifest = lua_manifest(plugin_dir.path(), "host-compare-contents-test");
2551 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2552
2553 let compare_request = CompareContentsRequest {
2554 expected: None,
2555 actual: None,
2556 allow_unexpected_keys: false,
2557 rules: HashMap::new(),
2558 plugin_configuration: None,
2559 };
2560 let response = plugin.compare_contents(compare_request).await.unwrap();
2561
2562 crate::core_capabilities::deregister_core_content_matcher(key);
2563
2564 assert_eq!(response.error, "core matcher says no");
2565 }
2566
2567 #[tokio::test]
2568 async fn match_contents_surfaces_a_clear_error_when_host_compare_contents_targets_an_unregistered_entry() {
2569 let key = "match_contents_surfaces_a_clear_error_when_host_compare_contents_targets_an_unregistered_entry";
2570 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2571 std::fs::write(
2572 plugin_dir.path().join("entry.lua"),
2573 format!(r#"
2574 function match_contents(request)
2575 return host_compare_contents("{key}", request)
2576 end
2577 "#, key = key),
2578 ).unwrap();
2579 let manifest = lua_manifest(plugin_dir.path(), "host-compare-contents-missing-test");
2580 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2581
2582 let compare_request = CompareContentsRequest {
2583 expected: None,
2584 actual: None,
2585 allow_unexpected_keys: false,
2586 rules: HashMap::new(),
2587 plugin_configuration: None,
2588 };
2589 let err = plugin.compare_contents(compare_request).await
2590 .expect_err("expected an error when the target entry is not registered");
2591 assert!(
2592 err.to_string().contains("No catalogue entry found"),
2593 "unexpected error message: {}", err
2594 );
2595 }
2596
2597 struct FixedCoreGenerator;
2598
2599 #[async_trait]
2600 impl crate::core_capabilities::CoreContentGenerator for FixedCoreGenerator {
2601 async fn generate_content(&self, _request: GenerateContentRequest) -> anyhow::Result<GenerateContentResponse> {
2602 Ok(GenerateContentResponse {
2603 contents: Some(Body {
2604 content_type: "text/plain".to_string(),
2605 content: Some(b"generated by the host".to_vec()),
2606 content_type_hint: body::ContentTypeHint::Default as i32,
2607 }),
2608 })
2609 }
2610 }
2611
2612 #[tokio::test]
2613 async fn generate_content_calls_host_generate_content_for_a_registered_core_capability() {
2614 let key = "generate_content_calls_host_generate_content_for_a_registered_core_capability";
2615 crate::catalogue_manager::register_core_entries(&vec![core_generator_entry(key)]);
2616 crate::core_capabilities::register_core_content_generator(key, Arc::new(FixedCoreGenerator));
2617
2618 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2619 std::fs::write(
2620 plugin_dir.path().join("entry.lua"),
2621 format!(r#"
2622 function generate_content(contents, generators, test_mode)
2623 return host_generate_content("{key}", contents, generators, test_mode)
2624 end
2625 "#, key = key),
2626 ).unwrap();
2627 let manifest = lua_manifest(plugin_dir.path(), "host-generate-content-test");
2628 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2629
2630 let request = GenerateContentRequest {
2631 contents: Some(Body {
2632 content_type: "text/plain".to_string(),
2633 content: Some(b"original".to_vec()),
2634 content_type_hint: body::ContentTypeHint::Default as i32,
2635 }),
2636 .. GenerateContentRequest::default()
2637 };
2638 let response = plugin.generate_content(request).await.unwrap();
2639
2640 crate::core_capabilities::deregister_core_content_generator(key);
2641
2642 assert_eq!(response.contents.unwrap().content, Some(b"generated by the host".to_vec()));
2643 }
2644
2645 fn transport_manifest(plugin_dir: &std::path::Path, plugin_interface_version: u8) -> PactPluginManifest {
2646 PactPluginManifest {
2647 plugin_dir: plugin_dir.to_string_lossy().to_string(),
2648 plugin_interface_version,
2649 name: "transport-test".to_string(),
2650 version: "0.0.0".to_string(),
2651 executable_type: "lua".to_string(),
2652 minimum_required_version: None,
2653 entry_point: "entry.lua".to_string(),
2654 entry_points: HashMap::new(),
2655 args: None,
2656 dependencies: None,
2657 plugin_config: HashMap::new(),
2658 }
2659 }
2660
2661 const TRANSPORT_PLUGIN_SCRIPT: &str = r#"
2662 function start_mock_server(request)
2663 START_MOCK_SERVER_REQUEST = request
2664 if request.port == 0 then
2665 return { error = "could not bind a mock server" }
2666 end
2667 return { details = { key = "mock-server-1", port = 12345, address = "127.0.0.1:12345" } }
2668 end
2669
2670 function shutdown_mock_server(server_key)
2671 SHUTDOWN_SERVER_KEY = server_key
2672 return {
2673 ok = false,
2674 results = { { path = "/foo", error = "did not match", mismatches = { "simple string mismatch" } } }
2675 }
2676 end
2677
2678 function get_mock_server_results(server_key)
2679 GET_RESULTS_SERVER_KEY = server_key
2680 return { ok = true, results = {} }
2681 end
2682
2683 function prepare_interaction_for_verification(request)
2684 PREPARE_REQUEST = request
2685 return {
2686 interaction_data = {
2687 body = { content_type = "application/json", contents = "prepared-body", content_type_hint = "TEXT" },
2688 metadata = { path = "/foo", tag = { binary = "raw-bytes" } }
2689 }
2690 }
2691 end
2692
2693 function verify_interaction(request)
2694 VERIFY_REQUEST = request
2695 if request.config ~= nil and request.config.fail == true then
2696 return { error = "verification failed" }
2697 end
2698 return {
2699 result = {
2700 success = true,
2701 response_data = { body = { content_type = "application/json", contents = "response-body" }, metadata = {} },
2702 mismatches = { "a plain mismatch", { mismatch = "a table mismatch", path = "$.foo", expected = 1, actual = 2 } },
2703 output = { "POST /foo", "200 OK" }
2704 }
2705 }
2706 end
2707 "#;
2708
2709 fn start_transport_plugin(plugin_interface_version: u8) -> LuaPactPlugin {
2710 let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2711 std::fs::write(plugin_dir.path().join("entry.lua"), TRANSPORT_PLUGIN_SCRIPT).unwrap();
2712 let manifest = transport_manifest(plugin_dir.path(), plugin_interface_version);
2713 start_lua_plugin(&manifest, "test-instance".to_string()).unwrap()
2716 }
2717
2718 #[tokio::test]
2719 async fn start_mock_server_v1_round_trip() {
2720 let plugin = start_transport_plugin(1);
2721 let response = plugin.start_mock_server(StartMockServerRequest {
2722 host_interface: "127.0.0.1".to_string(),
2723 port: 8080,
2724 tls: false,
2725 pact: "{\"consumer\":{}}".to_string(),
2726 test_context: None,
2727 }).await.unwrap();
2728 match response.response.unwrap() {
2729 start_mock_server_response::Response::Details(details) => {
2730 assert_eq!(details.key, "mock-server-1");
2731 assert_eq!(details.port, 12345);
2732 assert_eq!(details.address, "127.0.0.1:12345");
2733 }
2734 other => panic!("expected mock server details, got {:?}", other),
2735 }
2736 }
2737
2738 #[tokio::test]
2739 async fn start_mock_server_v1_returns_the_lua_error() {
2740 let plugin = start_transport_plugin(1);
2741 let response = plugin.start_mock_server(StartMockServerRequest {
2742 host_interface: "127.0.0.1".to_string(),
2743 port: 0,
2744 tls: false,
2745 pact: "{}".to_string(),
2746 test_context: None,
2747 }).await.unwrap();
2748 match response.response.unwrap() {
2749 start_mock_server_response::Response::Error(err) => assert_eq!(err, "could not bind a mock server"),
2750 other => panic!("expected an error response, got {:?}", other),
2751 }
2752 }
2753
2754 #[tokio::test]
2755 async fn start_mock_server_v2_passes_structured_interactions() {
2756 let plugin = start_transport_plugin(2);
2757 let request = proto_v2::StartMockServerRequest {
2758 host_interface: "127.0.0.1".to_string(),
2759 port: 8080,
2760 tls: false,
2761 interactions: vec![proto_v2::InteractionContents {
2762 interaction_type: "Synchronous/HTTP".to_string(),
2763 plugin_configuration: None,
2764 consumer: "test-consumer".to_string(),
2765 provider: "test-provider".to_string(),
2766 }],
2767 test_context: None,
2768 };
2769 let response = plugin.start_mock_server_v2(request).await.unwrap();
2770 assert!(matches!(response.response.unwrap(), start_mock_server_response::Response::Details(_)));
2771
2772 let lua = plugin.runtime.lock().await;
2773 let captured: Table = lua.globals().get("START_MOCK_SERVER_REQUEST").unwrap();
2774 let interactions: Table = captured.get("interactions").unwrap();
2775 let first: Table = interactions.get(1).unwrap();
2776 assert_eq!(first.get::<String>("interaction_type").unwrap(), "Synchronous/HTTP");
2777 assert_eq!(first.get::<String>("consumer").unwrap(), "test-consumer");
2778 }
2779
2780 #[tokio::test]
2781 async fn shutdown_and_get_mock_server_results_parse_mismatches() {
2782 let plugin = start_transport_plugin(1);
2783
2784 let shutdown_response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2785 server_key: "mock-server-1".to_string(),
2786 }).await.unwrap();
2787 assert!(!shutdown_response.ok);
2788 assert_eq!(shutdown_response.results.len(), 1);
2789 assert_eq!(shutdown_response.results[0].path, "/foo");
2790 assert_eq!(shutdown_response.results[0].mismatches[0].mismatch, "simple string mismatch");
2791
2792 let results_response = plugin.get_mock_server_results(MockServerRequest {
2793 server_key: "mock-server-1".to_string(),
2794 }).await.unwrap();
2795 assert!(results_response.ok);
2796 assert!(results_response.results.is_empty());
2797 }
2798
2799 #[tokio::test]
2800 async fn prepare_interaction_for_verification_v1_round_trip() {
2801 let plugin = start_transport_plugin(1);
2802 let response = plugin.prepare_interaction_for_verification(VerificationPreparationRequest {
2803 pact: "{}".to_string(),
2804 interaction_key: "interaction-1".to_string(),
2805 config: None,
2806 }).await.unwrap();
2807
2808 match response.response.unwrap() {
2809 verification_preparation_response::Response::InteractionData(data) => {
2810 let body = data.body.unwrap();
2811 assert_eq!(body.content, Some("prepared-body".as_bytes().to_vec()));
2812 let metadata = data.metadata;
2813 assert!(matches!(
2814 metadata["path"].value,
2815 Some(metadata_value::Value::NonBinaryValue(_))
2816 ));
2817 match &metadata["tag"].value {
2818 Some(metadata_value::Value::BinaryValue(bytes)) => assert_eq!(bytes, b"raw-bytes"),
2819 other => panic!("expected a binary metadata value, got {:?}", other),
2820 }
2821 }
2822 other => panic!("expected interaction data, got {:?}", other),
2823 }
2824 }
2825
2826 #[tokio::test]
2827 async fn prepare_interaction_for_verification_v2_passes_interaction_contents() {
2828 let plugin = start_transport_plugin(2);
2829 let request = proto_v2::VerificationPreparationRequest {
2830 interaction_contents: Some(proto_v2::InteractionContents {
2831 interaction_type: "Synchronous/HTTP".to_string(),
2832 plugin_configuration: None,
2833 consumer: "test-consumer".to_string(),
2834 provider: "test-provider".to_string(),
2835 }),
2836 config: None,
2837 test_context: None,
2838 };
2839 let response = plugin.prepare_interaction_for_verification_v2(request).await.unwrap();
2840 assert!(matches!(
2841 response.response.unwrap(),
2842 verification_preparation_response::Response::InteractionData(_)
2843 ));
2844
2845 let lua = plugin.runtime.lock().await;
2846 let captured: Table = lua.globals().get("PREPARE_REQUEST").unwrap();
2847 let interaction_contents: Table = captured.get("interaction_contents").unwrap();
2848 assert_eq!(interaction_contents.get::<String>("provider").unwrap(), "test-provider");
2849 }
2850
2851 #[tokio::test]
2852 async fn verify_interaction_v1_round_trip() {
2853 let plugin = start_transport_plugin(1);
2854 let mut metadata = HashMap::new();
2855 metadata.insert("path".to_string(), MetadataValue {
2856 value: Some(metadata_value::Value::NonBinaryValue(prost_types::Value {
2857 kind: Some(prost_types::value::Kind::StringValue("/foo".to_string())),
2858 })),
2859 });
2860 let response = plugin.verify_interaction(VerifyInteractionRequest {
2861 interaction_data: Some(InteractionData {
2862 body: Some(Body {
2863 content_type: "application/json".to_string(),
2864 content: Some("request-body".as_bytes().to_vec()),
2865 content_type_hint: body::ContentTypeHint::Text as i32,
2866 }),
2867 metadata,
2868 }),
2869 config: None,
2870 pact: "{}".to_string(),
2871 interaction_key: "interaction-1".to_string(),
2872 }).await.unwrap();
2873
2874 match response.response.unwrap() {
2875 verify_interaction_response::Response::Result(result) => {
2876 assert!(result.success);
2877 assert_eq!(result.output, vec!["POST /foo".to_string(), "200 OK".to_string()]);
2878 assert_eq!(result.mismatches.len(), 2);
2879 match &result.mismatches[0].result {
2880 Some(verification_result_item::Result::Error(err)) => assert_eq!(err, "a plain mismatch"),
2881 other => panic!("expected an error mismatch, got {:?}", other),
2882 }
2883 match &result.mismatches[1].result {
2884 Some(verification_result_item::Result::Mismatch(mismatch)) => {
2885 assert_eq!(mismatch.mismatch, "a table mismatch");
2886 assert_eq!(mismatch.expected, Some(b"1".to_vec()));
2887 }
2888 other => panic!("expected a mismatch, got {:?}", other),
2889 }
2890 }
2891 other => panic!("expected a verification result, got {:?}", other),
2892 }
2893
2894 let lua = plugin.runtime.lock().await;
2895 let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
2896 let interaction_data: Table = captured.get("interaction_data").unwrap();
2897 let metadata: Table = interaction_data.get("metadata").unwrap();
2898 assert_eq!(metadata.get::<String>("path").unwrap(), "/foo");
2899 }
2900
2901 #[tokio::test]
2902 async fn verify_interaction_v1_returns_the_lua_error() {
2903 let plugin = start_transport_plugin(1);
2904 let mut config = HashMap::new();
2905 config.insert("fail".to_string(), serde_json::Value::Bool(true));
2906 let response = plugin.verify_interaction(VerifyInteractionRequest {
2907 interaction_data: None,
2908 config: Some(to_proto_struct(&config)),
2909 pact: "{}".to_string(),
2910 interaction_key: "interaction-1".to_string(),
2911 }).await.unwrap();
2912 match response.response.unwrap() {
2913 verify_interaction_response::Response::Error(err) => assert_eq!(err, "verification failed"),
2914 other => panic!("expected an error response, got {:?}", other),
2915 }
2916 }
2917
2918 #[tokio::test]
2919 async fn verify_interaction_v2_converts_the_v2_interaction_data_and_contents() {
2920 let plugin = start_transport_plugin(2);
2921 let request = proto_v2::VerifyInteractionRequest {
2922 interaction_data: Some(proto_v2::InteractionData {
2923 body: Some(proto_v2::Body {
2924 content_type: "application/json".to_string(),
2925 content: Some("request-body".as_bytes().to_vec()),
2926 content_type_hint: 0,
2927 }),
2928 metadata: HashMap::new(),
2929 }),
2930 config: None,
2931 interaction_contents: Some(proto_v2::InteractionContents {
2932 interaction_type: "Synchronous/HTTP".to_string(),
2933 plugin_configuration: None,
2934 consumer: "test-consumer".to_string(),
2935 provider: "test-provider".to_string(),
2936 }),
2937 test_context: None,
2938 };
2939 let response = plugin.verify_interaction_v2(request).await.unwrap();
2940 assert!(matches!(
2941 response.response.unwrap(),
2942 verify_interaction_response::Response::Result(_)
2943 ));
2944
2945 let lua = plugin.runtime.lock().await;
2946 let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
2947 let interaction_data: Table = captured.get("interaction_data").unwrap();
2948 let body: Table = interaction_data.get("body").unwrap();
2949 assert_eq!(body.get::<mlua::LuaString>("contents").unwrap().to_str().unwrap(), "request-body");
2950 let interaction_contents: Table = captured.get("interaction_contents").unwrap();
2951 assert_eq!(interaction_contents.get::<String>("consumer").unwrap(), "test-consumer");
2952 }
2953
2954 #[tokio::test]
2955 async fn shutdown_mock_server_defaults_ok_to_true_when_the_field_is_absent() {
2956 let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2964 std::fs::write(
2965 plugin_dir.path().join("entry.lua"),
2966 r#"
2967 function shutdown_mock_server(server_key)
2968 return { results = {} }
2969 end
2970 "#,
2971 ).unwrap();
2972 let manifest = transport_manifest(plugin_dir.path(), 1);
2973 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2974
2975 let response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2976 server_key: "mock-server-1".to_string(),
2977 }).await.unwrap();
2978 assert!(response.ok, "expected 'ok' to default to true when the Lua script doesn't set it");
2979 }
2980
2981 #[tokio::test]
2982 async fn shutdown_mock_server_errors_on_a_wrong_typed_path_field() {
2983 let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2984 std::fs::write(
2985 plugin_dir.path().join("entry.lua"),
2986 r#"
2987 function shutdown_mock_server(server_key)
2988 return { ok = false, results = { { path = {}, error = "boom", mismatches = {} } } }
2989 end
2990 "#,
2991 ).unwrap();
2992 let manifest = transport_manifest(plugin_dir.path(), 1);
2993 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2994
2995 let result = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2996 server_key: "mock-server-1".to_string(),
2997 }).await;
2998 assert!(
2999 result.is_err(),
3000 "expected a wrong-typed 'path' field (a table, not a string) to be a hard error, not silently default, got {:?}",
3001 result
3002 );
3003 }
3004
3005 fn creditcard_manifest() -> PactPluginManifest {
3008 let plugin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../plugins/creditcard");
3010 assert!(plugin_dir.exists(), "plugins/creditcard directory should exist at {:?}", plugin_dir);
3011 PactPluginManifest {
3012 plugin_dir: plugin_dir.to_string_lossy().to_string(),
3013 plugin_interface_version: 2,
3014 name: "creditcard".to_string(),
3015 version: "0.0.0".to_string(),
3016 executable_type: "lua".to_string(),
3017 minimum_required_version: None,
3018 entry_point: "plugin.lua".to_string(),
3019 entry_points: HashMap::new(),
3020 args: None,
3021 dependencies: None,
3022 plugin_config: HashMap::new(),
3023 }
3024 }
3025
3026 fn text_field(value: &str) -> proto_v2::FieldValue {
3027 proto_v2::FieldValue {
3028 value: Some(proto_v2::field_value::Value::StringValue(value.to_string()))
3029 }
3030 }
3031
3032 fn creditcard_match_request(brand: Option<&str>, expected: &str, actual: &str) -> proto_v2::MatchFieldRequest {
3034 let values = brand.map(|brand| {
3035 let mut fields = HashMap::new();
3036 fields.insert("brand".to_string(), serde_json::Value::String(brand.to_string()));
3037 to_proto_struct(&fields)
3038 });
3039 proto_v2::MatchFieldRequest {
3040 key: "creditcard".to_string(),
3041 rule: Some(proto_v2::MatchingRule { r#type: "creditcard".to_string(), values }),
3042 path: "$.card.number".to_string(),
3043 mismatch_type: "body".to_string(),
3044 expected: Some(text_field(expected)),
3045 actual: Some(text_field(actual)),
3046 plugin_configuration: None,
3047 test_context: None,
3048 }
3049 }
3050
3051 fn creditcard_generate_request(brand: Option<&str>, example: &str) -> proto_v2::GenerateFieldRequest {
3052 let values = brand.map(|brand| {
3053 let mut fields = HashMap::new();
3054 fields.insert("brand".to_string(), serde_json::Value::String(brand.to_string()));
3055 to_proto_struct(&fields)
3056 });
3057 proto_v2::GenerateFieldRequest {
3058 key: "creditcard".to_string(),
3059 generator: Some(proto_v2::Generator { r#type: "creditcard".to_string(), values }),
3060 path: "$.card.number".to_string(),
3061 example_value: Some(text_field(example)),
3062 plugin_configuration: None,
3063 test_context: None,
3064 test_mode: proto_v2::generate_content_request::TestMode::Consumer as i32,
3065 }
3066 }
3067
3068 #[tokio::test]
3069 async fn creditcard_plugin_registers_a_matcher_and_a_generator_under_the_same_key() {
3070 let manifest = creditcard_manifest();
3071 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
3072 let lua = plugin.runtime.lock().await;
3073 let entries = call_init(&lua, "test", "0.0.0").unwrap();
3074
3075 assert_eq!(entries.len(), 2);
3076 assert_eq!(entries[0].key, "creditcard");
3077 assert_eq!(entries[0].r#type, proto_v2::catalogue_entry::EntryType::Matcher as i32);
3078 assert_eq!(entries[1].key, "creditcard");
3079 assert_eq!(entries[1].r#type, proto_v2::catalogue_entry::EntryType::Generator as i32);
3080 assert_eq!(entries[0].values.get("config-key"), Some(&"brand".to_string()));
3082 }
3083
3084 #[tokio::test]
3085 async fn creditcard_plugin_accepts_a_valid_card_number() {
3086 let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3087
3088 let response = plugin
3089 .match_field(creditcard_match_request(Some("visa"), "4111111111111111", "4012888888881881"))
3090 .await
3091 .unwrap();
3092
3093 assert_eq!(response.error, "");
3094 assert!(response.mismatches.is_empty(), "expected no mismatches, got {:?}", response.mismatches);
3095 }
3096
3097 #[tokio::test]
3098 async fn creditcard_plugin_reports_a_number_that_fails_the_luhn_check() {
3099 let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3100
3101 let response = plugin
3102 .match_field(creditcard_match_request(None, "4111111111111111", "4111111111111112"))
3103 .await
3104 .unwrap();
3105
3106 assert_eq!(response.error, "");
3107 assert_eq!(response.mismatches.len(), 1);
3108 let mismatch = &response.mismatches[0];
3109 assert!(
3110 mismatch.mismatch.contains("Luhn check"),
3111 "unexpected mismatch description: {}", mismatch.mismatch
3112 );
3113 assert_eq!(mismatch.path, "$.card.number");
3115 assert_eq!(mismatch.mismatch_type, "body");
3116 assert_eq!(mismatch.expected.as_deref(), Some("4111111111111111".as_bytes()));
3117 assert_eq!(mismatch.actual.as_deref(), Some("4111111111111112".as_bytes()));
3118 }
3119
3120 #[tokio::test]
3121 async fn creditcard_plugin_reports_a_number_from_the_wrong_brand() {
3122 let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3123
3124 let response = plugin
3126 .match_field(creditcard_match_request(Some("mastercard"), "5555555555554444", "4012888888881881"))
3127 .await
3128 .unwrap();
3129
3130 assert_eq!(response.mismatches.len(), 1);
3131 assert!(
3132 response.mismatches[0].mismatch.contains("Mastercard"),
3133 "unexpected mismatch description: {}", response.mismatches[0].mismatch
3134 );
3135 }
3136
3137 #[tokio::test]
3138 async fn creditcard_plugin_reports_a_misconfigured_brand_as_an_error_not_a_mismatch() {
3139 let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3142
3143 let response = plugin
3144 .match_field(creditcard_match_request(Some("amx"), "4111111111111111", "4111111111111111"))
3145 .await
3146 .unwrap();
3147
3148 assert!(
3149 response.error.contains("'amx' is not a credit card brand"),
3150 "unexpected error: {}", response.error
3151 );
3152 assert!(response.mismatches.is_empty());
3153 }
3154
3155 #[tokio::test]
3156 async fn creditcard_plugin_generates_a_number_for_the_configured_brand() {
3157 let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3158
3159 let response = plugin
3160 .generate_field(creditcard_generate_request(Some("amex"), "4111111111111111"))
3161 .await
3162 .unwrap();
3163
3164 assert_eq!(response.error, "");
3165 let generated = match response.value.and_then(|value| value.value) {
3166 Some(proto_v2::field_value::Value::StringValue(value)) => value,
3167 other => panic!("expected a generated string value, got {:?}", other)
3168 };
3169 assert_eq!(generated.len(), 15, "an Amex number has 15 digits, got '{}'", generated);
3170 assert!(generated.starts_with("34") || generated.starts_with("37"), "got '{}'", generated);
3171
3172 let match_response = plugin
3174 .match_field(creditcard_match_request(Some("amex"), "371449635398431", &generated))
3175 .await
3176 .unwrap();
3177 assert!(
3178 match_response.mismatches.is_empty(),
3179 "the plugin should accept its own generated number, got {:?}", match_response.mismatches
3180 );
3181 }
3182
3183 #[tokio::test]
3184 async fn creditcard_plugin_reports_a_generator_error() {
3185 let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3186
3187 let response = plugin
3188 .generate_field(creditcard_generate_request(Some("amx"), "4111111111111111"))
3189 .await
3190 .unwrap();
3191
3192 assert!(response.error.contains("amx"), "unexpected error: {}", response.error);
3193 assert!(response.value.is_none());
3194 }
3195
3196 fn start_field_plugin(name: &str, script: &str) -> (tempdir::TempDir, LuaPactPlugin) {
3198 let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
3199 std::fs::write(plugin_dir.path().join("entry.lua"), script).unwrap();
3200 let manifest = lua_manifest(plugin_dir.path(), name);
3201 let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
3202 (plugin_dir, plugin)
3204 }
3205
3206 #[tokio::test]
3207 async fn each_field_value_type_survives_the_round_trip_through_lua() {
3208 use proto_v2::field_value::Value as FieldValue;
3209
3210 let (_dir, plugin) = start_field_plugin(
3211 "field-value-round-trip-test",
3212 r#"
3213 function generate_field(request)
3214 return { value = request.example_value }
3215 end
3216 "#,
3217 );
3218
3219 let values = vec![
3220 FieldValue::NullValue(0),
3221 FieldValue::BooleanValue(true),
3222 FieldValue::StringValue("4111111111111111".to_string()),
3223 FieldValue::IntegerValue(100),
3224 FieldValue::DecimalValue(100.5),
3225 FieldValue::BinaryValue(vec![0, 159, 146, 150]),
3226 ];
3227
3228 for value in values {
3229 let request = proto_v2::GenerateFieldRequest {
3230 generator: Some(proto_v2::Generator::default()),
3231 example_value: Some(proto_v2::FieldValue { value: Some(value.clone()) }),
3232 .. proto_v2::GenerateFieldRequest::default()
3233 };
3234 let response = plugin.generate_field(request).await.unwrap();
3235 assert_eq!(
3236 response.value.and_then(|response| response.value), Some(value.clone()),
3237 "{:?} did not survive the round trip through Lua", value
3238 );
3239 }
3240 }
3241
3242 #[tokio::test]
3243 async fn a_whole_number_reaches_lua_as_an_integer_and_a_decimal_as_a_float() {
3244 let (_dir, plugin) = start_field_plugin(
3247 "field-value-lua-type-test",
3248 r#"
3249 function generate_field(request)
3250 return { value = math.type(request.example_value) }
3251 end
3252 "#,
3253 );
3254
3255 let lua_type_of = async |value: proto_v2::field_value::Value| {
3256 let request = proto_v2::GenerateFieldRequest {
3257 example_value: Some(proto_v2::FieldValue { value: Some(value) }),
3258 .. proto_v2::GenerateFieldRequest::default()
3259 };
3260 match plugin.generate_field(request).await.unwrap().value.and_then(|value| value.value) {
3261 Some(proto_v2::field_value::Value::StringValue(value)) => value,
3262 other => panic!("expected math.type() to return a string, got {:?}", other)
3263 }
3264 };
3265
3266 assert_eq!(lua_type_of(proto_v2::field_value::Value::IntegerValue(100)).await, "integer");
3267 assert_eq!(lua_type_of(proto_v2::field_value::Value::DecimalValue(100.0)).await, "float");
3268 }
3269
3270 #[tokio::test]
3271 async fn a_mismatch_that_does_not_place_itself_is_reported_against_the_request_path() {
3272 let (_dir, plugin) = start_field_plugin(
3273 "field-mismatch-path-test",
3274 r#"
3275 function match_field(request)
3276 return { mismatches = { "not a card number" } }
3277 end
3278 "#,
3279 );
3280
3281 let response = plugin
3282 .match_field(creditcard_match_request(None, "4111111111111111", "nope"))
3283 .await
3284 .unwrap();
3285
3286 assert_eq!(response.mismatches.len(), 1);
3287 assert_eq!(response.mismatches[0].mismatch, "not a card number");
3288 assert_eq!(response.mismatches[0].path, "$.card.number");
3289 }
3290
3291 #[tokio::test]
3292 async fn a_plugin_that_does_not_define_the_field_functions_says_so() {
3293 let (_dir, plugin) = start_field_plugin("field-functions-missing-test", "-- nothing here");
3294
3295 let match_error = plugin.match_field(proto_v2::MatchFieldRequest::default()).await
3296 .expect_err("expected an error when the plugin does not define match_field");
3297 assert!(
3298 match_error.to_string().contains("does not define a global 'match_field' function"),
3299 "unexpected error: {}", match_error
3300 );
3301
3302 let generate_error = plugin.generate_field(proto_v2::GenerateFieldRequest::default()).await
3303 .expect_err("expected an error when the plugin does not define generate_field");
3304 assert!(
3305 generate_error.to_string().contains("does not define a global 'generate_field' function"),
3306 "unexpected error: {}", generate_error
3307 );
3308 }
3309
3310 fn core_field_matcher_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
3311 crate::catalogue_manager::CatalogueEntry {
3312 entry_type: crate::catalogue_manager::CatalogueEntryType::MATCHER,
3313 provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
3314 plugin: None,
3315 key: key.to_string(),
3316 values: HashMap::new()
3317 }
3318 }
3319
3320 fn core_field_generator_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
3321 crate::catalogue_manager::CatalogueEntry {
3322 entry_type: crate::catalogue_manager::CatalogueEntryType::GENERATOR,
3323 provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
3324 plugin: None,
3325 key: key.to_string(),
3326 values: HashMap::new()
3327 }
3328 }
3329
3330 struct EchoCoreFieldMatcher;
3333
3334 #[async_trait]
3335 impl crate::core_capabilities::CoreFieldMatcher for EchoCoreFieldMatcher {
3336 async fn match_field(&self, request: proto_v2::MatchFieldRequest) -> anyhow::Result<proto_v2::MatchFieldResponse> {
3337 let rule = request.rule.unwrap_or_default();
3338 Ok(proto_v2::MatchFieldResponse {
3339 error: String::new(),
3340 mismatches: vec![proto_v2::ContentMismatch {
3341 expected: None,
3342 actual: None,
3343 mismatch: format!("core matcher saw rule '{}' at {}", rule.r#type, request.path),
3344 path: request.path,
3345 diff: String::new(),
3346 mismatch_type: request.mismatch_type,
3347 }]
3348 })
3349 }
3350 }
3351
3352 #[tokio::test]
3353 async fn match_field_calls_host_match_field_for_a_registered_core_capability() {
3354 let key = "match_field_calls_host_match_field_for_a_registered_core_capability";
3357 crate::catalogue_manager::register_core_entries(&vec![core_field_matcher_entry(key)]);
3358 crate::core_capabilities::register_core_field_matcher(key, Arc::new(EchoCoreFieldMatcher));
3359
3360 let (_dir, plugin) = start_field_plugin(
3361 "host-match-field-test",
3362 &format!(r#"
3363 function match_field(request)
3364 return host_match_field("{key}", request)
3365 end
3366 "#, key = key),
3367 );
3368
3369 let response = plugin
3370 .match_field(creditcard_match_request(Some("visa"), "4111111111111111", "4012888888881881"))
3371 .await
3372 .unwrap();
3373
3374 crate::core_capabilities::deregister_core_field_matcher(key);
3375
3376 assert_eq!(response.mismatches.len(), 1);
3377 assert_eq!(
3378 response.mismatches[0].mismatch,
3379 "core matcher saw rule 'creditcard' at $.card.number"
3380 );
3381 assert_eq!(response.mismatches[0].mismatch_type, "body");
3382 }
3383
3384 #[tokio::test]
3385 async fn host_match_field_surfaces_a_clear_error_when_the_entry_is_not_registered() {
3386 let key = "host_match_field_surfaces_a_clear_error_when_the_entry_is_not_registered";
3387 let (_dir, plugin) = start_field_plugin(
3388 "host-match-field-missing-test",
3389 &format!(r#"
3390 function match_field(request)
3391 return host_match_field("{key}", request)
3392 end
3393 "#, key = key),
3394 );
3395
3396 let err = plugin.match_field(creditcard_match_request(None, "4111111111111111", "4111111111111111"))
3397 .await
3398 .expect_err("expected an error when the target entry is not registered");
3399 assert!(
3400 err.to_string().contains("No catalogue entry found"),
3401 "unexpected error message: {}", err
3402 );
3403 }
3404
3405 struct FixedCoreFieldGenerator;
3406
3407 #[async_trait]
3408 impl crate::core_capabilities::CoreFieldGenerator for FixedCoreFieldGenerator {
3409 async fn generate_field(&self, _request: proto_v2::GenerateFieldRequest) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
3410 Ok(proto_v2::GenerateFieldResponse {
3411 error: String::new(),
3412 value: Some(text_field("generated by the host"))
3413 })
3414 }
3415 }
3416
3417 #[tokio::test]
3418 async fn generate_field_calls_host_generate_field_for_a_registered_core_capability() {
3419 let key = "generate_field_calls_host_generate_field_for_a_registered_core_capability";
3420 crate::catalogue_manager::register_core_entries(&vec![core_field_generator_entry(key)]);
3421 crate::core_capabilities::register_core_field_generator(key, Arc::new(FixedCoreFieldGenerator));
3422
3423 let (_dir, plugin) = start_field_plugin(
3424 "host-generate-field-test",
3425 &format!(r#"
3426 function generate_field(request)
3427 return host_generate_field("{key}", request)
3428 end
3429 "#, key = key),
3430 );
3431
3432 let response = plugin
3433 .generate_field(creditcard_generate_request(Some("visa"), "4111111111111111"))
3434 .await
3435 .unwrap();
3436
3437 crate::core_capabilities::deregister_core_field_generator(key);
3438
3439 assert_eq!(response.error, "");
3440 assert_eq!(
3441 response.value.and_then(|value| value.value),
3442 Some(proto_v2::field_value::Value::StringValue("generated by the host".to_string()))
3443 );
3444 }
3445}