From c228266aa82e89204039a1fab57acf75a5167be9 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 3 Oct 2026 11:18:30 +0200 Subject: [PATCH 1/3] test: reproduce tag-dispatch publishing bypass Refs #19 Co-authored-by: Codex --- .../WorkflowPublishingConditionTests.cs | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowPublishingConditionTests.cs diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowPublishingConditionTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowPublishingConditionTests.cs new file mode 100644 index 0000000..06edc05 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowPublishingConditionTests.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Packaging.Tests +{ + [TestClass] + public sealed class WorkflowPublishingConditionTests + { + public static IEnumerable PublishingCases() + { + foreach (var eventName in new[] { "push", "pull_request", "workflow_dispatch" }) + foreach (var reference in new[] + { + "refs/tags/1.2.3", "refs/tags/1.2.3-beta.1", "refs/heads/main", + "refs/heads/release/1.2", "refs/heads/hotfix/1.2.3", "refs/heads/develop", + "refs/heads/feature/test", "refs/heads/release-candidate" + }) + foreach (var publish in new[] { false, true }) + foreach (var dryRun in new[] { false, true }) + yield return new object[] { eventName, reference, publish, dryRun }; + } + + [TestMethod] + [DynamicData(nameof(PublishingCases))] + public void PublishingRequiresAnAuthorizedEventAndSuccessfulArtifactHandoff( + string eventName, string reference, bool publish, bool dryRun) + { + var workflow = Workflow.Read(); + var context = Context(eventName, reference, publish, dryRun); + var tagPush = eventName == "push" && reference.StartsWith("refs/tags/", StringComparison.Ordinal); + var manualPublish = eventName == "workflow_dispatch" && publish && !dryRun && + (reference == "refs/heads/main" || reference.StartsWith("refs/heads/release/", StringComparison.Ordinal) || + reference.StartsWith("refs/heads/hotfix/", StringComparison.Ordinal)); + var expectedPublish = tagPush || manualPublish; + var expectedPack = tagPush || manualPublish || (eventName == "workflow_dispatch" && dryRun); + + // Check both the source condition and the source needs graph. A skipped pack can + // conceal an unsafe publish expression, as it did for non-dry-run tag dispatches. + Assert.AreEqual(expectedPublish, workflow.Condition("publish", context), "Raw publish condition."); + var results = workflow.Schedule(context); + Assert.AreEqual(expectedPack ? "success" : "skipped", results["pack-and-validate"]); + Assert.AreEqual(expectedPack ? "success" : "skipped", results["verify-release-artifacts"]); + Assert.AreEqual(expectedPublish ? "success" : "skipped", results["publish"]); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void TagSelectedRehearsalVerifiesArtifactsWithoutCallingNuGetPush(bool publish) + { + var workflow = Workflow.Read(); + var results = workflow.Schedule(Context("workflow_dispatch", "refs/tags/1.2.3", publish, true)); + Assert.AreEqual("success", results["pack-and-validate"]); + Assert.AreEqual("success", results["verify-release-artifacts"]); + var calls = RunMockPublish(workflow, results["publish"] == "success"); + Assert.AreEqual(0, calls.Length, "A tag-selected dry run invoked the workflow's publish command."); + Assert.AreEqual("skipped", results["publish"], "Dry runs must not reach the production approval gate."); + } + + public static IEnumerable UnsuccessfulPrerequisites() + { + foreach (var reference in new[] { "refs/tags/1.2.3", "refs/heads/main", "refs/heads/release/1.2", "refs/heads/hotfix/1.2.3" }) + foreach (var job in new[] { "build-and-test", "roslyn-hosts", "pack-and-validate", "verify-release-artifacts" }) + foreach (var result in new[] { "failure", "cancelled", "skipped" }) + yield return new object[] { reference, job, result }; + } + + [TestMethod] + [DynamicData(nameof(UnsuccessfulPrerequisites))] + public void UnsuccessfulPrerequisitesBlockPublishing(string reference, string job, string result) + { + var workflow = Workflow.Read(); + var eventName = reference.StartsWith("refs/tags/", StringComparison.Ordinal) ? "push" : "workflow_dispatch"; + var context = Context(eventName, reference, true, false); + Assert.AreEqual("success", workflow.Schedule(context)["publish"], "The control must reach publishing."); + var results = workflow.Schedule(context, new Dictionary { [job] = result }); + Assert.AreEqual("skipped", results["publish"], job + "=" + result); + } + + [TestMethod] + [DataRow("push", "refs/tags/1.2.3")] + [DataRow("push", "refs/tags/1.2.3-beta.1")] + [DataRow("workflow_dispatch", "refs/heads/main")] + [DataRow("workflow_dispatch", "refs/heads/release/1.2")] + [DataRow("workflow_dispatch", "refs/heads/hotfix/1.2.3")] + public void AuthorizedPublishingUsesTheExactThreePackagesWithMockedDotnet(string eventName, string reference) + { + var workflow = Workflow.Read(); + var results = workflow.Schedule(Context(eventName, reference, true, false)); + Assert.AreEqual("success", results["publish"]); + var calls = RunMockPublish(workflow, true); + CollectionAssert.AreEqual(new[] + { + "nuget push artifacts/packages/Mammoth.LiteMapper.Abstractions.1.2.3.nupkg --source https://api.nuget.org/v3/index.json --api-key local-mock-key", + "nuget push artifacts/packages/Mammoth.LiteMapper.Generator.1.2.3.nupkg --source https://api.nuget.org/v3/index.json --api-key local-mock-key", + "nuget push artifacts/packages/Mammoth.LiteMapper.1.2.3.nupkg --source https://api.nuget.org/v3/index.json --api-key local-mock-key" + }, calls.Select(call => call.Replace('\\', '/')).ToArray()); + StringAssert.Contains(workflow.Job("publish"), " environment: production"); + } + + [TestMethod] + [DataRow("schedule")] + [DataRow("workflow_run")] + public void OtherEventsCannotAuthorizeTagPublishing(string eventName) + { + var workflow = Workflow.Read(); + var context = Context(eventName, "refs/tags/1.2.3", true, false); + Assert.IsFalse(workflow.Condition("publish", context)); + Assert.AreEqual("skipped", workflow.Schedule(context)["publish"]); + } + + [TestMethod] + public void NonManualEventsHaveNoPublishingInputs() + { + var context = Context("push", "refs/tags/1.2.3", false, true); + context["inputs.publish"] = null; + context["inputs.dry_run"] = null; + Assert.AreEqual("success", Workflow.Read().Schedule(context)["publish"]); + context["github.event_name"] = "pull_request"; + Assert.AreEqual("skipped", Workflow.Read().Schedule(context)["publish"]); + } + + private static Dictionary Context(string eventName, string reference, bool publish, bool dryRun) + { + return new Dictionary + { + ["github.event_name"] = eventName, + ["github.ref"] = reference, + ["github.ref_type"] = reference.StartsWith("refs/tags/", StringComparison.Ordinal) ? "tag" : "branch", + ["inputs.publish"] = publish, + ["inputs.dry_run"] = dryRun + }; + } + + private static string[] RunMockPublish(Workflow workflow, bool scheduled) + { + // Run only the extracted push script, with dotnet shadowed by a local function. + // The genuine dotnet executable and any actual API key are never used here. + var command = @" +$ErrorActionPreference = 'Stop' +$env:NUGET_API_KEY = 'local-mock-key' +function dotnet { 'MOCK:' + ($args -join ' ') } +"; + if (scheduled) + command += workflow.PublishScript + .Replace("${{ needs.pack-and-validate.outputs.semver }}", "1.2.3", StringComparison.Ordinal) + .Replace("${{ env.PACKAGE_OUTPUT }}", "artifacts/packages", StringComparison.Ordinal); + Assert.IsFalse(command.Contains("${{", StringComparison.Ordinal), "Unresolved workflow interpolation."); + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(command)); + var result = TestProcess.Run("pwsh", "-NoProfile -EncodedCommand " + encoded, Repository.Root, TimeSpan.FromMinutes(1)); + Assert.AreEqual(0, result.ExitCode, result.Output + result.Error); + return result.Output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.StartsWith("MOCK:", StringComparison.Ordinal)).Select(line => line.Substring(5)).ToArray(); + } + + private sealed class Workflow + { + private readonly Dictionary jobs; + private Workflow(string source) + { + var jobStart = Regex.Match(source, @"(?m)^jobs:\r?$"); + Assert.IsTrue(jobStart.Success, "Workflow jobs missing."); + jobs = Regex.Matches(source.Substring(jobStart.Index + jobStart.Length), @"(?ms)^ (?[a-z][a-z0-9-]*):\r?\n(?.*?)(?=^ [a-z][a-z0-9-]*:|\z)") + .ToDictionary(match => match.Groups["id"].Value, match => match.Groups["body"].Value); + foreach (var id in new[] { "build-and-test", "roslyn-hosts", "pack-and-validate", "verify-release-artifacts", "publish" }) + Assert.IsTrue(jobs.ContainsKey(id), "Workflow job missing: " + id); + } + + public static Workflow Read() => new Workflow(File.ReadAllText(Repository.Path(".github/workflows/ci.yml"))); + public string Job(string id) => jobs[id]; + public string PublishScript + { + get + { + var match = Regex.Match(Job("publish"), @"(?ms)^ - name: Publish exact packages\r?\n.*?^ run: \|\r?\n(?