feat(api): fail-fast env parsing for ints + ENCRYPTION_KEY base64

This commit is contained in:
Claude
2026-08-28 14:28:47 +00:00
parent 35a5c4ec9b
commit f6de4c658b
3 changed files with 81 additions and 10 deletions

View File

@@ -11,24 +11,55 @@ using VolcanicCore.Persistence.Repositories;
var builder = WebApplication.CreateBuilder(args);
static string RequireOrThrow(string env, string raw, string reason)
=> throw new InvalidOperationException($"env var {env} is invalid: {reason} (got: {raw})");
// Flat-name env-var overrides for the deploy contract. Only fill in a key if
// nothing (e.g. WebFactory's UseSetting in tests) already set it — this lets
// test fixtures own their own config regardless of process env state.
foreach (var (env, prop) in new (string, string)[] {
("ADMIN_PASSWORD", nameof(AppSettings.AdminPassword)),
("DATA_DIRECTORY", nameof(AppSettings.DataDirectory)),
("ENCRYPTION_KEY", nameof(AppSettings.EncryptionKeyBase64)),
("EXECUTION_RETENTION_DAYS", nameof(AppSettings.ExecutionRetentionDays)),
("EXECUTION_KEEP_PER_WORKFLOW", nameof(AppSettings.ExecutionKeepPerWorkflow)),
("WEBHOOK_DEFAULT_TIMEOUT_SECONDS", nameof(AppSettings.WebhookDefaultTimeoutSeconds)),
("MAX_CONCURRENT_EXECUTIONS", nameof(AppSettings.MaxConcurrentExecutions)),
})
var envMap = new (string Env, string Prop, string Kind)[] {
("ADMIN_PASSWORD", nameof(AppSettings.AdminPassword), "string"),
("DATA_DIRECTORY", nameof(AppSettings.DataDirectory), "string"),
("ENCRYPTION_KEY", nameof(AppSettings.EncryptionKeyBase64), "base64-32"),
("EXECUTION_RETENTION_DAYS", nameof(AppSettings.ExecutionRetentionDays), "int"),
("EXECUTION_KEEP_PER_WORKFLOW", nameof(AppSettings.ExecutionKeepPerWorkflow), "int"),
("WEBHOOK_DEFAULT_TIMEOUT_SECONDS", nameof(AppSettings.WebhookDefaultTimeoutSeconds), "int"),
("MAX_CONCURRENT_EXECUTIONS", nameof(AppSettings.MaxConcurrentExecutions), "int"),
};
foreach (var (env, prop, _) in envMap)
{
if (!string.IsNullOrEmpty(builder.Configuration[prop])) continue;
var v = Environment.GetEnvironmentVariable(env);
if (v is not null) builder.Configuration[prop] = v;
}
// Fail-fast validation. Runs against whatever ended up in Configuration —
// covers both env vars and test UseSetting overrides.
foreach (var (env, prop, kind) in envMap)
{
var raw = builder.Configuration[prop];
if (string.IsNullOrEmpty(raw)) continue;
switch (kind)
{
case "int":
if (!int.TryParse(raw, out _))
RequireOrThrow(env, raw, "must be an integer");
break;
case "base64-32":
try
{
var bytes = Convert.FromBase64String(raw);
if (bytes.Length != 32)
RequireOrThrow(env, raw, "must decode to 32 bytes");
}
catch (FormatException)
{
RequireOrThrow(env, raw, "must be valid base64");
}
break;
}
}
var settings = new AppSettings();
builder.Configuration.Bind(settings);
builder.Services.AddSingleton(settings);

View File

@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Hosting;
namespace VolcanicCore.Tests;
public class ProgramEnvParsingTests
{
private sealed class BadIntFactory : WebFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.ExecutionRetentionDays), "not-a-number");
}
}
private sealed class BadKeyFactory : WebFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.EncryptionKeyBase64), "!!!not-base64!!!");
}
}
[Fact]
public void Bad_integer_env_fails_fast_with_named_error()
{
using var f = new BadIntFactory();
var ex = Assert.Throws<InvalidOperationException>(() => f.CreateClient());
Assert.Contains("EXECUTION_RETENTION_DAYS", ex.Message);
}
[Fact]
public void Bad_encryption_key_fails_fast_with_named_error()
{
using var f = new BadKeyFactory();
var ex = Assert.Throws<InvalidOperationException>(() => f.CreateClient());
Assert.Contains("ENCRYPTION_KEY", ex.Message);
}
}

View File

@@ -9,7 +9,7 @@ using Xunit;
namespace VolcanicCore.Tests;
public sealed class WebFactory : WebApplicationFactory<Program>
public class WebFactory : WebApplicationFactory<Program>
{
public string DataDir { get; } = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}");
public string AdminPassword { get; } = "hunter2";