food-market/tests/food-market.IntegrationTests/SessionRevokeTests.cs
nns 8e54e2e0d6 feat(s13): security headers + rate-limits + sensitive-ops audit + session revoke + Grafana
Sprint 13 — security + observability deep. 7 пунктов чек-листа ✓.

Подробности — docs/sprint13-progress.md и docs/food-market-server-postgres-role.md.

Главное:
- food-market-server (back.food-market.kz, legacy backend) теперь
  работает на dedicated PG-роли food_market_server_app (NOSUPERUSER /
  NOCREATEDB / NOCREATEROLE / NOREPLICATION / NOBYPASSRLS) с CRUD-only
  грантами. Раньше использовался postgres-superuser с паролем 1q2w3e4r.
  Бэкап конфига сохранён, rollback одной командой.
- SecurityHeadersMiddleware навешивает CSP / X-Frame-Options DENY /
  X-Content-Type-Options nosniff / Referrer-Policy strict-origin /
  Permissions-Policy. HSTS 365d + includeSubDomains + preload.
  Те же заголовки в deploy/nginx.conf для SPA HTML.
- Rate-limit:
  • Signup-IP — 3/час + 10/день (на stage'е переопределено через
    .env RATE_SIGNUP_HOUR=30 чтобы не ломать e2e).
  • Forgot-password — per-email 3/час + per-IP 10/час.
- SensitiveOpsAudit сервис, wired в:
  • TwoFactor enroll/disable
  • Employees.Update при смене RoleId (action=AssignRole,
    payload с prev/next role + полный RolePermissions)
  • MeAccount.ChangePassword (новый endpoint)
  • MeSessions.RevokeAll (новый endpoint)
- POST /api/me/sessions/revoke-all — через
  IOpenIddictAuthorizationManager.FindBySubjectAsync + TryRevokeAsync.
  Integration-тест: refresh после revoke → 400/401.
- Hangfire dashboard — nginx-route добавлен (раньше /hangfire ловился
  SPA-fallback'ом). Фильтр SuperAdmin'ом уже был. Тест: anon/tenant →
  401/403/404.
- Grafana dashboard JSON (deploy/grafana/dashboards/food-market.json,
  9 панелей) + инструкции импорта в docs/observability.md.

Проверено на stage'е: все 6 security-заголовков видны на /;
/hangfire → 401 (закрыт); 4-я форгот → 429; stage-smoke (5 этапов) ✓.

Тесты: 68 unit + 9 integration (включая 3 новых: SessionRevokeTests,
HangfireAccessTests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 12:30:10 +05:00

70 lines
3.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Sprint 13: <c>POST /api/me/sessions/revoke-all</c> гасит
/// все refresh-токены текущего юзера. После вызова попытка обновить
/// access через refresh — 400.</summary>
[Collection(ApiCollection.Name)]
public class SessionRevokeTests
{
private readonly ApiFactory _factory;
public SessionRevokeTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Refresh_after_revoke_all_fails()
{
var actor = new ApiActor(_factory.CreateClient());
var slug = $"revoke-{Guid.NewGuid():N}";
var email = $"{slug}@example.kz";
const string password = "Passw0rd!";
// Signup + первый login (получаем access + refresh).
(await actor.SignupAsync(email, password, $"RevokeOrg-{slug}")).EnsureSuccessStatusCode();
var (access1, refresh1) = await GetTokenPairAsync(actor.Http, email, password);
access1.Should().NotBeNullOrEmpty();
refresh1.Should().NotBeNullOrEmpty();
// Используем access для revoke-all.
actor.Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access1);
var revokeResp = await actor.Http.PostAsync("/api/me/sessions/revoke-all", null);
revokeResp.EnsureSuccessStatusCode();
var revokedJson = await revokeResp.Content.ReadFromJsonAsync<JsonElement>();
revokedJson.GetProperty("revokedAuthorizations").GetInt32().Should().BeGreaterThan(0,
"при revoke-all должна быть погашена хотя бы одна authorization");
// Попытка использовать ТОТ ЖЕ refresh-токен → 400 invalid_grant.
using var refreshResp = await actor.Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = refresh1!,
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api offline_access",
}));
((int)refreshResp.StatusCode).Should().BeOneOf(400, 401);
}
private static async Task<(string AccessToken, string? RefreshToken)> GetTokenPairAsync(
HttpClient http, string email, string password)
{
using var resp = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = email,
["password"] = password,
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api offline_access",
}));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
var access = json.GetProperty("access_token").GetString()!;
string? refresh = json.TryGetProperty("refresh_token", out var r) ? r.GetString() : null;
return (access, refresh);
}
}