1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Base64 from "effect/encoding/Base64";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as PlatformError from "effect/PlatformError";
11import * as Schema from "effect/Schema";
12
13import * as DesktopConfig from "../app/DesktopConfig.ts";
14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
15import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
16import * as DesktopSavedEnvironments from "./DesktopSavedEnvironments.ts";
17
18const textDecoder = new TextDecoder();
19const textEncoder = new TextEncoder();
20
21const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
22 environmentId: EnvironmentId.make("environment-1"),
23 label: "Remote environment",
24 httpBaseUrl: "https://remote.example.com/",
25 wsBaseUrl: "wss://remote.example.com/",
26 createdAt: "2026-04-09T00:00:00.000Z",
27 lastConnectedAt: "2026-04-09T01:00:00.000Z",
28 desktopSsh: {
29 alias: "devbox",
30 hostname: "devbox.example.com",
31 username: "julius",
32 port: 22,
33 },
34};
35
36const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({
37 version: Schema.Number,
38 records: Schema.Array(Schema.Unknown),
39});
40const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString(
41 SavedEnvironmentRegistryDocumentProbe,
42);
43const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect(
44 SavedEnvironmentRegistryDocumentProbeJson,
45);
46
47const seedSavedEnvironmentRegistry = Effect.fn(function* (encryptedBearerToken?: string) {
48 const environment = yield* DesktopEnvironment.DesktopEnvironment;
49 const fileSystem = yield* FileSystem.FileSystem;
50 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
51 const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({
52 version: 1,
53 records: [
54 {
55 ...savedRegistryRecord,
56 ...(encryptedBearerToken === undefined ? {} : { encryptedBearerToken }),
57 },
58 ],
59 });
60 yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`);
61});
62
63function layerSafeStorageFor(input: {
64 readonly available: boolean;
65 readonly availabilityError?: unknown;
66 readonly decryptError?: unknown;
67}) {
68 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
69 isEncryptionAvailable:
70 input.availabilityError === undefined
71 ? Effect.succeed(input.available)
72 : Effect.fail(
73 new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({
74 cause: input.availabilityError,
75 }),
76 ),
77 encryptString: (value) => Effect.succeed(textEncoder.encode(`enc:${value}`)),
78 decryptString: (value) => {
79 if (input.decryptError !== undefined) {
80 return Effect.fail(
81 new ElectronSafeStorage.ElectronSafeStorageDecryptError({
82 cause: input.decryptError,
83 }),
84 );
85 }
86
87 const decoded = textDecoder.decode(value);
88 if (!decoded.startsWith("enc:")) {
89 return Effect.fail(
90 new ElectronSafeStorage.ElectronSafeStorageDecryptError({
91 cause: new Error("invalid secret"),
92 }),
93 );
94 }
95 return Effect.succeed(decoded.slice("enc:".length));
96 },
97 selectedStorageBackend: Effect.succeedNone,
98 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);
99}
100
101function layer(
102 baseDir: string,
103 options?: {
104 readonly availableSecretStorage?: boolean;
105 readonly availabilityError?: unknown;
106 readonly decryptError?: unknown;
107 },
108 fileSystemLayer: Layer.Layer<FileSystem.FileSystem> = NodeServices.layer,
109) {
110 const layerEnvironment = DesktopEnvironment.layer({
111 dirname: "/repo/apps/desktop/src",
112 homeDirectory: baseDir,
113 platform: "darwin",
114 processArch: "x64",
115 appVersion: "1.2.3",
116 appPath: "/repo",
117 isPackaged: true,
118 resourcesPath: "/missing/resources",
119 runningUnderArm64Translation: false,
120 }).pipe(
121 Layer.provide(
122 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
123 ),
124 );
125
126 const layerSafeStorage = layerSafeStorageFor({
127 available: options?.availableSecretStorage ?? true,
128 availabilityError: options?.availabilityError,
129 decryptError: options?.decryptError,
130 });
131 const layerDependencies = Layer.mergeAll(
132 layerEnvironment,
133 layerSafeStorage,
134 NodeServices.layer,
135 fileSystemLayer,
136 );
137
138 return DesktopSavedEnvironments.layer.pipe(Layer.provideMerge(layerDependencies));
139}
140
141const withSavedEnvironments = <A, E, R>(
142 effect: Effect.Effect<A, E, R | DesktopSavedEnvironments.DesktopSavedEnvironments>,
143 options?: {
144 readonly availableSecretStorage?: boolean;
145 readonly availabilityError?: unknown;
146 readonly decryptError?: unknown;
147 },
148) =>
149 Effect.gen(function* () {
150 const fileSystem = yield* FileSystem.FileSystem;
151 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
152 prefix: "t3-desktop-saved-environments-test-",
153 });
154 return yield* effect.pipe(Effect.provide(layer(baseDir, options)));
155 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
156
157describe("DesktopSavedEnvironments", () => {
158 it.effect("reads saved environment metadata", () =>
159 withSavedEnvironments(
160 Effect.gen(function* () {
161 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
162 yield* seedSavedEnvironmentRegistry();
163
164 assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
165 }),
166 ),
167 );
168
169 it.effect("loads lenient saved environment registry documents", () =>
170 withSavedEnvironments(
171 Effect.gen(function* () {
172 const environment = yield* DesktopEnvironment.DesktopEnvironment;
173 const fileSystem = yield* FileSystem.FileSystem;
174 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
175 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
176 yield* fileSystem.writeFileString(
177 environment.savedEnvironmentRegistryPath,
178 `{
179 // Same optional envelope shape as browser saved environments.
180 "version": 1,
181 "records": [
182 {
183 "environmentId": "${savedRegistryRecord.environmentId}",
184 "label": "Remote environment",
185 "httpBaseUrl": "https://remote.example.com/",
186 "wsBaseUrl": "wss://remote.example.com/",
187 "createdAt": "2026-04-09T00:00:00.000Z",
188 "lastConnectedAt": "2026-04-09T01:00:00.000Z",
189 "desktopSsh": {
190 "alias": "devbox",
191 "hostname": "devbox.example.com",
192 "username": "julius",
193 "port": 22,
194 },
195 },
196 ],
197 }\n`,
198 );
199
200 assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
201 }),
202 ),
203 );
204
205 it.effect("reads encrypted saved environment secrets when encryption is available", () =>
206 withSavedEnvironments(
207 Effect.gen(function* () {
208 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
209 yield* seedSavedEnvironmentRegistry(Base64.encode(textEncoder.encode("enc:bearer-token")));
210
211 assert.deepEqual(
212 yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
213 Option.some("bearer-token"),
214 );
215 }),
216 ),
217 );
218
219 it.effect("reports invalid saved secret encoding without exposing the secret", () =>
220 withSavedEnvironments(
221 Effect.gen(function* () {
222 const environment = yield* DesktopEnvironment.DesktopEnvironment;
223 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
224 yield* seedSavedEnvironmentRegistry("%%%");
225
226 const error = yield* savedEnvironments
227 .getSecret(savedRegistryRecord.environmentId)
228 .pipe(Effect.flip);
229 assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentSecretDecodeError);
230 assert.equal(error.environmentId, savedRegistryRecord.environmentId);
231 assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath);
232 assert.equal(error.field, "encryptedBearerToken");
233 assert.exists(error.cause);
234 assert.equal(
235 error.message,
236 `Failed to decode encryptedBearerToken for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`,
237 );
238 assert.notInclude(error.message, "%%%");
239 }),
240 ),
241 );
242
243 it.effect("returns no secret while encryption is unavailable", () =>
244 withSavedEnvironments(
245 Effect.gen(function* () {
246 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
247 yield* seedSavedEnvironmentRegistry(Base64.encode(textEncoder.encode("enc:bearer-token")));
248
249 assert.deepEqual(
250 yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
251 Option.none(),
252 );
253 }),
254 { availableSecretStorage: false },
255 ),
256 );
257
258 it.effect("adds saved-environment context to safe storage availability failures", () => {
259 const cause = new Error("safe storage unavailable");
260 return withSavedEnvironments(
261 Effect.gen(function* () {
262 const environment = yield* DesktopEnvironment.DesktopEnvironment;
263 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
264 yield* seedSavedEnvironmentRegistry(Base64.encode(textEncoder.encode("enc:bearer-token")));
265
266 const error = yield* savedEnvironments
267 .getSecret(savedRegistryRecord.environmentId)
268 .pipe(Effect.flip);
269
270 assert.instanceOf(
271 error,
272 DesktopSavedEnvironments.DesktopSavedEnvironmentSecretProtectionError,
273 );
274 assert.equal(error.operation, "check-encryption-availability");
275 assert.equal(error.environmentId, savedRegistryRecord.environmentId);
276 assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath);
277 assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageAvailabilityError);
278 const availabilityError =
279 error.cause as ElectronSafeStorage.ElectronSafeStorageAvailabilityError;
280 assert.strictEqual(availabilityError.cause, cause);
281 assert.equal(
282 error.message,
283 `Desktop saved-environment secret protection failed during check-encryption-availability for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`,
284 );
285 assert.notEqual(error.message, availabilityError.message);
286 }),
287 { availabilityError: cause },
288 );
289 });
290
291 it.effect("treats empty saved environment documents as empty", () =>
292 withSavedEnvironments(
293 Effect.gen(function* () {
294 const environment = yield* DesktopEnvironment.DesktopEnvironment;
295 const fileSystem = yield* FileSystem.FileSystem;
296 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
297 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
298 yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{}\n");
299
300 assert.deepEqual(yield* savedEnvironments.getRegistry, []);
301 assert.isTrue(
302 Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
303 );
304 }),
305 ),
306 );
307
308 it.effect("surfaces malformed saved environment documents", () =>
309 withSavedEnvironments(
310 Effect.gen(function* () {
311 const environment = yield* DesktopEnvironment.DesktopEnvironment;
312 const fileSystem = yield* FileSystem.FileSystem;
313 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
314 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
315 yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json");
316
317 const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip);
318 assert.instanceOf(
319 registryError,
320 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
321 );
322 assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath);
323 assert.exists(registryError.cause);
324 const secretError = yield* savedEnvironments
325 .getSecret(savedRegistryRecord.environmentId)
326 .pipe(Effect.flip);
327 assert.instanceOf(
328 secretError,
329 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
330 );
331 }),
332 ),
333 );
334
335 it.effect("reports saved environment filesystem reads separately from document decoding", () =>
336 Effect.gen(function* () {
337 const path = yield* Path.Path;
338 const baseFileSystem = yield* FileSystem.FileSystem;
339 const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({
340 prefix: "t3-desktop-saved-environments-test-",
341 });
342 const registryPath = path.join(baseDir, "userdata", "saved-environments.json");
343 const permissionError = PlatformError.systemError({
344 _tag: "PermissionDenied",
345 module: "FileSystem",
346 method: "readFileString",
347 pathOrDescriptor: registryPath,
348 });
349 const layerFileSystem = Layer.succeed(
350 FileSystem.FileSystem,
351 FileSystem.makeNoop({
352 readFileString: () => Effect.fail(permissionError),
353 }),
354 );
355 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe(
356 Effect.provide(layer(baseDir, undefined, layerFileSystem)),
357 );
358
359 const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip);
360 assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError);
361 assert.equal(error.registryPath, registryPath);
362 assert.strictEqual(error.cause, permissionError);
363 assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`);
364 assert.notEqual(error.message, permissionError.message);
365 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
366 );
367});