import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { buildTailscaleHttpsBaseUrl, disableTailscaleServe, ensureTailscaleServe, isTailscaleIpv4Address, parseTailscaleMagicDnsName, parseTailscaleStatus, readTailscaleStatus, TAILSCALE_STATUS_TIMEOUT, TailscaleCommandExitError, TailscaleCommandSpawnError, TailscaleCommandTimeoutError, TailscaleStatusParseError, } from "./tailscale.ts"; const encoder = new TextEncoder(); /** * Asserts nothing reachable from `error` contains `secret`. Recurses through * nested objects, arrays, and `cause` chains rather than checking only * top-level strings: a leak one level down (say, a wrapped cause carrying raw * stderr) is just as visible in a log, and a shallow check would pass it. * * Walks values instead of serializing so it holds for fields added later, and * tracks visited objects so a cyclic cause chain terminates. */ function assertCarriesNoSecret(error: object, secret: string): void { const seen = new WeakSet(); const walk = (value: unknown, path: string): void => { if (typeof value === "string") { assert.notInclude(value, secret, `${path} leaked stderr`); return; } if (typeof value !== "object" || value === null || seen.has(value)) { return; } seen.add(value); if (Array.isArray(value)) { value.forEach((entry, index) => walk(entry, `${path}[${String(index)}]`)); return; } // `message` and `cause` are getters on Error subclasses, so they are not // own enumerable properties and Object.entries alone would skip them. walk((value as { message?: unknown }).message, `${path}.message`); walk((value as { cause?: unknown }).cause, `${path}.cause`); for (const [key, nested] of Object.entries(value)) { walk(nested, `${path}.${key}`); } }; walk(error, "error"); } const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`; const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`; function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, stdout: Stream.make(encoder.encode(result.stdout ?? "")), stderr: Stream.make(encoder.encode(result.stderr ?? "")), all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); } function neverFinishingMockHandle() { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.never, isRunning: Effect.succeed(true), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, stdout: Stream.empty, stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); } // The executable name depends on the host platform (`tailscale.exe` on // Windows), so pin it: these tests assert the posix spelling. function spawnerLayer(spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) { return Layer.merge( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed(HostProcessPlatform, "linux"), ); } function mockSpawnerLayer( handler: ( command: string, args: ReadonlyArray, ) => { stdout?: string; stderr?: string; code?: number }, ) { return spawnerLayer( ChildProcessSpawner.make((command) => { const childProcess = command as unknown as { readonly command: string; readonly args: ReadonlyArray; }; return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); }), ); } describe("tailscale", () => { it.effect("detects Tailnet IPv4 addresses", () => Effect.sync(() => { assert.equal(isTailscaleIpv4Address("100.64.0.1"), true); assert.equal(isTailscaleIpv4Address("100.127.255.254"), true); assert.equal(isTailscaleIpv4Address("100.128.0.1"), false); assert.equal(isTailscaleIpv4Address("192.168.1.44"), false); }), ); it.effect("parses MagicDNS names from tailscale status", () => Effect.gen(function* () { const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson); assert.equal(dnsName, "desktop.tail.ts.net"); assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); }), ); it.effect("parses status facts", () => Effect.gen(function* () { const status = yield* parseTailscaleStatus(tailscaleStatusJson); assert.deepEqual(status, { magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.100.100.100"], }); }), ); it.effect("preserves status decoding failures without exposing cause text", () => Effect.gen(function* () { const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip); assert.instanceOf(error, TailscaleStatusParseError); assert.equal(error.message, "Failed to decode tailscale status JSON."); assert.isDefined(error.cause); assert.notInclude(error.message, String(error.cause)); }), ); it.effect("builds clean HTTPS base URLs", () => Effect.sync(() => { assert.equal( buildTailscaleHttpsBaseUrl({ magicDnsName: "desktop.tail.ts.net" }), "https://desktop.tail.ts.net/", ); assert.equal( buildTailscaleHttpsBaseUrl({ magicDnsName: "desktop.tail.ts.net", servePort: 8443 }), "https://desktop.tail.ts.net:8443/", ); }), ); it.effect("reads tailscale status through the process spawner service", () => { const layer = mockSpawnerLayer((command, args) => { assert.equal(command, "tailscale"); assert.deepEqual(args, ["status", "--json"]); return { stdout: tailscaleStatusWithSingleIpJson, }; }); return Effect.gen(function* () { const status = yield* readTailscaleStatus.pipe(Effect.provide(layer)); assert.deepEqual(status, { magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.90.1.2"], }); }); }); it.effect("preserves tailscale spawn failures as causes", () => { const systemCause = new Error("private executable lookup detail"); const cause = PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn", cause: systemCause, }); const layer = spawnerLayer(ChildProcessSpawner.make(() => Effect.fail(cause))); return Effect.gen(function* () { const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); assert.instanceOf(error, TailscaleCommandSpawnError); assert.equal(error.executable, "tailscale"); assert.equal(error.subcommand, "status"); assert.equal(error.argumentCount, 2); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Failed to spawn tailscale status."); assert.notInclude(error.message, systemCause.message); }); }); it.effect("turns spawn defects into typed spawn failures", () => { // A non-directory entry on PATH makes node's spawn throw ENOTDIR // synchronously. The platform spawner calls `NodeChildProcess.spawn` from // inside an `Effect.callback` registration, so that throw arrives as a // defect rather than a typed error - the shape reproduced here. const defect = Object.assign(new Error("spawn tailscale ENOTDIR"), { code: "ENOTDIR" }); const layer = spawnerLayer( ChildProcessSpawner.make(() => Effect.callback(() => { throw defect; }), ), ); return Effect.gen(function* () { const statusError = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); assert.instanceOf(statusError, TailscaleCommandSpawnError); assert.equal(statusError.subcommand, "status"); assert.strictEqual(statusError.cause, defect); const serveError = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( Effect.flip, Effect.provide(layer), ); assert.instanceOf(serveError, TailscaleCommandSpawnError); assert.equal(serveError.subcommand, "serve"); assert.strictEqual(serveError.cause, defect); // What callers actually rely on: the desktop endpoint providers recover // with `Effect.orElseSucceed`, which only sees the typed error channel. const degraded = yield* readTailscaleStatus.pipe( Effect.orElseSucceed(() => null), Effect.provide(layer), ); assert.equal(degraded, null); }); }); it.effect("keeps nonzero exit diagnostics structured", () => { const layer = mockSpawnerLayer(() => ({ code: 7, stderr: "not logged in tskey-auth-secret-token-value", })); return Effect.gen(function* () { const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); assert.instanceOf(error, TailscaleCommandExitError); assert.equal(error.executable, "tailscale"); assert.equal(error.subcommand, "status"); assert.equal(error.argumentCount, 2); assert.equal(error.exitCode, 7); assert.equal(error.stdoutLength, 0); assert.equal(error.stderrLength, 43); assert.notProperty(error, "command"); assert.notProperty(error, "stderr"); assert.notInclude(error.message, "tskey-auth-secret-token-value"); assert.equal(error.message, "tailscale status exited with code 7."); assert.equal(error.stderrDiagnostic, "not-logged-in"); assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); }); }); it.effect("classifies unrecognized stderr without quoting it", () => { const layer = mockSpawnerLayer(() => ({ code: 3, stderr: "something novel went wrong for node fluffy-badger tskey-auth-secret-token-value", })); return Effect.gen(function* () { const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); assert.instanceOf(error, TailscaleCommandExitError); // Unmatched stderr degrades to "unknown" rather than passing text // through — that fallback is what keeps novel output from leaking. assert.equal(error.stderrDiagnostic, "unknown"); assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); assertCarriesNoSecret(error, "fluffy-badger"); }); }); it.effect("times out tailscale status through TestClock", () => { const layer = Layer.merge( TestClock.layer(), spawnerLayer(ChildProcessSpawner.make(() => Effect.succeed(neverFinishingMockHandle()))), ); return Effect.gen(function* () { const fiber = yield* readTailscaleStatus.pipe(Effect.flip, Effect.forkScoped); yield* Effect.yieldNow; yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT); const error = yield* Fiber.join(fiber); assert.instanceOf(error, TailscaleCommandTimeoutError); assert.equal(error.executable, "tailscale"); assert.equal(error.subcommand, "status"); assert.equal(error.argumentCount, 2); assert.equal(error.timeoutMs, 1_500); assert.isTrue(Cause.isTimeoutError(error.cause)); assert.equal(error.message, "tailscale status timed out after 1500ms."); }).pipe(Effect.provide(layer)); }); it.effect("configures tailscale serve through the process spawner service", () => { const layer = mockSpawnerLayer((command, args) => { assert.equal(command, "tailscale"); assert.deepEqual(args, ["serve", "--bg", "--https=8443", "http://127.0.0.1:13773"]); return {}; }); return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer)); }); it.effect("retains tailscale serve exit diagnostics", () => { const layer = mockSpawnerLayer(() => ({ code: 1, stderr: "serve permission denied tskey-auth-secret-token-value", })); return Effect.gen(function* () { const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( Effect.flip, Effect.provide(layer), ); assert.instanceOf(error, TailscaleCommandExitError); assert.equal(error.executable, "tailscale"); assert.equal(error.subcommand, "serve"); assert.equal(error.argumentCount, 4); assert.equal(error.exitCode, 1); assert.equal(error.stderrLength, 53); assert.notProperty(error, "command"); assert.notProperty(error, "stderr"); assert.notInclude(error.message, "tskey-auth-secret-token-value"); // The diagnostic classifies the failure without quoting stderr, so the // key cannot reach a log through it either. assert.equal(error.stderrDiagnostic, "permission-denied"); assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); }); }); it.effect("disables tailscale serve through the process spawner service", () => { const commands: { readonly command: string; readonly args: ReadonlyArray; }[] = []; const layer = mockSpawnerLayer((command, args) => { commands.push({ command, args }); assert.equal(command, "tailscale"); assert.deepEqual(args, ["serve", "--https=8443", "off"]); return {}; }); return Effect.gen(function* () { yield* disableTailscaleServe({ servePort: 8443 }).pipe(Effect.provide(layer)); assert.deepEqual(commands, [ { command: "tailscale", args: ["serve", "--https=8443", "off"] }, ]); }); }); });