diff --git a/src/commands.ts b/src/commands.ts index ef97bdda..e8bdef06 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -111,9 +111,6 @@ export class Commands { return; } - // Login might have happened in another process/window so we do not have the user yet. - result.user ??= await this.extensionClient.getAuthenticatedUser(); - await this.deploymentManager.setDeployment({ url, safeHostname, diff --git a/src/extension.ts b/src/extension.ts index 0afebc67..eceb112f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,10 +13,9 @@ import { ServiceContainer } from "./core/container"; import { type SecretsManager } from "./core/secretsManager"; import { DeploymentManager } from "./deployment/deploymentManager"; import { CertificateError, getErrorDetail } from "./error"; -import { maybeAskUrl } from "./promptUtils"; import { Remote } from "./remote/remote"; import { getRemoteSshExtension } from "./remote/sshExtension"; -import { toSafeHost } from "./util"; +import { registerUriHandler } from "./uri/uriHandler"; import { WorkspaceProvider, WorkspaceQuery, @@ -129,103 +128,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ]); ctx.subscriptions.push(deploymentManager); - // Handle vscode:// URIs. - const uriHandler = vscode.window.registerUriHandler({ - handleUri: async (uri) => { - const params = new URLSearchParams(uri.query); - - if (uri.path === "/open") { - const owner = params.get("owner"); - const workspace = params.get("workspace"); - const agent = params.get("agent"); - const folder = params.get("folder"); - const openRecent = - params.has("openRecent") && - (!params.get("openRecent") || params.get("openRecent") === "true"); - - if (!owner) { - throw new Error("owner must be specified as a query parameter"); - } - if (!workspace) { - throw new Error("workspace must be specified as a query parameter"); - } - - await setupDeploymentFromUri(params, serviceContainer); - - await commands.open( - owner, - workspace, - agent ?? undefined, - folder ?? undefined, - openRecent, - ); - } else if (uri.path === "/openDevContainer") { - const workspaceOwner = params.get("owner"); - const workspaceName = params.get("workspace"); - const workspaceAgent = params.get("agent"); - const devContainerName = params.get("devContainerName"); - const devContainerFolder = params.get("devContainerFolder"); - const localWorkspaceFolder = params.get("localWorkspaceFolder"); - const localConfigFile = params.get("localConfigFile"); - - if (!workspaceOwner) { - throw new Error( - "workspace owner must be specified as a query parameter", - ); - } - - if (!workspaceName) { - throw new Error( - "workspace name must be specified as a query parameter", - ); - } - - if (!workspaceAgent) { - throw new Error( - "workspace agent must be specified as a query parameter", - ); - } - - if (!devContainerName) { - throw new Error( - "dev container name must be specified as a query parameter", - ); - } - - if (!devContainerFolder) { - throw new Error( - "dev container folder must be specified as a query parameter", - ); - } - - if (localConfigFile && !localWorkspaceFolder) { - throw new Error( - "local workspace folder must be specified as a query parameter if local config file is provided", - ); - } - - await setupDeploymentFromUri(params, serviceContainer); - - await commands.openDevContainer( - workspaceOwner, - workspaceName, - workspaceAgent, - devContainerName, - devContainerFolder, - localWorkspaceFolder ?? "", - localConfigFile ?? "", - ); - } else { - throw new Error(`Unknown path ${uri.path}`); - } - }, - }); - ctx.subscriptions.push(uriHandler); - // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. const commands = new Commands(serviceContainer, client, deploymentManager); + ctx.subscriptions.push( + registerUriHandler( + serviceContainer, + deploymentManager, + commands, + vscodeProposed, + ), vscode.commands.registerCommand( "coder.login", commands.login.bind(commands), @@ -418,50 +331,6 @@ async function showTreeViewSearch(id: string): Promise { await vscode.commands.executeCommand("list.find"); } -/** - * Sets up deployment from URI parameters. Handles URL prompting, client setup, - * and token storage. Throws if user cancels URL input. - */ -async function setupDeploymentFromUri( - params: URLSearchParams, - serviceContainer: ServiceContainer, -): Promise { - const secretsManager = serviceContainer.getSecretsManager(); - const mementoManager = serviceContainer.getMementoManager(); - const currentDeployment = await secretsManager.getCurrentDeployment(); - - // We are not guaranteed that the URL we currently have is for the URL - // this workspace belongs to, or that we even have a URL at all (the - // queries will default to localhost) so ask for it if missing. - // Pre-populate in case we do have the right URL so the user can just - // hit enter and move on. - const url = await maybeAskUrl( - mementoManager, - params.get("url"), - currentDeployment?.url, - ); - if (!url) { - throw new Error("url must be provided or specified as a query parameter"); - } - - const safeHost = toSafeHost(url); - - // If the token is missing we will get a 401 later and the user will be - // prompted to sign in again, so we do not need to ensure it is set now. - const token: string | null = params.get("token"); - if (token === null) { - // We need to ensure there is at least an entry for this in storage - // so that we know what URL to prompt the user with when logging in. - const auth = await secretsManager.getSessionAuth(safeHost); - if (!auth) { - // Racy, we could accidentally overwrite the token that is written in the meantime. - await secretsManager.setSessionAuth(safeHost, { url, token: "" }); - } - } else { - await secretsManager.setSessionAuth(safeHost, { url, token }); - } -} - async function listStoredDeployments( secretsManager: SecretsManager, ): Promise { diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index 8b5d7b07..0a2c09b9 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -16,12 +16,13 @@ import type { Logger } from "../logging/logger"; type LoginResult = | { success: false } - | { success: true; user?: User; token: string }; + | { success: true; user: User; token: string }; interface LoginOptions { safeHostname: string; url: string | undefined; autoLogin?: boolean; + token?: string; } /** @@ -49,6 +50,7 @@ export class LoginCoordinator { const result = await this.attemptLogin( { safeHostname, url }, options.autoLogin ?? false, + options.token, ); await this.persistSessionAuth(result, safeHostname, url); @@ -95,6 +97,7 @@ export class LoginCoordinator { const result = await this.attemptLogin( { url: newUrl, safeHostname }, false, + options.token, ); await this.persistSessionAuth(result, safeHostname, newUrl); @@ -168,10 +171,17 @@ export class LoginCoordinator { const promise = new Promise((resolve) => { disposable = this.secretsManager.onDidChangeSessionAuth( safeHostname, - (auth) => { + async (auth) => { if (auth?.token) { disposable?.dispose(); - resolve({ success: true, token: auth.token }); + const client = CoderApi.create(auth.url, auth.token, this.logger); + try { + const user = await client.getAuthenticatedUser(); + resolve({ success: true, token: auth.token, user }); + } catch { + // Token from other window was invalid, ignore and keep waiting + // (or user can click Login/Cancel in the dialog) + } } }, ); @@ -191,54 +201,93 @@ export class LoginCoordinator { private async attemptLogin( deployment: Deployment, isAutoLogin: boolean, + providedToken?: string, ): Promise { - const needsToken = needToken(vscode.workspace.getConfiguration()); const client = CoderApi.create(deployment.url, "", this.logger); - let storedToken: string | undefined; - if (needsToken) { - const auth = await this.secretsManager.getSessionAuth( - deployment.safeHostname, + // mTLS authentication (no token needed) + if (!needToken(vscode.workspace.getConfiguration())) { + return this.tryMtlsAuth(client, isAutoLogin); + } + + // Try provided token first + if (providedToken) { + const result = await this.tryTokenAuth( + client, + providedToken, + isAutoLogin, ); - storedToken = auth?.token; - if (storedToken) { - client.setSessionToken(storedToken); + if (result !== "retry") { + return result; } } - // Attempt authentication with current credentials (token or mTLS) - try { - if (!needsToken || storedToken) { - const user = await client.getAuthenticatedUser(); - // Return the token that was used (empty string for mTLS since - // the `vscodessh` command currently always requires a token file) - return { success: true, token: storedToken ?? "", user }; + // Try stored token (skip if same as provided) + const auth = await this.secretsManager.getSessionAuth( + deployment.safeHostname, + ); + if (auth?.token && auth.token !== providedToken) { + const result = await this.tryTokenAuth(client, auth.token, isAutoLogin); + if (result !== "retry") { + return result; } + } + + // Prompt user for token + return this.loginWithToken(client); + } + + private async tryMtlsAuth( + client: CoderApi, + isAutoLogin: boolean, + ): Promise { + try { + const user = await client.getAuthenticatedUser(); + return { success: true, token: "", user }; } catch (err) { - const is401 = isAxiosError(err) && err.response?.status === 401; - if (needsToken && is401) { - // For token auth with 401: silently continue to prompt for new credentials - } else { - // For mTLS or non-401 errors: show error and abort - const message = getErrorMessage(err, "no response from the server"); - if (isAutoLogin) { - this.logger.warn("Failed to log in to Coder server:", message); - } else { - this.vscodeProposed.window.showErrorMessage( - "Failed to log in to Coder server", - { - detail: message, - modal: true, - useCustom: true, - }, - ); - } - return { success: false }; + this.showAuthError(err, isAutoLogin); + return { success: false }; + } + } + + /** + * Returns 'retry' on 401 to signal trying next token. + */ + private async tryTokenAuth( + client: CoderApi, + token: string, + isAutoLogin: boolean, + ): Promise { + client.setSessionToken(token); + try { + const user = await client.getAuthenticatedUser(); + return { success: true, token, user }; + } catch (err) { + if (isAxiosError(err) && err.response?.status === 401) { + return "retry"; } + this.showAuthError(err, isAutoLogin); + return { success: false }; } + } - const result = await this.loginWithToken(client); - return result; + /** + * Shows auth error via dialog or logs it for autoLogin. + */ + private showAuthError(err: unknown, isAutoLogin: boolean): void { + const message = getErrorMessage(err, "no response from the server"); + if (isAutoLogin) { + this.logger.warn("Failed to log in to Coder server:", message); + } else { + this.vscodeProposed.window.showErrorMessage( + "Failed to log in to Coder server", + { + detail: message, + modal: true, + useCustom: true, + }, + ); + } } /** diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts new file mode 100644 index 00000000..1e6eeff9 --- /dev/null +++ b/src/uri/uriHandler.ts @@ -0,0 +1,179 @@ +import * as vscode from "vscode"; + +import { errToStr } from "../api/api-helper"; +import { type Commands } from "../commands"; +import { type ServiceContainer } from "../core/container"; +import { type DeploymentManager } from "../deployment/deploymentManager"; +import { maybeAskUrl } from "../promptUtils"; +import { toSafeHost } from "../util"; + +interface UriRouteContext { + params: URLSearchParams; + serviceContainer: ServiceContainer; + deploymentManager: DeploymentManager; + commands: Commands; +} + +type UriRouteHandler = (ctx: UriRouteContext) => Promise; + +const routes: Record = { + "/open": handleOpen, + "/openDevContainer": handleOpenDevContainer, +}; + +/** + * Registers the URI handler for `{vscode.env.uriScheme}://coder.coder-remote`... URIs. + */ +export function registerUriHandler( + serviceContainer: ServiceContainer, + deploymentManager: DeploymentManager, + commands: Commands, + vscodeProposed: typeof vscode, +): vscode.Disposable { + const output = serviceContainer.getLogger(); + + return vscode.window.registerUriHandler({ + handleUri: async (uri) => { + try { + await routeUri(uri, serviceContainer, deploymentManager, commands); + } catch (error) { + const message = errToStr(error, "No error message was provided"); + output.warn(`Failed to handle URI ${uri.toString()}: ${message}`); + vscodeProposed.window.showErrorMessage("Failed to handle URI", { + detail: message, + modal: true, + useCustom: true, + }); + } + }, + }); +} + +async function routeUri( + uri: vscode.Uri, + serviceContainer: ServiceContainer, + deploymentManager: DeploymentManager, + commands: Commands, +): Promise { + const handler = routes[uri.path]; + if (!handler) { + throw new Error(`Unknown path ${uri.path}`); + } + + await handler({ + params: new URLSearchParams(uri.query), + serviceContainer, + deploymentManager, + commands, + }); +} + +function getRequiredParam(params: URLSearchParams, name: string): string { + const value = params.get(name); + if (!value) { + throw new Error(`${name} must be specified as a query parameter`); + } + return value; +} + +async function handleOpen(ctx: UriRouteContext): Promise { + const { params, serviceContainer, deploymentManager, commands } = ctx; + + const owner = getRequiredParam(params, "owner"); + const workspace = getRequiredParam(params, "workspace"); + const agent = params.get("agent"); + const folder = params.get("folder"); + const openRecent = + params.has("openRecent") && + (!params.get("openRecent") || params.get("openRecent") === "true"); + + await setupDeployment(params, serviceContainer, deploymentManager); + + await commands.open( + owner, + workspace, + agent ?? undefined, + folder ?? undefined, + openRecent, + ); +} + +async function handleOpenDevContainer(ctx: UriRouteContext): Promise { + const { params, serviceContainer, deploymentManager, commands } = ctx; + + const owner = getRequiredParam(params, "owner"); + const workspace = getRequiredParam(params, "workspace"); + const agent = getRequiredParam(params, "agent"); + const devContainerName = getRequiredParam(params, "devContainerName"); + const devContainerFolder = getRequiredParam(params, "devContainerFolder"); + const localWorkspaceFolder = params.get("localWorkspaceFolder"); + const localConfigFile = params.get("localConfigFile"); + + if (localConfigFile && !localWorkspaceFolder) { + throw new Error( + "localWorkspaceFolder must be specified as a query parameter if localConfigFile is provided", + ); + } + + await setupDeployment(params, serviceContainer, deploymentManager); + + await commands.openDevContainer( + owner, + workspace, + agent, + devContainerName, + devContainerFolder, + localWorkspaceFolder ?? "", + localConfigFile ?? "", + ); +} + +/** + * Sets up deployment from URI parameters. Handles URL prompting, client setup, + * and token storage. Throws if user cancels URL input or login fails. + */ +async function setupDeployment( + params: URLSearchParams, + serviceContainer: ServiceContainer, + deploymentManager: DeploymentManager, +): Promise { + const secretsManager = serviceContainer.getSecretsManager(); + const mementoManager = serviceContainer.getMementoManager(); + const loginCoordinator = serviceContainer.getLoginCoordinator(); + + const currentDeployment = await secretsManager.getCurrentDeployment(); + + // We are not guaranteed that the URL we currently have is for the URL + // this workspace belongs to, or that we even have a URL at all (the + // queries will default to localhost) so ask for it if missing. + // Pre-populate in case we do have the right URL so the user can just + // hit enter and move on. + const url = await maybeAskUrl( + mementoManager, + params.get("url"), + currentDeployment?.url, + ); + if (!url) { + throw new Error("url must be provided or specified as a query parameter"); + } + + const safeHostname = toSafeHost(url); + + const token: string | undefined = params.get("token") ?? undefined; + const result = await loginCoordinator.ensureLoggedIn({ + safeHostname, + url, + token, + }); + + if (!result.success) { + throw new Error("Failed to login to deployment from URI"); + } + + await deploymentManager.setDeployment({ + safeHostname, + url, + token: result.token, + user: result.user, + }); +} diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index ba282f40..cc557d09 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -99,6 +99,7 @@ export const window = { clear: vi.fn(), })), createStatusBarItem: vi.fn(), + registerUriHandler: vi.fn(() => ({ dispose: vi.fn() })), }; export const commands = { diff --git a/test/unit/login/loginCoordinator.test.ts b/test/unit/login/loginCoordinator.test.ts index fda88ada..6044dc90 100644 --- a/test/unit/login/loginCoordinator.test.ts +++ b/test/unit/login/loginCoordinator.test.ts @@ -331,4 +331,142 @@ describe("LoginCoordinator", () => { expect(result.success).toBe(false); }); }); + + describe("token fallback order", () => { + it("uses provided token first when valid", async () => { + const { secretsManager, coordinator, mockSuccessfulAuth } = + createTestContext(); + const user = mockSuccessfulAuth(); + + // Store a different token + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "stored-token", + }); + + const result = await coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "provided-token", + }); + + expect(result).toEqual({ success: true, user, token: "provided-token" }); + }); + + it("falls back to stored token when provided token is invalid", async () => { + const { mockAdapter, secretsManager, coordinator } = createTestContext(); + const user = createMockUser(); + + mockAdapter + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 401 }, // Fail the provided token with 401 + message: "Unauthorized", + }) + .mockResolvedValueOnce({ + data: user, + status: 200, // Succeed the stored token + headers: {}, + config: {}, + }); + + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "stored-token", + }); + + const result = await coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "invalid-provided-token", + }); + + expect(result).toEqual({ success: true, user, token: "stored-token" }); + }); + + it("prompts user when both provided and stored tokens are invalid", async () => { + const { mockAdapter, userInteraction, secretsManager, coordinator } = + createTestContext(); + const user = createMockUser(); + + mockAdapter + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 401 }, // provided token + message: "Unauthorized", + }) + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 401 }, // stored token + message: "Unauthorized", + }) + .mockResolvedValueOnce({ + data: user, + status: 200, // user-entered token + headers: {}, + config: {}, + }); + + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "stored-token", + }); + + userInteraction.setInputBoxValue("user-entered-token"); + + const result = await coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "invalid-provided-token", + }); + + expect(result).toEqual({ + success: true, + user, + token: "user-entered-token", + }); + expect(vscode.window.showInputBox).toHaveBeenCalled(); + }); + + it("skips stored token check when same as provided token", async () => { + const { mockAdapter, userInteraction, secretsManager, coordinator } = + createTestContext(); + const user = createMockUser(); + + mockAdapter + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 401 }, // provided token + message: "Unauthorized", + }) + .mockResolvedValueOnce({ + data: user, + status: 200, // user-entered token + headers: {}, + config: {}, + }); + + // Store the SAME token as will be provided + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "same-token", + }); + + userInteraction.setInputBoxValue("user-entered-token"); + + const result = await coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "same-token", + }); + + expect(result).toEqual({ + success: true, + user, + token: "user-entered-token", + }); + // Provided/stored token check only called once + user prompt + expect(mockAdapter).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts new file mode 100644 index 00000000..92690566 --- /dev/null +++ b/test/unit/uri/uriHandler.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { MementoManager } from "@/core/mementoManager"; +import { SecretsManager } from "@/core/secretsManager"; +import { maybeAskUrl } from "@/promptUtils"; +import { registerUriHandler } from "@/uri/uriHandler"; + +import { + createMockLogger, + createMockUser, + InMemoryMemento, + InMemorySecretStorage, +} from "../../mocks/testHelpers"; + +import type { Commands } from "@/commands"; +import type { ServiceContainer } from "@/core/container"; +import type { DeploymentManager } from "@/deployment/deploymentManager"; +import type { LoginCoordinator } from "@/login/loginCoordinator"; + +vi.mock("@/promptUtils", () => ({ maybeAskUrl: vi.fn() })); + +const TEST_URL = "https://coder.example.com"; +const TEST_HOSTNAME = "coder.example.com"; + +class MockCommands { + readonly open = vi.fn().mockResolvedValue(undefined); + readonly openDevContainer = vi.fn().mockResolvedValue(undefined); +} + +class MockDeploymentManager { + readonly setDeployment = vi.fn().mockResolvedValue(true); +} + +function createMockLoginCoordinator(secretsManager: SecretsManager) { + return { + ensureLoggedIn: vi.fn().mockImplementation(async (options) => { + const token = options.token ?? "test-token"; + // Simulate persistSessionAuth behavior + await secretsManager.setSessionAuth(options.safeHostname, { + url: options.url, + token, + }); + return { + success: true, + token, + user: createMockUser(), + }; + }), + }; +} + +function createMockUri(path: string, query: string): vscode.Uri { + return { + path, + query, + toString: () => `vscode://coder.coder-remote${path}?${query}`, + } as vscode.Uri; +} + +function createTestContext() { + vi.resetAllMocks(); + + const secretStorage = new InMemorySecretStorage(); + const memento = new InMemoryMemento(); + const logger = createMockLogger(); + const secretsManager = new SecretsManager(secretStorage, memento, logger); + const loginCoordinator = createMockLoginCoordinator(secretsManager); + const mementoManager = new MementoManager(memento); + const commands = new MockCommands(); + const deploymentManager = new MockDeploymentManager(); + + const container = { + getSecretsManager: () => secretsManager, + getMementoManager: () => mementoManager, + getLoginCoordinator: () => loginCoordinator as unknown as LoginCoordinator, + getLogger: () => logger, + } as unknown as ServiceContainer; + + vi.mocked(maybeAskUrl).mockImplementation((_m, urlParam) => + Promise.resolve(urlParam || TEST_URL), + ); + + let registeredHandler: vscode.UriHandler["handleUri"] | null = null; + vi.mocked(vscode.window.registerUriHandler).mockImplementation((handler) => { + registeredHandler = handler.handleUri; + return { dispose: vi.fn() }; + }); + + const showErrorMessage = vi.fn().mockResolvedValue(undefined); + const vscodeProposed = { + ...vscode, + window: { ...vscode.window, showErrorMessage }, + } as typeof vscode; + + registerUriHandler( + container, + deploymentManager as unknown as DeploymentManager, + commands as unknown as Commands, + vscodeProposed, + ); + + return { + commands, + deploymentManager, + loginCoordinator, + secretsManager, + logger, + showErrorMessage, + handleUri: registeredHandler!, + }; +} + +describe("uriHandler", () => { + beforeEach(() => vi.resetAllMocks()); + + it("registers a URI handler", () => { + createTestContext(); + expect(vscode.window.registerUriHandler).toHaveBeenCalledOnce(); + }); + + describe("/open", () => { + it("opens workspace with parameters", async () => { + const { handleUri, commands, deploymentManager } = createTestContext(); + await handleUri( + createMockUri( + "/open", + `owner=o&workspace=w&agent=a&folder=/f&openRecent=true&url=${encodeURIComponent(TEST_URL)}`, + ), + ); + + expect(deploymentManager.setDeployment).toHaveBeenCalled(); + expect(commands.open).toHaveBeenCalledWith("o", "w", "a", "/f", true); + }); + + it.each([ + ["openRecent=true", true], + ["openRecent", true], + ["openRecent=false", false], + ["", false], + ])("handles %s -> %s", async (param, expected) => { + const { handleUri, commands } = createTestContext(); + const query = `owner=o&workspace=w&${param}&url=${encodeURIComponent(TEST_URL)}`; + await handleUri(createMockUri("/open", query)); + expect(commands.open).toHaveBeenCalledWith( + "o", + "w", + undefined, + undefined, + expected, + ); + }); + }); + + describe("/openDevContainer", () => { + it("opens dev container with parameters", async () => { + const { handleUri, commands, deploymentManager } = createTestContext(); + await handleUri( + createMockUri( + "/openDevContainer", + `owner=o&workspace=w&agent=a&devContainerName=c&devContainerFolder=/f&localWorkspaceFolder=/l&localConfigFile=/cfg&url=${encodeURIComponent(TEST_URL)}`, + ), + ); + + expect(deploymentManager.setDeployment).toHaveBeenCalled(); + expect(commands.openDevContainer).toHaveBeenCalledWith( + "o", + "w", + "a", + "c", + "/f", + "/l", + "/cfg", + ); + }); + }); + + describe("missing required parameters", () => { + it.each([ + ["/open", "workspace=w", "owner"], + ["/open", "owner=o", "workspace"], + [ + "/openDevContainer", + "workspace=w&agent=a&devContainerName=c&devContainerFolder=/f", + "owner", + ], + [ + "/openDevContainer", + "owner=o&workspace=w&devContainerName=c&devContainerFolder=/f", + "agent", + ], + [ + "/openDevContainer", + "owner=o&workspace=w&agent=a&devContainerFolder=/f", + "devContainerName", + ], + [ + "/openDevContainer", + "owner=o&workspace=w&agent=a&devContainerName=c", + "devContainerFolder", + ], + ])("%s with %s throws for missing %s", async (path, query, param) => { + const { handleUri, showErrorMessage } = createTestContext(); + await handleUri( + createMockUri(path, `${query}&url=${encodeURIComponent(TEST_URL)}`), + ); + expect(showErrorMessage).toHaveBeenCalledWith( + "Failed to handle URI", + expect.objectContaining({ + detail: expect.stringContaining(`${param} must be specified`), + }), + ); + }); + + it("throws when localConfigFile provided without localWorkspaceFolder", async () => { + const { handleUri, showErrorMessage } = createTestContext(); + await handleUri( + createMockUri( + "/openDevContainer", + `owner=o&workspace=w&agent=a&devContainerName=c&devContainerFolder=/f&localConfigFile=/cfg&url=${encodeURIComponent(TEST_URL)}`, + ), + ); + expect(showErrorMessage).toHaveBeenCalledWith( + "Failed to handle URI", + expect.objectContaining({ + detail: expect.stringContaining( + "localWorkspaceFolder must be specified", + ), + }), + ); + }); + + it("throws for unknown path", async () => { + const { handleUri, showErrorMessage } = createTestContext(); + await handleUri(createMockUri("/unknown", "")); + expect(showErrorMessage).toHaveBeenCalledWith( + "Failed to handle URI", + expect.objectContaining({ + detail: expect.stringContaining("Unknown path"), + }), + ); + }); + }); + + describe("deployment setup", () => { + it("stores token from URI", async () => { + const { handleUri, secretsManager } = createTestContext(); + await handleUri( + createMockUri( + "/open", + `owner=o&workspace=w&url=${encodeURIComponent(TEST_URL)}&token=tok`, + ), + ); + + const stored = await secretsManager.getSessionAuth(TEST_HOSTNAME); + expect(stored).toEqual({ url: TEST_URL, token: "tok" }); + }); + + it("throws on login failure", async () => { + const { handleUri, loginCoordinator, showErrorMessage } = + createTestContext(); + loginCoordinator.ensureLoggedIn.mockResolvedValue({ success: false }); + + await handleUri( + createMockUri( + "/open", + `owner=o&workspace=w&url=${encodeURIComponent(TEST_URL)}`, + ), + ); + expect(showErrorMessage).toHaveBeenCalledWith( + "Failed to handle URI", + expect.objectContaining({ + detail: expect.stringContaining("Failed to login"), + }), + ); + }); + + it("throws when URL cancelled", async () => { + const { handleUri, showErrorMessage } = createTestContext(); + vi.mocked(maybeAskUrl).mockResolvedValue(undefined); + + await handleUri(createMockUri("/open", "owner=o&workspace=w")); + expect(showErrorMessage).toHaveBeenCalledWith( + "Failed to handle URI", + expect.objectContaining({ + detail: expect.stringContaining("url must be provided"), + }), + ); + }); + }); + + describe("error handling", () => { + it("logs and shows error message", async () => { + const { handleUri, logger, showErrorMessage } = createTestContext(); + await handleUri(createMockUri("/open", "workspace=w")); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Failed to handle URI"), + ); + expect(showErrorMessage).toHaveBeenCalled(); + }); + + it("propagates command errors", async () => { + const { handleUri, commands, showErrorMessage } = createTestContext(); + commands.open.mockRejectedValue(new Error("Connection failed")); + + await handleUri( + createMockUri( + "/open", + `owner=o&workspace=w&url=${encodeURIComponent(TEST_URL)}`, + ), + ); + expect(showErrorMessage).toHaveBeenCalledWith( + "Failed to handle URI", + expect.objectContaining({ + detail: expect.stringContaining("Connection failed"), + }), + ); + }); + }); +});