1// @effect-diagnostics preferSchemaOverJson:off - The fake HTTPS peer inspects and emits JSON wire payloads.
2import { describe, expect, it } from "@effect/vitest";
3import * as NodePath from "@effect/platform-node/NodePath";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";
10import * as DesktopHostedAuth from "./DesktopHostedAuth.ts";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12import * as ElectronApp from "../electron/ElectronApp.ts";
13import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
14import * as ElectronShell from "../electron/ElectronShell.ts";
15import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
16import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
17import type { DesktopBackendInstance } from "../backend/DesktopBackendPool.ts";
18import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts";
19
20const server = "https://knife.example.test";
21const encoder = new TextEncoder();
22const decoder = new TextDecoder();
23
24function harness() {
25 const files = new Map<string, Uint8Array>();
26 const requests: Array<{
27 path: string;
28 body: Record<string, unknown>;
29 authorization: string | null;
30 redirect: RequestRedirect | undefined;
31 }> = [];
32 const opened: string[] = [];
33 const control = {
34 available: true,
35 exchangeStatus: 200,
36 backend: false,
37 deleteStatus: 200,
38 redirectRefresh: false,
39 };
40 const grant = {
41 deviceId: "device",
42 accessToken: "access-in-memory",
43 refreshToken: "private-refresh-v1",
44 expiresAt: "2035-01-01T00:00:00.000Z",
45 };
46 const layer = Layer.mergeAll(
47 NodePath.layerPosix,
48 Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
49 stateDir: "/isolated",
50 } as DesktopEnvironment.DesktopEnvironment["Service"]),
51 Layer.mock(ElectronApp.ElectronApp)({}),
52 Layer.mock(HostProcess.Arguments)([]),
53 Layer.mock(DesktopBackendPool.DesktopBackendPool)({
54 list: Effect.sync(() =>
55 control.backend
56 ? [
57 {
58 id: PRIMARY_LOCAL_ENVIRONMENT_ID,
59 currentConfig: Effect.succeedSome({
60 httpBaseUrl: new URL("http://127.0.0.1:3773"),
61 }),
62 } as DesktopBackendInstance,
63 ]
64 : [],
65 ),
66 }),
67 Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth)({
68 getBearerToken: Effect.succeed("local-admin"),
69 }),
70 Layer.succeed(ElectronShell.ElectronShell, {
71 openExternal: (url) =>
72 Effect.sync(() => {
73 opened.push(String(url));
74 return true;
75 }),
76 copyText: () => Effect.void,
77 openSystemSettings: () => Effect.succeed(false),
78 }),
79 Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
80 isEncryptionAvailable: Effect.sync(() => control.available),
81 encryptString: (value) => Effect.succeed(encoder.encode(`encrypted:${value}`)),
82 decryptString: (value) => Effect.succeed(decoder.decode(value).slice("encrypted:".length)),
83 selectedStorageBackend: Effect.succeedNone,
84 }),
85 FileSystem.layerNoop({
86 exists: (path) => Effect.sync(() => files.has(path)),
87 readFile: (path) => Effect.sync(() => files.get(path)!),
88 writeFile: (path, data) =>
89 Effect.sync(() => {
90 files.set(path, data);
91 }),
92 rename: (from, to) =>
93 Effect.sync(() => {
94 files.set(to, files.get(from)!);
95 files.delete(from);
96 }),
97 remove: (path) =>
98 Effect.sync(() => {
99 files.delete(path);
100 }),
101 }),
102 layerRemoteHttpClient(async (input, init) => {
103 const request = new Request(input, init);
104 const path = new URL(request.url).pathname;
105 const body =
106 request.method === "DELETE" ? {} : ((await request.json()) as Record<string, unknown>);
107 requests.push({
108 path,
109 body,
110 authorization: request.headers.get("authorization"),
111 redirect: init?.redirect,
112 });
113 if (path.endsWith("/refresh") && control.redirectRefresh) {
114 if (init?.redirect === "error") throw new TypeError("Redirect blocked");
115 return Response.json({ ...grant, accessToken: "redirected-credential" });
116 }
117 if (path === "/api/auth/devices/token")
118 return Response.json({ accessToken: "source-narrow", expiresAt: "2035-01-01T00:00:00Z" });
119 if (path === "/api/self-hosted/connection")
120 return Response.json(
121 { connected: true },
122 { status: request.method === "DELETE" ? control.deleteStatus : 200 },
123 );
124 if (path.endsWith("/start"))
125 return Response.json({ authorizationUrl: `${server}/api/auth/oidc/start?request=one-use` });
126 if (path.endsWith("/exchange"))
127 return Response.json(grant, { status: control.exchangeStatus });
128 if (path.endsWith("/refresh"))
129 return Response.json({ ...grant, refreshToken: "private-refresh-v2" });
130 return new Response(null, { status: 404 });
131 }),
132 );
133 const callback = () => {
134 const request = requests.findLast((value) => value.path.endsWith("/start"))!;
135 return `t3code://app/self-hosted/callback?${new URLSearchParams({ server, state: String(request.body.state), code: "x".repeat(43) })}`;
136 };
137 return { files, requests, opened, control, layer, callback };
138}
139
140describe("desktop hosted account custody", () => {
141 it.effect("rejects refresh redirects without replacing saved credentials", () =>
142 Effect.gen(function* () {
143 const test = harness();
144 const first = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer));
145 yield* first.begin(server);
146 yield* first.receive(test.callback());
147 const before = test.files.get("/isolated/hosted-account.bin");
148 test.control.redirectRefresh = true;
149 const restarted = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer));
150 const result = yield* restarted.accessToken(server).pipe(Effect.result);
151 expect(result._tag).toBe("Failure");
152 expect(test.requests.findLast((request) => request.path.endsWith("/refresh"))?.redirect).toBe(
153 "error",
154 );
155 expect(test.files.get("/isolated/hosted-account.bin")).toEqual(before);
156 }),
157 );
158
159 it.effect("keeps credentials when the local source rejects disconnect", () =>
160 Effect.gen(function* () {
161 const test = harness();
162 test.control.backend = true;
163 const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer));
164 yield* auth.begin(server);
165 yield* auth.receive(test.callback());
166 test.control.deleteStatus = 503;
167 expect((yield* auth.disconnect.pipe(Effect.result))._tag).toBe("Failure");
168 expect(test.files.has("/isolated/hosted-account.bin")).toBe(true);
169 expect(test.requests.every((request) => request.redirect === "error")).toBe(true);
170 test.control.deleteStatus = 200;
171 yield* auth.disconnect;
172 expect(test.files.size).toBe(0);
173 }),
174 );
175
176 it.effect("gives the local source only a dedicated archive/connect grant", () =>
177 Effect.gen(function* () {
178 const test = harness();
179 test.control.backend = true;
180 const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer));
181 yield* auth.begin(server);
182 yield* auth.receive(test.callback());
183 const narrow = test.requests.find((request) => request.path === "/api/auth/devices/token");
184 expect(narrow?.authorization).toBe("Bearer access-in-memory");
185 expect(narrow?.body).toEqual({
186 scopes: ["archive:read", "archive:write", "environment:connect"],
187 });
188 const bridge = test.requests.find(
189 (request) => request.path === "/api/self-hosted/connection",
190 );
191 expect(bridge?.authorization).toBe("Bearer local-admin");
192 expect(bridge?.body).toEqual({
193 serverUrl: server,
194 authorization: "Bearer source-narrow",
195 expiresAt: "2035-01-01T00:00:00.000Z",
196 });
197 expect(JSON.stringify(bridge)).not.toContain("access-in-memory");
198 expect(JSON.stringify(bridge)).not.toContain("refresh");
199 }),
200 );
201
202 it.effect(
203 "keeps refresh credentials main-only, consumes handoff once, and renews after restart",
204 () =>
205 Effect.gen(function* () {
206 const test = harness();
207 const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer));
208 yield* auth.begin(server);
209 expect(test.opened).toEqual([`${server}/api/auth/oidc/start?request=one-use`]);
210 yield* auth.receive(test.callback());
211 yield* auth.receive(test.callback());
212 expect(test.requests.filter((value) => value.path.endsWith("/exchange"))).toHaveLength(1);
213 expect(yield* auth.accessToken(server)).toBe("access-in-memory");
214 expect(yield* auth.state).toEqual({ serverUrl: server, phase: "connected", error: null });
215 const saved = decoder.decode(test.files.get("/isolated/hosted-account.bin")!);
216 expect(saved).toContain("private-refresh-v1");
217 expect(saved).not.toContain("access-in-memory");
218 const restarted = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer));
219 expect(yield* restarted.accessToken(server)).toBe("access-in-memory");
220 expect(test.requests.findLast((value) => value.path.endsWith("/refresh"))!.body).toEqual({
221 refreshToken: "private-refresh-v1",
222 });
223 expect(decoder.decode(test.files.get("/isolated/hosted-account.bin")!)).toContain(
224 "private-refresh-v2",
225 );
226 }),
227 );
228
229 it.effect("preserves an existing credential when a new sign-in fails or is canceled", () =>
230 Effect.gen(function* () {
231 const test = harness();
232 const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer));
233 yield* auth.begin(server);
234 yield* auth.receive(test.callback());
235 const saved = test.files.get("/isolated/hosted-account.bin");
236 yield* auth.begin(server);
237 yield* auth.cancel;
238 yield* auth.receive(test.callback());
239 expect(test.files.get("/isolated/hosted-account.bin")).toEqual(saved);
240 test.control.exchangeStatus = 401;
241 yield* auth.begin(server);
242 yield* auth.receive(test.callback());
243 expect((yield* auth.state).phase).toBe("error");
244 expect(test.files.get("/isolated/hosted-account.bin")).toEqual(saved);
245 }),
246 );
247
248 it.effect("rejects callbacks for another state or server without exchanging proof", () =>
249 Effect.gen(function* () {
250 const test = harness();
251 const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer));
252 yield* auth.begin(server);
253 const wrong = new URL(test.callback());
254 wrong.searchParams.set("server", "https://other.example.test");
255 yield* auth.receive(wrong.href);
256 expect(test.requests.filter((value) => value.path.endsWith("/exchange"))).toEqual([]);
257 expect(test.files.size).toBe(0);
258 }),
259 );
260
261 it.effect("does not start sign-in when protected credential storage is unavailable", () =>
262 Effect.gen(function* () {
263 const test = harness();
264 test.control.available = false;
265 const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer));
266 expect(Option.isSome(yield* Effect.option(auth.begin(server)))).toBe(false);
267 expect(test.requests).toEqual([]);
268 expect(test.opened).toEqual([]);
269 }),
270 );
271});