From ab3b05007a8678876995e92aa636b31db9f1741f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 01:08:48 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=B7=D0=B0=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=B2=D1=88=D0=B8=D0=B9=20401=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=BD=D0=B5=D0=B3=D0=BE=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=B2=D1=8B=D0=B1=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B0=20=D0=B2=D1=85?= =?UTF-8?q?=D0=BE=D0=B4,=204xx=20=D0=BD=D0=B0=20=D0=B2=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=20=E2=80=94=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D1=85?= =?UTF-8?q?=D0=B0=D0=B1=D0=B0=20(T3.2=E2=80=93T3.6,=20#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/api/client.test.ts | 17 ++++++++++++-- web/src/api/client.ts | 23 ++++++++++++++---- web/src/api/queryClient.test.ts | 28 ++++++++++++++++++++++ web/src/api/queryClient.ts | 5 ++-- web/src/auth/LoginScreen.test.tsx | 39 ++++++++++++++++++++++++++----- web/src/auth/LoginScreen.tsx | 6 +++-- web/src/auth/token.test.ts | 3 +++ web/src/board/useBoardSource.ts | 5 ++-- 8 files changed, 105 insertions(+), 21 deletions(-) create mode 100644 web/src/api/queryClient.test.ts diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 9f8070e..d24f776 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { clearToken, setToken } from "../auth/token.ts"; +import { clearToken, getAuth, setToken } from "../auth/token.ts"; import { json, mockFetch } from "../test/fetchMock.ts"; -import { api, ApiError, AuthError, checkToken, HubDownError } from "./client.ts"; +import { api, ApiError, AuthError, checkToken, dropTokenOn, HubDownError } from "./client.ts"; describe("api", () => { beforeEach(() => setToken("tok")); @@ -21,6 +21,19 @@ describe("api", () => { expect((fetch.mock.calls[0][1]!.headers as Record)["Content-Type"]).toBe("application/json; charset=utf-8"); }); + it("AuthError хранит токен запроса; dropTokenOn не трогает новый токен (T3.5)", async () => { + mockFetch(() => json(401, { error: "unauthorized" })); + const e = await api.get("/board").catch((err: unknown) => err) as AuthError; + expect(e).toBeInstanceOf(AuthError); + expect(e.token).toBe("tok"); + setToken("new-tok"); + dropTokenOn(e); + expect(getAuth()).toEqual({ token: "new-tok", reason: "none" }); + setToken("tok"); + dropTokenOn(e); + expect(getAuth()).toEqual({ token: "", reason: "bad" }); + }); + it("401 — AuthError, 5xx и сеть — HubDownError, прочие 4xx — ApiError с detail", async () => { mockFetch(() => json(401, { error: "unauthorized" })); await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8ea73d6..41e74e1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,9 +1,22 @@ // единственный клиент HTTP API хаба: тот же хост, клиентский токен в Authorization, ответы — JSON. // Ошибки: AuthError — 401 или нет токена; HubDownError — сеть или 5xx; ApiError — прочие 4xx с {error, detail} -import { getToken, TOKEN_RE } from "../auth/token.ts"; +import { clearToken, getToken, TOKEN_RE } from "../auth/token.ts"; import type { HubSettings } from "./types.ts"; -export class AuthError extends Error {} +export class AuthError extends Error { + readonly token: string; // токен, с которым ушёл запрос: запоздавший 401 прежнему токену не выбрасывает на вход (T3.5) + constructor(message: string, token: string) { + super(message); + this.token = token; + } +} + +// 401 выводит на экран входа, только если запрос ушёл с текущим токеном (R-3): новый токен уже проверен GET /settings +export function dropTokenOn(e: AuthError): void { + if (e.token !== getToken()) return; + clearToken(e.token ? "bad" : "none"); +} + export class HubDownError extends Error {} export class ApiError extends Error { readonly status: number; @@ -28,9 +41,9 @@ function withParams(path: string, params?: Params): string { } async function call(method: string, path: string, body?: unknown, token = getToken()): Promise { - if (!token) throw new AuthError("нет токена"); + if (!token) throw new AuthError("нет токена", token); // токен с недопустимыми символами (например, оставленный прежней доской) — сразу на экран входа, а не «хаб не отвечает» - if (!TOKEN_RE.test(token)) throw new AuthError("недопустимые символы в токене"); + if (!TOKEN_RE.test(token)) throw new AuthError("недопустимые символы в токене", token); const headers: Record = { Authorization: "Bearer " + token, Accept: "application/json" }; if (body !== undefined) headers["Content-Type"] = "application/json; charset=utf-8"; let resp: Response; @@ -39,7 +52,7 @@ async function call(method: string, path: string, body?: unknown, token = get } catch { throw new HubDownError("хаб не отвечает"); } - if (resp.status === 401) throw new AuthError("токен не подошёл"); + if (resp.status === 401) throw new AuthError("токен не подошёл", token); let data: unknown = null; try { data = await resp.json(); } catch { /* тело не JSON */ } if (resp.status >= 500) throw new HubDownError(`хаб ответил ${resp.status}`); diff --git a/web/src/api/queryClient.test.ts b/web/src/api/queryClient.test.ts new file mode 100644 index 0000000..14affb2 --- /dev/null +++ b/web/src/api/queryClient.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { getAuth, setToken } from "../auth/token.ts"; +import { AuthError, HubDownError } from "./client.ts"; +import { makeQueryClient } from "./queryClient.ts"; + +const fail = (e: Error) => makeQueryClient().fetchQuery({ queryKey: ["x"], queryFn: () => Promise.reject(e) }).catch(() => undefined); + +describe("queryClient", () => { + beforeEach(() => setToken("tok")); + + it("401 с текущим токеном — экран входа с причиной «bad»", async () => { + await fail(new AuthError("токен не подошёл", "tok")); + expect(getAuth()).toEqual({ token: "", reason: "bad" }); + }); + it("401 с прежним токеном не стирает текущий (T3.5)", async () => { + await fail(new AuthError("токен не подошёл", "old")); + expect(getAuth()).toEqual({ token: "tok", reason: "none" }); + }); + it("прочие ошибки токен не трогают", async () => { + await fail(new HubDownError("хаб не отвечает")); + expect(getAuth().token).toBe("tok"); + }); + it("повторов нет: запрос с ошибкой выполняется один раз", async () => { + let n = 0; + await makeQueryClient().fetchQuery({ queryKey: ["y"], queryFn: () => { n += 1; return Promise.reject(new HubDownError("x")); } }).catch(() => undefined); + expect(n).toBe(1); + }); +}); diff --git a/web/src/api/queryClient.ts b/web/src/api/queryClient.ts index 7462f94..14fe4b3 100644 --- a/web/src/api/queryClient.ts +++ b/web/src/api/queryClient.ts @@ -1,10 +1,9 @@ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; -import { clearToken, getToken } from "../auth/token.ts"; -import { AuthError } from "./client.ts"; +import { AuthError, dropTokenOn } from "./client.ts"; // 401 в любом запросе или действии — экран входа с пояснением «токен не подошёл» export function makeQueryClient(): QueryClient { - const onError = (e: unknown) => { if (e instanceof AuthError) clearToken(getToken() ? "bad" : "none"); }; + const onError = (e: unknown) => { if (e instanceof AuthError) dropTokenOn(e); }; return new QueryClient({ queryCache: new QueryCache({ onError }), mutationCache: new MutationCache({ onError }), diff --git a/web/src/auth/LoginScreen.test.tsx b/web/src/auth/LoginScreen.test.tsx index fa4b1e3..31757c8 100644 --- a/web/src/auth/LoginScreen.test.tsx +++ b/web/src/auth/LoginScreen.test.tsx @@ -1,10 +1,11 @@ -import { screen, waitFor } from "@testing-library/react"; +import { act, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it } from "vitest"; +import { qk, useBoard } from "../api/queries.ts"; import { json, mockFetch } from "../test/fetchMock.ts"; import { renderWithProviders } from "../test/render.tsx"; import { AuthGate } from "./AuthGate.tsx"; -import { clearToken, getAuth } from "./token.ts"; +import { clearToken, getAuth, setToken } from "./token.ts"; const app =

страница приложения

; @@ -56,10 +57,36 @@ describe("вход", () => { expect(await screen.findByText(/Хаб не отвечает/)).toBeInTheDocument(); }); - it("после 401 в приложении экран входа поясняет причину", async () => { - clearToken("bad"); + it("401 в запросе страницы — экран входа «токен не подошёл», страница скрыта (T3.6)", async () => { + setToken("stale"); + mockFetch(() => json(401, { error: "unauthorized" })); + function Page() { useBoard(); return

страница приложения

; } + renderWithProviders(); + expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument(); + expect(screen.queryByText("страница приложения")).not.toBeInTheDocument(); + expect(getAuth()).toEqual({ token: "", reason: "bad" }); + }); + + it("запоздавший 401 от прежнего токена после входа с новым не выбрасывает на вход (T3.5)", async () => { + setToken("old-token"); + let release!: (r: Response) => void; + const slow = new Promise((r) => { release = r; }); + mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {}))); + function Page() { useBoard(); return

страница приложения

; } + const { client } = renderWithProviders(); + act(() => setToken("new-token")); + release(json(401, { error: "unauthorized" })); + await waitFor(() => expect(client.getQueryState(qk.board)?.status).toBe("error")); // 401 точно обработан + expect(screen.getByText("страница приложения")).toBeInTheDocument(); + expect(getAuth()).toEqual({ token: "new-token", reason: "none" }); + }); + + it("хаб отверг проверку токена (4xx) — текст хаба, не «хаб не отвечает» (T3.2)", async () => { + mockFetch(() => json(400, { error: "bad_request", detail: "заголовок Authorization без Bearer" })); renderWithProviders(app); - await waitFor(() => expect(getAuth().reason).toBe("bad")); - expect(screen.getByText(/Токен не подошёл/)).toBeInTheDocument(); + await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok"); + await userEvent.click(screen.getByRole("button", { name: "Войти" })); + expect(await screen.findByText(/заголовок Authorization без Bearer/)).toBeInTheDocument(); + expect(screen.queryByText(/Хаб не отвечает/)).not.toBeInTheDocument(); }); }); diff --git a/web/src/auth/LoginScreen.tsx b/web/src/auth/LoginScreen.tsx index fba6398..c8c3553 100644 --- a/web/src/auth/LoginScreen.tsx +++ b/web/src/auth/LoginScreen.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { useState, type FormEvent } from "react"; -import { AuthError, checkToken } from "../api/client.ts"; +import { ApiError, AuthError, checkToken } from "../api/client.ts"; import { setToken, TOKEN_RE } from "./token.ts"; import { useAuth } from "./useAuth.ts"; @@ -26,7 +26,9 @@ export function LoginScreen() { qc.clear(); setToken(t); } catch (e) { - setError(e instanceof AuthError ? BAD_TOKEN : HUB_DOWN); + setError(e instanceof AuthError ? BAD_TOKEN + : e instanceof ApiError ? `Хаб отверг проверку токена: ${e.detail ?? e.error}` // T3.2: 4xx — текст хаба + : HUB_DOWN); } finally { setBusy(false); } diff --git a/web/src/auth/token.test.ts b/web/src/auth/token.test.ts index b1d9ac5..574ade9 100644 --- a/web/src/auth/token.test.ts +++ b/web/src/auth/token.test.ts @@ -25,10 +25,13 @@ describe("token", () => { it("закрытое хранилище: токен живёт в памяти, исключений нет", async () => { vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); const t = await import("./token.ts"); expect(t.getToken()).toBe(""); t.setToken("mem"); expect(t.getToken()).toBe("mem"); + t.clearToken("bad"); + expect(t.getAuth()).toEqual({ token: "", reason: "bad" }); }); it("TOKEN_RE пропускает только печатный ASCII без пробелов", async () => { diff --git a/web/src/board/useBoardSource.ts b/web/src/board/useBoardSource.ts index 5a5ac05..d5221d8 100644 --- a/web/src/board/useBoardSource.ts +++ b/web/src/board/useBoardSource.ts @@ -3,10 +3,9 @@ // (M-2), подхват незавершённого пакета после любой загрузки (M-3), остановка опроса (M-4), одно событие состояния (M-5) import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { api, ApiError, AuthError, HubDownError } from "../api/client.ts"; +import { api, ApiError, AuthError, dropTokenOn, HubDownError } from "../api/client.ts"; import { qk, useBoard } from "../api/queries.ts"; import type { AgentView, Batch, CreatedBatch } from "../api/types.ts"; -import { clearToken } from "../auth/token.ts"; import { fromCsv } from "./model/fromCsv.ts"; import { fromHub } from "./model/fromHub.ts"; import { fromJson } from "./model/fromJson.ts"; @@ -44,7 +43,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll const hubModel = useMemo(() => (board.data && dicts ? fromHub(board.data, dicts) : null), [board.data, dicts]); const report = useCallback((e: unknown, hasData: boolean) => { - if (e instanceof AuthError) clearToken("bad"); + if (e instanceof AuthError) dropTokenOn(e); else if (e instanceof HubDownError) setEvent({ kind: "hub-down", hasData }); else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) }); }, []);