|
| 1 | +import { assert, describe, it } from "@effect/vitest"; |
| 2 | +import { PreviewPortUnreachableError } from "@t3tools/contracts"; |
| 3 | +import * as Net from "@t3tools/shared/Net"; |
| 4 | +import * as Effect from "effect/Effect"; |
| 5 | +import * as Fiber from "effect/Fiber"; |
| 6 | +import * as Layer from "effect/Layer"; |
| 7 | +import * as Ref from "effect/Ref"; |
| 8 | +import * as Sink from "effect/Sink"; |
| 9 | +import * as Stream from "effect/Stream"; |
| 10 | +import * as TestClock from "effect/testing/TestClock"; |
| 11 | +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"; |
| 12 | +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 13 | + |
| 14 | +import { PreviewPortExposure, layer as portExposureLayer } from "./PortExposure.ts"; |
| 15 | + |
| 16 | +const encoder = new TextEncoder(); |
| 17 | + |
| 18 | +const CLIENT_ON_TAILNET = "https://smart.tail.ts.net/"; |
| 19 | +const CLIENT_ON_LOOPBACK = "http://localhost:5732/"; |
| 20 | + |
| 21 | +interface SpawnCall { |
| 22 | + readonly args: ReadonlyArray<string>; |
| 23 | +} |
| 24 | + |
| 25 | +const serveStatusWith = (mappings: ReadonlyArray<{ servePort: number; localPort: number }>) => |
| 26 | + JSON.stringify({ |
| 27 | + Web: Object.fromEntries( |
| 28 | + mappings.map(({ servePort, localPort }) => [ |
| 29 | + `smart.tail.ts.net:${servePort}`, |
| 30 | + { Handlers: { "/": { Proxy: `http://127.0.0.1:${localPort}` } } }, |
| 31 | + ]), |
| 32 | + ), |
| 33 | + }); |
| 34 | + |
| 35 | +/** |
| 36 | + * Records every tailscale invocation and answers `serve status` from a mutable |
| 37 | + * script, so a test can assert that publishing a port is what makes it appear. |
| 38 | + */ |
| 39 | +const spawnerHarness = (input: { |
| 40 | + readonly serveStatus: () => string; |
| 41 | + readonly onServe?: (args: ReadonlyArray<string>) => { stderr?: string; code?: number }; |
| 42 | +}) => |
| 43 | + Effect.gen(function* () { |
| 44 | + const calls = yield* Ref.make<ReadonlyArray<SpawnCall>>([]); |
| 45 | + const layer = Layer.succeed( |
| 46 | + ChildProcessSpawner.ChildProcessSpawner, |
| 47 | + ChildProcessSpawner.make((command) => { |
| 48 | + const spawned = command as unknown as { readonly args: ReadonlyArray<string> }; |
| 49 | + const args = spawned.args; |
| 50 | + const isStatusRead = args[0] === "serve" && args[1] === "status"; |
| 51 | + const result = isStatusRead |
| 52 | + ? { stdout: input.serveStatus(), code: 0 } |
| 53 | + : { stdout: "", ...(input.onServe?.(args) ?? { code: 0 }) }; |
| 54 | + return Ref.update(calls, (previous) => [...previous, { args }]).pipe( |
| 55 | + Effect.as( |
| 56 | + ChildProcessSpawner.makeHandle({ |
| 57 | + pid: ChildProcessSpawner.ProcessId(1), |
| 58 | + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), |
| 59 | + isRunning: Effect.succeed(false), |
| 60 | + kill: () => Effect.void, |
| 61 | + unref: Effect.succeed(Effect.void), |
| 62 | + stdin: Sink.drain, |
| 63 | + stdout: Stream.make(encoder.encode(result.stdout ?? "")), |
| 64 | + stderr: Stream.make(encoder.encode(result.stderr ?? "")), |
| 65 | + all: Stream.empty, |
| 66 | + getInputFd: () => Sink.drain, |
| 67 | + getOutputFd: () => Stream.empty, |
| 68 | + }), |
| 69 | + ), |
| 70 | + ); |
| 71 | + }), |
| 72 | + ); |
| 73 | + return { calls, layer }; |
| 74 | + }); |
| 75 | + |
| 76 | +const netLayer = (listeningPorts: ReadonlyArray<number>) => |
| 77 | + Layer.succeed(Net.NetService, { |
| 78 | + canListenOnHost: () => Effect.succeed(true), |
| 79 | + isPortAvailableOnLoopback: (port: number) => Effect.succeed(!listeningPorts.includes(port)), |
| 80 | + reserveLoopbackPort: () => Effect.succeed(0), |
| 81 | + findAvailablePort: (preferred: number) => Effect.succeed(preferred), |
| 82 | + } as Net.NetServiceShape); |
| 83 | + |
| 84 | +/** |
| 85 | + * Answers a probe only for origins the test says are actually serving. Modelled |
| 86 | + * on reachability rather than a fixed status code, because the resolver's whole |
| 87 | + * job is to tell a route that answers from one that does not. |
| 88 | + */ |
| 89 | +const httpLayer = (isReachable: (url: string) => boolean) => |
| 90 | + Layer.succeed( |
| 91 | + HttpClient.HttpClient, |
| 92 | + HttpClient.make( |
| 93 | + ( |
| 94 | + request, |
| 95 | + ): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> => |
| 96 | + isReachable(request.url) |
| 97 | + ? Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))) |
| 98 | + : Effect.fail( |
| 99 | + new HttpClientError.HttpClientError({ |
| 100 | + reason: new HttpClientError.TransportError({ |
| 101 | + request, |
| 102 | + description: "connection refused", |
| 103 | + }), |
| 104 | + }), |
| 105 | + ), |
| 106 | + ), |
| 107 | + ); |
| 108 | + |
| 109 | +const runResolve = (input: { |
| 110 | + readonly port: number; |
| 111 | + readonly clientBaseUrl: string; |
| 112 | + readonly serveStatus: () => string; |
| 113 | + readonly listeningPorts: ReadonlyArray<number>; |
| 114 | + readonly onServe?: (args: ReadonlyArray<string>) => { stderr?: string; code?: number }; |
| 115 | + /** Origins that answer beyond whatever the current serve status publishes. */ |
| 116 | + readonly alsoReachable?: ReadonlyArray<string>; |
| 117 | + /** Set when a published mapping still must not answer. */ |
| 118 | + readonly neverReachable?: boolean; |
| 119 | +}) => |
| 120 | + Effect.gen(function* () { |
| 121 | + const harness = yield* spawnerHarness({ |
| 122 | + serveStatus: input.serveStatus, |
| 123 | + ...(input.onServe ? { onServe: input.onServe } : {}), |
| 124 | + }); |
| 125 | + const reachable = (url: string) => { |
| 126 | + if (input.neverReachable) return false; |
| 127 | + if (input.alsoReachable?.some((origin) => url.startsWith(origin))) return true; |
| 128 | + const published = JSON.parse(input.serveStatus()) as { |
| 129 | + Web?: Record<string, unknown>; |
| 130 | + }; |
| 131 | + return Object.keys(published.Web ?? {}).some((hostKey) => |
| 132 | + url.startsWith(`https://${hostKey}`), |
| 133 | + ); |
| 134 | + }; |
| 135 | + const resolving = yield* Effect.forkChild( |
| 136 | + Effect.gen(function* () { |
| 137 | + const exposure = yield* PreviewPortExposure; |
| 138 | + return yield* exposure |
| 139 | + .resolve({ port: input.port, clientBaseUrl: input.clientBaseUrl }) |
| 140 | + .pipe(Effect.result); |
| 141 | + }).pipe( |
| 142 | + Effect.provide( |
| 143 | + portExposureLayer.pipe( |
| 144 | + Layer.provide(harness.layer), |
| 145 | + Layer.provide(netLayer(input.listeningPorts)), |
| 146 | + Layer.provide(httpLayer(reachable)), |
| 147 | + ), |
| 148 | + ), |
| 149 | + ), |
| 150 | + ); |
| 151 | + // The reachability probe retries on a schedule until a deadline, so an |
| 152 | + // unreachable port only resolves once virtual time passes that deadline. |
| 153 | + yield* TestClock.adjust("10 seconds"); |
| 154 | + const result = yield* Fiber.join(resolving); |
| 155 | + return { result, calls: yield* Ref.get(harness.calls) }; |
| 156 | + }); |
| 157 | + |
| 158 | +describe("PreviewPortExposure", () => { |
| 159 | + it.effect("keeps a same-machine client on loopback and publishes nothing", () => |
| 160 | + Effect.gen(function* () { |
| 161 | + const { result, calls } = yield* runResolve({ |
| 162 | + port: 5733, |
| 163 | + clientBaseUrl: CLIENT_ON_LOOPBACK, |
| 164 | + serveStatus: () => "{}", |
| 165 | + listeningPorts: [5733], |
| 166 | + }); |
| 167 | + |
| 168 | + assert.deepEqual(result._tag === "Success" ? result.success : null, { |
| 169 | + origin: "http://localhost:5733", |
| 170 | + strategy: "loopback", |
| 171 | + createdExposure: false, |
| 172 | + }); |
| 173 | + // Nothing was asked of tailscale: a local client never needs the tailnet, |
| 174 | + // and publishing here would share a dev server nobody asked to share. |
| 175 | + assert.deepEqual(calls, []); |
| 176 | + }), |
| 177 | + ); |
| 178 | + |
| 179 | + it.effect("reuses an existing mapping instead of assuming port parity", () => |
| 180 | + Effect.gen(function* () { |
| 181 | + const { result, calls } = yield* runResolve({ |
| 182 | + port: 5733, |
| 183 | + clientBaseUrl: CLIENT_ON_TAILNET, |
| 184 | + // Published on a different tailnet port, over https — exactly the shape |
| 185 | + // the old client-side guess (same port, same scheme) got wrong. |
| 186 | + serveStatus: () => serveStatusWith([{ servePort: 45733, localPort: 5733 }]), |
| 187 | + listeningPorts: [5733], |
| 188 | + }); |
| 189 | + |
| 190 | + assert.deepEqual(result._tag === "Success" ? result.success : null, { |
| 191 | + origin: "https://smart.tail.ts.net:45733", |
| 192 | + strategy: "tailnet-serve", |
| 193 | + createdExposure: false, |
| 194 | + }); |
| 195 | + assert.isUndefined(calls.find((call) => call.args.includes("--bg"))); |
| 196 | + }), |
| 197 | + ); |
| 198 | + |
| 199 | + it.effect("uses the environment's own address when the port already answers there", () => |
| 200 | + Effect.gen(function* () { |
| 201 | + const { result, calls } = yield* runResolve({ |
| 202 | + port: 5173, |
| 203 | + // A WSL / LAN environment, where a dev server bound to a wildcard |
| 204 | + // address is genuinely reachable at the host the client already uses. |
| 205 | + clientBaseUrl: "http://172.25.85.75:3773/", |
| 206 | + serveStatus: () => "{}", |
| 207 | + listeningPorts: [5173], |
| 208 | + alsoReachable: ["http://172.25.85.75:5173"], |
| 209 | + }); |
| 210 | + |
| 211 | + assert.deepEqual(result._tag === "Success" ? result.success : null, { |
| 212 | + origin: "http://172.25.85.75:5173", |
| 213 | + strategy: "direct-private-network", |
| 214 | + createdExposure: false, |
| 215 | + }); |
| 216 | + // Nothing published: a route that already works needs no second one. |
| 217 | + assert.isUndefined(calls.find((call) => call.args.includes("--bg"))); |
| 218 | + }), |
| 219 | + ); |
| 220 | + |
| 221 | + it.effect("publishes a loopback-only port on demand", () => |
| 222 | + Effect.gen(function* () { |
| 223 | + let published = false; |
| 224 | + const { result, calls } = yield* runResolve({ |
| 225 | + port: 6545, |
| 226 | + clientBaseUrl: CLIENT_ON_TAILNET, |
| 227 | + serveStatus: () => |
| 228 | + published ? serveStatusWith([{ servePort: 6545, localPort: 6545 }]) : "{}", |
| 229 | + listeningPorts: [6545], |
| 230 | + onServe: () => { |
| 231 | + published = true; |
| 232 | + return { code: 0 }; |
| 233 | + }, |
| 234 | + }); |
| 235 | + |
| 236 | + assert.deepEqual(result._tag === "Success" ? result.success : null, { |
| 237 | + origin: "https://smart.tail.ts.net:6545", |
| 238 | + strategy: "tailnet-serve", |
| 239 | + createdExposure: true, |
| 240 | + }); |
| 241 | + // Targets `localhost`, not `127.0.0.1`: Vite's default bind is `::1` only, |
| 242 | + // and an IPv4-pinned mapping proxies to nothing and answers 502. |
| 243 | + assert.deepEqual(calls.find((call) => call.args.includes("--bg"))?.args, [ |
| 244 | + "serve", |
| 245 | + "--bg", |
| 246 | + "--https=6545", |
| 247 | + "http://localhost:6545", |
| 248 | + ]); |
| 249 | + }), |
| 250 | + ); |
| 251 | + |
| 252 | + it.effect("fails with a remedy when the dev server is not running", () => |
| 253 | + Effect.gen(function* () { |
| 254 | + const { result } = yield* runResolve({ |
| 255 | + port: 6545, |
| 256 | + clientBaseUrl: CLIENT_ON_TAILNET, |
| 257 | + serveStatus: () => "{}", |
| 258 | + listeningPorts: [], |
| 259 | + }); |
| 260 | + |
| 261 | + const error = result._tag === "Failure" ? result.failure : null; |
| 262 | + assert.instanceOf(error, PreviewPortUnreachableError); |
| 263 | + assert.equal(error?.reason, "not-listening"); |
| 264 | + assert.include(error?.message ?? "", "Start the dev server first"); |
| 265 | + }), |
| 266 | + ); |
| 267 | + |
| 268 | + it.effect("refuses to take over a tailnet port that routes elsewhere", () => |
| 269 | + Effect.gen(function* () { |
| 270 | + const { result, calls } = yield* runResolve({ |
| 271 | + port: 6545, |
| 272 | + clientBaseUrl: CLIENT_ON_TAILNET, |
| 273 | + serveStatus: () => |
| 274 | + serveStatusWith([ |
| 275 | + { servePort: 6545, localPort: 9999 }, |
| 276 | + { servePort: 46545, localPort: 9998 }, |
| 277 | + ]), |
| 278 | + listeningPorts: [6545], |
| 279 | + }); |
| 280 | + |
| 281 | + const error = result._tag === "Failure" ? result.failure : null; |
| 282 | + assert.equal(error?.reason, "serve-port-conflict"); |
| 283 | + // The pre-existing mapping is left exactly as it was found. |
| 284 | + assert.isUndefined(calls.find((call) => call.args.includes("--bg"))); |
| 285 | + }), |
| 286 | + ); |
| 287 | + |
| 288 | + it.effect("surfaces a permission failure as an actionable reason", () => |
| 289 | + Effect.gen(function* () { |
| 290 | + const { result } = yield* runResolve({ |
| 291 | + port: 6545, |
| 292 | + clientBaseUrl: CLIENT_ON_TAILNET, |
| 293 | + serveStatus: () => "{}", |
| 294 | + listeningPorts: [6545], |
| 295 | + onServe: () => ({ code: 1, stderr: "access denied: must be root" }), |
| 296 | + }); |
| 297 | + |
| 298 | + const error = result._tag === "Failure" ? result.failure : null; |
| 299 | + assert.equal(error?.reason, "tailscale-permission-denied"); |
| 300 | + // The classified label travels, never the raw stderr. |
| 301 | + assert.notInclude(error?.message ?? "", "must be root"); |
| 302 | + }), |
| 303 | + ); |
| 304 | + |
| 305 | + it.effect("withdraws a mapping it published but could not reach", () => |
| 306 | + Effect.gen(function* () { |
| 307 | + let published = false; |
| 308 | + const { result, calls } = yield* runResolve({ |
| 309 | + port: 6545, |
| 310 | + clientBaseUrl: CLIENT_ON_TAILNET, |
| 311 | + serveStatus: () => |
| 312 | + published ? serveStatusWith([{ servePort: 6545, localPort: 6545 }]) : "{}", |
| 313 | + listeningPorts: [6545], |
| 314 | + onServe: () => { |
| 315 | + published = true; |
| 316 | + return { code: 0 }; |
| 317 | + }, |
| 318 | + neverReachable: true, |
| 319 | + }); |
| 320 | + |
| 321 | + const error = result._tag === "Failure" ? result.failure : null; |
| 322 | + assert.equal(error?.reason, "not-reachable"); |
| 323 | + // Publishing a port and then leaving it behind would keep a dead route on |
| 324 | + // the tailnet, so the failure path has to undo its own mapping. |
| 325 | + assert.deepEqual(calls.at(-1)?.args, ["serve", "--https=6545", "off"]); |
| 326 | + }), |
| 327 | + ); |
| 328 | +}); |
0 commit comments