diff --git a/.eslintrc.json b/.eslintrc.json index 91d67601..32fb8e61 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,17 +23,6 @@ "import/internal-regex": "^@/" }, "overrides": [ - { - "files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"], - "settings": { - "import/resolver": { - "typescript": { - // In tests, resolve using the test tsconfig - "project": "test/tsconfig.json" - } - } - } - }, { "files": ["*.ts"], "rules": { @@ -46,9 +35,30 @@ "prefer": "type-imports", "fixStyle": "inline-type-imports" } + ], + "@typescript-eslint/switch-exhaustiveness-check": [ + "error", + { "considerDefaultExhaustiveForUnions": true } ] } }, + { + "files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"], + "settings": { + "import/resolver": { + "typescript": { + // In tests, resolve using the test tsconfig + "project": "test/tsconfig.json" + } + } + } + }, + { + "files": ["src/core/contextManager.ts"], + "rules": { + "no-restricted-syntax": "off" + } + }, { "extends": ["plugin:package-json/legacy-recommended"], "files": ["*.json"], @@ -106,6 +116,13 @@ "sublings_only": true } } + ], + "no-restricted-syntax": [ + "error", + { + "selector": "CallExpression[callee.property.name='executeCommand'][arguments.0.value='setContext'][arguments.length>=3]", + "message": "Do not use executeCommand('setContext', ...) directly. Use the ContextManager class instead." + } ] } } diff --git a/src/commands.ts b/src/commands.ts index bd4071cc..5abeb026 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -12,6 +12,7 @@ import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { type CliManager } from "./core/cliManager"; import { type ServiceContainer } from "./core/container"; +import { type ContextManager } from "./core/contextManager"; import { type MementoManager } from "./core/mementoManager"; import { type PathResolver } from "./core/pathResolver"; import { type SecretsManager } from "./core/secretsManager"; @@ -32,6 +33,7 @@ export class Commands { private readonly mementoManager: MementoManager; private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; + private readonly contextManager: ContextManager; // These will only be populated when actively connected to a workspace and are // used in commands. Because commands can be executed by the user, it is not // possible to pass in arguments, so we have to store the current workspace @@ -53,6 +55,7 @@ export class Commands { this.mementoManager = serviceContainer.getMementoManager(); this.secretsManager = serviceContainer.getSecretsManager(); this.cliManager = serviceContainer.getCliManager(); + this.contextManager = serviceContainer.getContextManager(); } /** @@ -179,19 +182,22 @@ export class Commands { } /** - * Log into the provided deployment. If the deployment URL is not specified, + * Log into the provided deployment. If the deployment URL is not specified, * ask for it first with a menu showing recent URLs along with the default URL * and CODER_URL, if those are set. */ - public async login(...args: string[]): Promise { - // Destructure would be nice but VS Code can pass undefined which errors. - const inputUrl = args[0]; - const inputToken = args[1]; - const inputLabel = args[2]; - const isAutologin = - typeof args[3] === "undefined" ? false : Boolean(args[3]); - - const url = await this.maybeAskUrl(inputUrl); + public async login(args?: { + url?: string; + token?: string; + label?: string; + autoLogin?: boolean; + }): Promise { + if (this.contextManager.get("coder.authenticated")) { + return; + } + this.logger.info("Logging in"); + + const url = await this.maybeAskUrl(args?.url); if (!url) { return; // The user aborted. } @@ -199,11 +205,11 @@ export class Commands { // It is possible that we are trying to log into an old-style host, in which // case we want to write with the provided blank label instead of generating // a host label. - const label = - typeof inputLabel === "undefined" ? toSafeHost(url) : inputLabel; + const label = args?.label === undefined ? toSafeHost(url) : args.label; // Try to get a token from the user, if we need one, and their user. - const res = await this.maybeAskToken(url, inputToken, isAutologin); + const autoLogin = args?.autoLogin === true; + const res = await this.maybeAskToken(url, args?.token, autoLogin); if (!res) { return; // The user aborted, or unable to auth. } @@ -221,13 +227,9 @@ export class Commands { await this.cliManager.configure(label, url, res.token); // These contexts control various menu items and the sidebar. - await vscode.commands.executeCommand( - "setContext", - "coder.authenticated", - true, - ); + this.contextManager.set("coder.authenticated", true); if (res.user.roles.find((role) => role.name === "owner")) { - await vscode.commands.executeCommand("setContext", "coder.isOwner", true); + this.contextManager.set("coder.isOwner", true); } vscode.window @@ -245,6 +247,7 @@ export class Commands { } }); + await this.secretsManager.triggerLoginStateChange("login"); // Fetch workspaces for the new deployment. vscode.commands.executeCommand("coder.refreshWorkspaces"); } @@ -257,19 +260,21 @@ export class Commands { */ private async maybeAskToken( url: string, - token: string, - isAutologin: boolean, + token: string | undefined, + isAutoLogin: boolean, ): Promise<{ user: User; token: string } | null> { const client = CoderApi.create(url, token, this.logger); - if (!needToken(vscode.workspace.getConfiguration())) { + const needsToken = needToken(vscode.workspace.getConfiguration()); + if (!needsToken || token) { try { const user = await client.getAuthenticatedUser(); // For non-token auth, we write a blank token since the `vscodessh` // command currently always requires a token file. - return { token: "", user }; + // For token auth, we have valid access so we can just return the user here + return { token: needsToken && token ? token : "", user }; } catch (err) { const message = getErrorMessage(err, "no response from the server"); - if (isAutologin) { + if (isAutoLogin) { this.logger.warn("Failed to log in to Coder server:", message); } else { this.vscodeProposed.window.showErrorMessage( @@ -301,6 +306,9 @@ export class Commands { value: token || (await this.secretsManager.getSessionToken()), ignoreFocusOut: true, validateInput: async (value) => { + if (!value) { + return null; + } client.setSessionToken(value); try { user = await client.getAuthenticatedUser(); @@ -369,7 +377,14 @@ export class Commands { // Sanity check; command should not be available if no url. throw new Error("You are not logged in"); } + await this.forceLogout(); + } + public async forceLogout(): Promise { + if (!this.contextManager.get("coder.authenticated")) { + return; + } + this.logger.info("Logging out"); // Clear from the REST client. An empty url will indicate to other parts of // the code that we are logged out. this.restClient.setHost(""); @@ -379,19 +394,16 @@ export class Commands { await this.mementoManager.setUrl(undefined); await this.secretsManager.setSessionToken(undefined); - await vscode.commands.executeCommand( - "setContext", - "coder.authenticated", - false, - ); + this.contextManager.set("coder.authenticated", false); vscode.window .showInformationMessage("You've been logged out of Coder!", "Login") .then((action) => { if (action === "Login") { - vscode.commands.executeCommand("coder.login"); + this.login(); } }); + await this.secretsManager.triggerLoginStateChange("logout"); // This will result in clearing the workspace list. vscode.commands.executeCommand("coder.refreshWorkspaces"); } diff --git a/src/core/container.ts b/src/core/container.ts index 72f28088..a8f938ea 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { type Logger } from "../logging/logger"; import { CliManager } from "./cliManager"; +import { ContextManager } from "./contextManager"; import { MementoManager } from "./mementoManager"; import { PathResolver } from "./pathResolver"; import { SecretsManager } from "./secretsManager"; @@ -17,6 +18,7 @@ export class ServiceContainer implements vscode.Disposable { private readonly mementoManager: MementoManager; private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; + private readonly contextManager: ContextManager; constructor( context: vscode.ExtensionContext, @@ -34,6 +36,7 @@ export class ServiceContainer implements vscode.Disposable { this.logger, this.pathResolver, ); + this.contextManager = new ContextManager(); } getVsCodeProposed(): typeof vscode { @@ -60,10 +63,15 @@ export class ServiceContainer implements vscode.Disposable { return this.cliManager; } + getContextManager(): ContextManager { + return this.contextManager; + } + /** * Dispose of all services and clean up resources. */ dispose(): void { + this.contextManager.dispose(); this.logger.dispose(); } } diff --git a/src/core/contextManager.ts b/src/core/contextManager.ts new file mode 100644 index 00000000..a5a18397 --- /dev/null +++ b/src/core/contextManager.ts @@ -0,0 +1,33 @@ +import * as vscode from "vscode"; + +const CONTEXT_DEFAULTS = { + "coder.authenticated": false, + "coder.isOwner": false, + "coder.loaded": false, + "coder.workspace.updatable": false, +} as const; + +type CoderContext = keyof typeof CONTEXT_DEFAULTS; + +export class ContextManager implements vscode.Disposable { + private readonly context = new Map(); + + public constructor() { + (Object.keys(CONTEXT_DEFAULTS) as CoderContext[]).forEach((key) => { + this.set(key, CONTEXT_DEFAULTS[key]); + }); + } + + public set(key: CoderContext, value: boolean): void { + this.context.set(key, value); + vscode.commands.executeCommand("setContext", key, value); + } + + public get(key: CoderContext): boolean { + return this.context.get(key) ?? CONTEXT_DEFAULTS[key]; + } + + public dispose() { + this.context.clear(); + } +} diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 6a6666da..94827b15 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -1,4 +1,14 @@ -import type { SecretStorage } from "vscode"; +import type { SecretStorage, Disposable } from "vscode"; + +const SESSION_TOKEN_KEY = "sessionToken"; + +const LOGIN_STATE_KEY = "loginState"; + +export enum AuthAction { + LOGIN, + LOGOUT, + INVALID, +} export class SecretsManager { constructor(private readonly secrets: SecretStorage) {} @@ -8,9 +18,9 @@ export class SecretsManager { */ public async setSessionToken(sessionToken?: string): Promise { if (!sessionToken) { - await this.secrets.delete("sessionToken"); + await this.secrets.delete(SESSION_TOKEN_KEY); } else { - await this.secrets.store("sessionToken", sessionToken); + await this.secrets.store(SESSION_TOKEN_KEY, sessionToken); } } @@ -19,11 +29,45 @@ export class SecretsManager { */ public async getSessionToken(): Promise { try { - return await this.secrets.get("sessionToken"); + return await this.secrets.get(SESSION_TOKEN_KEY); } catch { // The VS Code session store has become corrupt before, and // will fail to get the session token... return undefined; } } + + /** + * Triggers a login/logout event that propagates across all VS Code windows. + * Uses the secrets storage onDidChange event as a cross-window communication mechanism. + * Appends a timestamp to ensure the value always changes, guaranteeing the event fires. + */ + public async triggerLoginStateChange( + action: "login" | "logout", + ): Promise { + const date = new Date().toISOString(); + await this.secrets.store(LOGIN_STATE_KEY, `${action}-${date}`); + } + + /** + * Listens for login/logout events from any VS Code window. + * The secrets storage onDidChange event fires across all windows, enabling cross-window sync. + */ + public onDidChangeLoginState( + listener: (state: AuthAction) => Promise, + ): Disposable { + return this.secrets.onDidChange(async (e) => { + if (e.key === LOGIN_STATE_KEY) { + const state = await this.secrets.get(LOGIN_STATE_KEY); + if (state?.startsWith("login")) { + listener(AuthAction.LOGIN); + } else if (state?.startsWith("logout")) { + listener(AuthAction.LOGOUT); + } else { + // Secret was deleted or is invalid + listener(AuthAction.INVALID); + } + } + }); + } } diff --git a/src/error.ts b/src/error.ts index 7b93b458..70448d76 100644 --- a/src/error.ts +++ b/src/error.ts @@ -64,6 +64,8 @@ export class CertificateError extends Error { return new CertificateError(err.message, X509_ERR.UNTRUSTED_LEAF); case X509_ERR_CODE.SELF_SIGNED_CERT_IN_CHAIN: return new CertificateError(err.message, X509_ERR.UNTRUSTED_CHAIN); + case undefined: + break; } } return err; @@ -154,6 +156,7 @@ export class CertificateError extends Error { ); switch (val) { case CertificateError.ActionOK: + case undefined: return; case CertificateError.ActionAllowInsecure: await this.allowInsecure(); diff --git a/src/extension.ts b/src/extension.ts index e069c3a3..aba94cfe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,6 +10,7 @@ import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { Commands } from "./commands"; import { ServiceContainer } from "./core/container"; +import { AuthAction } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; import { Remote } from "./remote/remote"; import { toSafeHost } from "./util"; @@ -62,6 +63,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const output = serviceContainer.getLogger(); const mementoManager = serviceContainer.getMementoManager(); const secretsManager = serviceContainer.getSecretsManager(); + const contextManager = serviceContainer.getContextManager(); // Try to clear this flag ASAP const isFirstConnect = await mementoManager.getAndClearFirstConnect(); @@ -167,6 +169,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const token = needToken(vscode.workspace.getConfiguration()) ? params.get("token") : (params.get("token") ?? ""); + if (token) { client.setSessionToken(token); await secretsManager.setSessionToken(token); @@ -327,6 +330,29 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ), ); + const remote = new Remote(serviceContainer, commands, ctx.extensionMode); + + ctx.subscriptions.push( + secretsManager.onDidChangeLoginState(async (state) => { + switch (state) { + case AuthAction.LOGIN: { + const token = await secretsManager.getSessionToken(); + const url = mementoManager.getUrl(); + // Should login the user directly if the URL+Token are valid + await commands.login({ url, token }); + // Resolve any pending login detection promises + remote.resolveLoginDetected(); + break; + } + case AuthAction.LOGOUT: + await commands.forceLogout(); + break; + case AuthAction.INVALID: + break; + } + }), + ); + // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is // resolved by the remote SSH extension. @@ -337,7 +363,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // (this would require the user to uninstall the Coder extension and // reinstall after installing the remote SSH extension, which is annoying) if (remoteSSHExtension && vscodeProposed.env.remoteAuthority) { - const remote = new Remote(serviceContainer, commands, ctx.extensionMode); try { const details = await remote.setup( vscodeProposed.env.remoteAuthority, @@ -394,20 +419,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { output.info(`Logged in to ${baseUrl}; checking credentials`); client .getAuthenticatedUser() - .then(async (user) => { + .then((user) => { if (user && user.roles) { output.info("Credentials are valid"); - vscode.commands.executeCommand( - "setContext", - "coder.authenticated", - true, - ); + contextManager.set("coder.authenticated", true); if (user.roles.find((role) => role.name === "owner")) { - await vscode.commands.executeCommand( - "setContext", - "coder.isOwner", - true, - ); + contextManager.set("coder.isOwner", true); } // Fetch and monitor workspaces, now that we know the client is good. @@ -426,11 +443,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); }) .finally(() => { - vscode.commands.executeCommand("setContext", "coder.loaded", true); + contextManager.set("coder.loaded", true); }); } else { output.info("Not currently logged in"); - vscode.commands.executeCommand("setContext", "coder.loaded", true); + contextManager.set("coder.loaded", true); // Handle autologin, if not already logged in. const cfg = vscode.workspace.getConfiguration(); @@ -439,13 +456,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { cfg.get("coder.defaultUrl")?.trim() || process.env.CODER_URL?.trim(); if (defaultUrl) { - vscode.commands.executeCommand( - "coder.login", - defaultUrl, - undefined, - undefined, - "true", - ); + commands.login({ url: defaultUrl, autoLogin: true }); } } } diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 2a286ab4..832a8086 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -30,6 +30,7 @@ import { type Commands } from "../commands"; import { type CliManager } from "../core/cliManager"; import * as cliUtils from "../core/cliUtils"; import { type ServiceContainer } from "../core/container"; +import { type ContextManager } from "../core/contextManager"; import { type PathResolver } from "../core/pathResolver"; import { featureSetForVersion, type FeatureSet } from "../featureSet"; import { getGlobalFlags } from "../globalFlags"; @@ -58,6 +59,12 @@ export class Remote { private readonly logger: Logger; private readonly pathResolver: PathResolver; private readonly cliManager: CliManager; + private readonly contextManager: ContextManager; + + // Used to race between the login dialog and logging in from a different window + private loginDetectedResolver: (() => void) | undefined; + private loginDetectedRejector: ((reason?: Error) => void) | undefined; + private loginDetectedPromise: Promise = Promise.resolve(); public constructor( serviceContainer: ServiceContainer, @@ -68,6 +75,33 @@ export class Remote { this.logger = serviceContainer.getLogger(); this.pathResolver = serviceContainer.getPathResolver(); this.cliManager = serviceContainer.getCliManager(); + this.contextManager = serviceContainer.getContextManager(); + } + + /** + * Creates a new promise that will be resolved when login is detected in another window. + */ + private createLoginDetectionPromise(): void { + if (this.loginDetectedRejector) { + this.loginDetectedRejector( + new Error("Login detection cancelled - new login attempt started"), + ); + } + this.loginDetectedPromise = new Promise((resolve, reject) => { + this.loginDetectedResolver = resolve; + this.loginDetectedRejector = reject; + }); + } + + /** + * Resolves the current login detection promise if one exists. + */ + public resolveLoginDetected(): void { + if (this.loginDetectedResolver) { + this.loginDetectedResolver(); + this.loginDetectedResolver = undefined; + this.loginDetectedRejector = undefined; + } } private async confirmStart(workspaceName: string): Promise { @@ -238,34 +272,48 @@ export class Remote { parts.label, ); - // It could be that the cli config was deleted. If so, ask for the url. - if ( - !baseUrlRaw || - (!token && needToken(vscode.workspace.getConfiguration())) - ) { - const result = await this.vscodeProposed.window.showInformationMessage( - "You are not logged in...", + const showLoginDialog = async (message: string) => { + this.createLoginDetectionPromise(); + const dialogPromise = this.vscodeProposed.window.showInformationMessage( + message, { useCustom: true, modal: true, - detail: `You must log in to access ${workspaceName}.`, + detail: `You must log in to access ${workspaceName}. If you've already logged in, you may close this dialog.`, }, "Log In", ); - if (!result) { - // User declined to log in. - await this.closeRemote(); + + // Race between dialog and login detection + const result = await Promise.race([ + this.loginDetectedPromise.then(() => ({ type: "login" as const })), + dialogPromise.then((userChoice) => ({ + type: "dialog" as const, + userChoice, + })), + ]); + + if (result.type === "login") { + return this.setup(remoteAuthority, firstConnect); } else { - // Log in then try again. - await vscode.commands.executeCommand( - "coder.login", - baseUrlRaw, - undefined, - parts.label, - ); - await this.setup(remoteAuthority, firstConnect); + if (!result.userChoice) { + // User declined to log in. + await this.closeRemote(); + return; + } else { + // Log in then try again. + await this.commands.login({ url: baseUrlRaw, label: parts.label }); + return this.setup(remoteAuthority, firstConnect); + } } - return; + }; + + // It could be that the cli config was deleted. If so, ask for the url. + if ( + !baseUrlRaw || + (!token && needToken(vscode.workspace.getConfiguration())) + ) { + return showLoginDialog("You are not logged in..."); } this.logger.info("Using deployment URL", baseUrlRaw); @@ -364,28 +412,7 @@ export class Remote { return; } case 401: { - const result = - await this.vscodeProposed.window.showInformationMessage( - "Your session expired...", - { - useCustom: true, - modal: true, - detail: `You must log in to access ${workspaceName}.`, - }, - "Log In", - ); - if (!result) { - await this.closeRemote(); - } else { - await vscode.commands.executeCommand( - "coder.login", - baseUrlRaw, - undefined, - parts.label, - ); - await this.setup(remoteAuthority, firstConnect); - } - return; + return showLoginDialog("Your session expired..."); } default: throw error; @@ -521,6 +548,7 @@ export class Remote { workspaceClient, this.logger, this.vscodeProposed, + this.contextManager, ); disposables.push(monitor); disposables.push( diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 8ff99137..0b154f75 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -7,6 +7,7 @@ import * as vscode from "vscode"; import { createWorkspaceIdentifier, errToStr } from "../api/api-helper"; import { type CoderApi } from "../api/coderApi"; +import { type ContextManager } from "../core/contextManager"; import { type Logger } from "../logging/logger"; import { type OneWayWebSocket } from "../websocket/oneWayWebSocket"; @@ -41,6 +42,7 @@ export class WorkspaceMonitor implements vscode.Disposable { private readonly logger: Logger, // We use the proposed API to get access to useCustom in dialogs. private readonly vscodeProposed: typeof vscode, + private readonly contextManager: ContextManager, ) { this.name = createWorkspaceIdentifier(workspace); const socket = this.client.watchWorkspace(workspace); @@ -217,11 +219,7 @@ export class WorkspaceMonitor implements vscode.Disposable { } private updateContext(workspace: Workspace) { - vscode.commands.executeCommand( - "setContext", - "coder.workspace.updatable", - workspace.outdated, - ); + this.contextManager.set("coder.workspace.updatable", workspace.outdated); } private updateStatusBar(workspace: Workspace) { diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 14eca74b..5cfe44e5 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -234,10 +234,19 @@ export class InMemoryMemento implements vscode.Memento { export class InMemorySecretStorage implements vscode.SecretStorage { private secrets = new Map(); private isCorrupted = false; - - onDidChange: vscode.Event = () => ({ - dispose: () => {}, - }); + private listeners: Array<(e: vscode.SecretStorageChangeEvent) => void> = []; + + onDidChange: vscode.Event = (listener) => { + this.listeners.push(listener); + return { + dispose: () => { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + }, + }; + }; async get(key: string): Promise { if (this.isCorrupted) { @@ -250,17 +259,30 @@ export class InMemorySecretStorage implements vscode.SecretStorage { if (this.isCorrupted) { return Promise.reject(new Error("Storage corrupted")); } + const oldValue = this.secrets.get(key); this.secrets.set(key, value); + if (oldValue !== value) { + this.fireChangeEvent(key); + } } async delete(key: string): Promise { if (this.isCorrupted) { return Promise.reject(new Error("Storage corrupted")); } + const hadKey = this.secrets.has(key); this.secrets.delete(key); + if (hadKey) { + this.fireChangeEvent(key); + } } corruptStorage(): void { this.isCorrupted = true; } + + private fireChangeEvent(key: string): void { + const event: vscode.SecretStorageChangeEvent = { key }; + this.listeners.forEach((listener) => listener(event)); + } } diff --git a/test/unit/core/secretsManager.test.ts b/test/unit/core/secretsManager.test.ts index 7100a29b..bfe8c713 100644 --- a/test/unit/core/secretsManager.test.ts +++ b/test/unit/core/secretsManager.test.ts @@ -1,6 +1,6 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import { SecretsManager } from "@/core/secretsManager"; +import { AuthAction, SecretsManager } from "@/core/secretsManager"; import { InMemorySecretStorage } from "../../mocks/testHelpers"; @@ -13,7 +13,7 @@ describe("SecretsManager", () => { secretsManager = new SecretsManager(secretStorage); }); - describe("setSessionToken", () => { + describe("session token", () => { it("should store and retrieve tokens", async () => { await secretsManager.setSessionToken("test-token"); expect(await secretsManager.getSessionToken()).toBe("test-token"); @@ -31,9 +31,7 @@ describe("SecretsManager", () => { await secretsManager.setSessionToken(undefined); expect(await secretsManager.getSessionToken()).toBeUndefined(); }); - }); - describe("getSessionToken", () => { it("should return undefined for corrupted storage", async () => { await secretStorage.store("sessionToken", "valid-token"); secretStorage.corruptStorage(); @@ -41,4 +39,44 @@ describe("SecretsManager", () => { expect(await secretsManager.getSessionToken()).toBeUndefined(); }); }); + + describe("login state", () => { + it("should trigger login events", async () => { + const events: Array = []; + secretsManager.onDidChangeLoginState((state) => { + events.push(state); + return Promise.resolve(); + }); + + await secretsManager.triggerLoginStateChange("login"); + expect(events).toEqual([AuthAction.LOGIN]); + }); + + it("should trigger logout events", async () => { + const events: Array = []; + secretsManager.onDidChangeLoginState((state) => { + events.push(state); + return Promise.resolve(); + }); + + await secretsManager.triggerLoginStateChange("logout"); + expect(events).toEqual([AuthAction.LOGOUT]); + }); + + it("should fire same event twice in a row", async () => { + vi.useFakeTimers(); + const events: Array = []; + secretsManager.onDidChangeLoginState((state) => { + events.push(state); + return Promise.resolve(); + }); + + await secretsManager.triggerLoginStateChange("login"); + vi.advanceTimersByTime(5); + await secretsManager.triggerLoginStateChange("login"); + + expect(events).toEqual([AuthAction.LOGIN, AuthAction.LOGIN]); + vi.useRealTimers(); + }); + }); });