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
@@ -6,6 +6,7 @@ import {
act,
fireEvent,
render,
renderHook,
screen,
waitFor,
within,
@@ -26,7 +27,10 @@ import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { isDate } from "../../model/period";
import { AccountIncomePage } from "./AccountIncomePage";
import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
import {
toIncomeDisplayModel,
useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = {
@@ -148,6 +152,79 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
}
describe("AccountIncomePage", () => {
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(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
});
await act(async () => {});
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
});
it("keeps income calendar dates in GET and source sync requests", async () => {
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ 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 },
);
}),
);
renderPage("from=2024-02-29&to=2024-03-01");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "income",
accountId: "account-1",
from: "2024-02-29",
to: "2024-03-01",
},
]),
);
});
it("requests a multi-year period from the URL without resetting it", async () => {
const requested: string[] = [];
server.use(
@@ -431,20 +508,18 @@ describe("AccountIncomePage", () => {
});
it("shows the unavailable year state without falling back to the previous period", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(analytics, {
dependencies: [
resource(analytics).meta,
resource(analytics).meta,
{
...resource(null).meta,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
],
}),
),
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
@@ -829,11 +904,87 @@ describe("income period and snapshot boundary", () => {
});
});
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous = {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
};
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
]) {
const display = toIncomeDisplayModel(analytics, {
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
}
});
it.each([false, true])(
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
async (legacy) => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(
{ ...analytics, previous: null, yearComparison: null },
{
dependencies: legacy
? [
resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
},
),
),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с предыдущим периодом недоступно."),
).toBeVisible();
expect(
screen.getByText("Сравнение с годом ранее недоступно."),
).toBeVisible();
expect(
screen.queryByText("Период сравнения ещё не загружен полностью."),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
},
);
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
const dependencies = (["year", "selected", "previous"] as const).map(
(role) => ({
...resource(role === "selected" ? analytics : null).meta,
role,
}),
);
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource({ ...analytics, previous: null, yearComparison: null }),
resource(
{ ...analytics, previous: null, yearComparison: null },
{ dependencies },
),
),
),
);
@@ -856,24 +1007,22 @@ it("shows selected income before comparison coverage without inventing a zero ba
});
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
},
{ ...resource(analytics).meta, role: "selected" as const },
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(analytics, {
dependencies: [
resource(analytics).meta,
resource(analytics).meta,
{
...resource(null).meta,
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
},
],
}),
),
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
@@ -22,6 +22,7 @@ type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
type ComparisonNotice =
| "comparisonPending"
| "comparisonFailed"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = {
@@ -84,7 +85,7 @@ const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const requested = useMemo(
() => ({
@@ -130,7 +131,7 @@ const useAccountIncomePageViewModel = (
};
if (analytics.isLoading || !analytics.data) {
if (analytics.isError || analytics.meta?.sync.state === "failed")
return { state: "error", retry: sync.refresh, period };
return { state: "error", retry: sync.retry, period };
return { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
@@ -152,7 +153,7 @@ const useAccountIncomePageViewModel = (
partial: analytics.meta?.availability !== "complete",
refreshing: analytics.isFetching || sync.refreshing,
refreshError: analytics.isError || sync.sourceError || sync.transportError,
retry: sync.refresh,
retry: sync.retry,
form,
onSubmit,
onReset,
@@ -236,14 +237,14 @@ function toIncomeDisplayModel(
if (instrument.includedCount !== instrument.receiptCount) return null;
}
// The income endpoint returns selected, previous and year dependencies in order.
const [, previous, year] = meta?.dependencies ?? [];
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const year = meta?.dependencies?.find(({ role }) => role === "year");
return {
entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
previousNotice: comparisonNotice(previous),
yearNotice: comparisonNotice(year),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
}
@@ -251,9 +252,10 @@ function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (dependency?.unavailableReason) return "yearUnavailable";
return dependency?.sync.state === "failed"
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}