From d363ed548642f092c505821df9dedee9df4aa95a Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Wed, 3 Dec 2025 19:05:54 -0500 Subject: [PATCH] chore: add tests for nodes --- tests/integration/nodes.test.ts | 70 ++++++++++++++++++ tests/integration/setup/env.ts | 23 +++++- tests/integration/setup/start-headscale.ts | 2 - tests/integration/setup/start-tailscale.ts | 85 ++++++++++++++++++++++ 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 tests/integration/nodes.test.ts create mode 100644 tests/integration/setup/start-tailscale.ts diff --git a/tests/integration/nodes.test.ts b/tests/integration/nodes.test.ts new file mode 100644 index 0000000..f7170bb --- /dev/null +++ b/tests/integration/nodes.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'vitest'; +import { getNode, getRuntimeClient, HS_VERSIONS } from './setup/env'; + +describe.sequential.for(HS_VERSIONS)('Headscale %s: Users', (version) => { + let workingNodeId: string; + + test('nodes can register via their nodekey', async () => { + const client = await getRuntimeClient(version); + const tailnetNode = await getNode(version); + + const user = await client.createUser('node-reg@'); + const node = await client.registerNode(user.name, tailnetNode.authCode); + expect(node).toBeDefined(); + expect(node.registerMethod).toBe('REGISTER_METHOD_CLI'); + expect(node.name).toBe(tailnetNode.nodeName); + }); + + test('nodes can be retrieved', async () => { + const client = await getRuntimeClient(version); + const { nodeName } = await getNode(version); + const nodes = await client.getNodes(); + const node = nodes.find((n) => n.name === nodeName); + expect(node).toBeDefined(); + expect(node?.name).toBe(nodeName); + + const fetchedNode = await client.getNode(node!.id); + expect(fetchedNode).toBeDefined(); + expect(fetchedNode.id).toBe(node!.id); + workingNodeId = node!.id; + }); + + test('nodes can be renamed', async () => { + const client = await getRuntimeClient(version); + const { nodeName } = await getNode(version); + const newName = `${nodeName}-renamed`; + + await client.renameNode(workingNodeId, newName); + const renamedNode = await client.getNode(workingNodeId); + expect(renamedNode).toBeDefined(); + expect(renamedNode.givenName).toBe(newName); + }); + + test('nodes can be reassigned to another user', async () => { + const client = await getRuntimeClient(version); + const user = await client.createUser('node-reassign@'); + + await client.setNodeUser(workingNodeId, user.id); + const reassignedNode = await client.getNode(workingNodeId); + expect(reassignedNode).toBeDefined(); + expect(reassignedNode.user.name).toBe(user.name); + }); + + test('nodes can be expired', async () => { + const client = await getRuntimeClient(version); + await client.expireNode(workingNodeId); + + const expiredNode = await client.getNode(workingNodeId); + expect(expiredNode).toBeDefined(); + expect(expiredNode.expiry).toBeDefined(); + }); + + test('nodes can be deleted', async () => { + const client = await getRuntimeClient(version); + await client.deleteNode(workingNodeId); + + const nodes = await client.getNodes(); + const node = nodes.find((n) => n.id === workingNodeId); + expect(node).toBeUndefined(); + }); +}); diff --git a/tests/integration/setup/env.ts b/tests/integration/setup/env.ts index 816f014..1185128 100644 --- a/tests/integration/setup/env.ts +++ b/tests/integration/setup/env.ts @@ -4,12 +4,14 @@ import { type HeadscaleApiInterface, } from '~/server/headscale/api'; import { type HeadscaleEnv, startHeadscale } from './start-headscale'; +import { startTailscaleNode, TailscaleNodeEnv } from './start-tailscale'; export type Version = keyof typeof hashes; export const HS_VERSIONS = Object.keys(hashes) as Version[]; interface VersionStateEntry { env: HeadscaleEnv; + tailscaleNode: TailscaleNodeEnv; bootstrap: HeadscaleApiInterface; } @@ -20,9 +22,13 @@ async function ensureVersion(version: Version) { } const env = await startHeadscale(version); + const tailscaleNode = await startTailscaleNode( + version, + env.container.getMappedPort(8080), + ); const bootstrap = await createHeadscaleInterface(env.apiUrl); - const entry = { env, bootstrap }; + const entry = { env, tailscaleNode, bootstrap }; versionState.set(version, entry); return entry; } @@ -37,12 +43,25 @@ export async function getRuntimeClient(version: Version) { return bootstrap.getRuntimeClient(env.apiKey); } +export async function getNode(version: Version) { + const { tailscaleNode } = await ensureVersion(version); + return { + authCode: tailscaleNode.authCode, + nodeName: tailscaleNode.nodeName, + }; +} + export async function stopAllVersions() { - for (const { env } of versionState.values()) { + for (const { env, tailscaleNode } of versionState.values()) { await env.container.stop({ remove: true, removeVolumes: true, }); + + await tailscaleNode.container.stop({ + remove: true, + removeVolumes: true, + }); } versionState.clear(); diff --git a/tests/integration/setup/start-headscale.ts b/tests/integration/setup/start-headscale.ts index 0fe8a96..7dc752c 100644 --- a/tests/integration/setup/start-headscale.ts +++ b/tests/integration/setup/start-headscale.ts @@ -49,5 +49,3 @@ export async function startHeadscale(version: string): Promise { const apiKey = JSON.parse(exec.stdout.toString()); return { container, apiUrl, apiKey }; } - -// await startHeadscale('0.26.0'); diff --git a/tests/integration/setup/start-tailscale.ts b/tests/integration/setup/start-tailscale.ts new file mode 100644 index 0000000..c592d9b --- /dev/null +++ b/tests/integration/setup/start-tailscale.ts @@ -0,0 +1,85 @@ +import { createInterface } from 'node:readline'; +import tc from 'testcontainers'; +import hashes from '~/openapi-operation-hashes.json'; + +export type Version = keyof typeof hashes; + +export interface TailscaleNodeEnv { + container: tc.StartedTestContainer; + authCode: string; + nodeName: string; +} + +export async function startTailscaleNode( + version: Version, + headscalePort: number, +): Promise { + let resolveAuthCode!: (code: string) => void; + let rejectAuthCode!: (err: Error) => void; + let authCodeResolved = false; + + const authCodePromise = new Promise((resolve, reject) => { + resolveAuthCode = resolve; + rejectAuthCode = reject; + }); + + const nodeName = `test-node-${version.replace(/\./g, '-')}`; + const prefix = `http://localhost:8080/register/`; + const container = await new tc.GenericContainer('tailscale/tailscale:latest') + .withNetworkMode('host') + .withEnvironment({ + TS_STATE_DIR: '/tailscale-state', + TS_EXTRA_ARGS: [ + `--login-server=http://localhost:${headscalePort}`, + `--hostname=${nodeName}`, + '--accept-dns=false', + '--accept-routes=false', + ].join(' '), + }) + .withLogConsumer((stream) => { + const rl = createInterface({ input: stream }); + + rl.on('line', (line: string) => { + if (authCodeResolved) return; + const idx = line.indexOf(prefix); + if (idx === -1) return; + + const after = line.slice(idx + prefix.length).trim(); + if (!after) return; + + const token = after.split(/\s+/)[0]; + if (!token) return; + + authCodeResolved = true; + resolveAuthCode(token); + rl.close(); + }); + + rl.on('close', () => { + if (!authCodeResolved) { + rejectAuthCode( + new Error( + 'Tailscale container log stream closed before auth code was found', + ), + ); + } + }); + }) + .withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000)) + .start(); + + const authCode = await Promise.race([ + authCodePromise, + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error(`Timed out waiting for Tailscale auth URL on ${prefix}`), + ), + 25_000, + ), + ), + ]); + + return { container, authCode, nodeName }; +}