Merge pull request #40 from outis1one/claude/read-repo-YMu21
fix: move test suite JS to external file for Firefox CSP, bump 0.9.3
This commit is contained in:
@@ -32,7 +32,7 @@ build() {
|
||||
|
||||
# Copy other root files
|
||||
cp README.md "$out/" 2>/dev/null || true
|
||||
cp test-suite.html "$out/" 2>/dev/null || true
|
||||
cp test-suite.html test-suite.js "$out/" 2>/dev/null || true
|
||||
|
||||
echo " → $out/ ready"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Silent Send",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.3",
|
||||
"description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Silent Send",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.3",
|
||||
"description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "silent-send",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.3",
|
||||
"private": true,
|
||||
"license": "BSL-1.1",
|
||||
"description": "Browser extension that substitutes personal data before sending to AI services",
|
||||
|
||||
@@ -631,7 +631,7 @@
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>Silent Send v0.9.2</p>
|
||||
<p>Silent Send v0.9.3</p>
|
||||
<p style="font-size:11px;color:#9ca3af;margin-top:6px;max-width:600px">
|
||||
Silent Send is a convenience tool, not a security guarantee. Third-party sites may change how they send data at any time, which can cause missed substitutions without warning. You are responsible for verifying your data before sending. See the <a href="https://github.com/outis1one/silent-send/blob/main/LICENSE" target="_blank" style="color:#6b7280">LICENSE</a> for full terms.
|
||||
</p>
|
||||
|
||||
+1
-479
@@ -26,490 +26,12 @@
|
||||
<body>
|
||||
<h1>Silent Send - Feature Test Suite</h1>
|
||||
<p style="margin-bottom:12px;color:#6b7280">
|
||||
Load this page as an extension page (or inject via dev console on the Options page).<br>
|
||||
Tests exercise sync, encryption, storage, auto-redact, and domain management.
|
||||
</p>
|
||||
<button class="primary" id="btnRunTests">Run All Tests</button>
|
||||
<button id="btnClear">Clear</button>
|
||||
<div id="results" style="margin-top:16px"></div>
|
||||
<div id="summary"></div>
|
||||
|
||||
<script type="module">
|
||||
import Storage from './src/lib/storage.js';
|
||||
import SilentSendSync from './src/lib/sync.js';
|
||||
import SilentSendCrypto from './src/lib/crypto.js';
|
||||
import AutoRedact from './src/lib/auto-redact.js';
|
||||
import AutoDetect from './src/lib/auto-detect.js';
|
||||
import SmartPatterns from './src/lib/smart-patterns.js';
|
||||
import SubstitutionEngine from './src/lib/substitution-engine.js';
|
||||
|
||||
const results = [];
|
||||
let currentGroup = '';
|
||||
|
||||
function group(name) {
|
||||
currentGroup = name;
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
results.push({ group: currentGroup, name, fn });
|
||||
}
|
||||
|
||||
function renderResults(outcomes) {
|
||||
const el = document.getElementById('results');
|
||||
const groups = {};
|
||||
for (const o of outcomes) {
|
||||
if (!groups[o.group]) groups[o.group] = [];
|
||||
groups[o.group].push(o);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const [groupName, tests] of Object.entries(groups)) {
|
||||
html += `<div class="test-group"><h2>${groupName}</h2>`;
|
||||
for (const t of tests) {
|
||||
const icon = t.status === 'pass' ? '✓' : t.status === 'fail' ? '✗' : '○';
|
||||
const cls = t.status;
|
||||
html += `<div class="test"><span class="status ${cls}">${icon}</span> ${t.name}</div>`;
|
||||
if (t.error) html += `<div class="detail">${t.error}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
|
||||
const passed = outcomes.filter(o => o.status === 'pass').length;
|
||||
const failed = outcomes.filter(o => o.status === 'fail').length;
|
||||
const skipped = outcomes.filter(o => o.status === 'skip').length;
|
||||
const sum = document.getElementById('summary');
|
||||
sum.className = failed > 0 ? 'has-fail' : 'all-pass';
|
||||
sum.textContent = `${passed} passed, ${failed} failed, ${skipped} skipped — ${outcomes.length} total`;
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
results.length = 0;
|
||||
|
||||
// ============================================================
|
||||
// STORAGE TESTS
|
||||
// ============================================================
|
||||
group('Storage — Settings');
|
||||
|
||||
test('Default settings have autoRedact=true', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if (s.autoRedact !== true) throw `Expected autoRedact=true, got ${s.autoRedact}`;
|
||||
});
|
||||
|
||||
test('Default settings have customRedactPatterns=[]', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if (!Array.isArray(s.customRedactPatterns)) throw `Expected array, got ${typeof s.customRedactPatterns}`;
|
||||
});
|
||||
|
||||
test('Default settings have maxLogEntries=100', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if (s.maxLogEntries !== 100) throw `Expected 100, got ${s.maxLogEntries}`;
|
||||
});
|
||||
|
||||
test('Default settings have browserSync=false', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if (s.browserSync !== false) throw `Expected false, got ${s.browserSync}`;
|
||||
});
|
||||
|
||||
test('Save and read settings round-trip', async () => {
|
||||
await Storage.saveSettings({ autoRedact: false });
|
||||
const s = await Storage.getSettings();
|
||||
if (s.autoRedact !== false) throw `Expected false after save, got ${s.autoRedact}`;
|
||||
await Storage.saveSettings({ autoRedact: true }); // restore
|
||||
});
|
||||
|
||||
group('Storage — Activity Log');
|
||||
|
||||
test('Activity log respects maxLogEntries=100', async () => {
|
||||
// Save current log
|
||||
const origLog = await Storage.getLog();
|
||||
await Storage.clearLog();
|
||||
|
||||
// Add 110 entries
|
||||
for (let i = 0; i < 110; i++) {
|
||||
await Storage.addLogEntry({ type: 'test', original: `test${i}`, replaced: `fake${i}` });
|
||||
}
|
||||
const log = await Storage.getLog();
|
||||
if (log.length > 100) throw `Expected <=100 entries, got ${log.length}`;
|
||||
|
||||
await Storage.clearLog();
|
||||
});
|
||||
|
||||
group('Storage — Mappings');
|
||||
|
||||
test('Add and retrieve mapping', async () => {
|
||||
const m = await Storage.addMapping({ real: 'TestReal', substitute: 'TestFake', category: 'general' });
|
||||
if (!m.id) throw 'Mapping has no id';
|
||||
const all = await Storage.getMappings();
|
||||
const found = all.find(x => x.id === m.id);
|
||||
if (!found) throw 'Mapping not found after add';
|
||||
await Storage.deleteMapping(m.id);
|
||||
});
|
||||
|
||||
group('Storage — Profiles');
|
||||
|
||||
test('Add and retrieve profile', async () => {
|
||||
const p = await Storage.addProfile('Test Profile');
|
||||
if (!p.id) throw 'Profile has no id';
|
||||
const all = await Storage.getProfiles();
|
||||
const found = all.find(x => x.id === p.id);
|
||||
if (!found) throw 'Profile not found after add';
|
||||
await Storage.deleteProfile(p.id);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ENCRYPTION TESTS
|
||||
// ============================================================
|
||||
group('Encryption — Setup & Authentication');
|
||||
|
||||
test('Setup encryption with password', async () => {
|
||||
// Clean state
|
||||
await SilentSendSync.disableEncryption().catch(() => {});
|
||||
|
||||
const result = await SilentSendSync.setupEncryption({
|
||||
password: 'testpass123',
|
||||
enableTOTP: false,
|
||||
ttlDays: 90,
|
||||
});
|
||||
if (!result.success) throw `Setup failed: ${result.reason}`;
|
||||
});
|
||||
|
||||
test('isEncryptionEnabled returns true after setup', async () => {
|
||||
const enabled = await SilentSendSync.isEncryptionEnabled();
|
||||
if (!enabled) throw 'Expected encryption to be enabled';
|
||||
});
|
||||
|
||||
test('Authenticate with correct password', async () => {
|
||||
const result = await SilentSendSync.authenticate('testpass123');
|
||||
if (!result.success) throw `Auth failed: ${result.reason}`;
|
||||
});
|
||||
|
||||
test('Authenticate with wrong password fails', async () => {
|
||||
// Clear cached key first to force re-auth
|
||||
await SilentSendCrypto.clearCachedKey();
|
||||
const result = await SilentSendSync.authenticate('wrongpassword');
|
||||
if (result.success) throw 'Expected auth to fail with wrong password';
|
||||
// Re-auth with correct password
|
||||
await SilentSendSync.authenticate('testpass123');
|
||||
});
|
||||
|
||||
group('Encryption — At-Rest');
|
||||
|
||||
test('Data is encrypted at rest when encryption enabled', async () => {
|
||||
const api = (typeof browser !== 'undefined') ? browser : chrome;
|
||||
await Storage.saveMappings([{ id: 'enc-test', real: 'EncSecret', substitute: 'EncFake' }]);
|
||||
const raw = await api.storage.local.get('ss_mappings');
|
||||
const val = raw.ss_mappings;
|
||||
if (!val?._ssLocalEncrypted) throw 'Expected _ssLocalEncrypted flag on stored data';
|
||||
// Clean up
|
||||
await Storage.saveMappings([]);
|
||||
});
|
||||
|
||||
test('Encrypted data reads back correctly', async () => {
|
||||
await Storage.saveMappings([{ id: 'enc-rt', real: 'RoundTrip', substitute: 'RTFake' }]);
|
||||
const mappings = await Storage.getMappings();
|
||||
const found = mappings.find(m => m.id === 'enc-rt');
|
||||
if (!found) throw 'Mapping not found after encrypted round-trip';
|
||||
if (found.real !== 'RoundTrip') throw `Expected 'RoundTrip', got '${found.real}'`;
|
||||
await Storage.saveMappings([]);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SYNC — ENCRYPTION MANDATORY
|
||||
// ============================================================
|
||||
group('Sync — Encryption Required');
|
||||
|
||||
test('Sync code export works when encryption enabled', async () => {
|
||||
const code = await SilentSendSync.exportSyncCode();
|
||||
if (code?.needsEncryption) throw 'Should not need encryption — it is enabled';
|
||||
if (code?.needsAuth) throw 'Should not need auth — key is cached';
|
||||
if (typeof code !== 'string') throw `Expected string, got ${typeof code}`;
|
||||
if (code.length < 10) throw 'Sync code seems too short';
|
||||
});
|
||||
|
||||
test('Sync code export fails without encryption', async () => {
|
||||
await SilentSendSync.disableEncryption();
|
||||
const code = await SilentSendSync.exportSyncCode();
|
||||
if (!code?.needsEncryption) throw 'Expected needsEncryption=true when encryption disabled';
|
||||
// Re-enable
|
||||
await SilentSendSync.setupEncryption({ password: 'testpass123' });
|
||||
});
|
||||
|
||||
test('Sync code import round-trip', async () => {
|
||||
// Add test data
|
||||
await Storage.saveMappings([{ id: 'sync-test', real: 'SyncMe', substitute: 'SyncFake' }]);
|
||||
const code = await SilentSendSync.exportSyncCode();
|
||||
|
||||
// Clear and re-import
|
||||
await Storage.saveMappings([]);
|
||||
const result = await SilentSendSync.importSyncCode(code, { force: true });
|
||||
if (!result.success) throw `Import failed: ${result.reason}`;
|
||||
|
||||
const mappings = await Storage.getMappings();
|
||||
const found = mappings.find(m => m.real === 'SyncMe');
|
||||
if (!found) throw 'Mapping not found after sync code round-trip';
|
||||
await Storage.saveMappings([]);
|
||||
});
|
||||
|
||||
test('pushToSyncStorage silently skips without encryption', async () => {
|
||||
await SilentSendSync.disableEncryption();
|
||||
// Should not throw, just silently skip
|
||||
await SilentSendSync.pushToSyncStorage();
|
||||
// Re-enable
|
||||
await SilentSendSync.setupEncryption({ password: 'testpass123' });
|
||||
});
|
||||
|
||||
test('pushToGist fails without encryption', async () => {
|
||||
await SilentSendSync.disableEncryption();
|
||||
const result = await SilentSendSync.pushToGist('fake-token');
|
||||
if (!result.needsEncryption) throw 'Expected needsEncryption=true';
|
||||
// Re-enable
|
||||
await SilentSendSync.setupEncryption({ password: 'testpass123' });
|
||||
});
|
||||
|
||||
test('pushToUrl fails without encryption', async () => {
|
||||
await SilentSendSync.disableEncryption();
|
||||
const result = await SilentSendSync.pushToUrl({ url: 'https://example.com/sync' });
|
||||
if (!result.needsEncryption) throw 'Expected needsEncryption=true';
|
||||
// Re-enable
|
||||
await SilentSendSync.setupEncryption({ password: 'testpass123' });
|
||||
});
|
||||
|
||||
group('Sync — Disable Encryption Disables Sync');
|
||||
|
||||
test('Disabling encryption sets browserSync=false', async () => {
|
||||
await Storage.saveSettings({ browserSync: true });
|
||||
await SilentSendSync.disableEncryption();
|
||||
const s = await Storage.getSettings();
|
||||
if (s.browserSync !== false) throw `Expected browserSync=false, got ${s.browserSync}`;
|
||||
// Re-enable encryption
|
||||
await SilentSendSync.setupEncryption({ password: 'testpass123' });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ENCRYPTION WITH TOTP
|
||||
// ============================================================
|
||||
group('Encryption — TOTP');
|
||||
|
||||
test('Setup encryption with TOTP', async () => {
|
||||
await SilentSendSync.disableEncryption().catch(() => {});
|
||||
const result = await SilentSendSync.setupEncryption({
|
||||
password: 'totptest123',
|
||||
enableTOTP: true,
|
||||
ttlDays: 90,
|
||||
});
|
||||
if (!result.success) throw `Setup failed: ${result.reason}`;
|
||||
if (!result.totpSecret) throw 'Expected TOTP secret in response';
|
||||
if (!result.totpURI) throw 'Expected TOTP URI in response';
|
||||
});
|
||||
|
||||
test('TOTP secret is encrypted at rest', async () => {
|
||||
const config = await SilentSendSync._getSyncEncryption();
|
||||
if (config.totpSecret) throw 'TOTP secret should be encrypted, not plaintext';
|
||||
if (!config._totpEncrypted) throw 'Expected _totpEncrypted blob';
|
||||
});
|
||||
|
||||
test('Clean up — disable encryption', async () => {
|
||||
await SilentSendSync.disableEncryption();
|
||||
const enabled = await SilentSendSync.isEncryptionEnabled();
|
||||
if (enabled) throw 'Encryption should be disabled';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// AUTO-REDACT TESTS
|
||||
// ============================================================
|
||||
group('Auto-Redact — Built-in Patterns');
|
||||
|
||||
test('Detects OpenAI key', () => {
|
||||
const result = AutoRedact.redact('my key is sk-abc123def456ghi789jkl012mno');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
if (!result.text.includes('[REDACTED-OPENAI-KEY]')) throw 'Expected [REDACTED-OPENAI-KEY]';
|
||||
});
|
||||
|
||||
test('Detects GitHub token', () => {
|
||||
const result = AutoRedact.redact('token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
});
|
||||
|
||||
test('Detects SSN', () => {
|
||||
const result = AutoRedact.redact('my ssn is 123-45-6789');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
if (!result.text.includes('[REDACTED-SSN]')) throw 'Expected [REDACTED-SSN]';
|
||||
});
|
||||
|
||||
test('Detects credit card', () => {
|
||||
const result = AutoRedact.redact('card: 4111 1111 1111 1111');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
});
|
||||
|
||||
test('Detects Bearer token', () => {
|
||||
const result = AutoRedact.redact('Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9abcdef');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
});
|
||||
|
||||
test('Detects private key block', () => {
|
||||
const result = AutoRedact.redact('-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
});
|
||||
|
||||
test('Detects connection string', () => {
|
||||
const result = AutoRedact.redact('mongodb://admin:secret@db.example.com:27017/mydb');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
});
|
||||
|
||||
group('Auto-Redact — Custom Patterns');
|
||||
|
||||
test('Custom pattern matches', () => {
|
||||
const custom = [{ name: 'ControlD', pattern: 'dns\\.controld\\.com/[A-Za-z0-9;]+', redact: '[REDACTED-CONTROLD]', enabled: true }];
|
||||
const result = AutoRedact.redact('url: https://dns.controld.com/sasdkj;kjasda', custom);
|
||||
if (result.redactions.length === 0) throw 'Expected custom pattern redaction';
|
||||
if (!result.text.includes('[REDACTED-CONTROLD]')) throw 'Expected [REDACTED-CONTROLD]';
|
||||
});
|
||||
|
||||
test('Disabled custom pattern does not match', () => {
|
||||
const custom = [{ name: 'ControlD', pattern: 'dns\\.controld\\.com/[A-Za-z0-9;]+', redact: '[REDACTED]', enabled: false }];
|
||||
const result = AutoRedact.redact('url: https://dns.controld.com/sasdkj;kjasda', custom);
|
||||
if (result.text.includes('[REDACTED]')) throw 'Disabled pattern should not match';
|
||||
});
|
||||
|
||||
test('Invalid regex in custom pattern is skipped gracefully', () => {
|
||||
const custom = [{ name: 'Bad', pattern: '[invalid(regex', redact: '[X]', enabled: true }];
|
||||
// Should not throw
|
||||
const result = AutoRedact.redact('test text', custom);
|
||||
if (result.text !== 'test text') throw 'Text should be unchanged';
|
||||
});
|
||||
|
||||
test('Redactions use category=redact', () => {
|
||||
const result = AutoRedact.redact('key: sk-abc123def456ghi789jkl012mno');
|
||||
if (result.redactions.length > 0 && result.redactions[0].category !== 'redact') {
|
||||
throw `Expected category 'redact', got '${result.redactions[0].category}'`;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SUBSTITUTION ENGINE
|
||||
// ============================================================
|
||||
group('Substitution Engine');
|
||||
|
||||
test('Basic substitution works', () => {
|
||||
const result = SubstitutionEngine.substitute('Hello John Smith', [
|
||||
{ real: 'John Smith', substitute: 'Alex Demo', enabled: true }
|
||||
]);
|
||||
if (!result.text.includes('Alex Demo')) throw `Expected 'Alex Demo' in output`;
|
||||
});
|
||||
|
||||
test('Case insensitive by default', () => {
|
||||
const result = SubstitutionEngine.substitute('hello JOHN SMITH', [
|
||||
{ real: 'John Smith', substitute: 'Alex Demo', enabled: true, caseSensitive: false }
|
||||
]);
|
||||
if (!result.text.includes('Alex Demo')) throw 'Expected case-insensitive match';
|
||||
});
|
||||
|
||||
test('Disabled mapping is skipped', () => {
|
||||
const result = SubstitutionEngine.substitute('Hello John Smith', [
|
||||
{ real: 'John Smith', substitute: 'Alex Demo', enabled: false }
|
||||
]);
|
||||
if (result.text.includes('Alex Demo')) throw 'Disabled mapping should not substitute';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SMART PATTERNS
|
||||
// ============================================================
|
||||
group('Smart Patterns — Identity');
|
||||
|
||||
test('Email substitution', () => {
|
||||
const identity = {
|
||||
emails: [{ real: 'john@gmail.com', substitute: 'alex@example.com' }],
|
||||
names: [], usernames: [], phones: [], hostnames: [],
|
||||
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
|
||||
};
|
||||
const result = SmartPatterns.substitute('email: john@gmail.com', identity);
|
||||
if (!result.text.includes('alex@example.com')) throw 'Expected email substitution';
|
||||
});
|
||||
|
||||
test('Name substitution', () => {
|
||||
const identity = {
|
||||
names: [{ real: 'John Smith', substitute: 'Alex Demo' }],
|
||||
emails: [], usernames: [], phones: [], hostnames: [],
|
||||
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
|
||||
};
|
||||
const result = SmartPatterns.substitute('My name is John Smith', identity);
|
||||
if (!result.text.includes('Alex Demo')) throw 'Expected name substitution';
|
||||
});
|
||||
|
||||
test('Phone substitution', () => {
|
||||
const identity = {
|
||||
phones: [{ real: '555-123-4567', substitute: '555-000-0000' }],
|
||||
names: [], emails: [], usernames: [], hostnames: [],
|
||||
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
|
||||
};
|
||||
const result = SmartPatterns.substitute('call 555-123-4567', identity);
|
||||
if (!result.text.includes('555-000-0000')) throw 'Expected phone substitution';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// AUTO-DETECT (PII Scanner)
|
||||
// ============================================================
|
||||
group('Auto-Detect — PII Patterns');
|
||||
|
||||
test('Detects private IP addresses', () => {
|
||||
const warnings = AutoDetect.scan('server at 192.168.1.100', {});
|
||||
const ipWarn = warnings.find(w => w.type === 'private-ip');
|
||||
if (!ipWarn) throw 'Expected private IP detection';
|
||||
});
|
||||
|
||||
test('Detects SSN pattern', () => {
|
||||
const warnings = AutoDetect.scan('ssn: 123-45-6789', {});
|
||||
// SSN may be caught by auto-detect or auto-redact — either is fine
|
||||
if (warnings.length === 0) {
|
||||
// Auto-redact catches it, auto-detect may not duplicate — this is OK
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SETTINGS INTEGRITY
|
||||
// ============================================================
|
||||
group('Settings — No Stale Keys');
|
||||
|
||||
test('No secretScanning key in defaults', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if ('secretScanning' in s && !('autoRedact' in s)) throw 'Found stale secretScanning key';
|
||||
});
|
||||
|
||||
test('No customSecretPatterns key in defaults', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if ('customSecretPatterns' in s && !('customRedactPatterns' in s)) throw 'Found stale customSecretPatterns key';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// RUN ALL
|
||||
// ============================================================
|
||||
|
||||
async function runAllTests() {
|
||||
document.getElementById('results').innerHTML = '<p style="color:#6b7280">Running tests...</p>';
|
||||
const outcomes = [];
|
||||
for (const t of results) {
|
||||
try {
|
||||
await t.fn();
|
||||
outcomes.push({ group: t.group, name: t.name, status: 'pass' });
|
||||
} catch (e) {
|
||||
const msg = typeof e === 'string' ? e : (e?.message || String(e));
|
||||
outcomes.push({ group: t.group, name: t.name, status: 'fail', error: msg });
|
||||
}
|
||||
}
|
||||
renderResults(outcomes);
|
||||
}
|
||||
|
||||
// Wire up buttons (CSP blocks inline onclick)
|
||||
document.getElementById('btnRunTests').addEventListener('click', runAllTests);
|
||||
document.getElementById('btnClear').addEventListener('click', () => {
|
||||
document.getElementById('results').innerHTML = '';
|
||||
document.getElementById('summary').textContent = '';
|
||||
});
|
||||
|
||||
// Show ready state
|
||||
console.log(`[Silent Send Tests] ${results.length} tests registered. Click "Run All Tests" to start.`);
|
||||
document.getElementById('results').innerHTML = `<p style="color:#6b7280">${results.length} tests loaded. Click "Run All Tests" to start.</p>`;
|
||||
</script>
|
||||
<script src="test-suite.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
import Storage from './src/lib/storage.js';
|
||||
import SilentSendSync from './src/lib/sync.js';
|
||||
import SilentSendCrypto from './src/lib/crypto.js';
|
||||
import AutoRedact from './src/lib/auto-redact.js';
|
||||
import AutoDetect from './src/lib/auto-detect.js';
|
||||
import SmartPatterns from './src/lib/smart-patterns.js';
|
||||
import SubstitutionEngine from './src/lib/substitution-engine.js';
|
||||
|
||||
const tests = [];
|
||||
let currentGroup = '';
|
||||
|
||||
function group(name) { currentGroup = name; }
|
||||
function test(name, fn) { tests.push({ group: currentGroup, name, fn }); }
|
||||
|
||||
function renderResults(outcomes) {
|
||||
const el = document.getElementById('results');
|
||||
const groups = {};
|
||||
for (const o of outcomes) {
|
||||
if (!groups[o.group]) groups[o.group] = [];
|
||||
groups[o.group].push(o);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const [groupName, items] of Object.entries(groups)) {
|
||||
html += `<div class="test-group"><h2>${groupName}</h2>`;
|
||||
for (const t of items) {
|
||||
const icon = t.status === 'pass' ? '\u2713' : t.status === 'fail' ? '\u2717' : '\u25CB';
|
||||
html += `<div class="test"><span class="status ${t.status}">${icon}</span> ${t.name}</div>`;
|
||||
if (t.error) html += `<div class="detail">${t.error}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
|
||||
const passed = outcomes.filter(o => o.status === 'pass').length;
|
||||
const failed = outcomes.filter(o => o.status === 'fail').length;
|
||||
const skipped = outcomes.filter(o => o.status === 'skip').length;
|
||||
const sum = document.getElementById('summary');
|
||||
sum.className = failed > 0 ? 'has-fail' : 'all-pass';
|
||||
sum.textContent = `${passed} passed, ${failed} failed, ${skipped} skipped \u2014 ${outcomes.length} total`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// STORAGE TESTS
|
||||
// ============================================================
|
||||
group('Storage \u2014 Settings');
|
||||
|
||||
test('Default settings have autoRedact=true', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if (s.autoRedact !== true) throw `Expected autoRedact=true, got ${s.autoRedact}`;
|
||||
});
|
||||
|
||||
test('Default settings have customRedactPatterns=[]', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if (!Array.isArray(s.customRedactPatterns)) throw `Expected array, got ${typeof s.customRedactPatterns}`;
|
||||
});
|
||||
|
||||
test('Default settings have maxLogEntries=100', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if (s.maxLogEntries !== 100) throw `Expected 100, got ${s.maxLogEntries}`;
|
||||
});
|
||||
|
||||
test('Default settings have browserSync=false', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if (s.browserSync !== false) throw `Expected false, got ${s.browserSync}`;
|
||||
});
|
||||
|
||||
test('Save and read settings round-trip', async () => {
|
||||
await Storage.saveSettings({ autoRedact: false });
|
||||
const s = await Storage.getSettings();
|
||||
if (s.autoRedact !== false) throw `Expected false after save, got ${s.autoRedact}`;
|
||||
await Storage.saveSettings({ autoRedact: true });
|
||||
});
|
||||
|
||||
group('Storage \u2014 Activity Log');
|
||||
|
||||
test('Activity log respects maxLogEntries=100', async () => {
|
||||
await Storage.clearLog();
|
||||
for (let i = 0; i < 110; i++) {
|
||||
await Storage.addLogEntry({ type: 'test', original: `test${i}`, replaced: `fake${i}` });
|
||||
}
|
||||
const log = await Storage.getLog();
|
||||
if (log.length > 100) throw `Expected <=100 entries, got ${log.length}`;
|
||||
await Storage.clearLog();
|
||||
});
|
||||
|
||||
group('Storage \u2014 Mappings');
|
||||
|
||||
test('Add and retrieve mapping', async () => {
|
||||
const m = await Storage.addMapping({ real: 'TestReal', substitute: 'TestFake', category: 'general' });
|
||||
if (!m.id) throw 'Mapping has no id';
|
||||
const all = await Storage.getMappings();
|
||||
const found = all.find(x => x.id === m.id);
|
||||
if (!found) throw 'Mapping not found after add';
|
||||
await Storage.deleteMapping(m.id);
|
||||
});
|
||||
|
||||
group('Storage \u2014 Profiles');
|
||||
|
||||
test('Add and retrieve profile', async () => {
|
||||
const p = await Storage.addProfile('Test Profile');
|
||||
if (!p.id) throw 'Profile has no id';
|
||||
const all = await Storage.getProfiles();
|
||||
const found = all.find(x => x.id === p.id);
|
||||
if (!found) throw 'Profile not found after add';
|
||||
await Storage.deleteProfile(p.id);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ENCRYPTION TESTS
|
||||
// ============================================================
|
||||
group('Encryption \u2014 Setup & Authentication');
|
||||
|
||||
test('Setup encryption with password', async () => {
|
||||
await SilentSendSync.disableEncryption().catch(() => {});
|
||||
const result = await SilentSendSync.setupEncryption({
|
||||
password: 'testpass123',
|
||||
enableTOTP: false,
|
||||
ttlDays: 90,
|
||||
});
|
||||
if (!result.success) throw `Setup failed: ${result.reason}`;
|
||||
});
|
||||
|
||||
test('isEncryptionEnabled returns true after setup', async () => {
|
||||
const enabled = await SilentSendSync.isEncryptionEnabled();
|
||||
if (!enabled) throw 'Expected encryption to be enabled';
|
||||
});
|
||||
|
||||
test('Authenticate with correct password', async () => {
|
||||
const result = await SilentSendSync.authenticate('testpass123');
|
||||
if (!result.success) throw `Auth failed: ${result.reason}`;
|
||||
});
|
||||
|
||||
test('Authenticate with wrong password fails', async () => {
|
||||
await SilentSendCrypto.clearCachedKey();
|
||||
const result = await SilentSendSync.authenticate('wrongpassword');
|
||||
if (result.success) throw 'Expected auth to fail with wrong password';
|
||||
await SilentSendSync.authenticate('testpass123');
|
||||
});
|
||||
|
||||
group('Encryption \u2014 At-Rest');
|
||||
|
||||
test('Data is encrypted at rest when encryption enabled', async () => {
|
||||
const api = (typeof browser !== 'undefined') ? browser : chrome;
|
||||
await Storage.saveMappings([{ id: 'enc-test', real: 'EncSecret', substitute: 'EncFake' }]);
|
||||
const raw = await api.storage.local.get('ss_mappings');
|
||||
const val = raw.ss_mappings;
|
||||
if (!val?._ssLocalEncrypted) throw 'Expected _ssLocalEncrypted flag on stored data';
|
||||
await Storage.saveMappings([]);
|
||||
});
|
||||
|
||||
test('Encrypted data reads back correctly', async () => {
|
||||
await Storage.saveMappings([{ id: 'enc-rt', real: 'RoundTrip', substitute: 'RTFake' }]);
|
||||
const mappings = await Storage.getMappings();
|
||||
const found = mappings.find(m => m.id === 'enc-rt');
|
||||
if (!found) throw 'Mapping not found after encrypted round-trip';
|
||||
if (found.real !== 'RoundTrip') throw `Expected 'RoundTrip', got '${found.real}'`;
|
||||
await Storage.saveMappings([]);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SYNC — ENCRYPTION MANDATORY
|
||||
// ============================================================
|
||||
group('Sync \u2014 Encryption Required');
|
||||
|
||||
test('Sync code export works when encryption enabled', async () => {
|
||||
const code = await SilentSendSync.exportSyncCode();
|
||||
if (code?.needsEncryption) throw 'Should not need encryption \u2014 it is enabled';
|
||||
if (code?.needsAuth) throw 'Should not need auth \u2014 key is cached';
|
||||
if (typeof code !== 'string') throw `Expected string, got ${typeof code}`;
|
||||
if (code.length < 10) throw 'Sync code seems too short';
|
||||
});
|
||||
|
||||
test('Sync code export fails without encryption', async () => {
|
||||
await SilentSendSync.disableEncryption();
|
||||
const code = await SilentSendSync.exportSyncCode();
|
||||
if (!code?.needsEncryption) throw 'Expected needsEncryption=true when encryption disabled';
|
||||
await SilentSendSync.setupEncryption({ password: 'testpass123' });
|
||||
});
|
||||
|
||||
test('Sync code import round-trip', async () => {
|
||||
await Storage.saveMappings([{ id: 'sync-test', real: 'SyncMe', substitute: 'SyncFake' }]);
|
||||
const code = await SilentSendSync.exportSyncCode();
|
||||
await Storage.saveMappings([]);
|
||||
const result = await SilentSendSync.importSyncCode(code, { force: true });
|
||||
if (!result.success) throw `Import failed: ${result.reason}`;
|
||||
const mappings = await Storage.getMappings();
|
||||
const found = mappings.find(m => m.real === 'SyncMe');
|
||||
if (!found) throw 'Mapping not found after sync code round-trip';
|
||||
await Storage.saveMappings([]);
|
||||
});
|
||||
|
||||
test('pushToSyncStorage silently skips without encryption', async () => {
|
||||
await SilentSendSync.disableEncryption();
|
||||
await SilentSendSync.pushToSyncStorage();
|
||||
await SilentSendSync.setupEncryption({ password: 'testpass123' });
|
||||
});
|
||||
|
||||
test('pushToGist fails without encryption', async () => {
|
||||
await SilentSendSync.disableEncryption();
|
||||
const result = await SilentSendSync.pushToGist('fake-token');
|
||||
if (!result.needsEncryption) throw 'Expected needsEncryption=true';
|
||||
await SilentSendSync.setupEncryption({ password: 'testpass123' });
|
||||
});
|
||||
|
||||
test('pushToUrl fails without encryption', async () => {
|
||||
await SilentSendSync.disableEncryption();
|
||||
const result = await SilentSendSync.pushToUrl({ url: 'https://example.com/sync' });
|
||||
if (!result.needsEncryption) throw 'Expected needsEncryption=true';
|
||||
await SilentSendSync.setupEncryption({ password: 'testpass123' });
|
||||
});
|
||||
|
||||
group('Sync \u2014 Disable Encryption Disables Sync');
|
||||
|
||||
test('Disabling encryption sets browserSync=false', async () => {
|
||||
await Storage.saveSettings({ browserSync: true });
|
||||
await SilentSendSync.disableEncryption();
|
||||
const s = await Storage.getSettings();
|
||||
if (s.browserSync !== false) throw `Expected browserSync=false, got ${s.browserSync}`;
|
||||
await SilentSendSync.setupEncryption({ password: 'testpass123' });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ENCRYPTION WITH TOTP
|
||||
// ============================================================
|
||||
group('Encryption \u2014 TOTP');
|
||||
|
||||
test('Setup encryption with TOTP', async () => {
|
||||
await SilentSendSync.disableEncryption().catch(() => {});
|
||||
const result = await SilentSendSync.setupEncryption({
|
||||
password: 'totptest123',
|
||||
enableTOTP: true,
|
||||
ttlDays: 90,
|
||||
});
|
||||
if (!result.success) throw `Setup failed: ${result.reason}`;
|
||||
if (!result.totpSecret) throw 'Expected TOTP secret in response';
|
||||
if (!result.totpURI) throw 'Expected TOTP URI in response';
|
||||
});
|
||||
|
||||
test('TOTP secret is encrypted at rest', async () => {
|
||||
const config = await SilentSendSync._getSyncEncryption();
|
||||
if (config.totpSecret) throw 'TOTP secret should be encrypted, not plaintext';
|
||||
if (!config._totpEncrypted) throw 'Expected _totpEncrypted blob';
|
||||
});
|
||||
|
||||
test('Clean up \u2014 disable encryption', async () => {
|
||||
await SilentSendSync.disableEncryption();
|
||||
const enabled = await SilentSendSync.isEncryptionEnabled();
|
||||
if (enabled) throw 'Encryption should be disabled';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// AUTO-REDACT TESTS
|
||||
// ============================================================
|
||||
group('Auto-Redact \u2014 Built-in Patterns');
|
||||
|
||||
test('Detects OpenAI key', () => {
|
||||
const result = AutoRedact.redact('my key is sk-abc123def456ghi789jkl012mno');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
if (!result.text.includes('[REDACTED-OPENAI-KEY]')) throw 'Expected [REDACTED-OPENAI-KEY]';
|
||||
});
|
||||
|
||||
test('Detects GitHub token', () => {
|
||||
const result = AutoRedact.redact('token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
});
|
||||
|
||||
test('Detects SSN', () => {
|
||||
const result = AutoRedact.redact('my ssn is 123-45-6789');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
if (!result.text.includes('[REDACTED-SSN]')) throw 'Expected [REDACTED-SSN]';
|
||||
});
|
||||
|
||||
test('Detects credit card', () => {
|
||||
const result = AutoRedact.redact('card: 4111 1111 1111 1111');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
});
|
||||
|
||||
test('Detects Bearer token', () => {
|
||||
const result = AutoRedact.redact('Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9abcdef');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
});
|
||||
|
||||
test('Detects private key block', () => {
|
||||
const result = AutoRedact.redact('-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
});
|
||||
|
||||
test('Detects connection string', () => {
|
||||
const result = AutoRedact.redact('mongodb://admin:secret@db.example.com:27017/mydb');
|
||||
if (result.redactions.length === 0) throw 'Expected redaction';
|
||||
});
|
||||
|
||||
group('Auto-Redact \u2014 Custom Patterns');
|
||||
|
||||
test('Custom pattern matches', () => {
|
||||
const custom = [{ name: 'ControlD', pattern: 'dns\\.controld\\.com/[A-Za-z0-9;]+', redact: '[REDACTED-CONTROLD]', enabled: true }];
|
||||
const result = AutoRedact.redact('url: https://dns.controld.com/sasdkj;kjasda', custom);
|
||||
if (result.redactions.length === 0) throw 'Expected custom pattern redaction';
|
||||
if (!result.text.includes('[REDACTED-CONTROLD]')) throw 'Expected [REDACTED-CONTROLD]';
|
||||
});
|
||||
|
||||
test('Disabled custom pattern does not match', () => {
|
||||
const custom = [{ name: 'ControlD', pattern: 'dns\\.controld\\.com/[A-Za-z0-9;]+', redact: '[REDACTED]', enabled: false }];
|
||||
const result = AutoRedact.redact('url: https://dns.controld.com/sasdkj;kjasda', custom);
|
||||
if (result.text.includes('[REDACTED]')) throw 'Disabled pattern should not match';
|
||||
});
|
||||
|
||||
test('Invalid regex in custom pattern is skipped gracefully', () => {
|
||||
const custom = [{ name: 'Bad', pattern: '[invalid(regex', redact: '[X]', enabled: true }];
|
||||
const result = AutoRedact.redact('test text', custom);
|
||||
if (result.text !== 'test text') throw 'Text should be unchanged';
|
||||
});
|
||||
|
||||
test('Redactions use category=redact', () => {
|
||||
const result = AutoRedact.redact('key: sk-abc123def456ghi789jkl012mno');
|
||||
if (result.redactions.length > 0 && result.redactions[0].category !== 'redact') {
|
||||
throw `Expected category 'redact', got '${result.redactions[0].category}'`;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SUBSTITUTION ENGINE
|
||||
// ============================================================
|
||||
group('Substitution Engine');
|
||||
|
||||
test('Basic substitution works', () => {
|
||||
const result = SubstitutionEngine.substitute('Hello John Smith', [
|
||||
{ real: 'John Smith', substitute: 'Alex Demo', enabled: true }
|
||||
]);
|
||||
if (!result.text.includes('Alex Demo')) throw 'Expected Alex Demo in output';
|
||||
});
|
||||
|
||||
test('Case insensitive by default', () => {
|
||||
const result = SubstitutionEngine.substitute('hello JOHN SMITH', [
|
||||
{ real: 'John Smith', substitute: 'Alex Demo', enabled: true, caseSensitive: false }
|
||||
]);
|
||||
if (!result.text.includes('Alex Demo')) throw 'Expected case-insensitive match';
|
||||
});
|
||||
|
||||
test('Disabled mapping is skipped', () => {
|
||||
const result = SubstitutionEngine.substitute('Hello John Smith', [
|
||||
{ real: 'John Smith', substitute: 'Alex Demo', enabled: false }
|
||||
]);
|
||||
if (result.text.includes('Alex Demo')) throw 'Disabled mapping should not substitute';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SMART PATTERNS
|
||||
// ============================================================
|
||||
group('Smart Patterns \u2014 Identity');
|
||||
|
||||
test('Email substitution', () => {
|
||||
const identity = {
|
||||
emails: [{ real: 'john@gmail.com', substitute: 'alex@example.com' }],
|
||||
names: [], usernames: [], phones: [], hostnames: [],
|
||||
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
|
||||
};
|
||||
const result = SmartPatterns.substitute('email: john@gmail.com', identity);
|
||||
if (!result.text.includes('alex@example.com')) throw 'Expected email substitution';
|
||||
});
|
||||
|
||||
test('Name substitution', () => {
|
||||
const identity = {
|
||||
names: [{ real: 'John Smith', substitute: 'Alex Demo' }],
|
||||
emails: [], usernames: [], phones: [], hostnames: [],
|
||||
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
|
||||
};
|
||||
const result = SmartPatterns.substitute('My name is John Smith', identity);
|
||||
if (!result.text.includes('Alex Demo')) throw 'Expected name substitution';
|
||||
});
|
||||
|
||||
test('Phone substitution', () => {
|
||||
const identity = {
|
||||
phones: [{ real: '555-123-4567', substitute: '555-000-0000' }],
|
||||
names: [], emails: [], usernames: [], hostnames: [],
|
||||
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
|
||||
};
|
||||
const result = SmartPatterns.substitute('call 555-123-4567', identity);
|
||||
if (!result.text.includes('555-000-0000')) throw 'Expected phone substitution';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// AUTO-DETECT (PII Scanner)
|
||||
// ============================================================
|
||||
group('Auto-Detect \u2014 PII Patterns');
|
||||
|
||||
test('Detects private IP addresses', () => {
|
||||
const warnings = AutoDetect.scan('server at 192.168.1.100', {});
|
||||
const ipWarn = warnings.find(w => w.type === 'private-ip');
|
||||
if (!ipWarn) throw 'Expected private IP detection';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SETTINGS INTEGRITY
|
||||
// ============================================================
|
||||
group('Settings \u2014 No Stale Keys');
|
||||
|
||||
test('No secretScanning key in defaults', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if ('secretScanning' in s && !('autoRedact' in s)) throw 'Found stale secretScanning key';
|
||||
});
|
||||
|
||||
test('No customSecretPatterns key in defaults', async () => {
|
||||
const s = await Storage.getSettings();
|
||||
if ('customSecretPatterns' in s && !('customRedactPatterns' in s)) throw 'Found stale customSecretPatterns key';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// RUN
|
||||
// ============================================================
|
||||
|
||||
async function runAllTests() {
|
||||
document.getElementById('results').innerHTML = '<p style="color:#6b7280">Running tests...</p>';
|
||||
const outcomes = [];
|
||||
for (const t of tests) {
|
||||
try {
|
||||
await t.fn();
|
||||
outcomes.push({ group: t.group, name: t.name, status: 'pass' });
|
||||
} catch (e) {
|
||||
const msg = typeof e === 'string' ? e : (e?.message || String(e));
|
||||
outcomes.push({ group: t.group, name: t.name, status: 'fail', error: msg });
|
||||
}
|
||||
}
|
||||
renderResults(outcomes);
|
||||
}
|
||||
|
||||
document.getElementById('btnRunTests').addEventListener('click', runAllTests);
|
||||
document.getElementById('btnClear').addEventListener('click', () => {
|
||||
document.getElementById('results').innerHTML = '';
|
||||
document.getElementById('summary').textContent = '';
|
||||
});
|
||||
|
||||
document.getElementById('results').innerHTML = `<p style="color:#6b7280">${tests.length} tests loaded. Click "Run All Tests" to start.</p>`;
|
||||
Reference in New Issue
Block a user