1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Option from "effect/Option";
4
5import {
6 BearerConnectionProfile,
7 type ConnectionCatalogEntry,
8 type ConnectionRoute,
9} from "./catalog.ts";
10import { gitHubRoutingConnectionKey } from "./githubRoutingPermissions.ts";
11import { BearerConnectionTarget, RelayConnectionTarget } from "./model.ts";
12import {
13 connectionRouteId,
14 connectionRouteKind,
15 connectionRouteLabel,
16 credentialConnectionId,
17 insertRoute,
18 entryWithRoutes,
19 mergeLearnedRoutes,
20 removedWithRelay,
21 routesAfterRemoving,
22 upsertRoute,
23} from "./routes.ts";
24
25const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
26
27function direct(id: string, httpBaseUrl: string): ConnectionRoute {
28 return {
29 target: new BearerConnectionTarget({
30 environmentId: ENVIRONMENT_ID,
31 label: "Desk",
32 connectionId: id,
33 }),
34 profile: Option.some(
35 new BearerConnectionProfile({
36 connectionId: id,
37 environmentId: ENVIRONMENT_ID,
38 label: "Desk",
39 httpBaseUrl,
40 wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"),
41 }),
42 ),
43 };
44}
45
46const RELAY: ConnectionRoute = {
47 target: new RelayConnectionTarget({ environmentId: ENVIRONMENT_ID, label: "Desk" }),
48 profile: Option.none(),
49};
50const LAN = direct("lan", "http://192.168.1.10:3773/");
51const TAILNET = direct("tailnet", "https://desk.tail1234.ts.net/");
52const PUBLIC = direct("public", "https://desk.example.com/");
53
54describe("connection routes", () => {
55 it("classifies direct routes by address", () => {
56 expect(connectionRouteKind(LAN)).toBe("lan");
57 // Tailscale, Cloudflare WARP and Mesh, and other VPNs share 100.64.0.0/10.
58 expect(connectionRouteKind(direct("ip", "http://100.101.102.103:3773/"))).toBe("vpn");
59 expect(connectionRouteLabel(direct("mesh", "http://100.96.0.1:3773/"))).toBe("VPN");
60 expect(connectionRouteKind(TAILNET)).toBe("tailnet");
61 expect(connectionRouteKind(PUBLIC)).toBe("public");
62 expect(connectionRouteKind(direct("lo", "http://127.0.0.1:3773/"))).toBe("loopback");
63 expect(connectionRouteKind(direct("ts6", "http://[fd7a:115c:a1e0::1]:3773/"))).toBe("tailnet");
64 expect(connectionRouteLabel(TAILNET)).toBe("Tailscale");
65 expect(connectionRouteLabel(RELAY)).toBe("T3 Connect");
66 });
67
68 it("places a new route after faster kinds and ahead of T3 Connect", () => {
69 expect(insertRoute([RELAY], LAN)).toEqual([LAN, RELAY]);
70 expect(insertRoute([LAN, RELAY], TAILNET)).toEqual([LAN, TAILNET, RELAY]);
71 expect(insertRoute([TAILNET], LAN)).toEqual([LAN, TAILNET]);
72 expect(insertRoute([LAN, TAILNET], RELAY)).toEqual([LAN, TAILNET, RELAY]);
73 });
74
75 it("keeps a user's order when a saved route is replaced", () => {
76 // The user preferred T3 Connect over the LAN; re-pairing the LAN keeps that.
77 const repaired = direct("lan", "http://192.168.1.11:3773/");
78 expect(upsertRoute([RELAY, LAN], repaired)).toEqual([RELAY, repaired]);
79 });
80});
81
82describe("learned routes", () => {
83 const relayOnly: ConnectionCatalogEntry = {
84 target: RELAY.target,
85 profile: RELAY.profile,
86 enabled: true,
87 };
88 const ids = (routes: ReadonlyArray<ConnectionRoute> | null) =>
89 routes?.map((route) => connectionRouteId(route.target)) ?? null;
90 const profileOf = (route: ConnectionRoute) => Option.getOrThrow(route.profile);
91
92 it("learns a LAN address over T3 Connect, ahead of it, using the T3 Connect credential", () => {
93 const routes = mergeLearnedRoutes({
94 entry: relayOnly,
95 activeRoute: RELAY,
96 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
97 allowInsecure: true,
98 });
99 expect(ids(routes)).toEqual([`learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`, "relay"]);
100 expect(profileOf(routes![0]!)).toMatchObject({
101 learned: true,
102 authorization: "t3-connect",
103 wsBaseUrl: "ws://192.168.1.10:3773/",
104 });
105 });
106
107 it("retains distinct learned environment bases on the same origin", () => {
108 const reported = [
109 { httpBaseUrl: "https://hub.example/prefix/environments/one/" },
110 { httpBaseUrl: "https://hub.example/prefix/environments/two/" },
111 ];
112 const routes = mergeLearnedRoutes({ entry: relayOnly, activeRoute: RELAY, reported, allowInsecure: false })!;
113 expect(routes.slice(0, 2).map((route) => profileOf(route))).toMatchObject([
114 { httpBaseUrl: reported[0]!.httpBaseUrl, wsBaseUrl: "wss://hub.example/prefix/environments/one/" },
115 { httpBaseUrl: reported[1]!.httpBaseUrl, wsBaseUrl: "wss://hub.example/prefix/environments/two/" },
116 ]);
117 expect(mergeLearnedRoutes({ entry: entryWithRoutes(relayOnly, routes), activeRoute: RELAY, reported, allowInsecure: false })).toBeNull();
118 });
119
120 it("replaces a learned LAN address when the server reports a new one", () => {
121 const first = mergeLearnedRoutes({
122 entry: relayOnly,
123 activeRoute: RELAY,
124 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
125 allowInsecure: true,
126 })!;
127 const entry: ConnectionCatalogEntry = {
128 ...relayOnly,
129 target: first[0]!.target,
130 profile: first[0]!.profile,
131 alternateRoutes: first.slice(1),
132 };
133 const moved = mergeLearnedRoutes({
134 entry,
135 activeRoute: RELAY,
136 reported: [{ httpBaseUrl: "http://10.0.0.42:3773/" }],
137 allowInsecure: true,
138 });
139 expect(ids(moved)).toEqual([`learned:${ENVIRONMENT_ID}:http://10.0.0.42:3773`, "relay"]);
140 });
141
142 it("keeps a learned route where the user moved it while the server reports it", () => {
143 const lan = { httpBaseUrl: "http://192.168.1.10:3773/" };
144 const first = mergeLearnedRoutes({
145 entry: relayOnly,
146 activeRoute: RELAY,
147 reported: [lan],
148 allowInsecure: true,
149 })!;
150 // The user prefers T3 Connect over the learned LAN address.
151 const reordered = entryWithRoutes(relayOnly, [first[1]!, first[0]!]);
152 expect(
153 mergeLearnedRoutes({
154 entry: reordered,
155 activeRoute: RELAY,
156 reported: [lan],
157 allowInsecure: true,
158 }),
159 ).toBeNull();
160 // A newly reported address is still placed by speed.
161 const next = mergeLearnedRoutes({
162 entry: reordered,
163 activeRoute: RELAY,
164 reported: [lan, { httpBaseUrl: "http://100.101.102.103:3773/" }],
165 allowInsecure: true,
166 });
167 expect(ids(next)).toEqual([
168 `learned:${ENVIRONMENT_ID}:http://100.101.102.103:3773`,
169 "relay",
170 `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`,
171 ]);
172 });
173
174 it("labels a learned address Tailscale only while the server reports it as tailnet", () => {
175 const tailscale = { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" };
176 const mesh = { kind: "lan", httpBaseUrl: "http://100.96.0.1:3773/" };
177 const first = mergeLearnedRoutes({
178 entry: relayOnly,
179 activeRoute: RELAY,
180 reported: [tailscale, mesh],
181 allowInsecure: true,
182 })!;
183 expect(first.map(connectionRouteLabel)).toEqual(["Tailscale", "VPN", "T3 Connect"]);
184
185 // The server later finds the address is not on its Tailscale interface.
186 const corrected = mergeLearnedRoutes({
187 entry: entryWithRoutes(relayOnly, first),
188 activeRoute: RELAY,
189 reported: [{ ...tailscale, kind: "lan" }, mesh],
190 allowInsecure: true,
191 })!;
192 expect(ids(corrected)).toEqual(ids(first));
193 expect(corrected.map(connectionRouteLabel)).toEqual(["VPN", "VPN", "T3 Connect"]);
194 expect(
195 mergeLearnedRoutes({
196 entry: entryWithRoutes(relayOnly, corrected),
197 activeRoute: RELAY,
198 reported: [{ ...tailscale, kind: "lan" }, mesh],
199 allowInsecure: true,
200 }),
201 ).toBeNull();
202 });
203
204 it("labels a paired numeric Tailscale address Tailscale once the server confirms it", () => {
205 const paired = direct("paired", "http://100.101.102.103:3773/");
206 const other = direct("other", "http://100.96.0.1:3773/");
207 const entry = entryWithRoutes(relayOnly, [paired, other, RELAY]);
208 expect(connectionRouteLabel(paired)).toBe("VPN");
209 const confirmed = mergeLearnedRoutes({
210 entry,
211 activeRoute: paired,
212 reported: [{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }],
213 allowInsecure: true,
214 })!;
215 // Same routes in the same order; only the label of the confirmed one changes.
216 expect(ids(confirmed)).toEqual(["paired", "other", "relay"]);
217 expect(confirmed.map(connectionRouteLabel)).toEqual(["Tailscale", "VPN", "T3 Connect"]);
218 });
219
220 it("leaves user routes alone and does not learn an address already saved", () => {
221 const entry: ConnectionCatalogEntry = {
222 target: LAN.target,
223 profile: LAN.profile,
224 alternateRoutes: [RELAY],
225 enabled: true,
226 };
227 expect(
228 mergeLearnedRoutes({
229 entry,
230 activeRoute: RELAY,
231 reported: [{ httpBaseUrl: "http://192.168.1.10:3773" }],
232 allowInsecure: true,
233 }),
234 ).toBeNull();
235 // The server stops reporting the LAN address; the paired route stays.
236 expect(
237 mergeLearnedRoutes({ entry, activeRoute: RELAY, reported: [], allowInsecure: true }),
238 ).toBeNull();
239 });
240
241 it("borrows the paired token when learned over a bearer route", () => {
242 const entry: ConnectionCatalogEntry = {
243 ...relayOnly,
244 target: LAN.target,
245 profile: LAN.profile,
246 };
247 const routes = mergeLearnedRoutes({
248 entry,
249 activeRoute: LAN,
250 reported: [{ httpBaseUrl: "https://desk.tail1234.ts.net/" }],
251 allowInsecure: true,
252 })!;
253 const learned = routes.find((route) => connectionRouteKind(route) === "tailnet")!;
254 expect(profileOf(learned)).not.toHaveProperty("authorization");
255 expect(credentialConnectionId(connectionRouteId(learned.target))).toBe("lan");
256 });
257
258 it("skips plain HTTP from an HTTPS page and never learns loopback", () => {
259 expect(
260 mergeLearnedRoutes({
261 entry: relayOnly,
262 activeRoute: RELAY,
263 reported: [
264 { httpBaseUrl: "http://192.168.1.10:3773/" },
265 { httpBaseUrl: "http://127.0.0.1:3773/" },
266 ],
267 allowInsecure: false,
268 }),
269 ).toBeNull();
270 });
271
272 it("removes learned routes along with the route whose credential they borrow", () => {
273 const overRelay = mergeLearnedRoutes({
274 entry: relayOnly,
275 activeRoute: RELAY,
276 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
277 allowInsecure: true,
278 })!;
279 expect(routesAfterRemoving(overRelay, "relay")).toEqual([]);
280
281 const paired: ConnectionCatalogEntry = {
282 ...relayOnly,
283 target: LAN.target,
284 profile: LAN.profile,
285 alternateRoutes: [RELAY],
286 };
287 const overLan = mergeLearnedRoutes({
288 entry: paired,
289 activeRoute: LAN,
290 reported: [{ httpBaseUrl: "https://desk.tail1234.ts.net/" }],
291 allowInsecure: true,
292 })!;
293 expect(ids(routesAfterRemoving(overLan, "lan"))).toEqual(["relay"]);
294 // Removing T3 Connect keeps the paired LAN and what it learned.
295 expect(ids(routesAfterRemoving(overLan, "relay"))).toHaveLength(2);
296 });
297
298 it("counts an environment reached only through T3 Connect as removed with it", () => {
299 const learned = mergeLearnedRoutes({
300 entry: relayOnly,
301 activeRoute: RELAY,
302 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
303 allowInsecure: true,
304 })!;
305 expect(removedWithRelay(relayOnly)).toBe(true);
306 expect(removedWithRelay(entryWithRoutes(relayOnly, learned))).toBe(true);
307 expect(removedWithRelay(entryWithRoutes(relayOnly, [LAN, RELAY]))).toBe(false);
308 });
309
310 it("keeps GitHub routing trust when a route is learned", () => {
311 const learned = mergeLearnedRoutes({
312 entry: relayOnly,
313 activeRoute: RELAY,
314 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
315 allowInsecure: true,
316 })!;
317 const withLearned: ConnectionCatalogEntry = {
318 ...relayOnly,
319 target: learned[0]!.target,
320 profile: learned[0]!.profile,
321 alternateRoutes: learned.slice(1),
322 };
323 expect(gitHubRoutingConnectionKey(withLearned)).toBe(gitHubRoutingConnectionKey(relayOnly));
324 });
325
326 it("keeps the T3 Connect credential when learning over a learned T3 Connect route", () => {
327 const first = mergeLearnedRoutes({
328 entry: relayOnly,
329 activeRoute: RELAY,
330 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
331 allowInsecure: true,
332 })!;
333 const learnedLan = first[0]!;
334 const entry: ConnectionCatalogEntry = {
335 ...relayOnly,
336 target: learnedLan.target,
337 profile: learnedLan.profile,
338 alternateRoutes: first.slice(1),
339 };
340 const next = mergeLearnedRoutes({
341 entry,
342 activeRoute: learnedLan,
343 reported: [
344 { httpBaseUrl: "http://192.168.1.10:3773/" },
345 { httpBaseUrl: "https://desk.tail1234.ts.net/" },
346 ],
347 allowInsecure: true,
348 })!;
349 for (const route of next.filter((candidate) => connectionRouteKind(candidate) !== "relay")) {
350 expect(profileOf(route)).toMatchObject({ authorization: "t3-connect" });
351 expect(connectionRouteId(route.target)).not.toContain("@");
352 }
353 });
354
355 it("saves a scheme change on the same host as a new address", () => {
356 const first = mergeLearnedRoutes({
357 entry: relayOnly,
358 activeRoute: RELAY,
359 reported: [{ httpBaseUrl: "http://desk.local:3773/" }],
360 allowInsecure: true,
361 })!;
362 const entry: ConnectionCatalogEntry = {
363 ...relayOnly,
364 target: first[0]!.target,
365 profile: first[0]!.profile,
366 alternateRoutes: first.slice(1),
367 };
368 const moved = mergeLearnedRoutes({
369 entry,
370 activeRoute: RELAY,
371 reported: [{ httpBaseUrl: "https://desk.local:3773/" }],
372 allowInsecure: true,
373 });
374 expect(moved).not.toBeNull();
375 expect(profileOf(moved![0]!)).toMatchObject({ httpBaseUrl: "https://desk.local:3773/" });
376 });
377});