diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 61c1e80..981afc7 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -46,7 +46,7 @@ jobs: run: ./build.sh --skip-pnpm-prune - name: Run unit tests - run: pnpm test + run: pnpm run test:unit - name: Run integration tests run: pnpm run test:integration diff --git a/package.json b/package.json index aed96b0..bdc70ce 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "dev": "HEADPLANE_LOAD_ENV_OVERRIDES=true HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev", "start": "node build/server/index.js", "typecheck": "react-router typegen && tsgo", - "test": "vitest run", - "test:integration": "vitest run --mode integration", + "test:unit": "vitest run --project unit", + "test:integration": "vitest run --project integration", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", @@ -56,7 +56,6 @@ "drizzle-orm": "0.44.7", "isbot": "5.1.32", "jose": "6.1.2", - "js-yaml": "^4.1.1", "lefthook": "^2.0.4", "lucide-react": "^0.540.0", "mime": "^4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bab4f81..67c2c3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,9 +122,6 @@ importers: jose: specifier: 6.1.2 version: 6.1.2 - js-yaml: - specifier: ^4.1.1 - version: 4.1.1 lefthook: specifier: ^2.0.4 version: 2.0.4 diff --git a/tests/config.test.js b/tests/unit/config.test.ts similarity index 61% rename from tests/config.test.js rename to tests/unit/config.test.ts index 5ee35a4..cc13f7f 100644 --- a/tests/config.test.js +++ b/tests/unit/config.test.ts @@ -1,48 +1,50 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import yaml from 'js-yaml'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { overlayFS } from './setupOverlayFs.js'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { stringify } from 'yaml'; +import { ConfigError, loadConfig } from '~/server/config/loader'; +import { HeadplaneConfig } from '~/server/config/schema'; +import { clearFakeFiles, createFakeFile } from './setup/overlay-fs'; -// Get the directory name in ESM -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +async function writeTempFile(baseName: string, content: string) { + const dir = await mkdtemp(join(tmpdir(), 'headplane-test-')); -// Helper to create a temp file with given content, return its path -function writeTempFile(baseName, content) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'headplane-test-')); - const filePath = path.join(dir, baseName); - fs.writeFileSync(filePath, content); - return filePath; + const path = join(dir, baseName); + await writeFile(path, content); + return path; } -// Helper to create a temporary YAML config file with minimal required structure -function writeTempYamlConfig(customConfig = {}) { +type RecursivePartial = { + [P in keyof T]?: T[P] extends object ? RecursivePartial : T[P]; +}; + +function writeTempYamlConfig( + customConfig: RecursivePartial | string = {}, +) { const defaultConfig = { debug: false, server: { host: '127.0.0.1', port: 8080, + data_path: '/var/lib/headplane', cookie_secret: '12345678901234567890123456789012', cookie_secure: false, - agent: { - authkey: 'testagentauthkey', - ttl: 180000, - cache_path: '/tmp/agent_cache.json', - }, + cookie_max_age: 86400, }, headscale: { url: 'http://localhost:8081', config_strict: false }, - }; + } satisfies HeadplaneConfig; - function deepMerge(target, source) { + // biome-ignore lint/suspicious/noExplicitAny: I don't care + function deepMerge(target: any, source: any): any { let output = { ...target }; if (isObject(target) && isObject(source)) { output = { ...target, ...source }; for (const key of Object.keys(source)) { - if (isObject(source[key]) && key in target && isObject(target[key])) { - output[key] = deepMerge(target[key], source[key]); + const sourceValue = source[key as keyof typeof source]; + const targetValue = target[key as keyof typeof target]; + if (isObject(sourceValue) && key in target && isObject(targetValue)) { + output[key] = deepMerge(targetValue, sourceValue); } else { output[key] = source[key]; } @@ -73,13 +75,14 @@ function writeTempYamlConfig(customConfig = {}) { return output; } - function isObject(item) { + // biome-ignore lint/suspicious/noExplicitAny: I don't care + function isObject(item: any): item is Record { return item && typeof item === 'object' && !Array.isArray(item); } const merged = deepMerge(defaultConfig, customConfig); const yamlContent = - typeof customConfig === 'string' ? customConfig : yaml.dump(merged); + typeof customConfig === 'string' ? customConfig : stringify(merged); return writeTempFile('config.yaml', yamlContent); } @@ -87,60 +90,43 @@ function writeTempYamlConfig(customConfig = {}) { const originalEnv = { ...process.env }; describe('Configuration Loading', () => { - let loadConfig; - let ConfigError; - - beforeAll(async () => { - const module = await import('../app/server/config/loader.js'); - loadConfig = module.loadConfig; - ConfigError = module.ConfigError; - }); - beforeEach(() => { - // biome-ignore lint/performance/noDelete: needed for test cleanup delete process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH; - // biome-ignore lint/performance/noDelete: needed for test cleanup delete process.env.HEADPLANE_SERVER__COOKIE_SECRET_PATH; - // biome-ignore lint/performance/noDelete: needed for test cleanup delete process.env.HEADPLANE_HEADSCALE__API_KEY_PATH; - // biome-ignore lint/performance/noDelete: needed for test cleanup delete process.env.TEST_SECRET_DIR; - // Clear overlayfs - overlayFS.clear(); - - // Create default files that the config expects - overlayFS.createFile( - '/var/lib/headplane/agent_cache.json', - JSON.stringify({}), - ); - overlayFS.createFile( + clearFakeFiles(); + createFakeFile('/var/lib/headplane/agent_cache.json', JSON.stringify({})); + createFakeFile( '/var/lib/headplane/users.json', `[{"u":"acb3294f89a16b554e06b80d5266a3c8b09a883e1fa78ac459a550bf52a32564","c":65535}]`, ); - overlayFS.createFile('/tmp/agent_cache.json', JSON.stringify({})); - - // Create test files that some tests expect to exist - overlayFS.createFile('irrelevant', 'irrelevant-content'); - overlayFS.createFile('placeholder', 'placeholder-content'); + createFakeFile('/tmp/agent_cache.json', JSON.stringify({})); + createFakeFile('irrelevant', 'irrelevant-content'); + createFakeFile('placeholder', 'placeholder-content'); }); afterAll(() => { Object.assign(process.env, originalEnv); - overlayFS.clear(); + clearFakeFiles(); }); describe('OIDC Configuration', () => { const minimalOidcFields = { - token_endpoint_auth_method: 'client_secret_basic', + token_endpoint_auth_method: 'client_secret_basic' as const, disable_api_key_login: false, headscale_api_key: 'dummyKey', + user_storage_file: '/var/lib/headplane/users.json', + profile_picture_source: 'oidc' as const, + strict_validation: true, + scope: 'openid email profile', }; it('should load client_secret from file specified in client_secret_path', async () => { const secretValue = 'yaml-file-oidc-secret'; - const secretPath = writeTempFile('oidc_secret.txt', secretValue); - const tempConfigPath = writeTempYamlConfig({ + const secretPath = await writeTempFile('oidc_secret.txt', secretValue); + const tempConfigPath = await writeTempYamlConfig({ oidc: { issuer: 'https://example.com/oidc', client_id: 'test', @@ -149,19 +135,19 @@ describe('Configuration Loading', () => { }, }); const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); - expect(config.oidc.client_secret).toBe(secretValue); + expect(config.oidc?.client_secret).toBe(secretValue); }); it('should override YAML client_secret_path with environment variable', async () => { const envValue = 'env-file-oidc-secret'; - const envPath = writeTempFile('env_oidc_secret.txt', envValue); + const envPath = await writeTempFile('env_oidc_secret.txt', envValue); process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = envPath; // Instead of 'irrelevant', use a temp file that exists - const irrelevantPath = writeTempFile( + const irrelevantPath = await writeTempFile( 'irrelevant.txt', 'irrelevant-content', ); - const tempConfigPath = writeTempYamlConfig({ + const tempConfigPath = await writeTempYamlConfig({ oidc: { issuer: 'https://example.com/oidc', client_id: 'test', @@ -170,25 +156,25 @@ describe('Configuration Loading', () => { }, }); const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); - expect(config.oidc.client_secret).toBe(envValue); + expect(config.oidc?.client_secret).toBe(envValue); }); it('should handle environment variable interpolation in client_secret_path', async () => { const value = 'interpolated-secret'; - const dir = fs.mkdtempSync( - path.join(os.tmpdir(), 'headplane-secret-dir-'), - ); - const file = path.join(dir, 'secret.txt'); - fs.writeFileSync(file, value); + const dir = await mkdtemp(join(tmpdir(), 'headplane-secret-dir-')); + const filePath = join(dir, 'secret.txt'); + await writeFile(filePath, value); + process.env.TEST_SECRET_DIR = dir; process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = + // biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation '${TEST_SECRET_DIR}/secret.txt'; // Instead of 'placeholder', use a temp file that exists - const placeholderPath = writeTempFile( + const placeholderPath = await writeTempFile( 'placeholder.txt', 'placeholder-content', ); - const tempConfigPath = writeTempYamlConfig({ + const tempConfigPath = await writeTempYamlConfig({ oidc: { issuer: 'https://example.com/oidc', client_id: 'test', @@ -197,11 +183,11 @@ describe('Configuration Loading', () => { }, }); const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); - expect(config.oidc.client_secret).toBe(value); + expect(config.oidc?.client_secret).toBe(value); }); it('should reject when client_secret_path points to non-existent file', async () => { - const tempConfigPath = writeTempYamlConfig({ + const tempConfigPath = await writeTempYamlConfig({ oidc: { issuer: 'https://example.com/oidc', client_id: 'test', @@ -216,8 +202,9 @@ describe('Configuration Loading', () => { it('should reject when client_secret_path has unresolvable env var interpolation', async () => { process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = + // biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation '${MISSING_DIR}/secret.txt'; - const tempConfigPath = writeTempYamlConfig({ + const tempConfigPath = await writeTempYamlConfig({ oidc: { issuer: 'https://example.com/oidc', client_id: 'test', @@ -234,7 +221,7 @@ describe('Configuration Loading', () => { describe('Server Configuration', () => { it('should load cookie_secret directly from YAML', async () => { const valid = 'abcdefghijklmnopqrstuvwxyz123456'; - const tempConfigPath = writeTempYamlConfig({ + const tempConfigPath = await writeTempYamlConfig({ server: { cookie_secret: valid }, }); const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); @@ -243,8 +230,8 @@ describe('Configuration Loading', () => { it('should load cookie_secret from file', async () => { const secret = 'a'.repeat(32); - const secretPath = writeTempFile('cookie_secret.txt', secret); - const tempConfigPath = writeTempYamlConfig({ + const secretPath = await writeTempFile('cookie_secret.txt', secret); + const tempConfigPath = await writeTempYamlConfig({ server: { cookie_secret_path: secretPath }, }); const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); @@ -252,8 +239,8 @@ describe('Configuration Loading', () => { }); it('should reject when both cookie_secret and cookie_secret_path are in YAML', async () => { - const secretPath = writeTempFile('conflict.txt', 'x'.repeat(32)); - const tempConfigPath = writeTempYamlConfig({ + const secretPath = await writeTempFile('conflict.txt', 'x'.repeat(32)); + const tempConfigPath = await writeTempYamlConfig({ server: { cookie_secret: '1'.repeat(32), cookie_secret_path: secretPath, @@ -262,67 +249,76 @@ describe('Configuration Loading', () => { await expect( loadConfig({ loadEnv: false, path: tempConfigPath }), ).rejects.toThrow( - /Only one of \"cookie_secret\" or \"cookie_secret_path\" may be set/, + /Only one of "cookie_secret" or "cookie_secret_path" may be set/, ); }); it('should reject when neither cookie_secret nor cookie_secret_path is provided', async () => { - const yaml = `debug: false + const yaml = ` +debug: false server: - host: \"127.0.0.1\" + host: "127.0.0.1" port: 8080 cookie_secure: false agent: - authkey: \"key\" + authkey: "key" ttl: 180000 - cache_path: \"/tmp/cache.json\" + cache_path: "/tmp/cache.json" headscale: - url: \"http://localhost\" + url: "http://localhost" config_strict: false `; - const tempConfigPath = writeTempYamlConfig(yaml); + const tempConfigPath = await writeTempYamlConfig(yaml); await expect( loadConfig({ loadEnv: false, path: tempConfigPath }), ).rejects.toThrow( - /Either \"cookie_secret\" or \"cookie_secret_path\" must be provided for cookie_secret/, + /Either "cookie_secret" or "cookie_secret_path" must be provided for cookie_secret/, ); }); }); describe('Headscale Configuration', () => { it('should load headscale_api_key directly from YAML', async () => { - const tempConfigPath = writeTempYamlConfig({ + const tempConfigPath = await writeTempYamlConfig({ oidc: { issuer: 'https://example.com/oidc', client_id: 'test', headscale_api_key: 'hs-yaml-key', token_endpoint_auth_method: 'client_secret_basic', disable_api_key_login: false, + user_storage_file: '/var/lib/headplane/users.json', + profile_picture_source: 'oidc', + strict_validation: true, + scope: 'openid email profile', }, }); const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); - expect(config.oidc.headscale_api_key).toBe('hs-yaml-key'); + expect(config.oidc?.headscale_api_key).toBe('hs-yaml-key'); }); it('should load headscale_api_key from file', async () => { const val = 'hs-file-key'; - const p = writeTempFile('hs_api_key.txt', val); - const tempConfigPath = writeTempYamlConfig({ + const p = await writeTempFile('hs_api_key.txt', val); + const tempConfigPath = await writeTempYamlConfig({ oidc: { issuer: 'https://example.com/oidc', client_id: 'test', headscale_api_key_path: p, token_endpoint_auth_method: 'client_secret_basic', disable_api_key_login: false, + user_storage_file: '/var/lib/headplane/users.json', + profile_picture_source: 'oidc', + strict_validation: true, + scope: 'openid email profile', }, }); const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); - expect(config.oidc.headscale_api_key).toBe(val); + expect(config.oidc?.headscale_api_key).toBe(val); }); it('should reject when both headscale_api_key and headscale_api_key_path are in YAML', async () => { - const p = writeTempFile('conflict.txt', 'irrelevant'); - const tempConfigPath = writeTempYamlConfig({ + const p = await writeTempFile('conflict.txt', 'irrelevant'); + const tempConfigPath = await writeTempYamlConfig({ oidc: { issuer: 'https://example.com/oidc', client_id: 'test', @@ -330,20 +326,25 @@ headscale: headscale_api_key_path: p, token_endpoint_auth_method: 'client_secret_basic', disable_api_key_login: false, + user_storage_file: '/var/lib/headplane/users.json', + profile_picture_source: 'oidc', + strict_validation: true, + scope: 'openid email profile', }, }); await expect( loadConfig({ loadEnv: false, path: tempConfigPath }), ).rejects.toThrow( - /Only one of \"headscale_api_key\" or \"headscale_api_key_path\" may be set/, + /Only one of "headscale_api_key" or "headscale_api_key_path" may be set/, ); }); it('should keep config_path string and interpolate env vars', async () => { process.env.MY_HS_CONFIG_SUBDIR = 'hs-test'; + // biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation const cfgVal = '/etc/headscale-${MY_HS_CONFIG_SUBDIR}/config.yaml'; const exp = '/etc/headscale-hs-test/config.yaml'; - const tempConfigPath = writeTempYamlConfig({ + const tempConfigPath = await writeTempYamlConfig({ headscale: { config_path: cfgVal }, }); const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); diff --git a/tests/unit/setup/overlay-fs.ts b/tests/unit/setup/overlay-fs.ts new file mode 100644 index 0000000..d6c3a0d --- /dev/null +++ b/tests/unit/setup/overlay-fs.ts @@ -0,0 +1,39 @@ +import { vi } from 'vitest'; + +const fakeFs = new Map(); +export function createFakeFile(path: string, content: string) { + fakeFs.set(path, content); +} + +export function clearFakeFiles() { + fakeFs.clear(); +} + +vi.mock(import('node:fs/promises'), async (importOrig) => { + const orig = await importOrig(); + return { + ...orig, + readFile: (path, options) => { + const p = path.toString(); + if (fakeFs.has(p)) { + const content = fakeFs.get(p)!; + if (typeof options === 'string' || options?.encoding) { + return Promise.resolve(content); + } + + return Promise.resolve(Buffer.from(content)); + } + + return orig.readFile.call(this, path, options); + }, + + access: (path, mode) => { + const p = path.toString(); + if (p === '/var/lib/headplane/' || p === '/var/lib/headplane') { + return Promise.resolve(); + } + + return orig.access.call(this, path, mode); + }, + }; +}); diff --git a/vitest.config.ts b/vitest.config.ts index ae73d4c..b38280c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,22 +15,32 @@ if (!version) { throw new Error('Unable to read version from package.json'); } -export default defineConfig(({ mode }) => ({ +export default defineConfig({ test: { + projects: [ + { + extends: true, + test: { + name: 'unit', + include: ['tests/unit/*.test.ts'], + setupFiles: ['tests/unit/setup/overlay-fs.ts'], + }, + }, + { + extends: true, + test: { + name: 'integration', + include: ['tests/integration/*.test.ts'], + setupFiles: ['tests/integration/setup/vitest-hook.ts'], + testTimeout: 15_000, + }, + }, + ], env: { HEADPLANE_DEBUG_LOG: 'true', }, - // bail: mode === 'integration' ? 1 : undefined, environment: 'node', - include: - mode === 'integration' - ? ['tests/integration/*.test.ts'] - : ['tests/**/*.test.js'], exclude: ['node_modules/**', 'build/**'], - setupFiles: - mode === 'integration' - ? ['./tests/integration/setup/vitest-hook.ts'] - : ['./tests/setupOverlayFs.js'], }, resolve: { alias: { @@ -41,4 +51,4 @@ export default defineConfig(({ mode }) => ({ __VERSION__: JSON.stringify(isNext ? `${version}-next` : version), __PREFIX__: JSON.stringify(prefix), }, -})); +});