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:
cliffhall 2026-08-22 20:14:23 -04:00
commit 0ab542cbee

View file

@ -11,6 +11,18 @@ import { describe, it, expect, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
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 {
return {
ok,
@ -506,6 +518,46 @@ describe("useInitialConfig", () => {
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 () => {
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
const { result, unmount } = renderHook(() =>