diff --git a/src/Test/L0/Worker/HandlerFactoryL0.cs b/src/Test/L0/Worker/HandlerFactoryL0.cs index 4cfb70bc5..ea0c6ef46 100644 --- a/src/Test/L0/Worker/HandlerFactoryL0.cs +++ b/src/Test/L0/Worker/HandlerFactoryL0.cs @@ -317,5 +317,109 @@ namespace GitHub.Runner.Common.Tests.Worker Assert.Contains("some-org/old-action@v1", deprecatedActions); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node20Action_WithSubpath_TrackedCorrectly() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "some-org/monorepo", + Path = "actions/my-action", + Ref = "v2" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert - subpath should be included in tracked name + Assert.Contains("some-org/monorepo/actions/my-action@v2", deprecatedActions); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node20Action_SanitizesNewlinesInActionName() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "evil-org/bad\r\naction", + Ref = "v1" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert - CR/LF should be stripped to prevent log/annotation injection + Assert.Contains("evil-org/badaction@v1", deprecatedActions); + Assert.DoesNotContain("evil-org/bad\r\naction@v1", deprecatedActions); + } + } } } diff --git a/src/Test/L0/Worker/JobExtensionL0.cs b/src/Test/L0/Worker/JobExtensionL0.cs index 60814998e..d9954e259 100644 --- a/src/Test/L0/Worker/JobExtensionL0.cs +++ b/src/Test/L0/Worker/JobExtensionL0.cs @@ -755,5 +755,113 @@ namespace GitHub.Runner.Common.Tests.Worker Environment.SetEnvironmentVariable("RUNNER_ENVIRONMENT", null); Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", null); } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task FinalizeJob_EmitsDeprecationWarning_WhenNode20ActionsExist() + { + using (TestHostContext hc = CreateTestContext()) + { + var jobExtension = new JobExtension(); + jobExtension.Initialize(hc); + + _jobEc = new Runner.Worker.ExecutionContext { Result = TaskResult.Succeeded }; + _jobEc.Initialize(hc); + _jobEc.InitializeJob(_message, _tokenSource.Token); + + // Add deprecated actions + _jobEc.Global.DeprecatedNode20Actions.Add("actions/checkout@v4"); + _jobEc.Global.DeprecatedNode20Actions.Add("actions/setup-node@v3"); + + await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow); + + // Verify warning was written to log containing the action names + _logger.Verify(x => x.Write(It.Is(s => + s.Contains("actions/checkout@v4") && + s.Contains("actions/setup-node@v3") && + s.Contains("Node.js 20 is approaching end-of-life"))), Times.AtLeastOnce); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task FinalizeJob_NoDeprecationWarning_WhenNoNode20Actions() + { + using (TestHostContext hc = CreateTestContext()) + { + var jobExtension = new JobExtension(); + jobExtension.Initialize(hc); + + _jobEc = new Runner.Worker.ExecutionContext { Result = TaskResult.Succeeded }; + _jobEc.Initialize(hc); + _jobEc.InitializeJob(_message, _tokenSource.Token); + + // DeprecatedNode20Actions is initialized but empty + Assert.Empty(_jobEc.Global.DeprecatedNode20Actions); + + await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow); + + // Verify no deprecation warning was emitted + _logger.Verify(x => x.Write(It.Is(s => + s.Contains("Node.js 20 is approaching end-of-life"))), Times.Never); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task FinalizeJob_TruncatesActionsListAtTen() + { + using (TestHostContext hc = CreateTestContext()) + { + var jobExtension = new JobExtension(); + jobExtension.Initialize(hc); + + _jobEc = new Runner.Worker.ExecutionContext { Result = TaskResult.Succeeded }; + _jobEc.Initialize(hc); + _jobEc.InitializeJob(_message, _tokenSource.Token); + + // Add 15 deprecated actions + for (int i = 1; i <= 15; i++) + { + _jobEc.Global.DeprecatedNode20Actions.Add($"org/action-{i:D2}@v1"); + } + + await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow); + + // Verify truncation message appears with correct count + _logger.Verify(x => x.Write(It.Is(s => + s.Contains("... and 5 more"))), Times.AtLeastOnce); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task FinalizeJob_SortsActionsAlphabetically() + { + using (TestHostContext hc = CreateTestContext()) + { + var jobExtension = new JobExtension(); + jobExtension.Initialize(hc); + + _jobEc = new Runner.Worker.ExecutionContext { Result = TaskResult.Succeeded }; + _jobEc.Initialize(hc); + _jobEc.InitializeJob(_message, _tokenSource.Token); + + // Add actions in reverse alphabetical order + _jobEc.Global.DeprecatedNode20Actions.Add("z-org/last-action@v1"); + _jobEc.Global.DeprecatedNode20Actions.Add("a-org/first-action@v1"); + _jobEc.Global.DeprecatedNode20Actions.Add("m-org/middle-action@v1"); + + await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow); + + // Verify actions appear sorted: a-org before m-org before z-org + _logger.Verify(x => x.Write(It.Is(s => + s.Contains("a-org/first-action@v1, m-org/middle-action@v1, z-org/last-action@v1"))), Times.AtLeastOnce); + } + } } }