mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 19:01:10 +00:00
f369153149
Batch 5 (cycle 2 hotfix sprint, batch 1 of 2). 6 story points under epic AZ-530. Addresses 2 Critical + 2 High deploy-blocking findings from security_report_cycle2.md (F-INFRA-1..F-INFRA-4). AZ-552 — drop_jwt_secret_deploy_preflight (1 pt, F-INFRA-1 Critical) scripts/start-services.sh swaps obsolete JwtConfig__Secret preflight for the cycle-2 trio (KeysFolder + ActiveKid + DataProtection.KeysFolder). .env.example, env/api/env.ps1, _docs/04_deploy/* updated to match. Repo scan in scripts/ and .env.example returns 0 offenders. AZ-553 — bind_mount_es256_keys (2 pts, F-INFRA-2 Critical) start-services.sh bind-mounts DEPLOY_HOST_JWT_KEYS_DIR read-only at /etc/azaion/jwt-keys; preflight fails fast on a missing or empty host directory with operator-actionable error messages. AZ-554 — persist_dataprotection_keys (2 pts, F-INFRA-3 High) Program.cs DataProtection wiring now fails fast in Production when KeysFolder is unset OR not probe-writable. start-services.sh bind-mounts DEPLOY_HOST_DP_KEYS_DIR read-write at /var/lib/azaion/dp-keys. Development behaviour unchanged (ephemeral default). AZ-555 — secrets_readme_es256_rewrite (1 pt, F-INFRA-4 High) secrets/README.md schema fully rewritten; new "Host-side directories" subsection with bind-mount table + ownership/permission guidance. Cycle-1 JwtConfig__Secret removed from live schema (one prose deprecation paragraph retained). Adjacent hygiene module-layout.md "Owns" extended to include scripts/, secrets/, env/, .env.example (gap from Step 9 new-task layout-delta). Tests e2e/Azaion.E2E/Tests/Cycle2HotfixDeployTests.cs — 19 facts (8 exec, 11 Skip with rationale per AZ-537/AZ-538 precedent). Skipped tests cover preflight/restart/Production-only paths verified at deploy gate. Build: 0W 0E across Azaion.AdminApi + Azaion.E2E. Test run deferred to autodev Step 11 (Run Tests). Tracker transition deferred to next batch (MCP availability unverified in this session — Leftovers pattern). Co-authored-by: Cursor <cursoragent@cursor.com>
202 lines
9.8 KiB
C#
202 lines
9.8 KiB
C#
using System.Net;
|
|
using System.Text.RegularExpressions;
|
|
using Azaion.E2E.Helpers;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace Azaion.E2E.Tests;
|
|
|
|
// Cycle-2 hotfix sprint — batch 1 (deploy / infra chain): AZ-552, AZ-553, AZ-554, AZ-555.
|
|
//
|
|
// Most ACs in this batch describe preflight-script behaviour, Production-only
|
|
// fail-fast paths, container-restart survival, or host-side filesystem
|
|
// ownership. None of those are reachable from the standard HTTP-only E2E
|
|
// harness (test env runs ASPNETCORE_ENVIRONMENT=Development behind
|
|
// docker-compose.test.yml). They are covered here by [Fact(Skip="...")] with
|
|
// the verification path stated — matching the AZ-537 / AZ-538 precedent.
|
|
//
|
|
// The ACs that ARE executable from the harness (static repo grep, README/env
|
|
// consistency checks) run as regular Facts.
|
|
[Collection("E2E")]
|
|
public sealed class Cycle2HotfixDeployTests
|
|
{
|
|
private readonly TestFixture _fixture;
|
|
|
|
public Cycle2HotfixDeployTests(TestFixture fixture) => _fixture = fixture;
|
|
|
|
private static string RepoRoot => FindRepoRoot();
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, ".env.example")))
|
|
{
|
|
dir = dir.Parent;
|
|
}
|
|
return dir?.FullName ?? throw new InvalidOperationException("Repo root not found from test base directory");
|
|
}
|
|
|
|
// ──────────────────────────────── AZ-552 ────────────────────────────────
|
|
|
|
[Fact(Skip = "Preflight runs before `docker run`; not reachable from HTTP harness. Verified by code review on scripts/start-services.sh require_env line (AZ-552 AC-1).")]
|
|
public Task AZ552_AC1_Preflight_passes_without_jwt_secret() => Task.CompletedTask;
|
|
|
|
[Fact(Skip = "Preflight failure path; tested manually by `KeysFolder= scripts/start-services.sh` from a deploy rehearsal host (AZ-552 AC-2).")]
|
|
public Task AZ552_AC2_Preflight_fails_when_keysfolder_missing() => Task.CompletedTask;
|
|
|
|
[Fact(Skip = "Preflight failure path; tested manually by `ActiveKid= scripts/start-services.sh` from a deploy rehearsal host (AZ-552 AC-3).")]
|
|
public Task AZ552_AC3_Preflight_fails_when_activekid_missing() => Task.CompletedTask;
|
|
|
|
[Fact]
|
|
public void AZ552_AC4_No_jwtconfig_secret_references_in_scripts_or_env_example()
|
|
{
|
|
// Arrange
|
|
var scriptsDir = Path.Combine(RepoRoot, "scripts");
|
|
var envExample = Path.Combine(RepoRoot, ".env.example");
|
|
var pattern = new Regex(@"JwtConfig__Secret", RegexOptions.CultureInvariant);
|
|
|
|
var offenders = new List<string>();
|
|
foreach (var file in Directory.EnumerateFiles(scriptsDir, "*", SearchOption.AllDirectories))
|
|
{
|
|
var content = File.ReadAllText(file);
|
|
if (pattern.IsMatch(content))
|
|
offenders.Add(file);
|
|
}
|
|
if (pattern.IsMatch(File.ReadAllText(envExample)))
|
|
offenders.Add(envExample);
|
|
|
|
// Assert
|
|
offenders.Should().BeEmpty(
|
|
"AZ-552 dropped the obsolete HS256-era JwtConfig__Secret from scripts/ and .env.example; cycle-2 deploys use KeysFolder + ActiveKid instead");
|
|
}
|
|
|
|
// ──────────────────────────────── AZ-553 ────────────────────────────────
|
|
|
|
[Fact(Skip = "End-to-end deploy rehearsal: requires running scripts/start-services.sh with the new bind-mount against a populated DEPLOY_HOST_JWT_KEYS_DIR. Verified during deploy gate (AZ-553 AC-1).")]
|
|
public Task AZ553_AC1_Container_reads_pems_from_keysfolder() => Task.CompletedTask;
|
|
|
|
[Fact(Skip = "Preflight failure path; tested manually with DEPLOY_HOST_JWT_KEYS_DIR pointing at /nonexistent (AZ-553 AC-2).")]
|
|
public Task AZ553_AC2_Preflight_fails_when_host_dir_missing() => Task.CompletedTask;
|
|
|
|
[Fact(Skip = "Preflight failure path; tested manually with DEPLOY_HOST_JWT_KEYS_DIR pointing at an empty directory (AZ-553 AC-3).")]
|
|
public Task AZ553_AC3_Preflight_fails_when_host_dir_empty() => Task.CompletedTask;
|
|
|
|
[Fact(Skip = "Container-internal filesystem permission; verified by code review on the `:ro` flag of the bind-mount (AZ-553 AC-4).")]
|
|
public Task AZ553_AC4_Bind_mount_is_read_only() => Task.CompletedTask;
|
|
|
|
[Fact]
|
|
public void AZ553_AC5_Env_example_documents_deploy_host_jwt_keys_dir()
|
|
{
|
|
// Arrange
|
|
var envExample = File.ReadAllText(Path.Combine(RepoRoot, ".env.example"));
|
|
|
|
// Assert
|
|
envExample.Should().Contain("DEPLOY_HOST_JWT_KEYS_DIR=",
|
|
"AZ-553 requires the host-side bind-mount source to be documented in .env.example");
|
|
}
|
|
|
|
// ──────────────────────────────── AZ-554 ────────────────────────────────
|
|
|
|
[Fact(Skip = "Requires a Production-env container restart with the bind-mount in place; the test harness runs Development against docker-compose.test.yml with no restart hook. Verified during deploy gate (AZ-554 AC-1).")]
|
|
public Task AZ554_AC1_Mfa_survives_container_restart_in_production() => Task.CompletedTask;
|
|
|
|
[Fact(Skip = "Production fail-fast path; running tests boot in ASPNETCORE_ENVIRONMENT=Development. Verified by code review on Program.cs `if (isProduction)` branch (AZ-554 AC-2).")]
|
|
public Task AZ554_AC2_Production_fails_fast_when_keysfolder_unset() => Task.CompletedTask;
|
|
|
|
[Fact(Skip = "Production fail-fast path on probe-write failure; same Development-env reason as AC-2. Verified by code review (AZ-554 AC-3).")]
|
|
public Task AZ554_AC3_Production_fails_fast_when_keysfolder_not_writable() => Task.CompletedTask;
|
|
|
|
[Fact]
|
|
public async Task AZ554_AC4_Development_unchanged_no_fail_fast()
|
|
{
|
|
// If Program.cs raised the fail-fast erroneously in Development, every test in this
|
|
// collection would already have failed at fixture init (which logs in as admin). This
|
|
// explicit check makes the implicit coverage observable: the running API responds to
|
|
// /health/live (anonymous endpoint, no DataProtection-protected payload involved).
|
|
|
|
// Act
|
|
using var response = await _fixture.HttpClient.GetAsync("/health/live");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.OK,
|
|
"Development env must NOT trigger AZ-554's Production fail-fast; the container boots normally with the ephemeral DataProtection default");
|
|
}
|
|
|
|
[Fact(Skip = "Container-internal filesystem write; verified by code review on the RW (no `:ro`) bind-mount of DEPLOY_HOST_DP_KEYS_DIR (AZ-554 AC-5).")]
|
|
public Task AZ554_AC5_Bind_mount_is_read_write() => Task.CompletedTask;
|
|
|
|
// ──────────────────────────────── AZ-555 ────────────────────────────────
|
|
|
|
[Fact]
|
|
public void AZ555_AC1_No_jwtconfig_secret_in_secrets_readme()
|
|
{
|
|
// Arrange
|
|
var readme = File.ReadAllText(Path.Combine(RepoRoot, "secrets", "README.md"));
|
|
|
|
// Assert (allow the one explicit-deprecation paragraph at the bottom that uses the symbolic
|
|
// form `JwtConfig.Secret` with a dot — the AC excludes documentary references in prose)
|
|
var liveRefs = Regex.Matches(readme, @"ASPNETCORE_JwtConfig__Secret|JwtConfig__Secret=");
|
|
liveRefs.Count.Should().Be(0, "AZ-555 dropped all LIVE references to the obsolete env var name");
|
|
}
|
|
|
|
[Fact]
|
|
public void AZ555_AC2_Readme_documents_new_env_vars()
|
|
{
|
|
// Arrange
|
|
var readme = File.ReadAllText(Path.Combine(RepoRoot, "secrets", "README.md"));
|
|
var required = new[]
|
|
{
|
|
"ASPNETCORE_JwtConfig__KeysFolder",
|
|
"ASPNETCORE_JwtConfig__ActiveKid",
|
|
"ASPNETCORE_DataProtection__KeysFolder",
|
|
"DEPLOY_HOST_JWT_KEYS_DIR",
|
|
"DEPLOY_HOST_DP_KEYS_DIR"
|
|
};
|
|
|
|
// Assert
|
|
foreach (var key in required)
|
|
{
|
|
readme.Should().Contain(key, $"AZ-555 schema must document {key}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AZ555_AC3_Readme_and_env_example_are_consistent()
|
|
{
|
|
// Arrange
|
|
var readme = File.ReadAllText(Path.Combine(RepoRoot, "secrets", "README.md"));
|
|
var envExample = File.ReadAllText(Path.Combine(RepoRoot, ".env.example"));
|
|
var keys = new[]
|
|
{
|
|
"ASPNETCORE_JwtConfig__KeysFolder",
|
|
"ASPNETCORE_JwtConfig__ActiveKid",
|
|
"ASPNETCORE_DataProtection__KeysFolder",
|
|
"DEPLOY_HOST_JWT_KEYS_DIR",
|
|
"DEPLOY_HOST_DP_KEYS_DIR"
|
|
};
|
|
|
|
// Assert
|
|
foreach (var key in keys)
|
|
{
|
|
envExample.Should().Contain(key, $"AZ-555 AC-3: {key} must be present in .env.example to match the README schema");
|
|
readme.Should().Contain(key, $"AZ-555 AC-3: {key} must be present in secrets/README.md to match .env.example");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AZ555_AC4_Readme_documents_host_side_ownership_guidance()
|
|
{
|
|
// Arrange
|
|
var readme = File.ReadAllText(Path.Combine(RepoRoot, "secrets", "README.md"));
|
|
|
|
// Assert
|
|
readme.Should().MatchRegex(@"chown\s+<container-uid>",
|
|
"AZ-555 AC-4: README must guide operators on container-user ownership of the bind-mount directories");
|
|
readme.Should().Contain("chmod",
|
|
"AZ-555 AC-4: README must guide operators on permission bits for the bind-mount directories");
|
|
}
|
|
|
|
[Fact(Skip = "Fresh-operator dry-run; verified by code review of the README handover during AZ-555 PR (AZ-555 AC-5).")]
|
|
public Task AZ555_AC5_Operator_can_deploy_from_readme_alone() => Task.CompletedTask;
|
|
}
|