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); using (var r = await client.PostAsync("/resources/get", new { password = "irrelevant1", hardware = "h", fileName = "f.bin" })) 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 var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_fixture.JwtSecret)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature); 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 Per_user_encryption_produces_distinct_ciphertext_for_same_file() { // Arrange var folder = $"sectest-{Guid.NewGuid():N}"; var fileName = $"enc-{Guid.NewGuid():N}.bin"; var payload = Encoding.UTF8.GetBytes($"secret-{Guid.NewGuid()}"); var email1 = $"{Guid.NewGuid():N}@sectest.example.com"; var email2 = $"{Guid.NewGuid():N}@sectest.example.com"; const string password = "TestPwd12345"; var hw1 = $"hw-{Guid.NewGuid():N}"; var hw2 = $"hw-{Guid.NewGuid():N}"; try { foreach (var email in new[] { email1, email2 }) { 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 adminUpload = _fixture.CreateAuthenticatedClient(_fixture.AdminToken)) { using var up = await adminUpload.UploadFileAsync($"/resources/{folder}", payload, fileName); up.IsSuccessStatusCode.Should().BeTrue(); } async Task DownloadForAsync(string email, string hardware) { using var api = _fixture.CreateApiClient(); var token = await api.LoginAsync(email, password); api.SetAuthToken(token); using var check = await api.PostAsync("/resources/check", new { hardware }); check.IsSuccessStatusCode.Should().BeTrue(); using var get = await api.PostAsync($"/resources/get/{folder}", new { password, hardware, fileName }); get.IsSuccessStatusCode.Should().BeTrue(); return await get.Content.ReadAsByteArrayAsync(); } // Act var bytes1 = await DownloadForAsync(email1, hw1); var bytes2 = await DownloadForAsync(email2, hw2); // Assert bytes1.Should().NotBeEquivalentTo(bytes2); } finally { using var clearResponse = await _fixture.HttpClient.PostAsync($"/resources/clear/{folder}", new StringContent("", Encoding.UTF8, "application/json")); foreach (var email in new[] { email1, email2 }) { using var _ = await _fixture.HttpClient.DeleteAsync($"/users/{Uri.EscapeDataString(email)}"); } } } [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; } = ""; } }