mirror of
https://github.com/tale/headplane.git
synced 2026-08-29 16:37:10 +00:00
chore: refactor unit tests
This commit is contained in:
@@ -46,7 +46,7 @@ jobs:
|
|||||||
run: ./build.sh --skip-pnpm-prune
|
run: ./build.sh --skip-pnpm-prune
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm test
|
run: pnpm run test:unit
|
||||||
|
|
||||||
- name: Run integration tests
|
- name: Run integration tests
|
||||||
run: pnpm run test:integration
|
run: pnpm run test:integration
|
||||||
|
|||||||
+2
-3
@@ -10,8 +10,8 @@
|
|||||||
"dev": "HEADPLANE_LOAD_ENV_OVERRIDES=true HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
|
"dev": "HEADPLANE_LOAD_ENV_OVERRIDES=true HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
|
||||||
"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:unit": "vitest run --project unit",
|
||||||
"test:integration": "vitest run --mode integration",
|
"test:integration": "vitest run --project 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",
|
||||||
@@ -56,7 +56,6 @@
|
|||||||
"drizzle-orm": "0.44.7",
|
"drizzle-orm": "0.44.7",
|
||||||
"isbot": "5.1.32",
|
"isbot": "5.1.32",
|
||||||
"jose": "6.1.2",
|
"jose": "6.1.2",
|
||||||
"js-yaml": "^4.1.1",
|
|
||||||
"lefthook": "^2.0.4",
|
"lefthook": "^2.0.4",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"mime": "^4.1.0",
|
"mime": "^4.1.0",
|
||||||
|
|||||||
Generated
-3
@@ -122,9 +122,6 @@ importers:
|
|||||||
jose:
|
jose:
|
||||||
specifier: 6.1.2
|
specifier: 6.1.2
|
||||||
version: 6.1.2
|
version: 6.1.2
|
||||||
js-yaml:
|
|
||||||
specifier: ^4.1.1
|
|
||||||
version: 4.1.1
|
|
||||||
lefthook:
|
lefthook:
|
||||||
specifier: ^2.0.4
|
specifier: ^2.0.4
|
||||||
version: 2.0.4
|
version: 2.0.4
|
||||||
|
|||||||
@@ -1,48 +1,50 @@
|
|||||||
import fs from 'node:fs';
|
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import yaml from 'js-yaml';
|
import { stringify } from 'yaml';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
import { ConfigError, loadConfig } from '~/server/config/loader';
|
||||||
import { overlayFS } from './setupOverlayFs.js';
|
import { HeadplaneConfig } from '~/server/config/schema';
|
||||||
|
import { clearFakeFiles, createFakeFile } from './setup/overlay-fs';
|
||||||
|
|
||||||
// Get the directory name in ESM
|
async function writeTempFile(baseName: string, content: string) {
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const dir = await mkdtemp(join(tmpdir(), 'headplane-test-'));
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
// Helper to create a temp file with given content, return its path
|
const path = join(dir, baseName);
|
||||||
function writeTempFile(baseName, content) {
|
await writeFile(path, content);
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'headplane-test-'));
|
return path;
|
||||||
const filePath = path.join(dir, baseName);
|
|
||||||
fs.writeFileSync(filePath, content);
|
|
||||||
return filePath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create a temporary YAML config file with minimal required structure
|
type RecursivePartial<T> = {
|
||||||
function writeTempYamlConfig(customConfig = {}) {
|
[P in keyof T]?: T[P] extends object ? RecursivePartial<T[P]> : T[P];
|
||||||
|
};
|
||||||
|
|
||||||
|
function writeTempYamlConfig(
|
||||||
|
customConfig: RecursivePartial<HeadplaneConfig> | string = {},
|
||||||
|
) {
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
debug: false,
|
debug: false,
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 8080,
|
port: 8080,
|
||||||
|
data_path: '/var/lib/headplane',
|
||||||
cookie_secret: '12345678901234567890123456789012',
|
cookie_secret: '12345678901234567890123456789012',
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
agent: {
|
cookie_max_age: 86400,
|
||||||
authkey: 'testagentauthkey',
|
|
||||||
ttl: 180000,
|
|
||||||
cache_path: '/tmp/agent_cache.json',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
headscale: { url: 'http://localhost:8081', config_strict: false },
|
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 };
|
let output = { ...target };
|
||||||
if (isObject(target) && isObject(source)) {
|
if (isObject(target) && isObject(source)) {
|
||||||
output = { ...target, ...source };
|
output = { ...target, ...source };
|
||||||
for (const key of Object.keys(source)) {
|
for (const key of Object.keys(source)) {
|
||||||
if (isObject(source[key]) && key in target && isObject(target[key])) {
|
const sourceValue = source[key as keyof typeof source];
|
||||||
output[key] = deepMerge(target[key], source[key]);
|
const targetValue = target[key as keyof typeof target];
|
||||||
|
if (isObject(sourceValue) && key in target && isObject(targetValue)) {
|
||||||
|
output[key] = deepMerge(targetValue, sourceValue);
|
||||||
} else {
|
} else {
|
||||||
output[key] = source[key];
|
output[key] = source[key];
|
||||||
}
|
}
|
||||||
@@ -73,13 +75,14 @@ function writeTempYamlConfig(customConfig = {}) {
|
|||||||
return output;
|
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);
|
return item && typeof item === 'object' && !Array.isArray(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = deepMerge(defaultConfig, customConfig);
|
const merged = deepMerge(defaultConfig, customConfig);
|
||||||
const yamlContent =
|
const yamlContent =
|
||||||
typeof customConfig === 'string' ? customConfig : yaml.dump(merged);
|
typeof customConfig === 'string' ? customConfig : stringify(merged);
|
||||||
return writeTempFile('config.yaml', yamlContent);
|
return writeTempFile('config.yaml', yamlContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,60 +90,43 @@ function writeTempYamlConfig(customConfig = {}) {
|
|||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
describe('Configuration Loading', () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
// biome-ignore lint/performance/noDelete: needed for test cleanup
|
|
||||||
delete process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH;
|
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;
|
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;
|
delete process.env.HEADPLANE_HEADSCALE__API_KEY_PATH;
|
||||||
// biome-ignore lint/performance/noDelete: needed for test cleanup
|
|
||||||
delete process.env.TEST_SECRET_DIR;
|
delete process.env.TEST_SECRET_DIR;
|
||||||
|
|
||||||
// Clear overlayfs
|
clearFakeFiles();
|
||||||
overlayFS.clear();
|
createFakeFile('/var/lib/headplane/agent_cache.json', JSON.stringify({}));
|
||||||
|
createFakeFile(
|
||||||
// Create default files that the config expects
|
|
||||||
overlayFS.createFile(
|
|
||||||
'/var/lib/headplane/agent_cache.json',
|
|
||||||
JSON.stringify({}),
|
|
||||||
);
|
|
||||||
overlayFS.createFile(
|
|
||||||
'/var/lib/headplane/users.json',
|
'/var/lib/headplane/users.json',
|
||||||
`[{"u":"acb3294f89a16b554e06b80d5266a3c8b09a883e1fa78ac459a550bf52a32564","c":65535}]`,
|
`[{"u":"acb3294f89a16b554e06b80d5266a3c8b09a883e1fa78ac459a550bf52a32564","c":65535}]`,
|
||||||
);
|
);
|
||||||
overlayFS.createFile('/tmp/agent_cache.json', JSON.stringify({}));
|
createFakeFile('/tmp/agent_cache.json', JSON.stringify({}));
|
||||||
|
createFakeFile('irrelevant', 'irrelevant-content');
|
||||||
// Create test files that some tests expect to exist
|
createFakeFile('placeholder', 'placeholder-content');
|
||||||
overlayFS.createFile('irrelevant', 'irrelevant-content');
|
|
||||||
overlayFS.createFile('placeholder', 'placeholder-content');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
Object.assign(process.env, originalEnv);
|
Object.assign(process.env, originalEnv);
|
||||||
overlayFS.clear();
|
clearFakeFiles();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OIDC Configuration', () => {
|
describe('OIDC Configuration', () => {
|
||||||
const minimalOidcFields = {
|
const minimalOidcFields = {
|
||||||
token_endpoint_auth_method: 'client_secret_basic',
|
token_endpoint_auth_method: 'client_secret_basic' as const,
|
||||||
disable_api_key_login: false,
|
disable_api_key_login: false,
|
||||||
headscale_api_key: 'dummyKey',
|
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 () => {
|
it('should load client_secret from file specified in client_secret_path', async () => {
|
||||||
const secretValue = 'yaml-file-oidc-secret';
|
const secretValue = 'yaml-file-oidc-secret';
|
||||||
const secretPath = writeTempFile('oidc_secret.txt', secretValue);
|
const secretPath = await writeTempFile('oidc_secret.txt', secretValue);
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: 'https://example.com/oidc',
|
issuer: 'https://example.com/oidc',
|
||||||
client_id: 'test',
|
client_id: 'test',
|
||||||
@@ -149,19 +135,19 @@ describe('Configuration Loading', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const config = await loadConfig({ loadEnv: false, path: tempConfigPath });
|
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 () => {
|
it('should override YAML client_secret_path with environment variable', async () => {
|
||||||
const envValue = 'env-file-oidc-secret';
|
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;
|
process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = envPath;
|
||||||
// Instead of 'irrelevant', use a temp file that exists
|
// Instead of 'irrelevant', use a temp file that exists
|
||||||
const irrelevantPath = writeTempFile(
|
const irrelevantPath = await writeTempFile(
|
||||||
'irrelevant.txt',
|
'irrelevant.txt',
|
||||||
'irrelevant-content',
|
'irrelevant-content',
|
||||||
);
|
);
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: 'https://example.com/oidc',
|
issuer: 'https://example.com/oidc',
|
||||||
client_id: 'test',
|
client_id: 'test',
|
||||||
@@ -170,25 +156,25 @@ describe('Configuration Loading', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const config = await loadConfig({ loadEnv: true, path: tempConfigPath });
|
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 () => {
|
it('should handle environment variable interpolation in client_secret_path', async () => {
|
||||||
const value = 'interpolated-secret';
|
const value = 'interpolated-secret';
|
||||||
const dir = fs.mkdtempSync(
|
const dir = await mkdtemp(join(tmpdir(), 'headplane-secret-dir-'));
|
||||||
path.join(os.tmpdir(), 'headplane-secret-dir-'),
|
const filePath = join(dir, 'secret.txt');
|
||||||
);
|
await writeFile(filePath, value);
|
||||||
const file = path.join(dir, 'secret.txt');
|
|
||||||
fs.writeFileSync(file, value);
|
|
||||||
process.env.TEST_SECRET_DIR = dir;
|
process.env.TEST_SECRET_DIR = dir;
|
||||||
process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH =
|
process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH =
|
||||||
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation
|
||||||
'${TEST_SECRET_DIR}/secret.txt';
|
'${TEST_SECRET_DIR}/secret.txt';
|
||||||
// Instead of 'placeholder', use a temp file that exists
|
// Instead of 'placeholder', use a temp file that exists
|
||||||
const placeholderPath = writeTempFile(
|
const placeholderPath = await writeTempFile(
|
||||||
'placeholder.txt',
|
'placeholder.txt',
|
||||||
'placeholder-content',
|
'placeholder-content',
|
||||||
);
|
);
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: 'https://example.com/oidc',
|
issuer: 'https://example.com/oidc',
|
||||||
client_id: 'test',
|
client_id: 'test',
|
||||||
@@ -197,11 +183,11 @@ describe('Configuration Loading', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const config = await loadConfig({ loadEnv: true, path: tempConfigPath });
|
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 () => {
|
it('should reject when client_secret_path points to non-existent file', async () => {
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: 'https://example.com/oidc',
|
issuer: 'https://example.com/oidc',
|
||||||
client_id: 'test',
|
client_id: 'test',
|
||||||
@@ -216,8 +202,9 @@ describe('Configuration Loading', () => {
|
|||||||
|
|
||||||
it('should reject when client_secret_path has unresolvable env var interpolation', async () => {
|
it('should reject when client_secret_path has unresolvable env var interpolation', async () => {
|
||||||
process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH =
|
process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH =
|
||||||
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation
|
||||||
'${MISSING_DIR}/secret.txt';
|
'${MISSING_DIR}/secret.txt';
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: 'https://example.com/oidc',
|
issuer: 'https://example.com/oidc',
|
||||||
client_id: 'test',
|
client_id: 'test',
|
||||||
@@ -234,7 +221,7 @@ describe('Configuration Loading', () => {
|
|||||||
describe('Server Configuration', () => {
|
describe('Server Configuration', () => {
|
||||||
it('should load cookie_secret directly from YAML', async () => {
|
it('should load cookie_secret directly from YAML', async () => {
|
||||||
const valid = 'abcdefghijklmnopqrstuvwxyz123456';
|
const valid = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
server: { cookie_secret: valid },
|
server: { cookie_secret: valid },
|
||||||
});
|
});
|
||||||
const config = await loadConfig({ loadEnv: false, path: tempConfigPath });
|
const config = await loadConfig({ loadEnv: false, path: tempConfigPath });
|
||||||
@@ -243,8 +230,8 @@ describe('Configuration Loading', () => {
|
|||||||
|
|
||||||
it('should load cookie_secret from file', async () => {
|
it('should load cookie_secret from file', async () => {
|
||||||
const secret = 'a'.repeat(32);
|
const secret = 'a'.repeat(32);
|
||||||
const secretPath = writeTempFile('cookie_secret.txt', secret);
|
const secretPath = await writeTempFile('cookie_secret.txt', secret);
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
server: { cookie_secret_path: secretPath },
|
server: { cookie_secret_path: secretPath },
|
||||||
});
|
});
|
||||||
const config = await loadConfig({ loadEnv: false, path: tempConfigPath });
|
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 () => {
|
it('should reject when both cookie_secret and cookie_secret_path are in YAML', async () => {
|
||||||
const secretPath = writeTempFile('conflict.txt', 'x'.repeat(32));
|
const secretPath = await writeTempFile('conflict.txt', 'x'.repeat(32));
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
server: {
|
server: {
|
||||||
cookie_secret: '1'.repeat(32),
|
cookie_secret: '1'.repeat(32),
|
||||||
cookie_secret_path: secretPath,
|
cookie_secret_path: secretPath,
|
||||||
@@ -262,67 +249,76 @@ describe('Configuration Loading', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
loadConfig({ loadEnv: false, path: tempConfigPath }),
|
loadConfig({ loadEnv: false, path: tempConfigPath }),
|
||||||
).rejects.toThrow(
|
).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 () => {
|
it('should reject when neither cookie_secret nor cookie_secret_path is provided', async () => {
|
||||||
const yaml = `debug: false
|
const yaml = `
|
||||||
|
debug: false
|
||||||
server:
|
server:
|
||||||
host: \"127.0.0.1\"
|
host: "127.0.0.1"
|
||||||
port: 8080
|
port: 8080
|
||||||
cookie_secure: false
|
cookie_secure: false
|
||||||
agent:
|
agent:
|
||||||
authkey: \"key\"
|
authkey: "key"
|
||||||
ttl: 180000
|
ttl: 180000
|
||||||
cache_path: \"/tmp/cache.json\"
|
cache_path: "/tmp/cache.json"
|
||||||
headscale:
|
headscale:
|
||||||
url: \"http://localhost\"
|
url: "http://localhost"
|
||||||
config_strict: false
|
config_strict: false
|
||||||
`;
|
`;
|
||||||
const tempConfigPath = writeTempYamlConfig(yaml);
|
const tempConfigPath = await writeTempYamlConfig(yaml);
|
||||||
await expect(
|
await expect(
|
||||||
loadConfig({ loadEnv: false, path: tempConfigPath }),
|
loadConfig({ loadEnv: false, path: tempConfigPath }),
|
||||||
).rejects.toThrow(
|
).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', () => {
|
describe('Headscale Configuration', () => {
|
||||||
it('should load headscale_api_key directly from YAML', async () => {
|
it('should load headscale_api_key directly from YAML', async () => {
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: 'https://example.com/oidc',
|
issuer: 'https://example.com/oidc',
|
||||||
client_id: 'test',
|
client_id: 'test',
|
||||||
headscale_api_key: 'hs-yaml-key',
|
headscale_api_key: 'hs-yaml-key',
|
||||||
token_endpoint_auth_method: 'client_secret_basic',
|
token_endpoint_auth_method: 'client_secret_basic',
|
||||||
disable_api_key_login: false,
|
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 });
|
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 () => {
|
it('should load headscale_api_key from file', async () => {
|
||||||
const val = 'hs-file-key';
|
const val = 'hs-file-key';
|
||||||
const p = writeTempFile('hs_api_key.txt', val);
|
const p = await writeTempFile('hs_api_key.txt', val);
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: 'https://example.com/oidc',
|
issuer: 'https://example.com/oidc',
|
||||||
client_id: 'test',
|
client_id: 'test',
|
||||||
headscale_api_key_path: p,
|
headscale_api_key_path: p,
|
||||||
token_endpoint_auth_method: 'client_secret_basic',
|
token_endpoint_auth_method: 'client_secret_basic',
|
||||||
disable_api_key_login: false,
|
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 });
|
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 () => {
|
it('should reject when both headscale_api_key and headscale_api_key_path are in YAML', async () => {
|
||||||
const p = writeTempFile('conflict.txt', 'irrelevant');
|
const p = await writeTempFile('conflict.txt', 'irrelevant');
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: 'https://example.com/oidc',
|
issuer: 'https://example.com/oidc',
|
||||||
client_id: 'test',
|
client_id: 'test',
|
||||||
@@ -330,20 +326,25 @@ headscale:
|
|||||||
headscale_api_key_path: p,
|
headscale_api_key_path: p,
|
||||||
token_endpoint_auth_method: 'client_secret_basic',
|
token_endpoint_auth_method: 'client_secret_basic',
|
||||||
disable_api_key_login: false,
|
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(
|
await expect(
|
||||||
loadConfig({ loadEnv: false, path: tempConfigPath }),
|
loadConfig({ loadEnv: false, path: tempConfigPath }),
|
||||||
).rejects.toThrow(
|
).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 () => {
|
it('should keep config_path string and interpolate env vars', async () => {
|
||||||
process.env.MY_HS_CONFIG_SUBDIR = 'hs-test';
|
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 cfgVal = '/etc/headscale-${MY_HS_CONFIG_SUBDIR}/config.yaml';
|
||||||
const exp = '/etc/headscale-hs-test/config.yaml';
|
const exp = '/etc/headscale-hs-test/config.yaml';
|
||||||
const tempConfigPath = writeTempYamlConfig({
|
const tempConfigPath = await writeTempYamlConfig({
|
||||||
headscale: { config_path: cfgVal },
|
headscale: { config_path: cfgVal },
|
||||||
});
|
});
|
||||||
const config = await loadConfig({ loadEnv: true, path: tempConfigPath });
|
const config = await loadConfig({ loadEnv: true, path: tempConfigPath });
|
||||||
@@ -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
@@ -15,22 +15,32 @@ if (!version) {
|
|||||||
throw new Error('Unable to read version from package.json');
|
throw new Error('Unable to read version from package.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => ({
|
export default defineConfig({
|
||||||
test: {
|
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: {
|
env: {
|
||||||
HEADPLANE_DEBUG_LOG: 'true',
|
HEADPLANE_DEBUG_LOG: 'true',
|
||||||
},
|
},
|
||||||
// bail: mode === 'integration' ? 1 : undefined,
|
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include:
|
|
||||||
mode === 'integration'
|
|
||||||
? ['tests/integration/*.test.ts']
|
|
||||||
: ['tests/**/*.test.js'],
|
|
||||||
exclude: ['node_modules/**', 'build/**'],
|
exclude: ['node_modules/**', 'build/**'],
|
||||||
setupFiles:
|
|
||||||
mode === 'integration'
|
|
||||||
? ['./tests/integration/setup/vitest-hook.ts']
|
|
||||||
: ['./tests/setupOverlayFs.js'],
|
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -41,4 +51,4 @@ export default defineConfig(({ mode }) => ({
|
|||||||
__VERSION__: JSON.stringify(isNext ? `${version}-next` : version),
|
__VERSION__: JSON.stringify(isNext ? `${version}-next` : version),
|
||||||
__PREFIX__: JSON.stringify(prefix),
|
__PREFIX__: JSON.stringify(prefix),
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user