fix(auth): commit the utility round 17 wired in, and drop a prohibited cast

**Round 17 pushed a branch that does not compile, and I reported it green.**
`refreshingPersist.ts` and its test were new files; `git commit -s -a` stages
modified *tracked* files and not untracked ones, so `App.tsx` went to origin
importing a module that was never committed. `npm run ci` passed against my
working tree, where both files exist — so the claim was true of what I had and
false of what I pushed, which is the worst shape a green result can take.

That is the second staging mistake on this branch. The first was `git commit
-o <paths>`, which commits whole files rather than my hunks and swept in a
collaborator's in-flight tests. Both come from trusting a flag's shorthand
instead of reading what was actually staged, so from here the commit goes
through an explicit `git add -A` plus a look at `git status --short` before
the gate runs — the check is cheap and it is the only one that describes the
pushed state rather than the local one.

Also replaces the `as unknown as` on the test logger. The rules prohibit it,
and it was doing real damage beyond style: forcing a four-method object
through `pino.Logger` erased the fact that the stand-in was not one, so a
change in how the server logs — a child logger, bindings, a different level —
would have gone unnoticed. It is a real pino logger writing to a capturing
destination now, which needs no cast because the destination interface is
satisfied structurally.

Both remaining suppressed comments are the cross-process guarantee, tracked
in #2082.

Full `npm run ci` green, run against a fully staged tree this time.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 21:06:47 -04:00
commit 469e59a7c4
2 changed files with 110 additions and 14 deletions

View file

@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { refreshingPersist } from "./refreshingPersist";
describe("refreshingPersist", () => {
it("refreshes after the persist resolves, not before", async () => {
// Ordering is the point: refreshing first would re-fetch the descriptor
// that the pending write is about to invalidate, which is the stale read
// this wrapper exists to prevent, one step earlier.
const order: string[] = [];
const persist = vi.fn(async () => {
order.push("persist");
});
const refresh = vi.fn(() => {
order.push("refresh");
});
await refreshingPersist(persist, refresh)();
expect(order).toEqual(["persist", "refresh"]);
});
it("passes every argument through unchanged", async () => {
// The server-settings caller is `(id, settings)`; the wrapper must be
// transparent or it silently drops the entry it was meant to save.
// Typed rather than given placeholder parameters: this scope's eslint
// does not honour the `_` prefix, and the signature is what matters.
const persist = vi
.fn<(id: string, settings: unknown) => Promise<void>>()
.mockResolvedValue(undefined);
const settings = { headers: [] };
await refreshingPersist(persist, vi.fn())("srv-1", settings);
expect(persist).toHaveBeenCalledWith("srv-1", settings);
});
it("does not refresh when the persist fails, and propagates the error", async () => {
// A save that threw did not write, so there is nothing new to describe —
// and the caller is mid-error-handling, which is the wrong moment to
// repaint state that did not change.
const boom = new Error("keychain unavailable");
const persist = vi.fn().mockRejectedValue(boom);
const refresh = vi.fn();
await expect(refreshingPersist(persist, refresh)()).rejects.toBe(boom);
expect(refresh).not.toHaveBeenCalled();
});
it("returns a function usable as a persist callback more than once", async () => {
const persist = vi.fn(async () => {});
const refresh = vi.fn();
const wrapped = refreshingPersist(persist, refresh);
await wrapped();
await wrapped();
expect(persist).toHaveBeenCalledTimes(2);
expect(refresh).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,31 @@
/**
* Wrap a persist callback so it refreshes derived state after it succeeds.
*
* Written for one case (#1950): saving a secret can change what the secrets
* file *is* — the first save under a newly-set passphrase re-encrypts a
* pre-existing plaintext file — so the `secretStorage` descriptor behind the
* settings-modal footer has to be re-fetched afterwards, or the footer keeps
* reporting the state it had at page load.
*
* It exists as a named unit rather than two inline `await …; refresh()` pairs
* in `App.tsx` because of where the coverage gate can reach. `App.tsx` is
* deliberately outside the coverage `include` (a ~4.5k-line composition root),
* so wiring written there is not exercised by anything: the hook's own tests
* and the modal's own tests can both pass while the callback that connects
* them is broken, leaving the security footer stale after exactly the write
* that changed it. Review round 17 of #1950 caught that, after three earlier
* rounds had each found a defect in this same path.
*
* `refresh` runs **only on success**. A persist that threw did not write, so
* there is nothing new to describe, and re-fetching would just repaint the
* state already on screen while the caller is handling the failure.
*/
export function refreshingPersist<Args extends unknown[]>(
persist: (...args: Args) => Promise<void>,
refresh: () => void,
): (...args: Args) => Promise<void> {
return async (...args: Args) => {
await persist(...args);
refresh();
};
}