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
1 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 { 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(() =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue