From 8e14148405b77be4d761c2ee37a5fd60211b45c9 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 20 Oct 2026 16:59:35 +0300 Subject: [PATCH] fix: key every per-deployment store by origin toSafeHost returns an origin label (host for HTTPS on 443, else host_), so sessions, OAuth client registrations, CLI config directories, SSH hosts, and the binary cache are per origin. - Ignore entries an older version stored under the bare hostname for another origin; re-derive the saved deployment's label from its URL. - Refuse another origin in the sign-in dialog before signing in. - Keep getSessionAuth for label-only callers; the rest look up by URL. --- CHANGELOG.md | 5 +++ src/common/url.ts | 24 +++++++++-- src/common/validation.ts | 15 +++---- src/deployment/types.ts | 2 +- src/login/loginCoordinator.ts | 53 ++++++++++-------------- src/storage/secretsManager.ts | 24 +++++------ test/unit/api/authInterceptor.test.ts | 2 +- test/unit/common/url.test.ts | 39 +++++++++++++++-- test/unit/common/validation.test.ts | 32 ++++++++------ test/unit/login/loginCoordinator.test.ts | 37 ++++++++++------- test/unit/oauth/sessionManager.test.ts | 4 +- test/unit/remote/authority.test.ts | 21 ++++++++++ test/unit/storage/secretsManager.test.ts | 38 +++++++++++++---- test/unit/uri/uriHandler.test.ts | 3 +- 14 files changed, 203 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2152386948..e4dd9f0827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ - A session saved for one URL is no longer used for a URL on the same host with a different scheme or port. +- Each deployment origin (scheme, host, and port) now has its own stored + session, CLI credentials, SSH hosts, and CLI binary cache, so two origins on + one host no longer overwrite each other or connect a window to the wrong + deployment. HTTPS on the default port is unchanged; other origins need one + new login and a fresh CLI download. - The session token and `coder.headerCommand` headers are now only sent to the deployment, not to other servers such as a `coder.binarySource` host. - Sessions for `http://` deployments are saved to the OS keyring only with Coder diff --git a/src/common/url.ts b/src/common/url.ts index c681680eee..f342698d9c 100644 --- a/src/common/url.ts +++ b/src/common/url.ts @@ -1,16 +1,34 @@ import url from "node:url"; import * as vscode from "vscode"; -import { parseDeploymentUrl, validateFilenameHostname } from "./validation"; +import { + InputValidationError, + parseDeploymentUrl, + validateFilenameHostname, +} from "./validation"; /** - * Given a URL, return the host in a format that is safe to write. + * The URL's origin as a file- and SSH-safe label that keys per-deployment + * storage: `host` for HTTPS on 443, else `host_` without `https` + * or a default port (`host_8443`, `host_http`). A hostname with `_` always gets + * a suffix (`my_host_https`), so labels never collide. */ export function toSafeHost(rawUrl: string): string { const u = parseDeploymentUrl(rawUrl); + if (u.protocol !== "https:" && u.protocol !== "http:") { + throw new InputValidationError( + "Invalid URL: the deployment URL must use http or https.", + u.protocol, + ); + } // If the host is invalid, an empty string is returned. Although, `new URL` // should already have thrown in that case. - return validateFilenameHostname(url.domainToASCII(u.hostname) || u.hostname); + const hostname = url.domainToASCII(u.hostname) || u.hostname; + let suffix = (u.protocol === "http:" ? "http" : "") + u.port; + if (!suffix && hostname.includes("_")) { + suffix = "https"; + } + return validateFilenameHostname(suffix ? `${hostname}_${suffix}` : hostname); } export function removeTrailingSlashes(value: string): string { diff --git a/src/common/validation.ts b/src/common/validation.ts index a195cb0d02..693fd2babe 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -82,15 +82,16 @@ export function validateFilenameHostname(value: string): string { return value; } +/** + * A bracketed IPv6 host, optionally with a `toSafeHost` origin suffix + * (`_https`, `_http`, `_http8080`, `_8443`). + */ +const BRACKETED_IPV6_LABEL = /^\[([^\]%]+)\](?:_(?:https|http\d*|\d+))?$/; + /** Shell-safe, literal SSH host characters; IP structure uses Node's parser. */ export function isShellSafeHostname(value: string): boolean { - return ( - /^[\w.-]+$/.test(value) || - (value.startsWith("[") && - value.endsWith("]") && - !value.includes("%") && - isIP(value.slice(1, -1)) === 6) - ); + const ipv6 = BRACKETED_IPV6_LABEL.exec(value); + return /^[\w.-]+$/.test(value) || (ipv6 !== null && isIP(ipv6[1]) === 6); } export function validateShellSafeHostname(value: string): string { diff --git a/src/deployment/types.ts b/src/deployment/types.ts index 790e5d5c59..1fb355ae01 100644 --- a/src/deployment/types.ts +++ b/src/deployment/types.ts @@ -5,7 +5,7 @@ import type { User } from "coder/site/src/api/typesGenerated"; /** * Represents a Coder deployment with its URL and hostname. * The safeHostname is used as a unique identifier for storing credentials and configuration. - * It is derived from the URL hostname (via toSafeHost) or from SSH host parsing. + * It is the URL's origin label (via toSafeHost) or comes from SSH host parsing. */ export const DeploymentSchema = z.object({ url: z.string(), diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index 25330d45df..3dd0c94b2d 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/httpAgent"; import { HttpStatusCode } from "../api/httpStatusCode"; -import { isSameOrigin, openInBrowser } from "../common/url"; +import { openInBrowser, toSafeHost } from "../common/url"; import { CertificateError } from "../error/certificateError"; import { CrossOriginRedirectError } from "../error/crossOriginRedirectError"; import { OAuthAuthorizer } from "../oauth/authorizer"; @@ -76,10 +76,8 @@ interface LoginAttemptContext { client: CoderApi; deployment: Deployment; isAutoLogin: boolean; - /** Stored session for the hostname, if any. */ + /** Stored session for the deployment's origin, if any. */ auth: SessionAuth | undefined; - /** `auth`, but only when it matches the deployment's exact origin. */ - sameOriginAuth: SessionAuth | undefined; } /** @@ -170,6 +168,13 @@ export class LoginCoordinator implements vscode.Disposable { if (!newUrl) { return { success: false, reason: "no_url_provided" }; } + // Another origin's session belongs under another label. + if (toSafeHost(newUrl) !== safeHostname) { + vscode.window.showErrorMessage( + `${newUrl} is a different deployment from ${safeHostname}. Sign in from the Coder sidebar to use it.`, + ); + return { success: false, reason: "no_url_provided" }; + } return this.attemptLogin( { url: newUrl, safeHostname }, @@ -200,16 +205,13 @@ export class LoginCoordinator implements vscode.Disposable { registration: OAuth2ClientRegistrationResponse | undefined, ): Promise { const { safeHostname, url } = deployment; - const previous = await this.secretsManager.getSessionAuth(safeHostname); + const previous = await this.secretsManager.getSessionAuthForUrl(url); const replaced = previous?.token === result.token ? undefined : previous; // A refresh replaced the stored session during this login; keep it. if (result.method === "stored_token" && replaced) { return; } - const reusedOAuth = - !replaced && previous && isSameOrigin(previous.url, url) - ? previous.oauth - : undefined; + const reusedOAuth = replaced ? undefined : previous?.oauth; // Empty token is valid for mTLS await this.secretsManager.setSessionAuth(safeHostname, { @@ -339,17 +341,12 @@ export class LoginCoordinator implements vscode.Disposable { ); } - const auth = await this.secretsManager.getSessionAuth( - deployment.safeHostname, - ); + const auth = await this.secretsManager.getSessionAuthForUrl(deployment.url); const ctx: LoginAttemptContext = { client, deployment, isAutoLogin, auth, - // Only sent to its own origin, so a lookalike hostname cannot get it. - sameOriginAuth: - auth && isSameOrigin(auth.url, deployment.url) ? auth : undefined, }; if (providedToken) { @@ -380,7 +377,7 @@ export class LoginCoordinator implements vscode.Disposable { providedToken: string, tokenSignInConfirmed: boolean, ): Promise { - const { client, deployment, isAutoLogin, auth, sameOriginAuth } = ctx; + const { client, deployment, isAutoLogin, auth } = ctx; this.logger.debug("Trying provided token"); const result = await this.tryTokenAuth(client, providedToken, isAutoLogin); if (result === "unauthorized") { @@ -398,13 +395,13 @@ export class LoginCoordinator implements vscode.Disposable { // Silent when the link repeats the same user on the same origin, or an // earlier prompt confirmed a sign-in that replaces nothing. const silent = - sameOriginAuth?.username === result.user.username || + auth?.username === result.user.username || (auth === undefined && tokenSignInConfirmed); if (!silent) { // Checked silently, only to word the prompt. const expired = - sameOriginAuth?.token !== undefined && - (await this.tryTokenAuth(client, sameOriginAuth.token, true)) === + auth?.token !== undefined && + (await this.tryTokenAuth(client, auth.token, true)) === "unauthorized"; const confirmed = await this.confirmSignIn( deployment.url, @@ -422,20 +419,16 @@ export class LoginCoordinator implements vscode.Disposable { return withLoginMethod("provided_token", result); } - /** Stored session for the deployment's exact origin, if it still works. */ + /** Stored session for the deployment's origin, if it still works. */ private async tryStoredSession( ctx: LoginAttemptContext, ): Promise { - const { client, isAutoLogin, sameOriginAuth } = ctx; - if (!sameOriginAuth?.token) { + const { client, isAutoLogin, auth } = ctx; + if (!auth?.token) { return undefined; } this.logger.debug("Trying stored session token"); - const result = await this.tryTokenAuth( - client, - sameOriginAuth.token, - isAutoLogin, - ); + const result = await this.tryTokenAuth(client, auth.token, isAutoLogin); if (result === "unauthorized") { return undefined; } @@ -446,7 +439,7 @@ export class LoginCoordinator implements vscode.Disposable { private async tryCliCredentials( ctx: LoginAttemptContext, ): Promise { - const { client, deployment, isAutoLogin, auth, sameOriginAuth } = ctx; + const { client, deployment, isAutoLogin, auth } = ctx; const configs = vscode.workspace.getConfiguration(); if ( !(await this.cliCredentialManager.hasCliStore(deployment.url, configs)) @@ -482,8 +475,8 @@ export class LoginCoordinator implements vscode.Disposable { title: "Sign in with the Coder CLI's session?", detail: `The Coder CLI's session signs you in as "${result.user.username}"`, }, - // A same-origin session reached this point only because it failed. - { username: auth.username, expired: sameOriginAuth !== undefined }, + // The stored session reached this point only because it failed. + { username: auth.username, expired: true }, ); if (!confirmed) { return undefined; diff --git a/src/storage/secretsManager.ts b/src/storage/secretsManager.ts index a1801bb548..549fd35589 100644 --- a/src/storage/secretsManager.ts +++ b/src/storage/secretsManager.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { isSameOrigin, toSafeHost } from "../common/url"; +import { toSafeHost } from "../common/url"; import { isValidDeploymentUrl } from "../common/validation"; import { DeploymentSchema, type Deployment } from "../deployment/types"; @@ -153,11 +153,15 @@ export class SecretsManager { const deployment = result.success ? result.data.deployment : null; // Treat an invalid URL stored by an older version as signed out, so the // next sign-in replaces it. - if (deployment && !isValidDeploymentUrl(deployment.url)) { + if (!deployment) { + return null; + } + if (!isValidDeploymentUrl(deployment.url)) { this.logger.warn("Ignoring current deployment with an invalid URL"); return null; } - return deployment; + // Older versions stored the bare hostname for every origin. + return { ...deployment, safeHostname: toSafeHost(deployment.url) }; } catch { return null; } @@ -208,6 +212,7 @@ export class SecretsManager { }); } + /** By origin label; callers with a URL use `getSessionAuthForUrl`. */ public async getSessionAuth( safeHostname: string, ): Promise { @@ -240,8 +245,8 @@ export class SecretsManager { } /** - * The stored session auth for `url`, only when recorded for the same - * origin. Fails closed on an unparsable URL. + * The stored session auth for `url`'s origin. Fails closed on an + * unparsable URL. */ public async getSessionAuthForUrl( url: string, @@ -252,14 +257,7 @@ export class SecretsManager { } catch { return undefined; } - const auth = await this.getSessionAuth(safeHostname); - if (auth && !isSameOrigin(auth.url, url)) { - this.logger.warn( - `Ignoring stored session auth for ${safeHostname}: recorded for ${new URL(auth.url).origin}, not ${new URL(url).origin}`, - ); - return undefined; - } - return auth; + return this.getSessionAuth(safeHostname); } /** diff --git a/test/unit/api/authInterceptor.test.ts b/test/unit/api/authInterceptor.test.ts index c39724694a..8032b10816 100644 --- a/test/unit/api/authInterceptor.test.ts +++ b/test/unit/api/authInterceptor.test.ts @@ -332,7 +332,7 @@ describe("AuthInterceptor", () => { it("does not retry with a token stored for another origin", async () => { const { secretsManager, axiosInstance, createInterceptor } = createTestContext(); - await secretsManager.setSessionAuth(TEST_HOSTNAME, { + await secretsManager.setSessionAuth(`${TEST_HOSTNAME}_8443`, { url: `${TEST_URL}:8443`, token: "other-origin-token", }); diff --git a/test/unit/common/url.test.ts b/test/unit/common/url.test.ts index 64be1200ef..f5525b56b2 100644 --- a/test/unit/common/url.test.ts +++ b/test/unit/common/url.test.ts @@ -24,7 +24,7 @@ describe("toSafeHost", () => { it("preserves the IPv6 deployment key on POSIX but rejects it on Windows", () => { vi.spyOn(os, "platform").mockReturnValue("linux"); - expect(toSafeHost("https://[::1]:8080")).toBe("[::1]"); + expect(toSafeHost("https://[::1]:8080")).toBe("[::1]_8080"); vi.spyOn(os, "platform").mockReturnValue("win32"); expect(() => toSafeHost("https://[::1]:8080")).toThrow( /Cannot connect on Windows/, @@ -32,16 +32,49 @@ describe("toSafeHost", () => { }); it.each([ - ["https://foobar:8080", "foobar"], + ["https://foobar", "foobar"], + ["https://foobar:443", "foobar"], + ["https://foobar:8080", "foobar_8080"], + ["http://foobar", "foobar_http"], + ["http://foobar:80", "foobar_http"], + ["http://foobar:8080", "foobar_http8080"], + ["https://foobar:80", "foobar_80"], + ["http://foobar:443", "foobar_http443"], + ["https://my_host", "my_host_https"], + ["https://my_host:8443", "my_host_8443"], ["https://ほげ", "xn--18j4d"], ["https://عربي", "xn--ngbrx4e"], ["https://test.😉.invalid", "test.xn--n28h.invalid"], ["https://dev.😉-coder.com", "dev.xn---coder-vx74e.com"], - ["http://ignore-port.com:8080", "ignore-port.com"], ])("returns %s for %s", (input, expected) => { expect(toSafeHost(input)).toBe(expected); }); + it("gives two URLs the same label exactly when they share an origin", () => { + const hosts = ["host", "HOST", "host.com", "my_host", "xn--n3h.com"]; + // Hostnames that look like another origin's label. + hosts.push("host_80", "host_http", "host_https", "host_http8080"); + const urls = hosts.flatMap((host) => + ["http", "https"].flatMap((scheme) => + ["", ":80", ":443", ":8080"].map( + (port) => `${scheme}://${host}${port}`, + ), + ), + ); + + for (const a of urls) { + for (const b of urls) { + expect(toSafeHost(a) === toSafeHost(b), `${a} vs ${b}`).toBe( + new URL(a).origin === new URL(b).origin, + ); + } + } + }); + + it("rejects schemes other than http and https", () => { + expect(() => toSafeHost("ftp://host")).toThrow("must use http or https"); + }); + it("throws for invalid URLs", () => { expect(() => toSafeHost("invalid url")).toThrow("Invalid URL"); }); diff --git a/test/unit/common/validation.test.ts b/test/unit/common/validation.test.ts index 1747d1a167..dabf0cdb91 100644 --- a/test/unit/common/validation.test.ts +++ b/test/unit/common/validation.test.ts @@ -148,12 +148,16 @@ describe("InputValidationError", () => { }); describe("isShellSafeHostname", () => { - it.each(["dev.coder.com", "my--host.example.com", "127.0.0.1", "[::1]"])( - "accepts %j", - (input) => { - expect(isShellSafeHostname(input)).toBe(true); - }, - ); + it.each([ + "dev.coder.com", + "my--host.example.com", + "127.0.0.1", + "[::1]", + "dev.coder.com_http8080", + "[::1]_8080", + ])("accepts %j", (input) => { + expect(isShellSafeHostname(input)).toBe(true); + }); it.each(["", "a/b", "a\\b", "host name", "x$(id)", "host%h", "::1"])( "rejects %j", @@ -162,12 +166,16 @@ describe("isShellSafeHostname", () => { }, ); - it.each(["[::1%h]", "[fe80::1%eth0]", "[::not-ip]"])( - "rejects scoped or invalid bracketed IPv6 %j", - (input) => { - expect(isShellSafeHostname(input)).toBe(false); - }, - ); + it.each([ + "[::1%h]", + "[fe80::1%eth0]", + "[::not-ip]", + "[::1]_$(id)", + "[::1]x", + "[::1]_ftp", + ])("rejects scoped or invalid bracketed IPv6 %j", (input) => { + expect(isShellSafeHostname(input)).toBe(false); + }); }); describe("validateShellSafeHostname", () => { diff --git a/test/unit/login/loginCoordinator.test.ts b/test/unit/login/loginCoordinator.test.ts index c4e13e176f..1227f60a14 100644 --- a/test/unit/login/loginCoordinator.test.ts +++ b/test/unit/login/loginCoordinator.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi, type Mock } from "vitest"; import * as vscode from "vscode"; import { HttpStatusCode } from "@/api/httpStatusCode"; +import { toSafeHost } from "@/common/url"; import { CrossOriginRedirectError } from "@/error/crossOriginRedirectError"; import { getHeaders } from "@/headers"; import { AuthTelemetry } from "@/instrumentation/auth"; @@ -213,7 +214,7 @@ function createSignInTestContext( } }, storeSession: (auth: { token: string; username?: string; url?: string }) => - ctx.secretsManager.setSessionAuth(TEST_HOSTNAME, { + ctx.secretsManager.setSessionAuth(toSafeHost(auth.url ?? TEST_URL), { url: TEST_URL, ...auth, }), @@ -489,6 +490,23 @@ describe("LoginCoordinator", () => { expect(result.success).toBe(false); }); + + it("refuses another origin of the hostname without signing in", async () => { + const { userInteraction, coordinator, mockSuccessfulAuth } = + createTestContext(); + mockSuccessfulAuth(); + vi.mocked(maybeAskUrl).mockResolvedValue("http://coder.example.com"); + userInteraction.setResponse("Authentication Required", "Login"); + + const result = await coordinator.ensureLoggedInWithDialog({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + trigger: "auth_required", + }); + + expect(result).toEqual({ success: false, reason: "no_url_provided" }); + expect(mockGetAuthenticatedUser).not.toHaveBeenCalled(); + }); }); describe("token fallback order", () => { @@ -566,11 +584,8 @@ describe("LoginCoordinator", () => { expect(await t.login()).toEqual(DISMISSED); // The stored token is never sent to a different origin. expect(mockGetAuthenticatedUser).toHaveBeenCalledTimes(1); - // The unchecked session is not called expired. - t.expectSignInPrompt( - user.username, - `current session for "${user.username}"`, - ); + // Another origin's session is not the one being replaced. + t.expectSignInPrompt(user.username); }); it("confirms a first-time link sign-in, naming the user", async () => { @@ -664,16 +679,6 @@ describe("LoginCoordinator", () => { ], session: { token: "valid-stored-token", username: "user-a" }, }, - { - name: "was pre-confirmed but replaces another origin's session", - auth: [createMockUser()], - session: { - url: "http://coder.example.com", - token: "other-origin-token", - username: "someone-else", - }, - preConfirmed: true, - }, { name: "was pre-confirmed but replaces an expired session", auth: [createMockUser(), "unauthorized"], diff --git a/test/unit/oauth/sessionManager.test.ts b/test/unit/oauth/sessionManager.test.ts index 91882ad5c9..3a5a599205 100644 --- a/test/unit/oauth/sessionManager.test.ts +++ b/test/unit/oauth/sessionManager.test.ts @@ -292,10 +292,10 @@ describe("OAuthSessionManager", () => { }); describe("getStoredTokens validation", () => { - it("returns undefined when the URL differs on the same hostname", async () => { + it("ignores another origin's tokens on the same hostname", async () => { const { secretsManager, manager } = createTestContext(); - await secretsManager.setSessionAuth(TEST_HOSTNAME, { + await secretsManager.setSessionAuth(`${TEST_HOSTNAME}_8443`, { url: `${TEST_URL}:8443`, token: "access-token", oauth: { diff --git a/test/unit/remote/authority.test.ts b/test/unit/remote/authority.test.ts index b1ea51a8a6..98b77c418d 100644 --- a/test/unit/remote/authority.test.ts +++ b/test/unit/remote/authority.test.ts @@ -179,6 +179,8 @@ describe("parseRemoteAuthority", () => { }); describe("authority construction", () => { + afterEach(() => vi.restoreAllMocks()); + it("preserves the editor URI scheme and integrates with toSafeHost", () => { useEditor("cursor--dev"); expect( @@ -207,6 +209,25 @@ describe("authority construction", () => { }, ); + it.each([ + ["http://dev.coder.com:8080", "dev.coder.com_http8080"], + ["https://my_host", "my_host_https"], + ["https://[::1]:8080", "[::1]_8080"], + ])("round-trips the origin of %s as %s", (url, safeHostname) => { + vi.spyOn(os, "platform").mockReturnValue("linux"); + const authority = toRemoteAuthority(url, "alice", "dev", "main"); + + expect(authority).toBe( + `ssh-remote+coder-vscode.${safeHostname}--alice--dev.main`, + ); + expect(parseRemoteAuthority(authority)).toMatchObject({ + safeHostname, + username: "alice", + workspace: "dev", + agent: "main", + }); + }); + it("omits an absent agent", () => { expect( toRemoteAuthority("https://dev.coder.com", "foo", "bar", undefined), diff --git a/test/unit/storage/secretsManager.test.ts b/test/unit/storage/secretsManager.test.ts index ae242e5330..b96c2ea9d2 100644 --- a/test/unit/storage/secretsManager.test.ts +++ b/test/unit/storage/secretsManager.test.ts @@ -12,6 +12,7 @@ import { InMemorySecretStorage, LogCollector, createMockLogger, + flush, } from "../../mocks/testHelpers"; describe("SecretsManager", () => { @@ -48,16 +49,20 @@ describe("SecretsManager", () => { expect(newAuth?.token).toBe("new-token"); }); - it("should accept a URL port for a matching hostname", async () => { - await secretsManager.setSessionAuth("example.com", { - url: "https://example.com:8443", - token: "test-token", - }); + it("keeps another origin of the hostname apart, change events included", async () => { + const listener = vi.fn(); + secretsManager.onDidChangeSessionAuth("example.com", listener); - expect(await secretsManager.getSessionAuth("example.com")).toEqual({ + await secretsManager.setSessionAuth("example.com_8443", { url: "https://example.com:8443", - token: "test-token", + token: "port-token", }); + await flush(); + + expect(listener).not.toHaveBeenCalled(); + expect( + await secretsManager.getSessionAuth("example.com"), + ).toBeUndefined(); }); interface MismatchCase { @@ -81,6 +86,13 @@ describe("SecretsManager", () => { error: 'Session auth hostname mismatch: expected "example.com", got "other.example.com"', }, + { + // Older versions keyed every origin of a hostname together. + name: "other origin of the hostname", + url: "http://example.com/private?token=secret", + error: + 'Session auth hostname mismatch: expected "example.com", got "example.com_http"', + }, ]; it.each(MISMATCH_CASES)( @@ -357,6 +369,18 @@ describe("SecretsManager", () => { }, ); + it("re-derives the label an older version stored as the bare hostname", async () => { + await secretsManager.setCurrentDeployment({ + url: "http://coder.example.com", + safeHostname: "coder.example.com", + }); + + expect(await secretsManager.getCurrentDeployment()).toEqual({ + url: "http://coder.example.com", + safeHostname: "coder.example.com_http", + }); + }); + it("should handle corrupted storage gracefully", async () => { await secretStorage.store("coder.currentDeployment", "invalid-json{"); diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index 07d85898d2..a104c73418 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; +import { toSafeHost } from "@/common/url"; import { OPEN_CODER_OUTPUT_ACTION } from "@/error/inputValidationError"; import { CALLBACK_PATH } from "@/oauth/constants"; import { OAuthCallback } from "@/oauth/oauthCallback"; @@ -157,7 +158,7 @@ function createTestContext() { /** Store a session, which is what makes a deployment known. */ storeSession: (auth?: { url?: string; token?: string }) => - secretsManager.setSessionAuth(TEST_HOSTNAME, { + secretsManager.setSessionAuth(toSafeHost(auth?.url ?? TEST_URL), { url: TEST_URL, token: "known-token", ...auth,