Skip to content

Commit d386e7f

Browse files
committed
Add tests for deployment management
1 parent cb586a5 commit d386e7f

File tree

5 files changed

+939
-6
lines changed

5 files changed

+939
-6
lines changed

test/mocks/testHelpers.ts

Lines changed: 146 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { type IncomingMessage } from "node:http";
21
import { vi } from "vitest";
32
import * as vscode from "vscode";
43

5-
import { type Logger } from "@/logging/logger";
4+
import type { User } from "coder/site/src/api/typesGenerated";
5+
import type { IncomingMessage } from "node:http";
6+
7+
import type { CoderApi } from "@/api/coderApi";
8+
import type { Logger } from "@/logging/logger";
69

710
/**
811
* Mock configuration provider that integrates with the vscode workspace configuration mock.
@@ -137,24 +140,42 @@ export class MockProgressReporter {
137140
}
138141

139142
/**
140-
* Mock user interaction that integrates with vscode.window message dialogs.
143+
* Mock user interaction that integrates with vscode.window message dialogs and input boxes.
141144
* Use this to control user responses in tests.
142145
*/
143146
export class MockUserInteraction {
144147
private readonly responses = new Map<string, string | undefined>();
148+
private inputBoxValue: string | undefined;
149+
private inputBoxValidateInput: ((value: string) => Promise<void>) | undefined;
145150
private externalUrls: string[] = [];
146151

147152
constructor() {
148153
this.setupVSCodeMock();
149154
}
150155

151156
/**
152-
* Set a response for a specific message
157+
* Set a response for a specific message dialog
153158
*/
154159
setResponse(message: string, response: string | undefined): void {
155160
this.responses.set(message, response);
156161
}
157162

163+
/**
164+
* Set the value to return from showInputBox.
165+
* Pass undefined to simulate user cancelling.
166+
*/
167+
setInputBoxValue(value: string | undefined): void {
168+
this.inputBoxValue = value;
169+
}
170+
171+
/**
172+
* Set a custom validateInput handler for showInputBox.
173+
* This allows tests to simulate the validation callback behavior.
174+
*/
175+
setInputBoxValidateInput(fn: (value: string) => Promise<void>): void {
176+
this.inputBoxValidateInput = fn;
177+
}
178+
158179
/**
159180
* Get all URLs that were opened externally
160181
*/
@@ -170,10 +191,13 @@ export class MockUserInteraction {
170191
}
171192

172193
/**
173-
* Clear all responses
194+
* Clear all responses and input box values
174195
*/
175-
clearResponses(): void {
196+
clear(): void {
176197
this.responses.clear();
198+
this.inputBoxValue = undefined;
199+
this.inputBoxValidateInput = undefined;
200+
this.externalUrls = [];
177201
}
178202

179203
/**
@@ -206,6 +230,32 @@ export class MockUserInteraction {
206230
return Promise.resolve(true);
207231
},
208232
);
233+
234+
vi.mocked(vscode.window.showInputBox).mockImplementation(
235+
async (options?: vscode.InputBoxOptions) => {
236+
const value = this.inputBoxValue;
237+
if (value === undefined) {
238+
return undefined; // User cancelled
239+
}
240+
241+
if (options?.validateInput) {
242+
const validationResult = await options.validateInput(value);
243+
if (validationResult) {
244+
// Validation failed - in real VS Code this would show error
245+
// For tests, we can use the custom handler or return undefined
246+
if (this.inputBoxValidateInput) {
247+
await this.inputBoxValidateInput(value);
248+
}
249+
return undefined;
250+
}
251+
} else if (this.inputBoxValidateInput) {
252+
// Run custom validation handler even without options.validateInput
253+
await this.inputBoxValidateInput(value);
254+
}
255+
256+
return value;
257+
},
258+
);
209259
}
210260
}
211261

@@ -399,3 +449,93 @@ export class MockStatusBar {
399449
);
400450
}
401451
}
452+
453+
/**
454+
* Mock CoderApi for testing. Tracks method calls and allows controlling responses.
455+
*/
456+
export class MockCoderApi
457+
implements
458+
Pick<
459+
CoderApi,
460+
| "setHost"
461+
| "setSessionToken"
462+
| "setCredentials"
463+
| "getAuthenticatedUser"
464+
| "dispose"
465+
>
466+
{
467+
private _host: string | undefined;
468+
private _token: string | undefined;
469+
private authenticatedUser: User | Error | undefined;
470+
471+
readonly setHost = vi.fn((host: string | undefined) => {
472+
this._host = host;
473+
});
474+
475+
readonly setSessionToken = vi.fn((token: string) => {
476+
this._token = token;
477+
});
478+
479+
readonly setCredentials = vi.fn(
480+
(host: string | undefined, token: string | undefined) => {
481+
this._host = host;
482+
this._token = token;
483+
},
484+
);
485+
486+
readonly getAuthenticatedUser = vi.fn((): Promise<User> => {
487+
if (this.authenticatedUser instanceof Error) {
488+
return Promise.reject(this.authenticatedUser);
489+
}
490+
if (!this.authenticatedUser) {
491+
return Promise.reject(new Error("Not authenticated"));
492+
}
493+
return Promise.resolve(this.authenticatedUser);
494+
});
495+
496+
readonly dispose = vi.fn();
497+
498+
/**
499+
* Get current host (for assertions)
500+
*/
501+
get host(): string | undefined {
502+
return this._host;
503+
}
504+
505+
/**
506+
* Get current token (for assertions)
507+
*/
508+
get token(): string | undefined {
509+
return this._token;
510+
}
511+
512+
/**
513+
* Set the authenticated user that will be returned by getAuthenticatedUser.
514+
* Pass an Error to make getAuthenticatedUser reject.
515+
*/
516+
setAuthenticatedUserResponse(user: User | Error | undefined): void {
517+
this.authenticatedUser = user;
518+
}
519+
}
520+
521+
/**
522+
* Create a mock User for testing.
523+
*/
524+
export function createMockUser(overrides: Partial<User> = {}): User {
525+
return {
526+
id: "user-123",
527+
username: "testuser",
528+
529+
name: "Test User",
530+
created_at: new Date().toISOString(),
531+
updated_at: new Date().toISOString(),
532+
last_seen_at: new Date().toISOString(),
533+
status: "active",
534+
organization_ids: [],
535+
roles: [],
536+
avatar_url: "",
537+
login_type: "password",
538+
theme_preference: "",
539+
...overrides,
540+
};
541+
}

test/unit/api/coderApi.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,42 @@ describe("CoderApi", () => {
557557
);
558558
});
559559
});
560+
561+
describe("getHost/getSessionToken", () => {
562+
it("returns current host and token", () => {
563+
const api = createApi(CODER_URL, AXIOS_TOKEN);
564+
565+
expect(api.getHost()).toBe(CODER_URL);
566+
expect(api.getSessionToken()).toBe(AXIOS_TOKEN);
567+
});
568+
});
569+
570+
describe("dispose", () => {
571+
it("disposes all tracked reconnecting sockets", async () => {
572+
const sockets: Array<Partial<Ws>> = [];
573+
vi.mocked(Ws).mockImplementation((url: string | URL) => {
574+
const mockWs = createMockWebSocket(String(url), {
575+
on: vi.fn((event, handler) => {
576+
if (event === "open") {
577+
setImmediate(() => handler());
578+
}
579+
return mockWs as Ws;
580+
}),
581+
});
582+
sockets.push(mockWs);
583+
return mockWs as Ws;
584+
});
585+
586+
api = createApi(CODER_URL, AXIOS_TOKEN);
587+
await api.watchAgentMetadata(AGENT_ID);
588+
expect(sockets).toHaveLength(1);
589+
590+
api.dispose();
591+
592+
// Socket should be closed
593+
expect(sockets[0].close).toHaveBeenCalled();
594+
});
595+
});
560596
});
561597

562598
const mockAdapterImpl = vi.hoisted(() => (config: Record<string, unknown>) => {

test/unit/core/secretsManager.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,74 @@ describe("SecretsManager", () => {
200200
expect(result).toBeNull();
201201
});
202202
});
203+
204+
describe("migrateFromLegacyStorage", () => {
205+
it("migrates legacy url/token to new format and sets current deployment", async () => {
206+
// Set up legacy storage
207+
await memento.update("url", "https://legacy.coder.com");
208+
await secretStorage.store("sessionToken", "legacy-token");
209+
210+
const result = await secretsManager.migrateFromLegacyStorage();
211+
212+
// Should return the migrated hostname
213+
expect(result).toBe("legacy.coder.com");
214+
215+
// Should have migrated to new format
216+
const auth = await secretsManager.getSessionAuth("legacy.coder.com");
217+
expect(auth?.url).toBe("https://legacy.coder.com");
218+
expect(auth?.token).toBe("legacy-token");
219+
220+
// Should have set current deployment
221+
const deployment = await secretsManager.getCurrentDeployment();
222+
expect(deployment?.url).toBe("https://legacy.coder.com");
223+
expect(deployment?.safeHostname).toBe("legacy.coder.com");
224+
225+
// Legacy keys should be cleared
226+
expect(memento.get("url")).toBeUndefined();
227+
expect(await secretStorage.get("sessionToken")).toBeUndefined();
228+
});
229+
230+
it("does not overwrite existing session auth", async () => {
231+
// Set up existing auth
232+
await secretsManager.setSessionAuth("existing.coder.com", {
233+
url: "https://existing.coder.com",
234+
token: "existing-token",
235+
});
236+
237+
// Set up legacy storage with same hostname
238+
await memento.update("url", "https://existing.coder.com");
239+
await secretStorage.store("sessionToken", "legacy-token");
240+
241+
await secretsManager.migrateFromLegacyStorage();
242+
243+
// Existing auth should not be overwritten
244+
const auth = await secretsManager.getSessionAuth("existing.coder.com");
245+
expect(auth?.token).toBe("existing-token");
246+
});
247+
248+
it("returns undefined when no legacy data exists", async () => {
249+
const result = await secretsManager.migrateFromLegacyStorage();
250+
expect(result).toBeUndefined();
251+
});
252+
253+
it("returns undefined when only URL exists (no token)", async () => {
254+
await memento.update("url", "https://legacy.coder.com");
255+
256+
const result = await secretsManager.migrateFromLegacyStorage();
257+
expect(result).toBeUndefined();
258+
});
259+
});
260+
261+
describe("session auth - empty token handling (mTLS)", () => {
262+
it("stores and retrieves empty string token", async () => {
263+
await secretsManager.setSessionAuth("mtls.coder.com", {
264+
url: "https://mtls.coder.com",
265+
token: "",
266+
});
267+
268+
const auth = await secretsManager.getSessionAuth("mtls.coder.com");
269+
expect(auth?.token).toBe("");
270+
expect(auth?.url).toBe("https://mtls.coder.com");
271+
});
272+
});
203273
});

0 commit comments

Comments
 (0)