diff --git a/food-market.sln b/food-market.sln index 478408a..540d9bc 100644 --- a/food-market.sln +++ b/food-market.sln @@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F29E3026 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.UnitTests", "tests\food-market.UnitTests\food-market.UnitTests.csproj", "{D8556BE1-70E3-49AD-84FD-209C80B17B57}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.IntegrationTests", "tests\food-market.IntegrationTests\food-market.IntegrationTests.csproj", "{9122ECF4-9111-40B3-BB59-ED7C112FF575}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -64,6 +66,10 @@ Global {D8556BE1-70E3-49AD-84FD-209C80B17B57}.Debug|Any CPU.Build.0 = Debug|Any CPU {D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.ActiveCfg = Release|Any CPU {D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.Build.0 = Release|Any CPU + {9122ECF4-9111-40B3-BB59-ED7C112FF575}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9122ECF4-9111-40B3-BB59-ED7C112FF575}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} @@ -74,5 +80,6 @@ Global {BF3FBFD2-F40D-4510-8067-37305FFE1D14} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} {B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} {D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28} + {9122ECF4-9111-40B3-BB59-ED7C112FF575} = {F29E3026-31A5-4277-A265-081E87C76A28} EndGlobalSection EndGlobal diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 7e1cd41..6e94253 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -323,3 +323,7 @@ }), }); } + +// Делает сгенерированный из top-level statements класс Program видимым для +// интеграционных тестов (WebApplicationFactory<Program>). +public partial class Program; diff --git a/tests/food-market.IntegrationTests/PermissionTests.cs b/tests/food-market.IntegrationTests/PermissionTests.cs new file mode 100644 index 0000000..bca291e --- /dev/null +++ b/tests/food-market.IntegrationTests/PermissionTests.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class PermissionTests +{ + private readonly ApiFactory _factory; + public PermissionTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Custom_role_without_ProductsEdit_gets_403_on_put_product() + { + var admin = new ApiActor(_factory.CreateClient()); + await admin.SignupAndLoginAsync($"permadmin-{Guid.NewGuid():N}"); + + // Кастомная роль: просмотр товаров, но БЕЗ права правки. + var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new + { + name = $"Viewer-{Guid.NewGuid():N}", + description = "view only", + permissions = new { productsView = true, productsEdit = false }, + }); + roleResp.EnsureSuccessStatusCode(); + var roleId = (await roleResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + // Сотрудник с логином на этой роли. + var empEmail = $"viewer-{Guid.NewGuid():N}@example.kz"; + var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new + { + lastName = "Просмотров", firstName = "Вью", roleId, + isActive = true, createAccount = true, email = empEmail, + }); + empResp.EnsureSuccessStatusCode(); + var pwd = (await empResp.Content.ReadFromJsonAsync()).GetProperty("generatedPassword").GetString()!; + + var viewer = new ApiActor(_factory.CreateClient()); + viewer.UseToken(await viewer.TokenAsync(empEmail, pwd)); + + // Авторизация проверяется до биндинга тела: несуществующий id → 403, если нет права. + using var put = await viewer.Http.PutAsJsonAsync($"/api/catalog/products/{Guid.NewGuid()}", new { }); + put.StatusCode.Should().Be(HttpStatusCode.Forbidden); + + // Чтение (ProductsView) разрешено. + using var get = await viewer.Http.GetAsync("/api/catalog/products"); + get.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Admin_passes_permission_gate() + { + var admin = new ApiActor(_factory.CreateClient()); + await admin.SignupAndLoginAsync($"permadmin2-{Guid.NewGuid():N}"); + + // Админ проходит permission-гейт (дальше — 404/400 на несуществующем товаре, но НЕ 403). + using var put = await admin.Http.PutAsJsonAsync($"/api/catalog/products/{Guid.NewGuid()}", new { }); + put.StatusCode.Should().NotBe(HttpStatusCode.Forbidden); + } +} diff --git a/tests/food-market.IntegrationTests/RetailOversellingTests.cs b/tests/food-market.IntegrationTests/RetailOversellingTests.cs new file mode 100644 index 0000000..af49abd --- /dev/null +++ b/tests/food-market.IntegrationTests/RetailOversellingTests.cs @@ -0,0 +1,70 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class RetailOversellingTests +{ + private readonly ApiFactory _factory; + public RetailOversellingTests(ApiFactory factory) => _factory = factory; + + private static string RandomBarcode() + => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); + + [Fact] + public async Task Posting_sale_above_available_stock_is_rejected() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"oversell-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", retailPrice: 100m, RandomBarcode()); + + // Остатка нет (приёмок не делали). Продаём 5 шт. paidCash покрывает итог, + // чтобы дойти до проверки остатка (а не упасть на валидации оплаты). + var draftResp = await api.Http.PostAsJsonAsync("/api/sales/retail", new + { + date = DateTime.UtcNow, + storeId = refs.StoreId, + retailPointId = (string?)null, + customerId = (string?)null, + currencyId = refs.CurrencyId, + payment = 0, + paidCash = 500m, + paidCard = 0m, + notes = "oversell", + lines = new[] { new { productId, quantity = 5m, unitPrice = 100m, discount = 0m, vatPercent = 0m } }, + }); + draftResp.EnsureSuccessStatusCode(); + var saleId = (await draftResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + using var post = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }); + ((int)post.StatusCode).Should().Be(409, "продажа сверх остатка должна блокироваться"); + } + + [Fact] + public async Task Posting_sale_with_underpayment_is_rejected() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"underpay-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + // Итог 200, оплачено 100 → недоплата → 400 (до проверки остатка). + var draftResp = await api.Http.PostAsJsonAsync("/api/sales/retail", new + { + date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId = (string?)null, + customerId = (string?)null, currencyId = refs.CurrencyId, + payment = 0, paidCash = 100m, paidCard = 0m, notes = "underpay", + lines = new[] { new { productId, quantity = 2m, unitPrice = 100m, discount = 0m, vatPercent = 0m } }, + }); + draftResp.EnsureSuccessStatusCode(); + var saleId = (await draftResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + using var post = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }); + ((int)post.StatusCode).Should().Be(400, "недоплата должна блокировать проведение"); + } +} diff --git a/tests/food-market.IntegrationTests/SignupFlowTests.cs b/tests/food-market.IntegrationTests/SignupFlowTests.cs new file mode 100644 index 0000000..ac18607 --- /dev/null +++ b/tests/food-market.IntegrationTests/SignupFlowTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class SignupFlowTests +{ + private readonly ApiFactory _factory; + public SignupFlowTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Signup_then_login_yields_token_with_org() + { + var actor = new ApiActor(_factory.CreateClient()); + var email = $"signup-{Guid.NewGuid():N}@example.kz"; + const string password = "Passw0rd!"; + + var signup = await actor.SignupAsync(email, password, "Signup Org"); + signup.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = await actor.TokenAsync(email, password); + token.Should().NotBeNullOrEmpty(); + actor.UseToken(token); + + var me = await actor.GetJsonAsync("/api/me"); + me.GetProperty("email").GetString().Should().Be(email); + me.GetProperty("orgId").GetString().Should().NotBeNullOrEmpty(); + me.GetProperty("hasLiveOrg").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task Duplicate_signup_is_rejected() + { + var actor = new ApiActor(_factory.CreateClient()); + var email = $"dup-{Guid.NewGuid():N}@example.kz"; + + (await actor.SignupAsync(email, "Passw0rd!", "Dup Org 1")).StatusCode.Should().Be(HttpStatusCode.OK); + var second = await actor.SignupAsync(email, "Passw0rd!", "Dup Org 2"); + second.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Weak_password_is_rejected() + { + var actor = new ApiActor(_factory.CreateClient()); + var resp = await actor.SignupAsync($"weak-{Guid.NewGuid():N}@example.kz", "short", "Weak Org"); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} diff --git a/tests/food-market.IntegrationTests/SupplyPostUnpostTests.cs b/tests/food-market.IntegrationTests/SupplyPostUnpostTests.cs new file mode 100644 index 0000000..ba5e942 --- /dev/null +++ b/tests/food-market.IntegrationTests/SupplyPostUnpostTests.cs @@ -0,0 +1,79 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class SupplyPostUnpostTests +{ + private readonly ApiFactory _factory; + public SupplyPostUnpostTests(ApiFactory factory) => _factory = factory; + + private static string RandomBarcode() + => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); + + [Fact] + public async Task Posting_supply_raises_stock_and_sets_cost_unpost_reverts() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"supply-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var supplierId = await api.CreateCounterpartyAsync($"Supplier-{Guid.NewGuid():N}"); + var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", retailPrice: 150m, RandomBarcode()); + + (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m, "новый товар без приёмок"); + + // Draft supply: 10 шт по 70. + var draftResp = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new + { + date = DateTime.UtcNow, + supplierId, + storeId = refs.StoreId, + currencyId = refs.CurrencyId, + notes = "integration", + lines = new[] { new { productId, quantity = 10m, unitPrice = 70m } }, + }); + draftResp.EnsureSuccessStatusCode(); + var supplyId = (await draftResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + // Post → остаток 10, себестоимость 70 (скользящее среднее с нуля). + using var post = await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { }); + post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}"); + + (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m); + var product = await api.GetJsonAsync($"/api/catalog/products/{productId}"); + product.GetProperty("cost").GetDecimal().Should().Be(70m); + + // Unpost → остаток возвращается к 0. + using var unpost = await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/unpost", new { }); + unpost.IsSuccessStatusCode.Should().BeTrue($"unpost вернул {(int)unpost.StatusCode}: {await unpost.Content.ReadAsStringAsync()}"); + + (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m); + } + + [Fact] + public async Task Double_post_is_rejected() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"dblpost-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var supplierId = await api.CreateCounterpartyAsync($"Supplier-{Guid.NewGuid():N}"); + var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 150m, RandomBarcode()); + + var draftResp = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new + { + date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "dbl", lines = new[] { new { productId, quantity = 3m, unitPrice = 50m } }, + }); + draftResp.EnsureSuccessStatusCode(); + var supplyId = (await draftResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + (await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { })) + .IsSuccessStatusCode.Should().BeTrue(); + using var second = await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { }); + ((int)second.StatusCode).Should().Be(409); + } +} diff --git a/tests/food-market.IntegrationTests/Support/ApiActor.cs b/tests/food-market.IntegrationTests/Support/ApiActor.cs new file mode 100644 index 0000000..47aa506 --- /dev/null +++ b/tests/food-market.IntegrationTests/Support/ApiActor.cs @@ -0,0 +1,152 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace foodmarket.IntegrationTests.Support; + +/// Тонкая обёртка над HttpClient для интеграционных тестов: логин, +/// JSON-хелперы, выборка справочников орг-бутстрапа. Один экземпляр = один +/// «актор» (свой токен/орг). +public sealed class ApiActor +{ + public HttpClient Http { get; } + + public ApiActor(HttpClient http) => Http = http; + + public void UseToken(string token) + => Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // ── Auth ──────────────────────────────────────────────────────────────── + public Task SignupAsync(string email, string password, string orgName, string phone = "+77001234567") + => Http.PostAsJsonAsync("/api/auth/signup", + new { email, password, organizationName = orgName, phone, plan = (string?)null }); + + public async Task TokenAsync(string email, string password) + { + using var resp = await Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["username"] = email, + ["password"] = password, + ["client_id"] = "food-market-web", + ["scope"] = "openid profile email roles api offline_access", + })); + resp.EnsureSuccessStatusCode(); + return (await resp.Content.ReadFromJsonAsync()).GetProperty("access_token").GetString()!; + } + + /// Регистрирует орг, логинит её администратора и проставляет токен. + /// Возвращает email/пароль администратора. + public async Task<(string Email, string Password)> SignupAndLoginAsync(string slug) + { + var email = $"{slug}@example.kz"; + const string password = "Passw0rd!"; + (await SignupAsync(email, password, $"Org {slug}")).EnsureSuccessStatusCode(); + UseToken(await TokenAsync(email, password)); + return (email, password); + } + + // ── JSON helpers ────────────────────────────────────────────────────────── + public async Task GetJsonAsync(string url) + { + using var resp = await Http.GetAsync(url); + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadFromJsonAsync(); + } + + /// items[] из PagedResult (или сам массив, если endpoint отдаёт список). + public async Task> ListAsync(string url) + { + var json = await GetJsonAsync(url); + var arr = json.ValueKind == JsonValueKind.Array ? json + : json.TryGetProperty("items", out var items) ? items + : default; + return arr.ValueKind == JsonValueKind.Array ? arr.EnumerateArray().ToList() : new(); + } + + public async Task FirstIdAsync(string url, Func? match = null) + { + var list = await ListAsync(url); + var hit = match is null ? list.FirstOrDefault() : list.FirstOrDefault(match); + return hit.ValueKind == JsonValueKind.Object ? hit.GetProperty("id").GetString()! : throw new InvalidOperationException($"No item in {url}"); + } + + // ── Org bootstrap refs ───────────────────────────────────────────────────── + public async Task LoadRefsAsync() + { + var unit = await FirstIdAsync("/api/catalog/units-of-measure?pageSize=50"); + var group = await FirstIdAsync("/api/catalog/product-groups?pageSize=200"); + var store = await FirstIdAsync("/api/catalog/stores?pageSize=200"); + var currency = await FirstIdAsync("/api/catalog/currencies?pageSize=50", + e => e.GetProperty("code").GetString() == "KZT"); + // Системный/розничный тип цены. + var priceType = await FirstIdAsync("/api/catalog/price-types?pageSize=200", + e => e.TryGetProperty("isSystem", out var s) && s.GetBoolean()); + return new Refs(unit, group, store, currency, priceType); + } + + public async Task CreateCounterpartyAsync(string name) + { + var resp = await Http.PostAsJsonAsync("/api/catalog/counterparties", new + { + name, + legalName = name, + type = 0, + bin = "987654321098", + iin = (string?)null, + taxNumber = (string?)null, + countryId = (string?)null, + address = "Алматы", + phone = "+77003332211", + email = "cp@example.kz", + bankName = (string?)null, + bankAccount = (string?)null, + bik = (string?)null, + contactPerson = "Иванов", + notes = (string?)null, + }); + resp.EnsureSuccessStatusCode(); + return (await resp.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!; + } + + public async Task CreateProductAsync(Refs refs, string name, decimal retailPrice, string barcode) + { + var resp = await Http.PostAsJsonAsync("/api/catalog/products", new + { + name, + article = (string?)null, + description = (string?)null, + unitOfMeasureId = refs.UnitId, + vat = 12, + vatEnabled = true, + productGroupId = refs.GroupId, + defaultSupplierId = (string?)null, + countryOfOriginId = (string?)null, + isService = false, + packaging = 0, + isMarked = false, + minStock = (decimal?)null, + maxStock = (decimal?)null, + referencePrice = retailPrice * 0.7m, + purchaseCurrencyId = refs.CurrencyId, + imageUrl = (string?)null, + prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = retailPrice, currencyId = refs.CurrencyId } }, + barcodes = new[] { new { code = barcode, type = 0, isPrimary = true } }, + }); + var body = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + throw new InvalidOperationException($"CreateProduct failed {(int)resp.StatusCode}: {body}"); + return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!; + } + + public async Task StockOfAsync(string storeId, string productId) + { + var list = await ListAsync($"/api/inventory/stock?productId={productId}&pageSize=200"); + var row = list.FirstOrDefault(i => + i.GetProperty("productId").GetString() == productId && + i.GetProperty("storeId").GetString() == storeId); + return row.ValueKind == JsonValueKind.Object ? row.GetProperty("quantity").GetDecimal() : 0m; + } + + public sealed record Refs(string UnitId, string GroupId, string StoreId, string CurrencyId, string PriceTypeId); +} diff --git a/tests/food-market.IntegrationTests/Support/ApiCollection.cs b/tests/food-market.IntegrationTests/Support/ApiCollection.cs new file mode 100644 index 0000000..101a2eb --- /dev/null +++ b/tests/food-market.IntegrationTests/Support/ApiCollection.cs @@ -0,0 +1,11 @@ +using Xunit; + +namespace foodmarket.IntegrationTests.Support; + +/// Один Postgres-контейнер + host на всю коллекцию интеграционных тестов +/// (старт контейнера ~секунды — не хотим повторять на каждый класс). +[CollectionDefinition(Name)] +public sealed class ApiCollection : ICollectionFixture +{ + public const string Name = "api"; +} diff --git a/tests/food-market.IntegrationTests/Support/ApiFactory.cs b/tests/food-market.IntegrationTests/Support/ApiFactory.cs new file mode 100644 index 0000000..7b75192 --- /dev/null +++ b/tests/food-market.IntegrationTests/Support/ApiFactory.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Testcontainers.PostgreSql; +using Xunit; + +namespace foodmarket.IntegrationTests.Support; + +/// Поднимает реальный API (WebApplicationFactory) поверх одноразового +/// Postgres-контейнера (Testcontainers). Миграции применяются на старте host'а +/// (Program.Migrate), сидеры наполняют справочники и SuperAdmin. Запускается +/// один раз на всю коллекцию тестов. +public sealed class ApiFactory : WebApplicationFactory, IAsyncLifetime +{ + static ApiFactory() + { + // Ryuk (reaper) тянет образ из Docker Hub — на этом хосте сеть к внешним + // реестрам нестабильна, а postgres:16-alpine уже закэширован. Выключаем. + Environment.SetEnvironmentVariable("TESTCONTAINERS_RYUK_DISABLED", "true"); + // Лимитер читает конфиг ЭАГЕРНО при регистрации сервисов — поэтому + // переопределяем через env-переменную (её CreateBuilder подхватывает до + // регистрации), а не через ConfigureAppConfiguration (применяется позже). + // Тесты логинятся десятки раз с одного loopback-IP — иначе 429. + Environment.SetEnvironmentVariable("RateLimiting__Enabled", "false"); + // Тише логи в тестовом прогоне (OpenIddict сыплет Debug-событиями). + Environment.SetEnvironmentVariable("Serilog__MinimumLevel__Default", "Warning"); + } + + private readonly PostgreSqlContainer _db = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("food_market") + .WithUsername("food_market") + .WithPassword("food_market_test") + .Build(); + + public async Task InitializeAsync() => await _db.StartAsync(); + + public new async Task DisposeAsync() + { + await _db.DisposeAsync(); + await base.DisposeAsync(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Development — простые dev-ключи OpenIddict (без генерации сертификатов), + // 3-сегментный токен. Лимитер выключаем: тесты логинятся много раз с одного IP. + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((_, cfg) => + { + // Строку подключения AddDbContext читает лениво — здесь override + // срабатывает. RateLimiting выключён через env (см. static ctor). + cfg.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Default"] = _db.GetConnectionString(), + }); + }); + } +} diff --git a/tests/food-market.IntegrationTests/TenantIsolationTests.cs b/tests/food-market.IntegrationTests/TenantIsolationTests.cs new file mode 100644 index 0000000..922b56b --- /dev/null +++ b/tests/food-market.IntegrationTests/TenantIsolationTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class TenantIsolationTests +{ + private readonly ApiFactory _factory; + public TenantIsolationTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Org_B_cannot_see_org_A_data() + { + var a = new ApiActor(_factory.CreateClient()); + var b = new ApiActor(_factory.CreateClient()); + await a.SignupAndLoginAsync($"iso-a-{Guid.NewGuid():N}"); + await b.SignupAndLoginAsync($"iso-b-{Guid.NewGuid():N}"); + + var marker = $"A-CP-{Guid.NewGuid():N}"; + var createdId = await a.CreateCounterpartyAsync(marker); + + // A видит свой контрагент. + var aList = await a.ListAsync("/api/catalog/counterparties?pageSize=200"); + aList.Should().Contain(c => c.GetProperty("id").GetString() == createdId); + + // B не видит ни id, ни имя контрагента A. + var bList = await b.ListAsync("/api/catalog/counterparties?pageSize=200"); + bList.Should().NotContain(c => c.GetProperty("id").GetString() == createdId); + bList.Should().NotContain(c => c.GetProperty("name").GetString() == marker); + + // B не может прочитать контрагент A напрямую по id (query-filter → 404). + using var direct = await b.Http.GetAsync($"/api/catalog/counterparties/{createdId}"); + direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); + } +} diff --git a/tests/food-market.IntegrationTests/food-market.IntegrationTests.csproj b/tests/food-market.IntegrationTests/food-market.IntegrationTests.csproj new file mode 100644 index 0000000..d900bc7 --- /dev/null +++ b/tests/food-market.IntegrationTests/food-market.IntegrationTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + foodmarket.IntegrationTests + foodmarket.IntegrationTests + enable + enable + false + true + + + + + + + + + + + + + + + + +