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
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:
@@ -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
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user