fix(auth): order /api/config loads so an older response cannot win
Round 15, one finding — in the refresh path added one commit ago. `refresh()` fires after each secret-bearing save, and nothing ordered the responses. Two debounced settings saves landing close together issue two refreshes; if the earlier request resolves last, it commits the descriptor from *before* the write and the footer reverts to "Plaintext file" for a file that is now encrypted. Reverting a security statement to a stale value is the worst direction for this particular field to be wrong in, and it needed no adverse network conditions — just two saves in quick succession. Loads now claim a monotonic token and may commit only while they still hold the current one. That subsumes the liveness ref the previous commit added: bumping the token on effect teardown covers unmount *and* a `baseUrl` / `authToken` change, and bumping it on every load covers the overlap. The test drives it by holding the `fetch` promises and resolving them out of order — newer first, then older — which fails against the previous implementation and passes here. The remaining suppressed comment is the hand-off's check-to-delete race, which is the cross-process guarantee tracked in #2082. Full `npm run ci` green. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
0c52172379
commit
0ab542cbee
2 changed files with 108 additions and 52 deletions
|
|
@ -11,6 +11,18 @@ import { describe, it, expect, vi } from "vitest";
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { useInitialConfig } from "@inspector/core/react/useInitialConfig";
|
import { useInitialConfig } from "@inspector/core/react/useInitialConfig";
|
||||||
|
|
||||||
|
function payloadWithPlaintext(plaintext: boolean): Response {
|
||||||
|
return jsonResponse({
|
||||||
|
secretStorage: {
|
||||||
|
kind: "file",
|
||||||
|
reason: "fallback",
|
||||||
|
durable: true,
|
||||||
|
plaintext,
|
||||||
|
path: "/tmp/secrets.json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function jsonResponse(body: unknown, ok = true): Response {
|
function jsonResponse(body: unknown, ok = true): Response {
|
||||||
return {
|
return {
|
||||||
ok,
|
ok,
|
||||||
|
|
@ -506,6 +518,46 @@ describe("useInitialConfig", () => {
|
||||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("commits only the newest load when refreshes overlap", async () => {
|
||||||
|
// Two debounced settings saves landing close together fire two
|
||||||
|
// refreshes, and nothing orders the responses. Before the request
|
||||||
|
// token, an earlier request resolving last put the footer back to the
|
||||||
|
// descriptor it had *before* the write — reverting a security
|
||||||
|
// statement to a stale value, which is the worst direction for this
|
||||||
|
// particular field to be wrong in.
|
||||||
|
const resolvers: Array<(v: Response) => void> = [];
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(
|
||||||
|
() => new Promise<Response>((resolve) => resolvers.push(resolve)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
|
||||||
|
);
|
||||||
|
// Mount fetch is resolvers[0]; settle it so `loading` clears.
|
||||||
|
await act(async () => {
|
||||||
|
resolvers[0]?.(payloadWithPlaintext(true));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
// Two overlapping refreshes: the first reports the pre-write state,
|
||||||
|
// the second the post-write state.
|
||||||
|
act(() => result.current.refresh()); // resolvers[1] — older
|
||||||
|
act(() => result.current.refresh()); // resolvers[2] — newer
|
||||||
|
|
||||||
|
// Resolve the NEWER one first, then the older one.
|
||||||
|
await act(async () => {
|
||||||
|
resolvers[2]?.(payloadWithPlaintext(false));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
resolvers[1]?.(payloadWithPlaintext(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The late-arriving older response must not win.
|
||||||
|
expect(result.current.secretStorage?.plaintext).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("drops a refresh that resolves after unmount", async () => {
|
it("drops a refresh that resolves after unmount", async () => {
|
||||||
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
|
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
|
||||||
const { result, unmount } = renderHook(() =>
|
const { result, unmount } = renderHook(() =>
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,10 @@ export interface UseInitialConfigResult {
|
||||||
* about a file that is no longer plaintext. The backend already re-derives
|
* about a file that is no longer plaintext. The backend already re-derives
|
||||||
* the descriptor per request; this is the client half of that.
|
* the descriptor per request; this is the client half of that.
|
||||||
*
|
*
|
||||||
* Safe to call at any time: the GET is idempotent, and a response that
|
* Safe to call at any time and safe to overlap: the GET is idempotent, and
|
||||||
* arrives after unmount is dropped.
|
* loads are ordered by a request token, so only the most recent one may
|
||||||
|
* commit — a response that arrives after unmount, after a re-fetch, or out
|
||||||
|
* of order behind a newer refresh is dropped.
|
||||||
*/
|
*/
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -179,63 +181,65 @@ export function useInitialConfig(
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
// `isCancelled` lets the effect drop a response that resolves after unmount or
|
/**
|
||||||
// after a re-run (baseUrl/authToken change), so a stale payload can't overwrite
|
* Monotonic request token. Every load claims the next value; a load may
|
||||||
// current state. React 18 no longer warns on setState-after-unmount — it's a
|
* commit only while it still holds the current one.
|
||||||
// silent no-op — so the guard is about correctness, not the warning.
|
*
|
||||||
const load = useCallback(
|
* This replaces a per-caller "am I cancelled" flag, which was enough for
|
||||||
async (isCancelled: () => boolean): Promise<void> => {
|
* the mount fetch and *not* enough once `refresh()` existed. Two overlapping
|
||||||
const headers: Record<string, string> = {};
|
* refreshes — two debounced settings saves landing close together — are
|
||||||
if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`;
|
* unordered, so the earlier request could resolve last and put the footer
|
||||||
try {
|
* back to the descriptor it had before the write. Stale-response ordering
|
||||||
const res = await doFetch(`${base}/api/config`, {
|
* matters more here than in most places precisely because the field it
|
||||||
method: "GET",
|
* would revert is a security statement.
|
||||||
headers,
|
*
|
||||||
});
|
* Bumping on effect teardown covers the other two cases for free: unmount,
|
||||||
if (isCancelled() || !res.ok) return;
|
* and a `baseUrl`/`authToken` change that re-runs the effect.
|
||||||
const body = (await res.json()) as ConfigPayload;
|
*/
|
||||||
if (isCancelled()) return;
|
const generation = useRef(0);
|
||||||
// Tolerate missing/non-usable fields — a legacy backend that omits any
|
|
||||||
// of them leaves that value at its "unavailable" default rather than
|
const load = useCallback(async (): Promise<void> => {
|
||||||
// showing a bogus value.
|
const mine = ++generation.current;
|
||||||
setVersion(usableString(body.version));
|
const stale = () => generation.current !== mine;
|
||||||
setSandboxUrl(usableString(body.sandboxUrl));
|
const headers: Record<string, string> = {};
|
||||||
// Only an explicit `false` makes the list read-only; a missing field
|
if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`;
|
||||||
// (legacy backend) stays writable.
|
try {
|
||||||
setWritable(body.writable !== false);
|
const res = await doFetch(`${base}/api/config`, {
|
||||||
setSecretStorage(usableSecretStorage(body.secretStorage));
|
method: "GET",
|
||||||
} catch {
|
headers,
|
||||||
// Network error / aborted fetch: leave every field at its default
|
});
|
||||||
// (version/sandboxUrl undefined, writable true).
|
if (stale() || !res.ok) return;
|
||||||
} finally {
|
const body = (await res.json()) as ConfigPayload;
|
||||||
if (!isCancelled()) setLoading(false);
|
if (stale()) return;
|
||||||
}
|
// Tolerate missing/non-usable fields — a legacy backend that omits any
|
||||||
},
|
// of them leaves that value at its "unavailable" default rather than
|
||||||
[base, authToken, doFetch],
|
// showing a bogus value.
|
||||||
);
|
setVersion(usableString(body.version));
|
||||||
|
setSandboxUrl(usableString(body.sandboxUrl));
|
||||||
|
// Only an explicit `false` makes the list read-only; a missing field
|
||||||
|
// (legacy backend) stays writable.
|
||||||
|
setWritable(body.writable !== false);
|
||||||
|
setSecretStorage(usableSecretStorage(body.secretStorage));
|
||||||
|
} catch {
|
||||||
|
// Network error / aborted fetch: leave every field at its default
|
||||||
|
// (version/sandboxUrl undefined, writable true).
|
||||||
|
} finally {
|
||||||
|
if (!stale()) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [base, authToken, doFetch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
void load();
|
||||||
void load(() => cancelled);
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
// Invalidate whatever is in flight: this fires on unmount and on a
|
||||||
|
// `baseUrl`/`authToken` change, and in both cases a response for the
|
||||||
|
// old inputs must not commit.
|
||||||
|
generation.current++;
|
||||||
};
|
};
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// A manual re-fetch shares `load`'s cancellation contract, but has no
|
|
||||||
// effect teardown to key off — so it tracks its own liveness through a ref
|
|
||||||
// that the unmount effect flips. Without that, a refresh fired just before
|
|
||||||
// unmount would still call `setState` on a dead component.
|
|
||||||
const alive = useRef(true);
|
|
||||||
useEffect(() => {
|
|
||||||
alive.current = true;
|
|
||||||
return () => {
|
|
||||||
alive.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
void load(() => !alive.current);
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
return { version, sandboxUrl, writable, secretStorage, loading, refresh };
|
return { version, sandboxUrl, writable, secretStorage, loading, refresh };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue