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,7 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
render,
renderHook,
@@ -33,11 +34,210 @@ const Navigation: FC = () => {
<button type="button" onClick={() => navigate(-1)}>
History back
</button>
<button type="button" onClick={() => navigate(1)}>
History forward
</button>
</>
);
};
describe("Upcoming event date filters", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
});
await act(async () => {});
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
});
it.each([
"from=2099-02-01&to=2099-02-28",
"from=2099-02-01",
"to=2099-02-28",
])(
"keeps event calendar dates in GET and source sync requests: %s",
async (search) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
);
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
const period = Object.fromEntries(new URLSearchParams(search));
const { wrapper: QueryWrapper } = createQueryWrapper();
const { result } = renderHook(
() => useAccountEventsPageViewModel("all"),
{
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={[`?${search}`]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
},
);
await waitFor(() => expect(result.current.state).toBe("error"));
expect(requested).toEqual([`?${search}`]);
expect(syncRequests).toEqual([]);
act(() => {
if (result.current.state === "error") result.current.retry();
});
await waitFor(() =>
expect(syncRequests).toEqual([
{ screen: "events", accountId: "all", ...period },
]),
);
},
);
it.each(["account-1", "all"])(
"normalizes invalid links and history while preserving one-sided future dates for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const searches: string[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ events: [] }));
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2099-02-28",
"?from=2099-02-30",
"?from=2099-03-01&to=2099-02-28",
"?from=2099-02-01",
"?to=2099-02-28",
].map((search) => `/accounts/${accountId}/events${search}`)}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
}
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("URL")).toHaveTextContent(
"?from=2099-02-01",
),
);
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099");
expect(searches).toEqual(["", "?from=2099-02-01", "?to=2099-02-28"]);
},
);
it.each(["account-1", "all"])(
"lets users reset filters after a request failure for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(resource({ events: [] })),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
},
);
it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => {
@@ -9,6 +9,7 @@ const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
vi.mock("@/features/tbank-connection", () => ({
useScreenSync: () => ({
refresh,
retry: refresh,
refreshing: false,
sourceError: false,
transportError: false,
@@ -78,7 +79,7 @@ describe("AccountEventsPage", () => {
refetch: vi.fn(),
} as never);
renderPage();
expect(query).toHaveBeenCalledWith(undefined, {});
expect(query).toHaveBeenCalledWith("", {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
@@ -10,7 +10,7 @@ const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />;
return <AccountEventsPageError {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />;
};
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageEvent,
useBrokerageEvents,
@@ -8,7 +9,7 @@ import { useScreenSync } from "@/features/tbank-connection";
type AccountEventsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void }
| { state: "error"; retry: () => void; onReset: () => void }
| {
state: "loaded";
events: BrokerageEvent[];
@@ -31,12 +32,22 @@ function useAccountEventsPageViewModel(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const urlFrom = searchParams.get("from");
const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
useEffect(() => {
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
@@ -54,12 +65,18 @@ function useAccountEventsPageViewModel(
},
[events],
);
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
};
if (events.isLoading) return { state: "loading" };
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
state: "error",
onReset,
retry: () => {
void sync.refresh();
void sync.retry();
},
};
if (!events.data) return { state: "loading" };
@@ -72,26 +89,34 @@ function useAccountEventsPageViewModel(
refreshing: events.isFetching || sync.refreshing,
refreshError: events.isError || sync.sourceError || sync.transportError,
retry: () => {
void sync.refresh();
void sync.retry();
},
draftFrom,
draftTo,
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
(!draftFrom || !draftTo || draftFrom <= draftTo),
isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onApply: () =>
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
}),
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
});
},
onReset,
};
}
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
);
}
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -1,9 +1,9 @@
import { Button } from "@mantine/core";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
type AccountEventsPageErrorProps = { retry: () => void };
type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
@@ -12,7 +12,14 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={<Button onClick={props.retry}>{t("events.retry")}</Button>}
action={
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
}
/>
);
};