using System.Net; using PnvPanel.IntegrationTests.TestSupport; using Xunit; using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper; namespace PnvPanel.IntegrationTests.Configs; [Collection(IntegrationTestCollection.Name)] public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory) { private const int Quota = 2; private const int ConcurrentAttempts = 6; private sealed record RoleResponse(Guid Id, string Name, int MaxConfigs, bool IsSystem); private sealed record NodeResponse(Guid Id, string Name); private sealed record SyncNodeResponse(int InboundsSynced, string Status); private sealed record InboundResponse(Guid Id, Guid NodeId, string RemoteInboundId, string Protocol, string Remark, int Port, bool IsPublished); private sealed record ActivationRequestResponse(Guid Id, string? Comment, DateTimeOffset CreatedAt); private sealed record MyConfigsResponse(List Configs, int MaxConfigs); /// /// Доказывает, что pg_advisory_xact_lock в CreateVpnConfigCommandHandler реально защищает /// от гонки: при параллельных запросах ровно Quota проходят, остальные — 409 QuotaExceeded. /// Это поведение невозможно проверить unit-тестами на InMemory-провайдере (см. Application.Tests). /// [Fact] public async Task ConcurrentConfigCreation_AllowsExactlyQuotaSuccesses() { using var adminClient = factory.CreateClient(); var adminToken = await LoginAsAdminAsync(adminClient); adminClient.UseBearerToken(adminToken); var rolesResponse = await adminClient.GetAsync("/api/admin/roles"); Assert.Equal(HttpStatusCode.OK, rolesResponse.StatusCode); var roles = await rolesResponse.ReadAsAsync>(); var userRole = roles!.Single(r => r.Name == "user"); var updateRoleResponse = await adminClient.SendPutJsonAsync($"/api/admin/roles/{userRole.Id}", new { maxConfigs = Quota }); Assert.Equal(HttpStatusCode.OK, updateRoleResponse.StatusCode); var registerNodeResponse = await adminClient.PostJsonAsync("/api/admin/nodes", new { name = $"QuotaNode-{Guid.NewGuid():N}"[..24], baseAddress = "https://quota-node.example.com", username = "admin", password = "node-panel-password", location = (string?)null, }); var node = await registerNodeResponse.ReadAsAsync(); var syncResponse = await adminClient.PostAsync($"/api/admin/nodes/{node!.Id}/sync", content: null); var sync = await syncResponse.ReadAsAsync(); Assert.Equal(1, sync!.InboundsSynced); var inboundsResponse = await adminClient.GetAsync($"/api/admin/inbounds?nodeId={node.Id}"); var inbound = (await inboundsResponse.ReadAsAsync>())!.Single(); var publishResponse = await adminClient.SendPutJsonAsync($"/api/admin/inbounds/{inbound.Id}/publish", new { isPublished = true, displayName = "Quota inbound", allowedRoleIds = new[] { userRole.Id }, maxClients = (int?)null, }); Assert.Equal(HttpStatusCode.OK, publishResponse.StatusCode); using var userClient = factory.CreateClient(); var userName = $"quota_{Guid.NewGuid():N}"[..20]; var (_, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123"); userClient.UseBearerToken(userToken); var activationRequestResponse = await userClient.PostJsonAsync("/api/activation/request", new { comment = (string?)null }); var activationRequest = await activationRequestResponse.ReadAsAsync(); var approveResponse = await adminClient.PostAsync($"/api/admin/activation-requests/{activationRequest!.Id}/approve", content: null); Assert.Equal(HttpStatusCode.NoContent, approveResponse.StatusCode); var tasks = Enumerable.Range(0, ConcurrentAttempts).Select(async i => { using var attemptClient = factory.CreateClient(); attemptClient.UseBearerToken(userToken); return await attemptClient.PostJsonAsync("/api/configs", new { inboundId = inbound.Id, label = $"device-{i}", deviceLimit = (int?)null, }); }); var responses = await Task.WhenAll(tasks); var succeeded = responses.Count(r => r.StatusCode == HttpStatusCode.OK); var quotaExceeded = responses.Count(r => r.StatusCode == HttpStatusCode.Conflict); Assert.Equal(Quota, succeeded); Assert.Equal(ConcurrentAttempts - Quota, quotaExceeded); var myConfigsResponse = await userClient.GetAsync("/api/configs"); var myConfigs = await myConfigsResponse.ReadAsAsync(); Assert.Equal(Quota, myConfigs!.Configs.Count); } }