Add unified setup script (configure.js) merging deploy + config wizard
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.
This commit is contained in:
756
backend/__tests__/config-wizard.integration.test.js
Normal file
756
backend/__tests__/config-wizard.integration.test.js
Normal file
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user