Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
948 changes: 287 additions & 661 deletions LICENSE

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Source/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@
<PackageVersion Include="Otp.NET" Version="1.4.1" />
<PackageVersion Include="QRCoder" Version="1.8.0" />
<PackageVersion Include="QuestPDF" Version="2026.2.4" />
<PackageVersion Include="Scriban" Version="7.1.0" />
<PackageVersion Include="Scriban" Version="7.2.4" />
<PackageVersion Include="Serilog" Version="4.3.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.EventLog" Version="4.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
<PackageVersion Include="YamlDotNet" Version="17.0.1" />
Expand Down
51 changes: 51 additions & 0 deletions Source/Reef.Tests/Scripting/InterpreterServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using FluentAssertions;
using Reef.Core.Scripting;
using Reef.Core.Services;

namespace Reef.Tests.Scripting;

public class InterpreterServiceTests
{
private readonly InterpreterService _service = new(new ProcessScriptRunner());

[Fact]
public async Task CheckOneAsync_RunnableInterpreter_ReportsAvailableWithOutput()
{
var status = await _service.CheckOneAsync("sh");

status.Available.Should().BeTrue();
status.Output.Should().Be("ok");
status.Error.Should().BeNull();
}

[Fact]
public async Task CheckOneAsync_UnknownInterpreter_ReportsUnavailableWithError()
{
var status = await _service.CheckOneAsync("not-a-real-interpreter");

status.Available.Should().BeFalse();
status.Error.Should().Contain("Unknown interpreter");
}

[Fact]
public async Task CheckAllAsync_ReturnsOneStatusPerKnownInterpreter()
{
var statuses = await _service.CheckAllAsync();

statuses.Should().HaveCount(InterpreterService.KnownInterpreters.Count);
statuses.Select(s => s.Name).Should().BeEquivalentTo(
InterpreterService.KnownInterpreters.Select(d => d.Name));
}

[Fact]
public async Task CheckAllAsync_OnThisHost_FindsAtLeastShAndBashAvailable()
{
// sh/bash are present on every Linux/macOS CI runner and dev box this
// project targets - a sane baseline so a regression in the probe
// logic itself doesn't silently report everything as unavailable.
var statuses = await _service.CheckAllAsync();

statuses.First(s => s.Name == "sh").Available.Should().BeTrue();
statuses.First(s => s.Name == "bash").Available.Should().BeTrue();
}
}
129 changes: 129 additions & 0 deletions Source/Reef.Tests/Scripting/ProcessScriptRunnerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using FluentAssertions;
using Reef.Core.Scripting;

namespace Reef.Tests.Scripting;

public class ProcessScriptRunnerTests
{
private readonly ProcessScriptRunner _runner = new();

[Fact]
public async Task RunAsync_InlineScriptExitingZero_ReturnsSuccessWithStdout()
{
var result = await _runner.RunAsync(new ScriptExecutionRequest
{
Interpreter = "sh",
ScriptPathOrInline = "echo hello-from-script",
IsInline = true,
StdinJson = "{}"
});

result.Success.Should().BeTrue();
result.ExitCode.Should().Be(0);
result.Stdout.Should().Contain("hello-from-script");
}

[Fact]
public async Task RunAsync_InlineScriptExitingNonZero_ReturnsFailure()
{
var result = await _runner.RunAsync(new ScriptExecutionRequest
{
Interpreter = "sh",
ScriptPathOrInline = "exit 7",
IsInline = true,
StdinJson = "{}"
});

result.Success.Should().BeFalse();
result.ExitCode.Should().Be(7);
}

[Fact]
public async Task RunAsync_ScriptReadsStdin_ReceivesPayload()
{
var result = await _runner.RunAsync(new ScriptExecutionRequest
{
Interpreter = "sh",
ScriptPathOrInline = "cat",
IsInline = true,
StdinJson = "{\"executionId\":42}"
});

result.Success.Should().BeTrue();
result.Stdout.Should().Contain("\"executionId\":42");
}

[Fact]
public async Task RunAsync_ScriptIgnoresStdin_DoesNotFailOnBrokenPipe()
{
var result = await _runner.RunAsync(new ScriptExecutionRequest
{
Interpreter = "sh",
ScriptPathOrInline = "echo done",
IsInline = true,
StdinJson = new string('x', 1024 * 1024) // large payload the script never reads
});

result.Success.Should().BeTrue();
result.Stdout.Should().Contain("done");
}

[Fact]
public async Task RunAsync_ScriptExceedsTimeout_IsKilledAndReportsTimeout()
{
var result = await _runner.RunAsync(new ScriptExecutionRequest
{
Interpreter = "sh",
ScriptPathOrInline = "sleep 30",
IsInline = true,
StdinJson = "{}",
TimeoutSeconds = 1
});

result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("timed out");
}

[Fact]
public async Task RunAsync_NonExistentScriptPath_ReturnsFailureWithoutThrowing()
{
var result = await _runner.RunAsync(new ScriptExecutionRequest
{
Interpreter = "sh",
ScriptPathOrInline = "/no/such/script.sh",
IsInline = false,
StdinJson = "{}"
});

result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("not found");
}

[Fact]
public async Task RunAsync_EnvAllowlist_PassesOnlyAllowedVariables()
{
Environment.SetEnvironmentVariable("REEF_TEST_ALLOWED", "visible");
Environment.SetEnvironmentVariable("REEF_TEST_BLOCKED", "secret");

try
{
var result = await _runner.RunAsync(new ScriptExecutionRequest
{
Interpreter = "sh",
ScriptPathOrInline = "echo \"A=$REEF_TEST_ALLOWED B=$REEF_TEST_BLOCKED\"",
IsInline = true,
StdinJson = "{}",
EnvAllowlist = new List<string> { "REEF_TEST_ALLOWED" }
});

result.Stdout.Should().Contain("A=visible");
result.Stdout.Should().Contain("B=");
result.Stdout.Should().NotContain("secret");
}
finally
{
Environment.SetEnvironmentVariable("REEF_TEST_ALLOWED", null);
Environment.SetEnvironmentVariable("REEF_TEST_BLOCKED", null);
}
}
}
96 changes: 96 additions & 0 deletions Source/Reef.Tests/Templates/MockHttpServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace Reef.Tests.Templates;

/// <summary>
/// Minimal loopback HTTP server for exercising the example Script Templates
/// against a fake webhook/Slack/ERP endpoint instead of the real internet.
/// </summary>
public sealed class MockHttpServer : IDisposable
{
private readonly HttpListener _listener;
private readonly CancellationTokenSource _cts = new();
private readonly Task _acceptLoop;

private int _statusCode = 200;
private string _responseBody = "ok";

public string BaseUrl { get; }
public string? LastRequestBody { get; private set; }
public string? LastMethod { get; private set; }
public string? LastAuthorizationHeader { get; private set; }
public string? LastContentTypeHeader { get; private set; }
public int RequestCount { get; private set; }

public MockHttpServer()
{
var port = GetFreePort();
BaseUrl = $"http://127.0.0.1:{port}/";
_listener = new HttpListener();
_listener.Prefixes.Add(BaseUrl);
_listener.Start();
_acceptLoop = Task.Run(AcceptLoopAsync);
}

public void SetResponse(int statusCode, string body = "ok")
{
_statusCode = statusCode;
_responseBody = body;
}

private async Task AcceptLoopAsync()
{
while (!_cts.IsCancellationRequested && _listener.IsListening)
{
HttpListenerContext ctx;
try
{
ctx = await _listener.GetContextAsync();
}
catch
{
return;
}

try
{
using var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8);
LastRequestBody = await reader.ReadToEndAsync();
LastMethod = ctx.Request.HttpMethod;
LastAuthorizationHeader = ctx.Request.Headers["Authorization"];
LastContentTypeHeader = ctx.Request.Headers["Content-Type"];
RequestCount++;

ctx.Response.StatusCode = _statusCode;
var bytes = Encoding.UTF8.GetBytes(_responseBody);
ctx.Response.ContentLength64 = bytes.Length;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.OutputStream.Close();
}
catch
{
// Best effort - a flaky/aborted request shouldn't kill the accept loop.
}
}
}

private static int GetFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}

public void Dispose()
{
_cts.Cancel();
_listener.Stop();
_listener.Close();
try { _acceptLoop.Wait(TimeSpan.FromSeconds(2)); } catch { /* best effort */ }
_cts.Dispose();
}
}
Loading
Loading