381 lines · 14.0 KB · TypeScript
RawDownload
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<object>();   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<string>,  ) => { stdout?: string; stderr?: string; code?: number },) {  return spawnerLayer(    ChildProcessSpawner.make((command) => {      const childProcess = command as unknown as {        readonly command: string;        readonly args: ReadonlyArray<string>;      };      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<never, never>(() => {          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<string>;    }[] = [];    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"] },      ]);    });  });});