mirror of
https://github.com/azaion/admin.git
synced 2026-04-22 08:46:34 +00:00
[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:
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Azaion.E2E</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.6.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="XunitXml.TestLogger" Version="4.0.254" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.test.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="xunit.runner.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Azaion.E2E.Helpers;
|
||||
|
||||
public sealed class ApiClient : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _disposeClient;
|
||||
|
||||
public ApiClient(HttpClient httpClient, bool disposeClient = false)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_disposeClient = disposeClient;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposeClient)
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
public void SetAuthToken(string token)
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
public async Task<string> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await PostAsync("/login", new { email, password }, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<LoginResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (body?.Token is not { Length: > 0 } t)
|
||||
throw new InvalidOperationException("Login response did not contain a token.");
|
||||
return t;
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> PostAsync<T>(string url, T body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(body, JsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
return _httpClient.PostAsync(url, content, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> GetAsync(string url, CancellationToken cancellationToken = default) =>
|
||||
_httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
public Task<HttpResponseMessage> PutAsync(string url, CancellationToken cancellationToken = default) =>
|
||||
_httpClient.PutAsync(url, null, cancellationToken);
|
||||
|
||||
public Task<HttpResponseMessage> PutAsync<T>(string url, T body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(body, JsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
return _httpClient.PutAsync(url, content, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> DeleteAsync(string url, CancellationToken cancellationToken = default) =>
|
||||
_httpClient.DeleteAsync(url, cancellationToken);
|
||||
|
||||
public Task<HttpResponseMessage> UploadFileAsync(string url, byte[] fileContent, string fileName,
|
||||
string formFieldName = "data", CancellationToken cancellationToken = default)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
var fileContentBytes = new ByteArrayContent(fileContent);
|
||||
fileContentBytes.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
content.Add(fileContentBytes, formFieldName, fileName);
|
||||
return _httpClient.PostAsync(url, content, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class LoginResponse
|
||||
{
|
||||
public string Token { get; init; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.E2E.Helpers;
|
||||
|
||||
public sealed class TestFixture : IAsyncLifetime
|
||||
{
|
||||
private string _baseUrl = "";
|
||||
|
||||
public HttpClient HttpClient { get; private set; } = null!;
|
||||
public string AdminToken { get; private set; } = "";
|
||||
public string AdminEmail { get; private set; } = "";
|
||||
public string AdminPassword { get; private set; } = "";
|
||||
public string UploaderEmail { get; private set; } = "";
|
||||
public string UploaderPassword { get; private set; } = "";
|
||||
public string JwtSecret { get; private set; } = "";
|
||||
public IConfiguration Configuration { get; private set; } = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
Configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.test.json", optional: false)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
_baseUrl = Configuration["ApiBaseUrl"]
|
||||
?? throw new InvalidOperationException("Configuration value ApiBaseUrl is required.");
|
||||
AdminEmail = Configuration["AdminEmail"]
|
||||
?? throw new InvalidOperationException("Configuration value AdminEmail is required.");
|
||||
AdminPassword = Configuration["AdminPassword"]
|
||||
?? throw new InvalidOperationException("Configuration value AdminPassword is required.");
|
||||
UploaderEmail = Configuration["UploaderEmail"]
|
||||
?? throw new InvalidOperationException("Configuration value UploaderEmail is required.");
|
||||
UploaderPassword = Configuration["UploaderPassword"]
|
||||
?? throw new InvalidOperationException("Configuration value UploaderPassword is required.");
|
||||
JwtSecret = Configuration["JwtSecret"]
|
||||
?? throw new InvalidOperationException("Configuration value JwtSecret is required.");
|
||||
|
||||
var baseUri = new Uri(_baseUrl, UriKind.Absolute);
|
||||
HttpClient = new HttpClient { BaseAddress = baseUri, Timeout = TimeSpan.FromMinutes(5) };
|
||||
|
||||
using var loginClient = CreateApiClient();
|
||||
AdminToken = await loginClient.LoginAsync(AdminEmail, AdminPassword).ConfigureAwait(false);
|
||||
HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AdminToken);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
HttpClient.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ApiClient CreateApiClient()
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = new Uri(_baseUrl, UriKind.Absolute), Timeout = TimeSpan.FromMinutes(5) };
|
||||
return new ApiClient(client, disposeClient: true);
|
||||
}
|
||||
|
||||
public ApiClient CreateAuthenticatedClient(string token)
|
||||
{
|
||||
var api = CreateApiClient();
|
||||
api.SetAuthToken(token);
|
||||
return api;
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("E2E")]
|
||||
public sealed class E2ECollection : ICollectionFixture<TestFixture>
|
||||
{
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"ApiBaseUrl": "http://system-under-test:8080",
|
||||
"AdminEmail": "admin@azaion.com",
|
||||
"AdminPassword": "Admin1234",
|
||||
"UploaderEmail": "uploader@azaion.com",
|
||||
"UploaderPassword": "Upload1234",
|
||||
"JwtSecret": "TestSecretKeyThatIsAtLeast32CharactersLong123!"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"longRunningTestSeconds": 120,
|
||||
"methodDisplay": "method"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY Azaion.E2E/Azaion.E2E.csproj Azaion.E2E/
|
||||
RUN dotnet restore Azaion.E2E/Azaion.E2E.csproj
|
||||
COPY Azaion.E2E/ Azaion.E2E/
|
||||
WORKDIR /src/Azaion.E2E
|
||||
RUN dotnet publish -c Release -o /out /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0
|
||||
WORKDIR /test
|
||||
COPY --from=build /out .
|
||||
ENTRYPOINT ["dotnet", "test", "Azaion.E2E.dll", "-c", "Release", "--no-build", "--results-directory", "/test-results", "--logger", "console;verbosity=normal", "--logger", "trx;LogFileName=results.trx", "--logger", "xunit;LogFilePath=/test-results/results.xunit.xml"]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Azaion Admin API — black-box E2E tests
|
||||
|
||||
## Run (Docker)
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from e2e-consumer
|
||||
```
|
||||
|
||||
Reports are written to `e2e/test-results/` on the host (`results.trx`, `results.xunit.xml`).
|
||||
|
||||
## Database bootstrap
|
||||
|
||||
The stock Postgres entrypoint runs every file in `/docker-entrypoint-initdb.d/` against `POSTGRES_DB` only. The scripts under `env/db/` expect different databases (`postgres` vs `azaion`), so `e2e/db-init/00_run_all.sh` runs `01_permissions.sql` on `postgres`, then `02_structure.sql`, `03_add_timestamp_columns.sql`, and `99_test_seed.sql` on `azaion`. The compose file uses `POSTGRES_USER=postgres` so `01_permissions.sql` can create roles and the `azaion` database as written.
|
||||
|
||||
`99_test_seed.sql` sets `azaion_admin` / `azaion_reader` passwords to `test_password` (matching the API connection strings) and updates seed user password hashes for `Admin1234` and `Upload1234`.
|
||||
|
||||
## Local `dotnet test` (without Docker)
|
||||
|
||||
`appsettings.test.json` targets `http://system-under-test:8080`. Running tests on the host will fail fixture setup unless you override `ApiBaseUrl` (for example via environment variables) and run the API plus Postgres yourself.
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
SQL_DIR=/docker-entrypoint-initdb.d/sql
|
||||
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres -f "$SQL_DIR/01_permissions.sql"
|
||||
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/02_structure.sql"
|
||||
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/03_add_timestamp_columns.sql"
|
||||
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/99_test_seed.sql"
|
||||
@@ -0,0 +1,10 @@
|
||||
ALTER ROLE azaion_admin WITH PASSWORD 'test_password';
|
||||
ALTER ROLE azaion_reader WITH PASSWORD 'test_password';
|
||||
|
||||
UPDATE public.users
|
||||
SET password_hash = 'elZ/nqXsL8E8T1V+9ZPb0bI4HZD0Sc7/ok9DdfxVFjQuGHj+Scya3q9wLXiX+I36'
|
||||
WHERE email = 'admin@azaion.com';
|
||||
|
||||
UPDATE public.users
|
||||
SET password_hash = '9cB4uEZlzPYisU4Dh73g+4U81rpeduPyv5Bs9nLMYzzoypEHYXQlTS4azDoVZd3l'
|
||||
WHERE email = 'uploader@azaion.com';
|
||||
Reference in New Issue
Block a user