diff --git a/src/agent_stats.rs b/src/agent_stats.rs index 686fe848..db987920 100644 --- a/src/agent_stats.rs +++ b/src/agent_stats.rs @@ -355,6 +355,14 @@ mod tests { ); } + #[test] + fn test_sanitize_for_markdown_strips_shorthand_pipeline_command() { + assert_eq!( + sanitize_for_markdown("##[error]Something bad"), + "[filtered][error]Something bad" + ); + } + #[test] fn test_internal_tools_excluded_from_count() { let entries = vec![ diff --git a/src/compile/common.rs b/src/compile/common.rs index d29943ce..a66518cc 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -4773,4 +4773,116 @@ mod tests { let warnings = validate::warn_potential_secrets("my-mcp", &env, &headers); assert!(warnings.is_empty(), "non-secret env var should not produce warnings"); } + + // ─── standalone setup/teardown/finalize/checkout/repositories generators ─── + + #[test] + fn test_generate_setup_job_empty_returns_empty() { + assert!(generate_setup_job(&[], "MyPool").is_empty()); + } + + #[test] + fn test_generate_setup_job_with_steps() { + let step: serde_yaml::Value = serde_yaml::from_str("bash: echo setup").unwrap(); + let out = generate_setup_job(&[step], "MyPool"); + assert!(out.contains("- job: Setup"), "out: {out}"); + assert!(out.contains("displayName: \"Setup\""), "out: {out}"); + assert!(out.contains("name: MyPool"), "out: {out}"); + assert!(out.contains("- checkout: self"), "out: {out}"); + assert!(out.contains("echo setup"), "out: {out}"); + } + + #[test] + fn test_generate_teardown_job_empty_returns_empty() { + assert!(generate_teardown_job(&[], "MyPool").is_empty()); + } + + #[test] + fn test_generate_teardown_job_with_steps() { + let step: serde_yaml::Value = serde_yaml::from_str("bash: echo td").unwrap(); + let out = generate_teardown_job(&[step], "MyPool"); + assert!(out.contains("- job: Teardown"), "out: {out}"); + assert!(out.contains("dependsOn: Execution"), "out: {out}"); + assert!(out.contains("name: MyPool"), "out: {out}"); + assert!(out.contains("echo td"), "out: {out}"); + } + + #[test] + fn test_generate_agentic_depends_on_empty_steps() { + assert!(generate_agentic_depends_on(&[]).is_empty()); + } + + #[test] + fn test_generate_agentic_depends_on_with_steps() { + let step: serde_yaml::Value = serde_yaml::from_str("bash: x").unwrap(); + assert_eq!(generate_agentic_depends_on(&[step]), "dependsOn: Setup"); + } + + #[test] + fn test_generate_finalize_steps_empty() { + assert!(generate_finalize_steps(&[]).is_empty()); + } + + #[test] + fn test_generate_finalize_steps_with_step() { + let step: serde_yaml::Value = serde_yaml::from_str("bash: echo done").unwrap(); + let out = generate_finalize_steps(&[step]); + assert!(out.contains("echo done"), "out: {out}"); + } + + #[test] + fn test_generate_checkout_steps_empty() { + assert!(generate_checkout_steps(&[]).is_empty()); + } + + #[test] + fn test_generate_checkout_steps_multiple() { + let aliases = vec!["repo-a".to_string(), "repo-b".to_string()]; + let out = generate_checkout_steps(&aliases); + assert!(out.contains("- checkout: repo-a"), "out: {out}"); + assert!(out.contains("- checkout: repo-b"), "out: {out}"); + } + + #[test] + fn test_generate_repositories_empty() { + assert!(generate_repositories(&[]).is_empty()); + } + + #[test] + fn test_generate_repositories_single() { + let repos = vec![Repository { + repository: "my-repo".to_string(), + repo_type: "git".to_string(), + name: "org/my-repo".to_string(), + repo_ref: "refs/heads/main".to_string(), + }]; + let out = generate_repositories(&repos); + assert!(out.contains("- repository: my-repo"), "out: {out}"); + assert!(out.contains("type: git"), "out: {out}"); + assert!(out.contains("name: org/my-repo"), "out: {out}"); + assert!(out.contains("ref: refs/heads/main"), "out: {out}"); + } + + #[test] + fn test_generate_repositories_multiple() { + let repos = vec![ + Repository { + repository: "repo-a".to_string(), + repo_type: "git".to_string(), + name: "org/repo-a".to_string(), + repo_ref: "refs/heads/main".to_string(), + }, + Repository { + repository: "repo-b".to_string(), + repo_type: "git".to_string(), + name: "org/repo-b".to_string(), + repo_ref: "refs/heads/develop".to_string(), + }, + ]; + let out = generate_repositories(&repos); + assert!(out.contains("- repository: repo-a"), "out: {out}"); + assert!(out.contains("- repository: repo-b"), "out: {out}"); + assert!(out.contains("name: org/repo-a"), "out: {out}"); + assert!(out.contains("ref: refs/heads/develop"), "out: {out}"); + } } diff --git a/src/safeoutputs/create_branch.rs b/src/safeoutputs/create_branch.rs index 59e4bf84..aea7756f 100644 --- a/src/safeoutputs/create_branch.rs +++ b/src/safeoutputs/create_branch.rs @@ -460,6 +460,54 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_validation_rejects_branch_starting_with_dash() { + let params = CreateBranchParams { + branch_name: "-bad".to_string(), + source_branch: None, + source_commit: None, + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err(), "branch starting with '-' should be rejected"); + } + + #[test] + fn test_validation_rejects_branch_with_spaces() { + let params = CreateBranchParams { + branch_name: "my branch".to_string(), + source_branch: None, + source_commit: None, + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err(), "branch with spaces should be rejected"); + } + + #[test] + fn test_validation_rejects_branch_over_200_chars() { + let params = CreateBranchParams { + branch_name: "a".repeat(201), + source_branch: None, + source_commit: None, + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err(), "branch >200 chars should be rejected"); + } + + #[test] + fn test_validation_rejects_source_branch_with_traversal() { + let params = CreateBranchParams { + branch_name: "feature/valid".to_string(), + source_branch: Some("../evil".to_string()), + source_commit: None, + repository: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err(), "source_branch with '..' should be rejected"); + } + #[test] fn test_result_serializes_correctly() { let params = CreateBranchParams { diff --git a/src/safeoutputs/create_pr.rs b/src/safeoutputs/create_pr.rs index 6e3e2fdc..d5aa0adb 100644 --- a/src/safeoutputs/create_pr.rs +++ b/src/safeoutputs/create_pr.rs @@ -2367,4 +2367,34 @@ new file mode 100755 assert!(config.auto_complete); } + // ─── truncate_error_body ──────────────────────────────────────────────── + + #[test] + fn test_truncate_error_body_shorter_than_max() { + assert_eq!(truncate_error_body("hello", 100), "hello"); + } + + #[test] + fn test_truncate_error_body_exact_max() { + assert_eq!(truncate_error_body("hello", 5), "hello"); + } + + #[test] + fn test_truncate_error_body_longer_than_max() { + assert_eq!(truncate_error_body("hello world", 5), "hello"); + } + + #[test] + fn test_truncate_error_body_multibyte_boundary() { + // "héllo" — é is 2 bytes; truncation must not split a multi-byte char. + // max_len is char count: 3 chars = "hél". + let s = "héllo"; + let result = truncate_error_body(s, 3); + assert_eq!(result, "hél"); + } + + #[test] + fn test_truncate_error_body_empty_input() { + assert_eq!(truncate_error_body("", 5), ""); + } } diff --git a/src/safeoutputs/mod.rs b/src/safeoutputs/mod.rs index fedd8d9e..31eea77f 100644 --- a/src/safeoutputs/mod.rs +++ b/src/safeoutputs/mod.rs @@ -215,7 +215,7 @@ pub(crate) fn validate_git_ref_name(name: &str, label: &str) -> anyhow::Result<( ensure!(!name.is_empty(), "{label} must not be empty"); ensure!(!name.contains(".."), "{label} must not contain '..'"); - ensure!(!name.contains("@{{"), "{label} must not contain '@{{'"); + ensure!(!name.contains("@{"), "{label} must not contain '@{{'"); ensure!(!name.ends_with('.'), "{label} must not end with '.'"); ensure!(!name.ends_with(".lock"), "{label} must not end with '.lock'"); ensure!( @@ -391,4 +391,64 @@ mod tests { "ALL_KNOWN_SAFE_OUTPUTS should be the union of write + diagnostic + non-MCP lists" ); } + + // ─── validate_git_ref_name ────────────────────────────────────────────── + + #[test] + fn test_validate_git_ref_name_rejects_at_brace() { + assert!(validate_git_ref_name("branch@{0}", "b").is_err()); + } + + #[test] + fn test_validate_git_ref_name_rejects_dotlock_suffix() { + assert!(validate_git_ref_name("my-branch.lock", "b").is_err()); + } + + #[test] + fn test_validate_git_ref_name_rejects_consecutive_slashes() { + assert!(validate_git_ref_name("feat//thing", "b").is_err()); + } + + #[test] + fn test_validate_git_ref_name_rejects_backslash() { + assert!(validate_git_ref_name("feat\\evil", "b").is_err()); + } + + #[test] + fn test_validate_git_ref_name_rejects_special_chars() { + for ch in ['~', '^', ':', '?', '*', '['] { + let name = format!("feat{ch}bad"); + assert!( + validate_git_ref_name(&name, "b").is_err(), + "should reject '{ch}'" + ); + } + } + + #[test] + fn test_validate_git_ref_name_rejects_component_starting_with_dot() { + assert!(validate_git_ref_name("feat/.hidden", "b").is_err()); + } + + #[test] + fn test_validate_git_ref_name_rejects_trailing_dot() { + assert!(validate_git_ref_name("my-branch.", "b").is_err()); + } + + #[test] + fn test_validate_git_ref_name_rejects_double_dot() { + assert!(validate_git_ref_name("foo..bar", "b").is_err()); + } + + #[test] + fn test_validate_git_ref_name_rejects_empty() { + assert!(validate_git_ref_name("", "b").is_err()); + } + + #[test] + fn test_validate_git_ref_name_accepts_valid_refs() { + assert!(validate_git_ref_name("feature/add-login", "b").is_ok()); + assert!(validate_git_ref_name("v1.2.3", "b").is_ok()); + assert!(validate_git_ref_name("release/2026-04-17", "b").is_ok()); + } } diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 98a44f9a..81766b0e 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -3110,6 +3110,34 @@ fn test_standalone_complete_compiled_output_is_valid_yaml() { assert_valid_yaml(&compiled, "complete-agent.md"); } +/// Test that the complete standalone fixture emits Setup/Teardown jobs and +/// that the agentic task waits on Setup. The fixture has `setup:`, +/// `teardown:`, and `post-steps:` sections so all three should appear. +#[test] +fn test_standalone_complete_agent_has_setup_and_teardown_jobs() { + let compiled = compile_fixture("complete-agent.md"); + assert!( + compiled.contains("- job: Setup"), + "Should generate Setup job: {compiled}" + ); + assert!( + compiled.contains("- job: Teardown"), + "Should generate Teardown job" + ); + assert!( + compiled.contains("dependsOn: Setup"), + "Agentic task should depend on Setup job" + ); + assert!( + compiled.contains("echo \"Setup step\"") || compiled.contains("echo 'Setup step'"), + "Should include setup step content" + ); + assert!( + compiled.contains("echo \"Teardown step\"") || compiled.contains("echo 'Teardown step'"), + "Should include teardown step content" + ); +} + /// Test that the pipeline-trigger fixture produces valid YAML #[test] fn test_standalone_pipeline_trigger_compiled_output_is_valid_yaml() {