feat(platform): add ReleaseOrchestratorScriptService compatibility shim

Platform: new ReleaseOrchestratorScriptService translates the Platform-level
script API surface into calls against the ReleaseOrchestrator scripts module
so clients that still target /api/scripts on Platform continue to work during
the transition. Program.cs wires the shim. ScriptApiModels gets a minor
contract alignment.

ReleaseOrchestrator: ScriptsEndpoints + ScriptRegistry + ScriptModels updated
to expose and persist script variables correctly. New integration test
(ScriptRegistryVariablePersistenceTests) covers the persistence round-trip;
new unit test (ReleaseOrchestratorScriptServiceTests) covers the Platform
shim behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-13 22:00:32 +03:00
parent d4fee74b53
commit 0b09298a3a
9 changed files with 602 additions and 2 deletions

View File

@@ -14,7 +14,8 @@ public enum ScriptLanguageDto
Java,
Go,
Bash,
TypeScript
TypeScript,
PowerShell
}
/// <summary>

View File

@@ -17,6 +17,8 @@ using StellaOps.Platform.WebService.Endpoints;
using StellaOps.Platform.WebService.Options;
using StellaOps.Platform.WebService.Services;
using StellaOps.ReleaseOrchestrator.Environment.Postgres;
using StellaOps.ReleaseOrchestrator.Scripts;
using StellaOps.ReleaseOrchestrator.Scripts.Persistence;
using StellaOps.Router.AspNet;
using StellaOps.Signals.UnifiedScore;
using StellaOps.Telemetry.Core;
@@ -388,7 +390,21 @@ builder.Services.AddSingleton<StellaOps.Scanner.Reachability.FunctionMap.Verific
builder.Services.AddSingleton<IFunctionMapService, FunctionMapService>();
// Script registry services (multi-language script editor)
builder.Services.AddSingleton<IScriptService, InMemoryScriptService>();
if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString))
{
builder.Services.AddOptions<ScriptsPostgresOptions>()
.Configure(options =>
{
options.ConnectionString = bootstrapOptions.Storage.PostgresConnectionString!;
options.SchemaName = ScriptsDataSource.DefaultSchemaName;
});
builder.Services.AddReleaseOrchestratorScripts();
builder.Services.AddSingleton<IScriptService, ReleaseOrchestratorScriptService>();
}
else
{
builder.Services.AddSingleton<IScriptService, InMemoryScriptService>();
}
// Unified audit emission (posts audit events to Timeline service)
builder.Services.AddAuditEmission(builder.Configuration);

View File

@@ -0,0 +1,332 @@
using System.Collections.Immutable;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.ReleaseOrchestrator.Scripts;
namespace StellaOps.Platform.WebService.Services;
/// <summary>
/// Platform script service backed by the Release Orchestrator scripts registry.
/// </summary>
public sealed class ReleaseOrchestratorScriptService : IScriptService
{
private readonly IScriptRegistry _registry;
private readonly IScriptCompatibilityEvaluator _compatibilityEvaluator;
public ReleaseOrchestratorScriptService(
IScriptRegistry registry,
IScriptCompatibilityEvaluator compatibilityEvaluator)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_compatibilityEvaluator = compatibilityEvaluator ?? throw new ArgumentNullException(nameof(compatibilityEvaluator));
}
public async Task<IReadOnlyList<ScriptSummary>> SearchAsync(
string tenantId,
ScriptLanguageDto? language,
ScriptVisibilityDto? visibility,
string? search,
int limit,
int offset,
CancellationToken cancellationToken = default)
{
var result = await SearchCoreAsync(language, visibility, search, limit, offset, cancellationToken).ConfigureAwait(false);
return result.Scripts.Select(ToSummary).ToArray();
}
public async Task<int> CountAsync(
string tenantId,
ScriptLanguageDto? language,
ScriptVisibilityDto? visibility,
string? search,
CancellationToken cancellationToken = default)
{
var result = await SearchCoreAsync(language, visibility, search, limit: 1, offset: 0, cancellationToken).ConfigureAwait(false);
return result.TotalCount;
}
public async Task<ScriptDetail?> GetByIdAsync(
string tenantId,
string scriptId,
CancellationToken cancellationToken = default)
{
var script = await _registry.GetScriptAsync(scriptId, cancellationToken).ConfigureAwait(false);
return script is null ? null : ToDetail(script);
}
public async Task<ScriptDetail> CreateAsync(
string tenantId,
string actorId,
CreateScriptApiRequest request,
CancellationToken cancellationToken = default)
{
var created = await _registry.CreateScriptAsync(
new CreateScriptRequest
{
Name = request.Name,
Description = request.Description,
Language = MapLanguage(request.Language),
Content = request.Content,
EntryPoint = request.EntryPoint,
Dependencies = request.Dependencies?.Select(ToDependency).ToImmutableArray(),
Tags = request.Tags?.ToImmutableArray(),
Variables = request.Variables?.Select(ToVariable).ToImmutableArray(),
Visibility = MapVisibility(request.Visibility),
},
actorId,
ct: cancellationToken).ConfigureAwait(false);
return ToDetail(created);
}
public async Task<ScriptDetail> UpdateAsync(
string tenantId,
string actorId,
string scriptId,
UpdateScriptApiRequest request,
CancellationToken cancellationToken = default)
{
var updated = await _registry.UpdateScriptAsync(
scriptId,
new UpdateScriptRequest
{
Name = request.Name,
Description = request.Description,
Content = request.Content,
EntryPoint = request.EntryPoint,
Dependencies = request.Dependencies?.Select(ToDependency).ToImmutableArray(),
Tags = request.Tags?.ToImmutableArray(),
Variables = request.Variables?.Select(ToVariable).ToImmutableArray(),
Visibility = request.Visibility is null ? null : MapVisibility(request.Visibility.Value),
ChangeNote = request.ChangeNote,
},
actorId,
cancellationToken).ConfigureAwait(false);
return ToDetail(updated);
}
public Task<bool> DeleteAsync(
string tenantId,
string scriptId,
CancellationToken cancellationToken = default)
=> _registry.DeleteScriptAsync(scriptId, cancellationToken);
public async Task<IReadOnlyList<ScriptVersionDto>> GetVersionsAsync(
string tenantId,
string scriptId,
CancellationToken cancellationToken = default)
{
var versions = await _registry.GetScriptVersionsAsync(scriptId, cancellationToken).ConfigureAwait(false);
return versions.Select(ToVersion).ToArray();
}
public async Task<ScriptValidationResultDto> ValidateAsync(
ValidateScriptApiRequest request,
CancellationToken cancellationToken = default)
{
var validation = await _registry.ValidateAsync(
MapLanguage(request.Language),
request.Content,
cancellationToken).ConfigureAwait(false);
return new ScriptValidationResultDto(
validation.IsValid,
validation.Errors,
validation.Diagnostics.Select(ToDiagnostic).ToArray());
}
public async Task<ScriptVersionDetailDto?> GetVersionContentAsync(
string tenantId,
string scriptId,
int version,
CancellationToken cancellationToken = default)
{
var scriptVersion = await _registry.GetScriptVersionAsync(scriptId, version, cancellationToken).ConfigureAwait(false);
return scriptVersion is null ? null : ToVersionDetail(scriptVersion);
}
public async Task<CompatibilityResultDto> CheckCompatibilityAsync(
string tenantId,
string scriptId,
CheckCompatibilityRequest request,
CancellationToken cancellationToken = default)
{
var script = await _registry.GetScriptAsync(scriptId, cancellationToken).ConfigureAwait(false);
if (script is null)
{
throw new InvalidOperationException("script_not_found");
}
var result = await _compatibilityEvaluator.EvaluateAsync(
script,
new ScriptCompatibilityRequest
{
TargetType = request.TargetType,
TargetMetadata = request.TargetMetadata ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
AvailableSecrets = request.AvailableSecrets ?? Array.Empty<string>(),
},
cancellationToken).ConfigureAwait(false);
return new CompatibilityResultDto(
result.IsCompatible,
result.Issues.Select(issue => new CompatibilityIssue(issue.Category, issue.Severity, issue.Message)).ToArray());
}
private async Task<ScriptSearchResult> SearchCoreAsync(
ScriptLanguageDto? language,
ScriptVisibilityDto? visibility,
string? search,
int limit,
int offset,
CancellationToken cancellationToken)
{
return await _registry.SearchAsync(
new ScriptSearchCriteria
{
SearchText = string.IsNullOrWhiteSpace(search) ? null : search.Trim(),
Language = language is null ? null : MapLanguage(language.Value),
Visibility = visibility is null ? null : MapVisibility(visibility.Value),
Limit = Math.Max(limit, 1),
Offset = Math.Max(offset, 0),
},
cancellationToken).ConfigureAwait(false);
}
private static ScriptSummary ToSummary(Script script) => new(
script.Id,
script.Name,
script.Description,
MapLanguage(script.Language),
MapVisibility(script.Visibility),
script.Version,
script.Tags,
script.Variables.Select(ToVariableDeclaration).ToArray(),
script.OwnerId,
script.TeamId,
script.CreatedAt,
script.UpdatedAt,
script.ContentHash);
private static ScriptDetail ToDetail(Script script) => new(
script.Id,
script.Name,
script.Description,
MapLanguage(script.Language),
script.Content,
script.EntryPoint,
MapVisibility(script.Visibility),
script.Version,
script.Dependencies.Select(ToDependencyDto).ToArray(),
script.Tags,
script.Variables.Select(ToVariableDeclaration).ToArray(),
script.OwnerId,
script.TeamId,
script.CreatedAt,
script.UpdatedAt,
script.ContentHash,
script.IsSample,
script.SampleCategory);
private static ScriptVersionDto ToVersion(ScriptVersion version) => new(
version.ScriptId,
version.Version,
version.ContentHash,
version.Dependencies.Select(ToDependencyDto).ToArray(),
version.CreatedAt,
version.CreatedBy,
version.ChangeNote);
private static ScriptVersionDetailDto ToVersionDetail(ScriptVersion version) => new(
version.ScriptId,
version.Version,
version.ContentHash,
version.Content,
version.CreatedAt,
version.CreatedBy,
version.ChangeNote);
private static ScriptVariableDeclaration ToVariableDeclaration(ScriptVariable variable) => new(
variable.Name,
variable.Description,
variable.IsRequired,
variable.DefaultValue,
variable.IsSecret);
private static ScriptVariable ToVariable(ScriptVariableDeclaration variable) => new()
{
Name = variable.Name,
Description = variable.Description,
IsRequired = variable.IsRequired,
DefaultValue = variable.DefaultValue,
IsSecret = variable.IsSecret,
};
private static ScriptDependency ToDependency(ScriptDependencyDto dependency) => new()
{
Name = dependency.Name,
Version = dependency.Version,
Source = dependency.Source,
IsDevelopment = dependency.IsDevelopment,
};
private static ScriptDependencyDto ToDependencyDto(ScriptDependency dependency) => new(
dependency.Name,
dependency.Version,
dependency.Source,
dependency.IsDevelopment);
private static ScriptDiagnosticDto ToDiagnostic(ScriptDiagnostic diagnostic) => new(
diagnostic.Severity switch
{
DiagnosticSeverity.Warning => ScriptDiagnosticSeverityDto.Warning,
DiagnosticSeverity.Error => ScriptDiagnosticSeverityDto.Error,
_ => ScriptDiagnosticSeverityDto.Info,
},
diagnostic.Message,
diagnostic.Line,
diagnostic.Column,
diagnostic.EndLine,
diagnostic.EndColumn);
private static ScriptLanguage MapLanguage(ScriptLanguageDto language) => language switch
{
ScriptLanguageDto.CSharp => ScriptLanguage.CSharp,
ScriptLanguageDto.Python => ScriptLanguage.Python,
ScriptLanguageDto.Java => ScriptLanguage.Java,
ScriptLanguageDto.Go => ScriptLanguage.Go,
ScriptLanguageDto.Bash => ScriptLanguage.Bash,
ScriptLanguageDto.TypeScript => ScriptLanguage.TypeScript,
ScriptLanguageDto.PowerShell => ScriptLanguage.PowerShell,
_ => ScriptLanguage.Bash,
};
private static ScriptLanguageDto MapLanguage(ScriptLanguage language) => language switch
{
ScriptLanguage.CSharp => ScriptLanguageDto.CSharp,
ScriptLanguage.Python => ScriptLanguageDto.Python,
ScriptLanguage.Java => ScriptLanguageDto.Java,
ScriptLanguage.Go => ScriptLanguageDto.Go,
ScriptLanguage.Bash => ScriptLanguageDto.Bash,
ScriptLanguage.TypeScript => ScriptLanguageDto.TypeScript,
ScriptLanguage.PowerShell => ScriptLanguageDto.PowerShell,
_ => ScriptLanguageDto.Bash,
};
private static ScriptVisibility MapVisibility(ScriptVisibilityDto visibility) => visibility switch
{
ScriptVisibilityDto.Private => ScriptVisibility.Private,
ScriptVisibilityDto.Team => ScriptVisibility.Team,
ScriptVisibilityDto.Organization => ScriptVisibility.Organization,
ScriptVisibilityDto.Public => ScriptVisibility.Public,
_ => ScriptVisibility.Private,
};
private static ScriptVisibilityDto MapVisibility(ScriptVisibility visibility) => visibility switch
{
ScriptVisibility.Private => ScriptVisibilityDto.Private,
ScriptVisibility.Team => ScriptVisibilityDto.Team,
ScriptVisibility.Organization => ScriptVisibilityDto.Organization,
ScriptVisibility.Public => ScriptVisibilityDto.Public,
_ => ScriptVisibilityDto.Private,
};
}

View File

@@ -26,6 +26,7 @@
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.EvidenceThread\StellaOps.ReleaseOrchestrator.EvidenceThread.csproj" />
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.Environment\StellaOps.ReleaseOrchestrator.Environment.csproj" />
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.Scripts\StellaOps.ReleaseOrchestrator.Scripts.csproj" />
<ProjectReference Include="..\StellaOps.Platform.Analytics\StellaOps.Platform.Analytics.csproj" />
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />

View File

@@ -0,0 +1,113 @@
using FluentAssertions;
using NSubstitute;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using StellaOps.ReleaseOrchestrator.Scripts;
namespace StellaOps.Platform.WebService.Tests;
public sealed class ReleaseOrchestratorScriptServiceTests
{
[Fact]
public async Task SearchAsync_MapsRegistryResultsIncludingPowerShell()
{
var registry = Substitute.For<IScriptRegistry>();
var evaluator = Substitute.For<IScriptCompatibilityEvaluator>();
registry.SearchAsync(Arg.Any<ScriptSearchCriteria>(), Arg.Any<CancellationToken>())
.Returns(new ScriptSearchResult
{
Scripts =
[
new Script
{
Id = "scr-ps",
Name = "PowerShell Script",
Description = "Runs on PowerShell.",
Language = ScriptLanguage.PowerShell,
Content = "Write-Host 'ok'",
Version = 2,
Dependencies = [],
Tags = ["ops"],
Variables =
[
new ScriptVariable
{
Name = "TOKEN",
IsRequired = true,
IsSecret = true,
}
],
Visibility = ScriptVisibility.Team,
OwnerId = "admin",
CreatedAt = DateTimeOffset.Parse("2026-04-13T10:00:00Z"),
UpdatedAt = DateTimeOffset.Parse("2026-04-13T11:00:00Z"),
ContentHash = "hash",
}
],
TotalCount = 1,
Offset = 0,
Limit = 20,
});
var sut = new ReleaseOrchestratorScriptService(registry, evaluator);
var scripts = await sut.SearchAsync("tenant-a", null, null, null, 20, 0);
scripts.Should().ContainSingle();
scripts[0].Language.Should().Be(ScriptLanguageDto.PowerShell);
scripts[0].Variables.Should().ContainSingle(variable => variable.Name == "TOKEN" && variable.IsSecret);
}
[Fact]
public async Task CheckCompatibilityAsync_MapsEvaluatorIssues()
{
var registry = Substitute.For<IScriptRegistry>();
var evaluator = Substitute.For<IScriptCompatibilityEvaluator>();
registry.GetScriptAsync("scr-001", Arg.Any<CancellationToken>())
.Returns(new Script
{
Id = "scr-001",
Name = "Compatibility",
Language = ScriptLanguage.Bash,
Content = "echo hi",
Version = 1,
Dependencies = [],
Tags = [],
Variables = [],
Visibility = ScriptVisibility.Private,
OwnerId = "admin",
CreatedAt = DateTimeOffset.UtcNow,
ContentHash = "hash",
});
evaluator.EvaluateAsync(Arg.Any<Script>(), Arg.Any<ScriptCompatibilityRequest>(), Arg.Any<CancellationToken>())
.Returns(new ScriptCompatibilityResult
{
IsCompatible = false,
Issues =
[
new ScriptCompatibilityIssue
{
Category = "secret",
Severity = "error",
Message = "Required secret 'API_TOKEN' is not declared in availableSecrets.",
}
],
});
var sut = new ReleaseOrchestratorScriptService(registry, evaluator);
var result = await sut.CheckCompatibilityAsync(
"tenant-a",
"scr-001",
new CheckCompatibilityRequest
{
TargetType = "docker_host",
AvailableSecrets = [],
});
result.IsCompatible.Should().BeFalse();
result.Issues.Should().ContainSingle(issue =>
issue.Category == "secret" &&
issue.Severity == "error");
}
}

View File

@@ -138,6 +138,7 @@ internal static class ScriptsEndpoints
Language = ParseLanguageRequired(body.Language),
Content = body.Content,
Tags = body.Tags?.ToImmutableArray(),
Variables = body.Variables?.Select(ToVariable).ToImmutableArray(),
Visibility = ParseVisibilityRequired(body.Visibility),
};
@@ -178,6 +179,7 @@ internal static class ScriptsEndpoints
Description = body.Description,
Content = body.Content,
Tags = body.Tags?.ToImmutableArray(),
Variables = body.Variables?.Select(ToVariable).ToImmutableArray(),
Visibility = body.Visibility is not null ? ParseVisibilityRequired(body.Visibility) : null,
ChangeNote = body.ChangeNotes,
};
@@ -365,6 +367,15 @@ internal static class ScriptsEndpoints
updatedAt = s.UpdatedAt,
};
private static ScriptVariable ToVariable(ScriptVariableDto dto) => new()
{
Name = dto.Name,
Description = dto.Description,
IsRequired = dto.IsRequired,
DefaultValue = dto.DefaultValue,
IsSecret = dto.IsSecret,
};
// -- DTO types -----------------------------------------------------------
private sealed record CreateScriptDto

View File

@@ -324,6 +324,7 @@ public sealed record CreateScriptRequest
public string? EntryPoint { get; init; }
public ImmutableArray<ScriptDependency>? Dependencies { get; init; }
public ImmutableArray<string>? Tags { get; init; }
public ImmutableArray<ScriptVariable>? Variables { get; init; }
public ScriptVisibility Visibility { get; init; } = ScriptVisibility.Private;
}
@@ -338,6 +339,7 @@ public sealed record UpdateScriptRequest
public string? EntryPoint { get; init; }
public ImmutableArray<ScriptDependency>? Dependencies { get; init; }
public ImmutableArray<string>? Tags { get; init; }
public ImmutableArray<ScriptVariable>? Variables { get; init; }
public ScriptVisibility? Visibility { get; init; }
public string? ChangeNote { get; init; }
}

View File

@@ -69,6 +69,7 @@ public sealed class ScriptRegistry : IScriptRegistry
Version = 1,
Dependencies = request.Dependencies ?? [],
Tags = request.Tags ?? [],
Variables = request.Variables ?? [],
Visibility = request.Visibility,
OwnerId = userId,
TeamId = teamId,
@@ -146,6 +147,7 @@ public sealed class ScriptRegistry : IScriptRegistry
EntryPoint = request.EntryPoint ?? existing.EntryPoint,
Dependencies = newDependencies,
Tags = request.Tags ?? existing.Tags,
Variables = request.Variables ?? existing.Variables,
Visibility = request.Visibility ?? existing.Visibility,
Version = newVersion,
ContentHash = contentHash,

View File

@@ -0,0 +1,122 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.ReleaseOrchestrator.Scripts;
namespace StellaOps.ReleaseOrchestrator.Integration.Tests;
public sealed class ScriptRegistryVariablePersistenceTests
{
[Fact]
public async Task CreateScriptAsync_PersistsDeclaredVariables()
{
var store = new Mock<IScriptStore>(MockBehavior.Strict);
var validator = new Mock<IScriptValidator>(MockBehavior.Strict);
var indexer = new Mock<ISearchIndexer>(MockBehavior.Strict);
Script? savedScript = null;
validator.Setup(v => v.ValidateAsync(ScriptLanguage.Bash, "echo hi", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ScriptValidationResult { IsValid = true, Errors = [], Diagnostics = [] });
store.Setup(s => s.SaveAsync(It.IsAny<Script>(), It.IsAny<CancellationToken>()))
.Callback<Script, CancellationToken>((script, _) => savedScript = script)
.Returns(Task.CompletedTask);
store.Setup(s => s.SaveVersionAsync(It.IsAny<ScriptVersion>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
indexer.Setup(i => i.IndexScriptAsync(It.IsAny<Script>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var sut = new ScriptRegistry(
store.Object,
validator.Object,
indexer.Object,
TimeProvider.System,
NullLogger<ScriptRegistry>.Instance);
await sut.CreateScriptAsync(
new CreateScriptRequest
{
Name = "test",
Language = ScriptLanguage.Bash,
Content = "echo hi",
Variables =
[
new ScriptVariable
{
Name = "SERVICE_URL",
IsRequired = true,
IsSecret = false,
}
],
},
"admin");
savedScript.Should().NotBeNull();
savedScript!.Variables.Should().ContainSingle(variable => variable.Name == "SERVICE_URL");
}
[Fact]
public async Task UpdateScriptAsync_ReplacesDeclaredVariables()
{
var store = new Mock<IScriptStore>(MockBehavior.Strict);
var validator = new Mock<IScriptValidator>(MockBehavior.Strict);
var indexer = new Mock<ISearchIndexer>(MockBehavior.Strict);
Script? savedScript = null;
var existing = new Script
{
Id = "scr-001",
Name = "test",
Language = ScriptLanguage.Bash,
Content = "echo hi",
Version = 1,
Dependencies = [],
Tags = [],
Variables =
[
new ScriptVariable
{
Name = "OLD_SECRET",
IsRequired = true,
IsSecret = true,
}
],
Visibility = ScriptVisibility.Private,
OwnerId = "admin",
CreatedAt = DateTimeOffset.UtcNow,
ContentHash = "hash",
};
store.Setup(s => s.GetAsync("scr-001", It.IsAny<CancellationToken>()))
.ReturnsAsync(existing);
store.Setup(s => s.SaveAsync(It.IsAny<Script>(), It.IsAny<CancellationToken>()))
.Callback<Script, CancellationToken>((script, _) => savedScript = script)
.Returns(Task.CompletedTask);
indexer.Setup(i => i.IndexScriptAsync(It.IsAny<Script>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var sut = new ScriptRegistry(
store.Object,
validator.Object,
indexer.Object,
TimeProvider.System,
NullLogger<ScriptRegistry>.Instance);
await sut.UpdateScriptAsync(
"scr-001",
new UpdateScriptRequest
{
Variables =
[
new ScriptVariable
{
Name = "NEW_SECRET",
IsRequired = true,
IsSecret = true,
}
],
},
"admin");
savedScript.Should().NotBeNull();
savedScript!.Variables.Should().ContainSingle(variable => variable.Name == "NEW_SECRET");
}
}