fix(auth): address Copilot review round 12
Three inline findings and one of the two suppressed. The other suppressed one — that the hand-off's delete is still a TOCTOU — is the cross-process guarantee, decided against for this PR and tracked as #2082. **My convergence tests did not converge anything.** The reviewer caught that the "two stores racing" tests cannot race: `serialize` keys its queue on the resolved path *process-wide*, which I made it do in the previous commit precisely so two instances would take turns. So the tests passed by serialization while their names and comments claimed they were exercising the optimistic retry, and the retry's successful path had no coverage at all. A fix I was pleased with silently disabled the tests written to prove it worked. Split in two now, both honest about what they drive: - `in-process mutations are serialized per path` keeps the multi-instance cases, which pin a real property — it is what makes the in-process case correct *without* relying on the retry. - `cross-process convergence` injects one external write between the store's write and its verifying read, via raw `fs` so it bypasses the queue the way another process would. The surviving entry can only be explained by the re-apply, so these pin the mechanism rather than the outcome. Covers a set, a delete, and the encrypted path where every attempt re-derives. **A well-formed envelope is not an openable one.** `readOnDiskEncryption` classified by structure alone, so a wrong or unset `MCP_INSPECTOR_SECRET_KEY` produced the quiet, healthy "Encrypted file" footer for a file whose every `get` returns `null` and every `set` is refused — the exact state the unreadable footer promises to warn about. It now authenticates: decrypt and discard, costing one scrypt derivation (~23ms) per descriptor build, once per `/api/config`. A test asserted the old behavior and has been rewritten; the comment on it explains why "describable" has to mean *accurately* describable. **The copy button's `aria-label` was eating the warning.** `aria-label` replaces descendant text, so a screen-reader user heard "Copy secrets file path …" and never heard "Plaintext file" — the one sentence that should change their mind about typing a secret. The status now leads the accessible name and the path follows, which also keeps the name a superset of the visible text (WCAG 2.5.3). **The spec still described the pre-#1950 invariant** in its migration bullets: every plaintext secret lifted into the keychain and removed from `mcp.json`, with only `KeychainUnavailableError` named. Both bullets now describe the selected store, the durability gate that withholds the disk delete for a session-scoped store, the strict read behind the store-wins lookup, and `SecretStoreUnavailableError` as the family the handler catches. Full `npm run ci` green, including the per-file ≥90 gate — the new validator and retry branches needed six more cases to clear it. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
219765efff
commit
bc4b774d80
3 changed files with 257 additions and 42 deletions
|
|
@ -52,7 +52,7 @@ export const EncryptedFile: Story = {
|
|||
// and stays readable in the button's accessible name.
|
||||
await expect(
|
||||
canvas.getByRole("button", {
|
||||
name: "Copy secrets file path: /home/node/.mcp-inspector/secrets.json",
|
||||
name: "Secrets: Encrypted file. Owner-only permissions. Copy secrets file path: /home/node/.mcp-inspector/secrets.json",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,9 +45,12 @@ describe("SecretStorageFooter", () => {
|
|||
"Secrets: Encrypted file. Owner-only permissions.",
|
||||
);
|
||||
expect(band).toHaveAttribute("data-tone", "neutral");
|
||||
// The accessible name must carry the *status* as well as the affordance:
|
||||
// `aria-label` replaces descendant text, so labelling it only "Copy…"
|
||||
// would announce the button and discard the warning the band exists for.
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Copy secrets file path: /home/node/.mcp-inspector/secrets.json",
|
||||
name: "Secrets: Encrypted file. Owner-only permissions. Copy secrets file path: /home/node/.mcp-inspector/secrets.json",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -136,6 +139,19 @@ describe("SecretStorageFooter", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("announces the security status, not just the copy affordance", () => {
|
||||
// The regression this pins: `aria-label` overrides the button's
|
||||
// descendant text, so a screen-reader user heard "Copy secrets file
|
||||
// path …" and lost "Plaintext file" entirely — the one sentence that
|
||||
// should change their mind about typing a secret.
|
||||
renderWithMantine(<SecretStorageFooter info={plaintextFile} />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toHaveAccessibleName(
|
||||
/^Secrets: Plaintext file\. Owner-only permissions\./,
|
||||
);
|
||||
expect(button).toHaveAccessibleName(/Copy secrets file path/);
|
||||
});
|
||||
|
||||
it("renders nothing when the backend didn't report a store", () => {
|
||||
// A guessed answer under a secret field is worse than no answer, so the
|
||||
// unknown case is silence rather than a default.
|
||||
|
|
|
|||
|
|
@ -291,12 +291,19 @@ export function SecretStorageFooter({ info }: SecretStorageFooterProps) {
|
|||
{({ copied, copy }) =>
|
||||
band(
|
||||
<CaveatTooltip label={copied ? "Path copied" : tip}>
|
||||
{/* The accessible name carries the path itself: a screen-reader
|
||||
user gets no benefit from a clipboard they cannot inspect, so
|
||||
the one place the location is still *readable* is here. */}
|
||||
{/* The accessible name has to carry **both** halves. `aria-label`
|
||||
replaces the button's descendant text, so labelling it only
|
||||
"Copy secrets file path …" announced the affordance and threw
|
||||
away the security status — "Plaintext file", "Unreadable
|
||||
file", "not owner-only" — which is the entire reason this band
|
||||
exists. The status leads, because it is the part a user needs
|
||||
before deciding to type a secret; the path follows, since a
|
||||
clipboard is no use to someone who cannot inspect it. Leading
|
||||
with the visible text also keeps the accessible name a
|
||||
superset of it (WCAG 2.5.3). */}
|
||||
<CopyTarget
|
||||
onClick={copy}
|
||||
aria-label={`Copy secrets file path: ${info.path}`}
|
||||
aria-label={`Secrets: ${footerMessage(info)} Copy secrets file path: ${info.path}`}
|
||||
>
|
||||
{row}
|
||||
</CopyTarget>
|
||||
|
|
|
|||
Loading…
Reference in a new issue