mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 15:37:04 +00:00
chore: add tests for nodes
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
type HeadscaleApiInterface,
|
type HeadscaleApiInterface,
|
||||||
} from '~/server/headscale/api';
|
} from '~/server/headscale/api';
|
||||||
import { type HeadscaleEnv, startHeadscale } from './start-headscale';
|
import { type HeadscaleEnv, startHeadscale } from './start-headscale';
|
||||||
|
import { startTailscaleNode, TailscaleNodeEnv } from './start-tailscale';
|
||||||
|
|
||||||
export type Version = keyof typeof hashes;
|
export type Version = keyof typeof hashes;
|
||||||
export const HS_VERSIONS = Object.keys(hashes) as Version[];
|
export const HS_VERSIONS = Object.keys(hashes) as Version[];
|
||||||
|
|
||||||
interface VersionStateEntry {
|
interface VersionStateEntry {
|
||||||
env: HeadscaleEnv;
|
env: HeadscaleEnv;
|
||||||
|
tailscaleNode: TailscaleNodeEnv;
|
||||||
bootstrap: HeadscaleApiInterface;
|
bootstrap: HeadscaleApiInterface;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,9 +22,13 @@ async function ensureVersion(version: Version) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const env = await startHeadscale(version);
|
const env = await startHeadscale(version);
|
||||||
|
const tailscaleNode = await startTailscaleNode(
|
||||||
|
version,
|
||||||
|
env.container.getMappedPort(8080),
|
||||||
|
);
|
||||||
const bootstrap = await createHeadscaleInterface(env.apiUrl);
|
const bootstrap = await createHeadscaleInterface(env.apiUrl);
|
||||||
|
|
||||||
const entry = { env, bootstrap };
|
const entry = { env, tailscaleNode, bootstrap };
|
||||||
versionState.set(version, entry);
|
versionState.set(version, entry);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
@@ -37,12 +43,25 @@ export async function getRuntimeClient(version: Version) {
|
|||||||
return bootstrap.getRuntimeClient(env.apiKey);
|
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() {
|
export async function stopAllVersions() {
|
||||||
for (const { env } of versionState.values()) {
|
for (const { env, tailscaleNode } of versionState.values()) {
|
||||||
await env.container.stop({
|
await env.container.stop({
|
||||||
remove: true,
|
remove: true,
|
||||||
removeVolumes: true,
|
removeVolumes: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tailscaleNode.container.stop({
|
||||||
|
remove: true,
|
||||||
|
removeVolumes: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
versionState.clear();
|
versionState.clear();
|
||||||
|
|||||||
@@ -49,5 +49,3 @@ export async function startHeadscale(version: string): Promise<HeadscaleEnv> {
|
|||||||
const apiKey = JSON.parse(exec.stdout.toString());
|
const apiKey = JSON.parse(exec.stdout.toString());
|
||||||
return { container, apiUrl, apiKey };
|
return { container, apiUrl, apiKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
// await startHeadscale('0.26.0');
|
|
||||||
|
|||||||
@@ -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<TailscaleNodeEnv> {
|
||||||
|
let resolveAuthCode!: (code: string) => void;
|
||||||
|
let rejectAuthCode!: (err: Error) => void;
|
||||||
|
let authCodeResolved = false;
|
||||||
|
|
||||||
|
const authCodePromise = new Promise<string>((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<string>([
|
||||||
|
authCodePromise,
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
reject(
|
||||||
|
new Error(`Timed out waiting for Tailscale auth URL on ${prefix}`),
|
||||||
|
),
|
||||||
|
25_000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { container, authCode, nodeName };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user