using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Json; using System.Security.Claims; using System.Text; using System.Text.Json; using Azaion.E2E.Helpers; using FluentAssertions; using Microsoft.IdentityModel.Tokens; using Xunit; namespace Azaion.E2E.Tests; [Collection("E2E")] public sealed class SecurityTests { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private static readonly JsonSerializerOptions ResponseJsonOptions = new() { PropertyNameCaseInsensitive = true }; private sealed record ErrorResponse(int ErrorCode, string Message); private readonly TestFixture _fixture; public SecurityTests(TestFixture fixture) => _fixture = fixture; [Fact] public async Task Unauthenticated_requests_to_protected_endpoints_return_401() { // Arrange var baseUrl = _fixture.Settings.ApiBaseUrl; using var bare = new HttpClient { BaseAddress = new Uri(baseUrl, UriKind.Absolute), Timeout = TimeSpan.FromMinutes(5) }; using var client = new ApiClient(bare, disposeClient: false); var probeEmail = "test@x.com"; // Act & Assert using (var r = await client.GetAsync("/users/current")) r.StatusCode.Should().Be(HttpStatusCode.Unauthorized); using (var r = await client.GetAsync("/users")) r.StatusCode.Should().Be(HttpStatusCode.Unauthorized); using (var r = await bare.PostAsync("/users", new StringContent("", Encoding.UTF8, "application/json"))) r.StatusCode.Should().Be(HttpStatusCode.Unauthorized); using (var r = await client.PutAsync($"/users/{Uri.EscapeDataString(probeEmail)}/enable")) r.StatusCode.Should().Be(HttpStatusCode.Unauthorized); using (var r = await client.DeleteAsync($"/users/{Uri.EscapeDataString(probeEmail)}")) r.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] public async Task Non_admin_uploader_is_forbidden_on_admin_endpoints() { // Arrange using var client = _fixture.CreateApiClient(); using var login = await client.PostAsync("/login", new { email = _fixture.UploaderEmail, password = _fixture.UploaderPassword }); login.StatusCode.Should().Be(HttpStatusCode.OK); var loginBody = await login.Content.ReadFromJsonAsync(JsonOptions); var token = loginBody?.Token ?? throw new InvalidOperationException("Missing token."); client.SetAuthToken(token); var targetEmail = $"{Guid.NewGuid():N}@sectest.example.com"; // Act & Assert using (var r = await client.PostAsync("/users", new { email = targetEmail, password = "TestPwd12345", role = 10 })) r.StatusCode.Should().Be(HttpStatusCode.Forbidden); using (var r = await client.GetAsync("/users")) r.StatusCode.Should().Be(HttpStatusCode.Forbidden); using (var r = await client.PutAsync($"/users/{Uri.EscapeDataString(targetEmail)}/set-role/10")) r.StatusCode.Should().Be(HttpStatusCode.Forbidden); using (var r = await client.DeleteAsync($"/users/{Uri.EscapeDataString(targetEmail)}")) r.StatusCode.Should().Be(HttpStatusCode.Forbidden); } [Fact] public async Task Users_list_must_not_expose_non_empty_password_hash_in_json() { // Arrange using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); // Act using var response = await client.GetAsync("/users"); var json = await response.Content.ReadAsStringAsync(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); using var doc = JsonDocument.Parse(json); doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array); foreach (var user in doc.RootElement.EnumerateArray()) { if (!user.TryGetProperty("passwordHash", out var ph)) continue; if (ph.ValueKind == JsonValueKind.Null) continue; ph.ValueKind.Should().Be(JsonValueKind.String); (ph.GetString() ?? "").Should().BeEmpty("password hash must not be exposed in API responses"); } } [Fact] public async Task Expired_jwt_is_rejected_for_admin_endpoint() { // Arrange — sign a deliberately-expired token with the same ES256 test key // the SUT trusts. AZ-532 dropped HS256 acceptance, so this test must use the // production signing path or every "is the token expired?" check would short- // circuit on a wrong-algorithm rejection instead. using var ecdsa = JwtTestSigner.LoadActive(_fixture.JwtKeysFolder, _fixture.JwtActiveKid); var key = new ECDsaSecurityKey(ecdsa) { KeyId = _fixture.JwtActiveKid }; var creds = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256); var token = new JwtSecurityToken( issuer: "AzaionApi", audience: "Annotators/OrangePi/Admins", claims: [ new Claim(ClaimTypes.Role, "ApiAdmin"), new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "expired@x.com") ], notBefore: DateTime.UtcNow.AddHours(-3), expires: DateTime.UtcNow.AddHours(-1), signingCredentials: creds); var jwt = new JwtSecurityTokenHandler().WriteToken(token); using var client = _fixture.CreateAuthenticatedClient(jwt); // Act using var response = await client.GetAsync("/users"); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] public async Task Hardware_endpoints_are_removed_AZ_197() { // Arrange using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); // Act using var setHw = await admin.PutAsync("/users/hardware/set", new { Email = "x@y.com", Hardware = "any" }); // /resources/check no longer exists. POST /resources/{dataFolder?} (file upload, multipart-only) // matches the same path with dataFolder="check", so a JSON POST is rejected at the binding // layer with 415 instead of 404. Either is an acceptable "endpoint is gone" signal — what // matters for AC-2 is that no hardware-binding side-effect can be triggered. using var checkHw = await admin.PostAsync("/resources/check", new { Hardware = "any" }); // Assert setHw.StatusCode.Should().Be(HttpStatusCode.NotFound); checkHw.StatusCode.Should().BeOneOf(HttpStatusCode.NotFound, HttpStatusCode.UnsupportedMediaType); } [Fact] public async Task Disabled_user_cannot_log_in() { // Arrange var email = $"{Guid.NewGuid():N}@sectest.example.com"; const string password = "TestPwd12345"; try { var reg = JsonSerializer.Serialize(new { email, password, role = 10 }, JsonOptions); using (var create = await _fixture.HttpClient.PostAsync("/users", new StringContent(reg, Encoding.UTF8, "application/json"))) create.IsSuccessStatusCode.Should().BeTrue(); using (var disable = await _fixture.HttpClient.PutAsync( $"/users/{Uri.EscapeDataString(email)}/disable", null)) disable.IsSuccessStatusCode.Should().BeTrue(); using var client = _fixture.CreateApiClient(); // Act using var login = await client.PostAsync("/login", new { email, password }); // Assert login.StatusCode.Should().Be(HttpStatusCode.Conflict); var err = await login.Content.ReadFromJsonAsync(ResponseJsonOptions); err.Should().NotBeNull(); err!.ErrorCode.Should().Be(38); } finally { using var _ = await _fixture.HttpClient.DeleteAsync($"/users/{Uri.EscapeDataString(email)}"); } } private sealed class LoginTokenResponse { public string Token { get; init; } = ""; } }