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:
277
backend/__tests__/config-wizard-validation.property.test.js
Normal file
277
backend/__tests__/config-wizard-validation.property.test.js
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Property-Based Tests: Config Wizard Validation Functions
|
||||
*
|
||||
* Feature: config-wizard
|
||||
*
|
||||
* Tests the pure validation functions from `configure.js`.
|
||||
*
|
||||
* Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.6, 5.7
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
const {
|
||||
validatePort,
|
||||
validateCorsOrigins,
|
||||
validateDatabaseUrl,
|
||||
validateSessionSecret,
|
||||
validateRequired
|
||||
} = require('../../configure.js');
|
||||
|
||||
// --- Property 8: Port validation ---
|
||||
describe('Property 8: Port validation', () => {
|
||||
/**
|
||||
* **Validates: Requirements 5.2**
|
||||
*
|
||||
* For any string, validatePort returns true iff the trimmed value is an integer
|
||||
* in [1, 65535] with no leading zeros.
|
||||
*/
|
||||
test('validatePort returns true iff trimmed value is integer in [1, 65535] with no leading zeros', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (input) => {
|
||||
const result = validatePort(input);
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Compute expected result
|
||||
if (trimmed === '') return result === false;
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
if (isNaN(parsed)) return result === false;
|
||||
// Must be exact string representation (no leading zeros, no floats, no extra chars)
|
||||
if (trimmed !== String(parsed)) return result === false;
|
||||
const expected = parsed >= 1 && parsed <= 65535;
|
||||
return result === expected;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validatePort returns true for valid port numbers', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 65535 }), (port) => {
|
||||
return validatePort(String(port)) === true;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validatePort returns false for out-of-range integers', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
fc.integer({ min: 65536, max: 999999 }),
|
||||
fc.integer({ min: -999999, max: 0 })
|
||||
),
|
||||
(port) => {
|
||||
return validatePort(String(port)) === false;
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validatePort rejects leading zeros', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 9999 }), (port) => {
|
||||
const withLeadingZero = '0' + String(port);
|
||||
return validatePort(withLeadingZero) === false;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 9: CORS origins validation ---
|
||||
describe('Property 9: CORS origins validation', () => {
|
||||
/**
|
||||
* **Validates: Requirements 5.3, 5.7**
|
||||
*
|
||||
* For any comma-separated string, validateCorsOrigins returns true iff at least
|
||||
* one valid entry remains after trim/discard and each starts with http:// or
|
||||
* https:// followed by non-whitespace.
|
||||
*/
|
||||
test('validateCorsOrigins returns true iff at least one valid entry remains after trim/discard', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (input) => {
|
||||
const result = validateCorsOrigins(input);
|
||||
|
||||
// Compute expected
|
||||
const entries = input.split(',')
|
||||
.map(entry => entry.trim())
|
||||
.filter(entry => entry.length > 0);
|
||||
|
||||
if (entries.length === 0) return result === false;
|
||||
|
||||
const allValid = entries.every(entry => /^https?:\/\/\S+/.test(entry));
|
||||
return result === allValid;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validateCorsOrigins accepts valid http/https origins', () => {
|
||||
const validOriginArb = fc.oneof(
|
||||
fc.webUrl().map(url => url.split('/').slice(0, 3).join('/')),
|
||||
fc.constantFrom(
|
||||
'http://localhost:3000',
|
||||
'https://example.com',
|
||||
'http://192.168.1.1:8080'
|
||||
)
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(validOriginArb, { minLength: 1, maxLength: 5 }),
|
||||
(origins) => {
|
||||
return validateCorsOrigins(origins.join(',')) === true;
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validateCorsOrigins rejects entries without http/https prefix', () => {
|
||||
const invalidOriginArb = fc.stringMatching(/^[a-z][a-z0-9]*:\/\/\S+/, { minLength: 4, maxLength: 30 })
|
||||
.filter(s => !s.startsWith('http://') && !s.startsWith('https://'));
|
||||
|
||||
fc.assert(
|
||||
fc.property(invalidOriginArb, (origin) => {
|
||||
return validateCorsOrigins(origin) === false;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 10: DATABASE_URL validation ---
|
||||
describe('Property 10: DATABASE_URL validation', () => {
|
||||
/**
|
||||
* **Validates: Requirements 5.4**
|
||||
*
|
||||
* For any string, validateDatabaseUrl returns true iff it starts with
|
||||
* `postgresql://` or equals `sqlite`.
|
||||
*/
|
||||
test('validateDatabaseUrl returns true iff starts with postgresql:// or equals sqlite', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (input) => {
|
||||
const result = validateDatabaseUrl(input);
|
||||
const expected = input.startsWith('postgresql://') || input === 'sqlite';
|
||||
return result === expected;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validateDatabaseUrl accepts any postgresql:// URL', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ minLength: 0, maxLength: 100 }), (suffix) => {
|
||||
return validateDatabaseUrl('postgresql://' + suffix) === true;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validateDatabaseUrl accepts sqlite literal', () => {
|
||||
expect(validateDatabaseUrl('sqlite')).toBe(true);
|
||||
});
|
||||
|
||||
test('validateDatabaseUrl rejects other strings', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 50 }).filter(
|
||||
s => !s.startsWith('postgresql://') && s !== 'sqlite'
|
||||
),
|
||||
(input) => {
|
||||
return validateDatabaseUrl(input) === false;
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 11: SESSION_SECRET validation ---
|
||||
describe('Property 11: SESSION_SECRET validation', () => {
|
||||
/**
|
||||
* **Validates: Requirements 5.6**
|
||||
*
|
||||
* For any string, validateSessionSecret returns true iff length in [16, 256].
|
||||
*/
|
||||
test('validateSessionSecret returns true iff length in [16, 256]', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ minLength: 0, maxLength: 300 }), (input) => {
|
||||
const result = validateSessionSecret(input);
|
||||
const expected = input.length >= 16 && input.length <= 256;
|
||||
return result === expected;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validateSessionSecret accepts strings of length 16-256', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 16, max: 256 }).chain(len =>
|
||||
fc.string({ minLength: len, maxLength: len })
|
||||
),
|
||||
(input) => {
|
||||
return validateSessionSecret(input) === true;
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validateSessionSecret rejects strings shorter than 16', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ minLength: 0, maxLength: 15 }), (input) => {
|
||||
return validateSessionSecret(input) === false;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 12: Required variable rejection of whitespace ---
|
||||
describe('Property 12: Required variable rejection of whitespace', () => {
|
||||
/**
|
||||
* **Validates: Requirements 5.1**
|
||||
*
|
||||
* For any whitespace-only string, validateRequired returns false;
|
||||
* for any string with non-whitespace, returns true.
|
||||
*/
|
||||
test('validateRequired returns false for whitespace-only strings', () => {
|
||||
const whitespaceArb = fc.array(
|
||||
fc.constantFrom(' ', '\t', '\n', '\r', '\f', '\v'),
|
||||
{ minLength: 0, maxLength: 20 }
|
||||
).map(chars => chars.join(''));
|
||||
|
||||
fc.assert(
|
||||
fc.property(whitespaceArb, (input) => {
|
||||
return validateRequired(input) === false;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validateRequired returns true for strings with non-whitespace', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
(input) => {
|
||||
return validateRequired(input) === true;
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('validateRequired equivalence: result matches trim().length > 0', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (input) => {
|
||||
const result = validateRequired(input);
|
||||
const expected = input.trim().length > 0;
|
||||
return result === expected;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user