From 668f2ef70e535a1222e7f600b1be4a31fefc6f8b Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 5 Nov 2026 07:23:07 +0300 Subject: [PATCH] fix: align audit findings for Events, currency and auth (WealthLedger/frontend#155) --- docker/nginx.conf | 9 +++++++ src/app/routes/AppRoutes/AllAccounts.test.tsx | 1 + .../AccountEventsPage.filters.test.tsx | 14 ++++++++-- .../AccountEventsPage.retry.test.tsx | 12 +++++++++ .../AccountEventsPage.test.tsx | 3 +++ .../AccountEventsPage/AccountEventsPage.vm.ts | 4 +++ .../AccountEventsPageLoaded.view.tsx | 10 ++++++- src/pages/account/model/positions.test.ts | 26 ++++++++++++++++--- src/pages/account/model/positions.ts | 2 +- src/shared/i18n/locales/ru.ts | 1 + src/test/handlers.ts | 1 + 11 files changed, 75 insertions(+), 8 deletions(-) diff --git a/docker/nginx.conf b/docker/nginx.conf index ef37b03..946f737 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -17,12 +17,21 @@ http { default $http_x_forwarded_proto; '' $scheme; } + # One shared budget: the external proxy's client-IP forwarding is not verified. + map "$request_method:$uri" $auth_admission_key { + default ""; + POST:/api/v1/auth/login auth; + POST:/api/v1/auth/register auth; + } + limit_req_zone $auth_admission_key zone=auth_admission:1m rate=2r/s; + limit_req_status 429; server { listen 8080; server_name _; root /usr/share/nginx/html; resolver 127.0.0.11 valid=5s ipv6=off; location /api/ { + limit_req zone=auth_admission burst=5 nodelay; set $backend http://backend:3000; proxy_pass $backend$request_uri; proxy_set_header Host $http_host; diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 0363249..c2e8452 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -217,6 +217,7 @@ beforeEach(() => { }), ), incomeTotalsByCurrency: [], + window: { from: "2026-09-01", to: "2026-10-01" }, }), ), ), diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index 71c8821..33cd2d6 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -176,7 +176,11 @@ describe("Upcoming event date filters", () => { ({ request }) => { searches.push(new URL(request.url).search); - return HttpResponse.json(resource({ events: [] })); + return HttpResponse.json(resource({ + calculatedAt: "2026-08-14T00:00:00Z", + events: [], + window: { from: "2026-08-14", to: "2026-09-13" }, + })); }, ), ); @@ -223,7 +227,11 @@ describe("Upcoming event date filters", () => { "*/api/v1/brokerage-accounts/:accountId/events", ({ request }) => new URL(request.url).searchParams.get("from") === defaultFrom - ? HttpResponse.json(resource({ events: [] })) + ? HttpResponse.json(resource({ + calculatedAt: "2026-08-14T00:00:00Z", + events: [], + window: { from: "2026-08-14", to: "2026-09-13" }, + })) : HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }), ), ); @@ -256,6 +264,7 @@ describe("Upcoming event date filters", () => { calculatedAt: "2099-01-01T00:00:00Z", events: [], incomeTotalsByCurrency: [], + window: { from: "2099-01-01", to: "2099-01-31" }, }), ); }, @@ -264,6 +273,7 @@ describe("Upcoming event date filters", () => { renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]); const from = await screen.findByLabelText("Период"); expect(from).toHaveTextContent("01.02.2026 – 28.02.2026"); + expect(screen.getByText(/Прогноз от .* на 01\.01\.2099 – 31\.01\.2099/)).toBeVisible(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx index db2644f..cb39af1 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx @@ -17,6 +17,8 @@ const RETRIES_EXHAUSTED_MS = 6000; const TEST_MS = 10_000; const snapshot = resource({ + accountId: "account-1", + calculatedAt: "2026-08-14T00:00:00Z", events: [ { date: "2026-10-01", @@ -32,6 +34,7 @@ const snapshot = resource({ }, ], incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }], + window: { from: "2026-08-14", to: "2026-09-13" }, }); function renderEvents() { @@ -100,7 +103,13 @@ describe("AccountEventsPage read retry", () => { async () => { let isFailing = false; let requests = 0; + let imports = 0; server.use( + http.post("*/api/v1/tbank-sync", () => { + imports += 1; + + return HttpResponse.json({}, { status: 202 }); + }), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => { requests += 1; @@ -111,6 +120,7 @@ describe("AccountEventsPage read retry", () => { ); const { queryClient } = renderEvents(); await screen.findByText("Купон · Synthetic coupon"); + expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible(); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); @@ -127,6 +137,7 @@ describe("AccountEventsPage read retry", () => { expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument(); + expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible(); expect(requests).toBe(4); isFailing = false; @@ -136,6 +147,7 @@ describe("AccountEventsPage read retry", () => { expect(screen.queryByText("Не удалось обновить данные")) .not.toBeInTheDocument()); expect(requests).toBe(5); + expect(imports).toBe(0); }, ); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 7a3db60..3d32729 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -47,6 +47,7 @@ describe("AccountEventsPage", () => { accountId: "account", calculatedAt: "2026-08-14T00:00:00Z", events: [], + window: { from: "2026-08-14", to: "2026-09-13" }, }, isError: false, isLoading: false, @@ -136,6 +137,7 @@ describe("AccountEventsPage", () => { type: "offer", }, ], + window: { from: "2026-08-14", to: "2026-09-13" }, }, isError: false, isLoading: false, @@ -276,6 +278,7 @@ describe("AccountEventsPage", () => { accountId: "all", calculatedAt: "2026-08-14T00:00:00Z", events: [...events], + window: { from: "2026-08-14", to: "2026-09-13" }, }, isError: false, isLoading: false, diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 010c865..fe6c4d1 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -18,6 +18,7 @@ import { type AccountEventsPageViewModel = | { allAccounts: boolean; + calculatedAt: string; canApply: boolean; draftPeriod: [null | string, null | string]; events: BrokerageEvent[]; @@ -31,6 +32,7 @@ type AccountEventsPageViewModel source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; + window: { from: string; to: string }; } | { onReset: () => void; @@ -110,6 +112,7 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel { return { allAccounts: accountId === "all", + calculatedAt: events.data.calculatedAt, canApply: (draftFrom !== appliedFrom || draftTo !== appliedTo) && isValidPeriod(draftFrom, draftTo), @@ -133,6 +136,7 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel { source: events.meta, state: "loaded", totalVisible: events.meta?.completeness === "complete", + window: events.data.window, }; } diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 514d839..5e79c56 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -17,7 +17,7 @@ import { brokerageEventGroupKey, brokerageEventKey, } from "@/entities/brokerage-event"; -import { groupItems } from "@/shared/lib"; +import { formatDate, formatSnapshotTime, groupItems } from "@/shared/lib"; import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; @@ -89,6 +89,14 @@ const AccountEventsPageLoaded: FC = (props) => { {t("events.hint")} + + {t("events.forecastWindow", { + date: formatSnapshotTime(props.calculatedAt), + from: formatDate(props.window.from), + to: formatDate(props.window.to), + })} + + { - it("excludes currency instruments only from display, preserving order and share denominators", () => { + it("excludes currency mirrors from display and share denominators", () => { const otherTypes = [ "OPTION", "OTHER", @@ -73,12 +73,30 @@ describe("account position sections", () => { "FIGI-FUND", ...otherTypes.map(type => `FIGI-${type}-NEXT`), ]); - expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %"); - expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %"); - expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %"); + expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("50 %"); + expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("25 %"); + expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("25 %"); expect(snapshot).toEqual(original); }); + it("keeps all-account positions from both accounts without counting mirrored cash", () => { + const result = sections([ + makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "100" } }), + makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "50" }, type: "CURRENCY" }), + makePosition({ account: { id: "b", name: "ИИС" }, currentValue: { currency: "RUB", value: "100" } }), + ]); + + const visible = result.flatMap(section => + section.groups.flatMap(group => group.positions)); + + expect(visible) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ account: { id: "a", name: "Основной" }, shareText: "50 %" }), + expect.objectContaining({ account: { id: "b", name: "ИИС" }, shareText: "50 %" }), + ])); + expect(visible).toHaveLength(2); + }); + it("prepares grouped position sections with derived display data", () => { expect( toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ diff --git a/src/pages/account/model/positions.ts b/src/pages/account/model/positions.ts index 8fd431f..5019923 100644 --- a/src/pages/account/model/positions.ts +++ b/src/pages/account/model/positions.ts @@ -152,7 +152,7 @@ function toAccountPagePositionSections( const denominators = new Map(); for (const position of portfolio.positions) { - if (!position.currentValue) continue; + if (position.type === "CURRENCY" || !position.currentValue) continue; const currency = position.currentValue.currency; denominators.set( currency, diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index d6fcc35..ffb76a4 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -360,6 +360,7 @@ const ru = { emptyTitle: "За выбранный период событий не найдено", errorText: "Попробуйте запросить прогноз еще раз.", errorTitle: "Не удалось загрузить будущие события", + forecastWindow: "Прогноз от {{date}} на {{from}} – {{to}}", hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.", noAmount: "Сумма неизвестна", open: "Открыть будущие события", diff --git a/src/test/handlers.ts b/src/test/handlers.ts index a3e1595..0b4606c 100644 --- a/src/test/handlers.ts +++ b/src/test/handlers.ts @@ -66,6 +66,7 @@ const handlers = [ accountId: "account-1", calculatedAt: "2026-08-14T00:00:00Z", events: [], + window: { from: "2026-08-14", to: "2026-09-13" }, }), ), ),