diff --git a/package.json b/package.json index cb0e8d01f..7c7c694d7 100644 --- a/package.json +++ b/package.json @@ -317,6 +317,13 @@ "visibility": "visible", "icon": "media/logo-white.svg" }, + { + "id": "sharedWorkspaces", + "name": "Shared Workspaces", + "visibility": "visible", + "icon": "media/logo-white.svg", + "when": "coder.authenticated" + }, { "id": "allWorkspaces", "name": "All Workspaces", @@ -459,6 +466,12 @@ "category": "Coder", "icon": "$(search)" }, + { + "command": "coder.searchSharedWorkspaces", + "title": "Search", + "category": "Coder", + "icon": "$(search)" + }, { "command": "coder.searchAllWorkspaces", "title": "Search", @@ -585,6 +598,10 @@ "command": "coder.searchMyWorkspaces", "when": "false" }, + { + "command": "coder.searchSharedWorkspaces", + "when": "false" + }, { "command": "coder.searchAllWorkspaces", "when": "false" @@ -628,6 +645,16 @@ "when": "coder.authenticated && view == myWorkspaces", "group": "navigation@3" }, + { + "command": "coder.refreshWorkspaces", + "when": "coder.authenticated && view == sharedWorkspaces", + "group": "navigation@2" + }, + { + "command": "coder.searchSharedWorkspaces", + "when": "coder.authenticated && view == sharedWorkspaces", + "group": "navigation@3" + }, { "command": "coder.searchAllWorkspaces", "when": "coder.authenticated && view == allWorkspaces", diff --git a/src/core/commandManager.ts b/src/core/commandManager.ts index 33c003519..1310e8a24 100644 --- a/src/core/commandManager.ts +++ b/src/core/commandManager.ts @@ -22,6 +22,7 @@ export const CODER_COMMAND_IDS = [ "coder.viewLogs", "coder.exportTelemetry", "coder.searchMyWorkspaces", + "coder.searchSharedWorkspaces", "coder.searchAllWorkspaces", "coder.manageCredentials", "coder.applyRecommendedSettings", diff --git a/src/deployment/deploymentManager.ts b/src/deployment/deploymentManager.ts index 8ef71d56e..bee26f6ae 100644 --- a/src/deployment/deploymentManager.ts +++ b/src/deployment/deploymentManager.ts @@ -16,8 +16,8 @@ import { type Logger } from "../logging/logger"; import { type OAuthSessionManager } from "../oauth/sessionManager"; import { getAuthConfigWatchSettings } from "../settings/authConfig"; import { type TelemetryService } from "../telemetry/service"; -import { type WorkspaceProvider } from "../workspace/workspacesProvider"; +import { SessionStore, type SessionData } from "./sessionStore"; import { DeploymentSchema, type Deployment, @@ -27,6 +27,11 @@ import { import type { User } from "coder/site/src/api/typesGenerated"; import type * as vscode from "vscode"; +import type { + WorkspaceSessionSnapshot, + WorkspaceSessionState, +} from "../workspace/session"; + /** * Manages deployment state for the extension. * @@ -36,10 +41,11 @@ import type * as vscode from "vscode"; * - OAuth session management * - Auth listener registration * - Context updates (coder.authenticated, coder.isOwner) - * - Workspace provider refresh * - Cross-window sync handling */ -export class DeploymentManager implements vscode.Disposable { +export class DeploymentManager + implements vscode.Disposable, WorkspaceSessionState +{ private readonly secretsManager: SecretsManager; private readonly mementoManager: MementoManager; private readonly contextManager: ContextManager; @@ -47,7 +53,8 @@ export class DeploymentManager implements vscode.Disposable { private readonly telemetryService: TelemetryService; private readonly deploymentTelemetry: DeploymentTelemetry; - #deployment: Deployment | null = null; + readonly #sessionStore = new SessionStore(); + public readonly onDidChange = this.#sessionStore.onDidChange; #disposed = false; #authListenerDisposable: vscode.Disposable | undefined; #authConfigDisposable: vscode.Disposable | undefined; @@ -59,7 +66,6 @@ export class DeploymentManager implements vscode.Disposable { serviceContainer: ServiceContainer, private readonly client: CoderApi, private readonly oauthSessionManager: OAuthSessionManager, - private readonly workspaceProviders: WorkspaceProvider[], ) { this.secretsManager = serviceContainer.getSecretsManager(); this.mementoManager = serviceContainer.getMementoManager(); @@ -73,13 +79,11 @@ export class DeploymentManager implements vscode.Disposable { serviceContainer: ServiceContainer, client: CoderApi, oauthSessionManager: OAuthSessionManager, - workspaceProviders: WorkspaceProvider[], ): DeploymentManager { const manager = new DeploymentManager( serviceContainer, client, oauthSessionManager, - workspaceProviders, ); manager.subscribeToAuthConfigChanges(); manager.subscribeToCrossWindowChanges(); @@ -90,35 +94,38 @@ export class DeploymentManager implements vscode.Disposable { * Get the current deployment state. */ public getCurrentDeployment(): Deployment | null { - return this.#deployment; + return this.#sessionStore.current.deployment; + } + + public getSnapshot(): WorkspaceSessionSnapshot { + return this.#sessionStore.getSnapshot(); } /** * Check if we have an authenticated deployment (does not guarantee that the current auth data is valid). */ public isAuthenticated(): boolean { - return this.contextManager.get("coder.authenticated"); + return this.#sessionStore.current.kind === "signedIn"; } /** - * Verify credentials and apply the deployment on success. Used for - * fresh logins and for un-suspending a session after auth settings or - * a token become valid again. Bails if state moved during the verify - * (logout, another login, dispose), so callers don't need a race guard. + * Verify credentials and apply the deployment on success, signing in. Used + * for fresh logins and for un-suspending a session once auth settings or a + * token become valid again. Bails if state moved during the verify (logout, + * another login, dispose), so callers don't need a race guard. */ - public async verifyAndApplyDeployment( + public async verifyAndApplySession( deployment: Deployment & { token?: string }, ): Promise { - const deploymentBefore = this.#deployment; + const sessionBefore = this.#sessionStore.current; const token = deployment.token ?? (await this.secretsManager.getSessionAuth(deployment.safeHostname)) ?.token; - const tempClient = CoderApi.create(deployment.url, token, this.logger); try { - const user = await tempClient.getAuthenticatedUser(); - if (this.#hasStateChangedSince(deploymentBefore)) { + const user = await this.#verifyCredentials(deployment.url, token); + if (this.#hasStateChangedSince(sessionBefore)) { return false; } await this.setDeployment({ ...deployment, token, user }); @@ -126,17 +133,31 @@ export class DeploymentManager implements vscode.Disposable { } catch (e) { this.logger.warn("Failed to authenticate with deployment:", e); return false; + } + } + + /** + * Verify credentials with a throwaway client and return the authenticated + * user. Throws if the credentials are rejected. + */ + async #verifyCredentials( + url: string, + token: string | undefined, + ): Promise { + const tempClient = CoderApi.create(url, token, this.logger); + try { + return await tempClient.getAuthenticatedUser(); } finally { tempClient.dispose(); } } /** True if disposal, login, or a deployment switch raced our await. */ - #hasStateChangedSince(deploymentBefore: Deployment | null): boolean { + #hasStateChangedSince(sessionBefore: SessionData): boolean { return ( this.#disposed || this.isAuthenticated() || - this.#deployment !== deploymentBefore + this.#sessionStore.current !== sessionBefore ); } @@ -151,29 +172,25 @@ export class DeploymentManager implements vscode.Disposable { hostname: deployment.safeHostname, user: deployment.user.username, }); - this.#deployment = { ...deployment }; - const ourRef = this.#deployment; + const deploymentWithoutAuth = DeploymentSchema.parse(deployment); this.telemetryService.setDeploymentUrl(deployment.url); - - // Updates client credentials if (deployment.token === undefined) { this.client.setHost(deployment.url); } else { this.client.setCredentials(deployment.url, deployment.token); } - // Register auth listener before setDeployment so background token refresh - // can update client credentials via the listener + const ourRef = this.#sessionStore.signIn( + deploymentWithoutAuth, + deployment.user, + ); + // Register before OAuth setup so background token refresh can update client credentials. this.registerAuthListener(); - // Contexts must be set before refresh (providers check isAuthenticated) this.updateAuthContexts(deployment.user); - this.refreshWorkspaces(); - const deploymentWithoutAuth: Deployment = - DeploymentSchema.parse(deployment); await this.oauthSessionManager.setDeployment(deploymentWithoutAuth); // Bail if a concurrent write took over during the await. - if (this.#deployment !== ourRef) { + if (this.#sessionStore.current !== ourRef) { return; } await this.persistDeployment(deploymentWithoutAuth); @@ -183,12 +200,19 @@ export class DeploymentManager implements vscode.Disposable { * Clears the current deployment. */ public async clearDeployment(reason: DeploymentSuspendReason): Promise { - this.logger.debug("Clearing deployment", this.#deployment?.safeHostname); - this.suspendSession(reason); + this.logger.debug( + "Clearing deployment", + this.#sessionStore.current.deployment?.safeHostname, + ); + const wasAuthenticated = this.isAuthenticated(); this.#authListenerDisposable?.dispose(); this.#authListenerDisposable = undefined; - this.#deployment = null; + this.#sessionStore.signOut(null); + this.clearAuthState(); this.telemetryService.setDeploymentUrl(""); + if (wasAuthenticated) { + this.deploymentTelemetry.suspended(reason); + } await this.secretsManager.setCurrentDeployment(undefined); } @@ -199,22 +223,17 @@ export class DeploymentManager implements vscode.Disposable { */ public suspendSession(reason: DeploymentSuspendReason): void { const wasAuthenticated = this.isAuthenticated(); - this.oauthSessionManager.clearDeployment(); - this.client.setCredentials(undefined, undefined); - this.updateAuthContexts(undefined); - this.clearWorkspaces(); + this.#sessionStore.signOut(this.#sessionStore.current.deployment); + this.clearAuthState(); if (wasAuthenticated) { this.deploymentTelemetry.suspended(reason); } } - /** - * Clear all workspace providers without fetching. - */ - private clearWorkspaces(): void { - for (const provider of this.workspaceProviders) { - provider.clear(); - } + private clearAuthState(): void { + this.oauthSessionManager.clearDeployment(); + this.client.setCredentials(undefined, undefined); + this.updateAuthContexts(undefined); } public dispose(): void { @@ -222,6 +241,7 @@ export class DeploymentManager implements vscode.Disposable { this.#authListenerDisposable?.dispose(); this.#authConfigDisposable?.dispose(); this.#crossWindowSyncDisposable?.dispose(); + this.#sessionStore.dispose(); } /** @@ -230,25 +250,32 @@ export class DeploymentManager implements vscode.Disposable { * Also handles recovery from suspended session state. */ private registerAuthListener(): void { - if (!this.#deployment) { + const deployment = this.#sessionStore.current.deployment; + if (!deployment) { return; } // Capture hostname at registration time for the guard clause - const safeHostname = this.#deployment.safeHostname; + const safeHostname = deployment.safeHostname; this.#authListenerDisposable?.dispose(); this.logger.debug("Registering auth listener for hostname", safeHostname); this.#authListenerDisposable = this.secretsManager.onDidChangeSessionAuth( safeHostname, async (auth) => { - if (this.#deployment?.safeHostname !== safeHostname) { + if ( + this.#sessionStore.current.deployment?.safeHostname !== safeHostname + ) { return; } if (auth) { if (this.isAuthenticated()) { - this.client.setCredentials(auth.url, auth.token); + await this.verifyAndUpdateSession({ + url: auth.url, + safeHostname, + token: auth.token, + }); } else { this.logger.debug( "Token updated after session suspended, recovering", @@ -269,6 +296,48 @@ export class DeploymentManager implements vscode.Disposable { ); } + /** + * Handle a token change while already signed in: verify the new token before + * applying it, so an unverified token never reaches the live client. + */ + private async verifyAndUpdateSession( + deployment: Deployment & { token: string }, + ): Promise { + const sessionBefore = this.#sessionStore.current; + try { + const user = await this.#verifyCredentials( + deployment.url, + deployment.token, + ); + if (this.#disposed) { + return; + } + const current = this.#sessionStore.current; + + if (current === sessionBefore) { + // Same-user rotation only needs fresh credentials; skipping + // setDeployment avoids a revision bump and tree rebuild. + if (current.kind === "signedIn" && current.user.id === user.id) { + this.client.setCredentials(deployment.url, deployment.token); + } else { + await this.setDeployment({ ...deployment, user }); + } + return; + } + + // Session changed under us: recover only a same-deployment suspension + // (e.g. a concurrent 401); a logout or switch wins. + if ( + current.kind === "signedOut" && + current.deployment?.safeHostname === deployment.safeHostname + ) { + await this.setDeployment({ ...deployment, user }); + } + } catch (e) { + this.logger.warn("Failed to authenticate updated session:", e); + } + } + private subscribeToAuthConfigChanges(): void { this.#authConfigDisposable = watchConfigurationChanges( getAuthConfigWatchSettings(), @@ -292,14 +361,17 @@ export class DeploymentManager implements vscode.Disposable { try { do { this.#recoveryPending = false; - const snapshot = this.#deployment; - if (this.#disposed || !snapshot || this.isAuthenticated()) { + const deployment = this.#sessionStore.current.deployment; + if (this.#disposed || !deployment || this.isAuthenticated()) { return; } this.logger.debug( "Authentication settings changed after session suspended, recovering", ); - const recovered = await this.recoverDeployment(snapshot, "auth_config"); + const recovered = await this.recoverDeployment( + deployment, + "auth_config", + ); if (!recovered) { this.deploymentTelemetry.authConfigRecoveryFailed(); } @@ -335,7 +407,7 @@ export class DeploymentManager implements vscode.Disposable { deployment: Deployment & { token?: string }, trigger: DeploymentRecoveryTrigger, ): Promise { - const recovered = await this.verifyAndApplyDeployment(deployment); + const recovered = await this.verifyAndApplySession(deployment); if (recovered) { this.deploymentTelemetry.recovered(trigger); } @@ -351,15 +423,6 @@ export class DeploymentManager implements vscode.Disposable { this.contextManager.set("coder.isOwner", isOwner); } - /** - * Refresh all workspace providers asynchronously. - */ - private refreshWorkspaces(): void { - for (const provider of this.workspaceProviders) { - void provider.fetchAndRefresh(); - } - } - /** * Persist deployment to storage for cross-window sync. */ diff --git a/src/deployment/sessionStore.ts b/src/deployment/sessionStore.ts new file mode 100644 index 000000000..2c373e5cc --- /dev/null +++ b/src/deployment/sessionStore.ts @@ -0,0 +1,76 @@ +import * as vscode from "vscode"; + +import type { User } from "coder/site/src/api/typesGenerated"; + +import type { + WorkspaceSessionSnapshot, + WorkspaceSessionState, +} from "../workspace/session"; + +import type { Deployment } from "./types"; + +/** + * The deployment session: signed out (optionally keeping the last deployment + * for re-login) or signed in with an authenticated user. + * + * Every transition makes a new object, so callers can spot a change by + * comparing identity against an earlier value. + */ +export type SessionData = + | { readonly kind: "signedOut"; readonly deployment: Deployment | null } + | { + readonly kind: "signedIn"; + readonly deployment: Deployment; + readonly user: User; + }; + +/** + * Owns the deployment session. State changes only through signIn()/signOut(), + * each of which bumps the revision and notifies listeners. + * + * Consumers that only need auth status (like the workspace tree) take the lean + * WorkspaceSessionState projection instead of the full session. + */ +export class SessionStore implements WorkspaceSessionState { + #data: SessionData = { kind: "signedOut", deployment: null }; + #revision = 0; + readonly #onDidChange = new vscode.EventEmitter(); + + public readonly onDidChange = this.#onDidChange.event; + + /** Full session state, including deployment and user. */ + public get current(): SessionData { + return this.#data; + } + + /** Lean projection for consumers that only track auth status and revision. */ + public getSnapshot(): WorkspaceSessionSnapshot { + if (this.#data.kind === "signedIn") { + return { + kind: "signedIn", + revision: this.#revision, + userId: this.#data.user.id, + }; + } + return { kind: "signedOut", revision: this.#revision }; + } + + public signIn(deployment: Deployment, user: User): SessionData { + return this.transition({ kind: "signedIn", deployment, user }); + } + + public signOut(deployment: Deployment | null): SessionData { + return this.transition({ kind: "signedOut", deployment }); + } + + private transition(data: SessionData): SessionData { + this.#data = data; + this.#revision++; + this.#onDidChange.fire(this.getSnapshot()); + return data; + } + + public dispose(): void { + this.#onDidChange.dispose(); + } +} diff --git a/src/extension.ts b/src/extension.ts index 5903d79cf..897cc29c6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -31,6 +31,7 @@ import { } from "./workspace/workspacesProvider"; const MY_WORKSPACES_TREE_ID = "myWorkspaces"; +const SHARED_WORKSPACES_TREE_ID = "sharedWorkspaces"; const ALL_WORKSPACES_TREE_ID = "allWorkspaces"; export async function activate(ctx: vscode.ExtensionContext): Promise { @@ -154,14 +155,19 @@ async function doActivate( ); ctx.subscriptions.push(authInterceptor); - const isAuthenticated = () => contextManager.get("coder.authenticated"); + const deploymentManager = DeploymentManager.create( + serviceContainer, + client, + oauthSessionManager, + ); + ctx.subscriptions.push(deploymentManager); const myWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.Mine, client, output, - isAuthenticated, - 5, + deploymentManager, + { refreshIntervalMs: 5_000 }, ); ctx.subscriptions.push(myWorkspacesProvider); @@ -169,46 +175,42 @@ async function doActivate( WorkspaceQuery.All, client, output, - isAuthenticated, + deploymentManager, ); ctx.subscriptions.push(allWorkspacesProvider); - // createTreeView, unlike registerTreeDataProvider, gives us the tree view API - // (so we can see when it is visible) but otherwise they have the same effect. - const myWsTree = vscode.window.createTreeView(MY_WORKSPACES_TREE_ID, { - treeDataProvider: myWorkspacesProvider, - }); - ctx.subscriptions.push(myWsTree); - myWorkspacesProvider.setVisibility(myWsTree.visible); - myWsTree.onDidChangeVisibility( - (event) => { - myWorkspacesProvider.setVisibility(event.visible); - }, - undefined, - ctx.subscriptions, + const sharedWorkspacesProvider = new WorkspaceProvider( + WorkspaceQuery.Shared, + client, + output, + deploymentManager, ); + ctx.subscriptions.push(sharedWorkspacesProvider); - const allWsTree = vscode.window.createTreeView(ALL_WORKSPACES_TREE_ID, { - treeDataProvider: allWorkspacesProvider, - }); - ctx.subscriptions.push(allWsTree); - allWorkspacesProvider.setVisibility(allWsTree.visible); - allWsTree.onDidChangeVisibility( - (event) => { - allWorkspacesProvider.setVisibility(event.visible); - }, - undefined, - ctx.subscriptions, - ); + // createTreeView, unlike registerTreeDataProvider, gives us the tree view API + // (so we can see when it is visible) but otherwise they have the same effect. + const registerWorkspaceTreeView = ( + viewId: string, + provider: WorkspaceProvider, + ) => { + const tree = vscode.window.createTreeView(viewId, { + treeDataProvider: provider, + }); + ctx.subscriptions.push(tree); + provider.setVisibility(tree.visible); + tree.onDidChangeVisibility( + (event) => provider.setVisibility(event.visible), + undefined, + ctx.subscriptions, + ); + }; - // Create deployment manager to centralize deployment state management - const deploymentManager = DeploymentManager.create( - serviceContainer, - client, - oauthSessionManager, - [myWorkspacesProvider, allWorkspacesProvider], + registerWorkspaceTreeView(MY_WORKSPACES_TREE_ID, myWorkspacesProvider); + registerWorkspaceTreeView( + SHARED_WORKSPACES_TREE_ID, + sharedWorkspacesProvider, ); - ctx.subscriptions.push(deploymentManager); + registerWorkspaceTreeView(ALL_WORKSPACES_TREE_ID, allWorkspacesProvider); // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. @@ -318,6 +320,7 @@ async function doActivate( ); commandManager.register("coder.refreshWorkspaces", () => { void myWorkspacesProvider.fetchAndRefresh(); + void sharedWorkspacesProvider.fetchAndRefresh(); void allWorkspacesProvider.fetchAndRefresh(); }); commandManager.register("coder.viewLogs", commands.viewLogs.bind(commands)); @@ -328,6 +331,9 @@ async function doActivate( commandManager.register("coder.searchMyWorkspaces", async () => showTreeViewSearch(MY_WORKSPACES_TREE_ID), ); + commandManager.register("coder.searchSharedWorkspaces", async () => + showTreeViewSearch(SHARED_WORKSPACES_TREE_ID), + ); commandManager.register("coder.searchAllWorkspaces", async () => showTreeViewSearch(ALL_WORKSPACES_TREE_ID), ); @@ -406,7 +412,7 @@ async function doActivate( if (details) { ctx.subscriptions.push(details); - const deploymentSet = await deploymentManager.verifyAndApplyDeployment({ + const deploymentSet = await deploymentManager.verifyAndApplySession({ safeHostname: details.safeHostname, url: details.url, token: details.token, @@ -461,7 +467,7 @@ async function doActivate( output.info(`Initializing deployment: ${deployment.url}`); tracer .traceDeploymentInit(() => - deploymentManager.verifyAndApplyDeployment(deployment), + deploymentManager.verifyAndApplySession(deployment), ) .then((success) => { if (success) { diff --git a/src/workspace/session.ts b/src/workspace/session.ts new file mode 100644 index 000000000..ab180e819 --- /dev/null +++ b/src/workspace/session.ts @@ -0,0 +1,21 @@ +import type * as vscode from "vscode"; + +/** + * A point-in-time view of the deployment session for workspace providers. + * + * `revision` increments on every sign-in or sign-out. Consumers snapshot the + * revision before an async call and compare afterward to detect that the + * session changed while the call was in flight. + */ +export type WorkspaceSessionSnapshot = + | { readonly kind: "signedOut"; readonly revision: number } + | { + readonly kind: "signedIn"; + readonly revision: number; + readonly userId: string; + }; + +export interface WorkspaceSessionState { + getSnapshot(): WorkspaceSessionSnapshot; + onDidChange: vscode.Event; +} diff --git a/src/workspace/workspacesProvider.ts b/src/workspace/workspacesProvider.ts index 7ead0dfaf..89fd132fb 100644 --- a/src/workspace/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -20,18 +20,56 @@ import { import { type CoderApi } from "../api/coderApi"; import { type Logger } from "../logging/logger"; +import type { + WorkspaceSessionSnapshot, + WorkspaceSessionState, +} from "./session"; + export enum WorkspaceQuery { Mine = "owner:me", All = "", + Shared = "shared:true", +} + +/** Per-view rendering behavior, keyed by workspace query. */ +interface WorkspaceQueryConfig { + readonly showOwner: boolean; + readonly showMetadata: boolean; + readonly excludeOwn: boolean; } +const WORKSPACE_QUERY_CONFIG = { + [WorkspaceQuery.Mine]: { + showOwner: false, + showMetadata: true, + excludeOwn: false, + }, + [WorkspaceQuery.All]: { + showOwner: true, + showMetadata: false, + excludeOwn: false, + }, + [WorkspaceQuery.Shared]: { + showOwner: true, + showMetadata: false, + excludeOwn: true, + }, +} as const satisfies Record; + +export interface WorkspaceProviderOptions { + readonly refreshIntervalMs?: number; +} + +// Bounds fetch() retries when the session keeps changing mid-request. +export const MAX_FETCH_ATTEMPTS = 3; + /** * Polls workspaces using the provided REST client and renders them in a tree. * * Polling does not start until fetchAndRefresh() is called at least once. * - * If the poll fails or the client has no URL configured, clear the tree and - * abort polling until fetchAndRefresh() is called again. + * If a poll fails or the session is signed out, the tree is cleared and polling + * stops until the next fetchAndRefresh() or session change. */ export class WorkspaceProvider implements vscode.TreeDataProvider, vscode.Disposable @@ -42,6 +80,8 @@ export class WorkspaceProvider WorkspaceAgent["id"], AgentMetadataWatcher >(); + private readonly sessionChangeDisposable: vscode.Disposable; + private readonly config: WorkspaceQueryConfig; private timeout: NodeJS.Timeout | undefined; private fetching = false; private visible = false; @@ -51,126 +91,150 @@ export class WorkspaceProvider private readonly getWorkspacesQuery: WorkspaceQuery, private readonly client: CoderApi, private readonly logger: Logger, - private readonly isAuthenticated: () => boolean, - private readonly timerSeconds?: number, + private readonly sessionState: WorkspaceSessionState, + private readonly options: WorkspaceProviderOptions = {}, ) { - // No initialization. + this.config = WORKSPACE_QUERY_CONFIG[getWorkspacesQuery]; + this.sessionChangeDisposable = this.sessionState.onDidChange(() => { + this.clear(); + void this.fetchAndRefresh(); + }); } - // fetchAndRefresh fetches new workspaces, re-renders the entire tree, then - // keeps refreshing (if a timer length was provided) as long as the user is - // still logged in and no errors were encountered fetching workspaces. - // Calling this while already refreshing or not visible is a no-op and will - // return immediately. - public async fetchAndRefresh() { - if ( - this.disposed || - this.fetching || - !this.visible || - !this.isAuthenticated() - ) { + // Fetch workspaces, render them, and queue the next poll. Does nothing when + // hidden, disposed, or already fetching. Never rejects, so it is safe as void. + public async fetchAndRefresh(): Promise { + if (this.disposed || this.fetching || !this.visible) { return; } this.fetching = true; - - // It is possible we called fetchAndRefresh() manually (through the button - // for example), in which case we might still have a pending refresh that - // needs to be cleared. + // A manual refresh may race a scheduled one, so drop any pending timer. this.cancelPendingRefresh(); let hadError = false; try { - this.workspaces = await this.fetch(); + this.setWorkspaces(await this.fetch()); } catch (error) { this.logger.warn("Failed to fetch workspaces:", error); hadError = true; - this.workspaces = []; + this.setWorkspaces([]); + } finally { + this.fetching = false; } - this.fetching = false; - - this.refresh(); - - // As long as there was no error we can schedule the next refresh. - if (!hadError) { + if ( + !hadError && + !this.disposed && + this.visible && + this.sessionState.getSnapshot().kind === "signedIn" + ) { this.maybeScheduleRefresh(); } } + private setWorkspaces(workspaces: WorkspaceTreeItem[]): void { + if (this.disposed) { + return; + } + this.workspaces = workspaces; + this.refreshTree(); + } + /** - * Fetch workspaces and turn them into tree items. Throw an error if not - * logged in or the query fails. + * Fetch workspaces and turn them into tree items. Returns an empty list when + * signed out, and throws if the query fails. */ private async fetch(): Promise { - // If there is no URL configured, assume we are logged out. - const url = this.client.getAxiosInstance().defaults.baseURL; - if (!url) { - throw new Error("not logged in"); - } + for (let attempt = 0; attempt < MAX_FETCH_ATTEMPTS; attempt++) { + if (this.disposed) { + return []; + } + const session = this.sessionState.getSnapshot(); + if (session.kind !== "signedIn") { + return []; + } - const resp = await this.client.getWorkspaces({ - q: this.getWorkspacesQuery, - }); + const resp = await this.client.getWorkspaces({ + q: this.getWorkspacesQuery, + }); - // We could have logged out while waiting for the query, or logged into a - // different deployment. - const url2 = this.client.getAxiosInstance().defaults.baseURL; - if (!url2) { - throw new Error("not logged in"); - } else if (url !== url2) { - // In this case we need to fetch from the new deployment instead. - // TODO: It would be better to cancel this fetch when that happens, - // because this means we have to wait for the old fetch to finish before - // finally getting workspaces for the new one. - return this.fetch(); - } + // Session changed mid-request; this result is stale, so retry. + if (this.sessionChangedSince(session)) { + continue; + } - const oldWatcherIds = [...this.agentWatchers.keys()]; - const reusedWatcherIds: string[] = []; - - // TODO: I think it might make more sense for the tree items to contain - // their own watchers, rather than recreate the tree items every time and - // have this separate map held outside the tree. - const showMetadata = this.getWorkspacesQuery === WorkspaceQuery.Mine; - if (showMetadata) { - const agents = extractAllAgents(resp.workspaces); - for (const agent of agents) { - // If we have an existing watcher, re-use it. - const oldWatcher = this.agentWatchers.get(agent.id); - if (oldWatcher) { - reusedWatcherIds.push(agent.id); - } else { - // Otherwise create a new watcher. + const workspaces = this.filterWorkspaces(resp.workspaces, session); + const oldWatcherIds = [...this.agentWatchers.keys()]; + const reusedWatcherIds: string[] = []; + + // TODO: I think it might make more sense for the tree items to contain + // their own watchers, rather than recreate the tree items every time + // and have this separate map held outside the tree. + if (this.config.showMetadata) { + const agents = extractAllAgents(workspaces); + for (const agent of agents) { + // If we have an existing watcher, re-use it. + const oldWatcher = this.agentWatchers.get(agent.id); + if (oldWatcher) { + reusedWatcherIds.push(agent.id); + continue; + } const watcher = await createAgentMetadataWatcher( agent.id, this.client, ); - watcher.onChange(() => this.refresh()); + // dispose() or a session change may have raced this await; + // drop the watcher rather than leak it or render stale data. + if (this.disposed || this.sessionChangedSince(session)) { + watcher.dispose(); + return []; + } + watcher.onChange(() => this.refreshTree()); this.agentWatchers.set(agent.id, watcher); } } - } - // Dispose of watchers we ended up not reusing. - for (const id of oldWatcherIds) { - if (!reusedWatcherIds.includes(id)) { - this.agentWatchers.get(id)?.dispose(); - this.agentWatchers.delete(id); + // Dispose of watchers we ended up not reusing. + for (const id of oldWatcherIds) { + if (!reusedWatcherIds.includes(id)) { + this.agentWatchers.get(id)?.dispose(); + this.agentWatchers.delete(id); + } } - } - // Create tree items for each workspace - const workspaceTreeItems = resp.workspaces.map((workspace: Workspace) => { - const workspaceTreeItem = new WorkspaceTreeItem( - workspace, - this.getWorkspacesQuery === WorkspaceQuery.All, - showMetadata, + return workspaces.map( + (workspace: Workspace) => + new WorkspaceTreeItem( + workspace, + this.config.showOwner, + this.config.showMetadata, + ), ); + } + // Session changed on every attempt; the next refresh will catch up. + return []; + } - return workspaceTreeItem; - }); + /** True if the session signed out or changed revision since `session`. */ + private sessionChangedSince( + session: Extract, + ): boolean { + const latest = this.sessionState.getSnapshot(); + return latest.kind !== "signedIn" || latest.revision !== session.revision; + } - return workspaceTreeItems; + private filterWorkspaces( + workspaces: readonly Workspace[], + session: Extract, + ): readonly Workspace[] { + if (!this.config.excludeOwn) { + return workspaces; + } + // `shared:true` also returns workspaces we own and shared out; drop them + // to leave only those shared with us. + return workspaces.filter( + (workspace) => workspace.owner_id !== session.userId, + ); } /** @@ -199,15 +263,12 @@ export class WorkspaceProvider } } - /** - * Schedule a refresh if one is not already scheduled or underway and a - * timeout length was provided. - */ + /** Schedule the next poll, unless one is pending or no interval is set. */ private maybeScheduleRefresh() { - if (this.timerSeconds && !this.timeout && !this.fetching) { + if (this.options.refreshIntervalMs && !this.timeout) { this.timeout = setTimeout(() => { void this.fetchAndRefresh(); - }, this.timerSeconds * 1000); + }, this.options.refreshIntervalMs); } } @@ -218,8 +279,8 @@ export class WorkspaceProvider vscode.TreeItem | undefined | null | void > = this._onDidChangeTreeData.event; - // refresh causes the tree to re-render. It does not fetch fresh workspaces. - public refresh(item?: vscode.TreeItem): void { + // Re-render the tree from the current workspaces. Does not fetch. + public refreshTree(item?: vscode.TreeItem): void { if (this.disposed) { return; } @@ -308,18 +369,24 @@ export class WorkspaceProvider * Clear all workspaces from the tree without fetching. */ public clear(): void { + this.clearState(); + this.refreshTree(); + } + + private clearState(): void { this.cancelPendingRefresh(); for (const watcher of this.agentWatchers.values()) { watcher.dispose(); } this.agentWatchers.clear(); this.workspaces = undefined; - this.refresh(); } public dispose() { this.disposed = true; - this.clear(); + this.clearState(); + this.sessionChangeDisposable.dispose(); + this._onDidChangeTreeData.dispose(); } } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index f715d9b4a..912fed72a 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -12,10 +12,15 @@ import * as vscode from "vscode"; import { createTestTelemetryService } from "./telemetry"; import { window as vscodeWindow } from "./vscode.runtime"; -import type { Experiment, User } from "coder/site/src/api/typesGenerated"; +import type { + Experiment, + User, + Workspace, +} from "coder/site/src/api/typesGenerated"; import type { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; import type { IncomingMessage } from "node:http"; +import type { AgentMetadataEvent } from "@/api/api-helper"; import type { CoderApi } from "@/api/coderApi"; import type { CliCredentialManager } from "@/core/cliCredentialManager"; import type { ServiceContainer } from "@/core/container"; @@ -31,6 +36,10 @@ import type { ParsedMessageEvent, UnidirectionalStream, } from "@/websocket/eventStreamConnection"; +import type { + WorkspaceSessionSnapshot, + WorkspaceSessionState, +} from "@/workspace/session"; /** * Subset of `ContextManager`'s public API that mocks (e.g. `MockContextManager`) @@ -461,6 +470,21 @@ export function createMockLogger(): Logger { }; } +/** Resolve once pending microtasks and the macrotask queue have drained. */ +export async function flush(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +/** + * Drain only the microtask queue. Use instead of flush() under fake timers, + * which leave the macrotask queue (setImmediate) untouched. + */ +export async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + /** * Backdate a file's atime/mtime by `daysAgo` days so tests can exercise the * support-bundle age cutoff without waiting. @@ -1184,3 +1208,83 @@ export class MockTerminalOutputChannel { this.write.mockClear(); } } + +/** Default user id reported by MockWorkspaceSessionState's signed-in snapshot. */ +export const TEST_CURRENT_USER_ID = "current-user"; + +/** In-memory WorkspaceSessionState double with sign-in/out controls. */ +export class MockWorkspaceSessionState implements WorkspaceSessionState { + private revision = 0; + private readonly emitter = + new vscode.EventEmitter(); + private snapshot: WorkspaceSessionSnapshot = { + kind: "signedIn", + revision: 0, + userId: TEST_CURRENT_USER_ID, + }; + + readonly onDidChange = this.emitter.event; + + getSnapshot(): WorkspaceSessionSnapshot { + return this.snapshot; + } + + signIn(userId = TEST_CURRENT_USER_ID): void { + this.revision += 1; + this.snapshot = { kind: "signedIn", revision: this.revision, userId }; + this.emitter.fire(this.snapshot); + } + + signOut(): void { + this.revision += 1; + this.snapshot = { kind: "signedOut", revision: this.revision }; + this.emitter.fire(this.snapshot); + } +} + +interface WorkspacesResponse { + workspaces: readonly Workspace[]; + count: number; +} + +/** + * Stands in for CoderApi at the boundaries WorkspaceProvider touches: the + * workspaces query and the per-agent metadata socket. Program responses with + * respondOnce()/pending(); metadata flows through a real MockEventStream so + * tests drive the production watcher rather than a stub. + */ +export class MockWorkspacesClient { + readonly metadataStreams = new Map< + string, + MockEventStream<{ data: AgentMetadataEvent[] }> + >(); + + readonly getWorkspaces = vi.fn( + (_req: { q: string }): Promise => + Promise.resolve({ workspaces: [], count: 0 }), + ); + + watchAgentMetadata(agentId: string) { + const stream = new MockEventStream<{ data: AgentMetadataEvent[] }>(); + this.metadataStreams.set(agentId, stream); + return Promise.resolve(stream); + } + + /** Resolve the next getWorkspaces call with these workspaces. */ + respondOnce(workspaces: readonly Workspace[]): void { + this.getWorkspaces.mockResolvedValueOnce({ + workspaces, + count: workspaces.length, + }); + } + + /** Make the next getWorkspaces call hang until the returned resolve() runs. */ + pending(): { resolve: (workspaces: readonly Workspace[]) => void } { + const { promise, resolve } = Promise.withResolvers(); + this.getWorkspaces.mockReturnValueOnce(promise); + return { + resolve: (workspaces) => + resolve({ workspaces, count: workspaces.length }), + }; + } +} diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 8eee24521..03dc548d2 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -67,16 +67,19 @@ export class ThemeColor { export class TreeItem { id?: string; - contextValue?: string; + label?: string; description?: string; tooltip?: string; + contextValue?: string; command?: unknown; iconPath?: unknown; constructor( - public label: string, + label: string, public collapsibleState?: number, - ) {} + ) { + this.label = label; + } } export class Uri { diff --git a/test/unit/deployment/deploymentManager.test.ts b/test/unit/deployment/deploymentManager.test.ts index e47c75cac..b5600d499 100644 --- a/test/unit/deployment/deploymentManager.test.ts +++ b/test/unit/deployment/deploymentManager.test.ts @@ -11,6 +11,7 @@ import { createMockLogger, createMockServiceContainer, createMockUser, + flush, InMemoryMemento, InMemorySecretStorage, MockCoderApi, @@ -20,7 +21,6 @@ import { } from "../../mocks/testHelpers"; import type { OAuthSessionManager } from "@/oauth/sessionManager"; -import type { WorkspaceProvider } from "@/workspace/workspacesProvider"; // Mock CoderApi.create to return our mock client for validation vi.mock("@/api/coderApi", async (importOriginal) => { @@ -34,18 +34,15 @@ vi.mock("@/api/coderApi", async (importOriginal) => { }; }); -/** - * Mock WorkspaceProvider for deployment tests. - */ -class MockWorkspaceProvider { - readonly fetchAndRefresh = vi.fn(); - readonly clear = vi.fn(); -} - const TEST_URL = "https://coder.example.com"; const TEST_HOSTNAME = "coder.example.com"; const managers: DeploymentManager[] = []; +function currentUserId(manager: DeploymentManager): string | undefined { + const snapshot = manager.getSnapshot(); + return snapshot.kind === "signedIn" ? snapshot.userId : undefined; +} + afterEach(() => { for (const manager of managers) { manager.dispose(); @@ -61,9 +58,8 @@ function createTestContext() { new MockConfigurationProvider(); const mockClient = new MockCoderApi(); - // For verifyAndApplyDeployment, we use a separate mock for validation + // For verifyAndApplySession, we use a separate mock for validation const validationMockClient = new MockCoderApi(); - const mockWorkspaceProvider = new MockWorkspaceProvider(); const mockOAuthSessionManager = new MockOAuthSessionManager(); const secretStorage = new InMemorySecretStorage(); const memento = new InMemoryMemento(); @@ -91,7 +87,6 @@ function createTestContext() { }), mockClient as unknown as CoderApi, mockOAuthSessionManager as unknown as OAuthSessionManager, - [mockWorkspaceProvider as unknown as WorkspaceProvider], ); managers.push(manager); @@ -101,7 +96,6 @@ function createTestContext() { secretsManager, contextManager, mockOAuthSessionManager, - mockWorkspaceProvider, telemetrySink, telemetryService, setDeploymentUrlSpy, @@ -115,12 +109,13 @@ describe("DeploymentManager", () => { const { manager } = createTestContext(); expect(manager.getCurrentDeployment()).toBeNull(); + expect(currentUserId(manager)).toBeUndefined(); expect(manager.isAuthenticated()).toBe(false); }); it("returns deployment and isAuthenticated=true after setDeployment", async () => { const { manager } = createTestContext(); - const user = createMockUser(); + const user = createMockUser({ id: "current-user" }); await manager.setDeployment({ url: TEST_URL, @@ -133,6 +128,7 @@ describe("DeploymentManager", () => { url: TEST_URL, safeHostname: TEST_HOSTNAME, }); + expect(currentUserId(manager)).toBe("current-user"); expect(manager.isAuthenticated()).toBe(true); }); @@ -150,6 +146,7 @@ describe("DeploymentManager", () => { await manager.clearDeployment("credentials_removed"); expect(manager.getCurrentDeployment()).toBeNull(); + expect(currentUserId(manager)).toBeUndefined(); expect(manager.isAuthenticated()).toBe(false); }); }); @@ -176,6 +173,29 @@ describe("DeploymentManager", () => { expect(persisted?.url).toBe(TEST_URL); }); + it("does not persist a deployment after a newer state wins", async () => { + const { mockOAuthSessionManager, secretsManager, manager } = + createTestContext(); + const oauthSetDeployment = Promise.withResolvers(); + mockOAuthSessionManager.setDeployment.mockReturnValueOnce( + oauthSetDeployment.promise, + ); + const firstSetDeployment = manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "test-token", + user: createMockUser(), + }); + + await flush(); + await manager.clearDeployment("logout"); + oauthSetDeployment.resolve(); + await firstSetDeployment; + + expect(await secretsManager.getCurrentDeployment()).toBeNull(); + expect(manager.isAuthenticated()).toBe(false); + }); + it("notifies telemetry of the deployment URL", async () => { const { setDeploymentUrlSpy, manager } = createTestContext(); @@ -206,13 +226,13 @@ describe("DeploymentManager", () => { }); }); - describe("verifyAndApplyDeployment", () => { + describe("verifyAndApplySession", () => { it("returns true and sets deployment on auth success", async () => { const { mockClient, validationMockClient, manager } = createTestContext(); const user = createMockUser(); validationMockClient.setAuthenticatedUserResponse(user); - const result = await manager.verifyAndApplyDeployment({ + const result = await manager.verifyAndApplySession({ url: TEST_URL, safeHostname: TEST_HOSTNAME, token: "test-token", @@ -221,6 +241,7 @@ describe("DeploymentManager", () => { expect(result).toBe(true); expect(mockClient.host).toBe(TEST_URL); expect(mockClient.token).toBe("test-token"); + expect(currentUserId(manager)).toBe(user.id); expect(manager.isAuthenticated()).toBe(true); }); @@ -230,7 +251,7 @@ describe("DeploymentManager", () => { new Error("Auth failed"), ); - const result = await manager.verifyAndApplyDeployment({ + const result = await manager.verifyAndApplySession({ url: TEST_URL, safeHostname: TEST_HOSTNAME, token: "test-token", @@ -238,6 +259,7 @@ describe("DeploymentManager", () => { expect(result).toBe(false); expect(manager.getCurrentDeployment()).toBeNull(); + expect(currentUserId(manager)).toBeUndefined(); expect(manager.isAuthenticated()).toBe(false); }); @@ -246,7 +268,7 @@ describe("DeploymentManager", () => { const user = createMockUser(); validationMockClient.setAuthenticatedUserResponse(user); - const result = await manager.verifyAndApplyDeployment({ + const result = await manager.verifyAndApplySession({ url: TEST_URL, safeHostname: TEST_HOSTNAME, token: "", @@ -270,7 +292,7 @@ describe("DeploymentManager", () => { token: "stored-token", }); - const result = await manager.verifyAndApplyDeployment({ + const result = await manager.verifyAndApplySession({ url: TEST_URL, safeHostname: TEST_HOSTNAME, }); @@ -284,7 +306,7 @@ describe("DeploymentManager", () => { const user = createMockUser(); validationMockClient.setAuthenticatedUserResponse(user); - await manager.verifyAndApplyDeployment({ + await manager.verifyAndApplySession({ url: TEST_URL, safeHostname: TEST_HOSTNAME, token: "test-token", @@ -292,6 +314,35 @@ describe("DeploymentManager", () => { expect(validationMockClient.disposed).toBe(true); }); + + it("does not apply stale validation after a concurrent login", async () => { + const { mockClient, validationMockClient, manager } = createTestContext(); + const remoteUser = createMockUser({ id: "remote-user" }); + const localUser = createMockUser({ id: "local-user" }); + const validation = Promise.withResolvers(); + validationMockClient.getAuthenticatedUser.mockReturnValueOnce( + validation.promise, + ); + const remoteLogin = manager.verifyAndApplySession({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "remote-token", + }); + + await flush(); + await manager.setDeployment({ + url: "https://local.example.com", + safeHostname: "local.example.com", + token: "local-token", + user: localUser, + }); + validation.resolve(remoteUser); + + expect(await remoteLogin).toBe(false); + expect(mockClient.host).toBe("https://local.example.com"); + expect(mockClient.token).toBe("local-token"); + expect(currentUserId(manager)).toBe("local-user"); + }); }); describe("cross-window sync", () => { @@ -340,7 +391,7 @@ describe("DeploymentManager", () => { }); // Wait for async handler - await new Promise((resolve) => setImmediate(resolve)); + await flush(); expect(mockClient.host).toBe(TEST_URL); expect(mockClient.token).toBe("synced-token"); @@ -369,7 +420,7 @@ describe("DeploymentManager", () => { }); // Wait for async handler - await new Promise((resolve) => setImmediate(resolve)); + await flush(); expect(mockClient.host).toBe(TEST_URL); expect(mockClient.token).toBe(""); @@ -377,11 +428,16 @@ describe("DeploymentManager", () => { }); describe("auth listener", () => { - it("updates credentials on token change", async () => { - const { mockClient, secretsManager, manager } = createTestContext(); - const user = createMockUser(); + it("updates credentials and user on token change", async () => { + const { mockClient, validationMockClient, secretsManager, manager } = + createTestContext(); + const user = createMockUser({ id: "initial-user" }); + const refreshedUser = createMockUser({ + id: "refreshed-user", + username: "refresheduser", + }); + validationMockClient.setAuthenticatedUserResponse(refreshedUser); - // Set up authenticated deployment await manager.setDeployment({ url: TEST_URL, safeHostname: TEST_HOSTNAME, @@ -390,19 +446,137 @@ describe("DeploymentManager", () => { }); expect(mockClient.token).toBe("initial-token"); + expect(currentUserId(manager)).toBe("initial-user"); expect(manager.isAuthenticated()).toBe(true); - // Simulate token refresh via secrets change await secretsManager.setSessionAuth(TEST_HOSTNAME, { url: TEST_URL, token: "refreshed-token", }); - - // Wait for async handler - await new Promise((resolve) => setImmediate(resolve)); + await flush(); expect(mockClient.token).toBe("refreshed-token"); + expect(currentUserId(manager)).toBe("refreshed-user"); + expect(manager.isAuthenticated()).toBe(true); + }); + + it("does not apply stale token validation after logout", async () => { + const { mockClient, validationMockClient, secretsManager, manager } = + createTestContext(); + const user = createMockUser({ id: "initial-user" }); + const refreshedUser = createMockUser({ id: "refreshed-user" }); + const validation = Promise.withResolvers(); + validationMockClient.getAuthenticatedUser.mockReturnValueOnce( + validation.promise, + ); + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "initial-token", + user, + }); + + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "refreshed-token", + }); + await flush(); + await manager.clearDeployment("logout"); + validation.resolve(refreshedUser); + await flush(); + + expect(mockClient.host).toBeUndefined(); + expect(mockClient.token).toBeUndefined(); + expect(manager.getCurrentDeployment()).toBeNull(); + expect(currentUserId(manager)).toBeUndefined(); + }); + + it("rotates the token in place without a revision bump when the user is unchanged", async () => { + const { mockClient, validationMockClient, secretsManager, manager } = + createTestContext(); + const user = createMockUser({ id: "stable-user" }); + validationMockClient.setAuthenticatedUserResponse(user); + + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "initial-token", + user, + }); + const revisionBefore = manager.getSnapshot().revision; + + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "rotated-token", + }); + await flush(); + + expect(mockClient.token).toBe("rotated-token"); + expect(currentUserId(manager)).toBe("stable-user"); + // No sign-in fired, so the workspace trees are not rebuilt. + expect(manager.getSnapshot().revision).toBe(revisionBefore); + }); + + it("keeps the existing session when verifying a rotated token fails", async () => { + const { mockClient, validationMockClient, secretsManager, manager } = + createTestContext(); + const user = createMockUser({ id: "stable-user" }); + + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "initial-token", + user, + }); + + // Verifying the rotated token fails (e.g. a transient network blip). + validationMockClient.getAuthenticatedUser.mockRejectedValueOnce( + new Error("network down"), + ); + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "rotated-token", + }); + await flush(); + + // Verify-before-apply: the unverified token never reaches the client. + expect(mockClient.token).toBe("initial-token"); expect(manager.isAuthenticated()).toBe(true); + expect(currentUserId(manager)).toBe("stable-user"); + }); + + it("recovers a session suspended while a rotated token is verified", async () => { + const { mockClient, validationMockClient, secretsManager, manager } = + createTestContext(); + const user = createMockUser({ id: "stable-user" }); + const validation = Promise.withResolvers(); + validationMockClient.getAuthenticatedUser.mockReturnValueOnce( + validation.promise, + ); + + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "initial-token", + user, + }); + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "rotated-token", + }); + await flush(); + + // A concurrent 401 on the old token suspends the session mid-verify. + manager.suspendSession("auth_failure"); + expect(manager.isAuthenticated()).toBe(false); + + validation.resolve(user); + await flush(); + + // The verified token recovers the session instead of staying suspended. + expect(manager.isAuthenticated()).toBe(true); + expect(mockClient.token).toBe("rotated-token"); + expect(currentUserId(manager)).toBe("stable-user"); }); }); @@ -424,6 +598,7 @@ describe("DeploymentManager", () => { expect(mockClient.token).toBeUndefined(); expect(contextManager.get("coder.authenticated")).toBe(false); expect(contextManager.get("coder.isOwner")).toBe(false); + expect(currentUserId(manager)).toBeUndefined(); }); it("resets the telemetry deployment URL on clearDeployment", async () => { @@ -461,13 +636,8 @@ describe("DeploymentManager", () => { }); it("clears auth state but keeps deployment for re-login", async () => { - const { - mockClient, - contextManager, - mockOAuthSessionManager, - mockWorkspaceProvider, - manager, - } = createTestContext(); + const { mockClient, contextManager, mockOAuthSessionManager, manager } = + createTestContext(); await manager.setDeployment({ url: TEST_URL, @@ -484,8 +654,8 @@ describe("DeploymentManager", () => { expect(mockClient.host).toBeUndefined(); expect(mockClient.token).toBeUndefined(); expect(contextManager.get("coder.authenticated")).toBe(false); + expect(currentUserId(manager)).toBeUndefined(); expect(manager.isAuthenticated()).toBe(false); - expect(mockWorkspaceProvider.clear).toHaveBeenCalled(); // Deployment is retained for easy re-login expect(manager.getCurrentDeployment()).toMatchObject({ @@ -499,12 +669,21 @@ describe("DeploymentManager", () => { it("recovers from suspended state when auth settings change", async () => { vi.useFakeTimers(); try { - const { mockClient, validationMockClient, telemetrySink, manager } = - createTestContext(); + const { + mockClient, + validationMockClient, + secretsManager, + telemetrySink, + manager, + } = createTestContext(); const config = new MockConfigurationProvider(); const user = createMockUser(); validationMockClient.setAuthenticatedUserResponse(user); + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "", + }); await manager.setDeployment({ url: TEST_URL, safeHostname: TEST_HOSTNAME, @@ -521,6 +700,7 @@ describe("DeploymentManager", () => { expect(mockClient.host).toBe(TEST_URL); expect(mockClient.token).toBe(""); expect(manager.isAuthenticated()).toBe(true); + expect(currentUserId(manager)).toBe(user.id); expect(validationMockClient.getAuthenticatedUser).toHaveBeenCalledTimes( 1, ); @@ -562,6 +742,7 @@ describe("DeploymentManager", () => { await vi.runAllTimersAsync(); expect(manager.getCurrentDeployment()).toBeNull(); + expect(currentUserId(manager)).toBeUndefined(); expect(manager.isAuthenticated()).toBe(false); } finally { vi.useRealTimers(); @@ -597,10 +778,11 @@ describe("DeploymentManager", () => { token: "recovered-token", }); - await new Promise((resolve) => setImmediate(resolve)); + await flush(); // Should recover and be authenticated again expect(mockClient.token).toBe("recovered-token"); + expect(currentUserId(manager)).toBe(user.id); expect(manager.isAuthenticated()).toBe(true); expect(telemetrySink.expectOne("deployment.recovered")).toMatchObject({ properties: { trigger: "token_update" }, diff --git a/test/unit/workspace/workspacesProvider.test.ts b/test/unit/workspace/workspacesProvider.test.ts new file mode 100644 index 000000000..a9ba758a6 --- /dev/null +++ b/test/unit/workspace/workspacesProvider.test.ts @@ -0,0 +1,562 @@ +import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { + MAX_FETCH_ATTEMPTS, + WorkspaceProvider, + WorkspaceQuery, + type AgentTreeItem, + type WorkspaceTreeItem, +} from "@/workspace/workspacesProvider"; + +import { agent, resource, workspace } from "@repo/mocks"; + +import { + createMockLogger, + flush, + flushPromises, + MockEventStream, + MockWorkspaceSessionState, + MockWorkspacesClient, + TEST_CURRENT_USER_ID, +} from "../../mocks/testHelpers"; + +import type { + Workspace, + WorkspaceAgent, + WorkspaceApp, + WorkspaceAppStatus, +} from "coder/site/src/api/typesGenerated"; + +import type { AgentMetadataEvent } from "@/api/api-helper"; +import type { CoderApi } from "@/api/coderApi"; + +function setup() { + const logger = createMockLogger(); + const client = new MockWorkspacesClient(); + const session = new MockWorkspaceSessionState(); + const makeProvider = ( + query: WorkspaceQuery, + options?: { refreshIntervalMs?: number }, + ): WorkspaceProvider => + new WorkspaceProvider( + query, + client as unknown as CoderApi, + logger, + session, + options, + ); + return { logger, client, session, makeProvider }; +} + +function workspaceWithAgents( + workspaceOverrides: Parameters[0] = {}, + agents: WorkspaceAgent[], +): Workspace { + return workspace({ + ...workspaceOverrides, + latest_build: { + ...workspaceOverrides.latest_build, + resources: [resource({ agents })], + }, + }); +} + +function appStatus( + overrides: Partial = {}, +): WorkspaceAppStatus { + return { + id: "status-1", + created_at: "2024-01-01T00:00:00Z", + workspace_id: "workspace-1", + agent_id: "agent-1", + app_id: "app-1", + state: "working", + message: "Opening pull request", + uri: "https://example.com/pr/1", + icon: "", + needs_user_attention: false, + ...overrides, + }; +} + +function app(overrides: Partial = {}): WorkspaceApp { + return { + id: "app-1", + external: false, + slug: "app", + subdomain: false, + sharing_level: "owner", + health: "healthy", + hidden: false, + open_in: "tab", + statuses: [], + ...overrides, + }; +} + +function metadata( + overrides: Partial = {}, +): AgentMetadataEvent { + return { + result: { + collected_at: "2024-01-01T00:00:00Z", + age: 0, + value: "42", + error: "", + }, + description: { + display_name: "CPU", + key: "cpu", + script: "cpu.sh", + interval: 5, + timeout: 1, + }, + ...overrides, + }; +} + +async function show(provider: WorkspaceProvider): Promise { + provider.setVisibility(true); + await flush(); +} + +async function labels(provider: WorkspaceProvider): Promise { + return (await provider.getChildren()).map((item) => item.label); +} + +describe("WorkspaceProvider", () => { + it("does not fetch while signed out", async () => { + const { client, session, makeProvider } = setup(); + session.signOut(); + const provider = makeProvider(WorkspaceQuery.Mine); + + await show(provider); + + expect(client.getWorkspaces).not.toHaveBeenCalled(); + expect(await provider.getChildren()).toEqual([]); + }); + + it.each([ + [WorkspaceQuery.Mine, "owner:me"], + [WorkspaceQuery.Shared, "shared:true"], + [WorkspaceQuery.All, ""], + ])("fetches %s with the expected query", async (query, expectedQuery) => { + const { client, makeProvider } = setup(); + const provider = makeProvider(query); + + await show(provider); + + expect(client.getWorkspaces).toHaveBeenCalledWith({ q: expectedQuery }); + }); + + it.each([ + { + query: WorkspaceQuery.Mine, + label: "dev", + collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + }, + { + query: WorkspaceQuery.Shared, + label: "alice / dev", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }, + { + query: WorkspaceQuery.All, + label: "alice / dev", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }, + ])( + "renders top-level workspace items for $query", + async ({ query, label, collapsibleState }) => { + const { client, makeProvider } = setup(); + client.respondOnce([ + workspace({ + id: "workspace-1", + name: "dev", + owner_id: "alice-id", + owner_name: "alice", + }), + ]); + const provider = makeProvider(query); + + await show(provider); + const [item] = (await provider.getChildren()) as WorkspaceTreeItem[]; + + expect(item?.label).toBe(label); + expect(item?.description).toBe("running"); + expect(item?.collapsibleState).toBe(collapsibleState); + expect(item?.contextValue).toContain("running"); + }, + ); + + it("filters current-user-owned workspaces from shared results", async () => { + const { client, makeProvider } = setup(); + client.respondOnce([ + workspace({ + id: "owned-shared-out", + name: "owned", + owner_id: TEST_CURRENT_USER_ID, + owner_name: "current", + }), + workspace({ + id: "shared-with-me", + name: "shared", + owner_id: "alice-id", + owner_name: "alice", + }), + ]); + const provider = makeProvider(WorkspaceQuery.Shared); + + await show(provider); + + expect(await labels(provider)).toEqual(["alice / shared"]); + }); + + it.each([WorkspaceQuery.Mine, WorkspaceQuery.All])( + "does not apply shared ownership filtering to %s", + async (query) => { + const { client, makeProvider } = setup(); + client.respondOnce([ + workspace({ + id: "owned", + name: "owned", + owner_id: TEST_CURRENT_USER_ID, + owner_name: "current", + }), + ]); + const provider = makeProvider(query); + + await show(provider); + + expect(await labels(provider)).toHaveLength(1); + }, + ); + + it("clears rendered workspaces when the session signs out", async () => { + const { client, session, makeProvider } = setup(); + client.respondOnce([workspace({ name: "dev" })]); + const provider = makeProvider(WorkspaceQuery.Mine); + + await show(provider); + expect(await labels(provider)).toEqual(["dev"]); + + session.signOut(); + await flush(); + + expect(await provider.getChildren()).toEqual([]); + }); + + it("does not render a pending response after sign-out", async () => { + const { client, session, makeProvider } = setup(); + const pending = client.pending(); + const provider = makeProvider(WorkspaceQuery.Shared); + + provider.setVisibility(true); + await flush(); + session.signOut(); + pending.resolve([workspace({ owner_id: "alice-id", owner_name: "alice" })]); + await flush(); + + expect(await provider.getChildren()).toEqual([]); + }); + + it("renders fresh results when the session changes mid-request", async () => { + const { client, session, makeProvider } = setup(); + const pending = client.pending(); + client.respondOnce([ + workspace({ owner_id: "alice-id", owner_name: "alice", name: "fresh" }), + ]); + const provider = makeProvider(WorkspaceQuery.Shared); + + provider.setVisibility(true); + await flush(); + session.signIn("second-user"); + pending.resolve([ + workspace({ owner_id: "bob-id", owner_name: "bob", name: "stale" }), + ]); + await flush(); + await flush(); + + expect(await labels(provider)).toEqual(["alice / fresh"]); + }); + + it("does not fetch while hidden", async () => { + const { client, makeProvider } = setup(); + const provider = makeProvider(WorkspaceQuery.Mine); + + await provider.fetchAndRefresh(); + + expect(client.getWorkspaces).not.toHaveBeenCalled(); + }); + + it("renders a response that completes after the tree is hidden", async () => { + const { client, makeProvider } = setup(); + const pending = client.pending(); + const provider = makeProvider(WorkspaceQuery.Mine); + + provider.setVisibility(true); + await flush(); + provider.setVisibility(false); + pending.resolve([workspace({ name: "dev" })]); + await flush(); + + expect(await labels(provider)).toEqual(["dev"]); + }); + + it("renders fresh results for a session change queued while hidden", async () => { + const { client, session, makeProvider } = setup(); + const pending = client.pending(); + client.respondOnce([workspace({ name: "fresh" })]); + const provider = makeProvider(WorkspaceQuery.Mine); + + provider.setVisibility(true); + await flush(); + provider.setVisibility(false); + session.signIn("second-user"); + pending.resolve([workspace({ name: "stale" })]); + await flush(); + + provider.setVisibility(true); + await flush(); + + expect(await labels(provider)).toEqual(["fresh"]); + }); + + it("clears the tree when a fetch fails", async () => { + const { client, makeProvider } = setup(); + client.respondOnce([workspace({ name: "dev" })]); + client.getWorkspaces.mockRejectedValueOnce(new Error("network down")); + const provider = makeProvider(WorkspaceQuery.Mine); + + await show(provider); + expect(await labels(provider)).toEqual(["dev"]); + + await provider.fetchAndRefresh(); + + expect(await provider.getChildren()).toEqual([]); + }); + + it("refreshes content on the polling interval", async () => { + vi.useFakeTimers(); + try { + const { client, makeProvider } = setup(); + client.respondOnce([workspace({ name: "first" })]); + client.respondOnce([workspace({ name: "second" })]); + const provider = makeProvider(WorkspaceQuery.Mine, { + refreshIntervalMs: 5_000, + }); + + provider.setVisibility(true); + await flushPromises(); + expect(await labels(provider)).toEqual(["first"]); + + await vi.advanceTimersByTimeAsync(5_000); + await flushPromises(); + + expect(await labels(provider)).toEqual(["second"]); + } finally { + vi.useRealTimers(); + } + }); + + it("renders workspace child agents", async () => { + const { client, makeProvider } = setup(); + client.respondOnce([ + workspaceWithAgents({ name: "dev" }, [ + agent({ id: "agent-1", name: "main", status: "connected" }), + agent({ id: "agent-2", name: "sidecar", status: "disconnected" }), + ]), + ]); + const provider = makeProvider(WorkspaceQuery.Mine); + + await show(provider); + const [workspaceItem] = + (await provider.getChildren()) as WorkspaceTreeItem[]; + const agentItems = (await provider.getChildren( + workspaceItem, + )) as AgentTreeItem[]; + + expect(agentItems.map((item) => item.label)).toEqual(["main", "sidecar"]); + expect(agentItems.map((item) => item.description)).toEqual([ + "connected", + "disconnected", + ]); + }); + + it("renders app status children for an agent", async () => { + const { client, makeProvider } = setup(); + client.respondOnce([ + workspaceWithAgents({ name: "dev" }, [ + agent({ + id: "agent-1", + apps: [ + app({ + command: "open-pr", + statuses: [ + appStatus({ id: "status-1", message: "First" }), + appStatus({ id: "status-2", message: "Second" }), + ], + }), + ], + }), + ]), + ]); + const provider = makeProvider(WorkspaceQuery.Mine); + + await show(provider); + const [workspaceItem] = + (await provider.getChildren()) as WorkspaceTreeItem[]; + const [agentItem] = (await provider.getChildren( + workspaceItem, + )) as AgentTreeItem[]; + const [section] = await provider.getChildren(agentItem); + const statuses = await provider.getChildren(section); + + expect(section?.label).toBe("App Statuses"); + expect(statuses.map((item) => item.description)).toEqual([ + "Second", + "First", + ]); + expect(statuses[0]?.command).toMatchObject({ + command: "coder.openAppStatus", + }); + }); + + it("renders agent metadata and surfaces metadata errors", async () => { + const { client, makeProvider } = setup(); + client.respondOnce([ + workspaceWithAgents({ name: "dev" }, [ + agent({ id: "agent-1", name: "main" }), + ]), + ]); + const provider = makeProvider(WorkspaceQuery.Mine); + + await show(provider); + const [workspaceItem] = + (await provider.getChildren()) as WorkspaceTreeItem[]; + const [agentItem] = (await provider.getChildren( + workspaceItem, + )) as AgentTreeItem[]; + const stream = client.metadataStreams.get("agent-1")!; + + stream.pushMessage({ data: [metadata()] }); + const [metadataSection] = await provider.getChildren(agentItem); + const metadataItems = await provider.getChildren(metadataSection); + expect(metadataSection?.label).toBe("Agent Metadata"); + expect(metadataItems[0]?.label).toBe("CPU: 42"); + + stream.pushError(new Error("boom")); + const [errorSection] = await provider.getChildren(agentItem); + expect(errorSection?.label).toBe("Failed to query metadata: boom"); + }); + + it("empties the tree when cleared", async () => { + const { client, makeProvider } = setup(); + client.respondOnce([ + workspaceWithAgents({ name: "dev" }, [agent({ id: "agent-1" })]), + ]); + const provider = makeProvider(WorkspaceQuery.Mine); + + await show(provider); + expect(await labels(provider)).toEqual(["dev"]); + + provider.clear(); + + expect(await provider.getChildren()).toEqual([]); + }); + + it("fetches when the session signs in after starting signed out", async () => { + const { client, session, makeProvider } = setup(); + session.signOut(); + client.respondOnce([workspace({ name: "dev" })]); + const provider = makeProvider(WorkspaceQuery.Mine); + + await show(provider); + expect(client.getWorkspaces).not.toHaveBeenCalled(); + + session.signIn(); + await flush(); + + expect(await labels(provider)).toEqual(["dev"]); + }); + + it("stops reacting to session changes after dispose", async () => { + const { client, session, makeProvider } = setup(); + client.respondOnce([workspace({ name: "dev" })]); + const provider = makeProvider(WorkspaceQuery.Mine); + + await show(provider); + expect(await labels(provider)).toEqual(["dev"]); + + provider.dispose(); + session.signIn("another-user"); + await flush(); + + expect(client.getWorkspaces).toHaveBeenCalledTimes(1); + expect(await provider.getChildren()).toEqual([]); + }); + + it("leaves the tree empty when a fetch fails after a mid-request session change", async () => { + const { client, session, makeProvider } = setup(); + const pending = client.pending(); + client.getWorkspaces.mockRejectedValueOnce(new Error("network down")); + const provider = makeProvider(WorkspaceQuery.Mine); + + provider.setVisibility(true); + await flush(); + session.signIn("second-user"); + pending.resolve([workspace({ name: "stale" })]); + await flush(); + await flush(); + + // Failed retry leaves the tree empty until a manual refresh. + expect(await provider.getChildren()).toEqual([]); + + // A manual refresh recovers. + client.respondOnce([workspace({ name: "recovered" })]); + await provider.fetchAndRefresh(); + expect(await labels(provider)).toEqual(["recovered"]); + }); + + it("gives up after the retry cap when the session keeps changing", async () => { + const { client, session, makeProvider } = setup(); + // Every response arrives stale: each call bumps the revision first. + client.getWorkspaces.mockImplementation(() => { + session.signIn(); + return Promise.resolve({ + workspaces: [workspace({ name: "ws" })], + count: 1, + }); + }); + const provider = makeProvider(WorkspaceQuery.Mine); + + await show(provider); + + expect(client.getWorkspaces).toHaveBeenCalledTimes(MAX_FETCH_ATTEMPTS); + expect(await provider.getChildren()).toEqual([]); + }); + + it("disposes a watcher created after dispose() races fetch()", async () => { + const { client, makeProvider } = setup(); + client.respondOnce([ + workspaceWithAgents({ name: "dev" }, [agent({ id: "agent-1" })]), + ]); + const stream = new MockEventStream<{ data: AgentMetadataEvent[] }>(); + const watch = + Promise.withResolvers>(); + client.watchAgentMetadata = vi.fn((_agentId: string) => watch.promise); + const provider = makeProvider(WorkspaceQuery.Mine); + + provider.setVisibility(true); + await flush(); + + // dispose() while the watcher socket is still opening. + provider.dispose(); + watch.resolve(stream); + await flush(); + + expect(stream.close).toHaveBeenCalled(); + expect(await provider.getChildren()).toEqual([]); + }); +});