fix(web): запоздавший 401 прежнего токена не выбрасывает на вход, 4xx на входе — текст хаба (T3.2–T3.6, #29)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3dc54c7bb1
commit
ab3b05007a
@ -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);
|
||||
|
||||
@ -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}`);
|
||||
|
||||
28
web/src/api/queryClient.test.ts
Normal file
28
web/src/api/queryClient.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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 }),
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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) });
|
||||
}, []);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user