1import { beforeEach, vi } from "vite-plus/test";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { EnvironmentId } from "@t3tools/contracts";
6import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import { ManagedRelay } from "@t3tools/client-runtime/relay";
8import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";
9import { HttpClient } from "effect/http";
10
11import * as MobileStorage from "../../persistence/mobile-storage";
12
13import { linkEnvironmentToCloudWithPreference } from "./linkEnvironment";
14
15vi.mock("expo-constants", () => ({
16 default: {
17 expoConfig: {
18 extra: {
19 relay: {
20 url: "https://relay.example.test",
21 },
22 },
23 },
24 },
25}));
26
27vi.mock("expo-device", () => ({
28 deviceType: 1,
29 DeviceType: {
30 UNKNOWN: 0,
31 PHONE: 1,
32 TABLET: 2,
33 DESKTOP: 3,
34 TV: 4,
35 },
36 osVersion: "18.4.1",
37 modelName: "iPhone 15 Pro",
38}));
39
40vi.mock("react-native", () => ({
41 Platform: {
42 OS: "ios",
43 },
44}));
45
46vi.mock("expo-secure-store", () => ({
47 deleteItemAsync: vi.fn(),
48 getItemAsync: vi.fn(),
49 setItemAsync: vi.fn(),
50}));
51
52const savedConnection = {
53 environmentId: EnvironmentId.make("env-1"),
54 environmentLabel: "Desktop",
55 pairingUrl: "https://desktop.example.test/",
56 displayUrl: "https://desktop.example.test/",
57 httpBaseUrl: "https://desktop.example.test/",
58 wsBaseUrl: "wss://desktop.example.test/ws",
59 bearerToken: "local-bearer",
60};
61
62const createProofMock = vi.fn(
63 (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
64 Effect.succeed(`dpop:${input.method}:${input.url}`),
65);
66const layerTestDpopSigner = Layer.succeed(
67 ManagedRelay.ManagedRelayDpopSigner,
68 ManagedRelay.ManagedRelayDpopSigner.of({
69 thumbprint: Effect.succeed("client-proof-key-thumbprint"),
70 createProof: (input) => createProofMock(input),
71 }),
72);
73
74function layerCloudClient() {
75 const layerHttpClient = layerRemoteHttpClient((input, init) => globalThis.fetch(input, init));
76 return Layer.mergeAll(
77 layerHttpClient,
78 Layer.succeed(
79 MobileStorage.MobileStorage,
80 MobileStorage.MobileStorage.of({
81 loadSavedConnections: Effect.succeed([]),
82 saveConnection: () => Effect.void,
83 clearSavedConnection: () => Effect.void,
84 loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"),
85 loadAgentAwarenessDeviceId: Effect.succeed("device-1"),
86 loadAgentAwarenessRegistrationRecord: Effect.succeed(null),
87 saveAgentAwarenessRegistrationRecord: () => Effect.void,
88 clearAgentAwarenessRegistrationRecord: Effect.void,
89 loadRecentThreadShortcuts: Effect.succeed([]),
90 saveRecentThreadShortcuts: () => Effect.void,
91 }),
92 ),
93 ManagedRelay.layer({
94 relayUrl: "https://relay.example.test",
95 clientId: RelayMobileClientId,
96 }).pipe(Layer.provideMerge(layerTestDpopSigner), Layer.provide(layerHttpClient)),
97 );
98}
99
100const withCloudServices = <A, E>(
101 effect: Effect.Effect<
102 A,
103 E,
104 | HttpClient.HttpClient
105 | ManagedRelay.ManagedRelayClient
106 | ManagedRelay.ManagedRelayDpopSigner
107 | MobileStorage.MobileStorage
108 >,
109) => effect.pipe(Effect.provide(layerCloudClient()));
110
111function validLinkProof() {
112 return "signed-environment-link-jwt";
113}
114
115function validLinkResponse(environmentId = "env-1") {
116 return {
117 ok: true,
118 environmentId,
119 endpoint: {
120 httpBaseUrl: "https://managed.example.test/",
121 wsBaseUrl: "wss://managed.example.test/ws",
122 providerKind: "cloudflare_tunnel",
123 },
124 endpointRuntime: {
125 providerKind: "cloudflare_tunnel",
126 connectorToken: "connector-token",
127 },
128 relayIssuer: "https://relay.example.test",
129 cloudUserId: "user_123",
130 environmentCredential: "environment-credential",
131 cloudMintPublicKey: "cloud-mint-public-key",
132 };
133}
134
135function validLinkChallengeResponse() {
136 return {
137 challenge: "link-challenge",
138 expiresAt: "2026-05-25T00:05:00.000Z",
139 };
140}
141
142function requestBodyText(body: BodyInit | null | undefined): string {
143 return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
144}
145
146describe("mobile cloud link environment client", () => {
147 beforeEach(() => {
148 vi.restoreAllMocks();
149 createProofMock.mockClear();
150 });
151
152 it.effect(
153 "rejects relay link credentials for a different environment before persisting relay config",
154 () =>
155 Effect.gen(function* () {
156 const fetchMock = vi.fn((url: string | URL) => {
157 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
158 return Promise.resolve(Response.json(validLinkChallengeResponse()));
159 }
160 if (String(url).endsWith("/api/connect/link-proof")) {
161 return Promise.resolve(Response.json(validLinkProof()));
162 }
163 return Promise.resolve(Response.json(validLinkResponse("env-other")));
164 });
165 vi.stubGlobal("fetch", fetchMock);
166
167 const error = yield* withCloudServices(
168 linkEnvironmentToCloudWithPreference({
169 clerkToken: "clerk-token",
170 connection: savedConnection,
171 liveActivitiesEnabled: true,
172 }),
173 ).pipe(Effect.flip);
174 expect(error).toMatchObject({
175 _tag: "CloudEnvironmentLinkError",
176 message: "Relay returned credentials for a different environment.",
177 });
178 expect(fetchMock).toHaveBeenCalledTimes(3);
179 }),
180 );
181
182 it.effect("preserves typed local environment failures while obtaining a link proof", () =>
183 Effect.gen(function* () {
184 const fetchMock = vi.fn((url: string | URL) => {
185 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
186 return Promise.resolve(Response.json(validLinkChallengeResponse()));
187 }
188 return Promise.resolve(
189 Response.json(
190 {
191 _tag: "EnvironmentHttpUnauthorizedError",
192 message: "Invalid environment bearer session.",
193 },
194 { status: 401 },
195 ),
196 );
197 });
198 vi.stubGlobal("fetch", fetchMock);
199
200 const error = yield* withCloudServices(
201 linkEnvironmentToCloudWithPreference({
202 clerkToken: "clerk-token",
203 connection: savedConnection,
204 liveActivitiesEnabled: true,
205 }),
206 ).pipe(Effect.flip);
207 expect(error._tag).toBe("CloudEnvironmentLinkError");
208 expect(error.message).toBe(
209 "Could not obtain environment link proof: Invalid environment bearer session.",
210 );
211 expect(fetchMock).toHaveBeenCalledTimes(2);
212 }),
213 );
214
215 it.effect("preserves typed relay error bodies while linking environments", () =>
216 Effect.gen(function* () {
217 const fetchMock = vi.fn((url: string | URL) => {
218 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
219 return Promise.resolve(Response.json(validLinkChallengeResponse()));
220 }
221 if (String(url).endsWith("/api/connect/link-proof")) {
222 return Promise.resolve(Response.json(validLinkProof()));
223 }
224 return Promise.resolve(
225 Response.json(
226 {
227 _tag: "RelayEnvironmentLinkProofInvalidError",
228 code: "environment_link_proof_invalid",
229 reason: "origin_not_allowed",
230 traceId: "trace-test",
231 },
232 { status: 400 },
233 ),
234 );
235 });
236 vi.stubGlobal("fetch", fetchMock);
237
238 const error = yield* withCloudServices(
239 linkEnvironmentToCloudWithPreference({
240 clerkToken: "clerk-token",
241 connection: savedConnection,
242 liveActivitiesEnabled: true,
243 }),
244 ).pipe(Effect.flip);
245 expect(error).toMatchObject({
246 _tag: "CloudEnvironmentLinkError",
247 message:
248 "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).",
249 traceId: "trace-test",
250 });
251 expect(fetchMock).toHaveBeenCalledTimes(3);
252 }),
253 );
254
255 it.effect("rejects relay link credentials for a different managed endpoint provider", () =>
256 Effect.gen(function* () {
257 const fetchMock = vi.fn((url: string | URL) => {
258 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
259 return Promise.resolve(Response.json(validLinkChallengeResponse()));
260 }
261 if (String(url).endsWith("/api/connect/link-proof")) {
262 return Promise.resolve(Response.json(validLinkProof()));
263 }
264 return Promise.resolve(
265 Response.json({
266 ...validLinkResponse(),
267 endpoint: {
268 ...validLinkResponse().endpoint,
269 providerKind: "manual",
270 },
271 }),
272 );
273 });
274 vi.stubGlobal("fetch", fetchMock);
275
276 const error = yield* withCloudServices(
277 linkEnvironmentToCloudWithPreference({
278 clerkToken: "clerk-token",
279 connection: savedConnection,
280 liveActivitiesEnabled: true,
281 }),
282 ).pipe(Effect.flip);
283 expect(error).toMatchObject({
284 _tag: "CloudEnvironmentLinkError",
285 message: "Relay returned credentials for a different endpoint provider.",
286 });
287 expect(fetchMock).toHaveBeenCalledTimes(3);
288 }),
289 );
290
291 it.effect("preserves disabled Live Activity preferences when linking an environment", () =>
292 Effect.gen(function* () {
293 const bodies: Array<unknown> = [];
294 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
295 if (init?.body) {
296 bodies.push(JSON.parse(requestBodyText(init.body)));
297 }
298 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
299 return Promise.resolve(Response.json(validLinkChallengeResponse()));
300 }
301 if (String(url).endsWith("/api/connect/link-proof")) {
302 return Promise.resolve(Response.json(validLinkProof()));
303 }
304 if (String(url).endsWith("/v1/client/environment-links")) {
305 return Promise.resolve(Response.json(validLinkResponse()));
306 }
307 return Promise.resolve(
308 Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }),
309 );
310 });
311 vi.stubGlobal("fetch", fetchMock);
312
313 yield* withCloudServices(
314 linkEnvironmentToCloudWithPreference({
315 clerkToken: "clerk-token",
316 connection: savedConnection,
317 liveActivitiesEnabled: false,
318 }),
319 );
320
321 expect(bodies[1]).toMatchObject({
322 endpoint: {
323 httpBaseUrl: "https://desktop.example.test/",
324 wsBaseUrl: "wss://desktop.example.test/ws",
325 providerKind: "cloudflare_tunnel",
326 },
327 origin: {
328 localHttpHost: "127.0.0.1",
329 localHttpPort: 443,
330 },
331 });
332 expect(bodies[2]).toMatchObject({
333 deviceId: "device-1",
334 notificationsEnabled: true,
335 liveActivitiesEnabled: false,
336 managedTunnelsEnabled: true,
337 });
338 expect(bodies[3]).toMatchObject({
339 cloudUserId: "user_123",
340 environmentCredential: "environment-credential",
341 });
342 }),
343 );
344
345 it.effect("enables Live Activities for both the link challenge and registration", () =>
346 Effect.gen(function* () {
347 const bodies: Array<Record<string, unknown>> = [];
348 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
349 if (init?.body) {
350 bodies.push(JSON.parse(requestBodyText(init.body)) as Record<string, unknown>);
351 }
352 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
353 return Promise.resolve(Response.json(validLinkChallengeResponse()));
354 }
355 if (String(url).endsWith("/api/connect/link-proof")) {
356 return Promise.resolve(Response.json(validLinkProof()));
357 }
358 if (String(url).endsWith("/v1/client/environment-links")) {
359 return Promise.resolve(Response.json(validLinkResponse()));
360 }
361 return Promise.resolve(
362 Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }),
363 );
364 });
365 vi.stubGlobal("fetch", fetchMock);
366
367 yield* withCloudServices(
368 linkEnvironmentToCloudWithPreference({
369 clerkToken: "clerk-token",
370 connection: savedConnection,
371 liveActivitiesEnabled: true,
372 }),
373 );
374
375 expect(bodies.filter((body) => "liveActivitiesEnabled" in body)).toEqual([
376 expect.objectContaining({ liveActivitiesEnabled: true }),
377 expect.objectContaining({ liveActivitiesEnabled: true }),
378 ]);
379 }),
380 );
381});