[AZ-189] [AZ-190] [AZ-191] [AZ-192] [AZ-193] [AZ-194] [AZ-195] Add e2e blackbox test suite

Made-with: Cursor
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-04-16 06:25:36 +03:00
parent 1b38e888e1
commit d320d6dd59
98 changed files with 6883 additions and 1 deletions
+104
View File
@@ -0,0 +1,104 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Azaion.E2E.Helpers;
using FluentAssertions;
using Xunit;
namespace Azaion.E2E.Tests;
[Collection("E2E")]
public sealed class AuthTests
{
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private sealed record ErrorResponse(int ErrorCode, string Message);
private sealed record LoginOkResponse(string Token);
private readonly TestFixture _fixture;
public AuthTests(TestFixture fixture) => _fixture = fixture;
[Fact]
public async Task Login_with_valid_admin_credentials_returns_200_and_token()
{
// Arrange
using var client = _fixture.CreateApiClient();
// Act
using var response = await client.PostAsync("/login",
new { email = _fixture.AdminEmail, password = _fixture.AdminPassword });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<LoginOkResponse>(ResponseJsonOptions);
body.Should().NotBeNull();
body!.Token.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task Jwt_contains_expected_claims_and_lifetime()
{
// Arrange
using var client = _fixture.CreateApiClient();
// Act
using var loginResponse = await client.PostAsync("/login",
new { email = _fixture.AdminEmail, password = _fixture.AdminPassword });
var loginBody = await loginResponse.Content.ReadFromJsonAsync<LoginOkResponse>(ResponseJsonOptions);
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(loginBody!.Token);
// Assert
loginResponse.StatusCode.Should().Be(HttpStatusCode.OK);
jwt.Issuer.Should().Be("AzaionApi");
jwt.Audiences.Should().Contain("Annotators/OrangePi/Admins");
var iatSeconds = long.Parse(
jwt.Claims.Single(c => c.Type == JwtRegisteredClaimNames.Iat).Value,
System.Globalization.CultureInfo.InvariantCulture);
var expSeconds = long.Parse(
jwt.Claims.Single(c => c.Type == JwtRegisteredClaimNames.Exp).Value,
System.Globalization.CultureInfo.InvariantCulture);
TimeSpan.FromSeconds(expSeconds - iatSeconds)
.Should().BeCloseTo(TimeSpan.FromHours(4), TimeSpan.FromSeconds(60));
jwt.Claims.Should().Contain(c => c.Type == ClaimTypes.Role);
}
[Fact]
public async Task Login_with_unknown_email_returns_409_with_error_code_10()
{
// Arrange
using var client = _fixture.CreateApiClient();
// Act
using var response = await client.PostAsync("/login",
new { email = "nonexistent@example.com", password = "irrelevant" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
err.Should().NotBeNull();
err!.ErrorCode.Should().Be(10);
}
[Fact]
public async Task Login_with_wrong_password_returns_409_with_error_code_30()
{
// Arrange
using var client = _fixture.CreateApiClient();
// Act
using var response = await client.PostAsync("/login",
new { email = _fixture.AdminEmail, password = "DefinitelyWrongPassword" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
err.Should().NotBeNull();
err!.ErrorCode.Should().Be(30);
}
}
@@ -0,0 +1,164 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Azaion.E2E.Helpers;
using FluentAssertions;
using Xunit;
namespace Azaion.E2E.Tests;
[Collection("E2E")]
public sealed class HardwareBindingTests
{
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private const string TestUserPassword = "TestPass1234";
private const string SampleHardware =
"CPU: TestCPU. GPU: TestGPU. Memory: 16384. DriveSerial: TESTDRIVE001.";
private sealed record ErrorResponse(int ErrorCode, string Message);
private readonly TestFixture _fixture;
public HardwareBindingTests(TestFixture fixture) => _fixture = fixture;
[Fact]
public async Task First_hardware_check_binds_and_returns_200_true()
{
// Arrange
string? email = null;
try
{
var candidateEmail = $"hwtest-{Guid.NewGuid()}@azaion.com";
using (var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
{
using var createResp = await adminClient.PostAsync("/users",
new { Email = candidateEmail, Password = TestUserPassword, Role = 10 });
createResp.EnsureSuccessStatusCode();
}
email = candidateEmail;
using var loginClient = _fixture.CreateApiClient();
var userToken = await loginClient.LoginAsync(email, TestUserPassword);
using var userClient = _fixture.CreateAuthenticatedClient(userToken);
// Act
using var response = await userClient.PostAsync("/resources/check", new { Hardware = SampleHardware });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<bool>(ResponseJsonOptions);
body.Should().BeTrue();
}
finally
{
if (email is not null)
{
using var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var del = await adminCleanup.DeleteAsync($"/users/{Uri.EscapeDataString(email)}");
del.EnsureSuccessStatusCode();
}
}
}
[Fact]
public async Task Repeat_hardware_check_with_same_hardware_returns_200_true()
{
// Arrange
string? email = null;
try
{
var candidateEmail = $"hwtest-{Guid.NewGuid()}@azaion.com";
using (var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
{
using var createResp = await adminClient.PostAsync("/users",
new { Email = candidateEmail, Password = TestUserPassword, Role = 10 });
createResp.EnsureSuccessStatusCode();
}
email = candidateEmail;
using var loginClient = _fixture.CreateApiClient();
var userToken = await loginClient.LoginAsync(email, TestUserPassword);
using var userClient = _fixture.CreateAuthenticatedClient(userToken);
using (var first = await userClient.PostAsync("/resources/check", new { Hardware = SampleHardware }))
{
first.EnsureSuccessStatusCode();
}
// Act
using var response = await userClient.PostAsync("/resources/check", new { Hardware = SampleHardware });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<bool>(ResponseJsonOptions);
body.Should().BeTrue();
}
finally
{
if (email is not null)
{
using var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var del = await adminCleanup.DeleteAsync($"/users/{Uri.EscapeDataString(email)}");
del.EnsureSuccessStatusCode();
}
}
}
[Fact]
public async Task Hardware_mismatch_returns_409_with_error_code_40()
{
// Arrange
const string hardwareA = "HARDWARE_A";
const string hardwareB = "HARDWARE_B";
string? email = null;
try
{
var candidateEmail = $"hwtest-{Guid.NewGuid()}@azaion.com";
using (var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
{
using var createResp = await adminClient.PostAsync("/users",
new { Email = candidateEmail, Password = TestUserPassword, Role = 10 });
createResp.EnsureSuccessStatusCode();
}
email = candidateEmail;
using var loginClient = _fixture.CreateApiClient();
var userToken = await loginClient.LoginAsync(email, TestUserPassword);
using var userClient = _fixture.CreateAuthenticatedClient(userToken);
using (var bind = await userClient.PostAsync("/resources/check", new { Hardware = hardwareA }))
{
bind.EnsureSuccessStatusCode();
}
// Act
using var response = await userClient.PostAsync("/resources/check", new { Hardware = hardwareB });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
err.Should().NotBeNull();
err!.ErrorCode.Should().Be(40);
}
finally
{
if (email is not null)
{
using var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var del = await adminCleanup.DeleteAsync($"/users/{Uri.EscapeDataString(email)}");
del.EnsureSuccessStatusCode();
}
}
}
}
+242
View File
@@ -0,0 +1,242 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Azaion.E2E.Helpers;
using FluentAssertions;
using Xunit;
namespace Azaion.E2E.Tests;
[Collection("E2E")]
public sealed class ResilienceTests
{
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private const string TestUserPassword = "TestPass1234";
private const string MalformedJwtUnsigned =
"eyJhbGciOiJub25lIn0.eyJ0ZXN0IjoiMSJ9.";
private readonly TestFixture _fixture;
public ResilienceTests(TestFixture fixture) => _fixture = fixture;
[Fact(Skip = "Requires Docker container control to stop/restart test-db")]
public void Db_stop_and_restart_recovery_within_10s()
{
// Arrange
// Would: stop the test-db container (docker stop test-db).
// Would: call a health or protected endpoint until API returns errors (e.g. 503/500) or connection failure.
// Act
// Would: start test-db again (docker start test-db).
// Assert
// Would: poll API until successful response within 10 seconds after DB is up.
}
[Fact]
public async Task Malformed_authorization_headers_return_401_and_system_remains_operational()
{
// Arrange
var baseUrl = _fixture.Configuration["ApiBaseUrl"]
?? throw new InvalidOperationException("ApiBaseUrl is required.");
var headers = new[]
{
"Bearer invalidtoken123",
$"Bearer {MalformedJwtUnsigned}",
"NotBearer somevalue",
"Bearer "
};
using var http = new HttpClient { BaseAddress = new Uri(baseUrl, UriKind.Absolute), Timeout = TimeSpan.FromMinutes(5) };
// Act
foreach (var h in headers)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/users/current");
request.Headers.TryAddWithoutValidation("Authorization", h);
using var response = await http.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
// Arrange
using var client = _fixture.CreateApiClient();
// Act
var token = await client.LoginAsync(_fixture.AdminEmail, _fixture.AdminPassword);
// Assert
token.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task Concurrent_hardware_binding_same_hardware_has_no_500_and_state_consistent()
{
// Arrange
string? email = null;
var hardware =
$"CPU: ConcCPU. GPU: ConcGPU. Memory: 8192. DriveSerial: {Guid.NewGuid():N}.";
try
{
var candidateEmail = $"resilience-hw-{Guid.NewGuid()}@azaion.com";
using (var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
{
using var createResp = await adminClient.PostAsync("/users",
new { Email = candidateEmail, Password = TestUserPassword, Role = 10 });
createResp.EnsureSuccessStatusCode();
}
email = candidateEmail;
using var loginClient = _fixture.CreateApiClient();
var userToken = await loginClient.LoginAsync(email, TestUserPassword);
using var userClient = _fixture.CreateAuthenticatedClient(userToken);
// Act
var concurrentTasks = Enumerable.Range(0, 5)
.Select(_ => userClient.PostAsync("/resources/check", new { Hardware = hardware }))
.ToArray();
var concurrentResponses = await Task.WhenAll(concurrentTasks);
// Assert
foreach (var r in concurrentResponses)
{
using (r)
{
r.StatusCode.Should().NotBe(HttpStatusCode.InternalServerError);
}
}
// Act
using var followUp = await userClient.PostAsync("/resources/check", new { Hardware = hardware });
// Assert
followUp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await followUp.Content.ReadFromJsonAsync<bool>(ResponseJsonOptions);
body.Should().BeTrue();
}
finally
{
if (email is not null)
{
using var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var del = await adminCleanup.DeleteAsync($"/users/{Uri.EscapeDataString(email)}");
del.EnsureSuccessStatusCode();
}
}
}
[Fact]
public async Task Login_p95_latency_under_500ms_after_warmup()
{
// Arrange
using var client = _fixture.CreateApiClient();
for (var i = 0; i < 5; i++)
{
using var w = await client.PostAsync("/login",
new { email = _fixture.UploaderEmail, password = _fixture.UploaderPassword });
w.EnsureSuccessStatusCode();
}
var samples = new List<double>(100);
// Act
for (var i = 0; i < 100; i++)
{
var sw = Stopwatch.StartNew();
using var resp = await client.PostAsync("/login",
new { email = _fixture.UploaderEmail, password = _fixture.UploaderPassword });
sw.Stop();
resp.EnsureSuccessStatusCode();
samples.Add(sw.Elapsed.TotalMilliseconds);
}
var sorted = samples.OrderBy(x => x).ToArray();
var p95Index = (int)Math.Ceiling(0.95 * sorted.Length) - 1;
if (p95Index < 0)
p95Index = 0;
var p95 = sorted[p95Index];
// Assert
p95.Should().BeLessThan(500);
}
[Fact]
[Trait("Category", "ResourceLimit")]
public async Task Max_file_upload_200_mb_accepted()
{
// Arrange
const string folder = "testfolder";
const string fileName = "max.bin";
var payload = new byte[200 * 1024 * 1024];
try
{
using var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
// Act
using var response = await adminClient.UploadFileAsync($"/resources/{folder}", payload, fileName);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
finally
{
using var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var clear = await adminCleanup.PostAsync($"/resources/clear/{folder}", new { });
clear.EnsureSuccessStatusCode();
}
}
[Fact]
[Trait("Category", "ResourceLimit")]
public async Task Over_max_upload_201_mb_rejected_or_connection_aborted()
{
// Arrange
const string folder = "testfolder";
const string fileName = "over.bin";
var payload = new byte[201 * 1024 * 1024];
using var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
// Act
var outcome = await TryUploadAsync(adminClient, $"/resources/{folder}", payload, fileName);
// Assert
outcome.Acceptable.Should().BeTrue();
using (var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
{
using var clear = await adminCleanup.PostAsync($"/resources/clear/{folder}", new { });
clear.EnsureSuccessStatusCode();
}
}
private static async Task<(bool Acceptable, HttpStatusCode? Status)> TryUploadAsync(
ApiClient adminClient, string url, byte[] payload, string fileName)
{
try
{
using var response = await adminClient.UploadFileAsync(url, payload, fileName);
if (response.StatusCode == HttpStatusCode.RequestEntityTooLarge)
return (true, response.StatusCode);
return (false, response.StatusCode);
}
catch (Exception ex) when (IsConnectionRelated(ex))
{
return (true, null);
}
}
private static bool IsConnectionRelated(Exception ex)
{
if (ex is HttpRequestException or TaskCanceledException or IOException)
return true;
return ex.InnerException is not null && IsConnectionRelated(ex.InnerException);
}
}
+216
View File
@@ -0,0 +1,216 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Azaion.E2E.Helpers;
using FluentAssertions;
using Xunit;
namespace Azaion.E2E.Tests;
[Collection("E2E")]
public sealed class ResourceTests
{
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private const string TestUserPassword = "TestPass1234";
private const string SampleHardware =
"CPU: TestCPU. GPU: TestGPU. Memory: 16384. DriveSerial: TESTDRIVE001.";
private sealed record ErrorResponse(int ErrorCode, string Message);
private readonly TestFixture _fixture;
public ResourceTests(TestFixture fixture) => _fixture = fixture;
[Fact]
public async Task File_upload_succeeds()
{
// Arrange
var folder = $"restest-{Guid.NewGuid():N}";
var fileBytes = Encoding.UTF8.GetBytes(new string('a', 100));
try
{
using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
// Act
using var response = await admin.UploadFileAsync($"/resources/{folder}", fileBytes, "upload.txt");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
finally
{
using var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var clear = await adminCleanup.PostAsync($"/resources/clear/{folder}", new { });
clear.EnsureSuccessStatusCode();
}
}
[Fact]
public async Task Encrypted_download_returns_octet_stream_and_non_empty_body()
{
// Arrange
var folder = $"restest-{Guid.NewGuid():N}";
const string fileName = "secure.bin";
var fileBytes = Encoding.UTF8.GetBytes("download-test-payload");
string? email = null;
try
{
using (var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
{
using var upload = await admin.UploadFileAsync($"/resources/{folder}", fileBytes, fileName);
upload.EnsureSuccessStatusCode();
}
var candidateEmail = $"restest-{Guid.NewGuid():N}@azaion.com";
using (var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
{
using var createResp = await admin.PostAsync("/users",
new { Email = candidateEmail, Password = TestUserPassword, Role = 10 });
createResp.EnsureSuccessStatusCode();
}
email = candidateEmail;
using var loginClient = _fixture.CreateApiClient();
var userToken = await loginClient.LoginAsync(email, TestUserPassword);
using var userClient = _fixture.CreateAuthenticatedClient(userToken);
using (var bind = await userClient.PostAsync("/resources/check", new { Hardware = SampleHardware }))
{
bind.EnsureSuccessStatusCode();
}
// Act
using var response = await userClient.PostAsync($"/resources/get/{folder}",
new { Password = TestUserPassword, Hardware = SampleHardware, FileName = fileName });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/octet-stream");
var body = await response.Content.ReadAsByteArrayAsync();
body.Should().NotBeEmpty();
}
finally
{
if (email is not null)
{
using var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var del = await adminCleanup.DeleteAsync($"/users/{Uri.EscapeDataString(email)}");
del.EnsureSuccessStatusCode();
}
using var adminClear = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var clear = await adminClear.PostAsync($"/resources/clear/{folder}", new { });
clear.EnsureSuccessStatusCode();
}
}
[Fact]
public async Task Encryption_round_trip_decrypt_matches_original_bytes()
{
// Arrange
var folder = $"restest-{Guid.NewGuid():N}";
const string fileName = "roundtrip.bin";
var original = Enumerable.Range(0, 128).Select(i => (byte)i).ToArray();
const string password = "RoundTrip1!";
const string hardware = "RT-HW-CPU-001-GPU-002";
string? email = null;
try
{
using (var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
{
using var upload = await admin.UploadFileAsync($"/resources/{folder}", original, fileName);
upload.EnsureSuccessStatusCode();
}
var candidateEmail = $"roundtrip-{Guid.NewGuid():N}@azaion.com";
using (var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
{
using var createResp = await admin.PostAsync("/users",
new { Email = candidateEmail, Password = password, Role = 10 });
createResp.EnsureSuccessStatusCode();
}
email = candidateEmail;
using var loginClient = _fixture.CreateApiClient();
var userToken = await loginClient.LoginAsync(email, password);
using var userClient = _fixture.CreateAuthenticatedClient(userToken);
using (var bind = await userClient.PostAsync("/resources/check", new { Hardware = hardware }))
{
bind.EnsureSuccessStatusCode();
}
// Act
using var download = await userClient.PostAsync($"/resources/get/{folder}",
new { Password = password, Hardware = hardware, FileName = fileName });
download.EnsureSuccessStatusCode();
var encrypted = await download.Content.ReadAsByteArrayAsync();
var decrypted = DecryptResourcePayload(encrypted, email!, password, hardware);
// Assert
decrypted.Should().Equal(original);
}
finally
{
if (email is not null)
{
using var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var del = await adminCleanup.DeleteAsync($"/users/{Uri.EscapeDataString(email)}");
del.EnsureSuccessStatusCode();
}
using var adminClear = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var clear = await adminClear.PostAsync($"/resources/clear/{folder}", new { });
clear.EnsureSuccessStatusCode();
}
}
[Fact]
public async Task Upload_without_file_is_rejected_with_400_or_409_and_60_on_conflict()
{
// Arrange
var folder = $"restest-{Guid.NewGuid():N}";
using var content = new MultipartFormDataContent();
// Act
using var response = await _fixture.HttpClient.PostAsync($"/resources/{folder}", content);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.Conflict);
if (response.StatusCode == HttpStatusCode.Conflict)
{
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
err.Should().NotBeNull();
err!.ErrorCode.Should().Be(60);
}
}
private static byte[] DecryptResourcePayload(byte[] encrypted, string email, string password, string hardware)
{
var hwHash = Convert.ToBase64String(SHA384.HashData(
Encoding.UTF8.GetBytes($"Azaion_{hardware}_%$$$)0_")));
var apiKey = Convert.ToBase64String(SHA384.HashData(
Encoding.UTF8.GetBytes($"{email}-{password}-{hwHash}-#%@AzaionKey@%#---")));
var aesKey = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey));
if (encrypted.Length <= 16)
throw new InvalidOperationException("Encrypted payload too short.");
using var aes = Aes.Create();
aes.Key = aesKey;
aes.IV = encrypted.AsSpan(0, 16).ToArray();
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
return decryptor.TransformFinalBlock(encrypted, 16, encrypted.Length - 16);
}
}
+234
View File
@@ -0,0 +1,234 @@
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 readonly TestFixture _fixture;
public SecurityTests(TestFixture fixture) => _fixture = fixture;
[Fact]
public async Task Unauthenticated_requests_to_protected_endpoints_return_401()
{
// Arrange
var baseUrl = _fixture.Configuration["ApiBaseUrl"]
?? throw new InvalidOperationException("ApiBaseUrl is required.");
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<LoginTokenResponse>(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 = "TestPwd1234", 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 = "TestPwd1234";
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<byte[]> 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 = "TestPwd1234";
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().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.Conflict);
}
finally
{
using var _ = await _fixture.HttpClient.DeleteAsync($"/users/{Uri.EscapeDataString(email)}");
}
}
private sealed class LoginTokenResponse
{
public string Token { get; init; } = "";
}
}
+221
View File
@@ -0,0 +1,221 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Azaion.E2E.Helpers;
using FluentAssertions;
using Xunit;
namespace Azaion.E2E.Tests;
[Collection("E2E")]
public sealed class UserManagementTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private sealed record ErrorResponse(int ErrorCode, string Message);
private sealed record UserDto(Guid Id, string Email, int Role, bool IsEnabled);
private readonly TestFixture _fixture;
public UserManagementTests(TestFixture fixture) => _fixture = fixture;
private static string UserBasePath(string email) => $"/users/{Uri.EscapeDataString(email)}";
[Fact]
public async Task Registration_with_valid_data_succeeds()
{
var email = $"testuser-{Guid.NewGuid():N}@azaion.com";
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
try
{
// Arrange
var body = new { email, password = "SecurePass1", role = 10 };
// Act
using var response = await client.PostAsync("/users", body);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
}
finally
{
using var deleteResponse = await client.DeleteAsync(UserBasePath(email));
}
}
[Fact]
public async Task List_users_returns_non_empty_array()
{
// Arrange
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
// Act
using var response = await client.GetAsync("/users");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var users = await response.Content.ReadFromJsonAsync<UserDto[]>(JsonOptions);
users.Should().NotBeNull();
users!.Length.Should().BeGreaterThanOrEqualTo(1);
}
[Fact]
public async Task List_users_filtered_by_email_contains_only_matches()
{
// Arrange
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
// Act
using var response = await client.GetAsync("/users?searchEmail=" + Uri.EscapeDataString("admin"));
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var users = await response.Content.ReadFromJsonAsync<UserDto[]>(JsonOptions);
users.Should().NotBeNull();
users!.Should().NotBeEmpty();
users.Should().OnlyContain(u => u.Email.Contains("admin", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Set_user_role_succeeds()
{
var email = $"testuser-{Guid.NewGuid():N}@azaion.com";
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
try
{
// Arrange
using (var createResp = await client.PostAsync("/users", new { email, password = "SecurePass1", role = 10 }))
{
createResp.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
}
// Act
using var response = await client.PutAsync($"{UserBasePath(email)}/set-role/50");
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
}
finally
{
using var deleteResponse = await client.DeleteAsync(UserBasePath(email));
}
}
[Fact]
public async Task Disable_user_succeeds()
{
var email = $"testuser-{Guid.NewGuid():N}@azaion.com";
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
try
{
// Arrange
using (var createResp = await client.PostAsync("/users", new { email, password = "SecurePass1", role = 10 }))
{
createResp.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
}
// Act
using var response = await client.PutAsync($"{UserBasePath(email)}/disable");
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
}
finally
{
using var deleteResponse = await client.DeleteAsync(UserBasePath(email));
}
}
[Fact]
public async Task Delete_user_succeeds_and_user_not_in_search_results()
{
var email = $"testuser-{Guid.NewGuid():N}@azaion.com";
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
try
{
// Arrange
using (var createResp = await client.PostAsync("/users", new { email, password = "SecurePass1", role = 10 }))
{
createResp.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
}
// Act
using var deleteResponse = await client.DeleteAsync(UserBasePath(email));
// Assert
deleteResponse.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
using var verifyResponse = await client.GetAsync("/users?searchEmail=" + Uri.EscapeDataString(email));
verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var users = await verifyResponse.Content.ReadFromJsonAsync<UserDto[]>(JsonOptions);
users.Should().NotBeNull();
users!.Should().NotContain(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
}
finally
{
using var deleteResponse = await client.DeleteAsync(UserBasePath(email));
}
}
[Fact]
public async Task Registration_rejects_short_email_with_400()
{
// Arrange
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
// Act
using var response = await client.PostAsync("/users",
new { email = "ab@c.de", password = "ValidPass1", role = 10 });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Registration_rejects_invalid_email_format_with_400()
{
// Arrange
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
// Act
using var response = await client.PostAsync("/users",
new { email = "notavalidemail", password = "ValidPass1", role = 10 });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Registration_rejects_short_password_with_400()
{
// Arrange
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
// Act
using var response = await client.PostAsync("/users",
new { email = "validmail@test.com", password = "short", role = 10 });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Registration_rejects_duplicate_admin_email_with_409()
{
// Arrange
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
// Act
using var response = await client.PostAsync("/users",
new { email = _fixture.AdminEmail, password = "DuplicateP1", role = 10 });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(JsonOptions);
err.Should().NotBeNull();
err!.ErrorCode.Should().Be(20);
}
}