fix: resolve frontend audit findings
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s

Repair investment recovery, draft preservation, date validation,
currency presentation, accessibility, and route error recovery.
Correct Docker and dependency checks and consume explicit income roles.
This commit is contained in:
2026-09-27 11:38:20 +03:00
parent b34c1c860a
commit f6db28defa
50 changed files with 2017 additions and 215 deletions
@@ -1,6 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api";
@@ -9,6 +15,149 @@ import { server } from "@/test/server";
import { CategoryManager } from "./CategoryManager";
describe("CategoryManager", () => {
it("ignores a late reload for A while editing B and submits B's draft and version", async () => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
const categories = [
{ id: "a", name: "Первая", isDefault: false, version: 1 },
{ id: "b", name: "Вторая", isDefault: false, version: 4 },
];
let reads = 0;
let release = () => {};
const reloaded = new Promise<void>((resolve) => {
release = resolve;
});
const patches: { id: unknown; body: unknown }[] = [];
server.use(
http.get("*/api/v1/bank-cards/categories", async () => {
reads++;
if (reads === 3) await reloaded;
return HttpResponse.json(
categories.map((category) =>
category.id === "a" && reads > 1
? {
...category,
name: reads >= 3 ? "Первая обновлена" : category.name,
version: 2,
}
: category,
),
);
}),
http.patch(
"*/api/v1/bank-cards/categories/:id",
async ({ request, params }) => {
patches.push({ id: params.id, body: await request.json() });
return patches.length === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json(categories[1]);
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<CategoryManager onClose={() => {}} />
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(
(await screen.findAllByRole("button", { name: "Переименовать" }))[0],
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }),
);
await waitFor(() => expect(reads).toBe(3));
fireEvent.click(
screen.getAllByRole("button", { name: "Переименовать" })[1],
);
fireEvent.change(screen.getByLabelText(/Название категории/), {
target: { value: "Новая вторая" },
});
await act(async () => {
release();
await reloaded;
});
await screen.findByText("Первая обновлена");
await waitFor(() =>
expect(
screen.queryByRole("button", {
name: /Использовать актуальную версию/,
}),
).not.toBeInTheDocument(),
);
expect(screen.getByLabelText(/Название категории/)).toHaveValue(
"Новая вторая",
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() =>
expect(patches).toEqual([
{ id: "a", body: { name: "Первая", version: 1 } },
{ id: "b", body: { name: "Новая вторая", version: 4 } },
]),
);
});
it.each(["Отмена", "Сохранить"])(
"clears reloaded recovery after %s",
async (action) => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
let version = 1;
let writes = 0;
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "a", name: "Первая", isDefault: false, version },
]),
),
http.patch("*/api/v1/bank-cards/categories/a", () => {
version = 2;
return ++writes === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json({});
}),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<CategoryManager onClose={() => {}} />
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(
await screen.findByRole("button", { name: "Переименовать" }),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }),
);
await screen.findByRole("button", {
name: /Использовать актуальную версию/,
});
fireEvent.click(screen.getByRole("button", { name: action }));
await screen.findByRole("button", { name: "Добавить категорию" });
expect(
screen.queryByRole("button", {
name: /Использовать актуальную версию/,
}),
).not.toBeInTheDocument();
},
);
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
configureAuthClient({
accessToken: () => "test",
@@ -8,7 +8,7 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { type FC, useState } from "react";
import { type FC, useRef, useState } from "react";
import {
Controller,
FormProvider,
@@ -60,6 +60,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
const categories = useBankCategories();
const mutation = useBankMutations();
const [editing, setEditing] = useState<ExpenseCategory>();
const editSession = useRef(0);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<ExpenseCategory>();
const methods = useForm<CategoryValues>({
@@ -114,7 +115,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
});
if (result) props.onCreated?.(result.id);
}
editSession.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
} catch (failure) {
setError(failure);
@@ -138,6 +141,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button
variant="subtle"
onClick={() => {
editSession.current++;
setEditing(category);
methods.reset({ name: category.name });
setError(undefined);
@@ -167,7 +171,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button
variant="default"
onClick={async () => {
const session = editSession.current;
const response = await categories.refetch();
if (session !== editSession.current) return;
if (response.error) setError(response.error);
else
setLatest(
@@ -180,7 +186,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
)}
</>
) : null}
{latest && (
{latest && latest.id === editing?.id && (
<>
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
<Button
@@ -202,7 +208,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button
variant="default"
onClick={() => {
editSession.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined);
}}