Skip to content

Commit 12c7c98

Browse files
committed
Refactoring and added tests
1 parent fe96eb5 commit 12c7c98

File tree

6 files changed

+99
-24
lines changed

6 files changed

+99
-24
lines changed

src/commands.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,13 @@ export class Commands {
219219
this.restClient.setHost(url);
220220
this.restClient.setSessionToken(res.token);
221221

222-
// Store on disk to be used by the cli.
223-
await this.cliManager.configure(label, url, res.token);
224-
225222
// Store these to be used in later sessions.
226223
await this.mementoManager.setUrl(url);
227224
await this.secretsManager.setSessionToken(res.token);
228225

226+
// Store on disk to be used by the cli.
227+
await this.cliManager.configure(label, url, res.token);
228+
229229
// These contexts control various menu items and the sidebar.
230230
this.contextManager.set("coder.authenticated", true);
231231
if (res.user.roles.find((role) => role.name === "owner")) {
@@ -247,9 +247,9 @@ export class Commands {
247247
}
248248
});
249249

250+
await this.secretsManager.triggerLoginStateChange("login");
250251
// Fetch workspaces for the new deployment.
251252
vscode.commands.executeCommand("coder.refreshWorkspaces");
252-
this.secretsManager.triggerLoginStateChange("login");
253253
}
254254

255255
/**
@@ -403,9 +403,9 @@ export class Commands {
403403
}
404404
});
405405

406+
await this.secretsManager.triggerLoginStateChange("logout");
406407
// This will result in clearing the workspace list.
407408
vscode.commands.executeCommand("coder.refreshWorkspaces");
408-
this.secretsManager.triggerLoginStateChange("logout");
409409
}
410410

411411
/**

src/core/secretsManager.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ const SESSION_TOKEN_KEY = "sessionToken";
55
const LOGIN_STATE_KEY = "loginState";
66

77
type AuthAction = "login" | "logout";
8+
89
export class SecretsManager {
9-
constructor(private readonly secrets: SecretStorage) {
10-
void this.secrets.delete(LOGIN_STATE_KEY);
11-
}
10+
constructor(private readonly secrets: SecretStorage) {}
1211

1312
/**
1413
* Set or unset the last used token.
@@ -34,17 +33,34 @@ export class SecretsManager {
3433
}
3534
}
3635

37-
public triggerLoginStateChange(action: AuthAction): void {
38-
this.secrets.store(LOGIN_STATE_KEY, action);
36+
/**
37+
* Triggers a login/logout event that propagates across all VS Code windows.
38+
* Uses the secrets storage onDidChange event as a cross-window communication mechanism.
39+
* Appends a timestamp to ensure the value always changes, guaranteeing the event fires.
40+
*/
41+
public async triggerLoginStateChange(action: AuthAction): Promise<void> {
42+
const date = new Date().toISOString();
43+
await this.secrets.store(LOGIN_STATE_KEY, `${action}-${date}`);
3944
}
4045

46+
/**
47+
* Listens for login/logout events from any VS Code window.
48+
* The secrets storage onDidChange event fires across all windows, enabling cross-window sync.
49+
*/
4150
public onDidChangeLoginState(
4251
listener: (state?: AuthAction) => Promise<void>,
4352
): Disposable {
4453
return this.secrets.onDidChange(async (e) => {
4554
if (e.key === LOGIN_STATE_KEY) {
4655
const state = await this.secrets.get(LOGIN_STATE_KEY);
47-
listener(state as AuthAction | undefined);
56+
if (state?.startsWith("login")) {
57+
listener("login");
58+
} else if (state?.startsWith("logout")) {
59+
listener("logout");
60+
} else {
61+
// Secret was deleted or is invalid
62+
listener(undefined);
63+
}
4864
}
4965
});
5066
}

src/extension.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,14 +169,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
169169
? params.get("token")
170170
: (params.get("token") ?? "");
171171

172-
// Store on disk to be used by the cli.
173-
await cliManager.configure(toSafeHost(url), url, token);
174-
175172
if (token) {
176173
client.setSessionToken(token);
177174
await secretsManager.setSessionToken(token);
178175
}
179176

177+
// Store on disk to be used by the cli.
178+
await cliManager.configure(toSafeHost(url), url, token);
179+
180180
vscode.commands.executeCommand(
181181
"coder.open",
182182
owner,
@@ -334,7 +334,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
334334
ctx.subscriptions.push(
335335
secretsManager.onDidChangeLoginState(async (state) => {
336336
if (state === undefined) {
337-
// Initalization - Ignore those events
338337
return;
339338
}
340339

src/remote/remote.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export class Remote {
6161
private readonly cliManager: CliManager;
6262
private readonly contextManager: ContextManager;
6363

64-
// Used to race between the login dialog and the logging in from a different window
64+
// Used to race between the login dialog and logging in from a different window
6565
private loginDetectedResolver: (() => void) | undefined;
6666
private loginDetectedRejector: ((reason?: Error) => void) | undefined;
6767
private loginDetectedPromise: Promise<void> = Promise.resolve();

test/mocks/testHelpers.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,10 +234,19 @@ export class InMemoryMemento implements vscode.Memento {
234234
export class InMemorySecretStorage implements vscode.SecretStorage {
235235
private secrets = new Map<string, string>();
236236
private isCorrupted = false;
237-
238-
onDidChange: vscode.Event<vscode.SecretStorageChangeEvent> = () => ({
239-
dispose: () => {},
240-
});
237+
private listeners: Array<(e: vscode.SecretStorageChangeEvent) => void> = [];
238+
239+
onDidChange: vscode.Event<vscode.SecretStorageChangeEvent> = (listener) => {
240+
this.listeners.push(listener);
241+
return {
242+
dispose: () => {
243+
const index = this.listeners.indexOf(listener);
244+
if (index > -1) {
245+
this.listeners.splice(index, 1);
246+
}
247+
},
248+
};
249+
};
241250

242251
async get(key: string): Promise<string | undefined> {
243252
if (this.isCorrupted) {
@@ -250,17 +259,30 @@ export class InMemorySecretStorage implements vscode.SecretStorage {
250259
if (this.isCorrupted) {
251260
return Promise.reject(new Error("Storage corrupted"));
252261
}
262+
const oldValue = this.secrets.get(key);
253263
this.secrets.set(key, value);
264+
if (oldValue !== value) {
265+
this.fireChangeEvent(key);
266+
}
254267
}
255268

256269
async delete(key: string): Promise<void> {
257270
if (this.isCorrupted) {
258271
return Promise.reject(new Error("Storage corrupted"));
259272
}
273+
const hadKey = this.secrets.has(key);
260274
this.secrets.delete(key);
275+
if (hadKey) {
276+
this.fireChangeEvent(key);
277+
}
261278
}
262279

263280
corruptStorage(): void {
264281
this.isCorrupted = true;
265282
}
283+
284+
private fireChangeEvent(key: string): void {
285+
const event: vscode.SecretStorageChangeEvent = { key };
286+
this.listeners.forEach((listener) => listener(event));
287+
}
266288
}

test/unit/core/secretsManager.test.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, it } from "vitest";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
import { SecretsManager } from "@/core/secretsManager";
44

@@ -13,7 +13,7 @@ describe("SecretsManager", () => {
1313
secretsManager = new SecretsManager(secretStorage);
1414
});
1515

16-
describe("setSessionToken", () => {
16+
describe("session token", () => {
1717
it("should store and retrieve tokens", async () => {
1818
await secretsManager.setSessionToken("test-token");
1919
expect(await secretsManager.getSessionToken()).toBe("test-token");
@@ -31,14 +31,52 @@ describe("SecretsManager", () => {
3131
await secretsManager.setSessionToken(undefined);
3232
expect(await secretsManager.getSessionToken()).toBeUndefined();
3333
});
34-
});
3534

36-
describe("getSessionToken", () => {
3735
it("should return undefined for corrupted storage", async () => {
3836
await secretStorage.store("sessionToken", "valid-token");
3937
secretStorage.corruptStorage();
4038

4139
expect(await secretsManager.getSessionToken()).toBeUndefined();
4240
});
4341
});
42+
43+
describe("login state", () => {
44+
it("should trigger login events", async () => {
45+
const events: Array<string | undefined> = [];
46+
secretsManager.onDidChangeLoginState((state) => {
47+
events.push(state);
48+
return Promise.resolve();
49+
});
50+
51+
await secretsManager.triggerLoginStateChange("login");
52+
expect(events).toEqual(["login"]);
53+
});
54+
55+
it("should trigger logout events", async () => {
56+
const events: Array<string | undefined> = [];
57+
secretsManager.onDidChangeLoginState((state) => {
58+
events.push(state);
59+
return Promise.resolve();
60+
});
61+
62+
await secretsManager.triggerLoginStateChange("logout");
63+
expect(events).toEqual(["logout"]);
64+
});
65+
66+
it("should fire same event twice in a row", async () => {
67+
vi.useFakeTimers();
68+
const events: Array<string | undefined> = [];
69+
secretsManager.onDidChangeLoginState((state) => {
70+
events.push(state);
71+
return Promise.resolve();
72+
});
73+
74+
await secretsManager.triggerLoginStateChange("login");
75+
vi.advanceTimersByTime(5);
76+
await secretsManager.triggerLoginStateChange("login");
77+
78+
expect(events).toEqual(["login", "login"]);
79+
vi.useRealTimers();
80+
});
81+
});
4482
});

0 commit comments

Comments
 (0)