diff --git a/src/auth.ts b/src/auth.ts index aa895fdd..9f11f112 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -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). + // `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 {} }) try { await syncLiveSessionFromSecretStorage() diff --git a/test/auth.test.mts b/test/auth.test.mts index d58c13ec..fd8ab70c 100644 --- a/test/auth.test.mts +++ b/test/auth.test.mts @@ -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' @@ -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[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() + }) +}) diff --git a/test/stubs/vscode.ts b/test/stubs/vscode.ts index ce9cf2cb..5193212d 100644 --- a/test/stubs/vscode.ts +++ b/test/stubs/vscode.ts @@ -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() {} } }, @@ -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> = [] + +/** + * Command id to handler, as registered via `commands.registerCommand`. + */ +export const registeredCommands: Map unknown> = + new Map() + +let getSessionResult: () => Promise = () => 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): 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, + ): Promise { + 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(() => {}) + }, +}