mirror of
https://github.com/tale/headplane.git
synced 2026-08-28 16:07:07 +00:00
feat: add e2e integration tests and canonical versioning
This commit is contained in:
@@ -45,9 +45,12 @@ jobs:
|
|||||||
- name: CI pipeline
|
- name: CI pipeline
|
||||||
run: ./build.sh --skip-pnpm-prune
|
run: ./build.sh --skip-pnpm-prune
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run unit tests
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
run: pnpm run test:integration
|
||||||
|
|
||||||
nix:
|
nix:
|
||||||
name: nix
|
name: nix
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import { StrictMode, startTransition } from 'react';
|
|||||||
import { hydrateRoot } from 'react-dom/client';
|
import { hydrateRoot } from 'react-dom/client';
|
||||||
import { HydratedRouter } from 'react-router/dom';
|
import { HydratedRouter } from 'react-router/dom';
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
import('react-scan').then(({ scan }) => {
|
|
||||||
scan({ enabled: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
hydrateRoot(
|
hydrateRoot(
|
||||||
document,
|
document,
|
||||||
|
|||||||
@@ -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) => ({
|
export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({
|
||||||
getApiKeys: async () => {
|
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[] }>(
|
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>(
|
||||||
'GET',
|
'GET',
|
||||||
'v1/apikey',
|
'v1/apikey',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import canonicals from '~/openapi-canonical-families.json';
|
||||||
import hashes from '~/openapi-operation-hashes.json';
|
import hashes from '~/openapi-operation-hashes.json';
|
||||||
import log from '~/utils/log';
|
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
@@ -11,9 +11,11 @@
|
|||||||
"start": "node build/server/index.js",
|
"start": "node build/server/index.js",
|
||||||
"typecheck": "react-router typegen && tsgo",
|
"typecheck": "react-router typegen && tsgo",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:integration": "vitest run --mode integration",
|
||||||
"docs:dev": "vitepress dev docs",
|
"docs:dev": "vitepress dev docs",
|
||||||
"docs:build": "vitepress build 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": {
|
"dependencies": {
|
||||||
"@libsql/client": "0.15.15"
|
"@libsql/client": "0.15.15"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { request } from 'undici';
|
|||||||
import { hashOpenApiDocument } from '~/server/headscale/api/hasher';
|
import { hashOpenApiDocument } from '~/server/headscale/api/hasher';
|
||||||
|
|
||||||
const HASH_FILE_LOCATION = 'app/openapi-operation-hashes.json';
|
const HASH_FILE_LOCATION = 'app/openapi-operation-hashes.json';
|
||||||
|
const CANONICAL_LOCATION = 'app/openapi-canonical-families.json';
|
||||||
|
|
||||||
const SPEC_MAP = {
|
const SPEC_MAP = {
|
||||||
// '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
// '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',
|
// '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);
|
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>>) {
|
async function writeHashes(hashes: Record<string, Record<string, string>>) {
|
||||||
const path = resolve(cwd(), HASH_FILE_LOCATION);
|
const path = resolve(cwd(), HASH_FILE_LOCATION);
|
||||||
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, 'utf-8');
|
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(
|
const newHashes = await Promise.all(
|
||||||
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
|
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
|
||||||
const hashes = await hashOpenApiOperations(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');
|
console.log('Writing new OpenAPI operation hashes to file');
|
||||||
await writeHashes(Object.fromEntries(newHashes));
|
await writeHashes(Object.fromEntries(newHashes));
|
||||||
|
await writeCanonicalizedFamilies(canonicalizedFamilies);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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
|
||||||
@@ -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');
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -1,16 +1,44 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { defineConfig } from 'vitest/config';
|
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: {
|
test: {
|
||||||
|
env: {
|
||||||
|
HEADPLANE_DEBUG_LOG: 'true',
|
||||||
|
},
|
||||||
|
// bail: mode === 'integration' ? 1 : undefined,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/**/*.test.js'],
|
include:
|
||||||
|
mode === 'integration'
|
||||||
|
? ['tests/integration/*.test.ts']
|
||||||
|
: ['tests/**/*.test.js'],
|
||||||
exclude: ['node_modules/**', 'build/**'],
|
exclude: ['node_modules/**', 'build/**'],
|
||||||
setupFiles: ['./tests/setupOverlayFs.js'],
|
setupFiles:
|
||||||
|
mode === 'integration'
|
||||||
|
? ['./tests/integration/setup/vitest-hook.ts']
|
||||||
|
: ['./tests/setupOverlayFs.js'],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'~': resolve(__dirname, './app'),
|
'~': resolve(__dirname, './app'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
define: {
|
||||||
|
__VERSION__: JSON.stringify(isNext ? `${version}-next` : version),
|
||||||
|
__PREFIX__: JSON.stringify(prefix),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user