More tests

This commit is contained in:
Francesco Renzi
2026-03-13 08:34:24 +00:00
committed by GitHub
parent 75760d1f34
commit 649dc74be3
5 changed files with 516 additions and 7 deletions

View File

@@ -207,7 +207,7 @@ namespace GitHub.Runner.Worker.Dap
/// replaced with its masked string result, mirroring the semantics of
/// expression interpolation in a workflow <c>run:</c> step body.
/// </summary>
private string ExpandExpressions(string input, IExecutionContext context)
internal string ExpandExpressions(string input, IExecutionContext context)
{
if (string.IsNullOrEmpty(input) || !input.Contains("${{"))
{
@@ -269,7 +269,7 @@ namespace GitHub.Runner.Worker.Dap
/// Resolves the default shell the same way <see cref="ScriptHandler"/>
/// does: check job defaults, then fall back to platform default.
/// </summary>
private string ResolveDefaultShell(IExecutionContext context)
internal string ResolveDefaultShell(IExecutionContext context)
{
// Check job defaults
if (context.Global?.JobDefaults != null &&
@@ -295,7 +295,7 @@ namespace GitHub.Runner.Worker.Dap
/// <summary>
/// Merges the job context environment with any REPL-specific overrides.
/// </summary>
private Dictionary<string, string> BuildEnvironment(
internal Dictionary<string, string> BuildEnvironment(
IExecutionContext context,
Dictionary<string, string> replEnv)
{

View File

@@ -786,9 +786,17 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Single(_sentResponses);
Assert.True(_sentResponses[0].Success);
// The response body is serialized — we can't easily inspect it from
// the mock, but the important thing is it succeeded without exposing
// raw secrets (which is tested in DapVariableProviderL0).
// Verify the response body actually contains redacted values
var body = _sentResponses[0].Body;
Assert.NotNull(body);
var varsBody = Assert.IsType<VariablesResponseBody>(body);
Assert.NotEmpty(varsBody.Variables);
foreach (var variable in varsBody.Variables)
{
Assert.Equal(DapVariableProvider.RedactedValue, variable.Value);
Assert.DoesNotContain("ghp_verysecret", variable.Value);
}
// Resume to unblock
var continueJson = JsonConvert.SerializeObject(new Request

View File

@@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapReplExecutorL0
{
private TestHostContext _hc;
private DapReplExecutor _executor;
private Mock<IDapServer> _mockServer;
private List<Event> _sentEvents;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
_hc = new TestHostContext(this, testName);
_sentEvents = new List<Event>();
_mockServer = new Mock<IDapServer>();
_mockServer.Setup(x => x.SendEvent(It.IsAny<Event>()))
.Callback<Event>(e => _sentEvents.Add(e));
_executor = new DapReplExecutor(_hc, _mockServer.Object);
return _hc;
}
private Mock<IExecutionContext> CreateMockContext(
DictionaryContextData exprValues = null,
IDictionary<string, IDictionary<string, string>> jobDefaults = null)
{
var mock = new Mock<IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
mock.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
var global = new GlobalContext
{
PrependPath = new List<string>(),
JobDefaults = jobDefaults
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
};
mock.Setup(x => x.Global).Returns(global);
return mock;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ExecuteRunCommand_NullContext_ReturnsError()
{
using (CreateTestContext())
{
var command = new RunCommand { Script = "echo hello" };
var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
Assert.Equal("error", result.Type);
Assert.Contains("No execution context available", result.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_NoExpressions_ReturnsInput()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions("echo hello", context.Object);
Assert.Equal("echo hello", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_NullInput_ReturnsEmpty()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions(null, context.Object);
Assert.Equal(string.Empty, result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_EmptyInput_ReturnsEmpty()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions("", context.Object);
Assert.Equal(string.Empty, result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_UnterminatedExpression_KeepsLiteral()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions("echo ${{ github.repo", context.Object);
Assert.Equal("echo ${{ github.repo", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveDefaultShell_NoJobDefaults_ReturnsSh()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ResolveDefaultShell(context.Object);
Assert.Equal("sh", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveDefaultShell_WithJobDefault_ReturnsJobDefault()
{
using (CreateTestContext())
{
var jobDefaults = new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase)
{
["run"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["shell"] = "bash"
}
};
var context = CreateMockContext(jobDefaults: jobDefaults);
var result = _executor.ResolveDefaultShell(context.Object);
Assert.Equal("bash", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BuildEnvironment_MergesEnvContextAndReplOverrides()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var envData = new DictionaryContextData
{
["FOO"] = new StringContextData("bar"),
};
exprValues["env"] = envData;
var context = CreateMockContext(exprValues);
var replEnv = new Dictionary<string, string> { { "BAZ", "qux" } };
var result = _executor.BuildEnvironment(context.Object, replEnv);
Assert.Equal("bar", result["FOO"]);
Assert.Equal("qux", result["BAZ"]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BuildEnvironment_ReplOverridesWin()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var envData = new DictionaryContextData
{
["FOO"] = new StringContextData("original"),
};
exprValues["env"] = envData;
var context = CreateMockContext(exprValues);
var replEnv = new Dictionary<string, string> { { "FOO", "override" } };
var result = _executor.BuildEnvironment(context.Object, replEnv);
Assert.Equal("override", result["FOO"]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var envData = new DictionaryContextData
{
["FOO"] = new StringContextData("bar"),
};
exprValues["env"] = envData;
var context = CreateMockContext(exprValues);
var result = _executor.BuildEnvironment(context.Object, null);
Assert.Equal("bar", result["FOO"]);
Assert.False(result.ContainsKey("BAZ"));
}
}
}
}

View File

@@ -1,5 +1,10 @@
using System;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Worker.Dap;
@@ -166,5 +171,172 @@ namespace GitHub.Runner.Common.Tests.Worker
await _server.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task MessageFraming_ValidMessage_ProcessedSuccessfully()
{
using (var hc = CreateTestContext())
{
var receivedMessages = new List<string>();
var mockSession = new Mock<IDapDebugSession>();
mockSession.Setup(x => x.HandleMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, CancellationToken>((json, ct) => receivedMessages.Add(json))
.Returns(Task.CompletedTask);
_server.SetSession(mockSession.Object);
var cts = new CancellationTokenSource();
await _server.StartAsync(0, cts.Token);
var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance);
var listener = (TcpListener)listenerField.GetValue(_server);
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port);
var stream = client.GetStream();
// Wait for server to accept connection
await Task.Delay(100);
// Send a valid DAP request with Content-Length framing
var requestJson = "{\"seq\":1,\"type\":\"request\",\"command\":\"initialize\"}";
var body = Encoding.UTF8.GetBytes(requestJson);
var header = $"Content-Length: {body.Length}\r\n\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
await stream.WriteAsync(headerBytes, 0, headerBytes.Length);
await stream.WriteAsync(body, 0, body.Length);
await stream.FlushAsync();
// Wait for processing
await Task.Delay(500);
Assert.Single(receivedMessages);
Assert.Contains("initialize", receivedMessages[0]);
cts.Cancel();
await _server.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CentralizedMasking_SecretsInResponseAreMasked()
{
using (var hc = CreateTestContext())
{
// Register a secret
hc.SecretMasker.AddValue("super-secret-token");
var cts = new CancellationTokenSource();
await _server.StartAsync(0, cts.Token);
var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance);
var listener = (TcpListener)listenerField.GetValue(_server);
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port);
var stream = client.GetStream();
await Task.Delay(100);
// Send a response that contains the secret (through the server API)
var response = new Response
{
Type = "response",
RequestSeq = 1,
Command = "evaluate",
Success = true,
Body = new EvaluateResponseBody
{
Result = "The value is super-secret-token here",
Type = "string",
VariablesReference = 0
}
};
_server.SendResponse(response);
// Read what the client received
await Task.Delay(200);
var buffer = new byte[4096];
var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
var received = Encoding.UTF8.GetString(buffer, 0, bytesRead);
// The response should NOT contain the raw secret
Assert.DoesNotContain("super-secret-token", received);
// It should contain the masked version
Assert.Contains("***", received);
cts.Cancel();
await _server.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CentralizedMasking_SecretsInEventsAreMasked()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue("event-secret-value");
var cts = new CancellationTokenSource();
await _server.StartAsync(0, cts.Token);
var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance);
var listener = (TcpListener)listenerField.GetValue(_server);
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port);
var stream = client.GetStream();
await Task.Delay(100);
_server.SendEvent(new Event
{
EventType = "output",
Body = new OutputEventBody
{
Category = "stdout",
Output = "Output contains event-secret-value here"
}
});
await Task.Delay(200);
var buffer = new byte[4096];
var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
var received = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Assert.DoesNotContain("event-secret-value", received);
Assert.Contains("***", received);
cts.Cancel();
await _server.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StopAsync_AwaitsConnectionLoopShutdown()
{
using (CreateTestContext())
{
var cts = new CancellationTokenSource();
await _server.StartAsync(0, cts.Token);
// Stop should complete within a reasonable time
var stopTask = _server.StopAsync();
var completed = await Task.WhenAny(stopTask, Task.Delay(10000));
Assert.Equal(stopTask, completed);
}
}
}
}

View File

@@ -665,5 +665,108 @@ namespace GitHub.Runner.Common.Tests.Worker
}
#endregion
#region Non-string secret type redaction
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsNumberContextData()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "NUMERIC_SECRET", new NumberContextData(12345) }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("NUMERIC_SECRET", variables[0].Name);
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
Assert.Equal("string", variables[0].Type);
Assert.Equal(0, variables[0].VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsBooleanContextData()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "BOOL_SECRET", new BooleanContextData(true) }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("BOOL_SECRET", variables[0].Name);
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
Assert.Equal("string", variables[0].Type);
Assert.Equal(0, variables[0].VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsNestedDictionary()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "NESTED_SECRET", new DictionaryContextData
{
{ "inner_key", new StringContextData("inner_value") }
}
}
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("NESTED_SECRET", variables[0].Name);
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
Assert.Equal("string", variables[0].Type);
// Nested container should NOT be drillable under secrets
Assert.Equal(0, variables[0].VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsNullValue()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var secrets = new DictionaryContextData();
secrets["NULL_SECRET"] = null;
exprValues["secrets"] = secrets;
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("NULL_SECRET", variables[0].Name);
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
Assert.Equal(0, variables[0].VariablesReference);
}
}
#endregion
}
}