Single-file Node.js CLI that orchestrates the full setup lifecycle: - Interactive env var configuration with validation and smart defaults - Postgres provisioning via Docker Compose with readiness polling - Schema initialization (psql with docker exec fallback) - npm dependency installation with 120s timeout - Optional SQLite-to-Postgres data migration with retry logic - Frontend build with smart skip on reconfiguration Includes 84 tests: 50 property-based (fast-check) covering 19 correctness properties, and 34 integration tests for filesystem and parsing flows.
757 lines
32 KiB
JavaScript
757 lines
32 KiB
JavaScript
/**
|
|
* Integration Tests: Config Wizard End-to-End Flows
|
|
*
|
|
* Feature: config-wizard
|
|
*
|
|
* Tests filesystem interactions, real-world data parsing, and end-to-end
|
|
* function composition from `configure.js`.
|
|
*
|
|
* Validates: Requirements 1.4, 1.5, 6.5, 6.6, 9.6, 9.7, 14.4, 16.2, 16.4, 16.6
|
|
*/
|
|
|
|
const os = require('os');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const {
|
|
VARIABLE_DESCRIPTORS,
|
|
GROUP_ORDER,
|
|
OPTIONAL_GROUPS,
|
|
parseEnvFile,
|
|
parseDockerCompose,
|
|
generateEnvContent,
|
|
writeEnvFile,
|
|
createBackup,
|
|
detectInfraState,
|
|
shouldSkipFrontendBuild,
|
|
checkNodeVersion,
|
|
checkProjectRoot,
|
|
} = require('../../configure.js');
|
|
|
|
/**
|
|
* Create a temporary directory for test isolation.
|
|
* Returns the path to the created directory.
|
|
*/
|
|
function createTempDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'config-wizard-test-'));
|
|
}
|
|
|
|
/**
|
|
* Recursively remove a directory and its contents.
|
|
*/
|
|
function removeTempDir(dirPath) {
|
|
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 1: Full wizard run with all defaults — verify correct files written
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('Full wizard run with all defaults', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
removeTempDir(tmpDir);
|
|
});
|
|
|
|
test('generateEnvContent + writeEnvFile produces valid backend .env with all required defaults', () => {
|
|
const values = new Map();
|
|
values.set('PORT', '3001');
|
|
values.set('API_HOST', 'localhost');
|
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
values.set('REACT_APP_API_BASE', 'http://localhost:3001/api');
|
|
values.set('REACT_APP_API_HOST', 'http://localhost:3001');
|
|
|
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
|
|
const filePath = path.join(tmpDir, '.env');
|
|
writeEnvFile(filePath, content);
|
|
|
|
expect(fs.existsSync(filePath)).toBe(true);
|
|
const written = fs.readFileSync(filePath, 'utf8');
|
|
|
|
// Verify key values are present
|
|
expect(written).toContain('PORT=3001');
|
|
expect(written).toContain('API_HOST=localhost');
|
|
expect(written).toContain('CORS_ORIGINS=http://localhost:3000');
|
|
expect(written).toContain('SESSION_SECRET=a-very-long-secret-key-here-1234');
|
|
// DATABASE_URL contains special chars, should be quoted
|
|
expect(written).toContain('DATABASE_URL=');
|
|
// Ends with newline
|
|
expect(written.endsWith('\n')).toBe(true);
|
|
});
|
|
|
|
test('generateEnvContent + writeEnvFile produces valid frontend .env with defaults', () => {
|
|
const values = new Map();
|
|
values.set('REACT_APP_API_BASE', 'http://localhost:3001/api');
|
|
values.set('REACT_APP_API_HOST', 'http://localhost:3001');
|
|
|
|
const frontendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'frontend');
|
|
const content = generateEnvContent(values, GROUP_ORDER, frontendDescriptors, []);
|
|
|
|
const filePath = path.join(tmpDir, 'frontend.env');
|
|
writeEnvFile(filePath, content);
|
|
|
|
const written = fs.readFileSync(filePath, 'utf8');
|
|
expect(written).toContain('REACT_APP_API_BASE=http://localhost:3001/api');
|
|
expect(written).toContain('REACT_APP_API_HOST=http://localhost:3001');
|
|
expect(written).toContain('# --- Frontend Settings ---');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 2: Wizard with existing .env files — values pre-filled correctly
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('Wizard with existing .env files', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
removeTempDir(tmpDir);
|
|
});
|
|
|
|
test('parseEnvFile reads existing values correctly', () => {
|
|
const envContent = [
|
|
'PORT=4000',
|
|
'API_HOST=192.168.1.100',
|
|
'CORS_ORIGINS=http://myhost:3000',
|
|
'DATABASE_URL="postgresql://user:pass@localhost:5433/mydb"',
|
|
'SESSION_SECRET=my-super-secret-session-key-123',
|
|
'MY_CUSTOM_VAR=preserved',
|
|
].join('\n');
|
|
|
|
const filePath = path.join(tmpDir, '.env');
|
|
fs.writeFileSync(filePath, envContent, 'utf8');
|
|
|
|
const result = parseEnvFile(filePath);
|
|
|
|
expect(result.managed.get('PORT')).toBe('4000');
|
|
expect(result.managed.get('API_HOST')).toBe('192.168.1.100');
|
|
expect(result.managed.get('CORS_ORIGINS')).toBe('http://myhost:3000');
|
|
expect(result.managed.get('DATABASE_URL')).toBe('postgresql://user:pass@localhost:5433/mydb');
|
|
expect(result.managed.get('SESSION_SECRET')).toBe('my-super-secret-session-key-123');
|
|
expect(result.unmanaged).toContain('MY_CUSTOM_VAR=preserved');
|
|
});
|
|
|
|
test('parseEnvFile handles quoted values with spaces', () => {
|
|
const envContent = 'CORS_ORIGINS="http://localhost:3000, http://localhost:8080"\n';
|
|
const filePath = path.join(tmpDir, '.env');
|
|
fs.writeFileSync(filePath, envContent, 'utf8');
|
|
|
|
const result = parseEnvFile(filePath);
|
|
expect(result.managed.get('CORS_ORIGINS')).toBe('http://localhost:3000, http://localhost:8080');
|
|
});
|
|
|
|
test('parseEnvFile returns empty maps for non-existent file', () => {
|
|
const result = parseEnvFile(path.join(tmpDir, 'nonexistent.env'));
|
|
expect(result.managed.size).toBe(0);
|
|
expect(result.unmanaged.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 3: Wizard with skipped groups — groups absent from output
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('Wizard with skipped groups', () => {
|
|
test('generateEnvContent excludes variables from skipped groups', () => {
|
|
const values = new Map();
|
|
values.set('PORT', '3001');
|
|
values.set('API_HOST', 'localhost');
|
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
// Intentionally NOT setting any Ivanti, Atlas, Jira, CARD, GitLab, NVD values
|
|
|
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
|
|
// Skipped groups should not appear in output
|
|
expect(content).not.toContain('# --- NVD API ---');
|
|
expect(content).not.toContain('# --- Ivanti Integration ---');
|
|
expect(content).not.toContain('# --- Atlas Integration ---');
|
|
expect(content).not.toContain('# --- Jira Integration ---');
|
|
expect(content).not.toContain('# --- CARD Integration ---');
|
|
expect(content).not.toContain('# --- GitLab Integration ---');
|
|
|
|
// Required groups should still be present
|
|
expect(content).toContain('# --- Core Settings ---');
|
|
expect(content).toContain('# --- Database ---');
|
|
expect(content).toContain('# --- Session ---');
|
|
});
|
|
|
|
test('generateEnvContent includes optional group when values are provided', () => {
|
|
const values = new Map();
|
|
values.set('PORT', '3001');
|
|
values.set('API_HOST', 'localhost');
|
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
values.set('NVD_API_KEY', 'my-nvd-key-12345');
|
|
|
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
|
|
expect(content).toContain('# --- NVD API ---');
|
|
expect(content).toContain('NVD_API_KEY=my-nvd-key-12345');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 4: Missing project structure — error exit
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('Missing project structure', () => {
|
|
let tmpDir;
|
|
let originalCwd;
|
|
let mockExit;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir();
|
|
originalCwd = process.cwd();
|
|
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
throw new Error('process.exit called');
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(originalCwd);
|
|
mockExit.mockRestore();
|
|
removeTempDir(tmpDir);
|
|
});
|
|
|
|
test('checkProjectRoot exits when backend/ is missing', () => {
|
|
// Create only frontend/
|
|
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
|
process.chdir(tmpDir);
|
|
|
|
expect(() => checkProjectRoot()).toThrow('process.exit called');
|
|
expect(mockExit).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
test('checkProjectRoot exits when frontend/ is missing', () => {
|
|
// Create only backend/
|
|
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
|
process.chdir(tmpDir);
|
|
|
|
expect(() => checkProjectRoot()).toThrow('process.exit called');
|
|
expect(mockExit).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
test('checkProjectRoot exits when both are missing', () => {
|
|
process.chdir(tmpDir);
|
|
|
|
expect(() => checkProjectRoot()).toThrow('process.exit called');
|
|
expect(mockExit).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
test('checkProjectRoot succeeds when both directories exist', () => {
|
|
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
|
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
|
process.chdir(tmpDir);
|
|
|
|
expect(() => checkProjectRoot()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 5: File write permission error — graceful failure
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('File write permission error', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore permissions before cleanup
|
|
try {
|
|
fs.chmodSync(path.join(tmpDir, 'readonly'), 0o755);
|
|
} catch { /* ignore */ }
|
|
removeTempDir(tmpDir);
|
|
});
|
|
|
|
test('writeEnvFile throws on invalid path (non-existent nested directory)', () => {
|
|
// Use a deeply nested non-existent path that will fail regardless of user
|
|
const filePath = path.join(tmpDir, 'no', 'such', 'deep', 'path', '.env');
|
|
expect(() => writeEnvFile(filePath, 'PORT=3001\n')).toThrow();
|
|
});
|
|
|
|
test('writeEnvFile succeeds on valid writable path', () => {
|
|
const filePath = path.join(tmpDir, '.env');
|
|
expect(() => writeEnvFile(filePath, 'PORT=3001\n')).not.toThrow();
|
|
expect(fs.readFileSync(filePath, 'utf8')).toBe('PORT=3001\n');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 6: Infrastructure state detection
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('Infrastructure state detection', () => {
|
|
let tmpDir;
|
|
let originalCwd;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir();
|
|
originalCwd = process.cwd();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(originalCwd);
|
|
removeTempDir(tmpDir);
|
|
});
|
|
|
|
test('detectInfraState returns correct values based on filesystem state', () => {
|
|
// Set up a minimal project structure
|
|
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
|
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
|
fs.mkdirSync(path.join(tmpDir, 'backend', 'node_modules'));
|
|
fs.writeFileSync(path.join(tmpDir, 'backend', '.env'), 'PORT=3001\n');
|
|
fs.writeFileSync(path.join(tmpDir, 'backend', 'db-schema.sql'), 'CREATE TABLE test();');
|
|
// No frontend node_modules, no frontend .env, no frontend build
|
|
|
|
process.chdir(tmpDir);
|
|
const state = detectInfraState();
|
|
|
|
expect(state.backendNodeModules).toBe(true);
|
|
expect(state.frontendNodeModules).toBe(false);
|
|
expect(state.backendEnvExists).toBe(true);
|
|
expect(state.frontendEnvExists).toBe(false);
|
|
expect(state.frontendBuildExists).toBe(false);
|
|
expect(state.schemaFileExists).toBe(true);
|
|
expect(state.sqliteDbExists).toBe(false);
|
|
// npmAvailable should be true in test environment
|
|
expect(typeof state.npmAvailable).toBe('boolean');
|
|
expect(typeof state.dockerAvailable).toBe('boolean');
|
|
expect(typeof state.psqlAvailable).toBe('boolean');
|
|
expect(typeof state.postgresRunning).toBe('boolean');
|
|
});
|
|
|
|
test('detectInfraState detects SQLite database when present', () => {
|
|
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
|
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
|
fs.writeFileSync(path.join(tmpDir, 'backend', 'cve_database.db'), '');
|
|
|
|
process.chdir(tmpDir);
|
|
const state = detectInfraState();
|
|
|
|
expect(state.sqliteDbExists).toBe(true);
|
|
});
|
|
|
|
test('detectInfraState detects frontend build when present', () => {
|
|
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
|
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
|
fs.mkdirSync(path.join(tmpDir, 'frontend', 'build'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, 'frontend', 'build', 'index.html'), '<html></html>');
|
|
|
|
process.chdir(tmpDir);
|
|
const state = detectInfraState();
|
|
|
|
expect(state.frontendBuildExists).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 7: Frontend build skip on unchanged REACT_APP_* values
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('Frontend build skip on unchanged REACT_APP_* values', () => {
|
|
test('shouldSkipFrontendBuild returns true when REACT_APP_* values are identical', () => {
|
|
const oldEnv = new Map([
|
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
|
]);
|
|
const newEnv = new Map([
|
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
|
]);
|
|
|
|
expect(shouldSkipFrontendBuild(oldEnv, newEnv)).toBe(true);
|
|
});
|
|
|
|
test('shouldSkipFrontendBuild returns false when REACT_APP_* values differ', () => {
|
|
const oldEnv = new Map([
|
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
|
]);
|
|
const newEnv = new Map([
|
|
['REACT_APP_API_BASE', 'http://192.168.1.100:4000/api'],
|
|
['REACT_APP_API_HOST', 'http://192.168.1.100:4000'],
|
|
]);
|
|
|
|
expect(shouldSkipFrontendBuild(oldEnv, newEnv)).toBe(false);
|
|
});
|
|
|
|
test('shouldSkipFrontendBuild returns false when oldFrontendEnv is null', () => {
|
|
const newEnv = new Map([
|
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
|
]);
|
|
|
|
expect(shouldSkipFrontendBuild(null, newEnv)).toBe(false);
|
|
});
|
|
|
|
test('shouldSkipFrontendBuild returns false when one REACT_APP_* key differs', () => {
|
|
const oldEnv = new Map([
|
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
|
]);
|
|
const newEnv = new Map([
|
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
['REACT_APP_API_HOST', 'http://newhost:3001'],
|
|
]);
|
|
|
|
expect(shouldSkipFrontendBuild(oldEnv, newEnv)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 8: Node.js version check
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('Node.js version check', () => {
|
|
let mockExit;
|
|
|
|
beforeEach(() => {
|
|
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
throw new Error('process.exit called');
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockExit.mockRestore();
|
|
});
|
|
|
|
test('checkNodeVersion does not exit on current Node.js version (>= 18)', () => {
|
|
// Current test environment should be Node 18+
|
|
expect(() => checkNodeVersion()).not.toThrow();
|
|
});
|
|
|
|
test('checkNodeVersion would exit on Node < 18 (simulated via version override)', () => {
|
|
const originalVersion = process.version;
|
|
Object.defineProperty(process, 'version', { value: 'v16.20.0', writable: true });
|
|
|
|
try {
|
|
expect(() => checkNodeVersion()).toThrow('process.exit called');
|
|
expect(mockExit).toHaveBeenCalledWith(1);
|
|
} finally {
|
|
Object.defineProperty(process, 'version', { value: originalVersion, writable: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 9: parseDockerCompose with real docker-compose.yml
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseDockerCompose with real docker-compose.yml', () => {
|
|
test('correctly parses the project actual docker-compose.yml', () => {
|
|
const composePath = path.join(__dirname, '..', '..', 'docker-compose.yml');
|
|
const result = parseDockerCompose(composePath);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result.user).toBe('steam');
|
|
expect(result.password).toBe('sV4xmC9xAUCFop0ypxMVS056QgPqGrX');
|
|
expect(result.database).toBe('cve_dashboard');
|
|
expect(result.port).toBe('5433');
|
|
});
|
|
|
|
test('parseDockerCompose returns null for non-existent file', () => {
|
|
const result = parseDockerCompose('/nonexistent/docker-compose.yml');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('parseDockerCompose returns null for invalid YAML content', () => {
|
|
const tmpDir = createTempDir();
|
|
const filePath = path.join(tmpDir, 'docker-compose.yml');
|
|
fs.writeFileSync(filePath, 'this is not valid yaml at all\nno services here\n');
|
|
|
|
const result = parseDockerCompose(filePath);
|
|
expect(result).toBeNull();
|
|
|
|
removeTempDir(tmpDir);
|
|
});
|
|
|
|
test('parseDockerCompose handles compose file with shell variable defaults', () => {
|
|
const tmpDir = createTempDir();
|
|
const filePath = path.join(tmpDir, 'docker-compose.yml');
|
|
const content = [
|
|
'services:',
|
|
' postgres:',
|
|
' image: postgres:16-alpine',
|
|
' environment:',
|
|
' POSTGRES_DB: testdb',
|
|
' POSTGRES_USER: testuser',
|
|
' POSTGRES_PASSWORD: ${PG_PASS:-mysecretpass}',
|
|
' ports:',
|
|
' - "5434:5432"',
|
|
].join('\n');
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
|
|
const result = parseDockerCompose(filePath);
|
|
expect(result).not.toBeNull();
|
|
expect(result.user).toBe('testuser');
|
|
expect(result.password).toBe('mysecretpass');
|
|
expect(result.database).toBe('testdb');
|
|
expect(result.port).toBe('5434');
|
|
|
|
removeTempDir(tmpDir);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 10: parseEnvFile round-trip — write and re-read produces identical values
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseEnvFile round-trip', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
removeTempDir(tmpDir);
|
|
});
|
|
|
|
test('writing and re-reading produces identical managed values', () => {
|
|
const values = new Map();
|
|
values.set('PORT', '3001');
|
|
values.set('API_HOST', 'localhost');
|
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
values.set('DATABASE_URL', 'postgresql://steam:sV4xmC9xAUCFop0ypxMVS056QgPqGrX@localhost:5433/cve_dashboard');
|
|
values.set('SESSION_SECRET', 'my-session-secret-at-least-16-chars');
|
|
|
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
|
|
const filePath = path.join(tmpDir, '.env');
|
|
writeEnvFile(filePath, content);
|
|
|
|
const parsed = parseEnvFile(filePath);
|
|
|
|
// All values we set should be recovered
|
|
for (const [key, value] of values) {
|
|
const descriptor = VARIABLE_DESCRIPTORS.find(d => d.name === key);
|
|
if (descriptor && descriptor.target === 'backend') {
|
|
expect(parsed.managed.get(key)).toBe(value);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('round-trip preserves values with special characters', () => {
|
|
const values = new Map();
|
|
values.set('PORT', '3001');
|
|
values.set('API_HOST', 'localhost');
|
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
values.set('DATABASE_URL', 'postgresql://user:p@ss$word@localhost:5433/db');
|
|
values.set('SESSION_SECRET', 'secret with spaces and #hash and $dollar');
|
|
|
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
|
|
const filePath = path.join(tmpDir, '.env');
|
|
writeEnvFile(filePath, content);
|
|
|
|
const parsed = parseEnvFile(filePath);
|
|
|
|
expect(parsed.managed.get('PORT')).toBe('3001');
|
|
expect(parsed.managed.get('API_HOST')).toBe('localhost');
|
|
// Values with special chars are quoted, parseEnvFile strips quotes
|
|
expect(parsed.managed.get('SESSION_SECRET')).toBe('secret with spaces and #hash and $dollar');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 11: generateEnvContent with all groups — complete output format
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('generateEnvContent with all groups', () => {
|
|
test('produces complete output with all group headers and values', () => {
|
|
const values = new Map();
|
|
// Core Settings
|
|
values.set('PORT', '3001');
|
|
values.set('API_HOST', 'localhost');
|
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
// Database
|
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
// Session
|
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
// NVD API
|
|
values.set('NVD_API_KEY', 'nvd-key-123');
|
|
// Ivanti
|
|
values.set('IVANTI_API_KEY', 'ivanti-key-456');
|
|
values.set('IVANTI_CLIENT_ID', '1550');
|
|
// Atlas
|
|
values.set('ATLAS_API_URL', 'https://atlas.example.com');
|
|
values.set('ATLAS_API_USER', 'atlasuser');
|
|
values.set('ATLAS_API_PASS', 'atlaspass');
|
|
// Jira
|
|
values.set('JIRA_BASE_URL', 'https://jira.example.com');
|
|
values.set('JIRA_AUTH_METHOD', 'basic');
|
|
values.set('JIRA_API_USER', 'jirauser');
|
|
values.set('JIRA_API_TOKEN', 'jira-token-789');
|
|
// CARD
|
|
values.set('CARD_API_URL', 'https://card.example.com');
|
|
values.set('CARD_API_USER', 'carduser');
|
|
values.set('CARD_API_PASS', 'cardpass');
|
|
// GitLab
|
|
values.set('GITLAB_URL', 'http://steam-gitlab.charterlab.com');
|
|
values.set('GITLAB_PROJECT_ID', '42');
|
|
values.set('GITLAB_PAT', 'glpat-abc123');
|
|
|
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
|
|
// Verify all group headers present
|
|
expect(content).toContain('# --- Core Settings ---');
|
|
expect(content).toContain('# --- Database ---');
|
|
expect(content).toContain('# --- Session ---');
|
|
expect(content).toContain('# --- NVD API ---');
|
|
expect(content).toContain('# --- Ivanti Integration ---');
|
|
expect(content).toContain('# --- Atlas Integration ---');
|
|
expect(content).toContain('# --- Jira Integration ---');
|
|
expect(content).toContain('# --- CARD Integration ---');
|
|
expect(content).toContain('# --- GitLab Integration ---');
|
|
|
|
// Verify values present
|
|
expect(content).toContain('PORT=3001');
|
|
expect(content).toContain('NVD_API_KEY=nvd-key-123');
|
|
expect(content).toContain('IVANTI_CLIENT_ID=1550');
|
|
expect(content).toContain('GITLAB_PROJECT_ID=42');
|
|
|
|
// Verify LF line endings (no \r)
|
|
expect(content).not.toContain('\r');
|
|
// Verify trailing newline
|
|
expect(content.endsWith('\n')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 12: generateEnvContent with skipped groups — excluded from output
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('generateEnvContent with skipped groups', () => {
|
|
test('skipped groups produce no KEY=value lines in output', () => {
|
|
const values = new Map();
|
|
// Only set required group values
|
|
values.set('PORT', '3001');
|
|
values.set('API_HOST', 'localhost');
|
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
|
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
|
|
// Verify no optional group variables appear
|
|
const optionalVarNames = VARIABLE_DESCRIPTORS
|
|
.filter(d => OPTIONAL_GROUPS.includes(d.group))
|
|
.map(d => d.name);
|
|
|
|
for (const varName of optionalVarNames) {
|
|
// Should not have any KEY= line for these variables
|
|
const regex = new RegExp(`^${varName}=`, 'm');
|
|
expect(content).not.toMatch(regex);
|
|
}
|
|
});
|
|
|
|
test('partial optional groups — only configured groups appear', () => {
|
|
const values = new Map();
|
|
values.set('PORT', '3001');
|
|
values.set('API_HOST', 'localhost');
|
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
// Only configure Jira
|
|
values.set('JIRA_BASE_URL', 'https://jira.example.com');
|
|
values.set('JIRA_API_USER', 'user');
|
|
values.set('JIRA_API_TOKEN', 'token-value-here');
|
|
|
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
|
|
expect(content).toContain('# --- Jira Integration ---');
|
|
expect(content).toContain('JIRA_BASE_URL=https://jira.example.com');
|
|
// Other optional groups should not appear
|
|
expect(content).not.toContain('# --- NVD API ---');
|
|
expect(content).not.toContain('# --- Ivanti Integration ---');
|
|
expect(content).not.toContain('# --- Atlas Integration ---');
|
|
expect(content).not.toContain('# --- CARD Integration ---');
|
|
expect(content).not.toContain('# --- GitLab Integration ---');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test 13: createBackup — backup file creation with timestamp naming
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('createBackup', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
removeTempDir(tmpDir);
|
|
});
|
|
|
|
test('creates backup file with timestamp naming', () => {
|
|
const originalPath = path.join(tmpDir, '.env');
|
|
fs.writeFileSync(originalPath, 'PORT=3001\nAPI_HOST=localhost\n');
|
|
|
|
const backupPath = createBackup(originalPath);
|
|
|
|
expect(fs.existsSync(backupPath)).toBe(true);
|
|
// Backup should match pattern: .env.backup.YYYYMMDD_HHmmss
|
|
expect(backupPath).toMatch(/\.env\.backup\.\d{8}_\d{6}$/);
|
|
// Content should be identical
|
|
const originalContent = fs.readFileSync(originalPath, 'utf8');
|
|
const backupContent = fs.readFileSync(backupPath, 'utf8');
|
|
expect(backupContent).toBe(originalContent);
|
|
});
|
|
|
|
test('creates numbered backup when timestamp backup already exists', () => {
|
|
const originalPath = path.join(tmpDir, '.env');
|
|
fs.writeFileSync(originalPath, 'PORT=3001\n');
|
|
|
|
// Create first backup
|
|
const firstBackup = createBackup(originalPath);
|
|
expect(fs.existsSync(firstBackup)).toBe(true);
|
|
|
|
// Modify original
|
|
fs.writeFileSync(originalPath, 'PORT=4000\n');
|
|
|
|
// Create second backup — since timestamp is same second, it should use .bak.N
|
|
// We simulate by creating the expected timestamp backup manually
|
|
const now = new Date();
|
|
const timestamp = now.getFullYear().toString() +
|
|
String(now.getMonth() + 1).padStart(2, '0') +
|
|
String(now.getDate()).padStart(2, '0') + '_' +
|
|
String(now.getHours()).padStart(2, '0') +
|
|
String(now.getMinutes()).padStart(2, '0') +
|
|
String(now.getSeconds()).padStart(2, '0');
|
|
const expectedTimestampPath = `${originalPath}.backup.${timestamp}`;
|
|
|
|
// If the timestamp backup already exists (from first call), second call uses .bak.N
|
|
if (fs.existsSync(expectedTimestampPath)) {
|
|
const secondBackup = createBackup(originalPath);
|
|
expect(secondBackup).toMatch(/\.bak\.\d+$/);
|
|
expect(fs.existsSync(secondBackup)).toBe(true);
|
|
const content = fs.readFileSync(secondBackup, 'utf8');
|
|
expect(content).toBe('PORT=4000\n');
|
|
}
|
|
});
|
|
|
|
test('backup preserves file content exactly', () => {
|
|
const originalPath = path.join(tmpDir, '.env');
|
|
const content = '# --- Core Settings ---\nPORT=3001\nAPI_HOST=localhost\n\n# Custom\nMY_VAR=hello\n';
|
|
fs.writeFileSync(originalPath, content);
|
|
|
|
const backupPath = createBackup(originalPath);
|
|
const backupContent = fs.readFileSync(backupPath, 'utf8');
|
|
expect(backupContent).toBe(content);
|
|
});
|
|
});
|