1import {
2 AuthRelayReadScope,
3 AuthRelayWriteScope,
4 EnvironmentId,
5 type EnvironmentCloudLinkStateResult,
6} from "@t3tools/contracts";
7import { ManagedRelay } from "@t3tools/client-runtime/relay";
8import {
9 RelayEnvironmentLinkLimitExceededError,
10 type RelayManagedEndpointProviderKind,
11} from "@t3tools/contracts/relay";
12import * as Cause from "effect/Cause";
13import { AsyncResult } from "effect/reactivity";
14import { useLayoutEffect } from "react";
15import { describe, expect, it, vi } from "vite-plus/test";
16
17import { act, render } from "../../test/render";
18
19const testState = vi.hoisted(() => ({
20 scopes: new Set<string>(),
21 snapshot: null as EnvironmentCloudLinkStateResult | null,
22 renderedSnapshot: null as EnvironmentCloudLinkStateResult | null,
23 relayProviderKind: null as RelayManagedEndpointProviderKind | null,
24 getToken: vi.fn<() => Promise<string | null>>(),
25 link: vi.fn(),
26 unlink: vi.fn(),
27 preferences: vi.fn(),
28 refreshDiscovery: vi.fn(),
29 refreshLink: vi.fn(),
30 toast: vi.fn(),
31}));
32
33vi.mock(import("@effect/atom-react"), async (original) => ({
34 ...(await original()),
35 useAtomValue: (() => ({
36 environments: new Map(
37 testState.relayProviderKind === null
38 ? []
39 : [
40 [
41 "service",
42 { environment: { endpoint: { providerKind: testState.relayProviderKind } } },
43 ],
44 ],
45 ),
46 })) as never,
47}));
48vi.mock(import("@clerk/react"), async (original) => ({
49 ...(await original()),
50 useAuth: (() => ({ getToken: testState.getToken, isSignedIn: true })) as never,
51}));
52vi.mock(import("../components/ui/toast"), async (original) => {
53 const actual = await original();
54 return { ...actual, toastManager: { ...actual.toastManager, add: testState.toast } as never };
55});
56vi.mock(import("../state/relay"), async (original) => {
57 const actual = await original();
58 return {
59 ...actual,
60 relayEnvironmentDiscovery: {
61 ...actual.relayEnvironmentDiscovery,
62 refresh: testState.refreshDiscovery,
63 } as never,
64 };
65});
66vi.mock(import("../state/session"), async (original) => ({
67 ...(await original()),
68 readEnvironmentScope: (_environmentId, scope) => testState.scopes.has(scope),
69}));
70vi.mock(import("../state/use-atom-command"), async (original) => ({
71 ...(await original()),
72 useAtomCommand: ((command: unknown) => command) as never,
73}));
74vi.mock(import("./linkEnvironmentAtoms"), async (original) => ({
75 ...(await original()),
76 linkEnvironment: testState.link as never,
77 unlinkEnvironment: testState.unlink as never,
78 updateEnvironmentPreferences: testState.preferences as never,
79}));
80vi.mock(import("./cloudLinkState"), async (original) => ({
81 ...(await original()),
82 readCachedCloudLinkState: () => testState.snapshot,
83 useCloudLinkState: ((environmentId: EnvironmentId | null) => ({
84 target: environmentId === null ? null : { environmentId },
85 data: testState.renderedSnapshot,
86 error: null,
87 isPending: false,
88 refresh: testState.refreshLink,
89 })) as never,
90}));
91vi.mock(import("./publicConfig"), async (original) => ({
92 ...(await original()),
93 resolveRelayClerkTokenOptions: () => ({}) as never,
94}));
95
96import { CloudEnvironmentLinkError } from "./linkEnvironment";
97import { useCloudLinkController, type CloudLinkDesiredState } from "./useCloudLinkController";
98
99const target = { environmentId: EnvironmentId.make("service") };
100const linkedState: EnvironmentCloudLinkStateResult = {
101 linked: true,
102 cloudUserId: "account-1",
103 relayUrl: "https://relay.example.com",
104 relayIssuer: "https://relay.example.com",
105 managedTunnelActive: true,
106 publishAgentActivity: false,
107};
108
109type Controller = ReturnType<typeof useCloudLinkController>;
110
111function ControllerProbe({ onRender }: { onRender: (controller: Controller) => void }) {
112 const value = useCloudLinkController(target.environmentId);
113 useLayoutEffect(() => {
114 onRender(value);
115 });
116 return null;
117}
118
119async function mountController() {
120 let controller: Controller | null = null;
121 const screen = await render(
122 <ControllerProbe
123 onRender={(value) => {
124 controller = value;
125 }}
126 />,
127 );
128 return Object.assign(screen, {
129 // Object.assign would copy a getter's value once, so read the latest render through a function.
130 controller: () => controller,
131 async reconcile(desired: CloudLinkDesiredState) {
132 let succeeded = false;
133 await act(async () => {
134 if (controller === null) throw new Error("Controller is not mounted.");
135 succeeded = await controller.reconcileCloudState(desired);
136 });
137 return succeeded;
138 },
139 });
140}
141
142function expectNoMutations() {
143 expect(testState.link).not.toHaveBeenCalled();
144 expect(testState.unlink).not.toHaveBeenCalled();
145 expect(testState.preferences).not.toHaveBeenCalled();
146}
147
148function setup() {
149 testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]);
150 testState.snapshot = linkedState;
151 testState.renderedSnapshot = linkedState;
152 testState.relayProviderKind = null;
153 testState.getToken.mockReset().mockResolvedValue("clerk-token");
154 for (const command of [
155 testState.link,
156 testState.unlink,
157 testState.preferences,
158 testState.refreshDiscovery,
159 ]) {
160 command.mockReset().mockResolvedValue(AsyncResult.success({}));
161 }
162 testState.refreshLink.mockReset();
163 testState.toast.mockReset();
164 return vi.spyOn(console, "error").mockImplementation(() => undefined);
165}
166
167describe("useCloudLinkController", () => {
168 it.each([AuthRelayReadScope, AuthRelayWriteScope])(
169 "does not mutate without %s, even with a cached link snapshot",
170 async (scope) => {
171 using _setup = setup();
172 await using screen = await mountController();
173 testState.scopes.delete(scope);
174
175 expect(await screen.reconcile({ managedTunnel: true, publish: true })).toBe(false);
176 expect(testState.getToken).not.toHaveBeenCalled();
177 expectNoMutations();
178 },
179 );
180
181 it("does not interpret unavailable link state as an unlinked environment", async () => {
182 using _setup = setup();
183 testState.snapshot = null;
184 await using screen = await mountController();
185
186 expect(await screen.reconcile({ managedTunnel: false, publish: true })).toBe(false);
187 expect(testState.getToken).not.toHaveBeenCalled();
188 expectNoMutations();
189 });
190
191 it.each([AuthRelayReadScope, AuthRelayWriteScope])(
192 "rechecks %s after requesting the cloud token",
193 async (scope) => {
194 using _setup = setup();
195 await using screen = await mountController();
196 testState.getToken.mockImplementationOnce(async () => {
197 testState.scopes.delete(scope);
198 return "clerk-token";
199 });
200
201 expect(await screen.reconcile({ managedTunnel: false, publish: false })).toBe(false);
202 expectNoMutations();
203 },
204 );
205
206 it("stops if the link-state read fails while requesting the cloud token", async () => {
207 using _setup = setup();
208 await using screen = await mountController();
209 testState.getToken.mockImplementationOnce(async () => {
210 testState.snapshot = null;
211 return "clerk-token";
212 });
213
214 expect(await screen.reconcile({ managedTunnel: true, publish: true })).toBe(false);
215 expectNoMutations();
216 });
217
218 it("still lets an authorized user unlink when the cloud token is unavailable", async () => {
219 using _setup = setup();
220 await using screen = await mountController();
221 testState.getToken.mockRejectedValueOnce(new Error("Cloud sign-in unavailable"));
222
223 expect(await screen.reconcile({ managedTunnel: false, publish: false })).toBe(true);
224 expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null });
225 expect(testState.link).not.toHaveBeenCalled();
226 expect(testState.preferences).not.toHaveBeenCalled();
227 });
228
229 it("reports a relay tunnel cap refusal as tunnelLimit instead of an error", async () => {
230 using _setup = setup();
231 testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false };
232 testState.link.mockResolvedValueOnce(
233 AsyncResult.failure(
234 Cause.fail(
235 new CloudEnvironmentLinkError({
236 message: "environment-links failed",
237 cause: new ManagedRelay.ManagedRelayRequestFailedError({
238 action: "link relay environment",
239 cause: null,
240 relayError: new RelayEnvironmentLinkLimitExceededError({
241 code: "environment_link_limit_exceeded",
242 maxTunnels: 3,
243 traceId: "trace-1",
244 }),
245 }),
246 }),
247 ),
248 ),
249 );
250 await using screen = await mountController();
251
252 expect(await screen.reconcile({ managedTunnel: true, publish: false })).toBe(false);
253 expect(screen.controller()?.tunnelLimit).toBe(3);
254 expect(screen.controller()?.operationError).toBeNull();
255 expect(testState.toast).not.toHaveBeenCalled();
256 });
257});