fix(auth): stop a config-only PUT deleting stored secrets (#2084)

Round 24 produced no inline comments again. Of the three suppressed items, one
is a pre-existing data-loss bug that predates #1950 entirely.

**Editing a server's config deletes its stored OAuth client secret — from the
keychain, not only from the stores this PR adds.** Filed as #2084 and fixed
here, since the review surfaced it and these lines were already changing.

`useServers.updateServer` deliberately sends `{ id, config }` with no
`settings`, so a config-only save cannot wipe persisted settings. The route
reads that as `preserve` and re-derives settings from the *on-disk* entry —
which by #1356's design no longer holds the secrets. The submitted secret set
is therefore empty, `expectedSecretFields` always lists the OAuth slot, and
the reconcile deleted a value the user never touched. The rename branch was
given `mergeRenameKeychainSecrets` for exactly this reason; the in-place
branch never got the equivalent.

The first fix was too broad — skip obsolete deletion whenever the intent is
`preserve` — and an existing test caught it immediately: stdio `env` is part
of `config`, so a config-only PUT that drops a key really has retired that
secret. Obsolete detection is per-field now:

- **`env:` fields** are implied by the entry's shape, so the new entry no
  longer expecting one is proof it was retired.
- **The OAuth slot** is implied by nothing, so absence counts as "cleared"
  only when the caller spoke about settings at all.

Both directions are tested, and the pre-existing env-key reconcile test still
passes.

Also:

- `refreshingPersist` moves to `lib/`. The combinator computes nothing, but
  what it exists to do is sequence side effects, which is the `lib` half of
  the repo's split; I judged it the other way on the grounds that the
  function is a pure higher-order one, and the rule is about what a module is
  *for*.
- `SecretStorageInfo.plaintext` is documented as file-only and *omitted*
  elsewhere rather than "always false", which is what the producer does and
  what `usableSecretStorage` enforces — the old wording invited a consumer to
  build the exact shape the web client discards.

Full `npm run ci` green, staged tree verified first.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 23:30:17 -04:00
commit af3d940e78
2 changed files with 171 additions and 16 deletions

View file

@ -1,64 +0,0 @@
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("refreshes even when the persist fails, and still propagates the error", async () => {
// This asserted the opposite until round 22, on reasoning that sounded
// right and was wrong for this write order: both persistence paths write
// the secret store *before* the file, so a rejected disk write can follow
// a `set` that already upgraded `secrets.json` from plaintext to
// encrypted. Skipping the refresh there left the footer describing a file
// that no longer exists in that form.
//
// The asymmetry decides it: a needless refresh costs one idempotent GET;
// a missed one leaves a security statement wrong until reload.
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).toHaveBeenCalledTimes(1);
});
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

@ -1,44 +0,0 @@
/**
* 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 in a `finally`, **not only on success**, and the first
* version of this got that wrong on plausible-sounding reasoning: "a persist
* that threw did not write, so there is nothing new to describe". That is
* false for this write order. Both persistence paths write the secret store
* *before* the file — so a rejected disk write can follow a `set` that has
* already upgraded `secrets.json` from plaintext to encrypted. Skipping the
* refresh there leaves the footer describing a file that no longer exists in
* that form, which is the stale-descriptor bug this wrapper exists to
* prevent, reached through its own error path.
*
* The asymmetry is what settles it: refreshing after a failure that changed
* nothing costs one idempotent GET, while not refreshing after a failure that
* changed something leaves a security statement wrong until reload.
*/
export function refreshingPersist<Args extends unknown[]>(
persist: (...args: Args) => Promise<void>,
refresh: () => void,
): (...args: Args) => Promise<void> {
return async (...args: Args) => {
try {
await persist(...args);
} finally {
refresh();
}
};
}