Шхуна не тонет: 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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -19,7 +19,7 @@ export function useAuth() {
|
||||
return data.user as AuthUser;
|
||||
},
|
||||
async logout() {
|
||||
await apiLogout();
|
||||
await apiLogout(accessToken);
|
||||
clearSession();
|
||||
},
|
||||
async refreshSession() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"],
|
||||
|
||||
Reference in New Issue
Block a user