1import { describe, expect, it } from "@effect/vitest";
2import * as NodeFs from "node:fs";
3import * as NodeNet from "node:net";
4import {
5 closeLoopbackUnixSockets,
6 CONTAINER_LOOPBACK_ALIAS,
7 CONTAINER_LOOPBACK_MOUNT,
8 ensureLoopbackUnixSockets,
9 loopbackPortsFromEnv,
10 loopbackPortsFromEnvValue,
11 loopbackSocketDir,
12 mergeSidecarLoopbackHostConfig,
13 sidecarLoopbackBinds,
14 sidecarLoopbackExtraHost,
15 usesUnixSocketLoopback,
16} from "../DockerLoopback.ts";
17
18describe("DockerLoopback", () => {
19 it("keeps the alias localhost-looking (Prisma's http/https gate)", () => {
20 expect(CONTAINER_LOOPBACK_ALIAS).toContain("localhost");
21 });
22
23 it("extracts ports from URL, DSN, and prisma+postgres shapes", () => {
24 expect(
25 loopbackPortsFromEnvValue("postgres://postgres@127.0.0.1:5432/db"),
26 ).toEqual([5432]);
27 expect(
28 loopbackPortsFromEnvValue(
29 "prisma+postgres://localhost:51216/?api_key=test",
30 ),
31 ).toEqual([51216]);
32 expect(
33 loopbackPortsFromEnvValue("http://host.docker.localhost:42117/hello"),
34 ).toEqual([42117]);
35 expect(
36 loopbackPortsFromEnvValue("host=127.0.0.1 port=5432 sslmode=disable"),
37 ).toEqual([5432]);
38 });
39
40 it("does not extract ports from Neon or PlanetScale URLs", () => {
41 expect(
42 loopbackPortsFromEnvValue(
43 "postgres://neondb_owner:secret@ep-cool-name.us-east-1.aws.neon.tech/neondb?sslmode=require",
44 ),
45 ).toEqual([]);
46 expect(
47 loopbackPortsFromEnvValue(
48 "postgresql://user:secret@xxxx.pg.psdb.cloud:6432/postgres?sslmode=verify-full",
49 ),
50 ).toEqual([]);
51 });
52
53 it("unions ports across a container env map", () => {
54 expect(
55 loopbackPortsFromEnv({
56 DATABASE_URL: "postgres://postgres@127.0.0.1:5432/db",
57 PPG_URL: "prisma+postgres://localhost:51216/?api_key=test",
58 NEON_URL:
59 "postgres://x@ep-cool-name.us-east-1.aws.neon.tech/neondb?sslmode=require",
60 }).sort((a, b) => a - b),
61 ).toEqual([5432, 51216]);
62 });
63
64 it("maps ExtraHosts to 127.0.0.1 on Linux and host-gateway elsewhere", () => {
65 const extra = sidecarLoopbackExtraHost();
66 if (usesUnixSocketLoopback()) {
67 expect(extra).toBe(`${CONTAINER_LOOPBACK_ALIAS}:127.0.0.1`);
68 expect(sidecarLoopbackBinds([5432])).toEqual([
69 `${loopbackSocketDir()}:${CONTAINER_LOOPBACK_MOUNT}`,
70 ]);
71 } else {
72 expect(extra).toBe(`${CONTAINER_LOOPBACK_ALIAS}:host-gateway`);
73 expect(sidecarLoopbackBinds([5432])).toEqual([]);
74 }
75 });
76
77 it("merges sidecar HostConfig without duplicating ExtraHosts", () => {
78 const extra = sidecarLoopbackExtraHost();
79 const once = mergeSidecarLoopbackHostConfig(
80 { ExtraHosts: ["host.docker.internal:host-gateway"] },
81 [5432],
82 );
83 expect(once.ExtraHosts).toContain(extra);
84 expect(once.ExtraHosts).toContain("host.docker.internal:host-gateway");
85 const twice = mergeSidecarLoopbackHostConfig(once, [5432]);
86 expect(twice.ExtraHosts.filter((h) => h === extra)).toHaveLength(1);
87 });
88
89 it.skipIf(!usesUnixSocketLoopback())(
90 "unix-socket forwards to a 127.0.0.1 listener without binding the bridge",
91 async () => {
92 const server = NodeNet.createServer((socket) => {
93 socket.write("pong");
94 socket.end();
95 });
96 const port = await new Promise<number>((resolve, reject) => {
97 server.once("error", reject);
98 server.listen(0, "127.0.0.1", () => {
99 const address = server.address();
100 if (address === null || typeof address === "string") {
101 reject(new Error("expected tcp address"));
102 return;
103 }
104 resolve(address.port);
105 });
106 });
107 try {
108 ensureLoopbackUnixSockets([port]);
109 const sock = `${loopbackSocketDir()}/${port}.sock`;
110 expect(NodeFs.existsSync(sock)).toBe(true);
111 const data = await new Promise<string>((resolve, reject) => {
112 const client = NodeNet.connect(sock);
113 client.setEncoding("utf8");
114 let body = "";
115 client.on("data", (chunk) => {
116 body += chunk;
117 });
118 client.on("end", () => resolve(body));
119 client.on("error", reject);
120 });
121 expect(data).toBe("pong");
122 } finally {
123 closeLoopbackUnixSockets();
124 await new Promise<void>((resolve) => server.close(() => resolve()));
125 }
126 },
127 );
128});