* fix: construct AsyncEntry inside the try so keychain degradation engages `KeyringSecretStore` built its `AsyncEntry` outside the `try` in `get`, `set`, and `delete`. `AsyncEntry::new` performs the platform-store setup (on Linux, the Secret Service connect with a keyutils fallback) and throws when no backend is reachable, so the documented degradation contract never engaged for a construction-time failure: the raw keyring error escaped, 500ing every `GET /api/servers` on a box without a Secret Service (the published container, which has no D-Bus session). `expectedSecretFields` always includes the OAuth slot, so `rehydrateConfig` constructs an entry for every server — the default seeded catalog was enough to 500 the first list load, before any server was added. And because the escaping error wasn't a `KeychainUnavailableError`, it bypassed both the routes' 503 translation and the `migratePlaintextSecrets` skip branch, yielding a generic 500 instead of the actionable message. Moving construction inside the existing `try` restores the contract for that failure mode: `get` returns null, `delete` no-ops, and `set` is the only operation that throws — as `KeychainUnavailableError`, which is what the 503 translation and the migration skip both match on. The test stub's constructor could not fail, which is why the coverage gate never saw the gap. Adds a `constructorThrows` hook alongside the existing method-level `failures` flags and covers all three methods plus `deleteAllForServer` under it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F * test: pin why the OAuth secret field is unconditional `expectedSecretFields` always includes the OAuth slot, and #1848's report frames that as why the 500 fired so broadly — which invites a follow-up that skips the keychain read for servers with "no OAuth config". That change would lose data. `extractSecretsFromStored` deletes the `oauth` block outright when `clientSecret` was its only property, so such a server carries NO marker on disk that a secret exists — the keychain is the sole record, and the unconditional slot is what finds it again. Gating the read on a disk-visible `oauth` block would silently stop rehydrating exactly that shape. The existing tests do fail if the slot is made conditional (verified by applying the change: 4 red), but only under names that explain nothing about the consequence — `always lists the OAuth slot first` reads like a tautology worth updating rather than a trap. Add a round-trip case that states the consequence, so the next reader sees the field is load-bearing rather than defensive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b3d2bce32
commit
bb219dc001
1 changed files with 111 additions and 16 deletions
|
|
@ -80,18 +80,27 @@ export interface SecretStore {
|
|||
* a real value and must round-trip).
|
||||
*
|
||||
* **Availability behavior.** When the keychain is unavailable (the
|
||||
* typical case is Linux without libsecret / gnome-keyring), `set` is
|
||||
* the only operation that throws `KeychainUnavailableError` — that's
|
||||
* the moment where data would actually be lost. `get` returns `null`
|
||||
* (as if no entry existed) and the destructive operations silently
|
||||
* no-op (there's nothing to delete anyway). This keeps non-secret
|
||||
* flows working on a stock CI runner / minimal Linux box; the user
|
||||
* only hits a hard error when they actually try to save a secret.
|
||||
* typical case is Linux without libsecret / gnome-keyring, or a
|
||||
* container with no D-Bus session), `set` is the only operation that
|
||||
* throws `KeychainUnavailableError` — that's the moment where data
|
||||
* would actually be lost. `get` returns `null` (as if no entry
|
||||
* existed) and the destructive operations silently no-op (there's
|
||||
* nothing to delete anyway). This keeps non-secret flows working on a
|
||||
* stock CI runner / minimal Linux box; the user only hits a hard error
|
||||
* when they actually try to save a secret.
|
||||
*
|
||||
* The `AsyncEntry` construction is deliberately **inside** each
|
||||
* method's `try`: `AsyncEntry::new` performs the platform-store setup
|
||||
* (on Linux, the Secret Service connect with a keyutils fallback) and
|
||||
* throws when no backend is reachable. Constructing it outside the
|
||||
* `try` let that raw error escape, 500ing every `GET /api/servers`
|
||||
* before any secret was involved — the contract above only holds if
|
||||
* construction failures are funneled through the same handlers (#1848).
|
||||
*/
|
||||
export class KeyringSecretStore implements SecretStore {
|
||||
async get(serverId: string, field: string): Promise<string | null> {
|
||||
const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
|
||||
try {
|
||||
const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
|
||||
const v = await entry.getPassword();
|
||||
return v ?? null;
|
||||
} catch {
|
||||
|
|
@ -104,8 +113,8 @@ export class KeyringSecretStore implements SecretStore {
|
|||
}
|
||||
|
||||
async set(serverId: string, field: string, value: string): Promise<void> {
|
||||
const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
|
||||
try {
|
||||
const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
|
||||
await entry.setPassword(value);
|
||||
} catch (err) {
|
||||
// The only operation that hard-fails — if we can't persist the
|
||||
|
|
@ -116,17 +125,17 @@ export class KeyringSecretStore implements SecretStore {
|
|||
}
|
||||
|
||||
async delete(serverId: string, field: string): Promise<void> {
|
||||
const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
|
||||
try {
|
||||
const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
|
||||
await entry.deleteCredential();
|
||||
} catch {
|
||||
// Both reasons for a throw collapse to the same desired outcome
|
||||
// Every reason for a throw collapses to the same desired outcome
|
||||
// ("the entry isn't there anymore"): `deleteCredential` raises
|
||||
// NoEntry for a missing credential, and the native binding
|
||||
// raises a runtime error when the keychain itself is unavailable.
|
||||
// We treat both as success — there's no value to lose either
|
||||
// way, and `set` is the operation that hard-fails when the
|
||||
// keychain is actually down.
|
||||
// NoEntry for a missing credential, and both the constructor and
|
||||
// the native binding raise a runtime error when the keychain
|
||||
// itself is unavailable. We treat all of them as success — there's
|
||||
// no value to lose either way, and `set` is the operation that
|
||||
// hard-fails when the keychain is actually down.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue