Шхуна не тонет: security, infra и доки на русском.

Безопасность довёл до ума — Cursor-генерацию переписал руками.
IDOR закрыл, CSRF задушил, refresh rotation теперь как надо.
HSTS на staging, ENABLE_DOCS=false, install.env recovery протестил.

Backend:
- jwt_denylist + auth_epoch: мгновенный revoke access JWT (logout/block/reset)
- auth/admin/users: bump epoch, logout с Bearer, forgot_password skip для blocked
- install_secrets: путь всегда apps/api/data/secrets/ (bootstrap из корня не ломает Docker)
- seed: SEED_DEMO_USERS=false на prod/staging
- тесты: jwt revoke, integration, coverage gate 90%

Frontend:
- logout шлёт Bearer, обработка TOKEN_REVOKED
- guards TypeScript fix
- E2E: blocked user → 401 сразу после block

Infra:
- staging/prod compose, TLS nginx, deploy-скрипты
- k6 §17.2, backup/health/smoke scripts

Docs:
- docs/ на русском: project, security, deploy, release (старые md слили)
- README короткий + план ТЗ + стандартные логины dev

Код готов к плаванию. Капитан может идти писать фронт.
This commit is contained in:
влад
2026-07-15 00:06:13 +03:00
parent 86cc3fa541
commit 12c983c0fc
66 changed files with 2377 additions and 520 deletions
+19
View File
@@ -2,6 +2,25 @@ import { expect, test } from "@playwright/test";
import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api";
test.describe("§15.7 scenarios 5 & 9: Admin users", () => {
test("blocked user access token rejected immediately after block", async ({ request }) => {
const email = uniqueEmail("e2e-block-jwt");
const session = await registerVerifyLogin(request, email);
const admin = await adminLogin(request);
const adminToken = (await admin.json()).access_token;
const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { status: "blocked" }
});
expect(patch.ok()).toBeTruthy();
const me = await request.get(`${API_URL}/api/v1/users/me`, {
headers: { Authorization: `Bearer ${session.accessToken}` }
});
expect(me.status()).toBe(401);
expect((await me.json()).detail).toBe("TOKEN_REVOKED");
});
test("admin blocks user → blocked user cannot login", async ({ page, request }) => {
const email = uniqueEmail("e2e-block");
const session = await registerVerifyLogin(request, email);
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore";
export function AdminGuard({ children }: PropsWithChildren): JSX.Element {
export function AdminGuard({ children }: PropsWithChildren): JSX.Element | null {
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth();
+1 -1
View File
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore";
export function AuthGuard({ children }: PropsWithChildren): JSX.Element {
export function AuthGuard({ children }: PropsWithChildren): JSX.Element | null {
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth();
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore";
export function GuestGuard({ children }: PropsWithChildren): JSX.Element {
export function GuestGuard({ children }: PropsWithChildren): JSX.Element | null {
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth();
@@ -43,9 +43,21 @@ vi.mock("@shared/api/client", () => ({
}
return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } };
}),
patch: vi.fn(async () => ({
data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" }
})),
patch: vi.fn(async (url: string) => {
if (url === "/api/v1/admin/settings") {
return {
data: {
values: { enable_docs: false },
locks: {},
settings_path: "data/compton_settings.json",
secrets: {}
}
};
}
return {
data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" }
};
}),
post: vi.fn(async (url: string) => {
if (url === "/api/v1/admin/users") {
return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } };
@@ -95,7 +107,7 @@ describe("adminApi", () => {
expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked");
expect((await deleteAdminUser("1")).status).toBe("deleted");
expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json");
expect((await patchAdminSettings({ enable_docs: false })).id).toBe("1");
expect((await patchAdminSettings({ enable_docs: false })).settings_path).toBe("data/compton_settings.json");
expect((await getAdminDiagnostics()).checks[0].status).toBe("ok");
expect((await getAdminActivityFeed()).events.length).toBe(1);
expect((await postAdminUiActivity("click")).status).toBe("ok");
@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminStats } from "./AdminStats";
@@ -23,9 +23,14 @@ describe("AdminStats", () => {
expect(await screen.findByText("CPU")).toBeInTheDocument();
expect(await screen.findByText("WESP")).toBeInTheDocument();
expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument();
expect(await screen.findByText("users")).toBeInTheDocument();
expect(await screen.findByText("10")).toBeInTheDocument();
expect(await screen.findByText("3")).toBeInTheDocument();
expect(await screen.findByText("2")).toBeInTheDocument();
expect(await screen.findByText("registrations today")).toBeInTheDocument();
expect((await screen.findAllByText("users")).length).toBeGreaterThanOrEqual(1);
const statCards = document.querySelectorAll(".wesp-admin-stat-card");
expect(statCards).toHaveLength(4);
expect(within(statCards[0] as HTMLElement).getByText("10")).toBeInTheDocument();
expect(within(statCards[1] as HTMLElement).getByText("3")).toBeInTheDocument();
expect(within(statCards[2] as HTMLElement).getByText("2")).toBeInTheDocument();
expect(within(statCards[3] as HTMLElement).getByText("1")).toBeInTheDocument();
});
});
+4 -3
View File
@@ -1,4 +1,4 @@
import { authClient } from "@shared/api/client";
import { authClient, applyAuthHeader } from "@shared/api/client";
export interface LoginPayload {
email: string;
@@ -38,8 +38,9 @@ export async function register(payload: RegisterPayload) {
return data;
}
export async function logout() {
await authClient.post("/api/v1/auth/logout");
export async function logout(accessToken?: string | null) {
const headers = accessToken ? applyAuthHeader({}) : {};
await authClient.post("/api/v1/auth/logout", undefined, { headers });
}
export async function refresh() {
+1 -1
View File
@@ -19,7 +19,7 @@ export function useAuth() {
return data.user as AuthUser;
},
async logout() {
await apiLogout();
await apiLogout(accessToken);
clearSession();
},
async refreshSession() {
+4
View File
@@ -72,6 +72,10 @@ apiClient.interceptors.response.use(
useAuthStore.getState().clearSession();
return Promise.reject(error);
}
if (error.response?.status === 401 && detail === "TOKEN_REVOKED") {
useAuthStore.getState().clearSession();
return Promise.reject(error);
}
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) {
return Promise.reject(error);
+1
View File
@@ -40,6 +40,7 @@ export default defineConfig({
exclude: ["e2e/**", "node_modules/**"],
environment: "jsdom",
setupFiles: ["src/__tests__/setup.ts"],
testTimeout: 10_000,
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],