diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 015eff9..61c1e80 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -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 diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 2ef0f24..9023e70 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -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, diff --git a/app/openapi-canonical-families.json b/app/openapi-canonical-families.json new file mode 100644 index 0000000..5445408 --- /dev/null +++ b/app/openapi-canonical-families.json @@ -0,0 +1,4 @@ +{ + "0.26.1": ["0.26.0", "0.26.1"], + "0.27.0": ["0.27.0"] +} diff --git a/app/server/headscale/api/endpoints/api-keys.ts b/app/server/headscale/api/endpoints/api-keys.ts index 3da564d..8e9090f 100644 --- a/app/server/headscale/api/endpoints/api-keys.ts +++ b/app/server/headscale/api/endpoints/api-keys.ts @@ -12,10 +12,6 @@ export interface ApiKeyEndpoints { export default defineApiEndpoints((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', diff --git a/app/server/headscale/api/version.ts b/app/server/headscale/api/version.ts index 9f45bc5..6fe130e 100644 --- a/app/server/headscale/api/version.ts +++ b/app/server/headscale/api/version.ts @@ -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; } /** diff --git a/package.json b/package.json index 1605b49..aed96b0 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/tests/generate-openapi-hashes.ts b/tests/generate-openapi-hashes.ts index 1c00e27..fd5d292 100644 --- a/tests/generate-openapi-hashes.ts +++ b/tests/generate-openapi-hashes.ts @@ -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])[], +) { + const canonicalizedFamilies: Record = {}; + 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>) { const path = resolve(cwd(), HASH_FILE_LOCATION); await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, 'utf-8'); } +async function writeCanonicalizedFamilies(families: Record) { + 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); diff --git a/tests/integration/api-keys.test.ts b/tests/integration/api-keys.test.ts new file mode 100644 index 0000000..88fcef5 --- /dev/null +++ b/tests/integration/api-keys.test.ts @@ -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); + }); +}); diff --git a/tests/integration/pre-auth-keys.test.ts b/tests/integration/pre-auth-keys.test.ts new file mode 100644 index 0000000..1792bf7 --- /dev/null +++ b/tests/integration/pre-auth-keys.test.ts @@ -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(), + ); + }); + }, +); diff --git a/tests/integration/setup/config.yaml b/tests/integration/setup/config.yaml new file mode 100644 index 0000000..d06d210 --- /dev/null +++ b/tests/integration/setup/config.yaml @@ -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 diff --git a/tests/integration/setup/env.ts b/tests/integration/setup/env.ts new file mode 100644 index 0000000..816f014 --- /dev/null +++ b/tests/integration/setup/env.ts @@ -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(); +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(); +} diff --git a/tests/integration/setup/start-headscale.ts b/tests/integration/setup/start-headscale.ts new file mode 100644 index 0000000..0fe8a96 --- /dev/null +++ b/tests/integration/setup/start-headscale.ts @@ -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 { + 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'); diff --git a/tests/integration/setup/vitest-hook.ts b/tests/integration/setup/vitest-hook.ts new file mode 100644 index 0000000..32fc5e0 --- /dev/null +++ b/tests/integration/setup/vitest-hook.ts @@ -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(); +} diff --git a/tests/integration/users.test.ts b/tests/integration/users.test.ts new file mode 100644 index 0000000..95701fe --- /dev/null +++ b/tests/integration/users.test.ts @@ -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); + }); +}); diff --git a/tests/integration/versions.test.ts b/tests/integration/versions.test.ts new file mode 100644 index 0000000..f11528d --- /dev/null +++ b/tests/integration/versions.test.ts @@ -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); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a77f53c..ae73d4c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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), + }, +}));