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
@@ -2,11 +2,13 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { configureAuthClient } from "@/shared/api";
import { useStockQuote } from "@/entities/stock";
import { configureAuthClient, invalidateResourceAccess } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
function auth() {
configureAuthClient({
@@ -16,6 +18,207 @@ function auth() {
});
}
describe("page-owned screen synchronization", () => {
it.each([false, true])(
"retries a failed GET without forcing the source (cached=%s)",
async (cached) => {
auth();
const modes: (string | null)[] = [];
let unavailable = !cached;
let posts = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return unavailable
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource([], { stateVersion: String(modes.length) }),
);
}),
http.post("*/api/v1/tbank-sync", () => {
posts += 1;
return new HttpResponse(null, { status: 500 });
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
disabled,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
if (cached) {
await waitFor(() =>
expect(result.current.accounts.isSuccess).toBe(true),
);
unavailable = true;
await act(() => result.current.accounts.refetch());
}
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
unavailable = false;
await act(() => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.meta?.stateVersion).toBe(
cached ? "3" : "2",
);
expect(modes).toEqual(
cached ? [null, null, "observe"] : [null, "observe"],
);
expect(posts).toBe(0);
expect(result.current.disabled.data).toBeUndefined();
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it.each([
["logout", true],
["connection", true],
["session", false],
["connection", false],
["access", false],
] as const)(
"discards recovery after %s changes (HTTP already pending=%s)",
async (boundary, pending) => {
auth();
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
let recovering = false;
let changed = false;
server.use(
http.get("*/api/v1/brokerage-accounts", async ({ request }) => {
if (boundary === "connection" && changed && !request.signal.aborted)
return HttpResponse.json(resource([], { stateVersion: "current" }));
if (!new URL(request.url).searchParams.has("sync"))
return changed
? HttpResponse.json(resource([], { stateVersion: "current" }))
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
recovering = true;
await blocked;
return HttpResponse.json(resource([], { stateVersion: "old" }));
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const published: unknown[] = [];
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.type === "updated" && event.action.type === "success")
published.push(event.query.state.data);
});
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
let recovery!: Promise<void>;
act(() => {
recovery = result.current.sync.retry();
});
if (pending) await waitFor(() => expect(recovering).toBe(true));
await act(async () => {
changed = true;
if (boundary === "logout") {
configureAuthClient(null);
queryClient.clear();
} else if (boundary === "session") {
auth();
queryClient.clear();
} else if (boundary === "access") {
invalidateResourceAccess(queryClient, "brokerage", {
code: "FORBIDDEN",
});
} else
commitTbankConnectionBoundary(queryClient, {
connected: true,
connectedAt: "2026-09-27T00:00:00Z",
});
release();
await recovery;
});
if (boundary === "connection")
await waitFor(() =>
expect(result.current.accounts.meta?.stateVersion).toBe("current"),
);
expect(published).not.toContainEqual(
expect.objectContaining({
meta: expect.objectContaining({ stateVersion: "old" }),
}),
);
unsubscribe();
},
);
it.each([false, true])(
"recovers an initial GET failure after forced sync without metadata (status retry=%s)",
async (retryStatus) => {
auth();
const modes: (string | null)[] = [];
let polls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([]));
}),
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json({ id: "recover" }, { status: 202 }),
),
http.get("*/api/v1/tbank-sync/:id", () => {
polls += 1;
return retryStatus && polls === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
id: "recover",
state: "succeeded",
resources: [resource([]).meta],
});
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
expect(result.current.accounts.meta).toBeUndefined();
await act(() => result.current.sync.refresh());
if (retryStatus) {
await waitFor(() =>
expect(result.current.sync.transportError).toBe(true),
);
await act(() => result.current.sync.retry());
}
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.data).toEqual([]);
expect(modes).toEqual([null, "observe"]);
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it("posts only its scope once and observes completion without importing again", async () => {
auth();
const modes: (string | null)[] = [];
@@ -1,4 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import {
apiErrorCode,
@@ -12,7 +12,12 @@ import { type InvestmentScreen, useTbankSync } from "./sync";
type ResourceMeta =
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type ScreenResource = {
queryKey: QueryKey;
meta?: ResourceMeta;
transport: {
status: "pending" | "success" | "error";
fetchStatus: "idle" | "fetching" | "paused";
};
observe: (version?: string) => Promise<unknown>;
};
const STATUS_INTERVAL_MS = 1000;
@@ -81,7 +86,21 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
latest.set(dependency.resourceKey, dependency);
}
for (const resource of resources) {
if (!resource.meta) continue;
if (!resource.meta) {
if (resource.transport.fetchStatus !== "idle") continue;
const completed = statuses.find(
(status) =>
requested?.scope === scopeKey &&
status.data?.id === requested.id &&
status.data.state !== "running",
)?.data;
const key = `query:${JSON.stringify(resource.queryKey)}`;
if (completed && observed.current.get(key) !== completed.id) {
observed.current.set(key, completed.id);
void resource.observe().catch(() => undefined);
}
continue;
}
const changed = [
resource.meta,
...(resource.meta.dependencies ?? []),
@@ -103,7 +122,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
);
void resource.observe(own?.stateVersion).catch(() => undefined);
}
}, [client, statuses, resources]);
}, [client, statuses, resources, requested, scopeKey]);
const refreshing =
(currentMutation && mutation.isPending) ||
statuses.some(
@@ -133,8 +152,23 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
if (requestInFlight.current === scopeKey) requestInFlight.current = null;
}
};
// Retry failed HTTP reads without asking the source to import again.
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => {
const failed = resources.filter(
(resource) => resource.transport.status === "error",
);
const failedStatuses = statuses.filter((status) => status.isError);
if (failed.length || failedStatuses.length) {
await Promise.allSettled([
...failed.map((resource) => resource.observe()),
...failedStatuses.map((status) => status.refetch()),
]);
} else await refresh();
};
return {
refresh,
retry,
refreshing,
transportError:
(currentMutation && mutation.isError) ||