generated from Labyricorn/labyricorn-project-template
277 lines
12 KiB
JavaScript
277 lines
12 KiB
JavaScript
/**
|
|
* CyberSim OS - Automated Test Suite (Phase 2.5)
|
|
*
|
|
* Runs headless with Node.js to verify localization, branding, login validation,
|
|
* personalization safety, deterministic fingerprinting, and offline integrity.
|
|
*/
|
|
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const rootDir = path.resolve(__dirname, '..');
|
|
const srcDir = path.resolve(rootDir, 'src');
|
|
|
|
// Polyfill Web Crypto for Node test environment if needed
|
|
if (!globalThis.crypto) {
|
|
const cryptoModule = await import('node:crypto');
|
|
globalThis.crypto = cryptoModule.webcrypto;
|
|
}
|
|
|
|
// Import modules
|
|
import { I18nService } from '../src/js/core/i18n.js';
|
|
import { BrandingManager } from '../src/js/core/branding.js';
|
|
import { ActionDispatcher } from '../src/js/engine/action_dispatcher.js';
|
|
import { calculateScenarioFingerprint } from '../src/js/scenario/fingerprint.js';
|
|
import { validateSchema } from '../src/js/scenario/schema.js';
|
|
import { validateScenario } from '../src/js/scenario/validator.js';
|
|
import { CertificateGenerator } from '../src/js/cert/cert_generator.js';
|
|
import { CertificateVerifier } from '../src/js/cert/cert_verifier.js';
|
|
|
|
let passedTests = 0;
|
|
let totalTests = 0;
|
|
|
|
async function test(name, fn) {
|
|
totalTests++;
|
|
try {
|
|
await fn();
|
|
console.log(` ✓ ${name}`);
|
|
passedTests++;
|
|
} catch (err) {
|
|
console.error(` ✕ ${name}`);
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
async function runAllTests() {
|
|
console.log('\n============================================================');
|
|
console.log(' CyberSim OS Phase 2.5 - Automated Test Suite');
|
|
console.log('============================================================\n');
|
|
|
|
console.log('--- 1. Localization Engine Tests ---');
|
|
const enCatalog = JSON.parse(fs.readFileSync(path.join(srcDir, 'locales/en.json'), 'utf8'));
|
|
const esCatalog = JSON.parse(fs.readFileSync(path.join(srcDir, 'locales/es.json'), 'utf8'));
|
|
|
|
const i18n = new I18nService({ defaultLocale: 'en', enabledLocales: ['en', 'es'] });
|
|
i18n.setDictionary('en', enCatalog);
|
|
i18n.setDictionary('es', esCatalog);
|
|
|
|
await test('Default locale loads English strings', async () => {
|
|
assert.equal(i18n.t('login.welcome'), 'Welcome');
|
|
assert.equal(i18n.t('apps.inlook'), 'Inlook Mail');
|
|
});
|
|
|
|
await test('Alternate locale loads Spanish demonstration strings', async () => {
|
|
await i18n.setLocale('es');
|
|
assert.equal(i18n.t('login.welcome'), 'Bienvenido');
|
|
assert.equal(i18n.t('apps.inlook'), 'Correo Inlook');
|
|
});
|
|
|
|
await test('Missing translation falls back to default and English deterministically', async () => {
|
|
const testI18n = new I18nService({ defaultLocale: 'en', enabledLocales: ['en', 'es'] });
|
|
testI18n.setDictionary('en', { 'only.in.en': 'English Text', 'common.key': 'EN Common' });
|
|
testI18n.setDictionary('es', { 'only.in.es': 'Spanish Text' });
|
|
await testI18n.setLocale('es');
|
|
|
|
// Key in es -> returns es
|
|
assert.equal(testI18n.t('only.in.es'), 'Spanish Text');
|
|
// Key missing in es but present in en -> falls back to en
|
|
assert.equal(testI18n.t('only.in.en'), 'English Text');
|
|
// Completely nonexistent key shows visible indicator
|
|
assert.equal(testI18n.t('nonexistent.key'), '[missing: nonexistent.key]');
|
|
});
|
|
|
|
await test('Localization string interpolation replaces parameters correctly', async () => {
|
|
await i18n.setLocale('en');
|
|
const res = i18n.t('cert.statement', { scenarioTitle: 'NexaCore' });
|
|
assert(res.includes('NexaCore'));
|
|
});
|
|
|
|
await test('Default embedded English catalog contains all cert.* keys without missing labels', async () => {
|
|
const rawI18n = new I18nService({ defaultLocale: 'en' });
|
|
const certTitle = rawI18n.t('cert.title');
|
|
const compTitle = rawI18n.t('cert.competencyTitle');
|
|
const env = rawI18n.t('cert.environment');
|
|
assert(!certTitle.startsWith('[missing:'));
|
|
assert(!compTitle.startsWith('[missing:'));
|
|
assert(!env.startsWith('[missing:'));
|
|
});
|
|
|
|
console.log('\n--- 2. Corporate Branding Tests ---');
|
|
const customConfig = {
|
|
organization: {
|
|
name: 'Acme Security Corp',
|
|
shortName: 'Acme',
|
|
accentColor: '#315A78',
|
|
supportName: 'Acme Helpdesk'
|
|
}
|
|
};
|
|
|
|
await test('Configured corporate branding loads correctly', async () => {
|
|
const branding = new BrandingManager(customConfig);
|
|
const org = branding.getOrganization();
|
|
assert.equal(org.name, 'Acme Security Corp');
|
|
assert.equal(org.shortName, 'Acme');
|
|
assert.equal(org.accentColor, '#315A78');
|
|
});
|
|
|
|
await test('Missing branding fields fall back to CyberSim defaults', async () => {
|
|
const branding = new BrandingManager({});
|
|
const org = branding.getOrganization();
|
|
assert.equal(org.name, 'CyberSim Enterprise');
|
|
assert.equal(org.shortName, 'CyberSim');
|
|
assert.equal(org.accentColor, '#2563eb');
|
|
});
|
|
|
|
console.log('\n--- 3. Personalization & Safe Template Interpolation Tests ---');
|
|
await test('ActionDispatcher resolves {{learner.firstName}}, {{learner.lastName}}, and {{learner.name}}', async () => {
|
|
const mockScenario = {
|
|
learner: { firstName: 'Chris', lastName: 'Morgan', name: 'Chris Morgan', role: 'Specialist' },
|
|
organizations: [{ name: 'NexaCore' }]
|
|
};
|
|
const dispatcher = new ActionDispatcher({ scenario: mockScenario });
|
|
const ctx = dispatcher._buildInterpolationContext();
|
|
assert.equal(ctx['learner.firstName'], 'Chris');
|
|
assert.equal(ctx['learner.lastName'], 'Morgan');
|
|
assert.equal(ctx['learner.name'], 'Chris Morgan');
|
|
|
|
const tmpl = 'Hello {{learner.firstName}} {{learner.lastName}}, full: {{learner.name}}.';
|
|
const interpolated = dispatcher.interpolate(tmpl, ctx);
|
|
assert.equal(interpolated, 'Hello Chris Morgan, full: Chris Morgan.');
|
|
});
|
|
|
|
await test('Malicious HTML entered as first/last name is escaped harmlessly', async () => {
|
|
const xssPayload = '<script>alert("pwned")</script><img src=x onerror=alert(1)>';
|
|
const mockScenario = {
|
|
learner: { firstName: xssPayload, lastName: xssPayload, name: xssPayload },
|
|
organizations: [{ name: 'NexaCore' }]
|
|
};
|
|
const dispatcher = new ActionDispatcher({ scenario: mockScenario });
|
|
const ctx = dispatcher._buildInterpolationContext();
|
|
|
|
assert(!ctx['learner.firstName'].includes('<script>'));
|
|
assert(ctx['learner.firstName'].includes('<script>'));
|
|
assert(ctx['learner.firstName'].includes('<img src=x onerror=alert(1)>'));
|
|
});
|
|
|
|
console.log('\n--- 4. Scenario Validation & Spanish Localization Overlay Tests ---');
|
|
const nexacorePath = path.join(srcDir, 'scenarios/nexacore-orientation/scenario.json');
|
|
const nexacoreEsPath = path.join(srcDir, 'scenarios/nexacore-orientation/locales/es.json');
|
|
const quickstartPath = path.join(srcDir, 'scenarios/quickstart-example/scenario.json');
|
|
const quickstartEsPath = path.join(srcDir, 'scenarios/quickstart-example/locales/es.json');
|
|
|
|
const nexacoreData = JSON.parse(fs.readFileSync(nexacorePath, 'utf8'));
|
|
const nexacoreEsData = JSON.parse(fs.readFileSync(nexacoreEsPath, 'utf8'));
|
|
const quickstartData = JSON.parse(fs.readFileSync(quickstartPath, 'utf8'));
|
|
const quickstartEsData = JSON.parse(fs.readFileSync(quickstartEsPath, 'utf8'));
|
|
|
|
await test('NexaCore scenario schema validates cleanly with supportedLocales and login metadata', async () => {
|
|
const res = validateSchema(nexacoreData);
|
|
assert.equal(res.valid, true, `Errors: ${JSON.stringify(res.errors)}`);
|
|
assert.deepEqual(nexacoreData.supportedLocales, ['en', 'es']);
|
|
assert(nexacoreData.login.networkName);
|
|
});
|
|
|
|
await test('Quickstart scenario schema validates cleanly with supportedLocales and login metadata', async () => {
|
|
const res = validateSchema(quickstartData);
|
|
assert.equal(res.valid, true, `Errors: ${JSON.stringify(res.errors)}`);
|
|
assert.deepEqual(quickstartData.supportedLocales, ['en', 'es']);
|
|
assert(quickstartData.login.networkName);
|
|
});
|
|
|
|
await test('Spanish translation overlay does not alter scenario structure, IDs, or scoring rules', async () => {
|
|
assert(nexacoreEsData.title);
|
|
assert(nexacoreEsData.messages.email_welcome.subject);
|
|
assert(quickstartEsData.messages.email_welcome.body.includes('{{learner.firstName}}'));
|
|
});
|
|
|
|
console.log('\n--- 5. Deterministic Scenario Fingerprinting & Certificate Verification Tests ---');
|
|
let fingerprintEn = '';
|
|
let fingerprintEs = '';
|
|
|
|
await test('Scenario fingerprint calculation is deterministic', async () => {
|
|
fingerprintEn = await calculateScenarioFingerprint(nexacoreData);
|
|
assert.equal(typeof fingerprintEn, 'string');
|
|
assert.equal(fingerprintEn.length, 64);
|
|
});
|
|
|
|
await test('Scenario fingerprint remains 100% identical when loaded with Spanish locale overlay', async () => {
|
|
const localizedScenario = JSON.parse(JSON.stringify(nexacoreData));
|
|
localizedScenario._canonicalScenario = nexacoreData;
|
|
localizedScenario.title = nexacoreEsData.title; // Overlay title
|
|
fingerprintEs = await calculateScenarioFingerprint(localizedScenario);
|
|
assert.equal(fingerprintEs, fingerprintEn, 'Fingerprints must match exactly across all locales!');
|
|
});
|
|
|
|
let generatedCert = null;
|
|
|
|
await test('CertificateGenerator creates valid structured *.cybercert with canonical integrity hash', async () => {
|
|
const mockScoreResult = {
|
|
totalScore: 90,
|
|
isPassed: true,
|
|
passingThreshold: 80,
|
|
categories: {
|
|
'threat-detection': { score: 30, max: 30, label: 'Threat Detection' },
|
|
'safe-handling': { score: 30, max: 40, label: 'Safe Handling' },
|
|
'policy-adherence': { score: 30, max: 30, label: 'Policy Adherence' }
|
|
}
|
|
};
|
|
const gen = new CertificateGenerator(nexacoreData, mockScoreResult, fingerprintEn);
|
|
generatedCert = await gen.createCertificateData('Maria Garcia');
|
|
|
|
assert.equal(generatedCert.schema_version, 1);
|
|
assert.equal(generatedCert.learner.name, 'Maria Garcia');
|
|
assert.equal(generatedCert.scenario_fingerprint, fingerprintEn);
|
|
assert.equal(generatedCert.evaluation.score, 90);
|
|
assert.equal(generatedCert.evaluation.passed, true);
|
|
assert(generatedCert.integrity_hash);
|
|
});
|
|
|
|
await test('CertificateVerifier verifies genuine certificate successfully offline', async () => {
|
|
const verifyResult = await CertificateVerifier.verify(generatedCert);
|
|
assert.equal(verifyResult.valid, true);
|
|
assert.equal(verifyResult.passed, true);
|
|
});
|
|
|
|
await test('CertificateVerifier rejects tampered score or modified learner name', async () => {
|
|
const tamperedCert = JSON.parse(JSON.stringify(generatedCert));
|
|
tamperedCert.evaluation.score = 100; // Alter score without updating integrity hash
|
|
const verifyResult = await CertificateVerifier.verify(tamperedCert);
|
|
assert.equal(verifyResult.valid, false);
|
|
});
|
|
|
|
console.log('\n--- 6. Offline & Zero External Dependencies Integrity Test ---');
|
|
await test('Codebase contains zero remote external CDN or runtime internet dependencies', async () => {
|
|
const scanDir = (dir) => {
|
|
const files = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const f of files) {
|
|
const fullPath = path.join(dir, f.name);
|
|
if (f.isDirectory()) {
|
|
scanDir(fullPath);
|
|
} else if (/\.(html|js|css)$/.test(f.name)) {
|
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
// Disallow external CDNs like cdnjs, unpkg, google fonts, etc.
|
|
assert(!content.includes('fonts.googleapis.com'), `Google Fonts detected in ${f.name}`);
|
|
assert(!content.includes('cdnjs.cloudflare.com'), `CDNJS detected in ${f.name}`);
|
|
assert(!content.includes('unpkg.com'), `Unpkg detected in ${f.name}`);
|
|
assert(!content.includes('cdn.jsdelivr.net'), `jsDelivr detected in ${f.name}`);
|
|
}
|
|
}
|
|
};
|
|
scanDir(srcDir);
|
|
});
|
|
|
|
console.log('\n============================================================');
|
|
console.log(` Tests Passed: ${passedTests} / ${totalTests}`);
|
|
console.log('============================================================\n');
|
|
|
|
if (passedTests !== totalTests) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
runAllTests();
|