Files
admin/e2e/Azaion.E2E/Tests/Cycle2HotfixDeployTests.cs
T
Oleksandr Bezdieniezhnykh f369153149 [AZ-552] [AZ-553] [AZ-554] [AZ-555] Cycle-2 hotfix: deploy/infra chain
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>
2026-05-14 09:35:57 +03:00

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;
}