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(); 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+", "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; }