mirror of
https://github.com/tale/headplane.git
synced 2026-08-11 22:36:53 +00:00
feat: overhaul config loading
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
|
||||
// Simple overlayfs implementation for tests
|
||||
class OverlayFS {
|
||||
constructor() {
|
||||
this.overlays = new Map();
|
||||
}
|
||||
|
||||
// Create a virtual file at the given path
|
||||
createFile(filePath, content) {
|
||||
this.overlays.set(filePath, content);
|
||||
}
|
||||
|
||||
// Check if a file exists in our overlay
|
||||
exists(filePath) {
|
||||
return this.overlays.has(filePath);
|
||||
}
|
||||
|
||||
// Read a file from our overlay
|
||||
readFile(filePath) {
|
||||
if (this.overlays.has(filePath)) {
|
||||
return this.overlays.get(filePath);
|
||||
}
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
|
||||
// Clean up all overlays
|
||||
clear() {
|
||||
this.overlays.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Global overlayfs instance
|
||||
export const overlayFS = new OverlayFS();
|
||||
|
||||
// Monkey patch fs.readFileSync to use our overlay
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
fs.readFileSync = function (filePath, options) {
|
||||
if (overlayFS.exists(filePath)) {
|
||||
const content = overlayFS.readFile(filePath);
|
||||
if (options?.encoding) {
|
||||
return content;
|
||||
}
|
||||
return Buffer.from(content);
|
||||
}
|
||||
return originalReadFileSync.call(this, filePath, options);
|
||||
};
|
||||
|
||||
// Monkey patch fs.promises.readFile (async) to use our overlay
|
||||
const originalAsyncReadFile = fsPromises.readFile;
|
||||
fsPromises.readFile = function (filePath, options) {
|
||||
if (overlayFS.exists(filePath)) {
|
||||
const content = overlayFS.readFile(filePath);
|
||||
if (options?.encoding) {
|
||||
return Promise.resolve(content);
|
||||
}
|
||||
return Promise.resolve(Buffer.from(content));
|
||||
}
|
||||
return originalAsyncReadFile.call(this, filePath, options);
|
||||
};
|
||||
|
||||
// Monkey patch fs.access to handle overlayfs directories
|
||||
const originalAccess = fs.access;
|
||||
fs.access = function (filePath, mode, callback) {
|
||||
// Handle directories that should exist in our overlay
|
||||
if (filePath === '/var/lib/headplane/' || filePath === '/var/lib/headplane') {
|
||||
if (callback) {
|
||||
callback(null);
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return;
|
||||
}
|
||||
return originalAccess.call(this, filePath, mode, callback);
|
||||
};
|
||||
|
||||
// Monkey patch fs.promises.access to handle overlayfs directories
|
||||
const originalAsyncAccess = fsPromises.access;
|
||||
fsPromises.access = function (filePath, mode) {
|
||||
// Handle directories that should exist in our overlay
|
||||
if (filePath === '/var/lib/headplane/' || filePath === '/var/lib/headplane') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return originalAsyncAccess.call(this, filePath, mode);
|
||||
};
|
||||
@@ -1,354 +0,0 @@
|
||||
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';
|
||||
|
||||
async function writeTempFile(baseName: string, content: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'headplane-test-'));
|
||||
|
||||
const path = join(dir, baseName);
|
||||
await writeFile(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
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,
|
||||
cookie_max_age: 86400,
|
||||
},
|
||||
headscale: { url: 'http://localhost:8081', config_strict: false },
|
||||
} satisfies HeadplaneConfig;
|
||||
|
||||
// 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)) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
for (const sectionName of ['oidc', 'server', 'headscale']) {
|
||||
if (output[sectionName] && source[sectionName]) {
|
||||
const sectionSource = source[sectionName];
|
||||
const sectionDefault = target[sectionName] || {};
|
||||
const sectionOutput = output[sectionName];
|
||||
|
||||
for (const baseKey of ['cookie_secret', 'client_secret', 'api_key']) {
|
||||
const valueKey = baseKey;
|
||||
const pathKey = `${baseKey}_path`;
|
||||
const sourceHasValue = Object.hasOwn(sectionSource, valueKey);
|
||||
const sourceHasPath = Object.hasOwn(sectionSource, pathKey);
|
||||
const defaultHasValue = Object.hasOwn(sectionDefault, valueKey);
|
||||
const defaultHasPath = Object.hasOwn(sectionDefault, pathKey);
|
||||
|
||||
if (sourceHasPath && !sourceHasValue && defaultHasValue) {
|
||||
delete sectionOutput[valueKey];
|
||||
} else if (sourceHasValue && !sourceHasPath && defaultHasPath) {
|
||||
delete sectionOutput[pathKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// 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 : stringify(merged);
|
||||
return writeTempFile('config.yaml', yamlContent);
|
||||
}
|
||||
|
||||
// Store original process.env to restore after tests
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
describe('Configuration Loading', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH;
|
||||
delete process.env.HEADPLANE_SERVER__COOKIE_SECRET_PATH;
|
||||
delete process.env.HEADPLANE_HEADSCALE__API_KEY_PATH;
|
||||
delete process.env.TEST_SECRET_DIR;
|
||||
|
||||
clearFakeFiles();
|
||||
createFakeFile('/var/lib/headplane/agent_cache.json', JSON.stringify({}));
|
||||
createFakeFile(
|
||||
'/var/lib/headplane/users.json',
|
||||
`[{"u":"acb3294f89a16b554e06b80d5266a3c8b09a883e1fa78ac459a550bf52a32564","c":65535}]`,
|
||||
);
|
||||
createFakeFile('/tmp/agent_cache.json', JSON.stringify({}));
|
||||
createFakeFile('irrelevant', 'irrelevant-content');
|
||||
createFakeFile('placeholder', 'placeholder-content');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.assign(process.env, originalEnv);
|
||||
clearFakeFiles();
|
||||
});
|
||||
|
||||
describe('OIDC Configuration', () => {
|
||||
const minimalOidcFields = {
|
||||
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 = await writeTempFile('oidc_secret.txt', secretValue);
|
||||
const tempConfigPath = await writeTempYamlConfig({
|
||||
oidc: {
|
||||
issuer: 'https://example.com/oidc',
|
||||
client_id: 'test',
|
||||
client_secret_path: secretPath,
|
||||
...minimalOidcFields,
|
||||
},
|
||||
});
|
||||
const config = await loadConfig({ loadEnv: false, path: tempConfigPath });
|
||||
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 = 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 = await writeTempFile(
|
||||
'irrelevant.txt',
|
||||
'irrelevant-content',
|
||||
);
|
||||
const tempConfigPath = await writeTempYamlConfig({
|
||||
oidc: {
|
||||
issuer: 'https://example.com/oidc',
|
||||
client_id: 'test',
|
||||
client_secret_path: irrelevantPath,
|
||||
...minimalOidcFields,
|
||||
},
|
||||
});
|
||||
const config = await loadConfig({ loadEnv: true, path: tempConfigPath });
|
||||
expect(config.oidc?.client_secret).toBe(envValue);
|
||||
});
|
||||
|
||||
it('should handle environment variable interpolation in client_secret_path', async () => {
|
||||
const value = 'interpolated-secret';
|
||||
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 = await writeTempFile(
|
||||
'placeholder.txt',
|
||||
'placeholder-content',
|
||||
);
|
||||
const tempConfigPath = await writeTempYamlConfig({
|
||||
oidc: {
|
||||
issuer: 'https://example.com/oidc',
|
||||
client_id: 'test',
|
||||
client_secret_path: placeholderPath,
|
||||
...minimalOidcFields,
|
||||
},
|
||||
});
|
||||
const config = await loadConfig({ loadEnv: true, path: tempConfigPath });
|
||||
expect(config.oidc?.client_secret).toBe(value);
|
||||
});
|
||||
|
||||
it('should reject when client_secret_path points to non-existent file', async () => {
|
||||
const tempConfigPath = await writeTempYamlConfig({
|
||||
oidc: {
|
||||
issuer: 'https://example.com/oidc',
|
||||
client_id: 'test',
|
||||
client_secret_path: '/no/such/file',
|
||||
...minimalOidcFields,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
loadConfig({ loadEnv: false, path: tempConfigPath }),
|
||||
).rejects.toThrow(ConfigError);
|
||||
});
|
||||
|
||||
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 = await writeTempYamlConfig({
|
||||
oidc: {
|
||||
issuer: 'https://example.com/oidc',
|
||||
client_id: 'test',
|
||||
client_secret_path: 'placeholder',
|
||||
...minimalOidcFields,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
loadConfig({ loadEnv: true, path: tempConfigPath }),
|
||||
).rejects.toThrow(/Environment variable "MISSING_DIR" not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server Configuration', () => {
|
||||
it('should load cookie_secret directly from YAML', async () => {
|
||||
const valid = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
const tempConfigPath = await writeTempYamlConfig({
|
||||
server: { cookie_secret: valid },
|
||||
});
|
||||
const config = await loadConfig({ loadEnv: false, path: tempConfigPath });
|
||||
expect(config.server.cookie_secret).toBe(valid);
|
||||
});
|
||||
|
||||
it('should load cookie_secret from file', async () => {
|
||||
const secret = 'a'.repeat(32);
|
||||
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 });
|
||||
expect(config.server.cookie_secret).toBe(secret);
|
||||
});
|
||||
|
||||
it('should reject when both cookie_secret and cookie_secret_path are in YAML', async () => {
|
||||
const secretPath = await writeTempFile('conflict.txt', 'x'.repeat(32));
|
||||
const tempConfigPath = await writeTempYamlConfig({
|
||||
server: {
|
||||
cookie_secret: '1'.repeat(32),
|
||||
cookie_secret_path: secretPath,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
loadConfig({ loadEnv: false, path: tempConfigPath }),
|
||||
).rejects.toThrow(
|
||||
/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
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 8080
|
||||
cookie_secure: false
|
||||
agent:
|
||||
authkey: "key"
|
||||
ttl: 180000
|
||||
cache_path: "/tmp/cache.json"
|
||||
headscale:
|
||||
url: "http://localhost"
|
||||
config_strict: false
|
||||
`;
|
||||
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Headscale Configuration', () => {
|
||||
it('should load headscale_api_key directly from YAML', async () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('should load headscale_api_key from file', async () => {
|
||||
const val = 'hs-file-key';
|
||||
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);
|
||||
});
|
||||
|
||||
it('should reject when both headscale_api_key and headscale_api_key_path are in YAML', async () => {
|
||||
const p = await writeTempFile('conflict.txt', 'irrelevant');
|
||||
const tempConfigPath = await writeTempYamlConfig({
|
||||
oidc: {
|
||||
issuer: 'https://example.com/oidc',
|
||||
client_id: 'test',
|
||||
headscale_api_key: 'key',
|
||||
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/,
|
||||
);
|
||||
});
|
||||
|
||||
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 = await writeTempYamlConfig({
|
||||
headscale: { config_path: cfgVal },
|
||||
});
|
||||
const config = await loadConfig({ loadEnv: true, path: tempConfigPath });
|
||||
expect(config.headscale.config_path).toBe(exp);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { dump } from 'js-yaml';
|
||||
import { beforeAll, describe, expect, test } from 'vitest';
|
||||
import { ConfigError } from '~/server/config/error';
|
||||
import { loadConfig, loadConfigFile } from '~/server/config/load';
|
||||
import { clearFakeFiles, createFakeFile } from '../setup/overlay-fs';
|
||||
|
||||
const writeYaml = (filePath: string, content: unknown) => {
|
||||
const yamlContent = dump(content);
|
||||
createFakeFile(filePath, yamlContent);
|
||||
};
|
||||
|
||||
describe('Configuration YAML file loading', () => {
|
||||
beforeAll(() => {
|
||||
clearFakeFiles();
|
||||
});
|
||||
|
||||
test('should correctly parse different types from YAML file', async () => {
|
||||
const filePath = '/config/test-config.yaml';
|
||||
writeYaml(filePath, {
|
||||
headscale: {
|
||||
url: 'http://localhost:8080',
|
||||
},
|
||||
oidc: {
|
||||
client_id: 'my-client-id',
|
||||
},
|
||||
server: {
|
||||
port: 8000,
|
||||
},
|
||||
integration: {
|
||||
agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const config = await loadConfigFile(filePath);
|
||||
expect(config?.headscale?.url).toBe('http://localhost:8080');
|
||||
expect(config?.oidc?.client_id).toBe('my-client-id');
|
||||
expect(config?.server?.port).toBe(8000);
|
||||
expect(config?.integration?.agent?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('should not throw errors for inaccessible file', async () => {
|
||||
await expect(
|
||||
loadConfigFile('/non-existent-path/config.yaml'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('should correctly get a finalized config from YAML', async () => {
|
||||
const filePath = '/config/minimal-config.yaml';
|
||||
writeYaml(filePath, {
|
||||
headscale: {
|
||||
url: 'http://localhost:8080',
|
||||
},
|
||||
server: {
|
||||
cookie_secret: 'thirtytwo-character-cookiesecret',
|
||||
},
|
||||
});
|
||||
|
||||
const config = await loadConfig(filePath);
|
||||
expect(config.headscale.url).toBe('http://localhost:8080');
|
||||
expect(config.server.cookie_secret).toBe(
|
||||
'thirtytwo-character-cookiesecret',
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error for missing required fields', async () => {
|
||||
const filePath = '/config/invalid-config.yaml';
|
||||
writeYaml(filePath, {
|
||||
server: {
|
||||
port: 8000,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(loadConfig(filePath)).rejects.toEqual(
|
||||
expect.objectContaining(
|
||||
ConfigError.from('INVALID_REQUIRED_FIELDS', { messages: [] }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { ConfigError } from '~/server/config/error';
|
||||
import { loadConfig, loadConfigEnv } from '~/server/config/load';
|
||||
|
||||
const envVarSnapshot = { ...process.env };
|
||||
describe('Configuration environment variable handling', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...envVarSnapshot };
|
||||
});
|
||||
|
||||
test('should correctly parse different types from env vars', async () => {
|
||||
process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080';
|
||||
process.env.HEADPLANE_OIDC__CLIENT_ID = 'my-client-id';
|
||||
process.env.HEADPLANE_SERVER__PORT = '8000';
|
||||
process.env.HEADPLANE_INTEGRATION__AGENT__ENABLED = 'true';
|
||||
|
||||
const config = await loadConfigEnv();
|
||||
expect(config?.headscale?.url).toBe('http://localhost:8080');
|
||||
expect(config?.oidc?.client_id).toBe('my-client-id');
|
||||
expect(config?.server?.port).toBe(8000);
|
||||
expect(config?.integration?.agent?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('should not load env vars without the HEADPLANE_ prefix', async () => {
|
||||
process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080';
|
||||
process.env.OTHER_PREFIX_OIDC__CLIENT_ID = 'should-not-be-loaded';
|
||||
|
||||
const config = await loadConfigEnv();
|
||||
expect(config?.headscale?.url).toBe('http://localhost:8080');
|
||||
expect(config?.oidc?.client_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should correctly get a finalized config from env vars', async () => {
|
||||
process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080';
|
||||
process.env.HEADPLANE_SERVER__COOKIE_SECRET =
|
||||
'thirtytwo-character-cookiesecret';
|
||||
|
||||
const config = await loadConfig('./non-existent-path.yaml');
|
||||
expect(config.headscale.url).toBe('http://localhost:8080');
|
||||
expect(config.server.cookie_secret).toBe(
|
||||
'thirtytwo-character-cookiesecret',
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error for missing required fields', async () => {
|
||||
process.env.HEADPLANE_SERVER__PORT = '8000';
|
||||
|
||||
await expect(loadConfig('./non-existent-path.yaml')).rejects.toEqual(
|
||||
expect.objectContaining(
|
||||
ConfigError.from('INVALID_REQUIRED_FIELDS', { messages: [] }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import type { PartialHeadplaneConfigWithPaths } from '~/server/config/config-schema';
|
||||
import { ConfigError } from '~/server/config/error';
|
||||
import { loadConfigKeyPaths } from '~/server/config/load';
|
||||
import { createFakeFile } from '../setup/overlay-fs';
|
||||
|
||||
describe('Configuration secret path handling', () => {
|
||||
test('should correctly substitute server.cookie_secret', async () => {
|
||||
createFakeFile('/secrets/cookie_secret.txt', 'supersecretcookievalue');
|
||||
const config = {
|
||||
server: {
|
||||
cookie_secret_path: '/secrets/cookie_secret.txt',
|
||||
},
|
||||
} as PartialHeadplaneConfigWithPaths;
|
||||
|
||||
await loadConfigKeyPaths(config);
|
||||
expect(config.server?.cookie_secret).toBe('supersecretcookievalue');
|
||||
});
|
||||
|
||||
test('should throw error for missing secret file', async () => {
|
||||
const config = {
|
||||
server: {
|
||||
cookie_secret_path: '/secrets/missing_cookie_secret.txt',
|
||||
},
|
||||
} as PartialHeadplaneConfigWithPaths;
|
||||
|
||||
await expect(loadConfigKeyPaths(config)).rejects.toMatchObject(
|
||||
ConfigError.from('MISSING_SECRET_FILE', {
|
||||
pathKey: 'server.cookie_secret_path',
|
||||
filePath: '/secrets/missing_cookie_secret.txt',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error for conflicting secret path and field', async () => {
|
||||
const config = {
|
||||
server: {
|
||||
cookie_secret: 'explicitsecretvalue',
|
||||
cookie_secret_path: '/secrets/cookie_secret.txt',
|
||||
},
|
||||
} as PartialHeadplaneConfigWithPaths;
|
||||
|
||||
await expect(loadConfigKeyPaths(config)).rejects.toMatchObject(
|
||||
ConfigError.from('CONFLICTING_SECRET_PATH_FIELD', {
|
||||
fieldName: 'server.cookie_secret',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should correctly interpolate env vars in secret paths', async () => {
|
||||
process.env.HP_TEST_COOKIE_SECRET_FILE = 'cookie_secret.txt';
|
||||
createFakeFile(
|
||||
`/secrets/${process.env.HP_TEST_COOKIE_SECRET_FILE}`,
|
||||
'envvarsecretvalue',
|
||||
);
|
||||
|
||||
const config = {
|
||||
server: {
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation
|
||||
cookie_secret_path: '/secrets/${HP_TEST_COOKIE_SECRET_FILE}',
|
||||
},
|
||||
} as PartialHeadplaneConfigWithPaths;
|
||||
|
||||
await loadConfigKeyPaths(config);
|
||||
expect(config.server?.cookie_secret).toBe('envvarsecretvalue');
|
||||
});
|
||||
|
||||
test('should throw error for missing interpolated env var in secret path', async () => {
|
||||
const config = {
|
||||
server: {
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation
|
||||
cookie_secret_path: '/secrets/${MISSING_ENV_VAR}',
|
||||
},
|
||||
} as PartialHeadplaneConfigWithPaths;
|
||||
|
||||
await expect(loadConfigKeyPaths(config)).rejects.toMatchObject(
|
||||
ConfigError.from('MISSING_INTERPOLATION_VARIABLE', {
|
||||
pathKey: 'server.cookie_secret_path',
|
||||
variableName: 'MISSING_ENV_VAR',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ vi.mock(import('node:fs/promises'), async (importOrig) => {
|
||||
|
||||
access: (path, mode) => {
|
||||
const p = path.toString();
|
||||
if (p === '/var/lib/headplane/' || p === '/var/lib/headplane') {
|
||||
if (fakeFs.has(p)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user