Files
orca/src/main/bitbucket/user-request.ts
Neil 0824351fe0 fix(bitbucket): commit credentials atomically and stop blaming the token for network faults (#13887)
* fix(bitbucket): commit credentials atomically and stop blaming the token for network faults

STA-3941 (P0): a credential edit could destroy the last working pair.
`writeFileSync` replaces in place, so a failed or interrupted write truncated
the previous secret, and the secret and metadata files were published
independently — a crash between them left a new secret paired with the old
email, unusable on restart.

- Write credential files through a temp + fsync + rename, so a reader sees
  either the old bytes or the complete new ones, never a truncated file.
- Carry authMode/email/baseUrl inside the encrypted envelope and treat the
  plaintext metadata as display-only. A torn write can now only leave a stale
  displayed account, never an unusable credential. Envelopes written before
  this change still resolve through the metadata fallback.

STA-3944 (P2): timeouts, DNS failures, 5xx and unparseable bodies collapsed to
the same miss as a 401, so Orca told users their credentials were invalid when
the host was simply unreachable — sending them to regenerate a working token.
`/user` now reports rejected vs unreachable, connect explains which happened,
and an unreachable host no longer renders as "Auth failed".

Adds failure-injection coverage for a partial secret write, an interrupt
between publishing the secret and the metadata, legacy envelopes, and the
transport-vs-auth split.

* fix(bitbucket): loop short writes and keep eligibility out of the keychain

Review of the atomic writer found it ignored writeSync's return value. write(2)
may return a short count, so a partial buffer could be fsynced and renamed into
place — the same truncation the change exists to prevent, moved one step later.
Write now loops to completion, with a test that fails without it.

Also stops isBitbucketReviewCreationAuthenticated force-decrypting the stored
secret. Create-PR eligibility is evaluated proactively for the sidebar, so that
popped an OS keychain prompt just from opening a worktree. Presence is enough;
creation itself still fails closed on an unusable credential.
2026-08-11 17:29:22 -07:00

66 lines
2.3 KiB
TypeScript

import { authHeaders, type BitbucketAuthConfig } from './bitbucket-auth-config'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
const USER_REQUEST_TIMEOUT_MS = 4000
export type RawBitbucketUser = {
username?: string | null
display_name?: string | null
account_id?: string | null
}
/**
* Why (STA-3944): a timeout, DNS failure, 5xx, or unparseable body says nothing
* about the credential. Collapsing those to the same miss as a 401 told users
* their token was invalid when the network was simply unreachable.
*/
export type BitbucketUserResult =
| { ok: true; user: RawBitbucketUser }
| { ok: false; reason: 'rejected' | 'unreachable' }
export function accountNameFromUser(user: RawBitbucketUser | null): string | null {
return user?.username ?? user?.display_name ?? user?.account_id ?? null
}
// Shared by live env-var status checks and by connect-time verification, so a
// credential is proven against `/user` before it is ever persisted.
export async function fetchBitbucketUserResult(
config: BitbucketAuthConfig,
timeoutMs: number = USER_REQUEST_TIMEOUT_MS
): Promise<BitbucketUserResult> {
let response: Response
try {
const base = config.baseUrl.replace(/\/+$/, '')
response = await fetch(`${base}/user`, {
headers: {
Accept: 'application/json',
...authHeaders(config)
},
signal: AbortSignal.timeout(timeoutMs)
})
} catch {
return { ok: false, reason: 'unreachable' }
}
if (!response.ok) {
await cancelUnreadResponseBody(response)
// Only the credential-bearing statuses are the credential's fault; 5xx and
// the rest are the server or the path in between.
const rejected = response.status === 401 || response.status === 403
return { ok: false, reason: rejected ? 'rejected' : 'unreachable' }
}
try {
return { ok: true, user: (await response.json()) as RawBitbucketUser }
} catch {
return { ok: false, reason: 'unreachable' }
}
}
/** Convenience for callers that only need the account, not the failure reason. */
export async function fetchBitbucketUser(
config: BitbucketAuthConfig,
timeoutMs: number = USER_REQUEST_TIMEOUT_MS
): Promise<RawBitbucketUser | null> {
const result = await fetchBitbucketUserResult(config, timeoutMs)
return result.ok ? result.user : null
}