Files
kandev/apps/cli/src/shared.test.ts
T
2026-05-17 12:29:44 +01:00

429 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildWebEnv,
listHostNetworkAddresses,
logStartupInfo,
networkUrlsForPort,
type PortConfig,
} from "./shared";
const ports: PortConfig = {
backendPort: 38429,
webPort: 37429,
agentctlPort: 36429,
backendUrl: "http://localhost:38429",
};
describe("buildWebEnv", () => {
const originalApiPort = process.env.NEXT_PUBLIC_KANDEV_API_PORT;
afterEach(() => {
if (originalApiPort === undefined) {
delete process.env.NEXT_PUBLIC_KANDEV_API_PORT;
} else {
process.env.NEXT_PUBLIC_KANDEV_API_PORT = originalApiPort;
}
});
it("sets NEXT_PUBLIC_KANDEV_API_PORT in dev mode so the browser knows the backend port", () => {
const env = buildWebEnv({ ports });
expect(env.NEXT_PUBLIC_KANDEV_API_PORT).toBe("38429");
expect(env.NODE_ENV).not.toBe("production");
});
it("does not set NEXT_PUBLIC_KANDEV_API_PORT in production single-port mode", () => {
// The Go backend reverse-proxies Next.js, so the client must use same-origin.
// A non-empty value would cause the client to build cross-origin URLs like
// `https://host:38429/...` that aren't reachable behind a reverse proxy.
const env = buildWebEnv({ ports, production: true });
expect(env.NEXT_PUBLIC_KANDEV_API_PORT).toBeUndefined();
expect(env.NODE_ENV).toBe("production");
});
it("strips a host-level NEXT_PUBLIC_KANDEV_API_PORT in production", () => {
// If an operator has the var in their shell/Docker env, the process.env
// spread would otherwise leak it into the Next.js process and reintroduce
// the cross-origin URL problem this fix addresses.
process.env.NEXT_PUBLIC_KANDEV_API_PORT = "99999";
const env = buildWebEnv({ ports, production: true });
expect(env.NEXT_PUBLIC_KANDEV_API_PORT).toBeUndefined();
});
it("always sets KANDEV_API_BASE_URL for SSR fetches", () => {
expect(buildWebEnv({ ports }).KANDEV_API_BASE_URL).toBe("http://localhost:38429");
expect(buildWebEnv({ ports, production: true }).KANDEV_API_BASE_URL).toBe(
"http://localhost:38429",
);
});
it("enables debug flag when requested", () => {
expect(buildWebEnv({ ports, debug: true }).NEXT_PUBLIC_KANDEV_DEBUG).toBe("true");
expect(buildWebEnv({ ports }).NEXT_PUBLIC_KANDEV_DEBUG).toBeUndefined();
});
});
describe("buildWebEnv allowedDevOrigins", () => {
const originalAllowed = process.env.NEXT_ALLOWED_DEV_ORIGINS;
beforeEach(() => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo: [
{
address: "127.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: true,
cidr: "127.0.0.1/8",
},
],
eth0: [
{
address: "192.168.1.34",
netmask: "255.255.252.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "192.168.1.34/22",
},
{
address: "fe80::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "fe80::1/64",
scopeid: 2,
},
],
tailscale0: [
{
address: "100.94.173.104",
netmask: "255.255.255.255",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "100.94.173.104/32",
},
],
});
});
afterEach(() => {
vi.restoreAllMocks();
if (originalAllowed === undefined) {
delete process.env.NEXT_ALLOWED_DEV_ORIGINS;
} else {
process.env.NEXT_ALLOWED_DEV_ORIGINS = originalAllowed;
}
});
it("populates NEXT_ALLOWED_DEV_ORIGINS with LAN + Tailscale IPs in dev mode", () => {
delete process.env.NEXT_ALLOWED_DEV_ORIGINS;
const env = buildWebEnv({ ports });
const origins = (env.NEXT_ALLOWED_DEV_ORIGINS ?? "").split(",");
expect(origins).toContain("192.168.1.34");
expect(origins).toContain("100.94.173.104");
});
it("excludes loopback and link-local IPv6 addresses", () => {
delete process.env.NEXT_ALLOWED_DEV_ORIGINS;
const env = buildWebEnv({ ports });
const origins = (env.NEXT_ALLOWED_DEV_ORIGINS ?? "").split(",");
expect(origins).not.toContain("127.0.0.1");
expect(origins).not.toContain("fe80::1");
});
it("merges with a user-supplied NEXT_ALLOWED_DEV_ORIGINS, deduped", () => {
process.env.NEXT_ALLOWED_DEV_ORIGINS = "dev.example, 192.168.1.34";
const env = buildWebEnv({ ports });
const origins = (env.NEXT_ALLOWED_DEV_ORIGINS ?? "").split(",");
expect(origins).toContain("dev.example");
expect(origins).toContain("192.168.1.34");
expect(origins).toContain("100.94.173.104");
expect(origins.filter((s) => s === "192.168.1.34")).toHaveLength(1);
});
it("omits NEXT_ALLOWED_DEV_ORIGINS entirely when there's nothing to allow", () => {
delete process.env.NEXT_ALLOWED_DEV_ORIGINS;
vi.restoreAllMocks();
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo: [
{
address: "127.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: true,
cidr: "127.0.0.1/8",
},
],
});
const env = buildWebEnv({ ports });
// Better than setting NEXT_ALLOWED_DEV_ORIGINS="" — keeps the env clean for
// the loopback-only case so a downstream consumer that distinguishes
// "unset" from "empty" doesn't get confused.
expect(env.NEXT_ALLOWED_DEV_ORIGINS).toBeUndefined();
});
it("does not set NEXT_ALLOWED_DEV_ORIGINS in production", () => {
delete process.env.NEXT_ALLOWED_DEV_ORIGINS;
const env = buildWebEnv({ ports, production: true });
expect(env.NEXT_ALLOWED_DEV_ORIGINS).toBeUndefined();
});
});
describe("listHostNetworkAddresses", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns a deduplicated list of non-internal addresses", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "10.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "10.0.0.1/8",
},
],
eth1: [
{
address: "10.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "11:22:33:44:55:66",
internal: false,
cidr: "10.0.0.1/8",
},
],
});
expect(listHostNetworkAddresses()).toEqual(["10.0.0.1"]);
});
it("excludes internal loopback addresses", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo: [
{
address: "127.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: true,
cidr: "127.0.0.1/8",
},
],
eth0: [
{
address: "10.0.0.5",
netmask: "255.0.0.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "10.0.0.5/8",
},
],
});
expect(listHostNetworkAddresses()).toEqual(["10.0.0.5"]);
});
it("excludes IPv4 link-local (169.254.0.0/16) addresses", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "169.254.83.107",
netmask: "255.255.0.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "169.254.83.107/16",
},
{
address: "192.168.1.34",
netmask: "255.255.252.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "192.168.1.34/22",
},
],
});
expect(listHostNetworkAddresses()).toEqual(["192.168.1.34"]);
});
it("excludes the full IPv6 link-local range (fe80::/10, covers fe80–febf)", () => {
// In practice OS stacks only assign fe80::/64, but per RFC 4291 the link-
// local range is fe80::/10 — anything from fe80:: through febf:ffff:... is
// link-local and never reachable from a remote machine.
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "fe80::abcd",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "fe80::abcd/64",
scopeid: 2,
},
{
address: "fea0::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "fea0::1/64",
scopeid: 2,
},
{
address: "FEBF::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "FEBF::1/64",
scopeid: 2,
},
{
address: "fec0::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "fec0::1/64",
scopeid: 0,
},
{
address: "2001:db8::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "2001:db8::1/64",
scopeid: 0,
},
],
});
// fe80::abcd, fea0::1, FEBF::1 are link-local (excluded).
// fec0::1 was deprecated site-local (kept — outside fe80::/10).
// 2001:db8::1 is global (kept).
expect(listHostNetworkAddresses()).toEqual(["fec0::1", "2001:db8::1"]);
});
});
describe("networkUrlsForPort", () => {
it("formats IPv4 hosts without brackets", () => {
expect(networkUrlsForPort(38429, ["192.168.1.34", "100.94.173.104"])).toEqual([
"http://192.168.1.34:38429",
"http://100.94.173.104:38429",
]);
});
it("wraps IPv6 hosts in brackets per RFC 3986", () => {
expect(networkUrlsForPort(38429, ["2001:db8::1"])).toEqual(["http://[2001:db8::1]:38429"]);
});
it("returns an empty list when there are no hosts", () => {
expect(networkUrlsForPort(38429, [])).toEqual([]);
});
});
describe("logStartupInfo", () => {
let log: ReturnType<typeof vi.spyOn<typeof console, "log">>;
beforeEach(() => {
log = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
function mockTwoInterfaces() {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "192.168.1.34",
netmask: "255.255.252.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "192.168.1.34/22",
},
],
tailscale0: [
{
address: "100.94.173.104",
netmask: "255.255.255.255",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "100.94.173.104/32",
},
],
});
}
it("prints the backend URL and network URLs by default (start/run mode)", () => {
mockTwoInterfaces();
logStartupInfo({ header: "test", ports });
const lines = log.mock.calls.map((c) => c.join(" "));
expect(lines).toContain("[kandev] url: http://localhost:38429");
expect(lines).toContain("[kandev] network: http://192.168.1.34:38429");
expect(lines).toContain("[kandev] network: http://100.94.173.104:38429");
// Internal-only ports must not appear — the user opens exactly one URL.
expect(lines.some((l) => l.includes(":37429"))).toBe(false);
expect(lines.some((l) => l.includes("agentctl"))).toBe(false);
});
it('prints the web URL and network URLs when primary="web" (dev mode)', () => {
mockTwoInterfaces();
logStartupInfo({ header: "test", ports, primary: "web" });
const lines = log.mock.calls.map((c) => c.join(" "));
expect(lines).toContain("[kandev] url: http://localhost:37429");
expect(lines).toContain("[kandev] network: http://192.168.1.34:37429");
expect(lines).toContain("[kandev] network: http://100.94.173.104:37429");
// The backend port is internal in dev mode (only the browser's API calls
// hit it, via NEXT_PUBLIC_KANDEV_API_PORT), so it doesn't show up as a URL
// for the user. The mcp endpoint still appears because MCP clients use it.
expect(lines.some((l) => l === "[kandev] url: http://localhost:38429")).toBe(false);
expect(lines.some((l) => l.includes("network:") && l.includes(":38429"))).toBe(false);
});
it("still emits the mcp endpoint pointing at the backend port", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({});
logStartupInfo({ header: "test", ports, primary: "web" });
const lines = log.mock.calls.map((c) => c.join(" "));
expect(lines).toContain("[kandev] mcp: http://localhost:38429/mcp");
});
it("emits no network lines when only loopback interfaces exist", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo: [
{
address: "127.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: true,
cidr: "127.0.0.1/8",
},
],
});
logStartupInfo({ header: "test", ports });
const lines = log.mock.calls.map((c) => c.join(" "));
expect(lines.some((l) => l.includes("network:"))).toBe(false);
});
});