feat: add e2e integration tests and canonical versioning

This commit is contained in:
Aarnav Tale
2025-11-16 18:31:09 -05:00
parent 63d1e84ebe
commit 08a251cc8c
16 changed files with 536 additions and 17 deletions
+4 -1
View File
@@ -45,9 +45,12 @@ jobs:
- name: CI pipeline
run: ./build.sh --skip-pnpm-prune
- name: Run tests
- name: Run unit tests
run: pnpm test
- name: Run integration tests
run: pnpm run test:integration
nix:
name: nix
runs-on: ubuntu-latest
-6
View File
@@ -2,12 +2,6 @@ import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
if (import.meta.env.DEV) {
import('react-scan').then(({ scan }) => {
scan({ enabled: true });
});
}
startTransition(() => {
hydrateRoot(
document,
+4
View File
@@ -0,0 +1,4 @@
{
"0.26.1": ["0.26.0", "0.26.1"],
"0.27.0": ["0.27.0"]
}
@@ -12,10 +12,6 @@ export interface ApiKeyEndpoints {
export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({
getApiKeys: async () => {
if (client.isAtleast('0.27.0')) {
console.log('wow we are at least 0.27.0!');
}
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>(
'GET',
'v1/apikey',
+25 -1
View File
@@ -1,3 +1,4 @@
import canonicals from '~/openapi-canonical-families.json';
import hashes from '~/openapi-operation-hashes.json';
import log from '~/utils/log';
@@ -64,7 +65,30 @@ export function detectApiVersion(
);
}
return bestVersion;
const canonical = Object.entries(canonicals).find(([_, family]) =>
family.includes(bestVersion),
)?.[0] as Version | undefined;
if (!canonical) {
log.warn(
'api',
'Could not canonicalize detected version %s, using as-is',
bestVersion,
);
return bestVersion;
}
if (canonical !== bestVersion) {
log.info(
'api',
'Canonicalizing detected version %s → %s (same schema)',
bestVersion,
canonical,
);
}
return canonical;
}
/**
+3 -1
View File
@@ -11,9 +11,11 @@
"start": "node build/server/index.js",
"typecheck": "react-router typegen && tsgo",
"test": "vitest run",
"test:integration": "vitest run --mode integration",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
"docs:preview": "vitepress preview docs",
"gen:hashes": "tsx tests/generate-openapi-hashes.ts"
},
"dependencies": {
"@libsql/client": "0.15.15"
+55
View File
@@ -6,6 +6,8 @@ import { request } from 'undici';
import { hashOpenApiDocument } from '~/server/headscale/api/hasher';
const HASH_FILE_LOCATION = 'app/openapi-operation-hashes.json';
const CANONICAL_LOCATION = 'app/openapi-canonical-families.json';
const SPEC_MAP = {
// '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
// '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
@@ -26,11 +28,61 @@ async function hashOpenApiOperations(specUrl: string) {
return hashOpenApiDocument(body);
}
async function collectCanonicalizedFamilies(
newHashes: readonly (readonly [string, Record<string, string>])[],
) {
const canonicalizedFamilies: Record<string, string[]> = {};
for (const [version, hashes] of newHashes) {
const signature = JSON.stringify(hashes);
let canonical: string | null = null;
for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
const existingSignature = JSON.stringify(
newHashes.find(([v]) => v === existingCanonical)![1],
);
if (existingSignature === signature) {
canonical = existingCanonical;
break;
}
}
if (!canonical) {
canonicalizedFamilies[version] = [version];
continue;
}
canonicalizedFamilies[canonical].push(version);
if (
version.localeCompare(canonical, undefined, {
numeric: true,
sensitivity: 'base',
}) > 0
) {
canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
delete canonicalizedFamilies[canonical];
}
}
for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
canonicalizedFamilies[canonical] = family.sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }),
);
}
return canonicalizedFamilies;
}
async function writeHashes(hashes: Record<string, Record<string, string>>) {
const path = resolve(cwd(), HASH_FILE_LOCATION);
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, 'utf-8');
}
async function writeCanonicalizedFamilies(families: Record<string, string[]>) {
const path = resolve(cwd(), CANONICAL_LOCATION);
await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, 'utf-8');
}
const newHashes = await Promise.all(
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
const hashes = await hashOpenApiOperations(specUrl);
@@ -38,5 +90,8 @@ const newHashes = await Promise.all(
}),
);
const canonicalizedFamilies = await collectCanonicalizedFamilies(newHashes);
console.log('Writing new OpenAPI operation hashes to file');
await writeHashes(Object.fromEntries(newHashes));
await writeCanonicalizedFamilies(canonicalizedFamilies);
+11
View File
@@ -0,0 +1,11 @@
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);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, test } from 'vitest';
import { 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 () => {
const client = await getRuntimeClient(version);
const preAuthKeyUser = await client.createUser('preauthkeyuser@');
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe('preauthkeyuser@');
const expiry = new Date(Date.now() + 3600 * 1000);
const preAuthKey = await client.createPreAuthKey(
preAuthKeyUser.id,
false,
false,
expiry,
null,
);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(false);
expect(preAuthKey.reusable).toBe(false);
expect(preAuthKey.aclTags).toEqual([]);
expect(new Date(preAuthKey.expiration).getTime()).toBeCloseTo(
expiry.getTime(),
-2,
);
});
test('pre-auth keys can be created with ACL tags', async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(
undefined,
'preauthkeyuser@',
);
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe('preauthkeyuser@');
const aclTags = ['tag:test1', 'tag:test2'];
const preAuthKey = await client.createPreAuthKey(
preAuthKeyUser.id,
true,
true,
null,
aclTags,
);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(true);
expect(preAuthKey.reusable).toBe(true);
expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort());
});
test('pre-auth keys can be listed', async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(
undefined,
'preauthkeyuser@',
);
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe('preauthkeyuser@');
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
expect(Array.isArray(preAuthKeys)).toBe(true);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
});
test('pre-auth keys can be expired', async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(
undefined,
'preauthkeyuser@',
);
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe('preauthkeyuser@');
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
const preAuthKeyToExpire = preAuthKeys[0];
await client.expirePreAuthKey(preAuthKeyUser.id, preAuthKeyToExpire.key);
const preAuthKeysAfterExpire = await client.getPreAuthKeys(
preAuthKeyUser.id,
);
const expiredKey = preAuthKeysAfterExpire.find(
(key) => key.key === preAuthKeyToExpire.key,
);
expect(expiredKey).toBeDefined();
expect(new Date(expiredKey!.expiration).getTime()).toBeLessThanOrEqual(
Date.now(),
);
});
},
);
+74
View File
@@ -0,0 +1,74 @@
server_url: http://localhost:8080
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
derp:
server:
enabled: false
region_id: 999
region_code: "headscale"
region_name: "Headscale Embedded DERP"
verify_clients: true
stun_listen_addr: "0.0.0.0:3478"
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
ipv4: 198.51.100.1
ipv6: 2001:db8::1
urls:
- https://controlplane.tailscale.com/derpmap/default
paths: []
auto_update_enabled: true
update_frequency: 3h
disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m
database:
type: sqlite
debug: false
gorm:
prepare_stmt: true
parameterized_queries: true
skip_err_record_not_found: true
slow_threshold: 1000
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
wal_autocheckpoint: 1000
acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: ""
tls_letsencrypt_hostname: ""
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ":http"
tls_cert_path: ""
tls_key_path: ""
log:
level: info
format: text
policy:
mode: database
path: ""
dns:
magic_dns: true
base_domain: example.com
override_local_dns: true
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
- 2606:4700:4700::1111
- 2606:4700:4700::1001
split: {}
search_domains: []
extra_records: []
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
randomize_client_port: false
+49
View File
@@ -0,0 +1,49 @@
import hashes from '~/openapi-operation-hashes.json';
import {
createHeadscaleInterface,
type HeadscaleApiInterface,
} from '~/server/headscale/api';
import { type HeadscaleEnv, startHeadscale } from './start-headscale';
export type Version = keyof typeof hashes;
export const HS_VERSIONS = Object.keys(hashes) as Version[];
interface VersionStateEntry {
env: HeadscaleEnv;
bootstrap: HeadscaleApiInterface;
}
const versionState = new Map<Version, VersionStateEntry>();
async function ensureVersion(version: Version) {
if (versionState.has(version)) {
return versionState.get(version)!;
}
const env = await startHeadscale(version);
const bootstrap = await createHeadscaleInterface(env.apiUrl);
const entry = { env, bootstrap };
versionState.set(version, entry);
return entry;
}
export async function getBootstrapClient(version: Version) {
const { bootstrap } = await ensureVersion(version);
return bootstrap;
}
export async function getRuntimeClient(version: Version) {
const { env, bootstrap } = await ensureVersion(version);
return bootstrap.getRuntimeClient(env.apiKey);
}
export async function stopAllVersions() {
for (const { env } of versionState.values()) {
await env.container.stop({
remove: true,
removeVolumes: true,
});
}
versionState.clear();
}
@@ -0,0 +1,53 @@
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import tc from 'testcontainers';
export interface HeadscaleEnv {
container: tc.StartedTestContainer;
apiUrl: string;
apiKey: string;
}
const cwd = fileURLToPath(import.meta.url);
const config = join(cwd, '..', 'config.yaml');
export async function startHeadscale(version: string): Promise<HeadscaleEnv> {
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();
const host = container.getHost();
const port = container.getMappedPort(8080);
const apiUrl = `http://${host}:${port}`;
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 };
}
// await startHeadscale('0.26.0');
+11
View File
@@ -0,0 +1,11 @@
import { getBootstrapClient, HS_VERSIONS, stopAllVersions } from './env';
export async function setup() {
for (const version of HS_VERSIONS) {
await getBootstrapClient(version);
}
}
export async function teardown() {
await stopAllVersions();
}
+77
View File
@@ -0,0 +1,77 @@
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);
});
});
+39
View File
@@ -0,0 +1,39 @@
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);
});
});
+32 -4
View File
@@ -1,16 +1,44 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
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 { version } = JSON.parse(pkg);
if (!version) {
throw new Error('Unable to read version from package.json');
}
export default defineConfig(({ mode }) => ({
test: {
env: {
HEADPLANE_DEBUG_LOG: 'true',
},
// bail: mode === 'integration' ? 1 : undefined,
environment: 'node',
include: ['tests/**/*.test.js'],
include:
mode === 'integration'
? ['tests/integration/*.test.ts']
: ['tests/**/*.test.js'],
exclude: ['node_modules/**', 'build/**'],
setupFiles: ['./tests/setupOverlayFs.js'],
setupFiles:
mode === 'integration'
? ['./tests/integration/setup/vitest-hook.ts']
: ['./tests/setupOverlayFs.js'],
},
resolve: {
alias: {
'~': resolve(__dirname, './app'),
},
},
});
define: {
__VERSION__: JSON.stringify(isNext ? `${version}-next` : version),
__PREFIX__: JSON.stringify(prefix),
},
}));