chore: refactor unit tests

This commit is contained in:
Aarnav Tale
2025-11-18 02:11:53 -05:00
parent afd512138b
commit 3d9b6370bf
6 changed files with 161 additions and 115 deletions
+1 -1
View File
@@ -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
+2 -3
View File
@@ -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",
-3
View File
@@ -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
@@ -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<T> = {
[P in keyof T]?: T[P] extends object ? RecursivePartial<T[P]> : T[P];
};
function writeTempYamlConfig(
customConfig: RecursivePartial<HeadplaneConfig> | 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<string, unknown> {
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 });
+39
View File
@@ -0,0 +1,39 @@
import { vi } from 'vitest';
const fakeFs = new Map<string, string>();
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);
},
};
});
+21 -11
View File
@@ -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),
},
}));
});