diff --git a/package.json b/package.json index ad5eec7..d6cb5ae 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start": "node build/server/index.js", "typecheck": "react-router typegen && tsgo", "test:unit": "vitest run --project unit", - "test:integration": "vitest run --project integration", + "test:integration": "vitest run --project integration:*", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", @@ -87,7 +87,7 @@ "typescript": "^5.9.3", "vite": "^8.0.0", "vitepress": "next", - "vitest": "^4.0.18" + "vitest": "^4.1.0" }, "engines": { "node": ">=24.2 <25", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 397ca71..8f71cb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,8 +210,8 @@ importers: specifier: next version: 2.0.0-alpha.12(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.32.0)(postcss@8.5.6)(terser@5.39.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) vitest: - specifier: ^4.0.18 - version: 4.0.18(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2) + specifier: ^4.1.0 + version: 4.1.0(@types/node@25.3.1)(vite@8.0.0(@types/node@25.3.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2)) packages: @@ -2679,34 +2679,34 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 vue: ^3.2.25 - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} '@vue/compiler-core@3.5.18': resolution: {integrity: sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==} @@ -3372,6 +3372,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -4478,8 +4481,8 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} stream-buffers@3.0.3: resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} @@ -4813,20 +4816,21 @@ packages: postcss: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -7664,43 +7668,45 @@ snapshots: vite: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.18(typescript@5.9.3) - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.0': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.1.0(vite@8.0.0(@types/node@25.3.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.3.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.0': dependencies: tinyrainbow: 3.0.3 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.0': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.0 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.0': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 tinyrainbow: 3.0.3 '@vue/compiler-core@3.5.18': @@ -8251,6 +8257,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -9492,7 +9500,7 @@ snapshots: stackback@0.0.2: {} - std-env@3.10.0: {} + std-env@4.0.0: {} stream-buffers@3.0.3: {} @@ -9883,42 +9891,32 @@ snapshots: - universal-cookie - yaml - vitest@4.0.18(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.1.0(@types/node@25.3.1)(vite@8.0.0(@types/node@25.3.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.3.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.10.0 + std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.3.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.3.1 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml vue@3.5.18(typescript@5.9.3): dependencies: diff --git a/tests/integration/api-keys.test.ts b/tests/integration/api-keys.test.ts deleted file mode 100644 index 88fcef5..0000000 --- a/tests/integration/api-keys.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { getRuntimeClient, HS_VERSIONS } from './setup/env'; - -describe.for(HS_VERSIONS)('Headscale %s: API Keys', (version) => { - test('api keys can be fetched', async () => { - const client = await getRuntimeClient(version); - const apiKeys = await client.getApiKeys(); - expect(Array.isArray(apiKeys)).toBe(true); - expect(apiKeys.length).toBe(1); - }); -}); diff --git a/tests/integration/api/api-keys.test.ts b/tests/integration/api/api-keys.test.ts new file mode 100644 index 0000000..c3c6ce2 --- /dev/null +++ b/tests/integration/api/api-keys.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "vitest"; + +import { getRuntimeClient, HS_VERSIONS } from "../setup/env"; + +describe.for(HS_VERSIONS)("Headscale %s: API Keys", (version) => { + test("api keys can be fetched", async () => { + const client = await getRuntimeClient(version); + const apiKeys = await client.getApiKeys(); + expect(Array.isArray(apiKeys)).toBe(true); + expect(apiKeys.length).toBe(1); + }); +}); diff --git a/tests/integration/nodes.test.ts b/tests/integration/api/nodes.test.ts similarity index 99% rename from tests/integration/nodes.test.ts rename to tests/integration/api/nodes.test.ts index 6e404a2..7565dd9 100644 --- a/tests/integration/nodes.test.ts +++ b/tests/integration/api/nodes.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getBootstrapClient, getNode, getRuntimeClient, HS_VERSIONS } from "./setup/env"; +import { getBootstrapClient, getNode, getRuntimeClient, HS_VERSIONS } from "../setup/env"; describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { let workingNodeId: string; diff --git a/tests/integration/pre-auth-keys.test.ts b/tests/integration/api/pre-auth-keys.test.ts similarity index 99% rename from tests/integration/pre-auth-keys.test.ts rename to tests/integration/api/pre-auth-keys.test.ts index cfb21be..53b8e2e 100644 --- a/tests/integration/pre-auth-keys.test.ts +++ b/tests/integration/api/pre-auth-keys.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "./setup/env"; +import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "../setup/env"; describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => { test("pre-auth keys can be created", async () => { diff --git a/tests/integration/api/users.test.ts b/tests/integration/api/users.test.ts new file mode 100644 index 0000000..8b1e2b8 --- /dev/null +++ b/tests/integration/api/users.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; + +import { getRuntimeClient, HS_VERSIONS } from "../setup/env"; + +describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { + test("users can be created", async () => { + const client = await getRuntimeClient(version); + const user = await client.createUser("tale@"); + expect(user).toBeDefined(); + expect(user.name).toBe("tale@"); + }); + + test("users can be created with attributes", async () => { + const client = await getRuntimeClient(version); + const user = await client.createUser( + "test-user@", + "test-user@example.com", + "Test User", + "https://github.com/tale.png", + ); + + expect(user).toBeDefined(); + expect(user.name).toBe("test-user@"); + expect(user.email).toBe("test-user@example.com"); + expect(user.displayName).toBe("Test User"); + expect(user.profilePicUrl).toBe("https://github.com/tale.png"); + }); + + test("users can be listed", async () => { + const client = await getRuntimeClient(version); + const users = await client.getUsers(); + expect(Array.isArray(users)).toBe(true); + expect(users.length).toBeGreaterThanOrEqual(2); + }); + + test("users can be listed by name", async () => { + const client = await getRuntimeClient(version); + const users = await client.getUsers(undefined, "tale@"); + expect(Array.isArray(users)).toBe(true); + expect(users.length).toBe(1); + expect(users[0].name).toBe("tale@"); + }); + + test("users can be listed by email", async () => { + const client = await getRuntimeClient(version); + const users = await client.getUsers(undefined, undefined, "test-user@example.com"); + expect(Array.isArray(users)).toBe(true); + expect(users.length).toBe(1); + expect(users[0].email).toBe("test-user@example.com"); + }); + + test("users can be renamed", async () => { + const client = await getRuntimeClient(version); + const usersBefore = await client.getUsers(undefined, "tale@"); + expect(usersBefore.length).toBe(1); + const user = usersBefore[0]; + + await client.renameUser(user.id, "renamed-user@"); + const usersAfter = await client.getUsers(undefined, "renamed-user@"); + expect(usersAfter.length).toBe(1); + expect(usersAfter[0].id).toBe(user.id); + }); + + test("users can be deleted", async () => { + const client = await getRuntimeClient(version); + const usersBefore = await client.getUsers(undefined, "test-user@"); + expect(usersBefore.length).toBe(1); + const user = usersBefore[0]; + + await client.deleteUser(user.id); + const usersAfter = await client.getUsers(undefined, "test-user@"); + expect(usersAfter.length).toBe(0); + }); +}); diff --git a/tests/integration/api/versions.test.ts b/tests/integration/api/versions.test.ts new file mode 100644 index 0000000..a9c53c9 --- /dev/null +++ b/tests/integration/api/versions.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; + +import canonicals from "~/openapi-canonical-families.json"; + +import { getBootstrapClient, getRuntimeClient, HS_VERSIONS, Version } from "../setup/env"; + +function getCanonicalVersion(version: Version) { + const canonical = Object.entries(canonicals).find(([_, family]) => + family.includes(version), + )?.[0] as Version | undefined; + + if (!canonical) { + return version; + } + + return canonical; +} + +describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => { + test("the runtime client is usable", async () => { + const bootstrapper = await getBootstrapClient(version); + const runtimeClient = bootstrapper.getRuntimeClient("test-api-key"); + expect(runtimeClient).toBeDefined(); + }); + + test("the runtime client has the correct canonical API version", async () => { + const bootstrapper = await getBootstrapClient(version); + expect(bootstrapper.apiVersion).toBe(getCanonicalVersion(version)); + }); + + test("the health check endpoint works", async () => { + const client = await getRuntimeClient(version); + const health = await client.isHealthy(); + expect(health).toBe(true); + }); +}); diff --git a/tests/integration/platform/docker.test.ts b/tests/integration/platform/docker.test.ts new file mode 100644 index 0000000..62384d5 --- /dev/null +++ b/tests/integration/platform/docker.test.ts @@ -0,0 +1,104 @@ +import { Client } from "undici"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import DockerIntegration from "~/server/config/integration/docker"; + +import { type HeadscaleEnv, startHeadscale } from "../setup/start-headscale"; + +const TEST_LABEL_KEY = "me.tale.headplane.integration-test"; +const TEST_LABEL_VALUE = `docker-${Date.now()}`; + +describe("DockerIntegration", () => { + let env: HeadscaleEnv; + let dockerSocket: string; + + beforeAll(async () => { + dockerSocket = process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock"; + env = await startHeadscale("0.25.1", { + labels: { [TEST_LABEL_KEY]: TEST_LABEL_VALUE }, + }); + }, 60_000); + + afterAll(async () => { + await env?.container.stop({ remove: true, removeVolumes: true }); + }); + + test("isAvailable finds the headscale container by label", async () => { + const integration = new DockerIntegration({ + enabled: true, + container_label: `${TEST_LABEL_KEY}=${TEST_LABEL_VALUE}`, + socket: dockerSocket, + }); + + expect(await integration.isAvailable()).toBe(true); + }); + + test("isAvailable finds the headscale container by name", async () => { + const name = env.container.getName().replace(/^\//, ""); + const integration = new DockerIntegration({ + enabled: true, + container_name: name, + container_label: "unused=unused", + socket: dockerSocket, + }); + + expect(await integration.isAvailable()).toBe(true); + }); + + test("isAvailable returns false for a non-existent label", async () => { + const integration = new DockerIntegration({ + enabled: true, + container_label: "me.tale.headplane.nonexistent=true", + socket: dockerSocket, + }); + + expect(await integration.isAvailable()).toBe(false); + }); + + test("isAvailable returns false for an invalid socket", async () => { + const integration = new DockerIntegration({ + enabled: true, + container_label: `${TEST_LABEL_KEY}=${TEST_LABEL_VALUE}`, + socket: "unix:///tmp/nonexistent-docker.sock", + }); + + expect(await integration.isAvailable()).toBe(false); + }); + + test("onConfigChange restarts the container", async () => { + const integration = new DockerIntegration({ + enabled: true, + container_label: `${TEST_LABEL_KEY}=${TEST_LABEL_VALUE}`, + socket: dockerSocket, + }); + + expect(await integration.isAvailable()).toBe(true); + + // Health check goes through the Docker socket to avoid stale port + // mappings after container restart. + const containerId = env.container.getId(); + const dockerClient = new Client("http://localhost", { + socketPath: "/var/run/docker.sock", + }); + + const mockClient = { + isHealthy: async () => { + try { + const res = await dockerClient.request({ + method: "GET", + path: `/v1.44/containers/${containerId}/json`, + }); + const info = (await res.body.json()) as any; + return info.State?.Running === true; + } catch { + return false; + } + }, + } as any; + + await integration.onConfigChange(mockClient); + + const healthy = await mockClient.isHealthy(); + expect(healthy).toBe(true); + }, 60_000); +}); diff --git a/tests/integration/platform/proc.test.ts b/tests/integration/platform/proc.test.ts new file mode 100644 index 0000000..6873931 --- /dev/null +++ b/tests/integration/platform/proc.test.ts @@ -0,0 +1,103 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import tc from "testcontainers"; +import { build } from "vite"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +// This is a little shim file that we use to execute the real proc-helper that +// is bundled through Vite in order to test against a real Linux /proc FS. +const testRunner = ` +const { findHeadscaleServe } = require("./proc-helper.js"); + +async function main() { + const pid = await findHeadscaleServe(); + console.log(JSON.stringify({ + pid, + found: typeof pid === "number" && pid > 0, + })); +} + +main().catch((e) => { + console.log(JSON.stringify({ error: e.message })); + process.exit(1); +}); +`; + +const root = fileURLToPath(new URL("../../..", import.meta.url)); +const procHelper = join(root, "app/server/config/integration/proc-helper.ts"); +const appDir = join(root, "app"); + +describe("ProcIntegration", () => { + let container: tc.StartedTestContainer; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "headplane-proc-test-")); + const bundlePath = join(tmpDir, "proc-helper.js"); + + await build({ + configFile: false, + logLevel: "silent", + build: { + lib: { + entry: procHelper, + formats: ["cjs"], + fileName: () => "proc-helper.js", + }, + outDir: tmpDir, + emptyOutDir: false, + rollupOptions: { + external: (id) => id.startsWith("node:"), + }, + }, + resolve: { + alias: { "~": appDir }, + }, + }); + + const runnerPath = join(tmpDir, "test-runner.js"); + await writeFile(runnerPath, testRunner); + + // Build a container image that has both headscale and Node.js using + // a multi-stage Dockerfile. The headscale image is distroless (no shell) + // so we pull the binary into node:alpine via COPY --from. + await writeFile( + join(tmpDir, "Dockerfile"), + [ + "FROM headscale/headscale:0.25.1-debug AS headscale", + "FROM node:24-alpine", + "COPY --from=headscale /ko-app/headscale /usr/local/bin/headscale", + ].join("\n"), + ); + + const configPath = join(root, "tests/integration/setup/config.yaml"); + const image = await tc.GenericContainer.fromDockerfile(tmpDir).build(); + + container = await image + .withCopyFilesToContainer([ + { source: configPath, target: "/etc/headscale/config.yaml" }, + { source: bundlePath, target: "/test/proc-helper.js" }, + { source: runnerPath, target: "/test/test-runner.js" }, + ]) + .withCommand(["sh", "-c", "headscale serve & sleep 2 && sleep infinity"]) + .withWaitStrategy(tc.Wait.forLogMessage("headscale", 1).withStartupTimeout(30_000)) + .start(); + }, 120_000); + + afterAll(async () => { + await container?.stop({ remove: true, removeVolumes: true }); + await rm(tmpDir, { recursive: true, force: true }); + }); + + test("findHeadscaleServe finds the real headscale process", async () => { + const result = await container.exec(["node", "/test/test-runner.js"]); + const output = JSON.parse(result.stdout.trim()); + + expect(result.exitCode).toBe(0); + expect(output.found).toBe(true); + expect(output.pid).toBeGreaterThan(0); + }); +}); diff --git a/tests/integration/setup/start-headscale.ts b/tests/integration/setup/start-headscale.ts index 7dc752c..19ec91b 100644 --- a/tests/integration/setup/start-headscale.ts +++ b/tests/integration/setup/start-headscale.ts @@ -1,51 +1,54 @@ -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import tc from 'testcontainers'; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import tc from "testcontainers"; export interface HeadscaleEnv { - container: tc.StartedTestContainer; - apiUrl: string; - apiKey: string; + container: tc.StartedTestContainer; + apiUrl: string; + apiKey: string; +} + +export interface HeadscaleOptions { + labels?: Record; } const cwd = fileURLToPath(import.meta.url); -const config = join(cwd, '..', 'config.yaml'); +const config = join(cwd, "..", "config.yaml"); -export async function startHeadscale(version: string): Promise { - const container = await new tc.GenericContainer( - `headscale/headscale:${version}`, - ) - .withExposedPorts(8080) - .withWaitStrategy( - tc.Wait.forHttp('/health', 8080) - .withStartupTimeout(30_000) - .forStatusCode(200), - ) - .withCopyFilesToContainer([ - { - source: config, - target: '/etc/headscale/config.yaml', - }, - ]) - .withCommand(['serve']) - .start(); +export async function startHeadscale( + version: string, + options?: HeadscaleOptions, +): Promise { + let builder = new tc.GenericContainer(`headscale/headscale:${version}`) + .withExposedPorts(8080) + .withWaitStrategy( + tc.Wait.forHttp("/health", 8080).withStartupTimeout(30_000).forStatusCode(200), + ) + .withCopyFilesToContainer([ + { + source: config, + target: "/etc/headscale/config.yaml", + }, + ]) + .withCommand(["serve"]); - const host = container.getHost(); - const port = container.getMappedPort(8080); - const apiUrl = `http://${host}:${port}`; + if (options?.labels) { + builder = builder.withLabels(options.labels); + } - const exec = await container.exec([ - 'headscale', - 'apikeys', - 'create', - '-o', - 'json', - ]); + const container = await builder.start(); - if (exec.exitCode !== 0) { - throw new Error(`headscale apikeys create failed:\n${exec.stderr}`); - } + const host = container.getHost(); + const port = container.getMappedPort(8080); + const apiUrl = `http://${host}:${port}`; - const apiKey = JSON.parse(exec.stdout.toString()); - return { container, apiUrl, apiKey }; + const exec = await container.exec(["headscale", "apikeys", "create", "-o", "json"]); + + if (exec.exitCode !== 0) { + throw new Error(`headscale apikeys create failed:\n${exec.stderr}`); + } + + const apiKey = JSON.parse(exec.stdout.toString()); + return { container, apiUrl, apiKey }; } diff --git a/tests/integration/users.test.ts b/tests/integration/users.test.ts deleted file mode 100644 index 95701fe..0000000 --- a/tests/integration/users.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { getRuntimeClient, HS_VERSIONS } from './setup/env'; - -describe.sequential.for(HS_VERSIONS)('Headscale %s: Users', (version) => { - test('users can be created', async () => { - const client = await getRuntimeClient(version); - const user = await client.createUser('tale@'); - expect(user).toBeDefined(); - expect(user.name).toBe('tale@'); - }); - - test('users can be created with attributes', async () => { - const client = await getRuntimeClient(version); - const user = await client.createUser( - 'test-user@', - 'test-user@example.com', - 'Test User', - 'https://github.com/tale.png', - ); - - expect(user).toBeDefined(); - expect(user.name).toBe('test-user@'); - expect(user.email).toBe('test-user@example.com'); - expect(user.displayName).toBe('Test User'); - expect(user.profilePicUrl).toBe('https://github.com/tale.png'); - }); - - test('users can be listed', async () => { - const client = await getRuntimeClient(version); - const users = await client.getUsers(); - expect(Array.isArray(users)).toBe(true); - expect(users.length).toBeGreaterThanOrEqual(2); - }); - - test('users can be listed by name', async () => { - const client = await getRuntimeClient(version); - const users = await client.getUsers(undefined, 'tale@'); - expect(Array.isArray(users)).toBe(true); - expect(users.length).toBe(1); - expect(users[0].name).toBe('tale@'); - }); - - test('users can be listed by email', async () => { - const client = await getRuntimeClient(version); - const users = await client.getUsers( - undefined, - undefined, - 'test-user@example.com', - ); - expect(Array.isArray(users)).toBe(true); - expect(users.length).toBe(1); - expect(users[0].email).toBe('test-user@example.com'); - }); - - test('users can be renamed', async () => { - const client = await getRuntimeClient(version); - const usersBefore = await client.getUsers(undefined, 'tale@'); - expect(usersBefore.length).toBe(1); - const user = usersBefore[0]; - - await client.renameUser(user.id, 'renamed-user@'); - const usersAfter = await client.getUsers(undefined, 'renamed-user@'); - expect(usersAfter.length).toBe(1); - expect(usersAfter[0].id).toBe(user.id); - }); - - test('users can be deleted', async () => { - const client = await getRuntimeClient(version); - const usersBefore = await client.getUsers(undefined, 'test-user@'); - expect(usersBefore.length).toBe(1); - const user = usersBefore[0]; - - await client.deleteUser(user.id); - const usersAfter = await client.getUsers(undefined, 'test-user@'); - expect(usersAfter.length).toBe(0); - }); -}); diff --git a/tests/integration/versions.test.ts b/tests/integration/versions.test.ts deleted file mode 100644 index f11528d..0000000 --- a/tests/integration/versions.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import canonicals from '~/openapi-canonical-families.json'; -import { - getBootstrapClient, - getRuntimeClient, - HS_VERSIONS, - Version, -} from './setup/env'; - -function getCanonicalVersion(version: Version) { - const canonical = Object.entries(canonicals).find(([_, family]) => - family.includes(version), - )?.[0] as Version | undefined; - - if (!canonical) { - return version; - } - - return canonical; -} - -describe.for(HS_VERSIONS)('Headscale %s: Runtime Client', (version) => { - test('the runtime client is usable', async () => { - const bootstrapper = await getBootstrapClient(version); - const runtimeClient = bootstrapper.getRuntimeClient('test-api-key'); - expect(runtimeClient).toBeDefined(); - }); - - test('the runtime client has the correct canonical API version', async () => { - const bootstrapper = await getBootstrapClient(version); - expect(bootstrapper.apiVersion).toBe(getCanonicalVersion(version)); - }); - - test('the health check endpoint works', async () => { - const client = await getRuntimeClient(version); - const health = await client.isHealthy(); - expect(health).toBe(true); - }); -}); diff --git a/vitest.config.ts b/vitest.config.ts index d892bf8..952c11e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,54 +1,63 @@ -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { defineConfig } from 'vitest/config'; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; -const prefix = process.env.__INTERNAL_PREFIX || '/admin'; -if (prefix.endsWith('/')) { - throw new Error('Prefix must not end with a slash'); +import { defineConfig } from "vitest/config"; + +const prefix = process.env.__INTERNAL_PREFIX || "/admin"; +if (prefix.endsWith("/")) { + throw new Error("Prefix must not end with a slash"); } // Load the version via package.json -const pkg = await readFile('package.json', 'utf-8'); -const isNext = process.env.IMAGE_TAG?.includes('next'); +const pkg = await readFile("package.json", "utf-8"); +const isNext = process.env.IMAGE_TAG?.includes("next"); const { version } = JSON.parse(pkg); if (!version) { - throw new Error('Unable to read version from package.json'); + throw new Error("Unable to read version from package.json"); } export default defineConfig({ - test: { - projects: [ - { - extends: true, - test: { - name: 'unit', - include: ['tests/unit/**/*.test.ts'], - setupFiles: ['tests/unit/setup/overlay-fs.ts'], - }, - }, - { - extends: true, - test: { - name: 'integration', - include: ['tests/integration/*.test.ts'], - setupFiles: ['tests/integration/setup/vitest-hook.ts'], - testTimeout: 15_000, - }, - }, - ], - env: { - HEADPLANE_DEBUG_LOG: 'true', - }, - environment: 'node', - exclude: ['node_modules/**', 'build/**'], - }, - resolve: { - alias: { - '~': resolve(__dirname, './app'), - }, - }, - define: { - __VERSION__: JSON.stringify(isNext ? `${version}-next` : version), - __PREFIX__: JSON.stringify(prefix), - }, + test: { + projects: [ + { + extends: true, + test: { + name: "unit", + include: ["tests/unit/**/*.test.ts"], + setupFiles: ["tests/unit/setup/overlay-fs.ts"], + }, + }, + { + extends: true, + test: { + name: "integration:api", + include: ["tests/integration/api/**/*.test.ts"], + setupFiles: ["tests/integration/setup/vitest-hook.ts"], + testTimeout: 15_000, + }, + }, + { + extends: true, + test: { + name: "integration:platform", + include: ["tests/integration/platform/**/*.test.ts"], + testTimeout: 60_000, + }, + }, + ], + env: { + HEADPLANE_DEBUG_LOG: "true", + }, + environment: "node", + exclude: ["node_modules/**", "build/**"], + }, + resolve: { + alias: { + "~": resolve(__dirname, "./app"), + }, + }, + define: { + __VERSION__: JSON.stringify(isNext ? `${version}-next` : version), + __PREFIX__: JSON.stringify(prefix), + }, });