Skip to content
Source
Sign inSign up

DesktopSshPasswordPrompts.test.ts

224 lines7.4 KB

1import { assert, describe, it } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as TestClock from "effect/testing/TestClock";
8import type * as Electron from "electron";
9​
10import * as ElectronWindow from "../electron/ElectronWindow.ts";
11import {
12 SSH_PASSWORD_PROMPT_CHANNEL,
13 SSH_PASSWORD_PROMPT_SETTLED_CHANNEL,
14} from "../ipc/channels.ts";
15import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
16​
17interface SentMessage {
18 readonly channel: string;
19 readonly args: readonly unknown[];
21​
22function makeTestWindow(
23 options: {
24 readonly isDestroyedError?: unknown;
25 readonly isMinimizedError?: unknown;
26 readonly sendError?: unknown;
27 } = {},
28) {
29 const listeners = new Map<string, Set<() => void>>();
30 const sentMessages: SentMessage[] = [];
31 let destroyed = false;
32 let minimized = true;
33 let restored = false;
34 let focused = false;
35​
36 const window = {
37 isDestroyed: () => {
38 if (options.isDestroyedError !== undefined) {
39 throw options.isDestroyedError;
40 }
41 return destroyed;
42 },
43 isMinimized: () => {
44 if (options.isMinimizedError !== undefined) {
45 throw options.isMinimizedError;
46 }
47 return minimized;
48 },
49 restore: () => {
50 restored = true;
51 minimized = false;
52 },
53 focus: () => {
54 focused = true;
55 },
56 once: (eventName: string, listener: () => void) => {
57 const eventListeners = listeners.get(eventName) ?? new Set<() => void>();
58 eventListeners.add(listener);
59 listeners.set(eventName, eventListeners);
60 },
61 removeListener: (eventName: string, listener: () => void) => {
62 listeners.get(eventName)?.delete(listener);
63 },
64 webContents: {
65 send: (channel: string, ...args: readonly unknown[]) => {
66 const message = { channel, args };
67 sentMessages.push(message);
68 if (options.sendError !== undefined) {
69 throw options.sendError;
70 }
71 },
72 },
73 };
74​
75 return {
76 window,
77 sentMessages,
78 isRestored: () => restored,
79 isFocused: () => focused,
80 closedListenerCount: () => listeners.get("closed")?.size ?? 0,
81 close: () => {
82 destroyed = true;
83 const closedListeners = [...(listeners.get("closed") ?? [])];
84 listeners.delete("closed");
85 for (const listener of closedListeners) {
86 listener();
87 }
88 },
89 };
91​
92function layerElectronWindow(window: ReturnType<typeof makeTestWindow>["window"]) {
93 return Layer.succeed(
94 ElectronWindow.ElectronWindow,
95 ElectronWindow.ElectronWindow.of({
96 create: () => Effect.die("unexpected BrowserWindow creation"),
97 main: Effect.succeedSome(window as Electron.BrowserWindow),
98 currentMainOrFirst: Effect.succeedSome(window as Electron.BrowserWindow),
99 focusedMainOrFirst: Effect.succeedSome(window as Electron.BrowserWindow),
100 setMain: () => Effect.void,
101 clearMain: () => Effect.void,
102 prepareReveal: () => Effect.succeed(false),
103 reveal: () => Effect.void,
104 sendAll: () => Effect.void,
105 destroyAll: Effect.void,
106 syncAllAppearance: () => Effect.void,
107 }),
108 );
110​
111function layer(window: ReturnType<typeof makeTestWindow>["window"]) {
112 return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe(
113 Layer.provide(layerElectronWindow(window)),
114 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
115 Layer.provideMerge(TestClock.layer()),
116 );
118​
119describe("DesktopSshPasswordPrompts", () => {
120 it.effect("sends renderer prompts and resolves them by request id", () => {
121 const testWindow = makeTestWindow();
122​
123 return Effect.gen(function* () {
124 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
125 const fiber = yield* prompts
126 .request({
127 destination: "devbox",
128 username: "julius",
129 prompt: "Enter the SSH password.",
130 attempt: 1,
131 })
132 .pipe(Effect.forkScoped);
133​
134 yield* Effect.yieldNow;
135 yield* Effect.yieldNow;
136 assert.equal(testWindow.sentMessages.length, 1);
137 const sent = testWindow.sentMessages[0];
138 assert.ok(sent);
139 assert.equal(sent.channel, SSH_PASSWORD_PROMPT_CHANNEL);
140 const request = sent.args[0] as { readonly requestId: string; readonly destination: string };
141 assert.equal(request.destination, "devbox");
142 assert.equal(testWindow.isRestored(), true);
143 assert.equal(testWindow.isFocused(), true);
144​
145 yield* prompts.resolve({ requestId: request.requestId, password: "secret" });
146 assert.equal(yield* Fiber.join(fiber), "secret");
147 }).pipe(Effect.provide(layer(testWindow.window)));
148 });
149​
150 it.effect.each(["answered", "cancelled", "timed out", "interrupted"] as const)(
151 "tells the window a prompt ended when it is %s",
152 (outcome) => {
153 const testWindow = makeTestWindow();
154​
155 return Effect.gen(function* () {
156 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
157 const fiber = yield* prompts
158 .request({
159 destination: "devbox",
160 username: "julius",
161 prompt: "Enter the SSH password.",
162 attempt: 1,
163 })
164 .pipe(Effect.exit, Effect.forkScoped);
165​
166 yield* Effect.yieldNow;
167 yield* Effect.yieldNow;
168 const request = testWindow.sentMessages[0]?.args[0] as { readonly requestId: string };
169 assert.ok(request);
170 switch (outcome) {
171 case "answered":
172 yield* prompts.resolve({ requestId: request.requestId, password: "secret" });
173 break;
174 case "cancelled":
175 yield* prompts.resolve({ requestId: request.requestId, password: null });
176 break;
177 case "timed out":
178 yield* TestClock.adjust("1 second");
179 break;
180 case "interrupted":
181 yield* Fiber.interrupt(fiber);
182 break;
183 }
184 yield* Fiber.await(fiber);
185​
186 assert.deepEqual(
187 testWindow.sentMessages
188 .filter((message) => message.channel === SSH_PASSWORD_PROMPT_SETTLED_CHANNEL)
189 .map((message) => message.args),
190 [[request.requestId]],
191 );
192 }).pipe(Effect.provide(layer(testWindow.window)));
193 },
194 );
195​
196 it.effect("keeps a submitted password when a later presentation step fails", () => {
197 const testWindow = makeTestWindow({
198 isMinimizedError: new Error("failed to read minimized state"),
199 });
200​
201 return Effect.gen(function* () {
202 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
203 const requestFiber = yield* prompts
204 .request({
205 destination: "devbox",
206 username: "julius",
207 prompt: "Enter the SSH password.",
208 attempt: 1,
209 })
210 .pipe(Effect.forkScoped);
211​
212 yield* Effect.yieldNow;
213 const sent = testWindow.sentMessages[0];
214 assert.ok(sent);
215 const request = sent.args[0] as { readonly requestId: string };
216 yield* prompts.resolve({ requestId: request.requestId, password: "secret" });
217 const password = yield* Fiber.join(requestFiber);
218​
219 assert.equal(password, "secret");
220 assert.equal(testWindow.isFocused(), false);
221 assert.equal(testWindow.closedListenerCount(), 0);
222 }).pipe(Effect.provide(layer(testWindow.window)));
223 });
224});