feat(stability): unify readiness and recovery
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped

This commit is contained in:
AzuTear
2026-08-01 01:21:33 +02:00
parent 38282e4f7f
commit cd8c78d165
67 changed files with 2616 additions and 601 deletions
+42
View File
@@ -1,6 +1,7 @@
using System.Reflection;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Primitives;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
@@ -209,6 +210,47 @@ public sealed class AuthServiceTests
Assert.Equal(originalHash, updated.PasswordHash);
}
[Fact]
public async Task RefreshToken_SurvivesServiceRestart_WhenDatabasePersists()
{
var databaseRoot = new InMemoryDatabaseRoot();
var databaseName = Guid.NewGuid().ToString();
var configuration = new MemoryConfig(new Dictionary<string, string?>
{
["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!",
["Jwt:Issuer"] = "nexus-test",
["Jwt:Audience"] = "nexus-test-web",
});
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthService>.Instance;
AuthSession initialSession;
await using (var firstDb = new NexusDbContext(
new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(databaseName, databaseRoot)
.Options))
{
await SeedUserAsync(firstDb, "restart@example.com", "RestartPassword123!");
var firstService = new AuthService(new UserRepository(firstDb), configuration, logger);
initialSession = Assert.IsType<AuthSession>(
await firstService.LoginAsync(Login("restart@example.com", "RestartPassword123!")));
}
await using var restartedDb = new NexusDbContext(
new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(databaseName, databaseRoot)
.Options);
var restartedService = new AuthService(
new UserRepository(restartedDb),
configuration,
logger);
var refreshed = await restartedService.RefreshAsync(initialSession.RefreshToken);
Assert.NotNull(refreshed);
Assert.NotEqual(initialSession.RefreshToken, refreshed.RefreshToken);
Assert.Equal(initialSession.User.Id, refreshed.User.Id);
}
// ══════════════════════════════════════════════════════════════════
// Change Password Tests
// ══════════════════════════════════════════════════════════════════
+69
View File
@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.Controllers;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class HealthControllerTests
{
[Fact]
public async Task Ready_Returns200_WhenRequiredChecksAreHealthy()
{
using var provider = BuildProvider(HealthCheckResult.Healthy());
var controller = new HealthController(
runtime: null!,
provider.GetRequiredService<HealthCheckService>());
var result = await controller.Ready(CancellationToken.None);
Assert.Equal(StatusCodes.Status200OK, Assert.IsAssignableFrom<IStatusCodeHttpResult>(result).StatusCode);
}
[Fact]
public async Task Ready_Returns503_WhenRequiredCheckIsUnhealthy()
{
using var provider = BuildProvider(HealthCheckResult.Unhealthy("database unavailable"));
var controller = new HealthController(
runtime: null!,
provider.GetRequiredService<HealthCheckService>());
var result = await controller.Ready(CancellationToken.None);
Assert.Equal(
StatusCodes.Status503ServiceUnavailable,
Assert.IsAssignableFrom<IStatusCodeHttpResult>(result).StatusCode);
}
[Fact]
public async Task Ready_Returns503_WhenPostgreSqlCannotBeReached()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddHealthChecks()
.AddNpgSql(
"Host=127.0.0.1;Port=1;Database=nexus;Username=nexus;Password=unused;Timeout=1;Command Timeout=1",
name: "postgresql",
tags: ["database", "ready"]);
using var provider = services.BuildServiceProvider();
var controller = new HealthController(
runtime: null!,
provider.GetRequiredService<HealthCheckService>());
var result = await controller.Ready(CancellationToken.None);
Assert.Equal(
StatusCodes.Status503ServiceUnavailable,
Assert.IsAssignableFrom<IStatusCodeHttpResult>(result).StatusCode);
}
private static ServiceProvider BuildProvider(HealthCheckResult result)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddHealthChecks()
.AddCheck("required", () => result, tags: ["ready"]);
return services.BuildServiceProvider();
}
}
@@ -0,0 +1,92 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Http;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class ProblemDetailsContractTests
{
[Theory]
[InlineData(400, NexusProblemCodes.ValidationFailed)]
[InlineData(401, NexusProblemCodes.Unauthenticated)]
[InlineData(403, NexusProblemCodes.Forbidden)]
[InlineData(409, NexusProblemCodes.Conflict)]
[InlineData(429, NexusProblemCodes.RateLimited)]
[InlineData(503, NexusProblemCodes.DependencyUnavailable)]
[InlineData(504, NexusProblemCodes.Timeout)]
[InlineData(500, NexusProblemCodes.InternalError)]
public void Defaults_AddStableCodeAndTraceId(int status, string expectedCode)
{
var context = new DefaultHttpContext { TraceIdentifier = "trace-contract" };
var problem = new ProblemDetails { Status = status };
NexusProblemDetailsDefaults.Apply(problem, context);
Assert.Equal(expectedCode, problem.Extensions["code"]);
Assert.Equal("trace-contract", problem.Extensions["traceId"]);
Assert.False(string.IsNullOrWhiteSpace(problem.Title));
Assert.False(string.IsNullOrWhiteSpace(problem.Type));
}
[Fact]
public void Defaults_PreserveExplicitMachineCode()
{
var context = new DefaultHttpContext { TraceIdentifier = "trace-contract" };
var problem = new ProblemDetails { Status = 409 };
problem.Extensions["code"] = "config_hash_conflict";
NexusProblemDetailsDefaults.Apply(problem, context);
Assert.Equal("config_hash_conflict", problem.Extensions["code"]);
}
[Fact]
public void LegacyError_IsConvertedWithoutLosingTheStableContract()
{
var context = new DefaultHttpContext { TraceIdentifier = "trace-legacy" };
var problem = NexusProblemDetailsDefaults.FromLegacyError(
new { error = "Task not found." },
StatusCodes.Status404NotFound,
context);
Assert.NotNull(problem);
Assert.Equal("Task not found.", problem.Detail);
Assert.Equal(NexusProblemCodes.NotFound, problem.Extensions["code"]);
Assert.Equal("trace-legacy", problem.Extensions["traceId"]);
}
[Fact]
public void LegacySuccessPayload_IsNotConverted()
{
var context = new DefaultHttpContext();
var problem = NexusProblemDetailsDefaults.FromLegacyError(
new { error = "informational field" },
StatusCodes.Status200OK,
context);
Assert.Null(problem);
}
[Fact]
public void SharedResult_UsesProblemJsonAndPreservesTypedExtensions()
{
var result = NexusHttpResults.Problem(
StatusCodes.Status409Conflict,
"The agent file changed.",
extensions: new Dictionary<string, object?>
{
["currentHash"] = "hash-current"
});
Assert.Equal(
"application/problem+json",
Assert.IsAssignableFrom<IContentTypeHttpResult>(result).ContentType);
var problem = Assert.IsType<ProblemDetails>(
Assert.IsAssignableFrom<IValueHttpResult>(result).Value);
Assert.Equal(NexusProblemCodes.Conflict, problem.Extensions["code"]);
Assert.Equal("hash-current", problem.Extensions["currentHash"]);
}
}
+30 -1
View File
@@ -10,6 +10,7 @@ using Microsoft.Extensions.Options;
using Nexus.Api.Controllers;
using Nexus.Api.Extensions;
using Nexus.Api.Middleware;
using Nexus.Api.Security;
using Nexus.Api.Services;
using Xunit;
@@ -90,7 +91,6 @@ public sealed class SecurityBoundaryTests
}
[Theory]
[InlineData(nameof(AuthController.GetCsrfToken))]
[InlineData(nameof(AuthController.Login))]
[InlineData(nameof(AuthController.Refresh))]
[InlineData(nameof(AuthController.Logout))]
@@ -104,6 +104,7 @@ public sealed class SecurityBoundaryTests
[Theory]
[InlineData(nameof(HealthController.Live))]
[InlineData(nameof(HealthController.Ready))]
[InlineData(nameof(HealthController.Get))]
public void PublicHealthEndpoints_AreExplicitlyAnonymous(string methodName)
{
@@ -123,6 +124,34 @@ public sealed class SecurityBoundaryTests
Assert.Empty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
}
[Fact]
public void CookieBackedAuth_AllowsSameOriginBrowserRequests()
{
var context = new DefaultHttpContext();
context.Request.Scheme = "https";
context.Request.Host = new HostString("nexus.noveria.net");
context.Request.Headers.Origin = "https://nexus.noveria.net";
context.Request.Headers["Sec-Fetch-Site"] = "same-origin";
Assert.True(BrowserRequestOriginGuard.IsAllowed(context.Request));
}
[Theory]
[InlineData("https://attacker.invalid", "cross-site")]
[InlineData("https://attacker.invalid", "")]
[InlineData("not-an-origin", "same-origin")]
public void CookieBackedAuth_RejectsExplicitCrossSiteRequests(string origin, string fetchSite)
{
var context = new DefaultHttpContext();
context.Request.Scheme = "https";
context.Request.Host = new HostString("nexus.noveria.net");
context.Request.Headers.Origin = origin;
if (fetchSite.Length > 0)
context.Request.Headers["Sec-Fetch-Site"] = fetchSite;
Assert.False(BrowserRequestOriginGuard.IsAllowed(context.Request));
}
[Fact]
public async Task AgentIdentityHeader_IsRejectedWithoutVerifiedAuthentication()
{