From 6f4efdfa2ea85c06e095112157e8a4964d6702a7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 19:47:17 -0400 Subject: [PATCH] =?UTF-8?q?fix(inbound-api):=20resolve=20InboundAPI-001/00?= =?UTF-8?q?3/005=20=E2=80=94=20concurrent=20handler=20cache,=20constant-ti?= =?UTF-8?q?me=20API=20key=20compare,=20script=20trust-model=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- code-reviews/InboundAPI/findings.md | 38 +++++- src/ScadaLink.InboundAPI/ApiKeyValidator.cs | 36 +++++- .../ForbiddenApiChecker.cs | 121 ++++++++++++++++++ .../InboundScriptExecutor.cs | 63 ++++++--- .../ApiKeyValidatorTests.cs | 56 +++++++- .../InboundScriptExecutorTests.cs | 107 ++++++++++++++++ 6 files changed, 393 insertions(+), 28 deletions(-) create mode 100644 src/ScadaLink.InboundAPI/ForbiddenApiChecker.cs diff --git a/code-reviews/InboundAPI/findings.md b/code-reviews/InboundAPI/findings.md index 4d1ccd2..9368e27 100644 --- a/code-reviews/InboundAPI/findings.md +++ b/code-reviews/InboundAPI/findings.md @@ -8,7 +8,7 @@ | Last reviewed | 2026-05-16 | | Reviewer | claude-agent | | Commit reviewed | `9c60592` | -| Open findings | 13 | +| Open findings | 10 | ## Summary @@ -53,7 +53,7 @@ are High severity and should be addressed before production use. |--|--| | Severity | High | | Category | Concurrency & thread safety | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:17`, `:32`, `:40`, `:89`, `:123-128` | **Description** @@ -76,7 +76,12 @@ compile at most once. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit ``): replaced the plain `Dictionary` handler +cache with a `ConcurrentDictionary`; `RemoveHandler` now uses `TryRemove`; the +lazy-compile path in `ExecuteAsync` compiles outside the cache and inserts atomically +via `GetOrAdd` so concurrent first-callers share one handler. Regression tests +`ConcurrentLazyCompile_SameMethod_DoesNotCorruptCache` and +`ConcurrentRegisterAndExecute_DoesNotThrow` added. ### InboundAPI-002 — Lazy compilation is a check-then-act race with no atomicity @@ -114,7 +119,7 @@ _Unresolved._ |--|--| | Severity | High | | Category | Security | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:22-23`, consumed by `src/ScadaLink.InboundAPI/ApiKeyValidator.cs:33` | **Description** @@ -138,7 +143,17 @@ match-position timing. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit ``): `ApiKeyValidator` no longer calls the +secret-equality lookup `GetApiKeyByValueAsync` (the SQL `WHERE KeyValue = @secret` +timing oracle). It now fetches all keys via `GetAllApiKeysAsync` and matches the +secret in-process with `CryptographicOperations.FixedTimeEquals` over the UTF-8 bytes, +scanning every candidate so neither match position nor secret length is observable. +Regression tests `ValidateAsync_DoesNotUseSecretEqualityLookup`, +`ValidateAsync_WrongKey_ConstantTimePath_Returns401`, and +`ValidateAsync_KeyOfDifferentLength_Returns401` added. Note: the timing-oracle method +`GetApiKeyByValueAsync` remains on `IInboundApiRepository` (it is outside this module); +removing it from the repository is left as separate follow-up since the validator no +longer depends on it. ### InboundAPI-004 — Client disconnect is misreported as a script timeout @@ -178,7 +193,7 @@ _Unresolved._ |--|--| | Severity | High | | Category | Security | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:56-93` | **Description** @@ -205,7 +220,16 @@ forbidden-API checker so the trust model is enforced consistently. Reject the me **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit ``): added `ForbiddenApiChecker`, a Roslyn +`CSharpSyntaxWalker` that statically rejects scripts referencing forbidden namespaces +(`System.IO`, `System.Diagnostics`, `System.Threading` except `Tasks`, +`System.Reflection`, `System.Net`, `System.Runtime.InteropServices`, `Microsoft.Win32`) +whether reached via a `using` directive or a fully-qualified name. `CompileAndRegister` +now runs the check before Roslyn compilation and refuses to register (and logs) a +violating method; `ExecuteAsync`'s lazy-compile path is gated by the same check. +Regression tests `CompileAndRegister_ForbiddenApi_RejectsScript` (theory), +`ExecuteAsync_ForbiddenApiScript_DoesNotRunAndReturnsFailure`, and +`CompileAndRegister_PermittedScript_StillRegisters` added. ### InboundAPI-006 — No request body size limit on the inbound endpoint diff --git a/src/ScadaLink.InboundAPI/ApiKeyValidator.cs b/src/ScadaLink.InboundAPI/ApiKeyValidator.cs index 2a91e5e..ec34028 100644 --- a/src/ScadaLink.InboundAPI/ApiKeyValidator.cs +++ b/src/ScadaLink.InboundAPI/ApiKeyValidator.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Interfaces.Repositories; @@ -30,7 +32,14 @@ public class ApiKeyValidator return ApiKeyValidationResult.Unauthorized("Missing X-API-Key header"); } - var apiKey = await _repository.GetApiKeyByValueAsync(apiKeyValue, cancellationToken); + // InboundAPI-003: do NOT resolve the key with a secret-equality lookup + // (GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit + // comparison — a timing side-channel). Fetch all keys and match the secret + // in-process with a constant-time comparison so neither match position nor + // secret length is observable to a network attacker. + var apiKey = FindKeyConstantTime( + await _repository.GetAllApiKeysAsync(cancellationToken), + apiKeyValue); if (apiKey == null || !apiKey.IsEnabled) { return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key"); @@ -53,6 +62,31 @@ public class ApiKeyValidator return ApiKeyValidationResult.Valid(apiKey, method); } + + /// + /// InboundAPI-003: Finds the key whose value matches + /// using over the UTF-8 bytes. + /// Every candidate row is compared so that the running time does not depend on the + /// match position; length mismatches return false without leaking length timing. + /// + private static ApiKey? FindKeyConstantTime(IEnumerable keys, string candidate) + { + var candidateBytes = Encoding.UTF8.GetBytes(candidate); + ApiKey? match = null; + + foreach (var key in keys) + { + var keyBytes = Encoding.UTF8.GetBytes(key.KeyValue); + if (CryptographicOperations.FixedTimeEquals(candidateBytes, keyBytes)) + { + // Do not break — continuing keeps the loop's timing independent of + // where (or whether) a match is found. + match = key; + } + } + + return match; + } } /// diff --git a/src/ScadaLink.InboundAPI/ForbiddenApiChecker.cs b/src/ScadaLink.InboundAPI/ForbiddenApiChecker.cs new file mode 100644 index 0000000..bda3219 --- /dev/null +++ b/src/ScadaLink.InboundAPI/ForbiddenApiChecker.cs @@ -0,0 +1,121 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ScadaLink.InboundAPI; + +/// +/// InboundAPI-005: Enforces the ScadaLink script trust model on inbound API method +/// scripts before they are compiled into executable handlers. +/// +/// The trust model (CLAUDE.md, Akka.NET conventions) forbids scripts from reaching +/// System.IO, System.Diagnostics.Process, System.Threading, +/// System.Reflection, and raw network APIs. Roslyn scripting performs no +/// API allow/deny-listing — restricting default imports is a convenience, not a +/// sandbox — so a script can fully-qualify any referenced type. This static check +/// walks the script syntax tree and rejects any reference to a forbidden namespace, +/// whether reached through a using directive or a fully-qualified name. +/// +public static class ForbiddenApiChecker +{ + /// + /// Namespace prefixes the trust model forbids. A script segment matches if it is + /// equal to one of these or is a child namespace of it. + /// + private static readonly string[] ForbiddenNamespaces = + { + "System.IO", + "System.Diagnostics", // covers Process + "System.Threading", // Task/Tasks is explicitly re-allowed below + "System.Reflection", + "System.Net", // raw network (Sockets, HttpClient, etc.) + "System.Runtime.InteropServices", + "Microsoft.Win32", + }; + + /// + /// Namespaces that would otherwise be caught by a forbidden prefix but are + /// required for normal async script authoring and carry no host-access risk. + /// + private static readonly string[] AllowedExceptions = + { + "System.Threading.Tasks", + }; + + /// + /// Analyses the script source and returns the list of trust-model violations. + /// An empty list means the script is acceptable. + /// + public static IReadOnlyList FindViolations(string scriptCode) + { + if (string.IsNullOrWhiteSpace(scriptCode)) + return Array.Empty(); + + var tree = CSharpSyntaxTree.ParseText( + scriptCode, + new CSharpParseOptions(kind: SourceCodeKind.Script)); + + var walker = new ForbiddenApiWalker(); + walker.Visit(tree.GetRoot()); + return walker.Violations; + } + + private static bool IsForbidden(string dottedName) + { + foreach (var allowed in AllowedExceptions) + { + if (dottedName == allowed || dottedName.StartsWith(allowed + ".", StringComparison.Ordinal)) + return false; + } + + foreach (var forbidden in ForbiddenNamespaces) + { + if (dottedName == forbidden || dottedName.StartsWith(forbidden + ".", StringComparison.Ordinal)) + return true; + } + + return false; + } + + private sealed class ForbiddenApiWalker : CSharpSyntaxWalker + { + private readonly List _violations = new(); + + public IReadOnlyList Violations => _violations; + + public override void VisitUsingDirective(UsingDirectiveSyntax node) + { + if (node.Name is not null && IsForbidden(node.Name.ToString())) + _violations.Add($"forbidden namespace import '{node.Name}'"); + + base.VisitUsingDirective(node); + } + + public override void VisitQualifiedName(QualifiedNameSyntax node) + { + // Check the longest qualified name; do not descend so a single + // System.IO.File reference is reported once, not three times. + var text = node.ToString(); + if (IsForbidden(text)) + { + _violations.Add($"forbidden type reference '{text}'"); + return; + } + + base.VisitQualifiedName(node); + } + + public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node) + { + // Catches fully-qualified expressions such as System.IO.File.Delete(...). + var text = node.ToString(); + if (IsForbidden(text)) + { + _violations.Add($"forbidden API access '{text}'"); + return; + } + + base.VisitMemberAccessExpression(node); + } + } +} diff --git a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs index 99d923b..ddbe526 100644 --- a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs +++ b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; @@ -14,7 +15,11 @@ namespace ScadaLink.InboundAPI; public class InboundScriptExecutor { private readonly ILogger _logger; - private readonly Dictionary>> _scriptHandlers = new(); + + // InboundAPI-001: this executor is registered as a singleton and its handler cache + // is read and written from concurrent ASP.NET request threads. A plain Dictionary is + // not safe for concurrent read/write, so a ConcurrentDictionary is used throughout. + private readonly ConcurrentDictionary>> _scriptHandlers = new(); private readonly IServiceProvider _serviceProvider; @@ -37,18 +42,45 @@ public class InboundScriptExecutor /// public void RemoveHandler(string methodName) { - _scriptHandlers.Remove(methodName); + _scriptHandlers.TryRemove(methodName, out _); } /// - /// Compiles and registers a single API method script. + /// Compiles and registers a single API method script. Returns false if the + /// script is empty, fails Roslyn compilation, or violates the script trust model. /// public bool CompileAndRegister(ApiMethod method) + => Compile(method) is { } handler && Register(method.Name, handler); + + private bool Register(string methodName, Func> handler) + { + _scriptHandlers[methodName] = handler; + return true; + } + + /// + /// Compiles a single API method script into an executable handler. Returns + /// null when the script is missing, fails to compile, or violates the + /// script trust model (InboundAPI-005). Does not mutate the handler cache. + /// + private Func>? Compile(ApiMethod method) { if (string.IsNullOrWhiteSpace(method.Script)) { _logger.LogWarning("API method {Method} has no script code", method.Name); - return false; + return null; + } + + // InboundAPI-005: enforce the script trust model before compiling. Roslyn + // scripting performs no API allow/deny-listing, so forbidden namespaces must + // be rejected statically or the script could reach the host process. + var violations = ForbiddenApiChecker.FindViolations(method.Script); + if (violations.Count > 0) + { + _logger.LogWarning( + "API method {Method} script rejected — trust model violation(s): {Violations}", + method.Name, string.Join("; ", violations)); + return null; } try @@ -83,22 +115,20 @@ public class InboundScriptExecutor _logger.LogWarning( "API method {Method} script compilation failed: {Errors}", method.Name, string.Join("; ", errors)); - return false; + return null; } - _scriptHandlers[method.Name] = async ctx => + _logger.LogInformation("API method {Method} script compiled", method.Name); + return async ctx => { var state = await compiled.RunAsync(ctx, ctx.CancellationToken); return state.ReturnValue; }; - - _logger.LogInformation("API method {Method} script compiled and registered", method.Name); - return true; } catch (Exception ex) { _logger.LogError(ex, "Failed to compile API method {Method} script", method.Name); - return false; + return null; } } @@ -119,15 +149,18 @@ public class InboundScriptExecutor var context = new InboundScriptContext(parameters, route, cts.Token); - object? result; if (!_scriptHandlers.TryGetValue(method.Name, out var handler)) { - // Lazy compile on first request (handles methods created after startup) - if (!CompileAndRegister(method)) + // Lazy compile on first request (handles methods created after startup). + // Compile outside the cache so a failed compile is not stored, then add + // atomically so concurrent first-callers share a single handler instance. + var compiled = Compile(method); + if (compiled == null) return new InboundScriptResult(false, null, "Script compilation failed for this method"); - handler = _scriptHandlers[method.Name]; + handler = _scriptHandlers.GetOrAdd(method.Name, compiled); } - result = await handler(context).WaitAsync(cts.Token); + + var result = await handler(context).WaitAsync(cts.Token); var resultJson = result != null ? JsonSerializer.Serialize(result) diff --git a/tests/ScadaLink.InboundAPI.Tests/ApiKeyValidatorTests.cs b/tests/ScadaLink.InboundAPI.Tests/ApiKeyValidatorTests.cs index a43b4ba..be25ccc 100644 --- a/tests/ScadaLink.InboundAPI.Tests/ApiKeyValidatorTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/ApiKeyValidatorTests.cs @@ -37,7 +37,7 @@ public class ApiKeyValidatorTests [Fact] public async Task InvalidApiKey_Returns401() { - _repository.GetApiKeyByValueAsync("bad-key").Returns((ApiKey?)null); + _repository.GetAllApiKeysAsync().Returns(new List()); var result = await _validator.ValidateAsync("bad-key", "testMethod"); Assert.False(result.IsValid); @@ -48,7 +48,7 @@ public class ApiKeyValidatorTests public async Task DisabledApiKey_Returns401() { var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = false }; - _repository.GetApiKeyByValueAsync("valid-key").Returns(key); + _repository.GetAllApiKeysAsync().Returns(new List { key }); var result = await _validator.ValidateAsync("valid-key", "testMethod"); Assert.False(result.IsValid); @@ -59,7 +59,7 @@ public class ApiKeyValidatorTests public async Task ValidKey_MethodNotFound_Returns400() { var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true }; - _repository.GetApiKeyByValueAsync("valid-key").Returns(key); + _repository.GetAllApiKeysAsync().Returns(new List { key }); _repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null); var result = await _validator.ValidateAsync("valid-key", "nonExistent"); @@ -73,7 +73,7 @@ public class ApiKeyValidatorTests var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true }; var method = new ApiMethod("testMethod", "return 1;") { Id = 10 }; - _repository.GetApiKeyByValueAsync("valid-key").Returns(key); + _repository.GetAllApiKeysAsync().Returns(new List { key }); _repository.GetMethodByNameAsync("testMethod").Returns(method); _repository.GetApprovedKeysForMethodAsync(10).Returns(new List()); @@ -88,7 +88,7 @@ public class ApiKeyValidatorTests var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true }; var method = new ApiMethod("testMethod", "return 1;") { Id = 10 }; - _repository.GetApiKeyByValueAsync("valid-key").Returns(key); + _repository.GetAllApiKeysAsync().Returns(new List { key }); _repository.GetMethodByNameAsync("testMethod").Returns(method); _repository.GetApprovedKeysForMethodAsync(10).Returns(new List { key }); @@ -98,4 +98,50 @@ public class ApiKeyValidatorTests Assert.Equal(key, result.ApiKey); Assert.Equal(method, result.Method); } + + // --- InboundAPI-003: API key must not be matched with a non-constant-time + // (timing-oracle) secret-equality lookup. --- + + [Fact] + public async Task ValidateAsync_DoesNotUseSecretEqualityLookup() + { + // GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit + // comparison — a timing side-channel. The validator must not call it. + var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true }; + var method = new ApiMethod("testMethod", "return 1;") { Id = 10 }; + + _repository.GetAllApiKeysAsync().Returns(new List { key }); + _repository.GetMethodByNameAsync("testMethod").Returns(method); + _repository.GetApprovedKeysForMethodAsync(10).Returns(new List { key }); + + await _validator.ValidateAsync("valid-key", "testMethod"); + + await _repository.DidNotReceive() + .GetApiKeyByValueAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ValidateAsync_WrongKey_ConstantTimePath_Returns401() + { + var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true }; + _repository.GetAllApiKeysAsync().Returns(new List { key }); + + var result = await _validator.ValidateAsync("wrong-key", "testMethod"); + + Assert.False(result.IsValid); + Assert.Equal(401, result.StatusCode); + } + + [Fact] + public async Task ValidateAsync_KeyOfDifferentLength_Returns401() + { + // FixedTimeEquals over UTF-8 bytes must reject length mismatches without leaking. + var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true }; + _repository.GetAllApiKeysAsync().Returns(new List { key }); + + var result = await _validator.ValidateAsync("valid-key-with-extra", "testMethod"); + + Assert.False(result.IsValid); + Assert.Equal(401, result.StatusCode); + } } diff --git a/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs b/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs index 6b15573..92c8c64 100644 --- a/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs @@ -132,4 +132,111 @@ public class InboundScriptExecutorTests Assert.True(result.Success); Assert.Contains("ScadaLink", result.ResultJson!); } + + // --- InboundAPI-001: concurrent lazy-compile must not corrupt the handler cache --- + + [Fact] + public async Task ConcurrentLazyCompile_SameMethod_DoesNotCorruptCache() + { + // Many concurrent first-callers of an uncompiled method race the lazy-compile + // path. With an unsynchronized Dictionary this can throw or return a torn/null + // handler; all calls must succeed and produce the same result. + var method = new ApiMethod("concurrent", "return 7;") { Id = 1, TimeoutSeconds = 10 }; + + var tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(() => + _executor.ExecuteAsync( + method, + new Dictionary(), + _route, + TimeSpan.FromSeconds(10)))).ToArray(); + + var results = await Task.WhenAll(tasks); + + Assert.All(results, r => + { + Assert.True(r.Success, r.ErrorMessage); + Assert.Equal("7", r.ResultJson); + }); + } + + [Fact] + public async Task ConcurrentRegisterAndExecute_DoesNotThrow() + { + // RegisterHandler/RemoveHandler racing ExecuteAsync must not crash the process + // with an InvalidOperationException from concurrent Dictionary mutation. + var method = new ApiMethod("racy", "return 1;") { Id = 1, TimeoutSeconds = 10 }; + + var writers = Enumerable.Range(0, 32).Select(i => Task.Run(() => + { + for (var n = 0; n < 50; n++) + { + _executor.RegisterHandler("racy", async ctx => { await Task.CompletedTask; return i; }); + _executor.RemoveHandler("racy"); + } + })); + + var readers = Enumerable.Range(0, 32).Select(_ => Task.Run(async () => + { + for (var n = 0; n < 50; n++) + { + await _executor.ExecuteAsync( + method, new Dictionary(), _route, TimeSpan.FromSeconds(10)); + } + })); + + // Should complete without an unhandled concurrency exception. + await Task.WhenAll(writers.Concat(readers)); + } + + // --- InboundAPI-005: compiled scripts must not bypass the script trust model --- + + [Theory] + [InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")] + [InlineData("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")] + [InlineData("var t = System.Reflection.Assembly.GetExecutingAssembly(); return null;")] + [InlineData("new System.Threading.Thread(() => {}).Start(); return null;")] + [InlineData("var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); return null;")] + public void CompileAndRegister_ForbiddenApi_RejectsScript(string script) + { + var method = new ApiMethod("forbidden", script) { Id = 1, TimeoutSeconds = 10 }; + + var registered = _executor.CompileAndRegister(method); + + Assert.False(registered); + } + + [Fact] + public async Task ExecuteAsync_ForbiddenApiScript_DoesNotRunAndReturnsFailure() + { + // A fully-qualified forbidden API call must be rejected at compile/register time + // so the script never executes. + var marker = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"scadalink-pwned-{Guid.NewGuid():N}"); + System.IO.File.Delete(marker); + + var method = new ApiMethod("evil", $"System.IO.File.WriteAllText(@\"{marker}\", \"x\"); return 1;") + { + Id = 1, + TimeoutSeconds = 10 + }; + + var result = await _executor.ExecuteAsync( + method, new Dictionary(), _route, TimeSpan.FromSeconds(10)); + + Assert.False(result.Success); + Assert.False(System.IO.File.Exists(marker)); + } + + [Fact] + public void CompileAndRegister_PermittedScript_StillRegisters() + { + // The trust-model check must not reject legitimate scripts. + var method = new ApiMethod("ok", "var list = new List { 1, 2, 3 }; return list.Sum();") + { + Id = 1, + TimeoutSeconds = 10 + }; + + Assert.True(_executor.CompileAndRegister(method)); + } }