feat: add privacy-aware security manager onboarding
This commit is contained in:
28
test/security-alert-policy.test.mjs
Normal file
28
test/security-alert-policy.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { evaluateSecurityAlertPolicy, isWithinQuietHours } from '../lib/security/security-alert-policy.mjs';
|
||||
|
||||
const profile = {
|
||||
timezone: 'Europe/Berlin',
|
||||
quietHours: '22:00-07:00',
|
||||
alertPreference: 'important',
|
||||
};
|
||||
|
||||
test('quiet hours use the configured profile timezone and wrap midnight', () => {
|
||||
assert.equal(isWithinQuietHours('22:00-07:00', 'Europe/Berlin', new Date('2026-07-05T21:00:00Z')), true);
|
||||
assert.equal(isWithinQuietHours('22:00-07:00', 'Europe/Berlin', new Date('2026-07-05T10:00:00Z')), false);
|
||||
assert.equal(isWithinQuietHours('invalid', 'Europe/Berlin', new Date()), false);
|
||||
});
|
||||
|
||||
test('flash alerts override quiet hours while lower priorities respect them', () => {
|
||||
const night = new Date('2026-07-05T21:00:00Z');
|
||||
assert.deepEqual(evaluateSecurityAlertPolicy({ notify: true, priority: 'priority' }, profile, night), { send: false, reason: 'quiet_hours' });
|
||||
assert.deepEqual(evaluateSecurityAlertPolicy({ notify: true, priority: 'flash' }, profile, night), { send: true, reason: 'allowed' });
|
||||
});
|
||||
|
||||
test('alert preference suppresses routine and non-critical notifications', () => {
|
||||
const day = new Date('2026-07-05T10:00:00Z');
|
||||
assert.equal(evaluateSecurityAlertPolicy({ notify: true, priority: 'routine' }, profile, day).send, false);
|
||||
assert.equal(evaluateSecurityAlertPolicy({ notify: true, priority: 'priority' }, { ...profile, alertPreference: 'critical_only' }, day).send, false);
|
||||
assert.equal(evaluateSecurityAlertPolicy({ notify: true, priority: 'flash' }, { ...profile, alertPreference: 'critical_only' }, day).send, true);
|
||||
});
|
||||
74
test/security-onboarding.test.mjs
Normal file
74
test/security-onboarding.test.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { SecurityProfileStore } from '../lib/security/security-profile-store.mjs';
|
||||
import { SecurityOnboarding } from '../lib/security/security-onboarding.mjs';
|
||||
|
||||
function setup(secret = 'test-only-security-profile-key-at-least-32-chars') {
|
||||
const sent = [];
|
||||
const alerter = {
|
||||
isConfigured: true,
|
||||
async sendMessage(text, options) {
|
||||
sent.push({ text, options });
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
const path = join(mkdtempSync(join(tmpdir(), 'security-onboarding-')), 'profile.enc');
|
||||
const store = new SecurityProfileStore(path, secret).init();
|
||||
return { sent, store, onboarding: new SecurityOnboarding({ store, alerter, chatId: '42' }) };
|
||||
}
|
||||
|
||||
function query() {
|
||||
return { message: { chat: { id: 42 } } };
|
||||
}
|
||||
|
||||
test('first startup asks only for language before personal information', async () => {
|
||||
const { sent, onboarding } = setup();
|
||||
assert.equal(await onboarding.ensureStarted(), true);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.match(sent[0].text, /language|Sprache/i);
|
||||
assert.deepEqual(sent[0].options.replyMarkup.inline_keyboard[0].map(button => button.callback_data), [
|
||||
'security_language:de',
|
||||
'security_language:en',
|
||||
]);
|
||||
assert.doesNotMatch(sent[0].text, /city|country|address|Stadt|Land|Adresse/i);
|
||||
});
|
||||
|
||||
test('minimal onboarding saves only after review confirmation', async () => {
|
||||
const { store, onboarding } = setup();
|
||||
await onboarding.start(42);
|
||||
let result = await onboarding.handleCallback('security_language:de', query());
|
||||
assert.equal(result.handled, true);
|
||||
assert.match(result.response.text, /verschl/iu);
|
||||
result = await onboarding.handleCallback('security_consent:minimal', query());
|
||||
assert.match(result.response.text, /Land/);
|
||||
|
||||
const answers = ['Deutschland', 'NRW', 'Koeln', 'Europe/Berlin', 'weather,cyber', 'important', '22:00-07:00'];
|
||||
for (const answer of answers) result = onboarding.handleMessage(answer, query().message);
|
||||
assert.equal(store.exists, false);
|
||||
assert.match(result.response.text, /pr.f/iu);
|
||||
|
||||
result = await onboarding.handleCallback('security_review:save', query());
|
||||
assert.equal(store.exists, true);
|
||||
assert.equal(store.getProfile().language, 'de');
|
||||
assert.equal(store.getProfile().location.city, 'Koeln');
|
||||
assert.equal(result.handled, true);
|
||||
});
|
||||
|
||||
test('profile deletion requires explicit callback confirmation', async () => {
|
||||
const { store, onboarding } = setup();
|
||||
store.save({ language: 'en', location: { country: 'DE' }, consentedAt: new Date().toISOString() });
|
||||
assert.match(onboarding.deletePrompt().text, /Delete/);
|
||||
await onboarding.handleCallback('security_delete:cancel', query());
|
||||
assert.equal(store.exists, true);
|
||||
await onboarding.handleCallback('security_delete:confirm', query());
|
||||
assert.equal(store.exists, false);
|
||||
});
|
||||
|
||||
test('setup remains unavailable without a valid encryption key', async () => {
|
||||
const { sent, onboarding } = setup('short');
|
||||
assert.equal(await onboarding.ensureStarted(), false);
|
||||
assert.match(sent[0].text, /SECURITY_PROFILE_ENCRYPTION_KEY/);
|
||||
});
|
||||
82
test/security-profile.test.mjs
Normal file
82
test/security-profile.test.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { SecurityProfileStore, sanitizeSecurityProfile } from '../lib/security/security-profile-store.mjs';
|
||||
import { createTerminalToolRegistry } from '../lib/agent/terminal-tools.mjs';
|
||||
|
||||
const SECRET = 'test-only-security-profile-key-at-least-32-chars';
|
||||
|
||||
function profile() {
|
||||
return {
|
||||
language: 'de',
|
||||
preferredName: 'Test Operator',
|
||||
location: { country: 'Deutschland', region: 'NRW', city: 'Koeln' },
|
||||
timezone: 'Europe/Berlin',
|
||||
household: { adults: 2, children: 1, pets: 1 },
|
||||
mobility: ['car', 'rail'],
|
||||
riskPriorities: ['weather', 'cyber'],
|
||||
criticalDependencies: ['electricity', 'internet'],
|
||||
alertPreference: 'important',
|
||||
quietHours: '22:00-07:00',
|
||||
consentedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
test('security profile is encrypted at rest and reloads with the correct key', () => {
|
||||
const path = join(mkdtempSync(join(tmpdir(), 'security-profile-')), 'profile.enc');
|
||||
const store = new SecurityProfileStore(path, SECRET).init();
|
||||
store.save(profile());
|
||||
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
assert.equal(raw.includes('Test Operator'), false);
|
||||
assert.equal(raw.includes('Koeln'), false);
|
||||
assert.equal(JSON.parse(raw).algorithm, 'aes-256-gcm');
|
||||
|
||||
const restored = new SecurityProfileStore(path, SECRET).init();
|
||||
assert.equal(restored.getProfile().preferredName, 'Test Operator');
|
||||
assert.equal(restored.getProfile().location.city, 'Koeln');
|
||||
});
|
||||
|
||||
test('wrong key fails closed and cannot overwrite an existing profile', () => {
|
||||
const path = join(mkdtempSync(join(tmpdir(), 'security-profile-')), 'profile.enc');
|
||||
new SecurityProfileStore(path, SECRET).init().save(profile());
|
||||
const wrong = new SecurityProfileStore(path, 'another-test-key-that-is-at-least-32-characters').init();
|
||||
assert.equal(wrong.status().configured, true);
|
||||
assert.equal(wrong.status().available, false);
|
||||
assert.equal(wrong.getProfile(), null);
|
||||
assert.throws(() => wrong.save(profile()));
|
||||
});
|
||||
|
||||
test('profile sanitizer only retains allowlisted risk and dependency categories', () => {
|
||||
const value = sanitizeSecurityProfile({
|
||||
...profile(),
|
||||
riskPriorities: ['weather', 'passwords'],
|
||||
criticalDependencies: ['internet', 'bank-login'],
|
||||
household: { adults: 999, children: -4, pets: 2 },
|
||||
});
|
||||
assert.deepEqual(value.riskPriorities, ['weather']);
|
||||
assert.deepEqual(value.criticalDependencies, ['internet']);
|
||||
assert.deepEqual(value.household, { adults: 20, children: 0, pets: 2 });
|
||||
});
|
||||
|
||||
test('agent can read only the approved profile through the allowlisted tool', async () => {
|
||||
const path = join(mkdtempSync(join(tmpdir(), 'security-profile-')), 'profile.enc');
|
||||
const store = new SecurityProfileStore(path, SECRET).init();
|
||||
store.save(profile());
|
||||
const registry = createTerminalToolRegistry({ securityProfileStore: store });
|
||||
const result = await registry.execute('get_security_profile');
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.profile.location.country, 'Deutschland');
|
||||
assert.equal(Object.hasOwn(result.profile, 'completedAt'), false);
|
||||
});
|
||||
|
||||
test('deleting a security profile removes it from the store', () => {
|
||||
const path = join(mkdtempSync(join(tmpdir(), 'security-profile-')), 'profile.enc');
|
||||
const store = new SecurityProfileStore(path, SECRET).init();
|
||||
store.save(profile());
|
||||
store.delete();
|
||||
assert.equal(store.exists, false);
|
||||
assert.equal(store.getProfile(), null);
|
||||
});
|
||||
Reference in New Issue
Block a user