-
Notifications
You must be signed in to change notification settings - Fork 10
fix(serve,ui): keep the UI session alive across server restarts (cache-proof shell + 401 self-heal) #668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
fix(serve,ui): keep the UI session alive across server restarts (cache-proof shell + 401 self-heal) #668
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| /** | ||
| * Tests for the 401 self-heal path in the API client. | ||
| * | ||
| * In single-process UI mode (`kbagent serve --ui`) auth rides on the | ||
| * HttpOnly `kbagent_session` cookie set by `GET /`. After a server restart | ||
| * the cookie is stale, every API call answers 401, and -- before this fix -- | ||
| * the SPA silently rendered empty lists. The client now re-fetches the shell | ||
| * once (`cache: "reload"` so no browser cache can swallow the request, which | ||
| * is exactly how the bug happened in the first place), retries the request, | ||
| * and only then surfaces a visible "session expired" signal. | ||
| * | ||
| * Runs in the default vitest node environment: `window` is stubbed with a | ||
| * real EventTarget so `dispatchEvent`/`addEventListener` behave like the | ||
| * browser's, and `fetch` is a vi.fn() -- no jsdom dependency needed. | ||
| */ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import { api, ApiError, SESSION_EXPIRED_EVENT } from "./client"; | ||
|
|
||
| function jsonResponse(status: number, body: unknown): Response { | ||
| return new Response(JSON.stringify(body), { | ||
| status, | ||
| headers: { "content-type": "application/json" }, | ||
| }); | ||
| } | ||
|
|
||
| function unauthorized(message = "Invalid Bearer token."): Response { | ||
| return jsonResponse(401, { | ||
| status: "error", | ||
| error: { code: "UNAUTHORIZED", message }, | ||
| }); | ||
| } | ||
|
|
||
| function shellResponse(): Response { | ||
| return new Response("<!doctype html>", { | ||
| status: 200, | ||
| headers: { "content-type": "text/html" }, | ||
| }); | ||
| } | ||
|
|
||
| /** URL of a fetch call, whether invoked with a string or a Request. */ | ||
| function calledUrl(input: unknown): string { | ||
| return typeof input === "string" ? input : (input as Request).url; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| const fakeWindow = new EventTarget() as unknown as Window & typeof globalThis; | ||
| (fakeWindow as unknown as { location: { origin: string } }).location = { | ||
| origin: "http://127.0.0.1:8001", | ||
| }; | ||
| vi.stubGlobal("window", fakeWindow); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| describe("request 401 retry", () => { | ||
| it("re-bootstraps the session cookie and retries once after a 401", async () => { | ||
| const fetchMock = vi | ||
| .fn<typeof fetch>() | ||
| .mockResolvedValueOnce(unauthorized()) // GET /api/projects -> stale cookie | ||
| .mockResolvedValueOnce(shellResponse()) // GET / -> fresh Set-Cookie | ||
| .mockResolvedValueOnce(jsonResponse(200, { projects: [] })); // retry | ||
| vi.stubGlobal("fetch", fetchMock); | ||
|
|
||
| await expect(api.get("/projects")).resolves.toEqual({ projects: [] }); | ||
|
|
||
| expect(fetchMock).toHaveBeenCalledTimes(3); | ||
| const [shellUrl, shellInit] = fetchMock.mock.calls[1]; | ||
| expect(calledUrl(shellUrl)).toBe("/"); | ||
| // cache: "reload" is the load-bearing part -- a plain fetch("/") could be | ||
| // answered from the very browser cache that made the cookie go stale. | ||
| expect(shellInit).toMatchObject({ cache: "reload", credentials: "include" }); | ||
| }); | ||
|
|
||
| it("dispatches a session-expired event when the retry still 401s", async () => { | ||
| const fetchMock = vi | ||
| .fn<typeof fetch>() | ||
| .mockResolvedValueOnce(unauthorized()) | ||
| .mockResolvedValueOnce(shellResponse()) | ||
| .mockResolvedValueOnce(unauthorized("Invalid Bearer token.")); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
| const events: CustomEvent[] = []; | ||
| window.addEventListener(SESSION_EXPIRED_EVENT, (evt) => { | ||
| events.push(evt as CustomEvent); | ||
| }); | ||
|
|
||
| await expect(api.get("/projects")).rejects.toMatchObject({ status: 401 }); | ||
|
|
||
| // Exactly one retry -- no loop of shell re-fetches on a genuinely | ||
| // broken session. | ||
| expect(fetchMock).toHaveBeenCalledTimes(3); | ||
| expect(events).toHaveLength(1); | ||
| expect(events[0].detail).toMatchObject({ | ||
| code: "UNAUTHORIZED", | ||
| message: "Invalid Bearer token.", | ||
| }); | ||
| }); | ||
|
|
||
| it("does not retry or dispatch on non-401 errors", async () => { | ||
| const fetchMock = vi | ||
| .fn<typeof fetch>() | ||
| .mockResolvedValueOnce( | ||
| jsonResponse(502, { | ||
| status: "error", | ||
| error: { code: "API_ERROR", message: "upstream down" }, | ||
| }), | ||
| ); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
| const events: Event[] = []; | ||
| window.addEventListener(SESSION_EXPIRED_EVENT, (evt) => events.push(evt)); | ||
|
|
||
| await expect(api.get("/projects")).rejects.toBeInstanceOf(ApiError); | ||
|
|
||
| expect(fetchMock).toHaveBeenCalledTimes(1); | ||
| expect(events).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("shares a single shell re-fetch across concurrent 401s", async () => { | ||
| let releaseShell: (() => void) | undefined; | ||
| const shellGate = new Promise<void>((resolve) => { | ||
| releaseShell = resolve; | ||
| }); | ||
| const seen401 = new Set<string>(); | ||
| let shellFetches = 0; | ||
| const fetchMock = vi.fn<typeof fetch>((input) => { | ||
| const url = calledUrl(input); | ||
| if (url === "/") { | ||
| shellFetches += 1; | ||
| return shellGate.then(shellResponse); | ||
| } | ||
| if (!seen401.has(url)) { | ||
| seen401.add(url); | ||
| return Promise.resolve(unauthorized()); | ||
| } | ||
| return Promise.resolve(jsonResponse(200, { ok: url })); | ||
| }); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
|
|
||
| const inFlight = Promise.all([api.get("/projects"), api.get("/jobs")]); | ||
| // Let both requests hit their 401 and pile onto the shell fetch. | ||
| await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| releaseShell?.(); | ||
|
|
||
| await expect(inFlight).resolves.toEqual([ | ||
| { ok: "/api/projects" }, | ||
| { ok: "/api/jobs" }, | ||
| ]); | ||
| expect(shellFetches).toBe(1); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.