Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,18 @@ export async function activate(
)
context.subscriptions.push(service)
vscode.commands.registerCommand(`${EXTENSION_PREFIX}.login`, async () => {
// The getSession call is intentionally side-effect-only: passing
// `createIfNone: true` triggers the login flow if no session
// exists; we don't need the returned session here.
await vscode.authentication.getSession(EXTENSION_PREFIX, [], {
createIfNone: true,
})
// An explicit Login must always let the user re-enter a token, even when a
// stale or cached session already exists. `createIfNone` only prompts when
// NO session is present, so a leftover session made the command a silent
// no-op and left the user with no way in at all (SURF-414).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internal ticket ID in source

Low Severity

New comments embed the Linear-style id SURF-414 in shipped source and tests. Fleet public-surface hygiene forbids ticket refs in code and comments, so this leaks an internal tracker id into the public repo and extension bundle.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9ee6c54. Configure here.

// `forceNewSession` always runs the token flow. The returned session is
// unused; the catch swallows the rejection VSCode raises when the user
// dismisses the prompt, which is a cancel and not a command failure.
try {
await vscode.authentication.getSession(EXTENSION_PREFIX, [], {
forceNewSession: true,
})
} catch {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Login catch hides real failures

Medium Severity

The new Login catch swallows every getSession rejection, not only cancel. Failures from createSession such as no organization on a accepted token or a secrets.store error now end as a silent no-op, recreating the same “Login does nothing” experience this change aims to fix.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9ee6c54. Configure here.

})
try {
await syncLiveSessionFromSecretStorage()
Expand Down
54 changes: 53 additions & 1 deletion test/auth.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,21 @@ import path from 'node:path'
import { afterEach, beforeEach, describe, expect, test } from 'vitest'

import {
activate,
API_TOKEN_SECRET_KEY,
getLegacySettingsPath,
migrateApiTokenToSecretStorage,
readLegacySettings,
sessionFromAPIKey,
} from '../src/auth'
import { setStubWorkspaceState } from './stubs/vscode'
import { EXTENSION_PREFIX } from '../src/util'
import {
getSessionCalls,
registeredCommands,
resetStubAuthState,
setStubGetSessionResult,
setStubWorkspaceState,
} from './stubs/vscode'

import type { OrgInfo } from '../src/api'
import { safeDelete } from '@socketsecurity/lib-stable/fs/safe'
Expand Down Expand Up @@ -177,3 +185,47 @@ describe('session identifiers', () => {
expect(first.id).not.toBe(second.id)
})
})

describe('the Login command', () => {
// Activate, then hand back the registered login handler. Activation calls
// getSession itself, so the recorded calls are cleared before the handler
// runs and the assertions can only see the command's own call.
async function activateAndGetLoginHandler(): Promise<
(...args: unknown[]) => unknown
> {
resetStubAuthState()
await activate(
{
secrets: new StubSecretStorage(),
subscriptions: [],
} as unknown as Parameters<typeof activate>[0],
[],
)
const handler = registeredCommands.get(`${EXTENSION_PREFIX}.login`)
if (typeof handler !== 'function') {
throw new Error('activate did not register the login command')
}
getSessionCalls.length = 0
return handler
}

test('forces a new session so an existing one cannot suppress the prompt', async () => {
const login = await activateAndGetLoginHandler()

await login()

// createIfNone only prompts when NO session exists, which is what made the
// command a silent no-op for a customer who already had a stale one
// (SURF-414). Asserting its absence is the point of the test.
expect(getSessionCalls).toEqual([{ forceNewSession: true }])
})

test('stays silent when the user dismisses the token prompt', async () => {
const login = await activateAndGetLoginHandler()
setStubGetSessionResult(() =>
Promise.reject(new Error('User did not consent to login.')),
)

await expect(login()).resolves.toBeUndefined()
})
})
74 changes: 74 additions & 0 deletions test/stubs/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,19 @@ export const workspace = {
}

export const window = {
createStatusBarItem(
_alignment?: number | undefined,
_priority?: number | undefined,
) {
return {
command: '',
dispose() {},
hide() {},
show() {},
text: '',
tooltip: '',
}
},
createTextEditorDecorationType(options: unknown) {
return { key: JSON.stringify(options), dispose() {} }
},
Expand All @@ -225,3 +238,64 @@ export const extensions = {
return undefined
},
}

export const StatusBarAlignment = {
Left: 1,
Right: 2,
} as const

/**
* Options every `authentication.getSession` call was made with, in order.
*/
export const getSessionCalls: Array<Record<string, unknown>> = []

/**
* Command id to handler, as registered via `commands.registerCommand`.
*/
export const registeredCommands: Map<string, (...args: unknown[]) => unknown> =
new Map()

let getSessionResult: () => Promise<unknown> = () => Promise.resolve(undefined)

/**
* Set what the next `authentication.getSession` calls do. Pass a rejecting
* thunk to model the user dismissing the token prompt, which VSCode surfaces
* as a rejection rather than an `undefined` session.
*/
export function setStubGetSessionResult(next: () => Promise<unknown>): void {
getSessionResult = next
}

export function resetStubAuthState(): void {
getSessionCalls.length = 0
registeredCommands.clear()
getSessionResult = () => Promise.resolve(undefined)
}

export const authentication = {
getSession(
_providerId: string,
_scopes: string[],
options: Record<string, unknown>,
): Promise<unknown> {
getSessionCalls.push(options)
return getSessionResult()
},
registerAuthenticationProvider(
_id: string,
_label: string,
_provider: unknown,
): Disposable {
return new Disposable(() => {})
},
}

export const commands = {
registerCommand(
command: string,
callback: (...args: unknown[]) => unknown,
): Disposable {
registeredCommands.set(command, callback)
return new Disposable(() => {})
},
}