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>
70 lines
3.2 KiB
C#
70 lines
3.2 KiB
C#
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);
|
||
}
|
||
}
|