[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
+35
View File
@@ -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>
+84
View File
@@ -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; } = "";
}
}
+72
View File
@@ -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>
{
}
+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);
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"ApiBaseUrl": "http://system-under-test:8080",
"AdminEmail": "admin@azaion.com",
"AdminPassword": "Admin1234",
"UploaderEmail": "uploader@azaion.com",
"UploaderPassword": "Upload1234",
"JwtSecret": "TestSecretKeyThatIsAtLeast32CharactersLong123!"
}
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"longRunningTestSeconds": 120,
"methodDisplay": "method"
}
+12
View File
@@ -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"]
+21
View File
@@ -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.
+7
View File
@@ -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"
+10
View File
@@ -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';
View File