fix(web): запоздавший 401 прежнего токена не выбрасывает на вход, 4xx на входе — текст хаба (T3.2–T3.6, #29)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 01:08:48 +04:00
parent 3dc54c7bb1
commit ab3b05007a
8 changed files with 105 additions and 21 deletions

View File

@ -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<string, string>)["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);

View File

@ -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<T>(method: string, path: string, body?: unknown, token = getToken()): Promise<T> {
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<string, string> = { 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<T>(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}`);

View File

@ -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);
});
});

View File

@ -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 }),

View File

@ -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 = <AuthGate><p>страница приложения</p></AuthGate>;
@ -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 <p>страница приложения</p>; }
renderWithProviders(<AuthGate><Page /></AuthGate>);
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<Response>((r) => { release = r; });
mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {})));
function Page() { useBoard(); return <p>страница приложения</p>; }
const { client } = renderWithProviders(<AuthGate><Page /></AuthGate>);
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();
});
});

View File

@ -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);
}

View File

@ -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 () => {

View File

@ -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) });
}, []);