Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
// Generated, do not edit. sha256:ba0d002b43c3a3cea3c4fcfb895e0825ada81fa58dfcdb0f545bd34613b051e6
import { describe, it, before, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDbHarness } from '../helpers/db-node.mjs';
const DATA_DIR = await mkdtemp(join(tmpdir(), 'assetapp-dryrun-'));
process.env.ASSETAPP_DATA_DIR = DATA_DIR;
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const { createMcpServer } = await import('../../server/mcp/protocol.mjs');
const { TOOLS } = await import('../../server/mcp/tools.mjs');
const { dryRunTools, dryRunTool, _resetDryRunServer } = await import('../../server/mcp/dry-run.mjs');
const { listAiToolCalls, countAiToolCalls } = await import('../../server/data/ai-audit.js');
const { setPref } = await import('../../server/data/preferences.js');
const { createAsset } = await import('../../server/data/assets.js');
const { createAccount } = await import('../../server/data/accounts.js');
let logboek;
const maak = () => {
logboek = [];
return createMcpServer({ tools: TOOLS, logCall: async (r) => { logboek.push(r); } });
};
const verzoek = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, params });
before(async () => {
harness.recreate();
await createAsset({ symbol: 'BTC', name: 'Bitcoin', kind: 'crypto', unit: 'BTC', subunit: 'sat', decimals: 8, displayUnit: 'unit' });
await createAccount({ name: 'Testrekening', kind: 'wallet', isOwnCustody: 1, scanEnabled: 1 });
});
beforeEach(async () => {
await setPref('ai', 'allow_read', 1);
_resetDryRunServer();
});
describe('dry-run: hetzelfde codepad als een echte aanroep', () => {
it('geeft dezelfde tekst als de echte aanroep', async () => {
const server = maak();
const echt = await server.handle(verzoek('tools/call', { name: 'admin_overview' }));
const droog = await server.dryRun('admin_overview');
assert.equal(droog.text, echt.result.content[0].text);
});
it('geeft dezelfde auditregel als de echte aanroep zou schrijven', async () => {
const server = maak();
await server.handle(verzoek('tools/call', { name: 'admin_overview' }));
const droog = await server.dryRun('admin_overview');
assert.equal(droog.outcome, logboek[0].outcome);
assert.deepEqual(droog.fieldsShared, logboek[0].fieldsShared);
assert.equal(droog.charactersOut, logboek[0].charactersOut);
});
it('noemt de velden die zouden vertrekken, en dat is de vraag die deze modus beantwoordt', async () => {
const droog = await dryRunTool('admin_overview');
assert.ok(droog.fieldsShared.length > 0, 'een leeslijst zonder velden zegt niets');
assert.ok(droog.charactersOut > 0);
});
});
describe('dry-run: er gaat niets de deur uit', () => {
it('schrijft GEEN auditregel', async () => {
const voor = countAiToolCalls().total;
await dryRunTool('admin_overview');
await dryRunTool('data_health');
assert.equal(countAiToolCalls().total, voor);
});
it('laat het logboek van een ECHTE aanroep ongemoeid ernaast', async () => {
const server = maak();
await server.handle(verzoek('tools/call', { name: 'connection_test', arguments: { note: 'echt' } }));
const na = listAiToolCalls().length;
await dryRunTool('connection_test', { note: 'droog' });
assert.equal(listAiToolCalls().length, na);
});
});
describe('dry-run: niet bereikbaar via het protocol', () => {
it('kent geen JSON-RPC-methode die hem aanroept', async () => {
const server = maak();
for (const method of ['dryRun', 'dry-run', 'tools/dryRun', 'tools/dry-run', 'tools/preview']) {
const a = await server.handle(verzoek(method, { name: 'admin_overview' }));
assert.equal(a.error?.code, -32601, `methode '${method}' werd afgehandeld`);
}
assert.equal(logboek.length, 0, 'een onbekende methode is geen tool-aanroep en hoort niets te loggen');
});
it('is ook niet als tool te bereiken', async () => {
const server = maak();
const a = await server.handle(verzoek('tools/call', { name: 'dry_run' }));
assert.ok(a.error, 'er hoort geen tool met deze naam te bestaan');
assert.equal(logboek[0].outcome, 'refused');
});
it('staat niet in de tool-lijst die een client krijgt', async () => {
const server = maak();
const a = await server.handle(verzoek('tools/list', {}));
for (const t of a.result.tools) assert.doesNotMatch(t.name, /dry/i);
});
});
describe('dry-run: de poort geldt gewoon', () => {
it('toont een weigering als het lezen uit staat, en dat is het eerlijke antwoord', async () => {
await setPref('ai', 'allow_read', 0);
try {
const droog = await dryRunTool('admin_overview');
assert.equal(droog.outcome, 'refused');
assert.deepEqual(droog.fieldsShared, []);
assert.match(droog.text, /niet lezen/i);
} finally {
await setPref('ai', 'allow_read', 1);
}
});
it('laat een tool zonder administratie-scope wél droog lopen met het lezen uit', async () => {
await setPref('ai', 'allow_read', 0);
try {
const droog = await dryRunTool('connection_test');
assert.equal(droog.outcome, 'ok');
} finally {
await setPref('ai', 'allow_read', 1);
}
});
});
describe('dry-run: de keuzelijst voor de gebruiker', () => {
it('noemt elke tool met zijn scope, zodat zichtbaar is wat hij aanraakt', () => {
const lijst = dryRunTools();
assert.equal(lijst.length, TOOLS.length);
for (const t of lijst) {
assert.ok(t.name && t.title && t.description, `${t.name} mist een veld`);
assert.ok(t.scope, `${t.name} mist zijn scope`);
}
});
it('geeft geen `run` mee, want dat is geen gegeven maar gedrag', () => {
for (const t of dryRunTools()) assert.equal(t.run, undefined);
});
});
describe('dry-run: rare invoer', () => {
it('meldt een onbekende tool netjes in plaats van om te vallen', async () => {
const droog = await dryRunTool('bestaat_niet');
assert.equal(droog.outcome, 'refused');
assert.match(droog.text, /bestaat niet/i);
});
it('verdraagt ontbrekende of onzinnige argumenten', async () => {
for (const args of [undefined, null, 'tekst', 42, []]) {
const droog = await dryRunTool('connection_test', args);
assert.equal(droog.outcome, 'ok', `argumenten ${JSON.stringify(args)} lieten de dry-run omvallen`);
}
});
});
+210
View File
@@ -0,0 +1,210 @@
// Generated, do not edit. sha256:f2303f8f896c78080d5d688ce3e2df97456f8a2894444e19e5c3457a31b7283d
import { describe, it, before, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { CONFIG, ENUMS } from '../../shared/config.js';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
let licentieStand = { level: 'full', enforced: true, why: 'licensed' };
const echteLicentie = await import('../../server/license.mjs');
mock.module('../../server/license.mjs', {
namedExports: { ...echteLicentie, hasFeature: (naam) => echteLicentie.hasFeature(naam, licentieStand) },
});
const { assertScope, mayScope, isAiEnabled, isAiWriteAllowed, isAiControlAllowed, isAiActive,
AI_INACTIVE, AI_DISABLED, AI_WRITE_DENIED, AI_CONTROL_DENIED, AI_UNLICENSED, AI_REFUSALS } =
await import('../../server/ai/gate.js');
const { setPref } = await import('../../server/data/preferences.js');
const zet = async (enabled, allowWrite, allowControl = false) => {
await setPref('ai', 'allow_read', enabled ? 1 : 0);
await setPref('ai', 'allow_write', allowWrite ? 1 : 0);
await setPref('ai', 'allow_control', allowControl ? 1 : 0);
};
before(() => { harness.recreate(); });
beforeEach(async () => { await zet(true, false, false); });
describe('scope-poort: de schuif "Actief" gaat vóór alles', () => {
it('weigert ELKE scope, ook none, als de koppeling niet actief is', async () => {
await setPref('ai', 'enabled', 0);
try {
for (const scope of ['none', 'read', 'write', 'control']) {
assert.throws(() => assertScope(scope), e => e.code === AI_INACTIVE,
`scope ${scope} hoort geweigerd te worden met de koppeling uit`);
}
assert.equal(isAiActive(), false);
assert.equal(mayScope('none'), false);
} finally {
await setPref('ai', 'enabled', 1);
}
});
it('zegt iets ANDERS dan de leesschuif, want dat is een andere schuif', async () => {
await setPref('ai', 'enabled', 0);
let koppeling;
try { assertScope('read'); } catch (e) { koppeling = e; }
await setPref('ai', 'enabled', 1);
await setPref('ai', 'allow_read', 0);
let lezen;
try { assertScope('read'); } catch (e) { lezen = e; }
assert.notEqual(koppeling.code, lezen.code);
assert.match(koppeling.message, /Actief/);
assert.match(lezen.message, /niet lezen/i);
});
});
describe('scope-poort: de standen', () => {
it('laat scope none door zolang de koppeling actief is, ook met de drie rechten uit', async () => {
await zet(false, false);
assert.doesNotThrow(() => assertScope('none'));
});
it('laat scope read door als de koppeling aan staat', () => {
assert.doesNotThrow(() => assertScope('read'));
});
it('weigert scope read als de koppeling uit staat', async () => {
await zet(false, false);
assert.throws(() => assertScope('read'), e => e.code === AI_DISABLED);
});
it('weigert scope write standaard, ook al staat de koppeling aan', () => {
assert.throws(() => assertScope('write'), e => e.code === AI_WRITE_DENIED);
});
it('laat scope write door als het schrijfrecht aan staat', async () => {
await zet(true, true);
assert.doesNotThrow(() => assertScope('write'));
});
it('laat scope write door terwijl LEZEN uit staat, want elke schuif gaat over zichzelf', async () => {
await zet(false, true);
assert.doesNotThrow(() => assertScope('write'));
assert.throws(() => assertScope('read'), e => e.code === AI_DISABLED);
});
it('weigert scope control standaard, ook al staat de koppeling aan', () => {
assert.throws(() => assertScope('control'), e => e.code === AI_CONTROL_DENIED);
});
it('laat scope control door als het besturen aan staat', async () => {
await zet(true, false, true);
assert.doesNotThrow(() => assertScope('control'));
assert.equal(isAiControlAllowed(), true);
});
it('laat scope control door terwijl lezen uit staat, om dezelfde reden als bij schrijven', async () => {
await zet(false, false, true);
assert.doesNotThrow(() => assertScope('control'));
assert.throws(() => assertScope('read'), e => e.code === AI_DISABLED);
});
it('houdt besturen en schrijven uit elkaar', () => {
assert.equal(isAiWriteAllowed(), false);
assert.equal(isAiControlAllowed(), false);
});
it('weigert een onbekende scope', () => {
for (const raar of ['lezen', 'READ', '', null, undefined, 'admin']) {
assert.throws(() => assertScope(raar), e => AI_REFUSALS.includes(e.code),
`scope ${JSON.stringify(raar)} werd doorgelaten`);
}
});
});
describe('scope-poort: de melding die het model leest', () => {
it('zegt bij een geweigerde schrijfactie iets anders dan bij een uitgezette koppeling', async () => {
let schrijf;
try { assertScope('write'); } catch (e) { schrijf = e; }
await zet(false, false);
let lees;
try { assertScope('read'); } catch (e) { lees = e; }
assert.notEqual(schrijf.code, lees.code);
assert.notEqual(schrijf.message, lees.message);
assert.match(schrijf.message, /niets gewijzigd/i);
});
it('telt elke weigering als "bewust niet uitgevoerd"', () => {
assert.deepEqual([...AI_REFUSALS].sort(),
[AI_UNLICENSED, AI_INACTIVE, AI_DISABLED, AI_WRITE_DENIED, AI_CONTROL_DENIED].sort());
});
});
describe('scope-poort: bij twijfel dicht', () => {
it('zegt nee als de voorkeur onleesbaar is', async () => {
const origineel = harness.exports.all;
harness.exports.all = () => { throw new Error('database weg'); };
try {
assert.equal(isAiEnabled(), false);
assert.equal(isAiWriteAllowed(), false);
} finally {
harness.exports.all = origineel;
}
});
});
describe('scope-poort: de SSOT', () => {
it('kent precies de scopes uit de enum', () => {
for (const scope of ENUMS.AI_SCOPE) {
assert.doesNotThrow(() => {
try { assertScope(scope); } catch (e) {
if (!AI_REFUSALS.includes(e.code)) throw e;
}
}, `${scope} wordt niet herkend`);
}
});
});
describe('scope-poort: mayScope stelt dezelfde vraag zonder te gooien', () => {
const standen = [];
for (const enabled of [true, false]) {
for (const write of [true, false]) {
for (const control of [true, false]) standen.push([enabled, write, control]);
}
}
it('geeft voor elke scope in elke stand hetzelfde oordeel als assertScope', async () => {
for (const [enabled, write, control] of standen) {
await zet(enabled, write, control);
for (const scope of ENUMS.AI_SCOPE) {
let gooide = false;
try { assertScope(scope); } catch { gooide = true; }
assert.equal(mayScope(scope), !gooide,
`scope '${scope}' bij enabled=${enabled} write=${write} control=${control}`);
}
}
});
it('weigert een onbekende scope, net als assertScope', () => {
assert.equal(mayScope('admin'), false);
assert.equal(mayScope(''), false);
assert.equal(mayScope(undefined), false);
});
it('laat none door, ook met alles uit', async () => {
await zet(false, false, false);
assert.equal(mayScope('none'), true);
});
it('zegt nee tegen read zodra de koppeling uit staat', async () => {
await zet(false, false, false);
assert.equal(mayScope('read'), false);
});
});
describe('scope-poort: de licentie gaat vóór de schuiven', () => {
it('laat de koppeling toe in de gratis stand, want AI hoort daar sinds 27-10-2026 bij', () => {
assert.equal(CONFIG.LICENSE.FREE.HAS.ai, true);
licentieStand = { level: 'free', enforced: true, why: 'none' };
for (const scope of ['none', 'read']) {
assert.doesNotThrow(() => assertScope(scope), `scope ${scope} hoort door de licentie te komen`);
}
licentieStand = { level: 'full', enforced: true, why: 'licensed' };
});
});
+93
View File
@@ -0,0 +1,93 @@
// Generated, do not edit. sha256:6af32d0d87cfc79565a819efa4f1b1242dc24918bed60c964dcd7e25b4033b27
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { limitRows, limitedList, limitText, omittedNote } from '../../server/ai/limits.js';
import { CONFIG } from '../../shared/config.js';
const MAX = CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST;
const reeks = n => Array.from({ length: n }, (_, i) => ({ id: i + 1 }));
describe('limitRows: de primaire grens', () => {
it('laat een lijst binnen de grens ongemoeid', () => {
const r = limitRows(reeks(3), 10);
assert.equal(r.rows.length, 3);
assert.equal(r.omitted, 0);
assert.equal(r.capped, false);
});
it('kort in en vertelt hoeveel er weg zijn', () => {
const r = limitRows(reeks(30), 10);
assert.equal(r.rows.length, 10);
assert.equal(r.omitted, 20);
assert.equal(r.capped, true);
});
it('kort NIET in op exact de grens', () => {
const r = limitRows(reeks(10), 10);
assert.equal(r.capped, false);
assert.equal(r.omitted, 0);
});
it('valt terug op de SSOT-grens als er geen maat wordt meegegeven', () => {
assert.equal(limitRows(reeks(MAX + 5)).rows.length, MAX);
});
it('verdraagt iets dat geen lijst is', () => {
for (const raar of [null, undefined, 'tekst', 42]) {
assert.deepEqual(limitRows(raar, 5).rows, []);
}
});
});
describe('limitedList: de vorm die een tool hoort te gebruiken', () => {
it('zet de mededeling ONDER de regels en niet ertussen', () => {
const { lines } = limitedList(reeks(30), r => `- ${r.id}`, 10);
assert.equal(lines.length, 11, 'tien regels plus de mededeling');
assert.match(lines.at(-1), /nog 20 niet getoond/);
assert.equal(lines[0], '- 1');
});
it('voegt niets toe als er niets is ingekort', () => {
const { lines, capped } = limitedList(reeks(4), r => `- ${r.id}`, 10);
assert.equal(lines.length, 4);
assert.equal(capped, false);
for (const regel of lines) assert.doesNotMatch(regel, /niet getoond/);
});
it('noemt de grens zelf in de mededeling', () => {
assert.match(omittedNote(7, 50), /grens: 50/);
});
});
describe('limitText: het vangnet', () => {
it('laat tekst binnen de grens ongemoeid', () => {
const r = limitText('kort', 100);
assert.equal(r.text, 'kort');
assert.equal(r.capped, false);
});
it('gaat NOOIT over de grens heen, ook niet met de markering erbij', () => {
for (const max of [40, 200, 5000]) {
const r = limitText('x'.repeat(20000), max);
assert.ok(r.text.length <= max, `${r.text.length} > ${max}`);
assert.equal(r.capped, true);
}
});
it('maakt het afkappen zichtbaar in de tekst zelf', () => {
const r = limitText('y'.repeat(500), 200);
assert.match(r.text, /Afgekapt/);
});
it('houdt zich aan de grens ook als die kleiner is dan de markering', () => {
const r = limitText('z'.repeat(100), 5);
assert.ok(r.text.length <= 5);
});
it('valt terug op de SSOT-grens', () => {
const r = limitText('q'.repeat(CONFIG.AI.LIMITS.MAX_TEXT_CHARS + 100));
assert.ok(r.text.length <= CONFIG.AI.LIMITS.MAX_TEXT_CHARS);
assert.equal(r.capped, true);
});
});
+145
View File
@@ -0,0 +1,145 @@
// Generated, do not edit. sha256:85fb028950f59216bdb01fd8cdbb5cd92ec098e018550fd5116c6e26d75ba680
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import { FIELD_POLICY, TABLE_POLICY, fieldClass, strictestClass } from '../../server/ai/policy.js';
import { openBaselineDb } from '../helpers/baseline-db.mjs';
import { CONFIG } from '../../shared/config.js';
let KOLOMMEN;
before(() => {
const db = openBaselineDb();
const tabellen = db.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`
).all().map(r => r.name);
KOLOMMEN = new Set();
for (const t of tabellen) {
for (const k of db.prepare(`PRAGMA table_info(${t})`).all()) KOLOMMEN.add(k.name);
}
});
describe('AI-veldbeleid tegen het schema', () => {
it('classificeert élke kolom uit de baseline', () => {
const zonder = [...KOLOMMEN].filter(k => fieldClass(k) === null).sort();
assert.deepEqual(zonder, [],
`deze kolommen hebben geen klasse in server/ai/policy.js: ${zonder.join(', ')}`);
});
const GEEN_KOLOM = new Set(['portfolio', 'fiatValue']);
it('bevat geen regel voor een kolom die niet bestaat', () => {
const spoken = Object.keys(FIELD_POLICY).filter(k => !KOLOMMEN.has(k) && !GEEN_KOLOM.has(k)).sort();
assert.deepEqual(spoken, [],
`deze regels wijzen naar een kolom die niet (meer) bestaat: ${spoken.join(', ')}`);
});
it('houdt de uitzonderingenlijst eerlijk: elk veld erin is ook echt geen kolom', () => {
const tochKolom = [...GEEN_KOLOM].filter(k => KOLOMMEN.has(k)).sort();
assert.deepEqual(tochKolom, [],
`deze staan als uitzondering maar zijn wél een kolom: ${tochKolom.join(', ')}`);
});
it('gebruikt alleen klassen die in CONFIG.AI.FIELD_CLASSES staan', () => {
const onbekend = [...new Set(Object.values(FIELD_POLICY))]
.filter(k => !CONFIG.AI.FIELD_CLASSES.includes(k)).sort();
assert.deepEqual(onbekend, []);
});
});
describe('AI-veldbeleid: de gevaarlijke velden', () => {
const KETEN = ['address', 'externalAddress', 'automaticLabel', 'txid', 'transactionHash',
'blockHeight', 'blockTime', 'chainIndex', 'derivationIndex', 'scriptType'];
const WEG = ['value', 'rawData', 'settings', 'arguments'];
for (const veld of KETEN) {
it(`${veld} is keten-data en gaat alleen als alias`, () => {
assert.equal(fieldClass(veld), 'chain');
});
}
for (const veld of WEG) {
it(`${veld} verlaat de machine in geen enkele vorm`, () => {
assert.equal(fieldClass(veld), 'drop');
});
}
it('laat geen enkele hoeveelheid als plain door', () => {
for (const veld of ['amount', 'balance', 'balanceSats', 'valueSats', 'feeSats',
'balanceFound', 'unitFee', 'principalAmount']) {
assert.equal(fieldClass(veld), 'quantity', `${veld} hoort in de klasse quantity`);
}
});
it('kent geen enkel veld dat ongedeclareerd tóch een waarde geeft', () => {
assert.equal(fieldClass('eenNieuweKolomDieNietBestaat'), null);
assert.equal(fieldClass('constructor'), null);
assert.equal(fieldClass('toString'), null);
});
});
describe('AI-veldbeleid: de uitzonderingen per tabel', () => {
it('geeft zonder tabel-argument nog steeds de gevoeligste lezing', () => {
assert.equal(fieldClass('value'), 'drop');
assert.equal(fieldClass('value', null), 'drop');
assert.equal(fieldClass('value', 'WalletSource'), 'drop');
assert.equal(fieldClass('value', 'EenTabelDieNietBestaat'), 'drop');
});
it('past de uitzondering toe voor precies de tabel die hem heeft', () => {
assert.equal(fieldClass('value', 'Preference'), 'plain');
assert.equal(fieldClass('Preference.value'), 'plain');
assert.equal(fieldClass('WalletSource.value'), 'drop');
});
it('kan met een uitzondering geen veld OPENZETTEN dat de basis niet kent', () => {
assert.equal(fieldClass('eenVeldDatNergensStaat', 'Preference'), null);
assert.equal(fieldClass('Preference.eenVeldDatNergensStaat'), null);
assert.equal(fieldClass('constructor', 'Preference'), null);
});
it('noemt in elke uitzondering een tabel en een veld die echt bestaan', () => {
const tabellen = new Set();
const db = openBaselineDb();
for (const r of db.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`).all()) {
tabellen.add(r.name);
}
for (const [tabel, velden] of Object.entries(TABLE_POLICY)) {
assert.ok(tabellen.has(tabel), `TABLE_POLICY noemt tabel '${tabel}', die niet bestaat`);
const kolommen = new Set(db.prepare(`PRAGMA table_info(${tabel})`).all().map(k => k.name));
for (const [veld, klasse] of Object.entries(velden)) {
assert.ok(kolommen.has(veld), `${tabel}.${veld} is geen kolom van die tabel`);
assert.ok(CONFIG.AI.FIELD_CLASSES.includes(klasse), `${tabel}.${veld} heeft een onbekende klasse`);
assert.ok(Object.hasOwn(FIELD_POLICY, veld),
`${tabel}.${veld} staat niet in het basisbeleid, dus de uitzondering doet niets`);
}
}
});
it('houdt de lijst kort, want elke regel is een plek waar de gevoeligste lezing NIET geldt', () => {
const regels = Object.values(TABLE_POLICY).reduce((n, v) => n + Object.keys(v).length, 0);
assert.ok(regels <= 5, `${regels} uitzonderingen; een uitzondering is een veld dat de AI onbewerkt ziet, dus niet zomaar meer`);
});
});
describe('strictestClass', () => {
it('kiest de gevoeligste van twee', () => {
assert.equal(strictestClass('plain', 'chain'), 'chain');
assert.equal(strictestClass('chain', 'plain'), 'chain');
assert.equal(strictestClass('handle', 'drop'), 'drop');
assert.equal(strictestClass('quantity', 'quantityFiat'), 'quantity');
});
it('behandelt een ontbrekende klasse als "de andere"', () => {
assert.equal(strictestClass(null, 'plain'), 'plain');
assert.equal(strictestClass('plain', null), 'plain');
});
it('is de regel waarmee de botsende namen zijn ingedeeld', () => {
assert.equal(fieldClass('value'), 'drop');
assert.equal(fieldClass('name'), 'handle');
assert.equal(fieldClass('key'), 'plain');
});
});
+162
View File
@@ -0,0 +1,162 @@
// Generated, do not edit. sha256:f1d5e464bcd04ed7f483f797346cfeb34bcf1b0c9306b56821baa618e0b3ee5f
import { describe, it, before, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const { precisionSettings, precisionKey, shareDisclosure } = await import('../../server/ai/precision.js');
const { createTranslator } = await import('../../server/ai/translate.js');
const { setPref } = await import('../../server/data/preferences.js');
const TEST_GEHEIM = 'geheim-voor-de-precisietest-0123456789ab';
before(() => { harness.recreate(); });
describe('precisionSettings: de stand van dit moment', () => {
it('volgt de voorkeur, en leest hem per aanroep', async () => {
await setPref('ai', 'allow_raw', 0);
assert.equal(precisionSettings().rawValues, false);
await setPref('ai', 'allow_raw', 1);
assert.equal(precisionSettings().rawValues, true,
'zonder herlezen doet de schuif pas iets bij een nieuwe verbinding');
await setPref('ai', 'allow_raw', 0);
});
it('is bevroren, zodat een tool zijn eigen antwoord niet kan verbreden', () => {
const stand = precisionSettings();
assert.throws(() => { stand.rawValues = true; }, TypeError);
assert.equal(stand.rawValues, false);
});
});
describe('precisionKey: de stand als geheel in de sleutel', () => {
it('hangt niet aan de volgorde van de velden', () => {
assert.equal(precisionKey({ rawValues: false, totals: true }), precisionKey({ totals: true, rawValues: false }));
});
it('geeft twee verschillende standen twee verschillende sleutels', () => {
const a = precisionKey({ rawValues: false, totals: true });
const b = precisionKey({ rawValues: false, totals: false });
assert.notEqual(a, b, 'een verschil in welk veld dan ook hoort een andere sleutel op te leveren');
});
it('heeft een eigen vorm voor de lege stand, en verwart die niet met een ontbrekende', () => {
assert.equal(precisionKey({}), 'leeg');
assert.equal(precisionKey(undefined), 'leeg');
assert.equal(precisionKey(null), 'leeg');
});
});
describe('createTranslator: de stand komt binnen als geheel', () => {
it('valt zonder stand terug op VERTAALD en niet op onbewerkt', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
assert.equal(t.precision.rawValues, false);
const uit = t.toAi({ id: 1, amount: 12345.678 }, { entity: 'account' });
assert.equal(typeof uit.amount, 'string');
});
it('draagt de stand die hij kreeg, en die is van buitenaf niet te verzetten', () => {
const t = createTranslator({ secret: TEST_GEHEIM, precision: { rawValues: true } });
assert.equal(t.precision.rawValues, true);
assert.throws(() => { t.precision.rawValues = false; }, TypeError);
});
it('laat een onbekend veld in de stand niet als raw gelden', () => {
const t = createTranslator({ secret: TEST_GEHEIM, precision: { rawWaarden: true } });
assert.equal(t.precision.rawValues, false);
const uit = t.toAi({ id: 1, amount: 12345.678 }, { entity: 'account' });
assert.equal(typeof uit.amount, 'string');
});
});
describe('de vier precisie-schuiven', () => {
const zet = async (sleutels) => {
for (const [k, v] of Object.entries(sleutels)) await setPref('ai', k, v ? 1 : 0);
};
it('zet de vier op false zodra onbewerkt delen aan staat, want dan valt er niets te emmeren', async () => {
await zet({ bucket_shares: 1, bucket_totals: 1, bucket_amounts: 1, bucket_quantities: 1 });
await setPref('ai', 'allow_raw', 1);
try {
const p = precisionSettings();
assert.equal(p.rawValues, true);
assert.deepEqual(
[p.bucketShares, p.bucketTotals, p.bucketAmounts, p.bucketQuantities],
[false, false, false, false],
'laag 3 hoort laag 1 te overrulen in de LAAG, niet bij elke aanroeper opnieuw');
} finally {
await setPref('ai', 'allow_raw', 0);
}
});
it('leest elke schuif apart, zodat er geen twee aan elkaar vastzitten', async () => {
await zet({ bucket_shares: 0, bucket_totals: 1, bucket_amounts: 0, bucket_quantities: 1 });
const p = precisionSettings();
assert.deepEqual(
[p.bucketShares, p.bucketTotals, p.bucketAmounts, p.bucketQuantities],
[false, true, false, true]);
await zet({ bucket_shares: 1, bucket_totals: 1, bucket_amounts: 1, bucket_quantities: 1 });
});
it('emmert een hoeveelheid alleen zolang ZIJN schuif aan staat', () => {
const aan = createTranslator({ secret: TEST_GEHEIM, precision: { bucketQuantities: true } });
const uit = createTranslator({ secret: TEST_GEHEIM, precision: { bucketQuantities: false } });
assert.equal(typeof aan.toAi({ amount: 12345.678 }, { entity: 'account' }).amount, 'string');
assert.equal(uit.toAi({ amount: 12345.678 }, { entity: 'account' }).amount, 12345.678);
});
it('KRUISPROEF: dezelfde klasse gaat anders naar buiten als TOTAAL dan als REGEL', () => {
const t = createTranslator({
secret: TEST_GEHEIM,
precision: { bucketTotals: false, bucketAmounts: true },
});
const alsTotaal = t.toAi({ fiatValue: 98765 }, { entity: 'wealth', role: 'total' }).fiatValue;
const alsRegel = t.toAi({ fiatValue: 98765 }, { entity: 'wealth' }).fiatValue;
assert.equal(alsTotaal, 98765, 'het totaal hoort exact te zijn: die schuif staat uit');
assert.equal(typeof alsRegel, 'string', 'het regelbedrag hoort een emmer te zijn: die staat aan');
});
it('leest een ontbrekende rol als REGEL, en dat is de striktste van de twee', () => {
const t = createTranslator({
secret: TEST_GEHEIM,
precision: { bucketTotals: false, bucketAmounts: true },
});
assert.equal(typeof t.toAi({ fiatValue: 98765 }, { entity: 'wealth' }).fiatValue, 'string');
});
});
describe('shareDisclosure: de invariant van §10c in de laag', () => {
const stand = (o) => ({ rawValues: false, bucketShares: true, bucketTotals: true,
bucketAmounts: true, bucketQuantities: true, ...o });
it('laat een geëmmerd aandeel naast een geëmmerd totaal toe: de gemeten uitzondering', () => {
assert.equal(shareDisclosure(stand({})), 'bucketed');
});
it('laat een exact aandeel toe zolang het TOTAAL geëmmerd is, want dat is één beeld', () => {
assert.equal(shareDisclosure(stand({ bucketShares: false })), 'exact');
});
it('LAAT DE VERDELING WEG als het totaal én het aandeel exact zijn', () => {
assert.equal(shareDisclosure(stand({ bucketShares: false, bucketTotals: false })), 'omit');
});
it('houdt de verdeling wél als de regelbedragen tóch al exact zijn', () => {
assert.equal(
shareDisclosure(stand({ bucketShares: false, bucketTotals: false, bucketAmounts: false })),
'exact');
});
it('geeft in de onbewerkte stand alles exact', () => {
assert.equal(shareDisclosure({ rawValues: true }), 'exact');
});
it('valt op een lege stand niet om', () => {
assert.equal(shareDisclosure(undefined), 'exact');
});
});
+140
View File
@@ -0,0 +1,140 @@
// Generated, do not edit. sha256:9bbc954e336de37a55fc73310bcc550947a6f91fd3fa5c98c1be9921acfadb2d
import { describe, it, before, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { fieldClass } from '../../server/ai/policy.js';
import { createTranslator } from '../../server/ai/translate.js';
const TEST_GEHEIM = 'test-geheim-voor-de-suite-0123456789abcdef';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const modules = {
accounts: await import('../../server/data/accounts.js'),
assets: await import('../../server/data/assets.js'),
'api-metrics': await import('../../server/data/api-metrics.js'),
connections: await import('../../server/data/connections.js'),
counts: await import('../../server/data/counts.js'),
dashboard: await import('../../server/data/dashboard.js'),
events: await import('../../server/data/events.js'),
'interest-credits': await import('../../server/data/interest-credits.js'),
'interest-schedules': await import('../../server/data/interest-schedules.js'),
labels: await import('../../server/data/labels.js'),
metals: await import('../../server/data/metals.js'),
notifications: await import('../../server/data/notifications.js'),
'price-history': await import('../../server/data/price-history.js'),
'price-source': await import('../../server/data/price-source.js'),
proposals: await import('../../server/data/proposals.js'),
providers: await import('../../server/data/providers.js'),
'scan-logs': await import('../../server/data/scan-logs.js'),
'tx-links': await import('../../server/data/tx-links.js'),
valuations: await import('../../server/data/valuations.js'),
'wallet-addresses': await import('../../server/data/wallet-addresses.js'),
'wallet-sources': await import('../../server/data/wallet-sources.js'),
'wallet-txs': await import('../../server/data/wallet-txs.js'),
};
const LOK = Object.freeze({
xpub: 'xpubLOKVOGEL66666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666',
adres: 'bc1qlokvogel99999999999999999999999999zzzz',
txid: 'lokvogel4444444444444444444444444444444444444444444444444444beef',
extern: 'bc1qlokvogelextern9999999999999999999zzzzz',
notitie: 'LOKVOGELNOTITIE',
rekening: 'LOKVOGELREKENING',
});
const LOKWAARDEN = Object.values(LOK);
const MUTEERT = /^(create|update|delete|upsert|set|purge|merge|assert|emit|forget|take|sync|book|restore|import|save|apply|resolve|dismiss|link|unlink|reset|seed|ensure|add|remove|move|rename|record|log|mark|clear|prune|revert)/;
let ids = {};
let resultaten = [];
let overgeslagen = [];
before(async () => {
harness.recreate();
const asset = await modules.assets.createAsset(
{ symbol: 'BTC', name: 'Bitcoin', kind: 'crypto', unit: 'BTC', subunit: 'sat', decimals: 8, displayUnit: 'unit' });
const account = await modules.accounts.createAccount(
{ name: LOK.rekening, kind: 'wallet', isOwnCustody: 1, scanEnabled: 1 });
ids = { asset: idVan(asset), account: idVan(account) };
const bron = await modules['wallet-sources'].createWalletSource(
{ accountId: ids.account, kind: 'xpub', value: LOK.xpub, name: LOK.rekening, scriptType: 'p2wpkh' });
ids.bron = idVan(bron);
await modules['wallet-addresses'].upsertWalletAddresses([{
accountId: ids.account, sourceId: ids.bron, address: LOK.adres,
chainIndex: 0, derivationIndex: 3, balanceSats: 123456789, transactionCount: 2,
}]);
await modules['wallet-txs'].upsertWalletTxs([{
accountId: ids.account, sourceId: ids.bron, txid: LOK.txid,
direction: 'in', valueSats: 123456789, feeSats: 250, blockHeight: 800000,
blockTime: '2026-01-02T03:04:05Z', address: LOK.adres,
}]);
await modules.events.createEvent({
assetId: ids.asset, type: 'buy', occurredAt: '2026-01-02T03:04:05Z', amount: 123456789,
fiatPrice: 54321.5, toAccountId: ids.account, source: 'manual',
note: LOK.notitie, externalAddress: LOK.extern,
});
for (const [mod, exports] of Object.entries(modules)) {
for (const [naam, fn] of Object.entries(exports)) {
if (typeof fn !== 'function' || MUTEERT.test(naam)) { overgeslagen.push(`${mod}.${naam}`); continue; }
let ruw, gelukt = false;
for (const args of [[], [ids.account], [ids.bron], [ids.asset]]) {
try { ruw = await fn(...args); gelukt = true; break; } catch { }
}
if (gelukt && ruw !== undefined) resultaten.push({ naam: `${mod}.${naam}`, ruw });
else overgeslagen.push(`${mod}.${naam}`);
}
}
});
function idVan(r) { return typeof r === 'object' && r !== null ? (r.id ?? r) : r; }
function bevat(structuur, naald) {
return JSON.stringify(structuur ?? null).includes(naald);
}
describe('AI-sweep, richting 1: geen lokvogel overleeft de vertaalslag', () => {
it('roept genoeg leesfuncties aan om iets te bewijzen', () => {
assert.ok(resultaten.length >= 30,
`te weinig aangeroepen om iets te bewijzen: ${resultaten.length} gelukt, ${overgeslagen.length} overgeslagen`);
});
it('ziet de lokvogels in de ONvertaalde uitvoer (anders bewijst de rest niets)', () => {
const gezien = LOKWAARDEN.filter(w => resultaten.some(r => bevat(r.ruw, w)));
assert.deepEqual(gezien.sort(), [...LOKWAARDEN].sort(),
'niet elke lokvogel kwam in de ruwe uitvoer voor; de sweep raakt die paden dus niet');
});
it('laat geen enkele lokvogel door de vertaalslag', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
const lekken = [];
for (const { naam, ruw } of resultaten) {
const uit = t.toAi(ruw);
for (const w of LOKWAARDEN) if (bevat(uit, w)) lekken.push(`${naam} lekt ${w.slice(0, 16)}…`);
}
assert.deepEqual(lekken, []);
});
});
describe('AI-sweep, richting 2: elk veld in de uitvoer is gedeclareerd', () => {
it('kent voor elk veld in de vertaalde uitvoer een klasse', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
const onbekend = new Set();
const loop = (v) => {
if (Array.isArray(v)) { v.forEach(loop); return; }
if (v === null || typeof v !== 'object') return;
for (const [k, w] of Object.entries(v)) {
if (fieldClass(k) === null) onbekend.add(k);
loop(w);
}
};
for (const { ruw } of resultaten) loop(t.toAi(ruw));
assert.deepEqual([...onbekend].sort(), []);
});
});
+405
View File
@@ -0,0 +1,405 @@
// Generated, do not edit. sha256:3566aeffe3b8fd67fb12a75b9b7a38c50a27b00abed96a2cec53436ed08cde28
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { schijnwaarde, isSchijnwaarde, createAliasRegistry, ALIAS_SOORTEN } from '../../server/ai/aliases.js';
import { bucket, bucketLabel, shareBucket, shareLabel } from '../../server/ai/buckets.js';
import { createTranslator } from '../../server/ai/translate.js';
import { CONFIG } from '../../shared/config.js';
const TEST_GEHEIM = 'test-geheim-voor-de-suite-0123456789abcdef';
describe('schijnwaarden: de onmogelijke letter', () => {
it('maakt een adres dat geen geldig bech32 kan zijn', () => {
const a = schijnwaarde('address', 0);
assert.ok(a.startsWith('bc1q'), a);
assert.equal(a.length, 42);
assert.ok(a.slice(4).includes('o'), `geen onmogelijke letter in ${a}`);
});
it('maakt een xpub die geen geldig base58 kan zijn', () => {
const x = schijnwaarde('xpub', 0);
assert.ok(x.startsWith('xpub'), x);
assert.equal(x.length, 111);
assert.ok(x.slice(4).includes('0'), `geen onmogelijke letter in ${x}`);
});
it('maakt een txid die geen geldige hex kan zijn', () => {
const t = schijnwaarde('txid', 0);
assert.equal(t.length, 64);
assert.ok(t.includes('z'), `geen onmogelijke letter in ${t}`);
});
it('herkent zijn eigen maaksel, ook uit een andere sessie', () => {
for (const soort of ALIAS_SOORTEN) {
assert.equal(isSchijnwaarde(schijnwaarde(soort, 7)), true, soort);
}
});
it('houdt een waarde die alleen uit het echte alfabet bestaat voor echt', () => {
const echtVormig = `bc1q${'qpzry9x8gf2tvdw0s3jn54khce6mua7l'.repeat(2).slice(0, 38)}`;
assert.equal(echtVormig.length, 42);
assert.equal(isSchijnwaarde(echtVormig), false);
});
it('geeft dezelfde invoer dezelfde schijnwaarde, en verschillende invoer een andere', () => {
const r = createAliasRegistry({ secret: TEST_GEHEIM });
const een = r.alias('address', 'bc1qecht1');
assert.equal(r.alias('address', 'bc1qecht1'), een);
assert.notEqual(r.alias('address', 'bc1qecht2'), een);
assert.equal(r.resolve(een), 'bc1qecht1');
});
});
describe('handles: stabiel over sessies, want anders wijzen ze stil naar de verkeerde rij', () => {
const versDeSessie = () => createTranslator({ secret: TEST_GEHEIM });
it('geeft dezelfde rij in twee losse sessies dezelfde handle', () => {
const een = versDeSessie().toAi({ id: 42 }, { entity: 'account' }).id;
const twee = versDeSessie().toAi({ id: 42 }, { entity: 'account' }).id;
assert.equal(een, twee);
});
it('geeft twee rijen een andere handle, ook als ze in omgekeerde volgorde langskomen', () => {
const a = versDeSessie();
const b = versDeSessie();
const a7 = a.toAi({ id: 7 }, { entity: 'account' }).id;
const a9 = a.toAi({ id: 9 }, { entity: 'account' }).id;
const b9 = b.toAi({ id: 9 }, { entity: 'account' }).id;
const b7 = b.toAi({ id: 7 }, { entity: 'account' }).id;
assert.equal(a7, b7);
assert.equal(a9, b9);
assert.notEqual(a7, a9);
});
it('geeft een ANDER geheim andere handles', () => {
const anders = createTranslator({ secret: 'een-heel-ander-geheim-0123456789abcdef' });
assert.notEqual(
versDeSessie().toAi({ id: 42 }, { entity: 'account' }).id,
anders.toAi({ id: 42 }, { entity: 'account' }).id);
});
it('geeft dezelfde waarde onder twee SOORTEN een andere handle', () => {
const t = versDeSessie();
const rekening = t.toAi({ id: 3 }, { entity: 'account' }).id;
const asset = t.toAi({ id: 3 }, { entity: 'asset' }).id;
assert.notEqual(rekening.split(':')[1], asset.split(':')[1]);
});
it('weigert te bestaan zonder geheim', () => {
assert.throws(() => createAliasRegistry(), /secret/i);
assert.throws(() => createAliasRegistry({ secret: 'kort' }), /secret/i);
});
it('maakt ook een schijnwaarde stabiel over sessies', () => {
const een = versDeSessie().toAi({ address: 'bc1qecht' }, { entity: 'walletAddress' }).address;
const twee = versDeSessie().toAi({ address: 'bc1qecht' }, { entity: 'walletAddress' }).address;
assert.equal(een, twee);
assert.ok(isSchijnwaarde(een), een);
});
});
describe('emmers', () => {
it('legt 12.347 in "10000 tot 15000" op de grove ladder', () => {
assert.equal(bucketLabel(12347, 'COARSE'), '10000 tot 15000');
});
it('is op elke schaal even nauwkeurig', () => {
assert.equal(bucketLabel(123.47, 'COARSE'), '100 tot 150');
assert.equal(bucketLabel(1234700, 'COARSE'), '1000000 tot 1500000');
});
it('is fijner op de fijne ladder', () => {
assert.equal(bucketLabel(12347, 'FINE'), '10000 tot 12500');
});
it('laat nul met rust, want nul is geen geheim', () => {
assert.deepEqual(bucket(0), { from: 0, to: 0, exact: true });
assert.equal(bucketLabel(0), '0');
});
it('houdt het teken van een negatief bedrag', () => {
const b = bucket(-12347, 'COARSE');
assert.ok(b.from < 0 && b.to < 0 && b.from < b.to, JSON.stringify(b));
});
it('valt niet om op een exacte macht van tien', () => {
assert.equal(bucketLabel(10000, 'COARSE'), '10000 tot 15000');
assert.equal(bucketLabel(100000, 'COARSE'), '100000 tot 150000');
});
it('geeft niets terug voor iets dat geen getal is', () => {
assert.equal(bucket(NaN), null);
assert.equal(bucket('12347'), null);
});
});
describe('emmers: aandelen', () => {
it('legt een aandeel in een marge van vijf punt', () => {
assert.equal(shareLabel(42.7), '40 tot 45%');
assert.equal(shareLabel(45), '45 tot 50%');
assert.equal(shareLabel(99.9), '95 tot 100%');
});
it('is fijner in de staart, want daar zijn de verschillen klein', () => {
assert.equal(shareLabel(2), '2 tot 4%');
assert.equal(shareLabel(7.4), '6 tot 8%');
assert.equal(shareLabel(11), '10 tot 15%');
});
it('zegt van een piepklein aandeel dat het onder de ondergrens ligt', () => {
assert.equal(shareLabel(0.4), 'minder dan 2%');
assert.equal(shareBucket(0.4).below, true);
assert.equal(shareBucket(3).below, false);
});
it('houdt 0% en 100% exact, want dat zijn feiten en geen marges', () => {
assert.equal(shareLabel(0), '0%');
assert.equal(shareLabel(100), '100%');
});
it('geeft niets terug voor iets dat geen aandeel is', () => {
assert.equal(shareBucket(NaN), null);
assert.equal(shareBucket(-3), null);
assert.equal(shareBucket('42'), null);
});
it('heeft een ladder die oploopt en op 100 eindigt', () => {
const g = CONFIG.AI.SHARE_BUCKETS;
assert.equal(g[g.length - 1], 100);
for (let i = 1; i < g.length; i++) assert.ok(g[i] > g[i - 1], `stap ${i} loopt niet op`);
assert.ok(g[0] > 0, 'de ondergrens hoort boven nul te liggen');
});
});
describe('emmers: de bodem, want een relatieve ladder heeft een absolute grens', () => {
const BODEM = 3000;
it('geeft onder de bodem een bovengrens in plaats van een bereik', () => {
assert.equal(bucketLabel(1200, 'COARSE'), `minder dan ${BODEM}`);
assert.equal(bucketLabel(42, 'COARSE'), `minder dan ${BODEM}`);
assert.equal(bucketLabel(2, 'COARSE'), `minder dan ${BODEM}`);
});
it('laat de bodem los zodra er genoeg waarden in de emmer passen', () => {
assert.equal(bucketLabel(BODEM, 'COARSE'), '3000 tot 5000');
assert.equal(bucketLabel(BODEM - 1, 'COARSE'), `minder dan ${BODEM}`);
});
it('geldt ALLEEN voor gehele waarden', () => {
assert.equal(bucketLabel(123.47, 'COARSE'), '100 tot 150');
assert.equal(bucketLabel(1.6, 'COARSE'), '1.5 tot 2');
});
it('geldt NIET voor de fijne ladder', () => {
assert.equal(bucketLabel(1200, 'FINE'), '1000 tot 1250');
assert.equal(bucketLabel(25, 'FINE'), '25 tot 31.5');
});
it('houdt het teken van een negatieve waarde onder de bodem', () => {
assert.equal(bucketLabel(-42, 'COARSE'), `meer dan -${BODEM}`);
const b = bucket(-42, 'COARSE');
assert.ok(b.from < 0 && b.to === 0, JSON.stringify(b));
});
it('laat nul nog steeds met rust', () => {
assert.equal(bucketLabel(0, 'COARSE'), '0');
});
it('schuift mee als de ladder fijner wordt', () => {
const smalst = Math.min(...CONFIG.AI.BUCKET_LADDERS.COARSE.map((v, i, a) =>
((i + 1 < a.length ? a[i + 1] : 10) - v) / v));
assert.equal(BODEM, CONFIG.AI.BUCKET_MIN_VALUES.COARSE / smalst);
});
});
describe('het choke point, heen', () => {
const rij = () => ({
id: 4, accountId: 9, address: 'bc1qechtadresvandegebruiker0000000000000',
txid: 'a'.repeat(64), valueSats: 123456789, fiatPrice: 54321.5, direction: 'in',
note: 'geheime notitie', blockHeight: 800000, geenBekendVeld: 'zou niet mee mogen',
});
it('laat alleen gedeclareerde velden door', () => {
const uit = createTranslator({ secret: TEST_GEHEIM }).toAi(rij(), { entity: 'walletTransaction' });
assert.equal(Object.hasOwn(uit, 'geenBekendVeld'), false);
assert.equal(Object.hasOwn(uit, 'blockHeight'), false, 'keten-metadata zonder alias-vorm gaat niet mee');
});
it('vervangt keten-data door een schijnwaarde van dezelfde soort', () => {
const uit = createTranslator({ secret: TEST_GEHEIM }).toAi(rij(), { entity: 'walletTransaction' });
assert.ok(uit.address.startsWith('bc1q'), uit.address);
assert.notEqual(uit.address, rij().address);
assert.equal(isSchijnwaarde(uit.address), true);
assert.equal(uit.txid.length, 64);
assert.equal(isSchijnwaarde(uit.txid), true);
});
it('emmert hoeveelheden en laat koersen exact', () => {
const uit = createTranslator({ secret: TEST_GEHEIM }).toAi(rij(), { entity: 'walletTransaction' });
assert.equal(uit.valueSats, '100000000 tot 150000000');
assert.equal(uit.fiatPrice, 54321.5);
assert.equal(uit.direction, 'in');
});
it('vertelt welke velden er doorgekomen zijn, zonder de waarden', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
t.toAi(rij(), { entity: 'walletTransaction' });
const velden = t.fieldsShared();
assert.ok(velden.includes('valueSats'));
assert.equal(velden.includes('geenBekendVeld'), false);
assert.equal(velden.includes('blockHeight'), false);
});
it('deelt Preference.value alleen mét de tabel-hint', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
const zonder = t.toAi({ scope: 'app', key: 'currency', value: 'eur' });
assert.equal(Object.hasOwn(zonder, 'value'), false, 'zonder hint blijft `value` binnen');
const met = t.toAi({ scope: 'app', key: 'currency', value: 'eur' }, { table: 'Preference' });
assert.equal(met.value, 'eur');
assert.equal(met.scope, 'app');
});
it('houdt de xpub binnen, ook met een tabel-hint erbij', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
const uit = t.toAi({ id: 3, value: 'xpub6CUGRUo...' }, { table: 'WalletSource' });
assert.equal(Object.hasOwn(uit, 'value'), false);
assert.ok(!JSON.stringify(uit).includes('xpub'));
});
it('schrijft de GEKWALIFICEERDE naam in het auditlog zodra de uitzondering is gebruikt', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
t.toAi({ scope: 'app', key: 'currency', value: 'eur' }, { table: 'Preference' });
const velden = t.fieldsShared();
assert.ok(velden.includes('Preference.value'));
assert.equal(velden.includes('value'), false);
assert.ok(velden.includes('scope'));
assert.equal(velden.includes('Preference.scope'), false);
});
it('erft de tabel niet naar een genest object', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
const uit = t.toAi({ scope: 'app', source: { value: 'xpub6CUGRUo...' } }, { table: 'Preference' });
assert.equal(uit.source?.value, undefined);
});
it('werkt op diepte, niet alleen op de bovenste laag', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
const uit = t.toAi({ id: 1, accounts: [{ id: 2, address: 'bc1qdiep', note: 'diep' }] });
assert.equal(Object.hasOwn(uit, 'accounts'), false);
});
});
describe('het choke point, terug', () => {
it('vertaalt terug in lopende tekst, niet alleen in velden', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
const uit = t.toAi({ id: 7, name: 'Bitvavo' }, { entity: 'account' });
const antwoord = `Ik zie dat ${uit.id} (${uit.name}) een tekort heeft.`;
const { text } = t.fromAi(antwoord);
assert.equal(text, 'Ik zie dat 7 (Bitvavo) een tekort heeft.');
});
it('kan geen handle verwarren met een andere, want ze zijn allemaal even lang', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
const tokens = [];
for (const naam of ['EEN', 'TWEE', 'DRIE', 'VIER', 'VIJF', 'ZES', 'ZEVEN', 'ACHT', 'NEGEN', 'TIEN']) {
tokens.push(t.toAi({ id: naam }, { entity: 'account' }).id);
}
const lengtes = new Set(tokens.map(x => x.length));
assert.equal(lengtes.size, 1, `handles zijn niet even lang: ${[...lengtes].join(', ')}`);
assert.equal(new Set(tokens).size, tokens.length, 'twee entiteiten kregen dezelfde handle');
for (const a of tokens) {
for (const b of tokens) {
if (a !== b) assert.equal(b.startsWith(a), false, `${a} is een prefix van ${b}`);
}
}
const { text } = t.fromAi(`${tokens[9]} en ${tokens[0]}`);
assert.equal(text, 'TIEN en EEN');
});
it('laat een schijnwaarde die hij niet kent STAAN, en meldt hem', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
const vreemd = schijnwaarde('address', 999);
const { text, unresolved } = t.fromAi(`Kijk naar ${vreemd} eens.`);
assert.ok(text.includes(vreemd), 'de onbekende schijnwaarde hoort te blijven staan');
assert.deepEqual(unresolved, [vreemd]);
});
it('meldt niets bij een tekst zonder schijnwaarden', () => {
const t = createTranslator({ secret: TEST_GEHEIM });
const { text, unresolved } = t.fromAi('Er is niets bijzonders gevonden.');
assert.equal(text, 'Er is niets bijzonders gevonden.');
assert.deepEqual(unresolved, []);
});
});
describe('onbewerkt delen: de vertaalslag uit (ai.allow_raw)', () => {
const RIJ = Object.freeze({
id: 7,
name: 'Spaarrekening bij de bank',
amount: 12345.678,
fiatFee: 4321,
fiatPrice: 98765,
address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
occurredAt: '2026-01-02T03:04:05Z',
});
const vertaald = () => createTranslator({ secret: TEST_GEHEIM });
const onbewerkt = () => createTranslator({ secret: TEST_GEHEIM, precision: { rawValues: true } });
it('geeft bedragen als getal in plaats van als emmer', () => {
const normaal = vertaald().toAi(RIJ, { entity: 'account' });
const ruw = onbewerkt().toAi(RIJ, { entity: 'account' });
assert.equal(typeof normaal.amount, 'string', 'voorwaarde: normaal is het een emmer-label');
assert.equal(typeof normaal.fiatFee, 'string');
assert.equal(ruw.amount, 12345.678);
assert.equal(ruw.fiatFee, 4321);
assert.equal(ruw.fiatPrice, 98765, 'een koers ging altijd al ongefilterd mee');
});
it('geeft namen en id\'s zoals ze zijn, in plaats van als handle', () => {
const normaal = vertaald().toAi(RIJ, { entity: 'account' });
const ruw = onbewerkt().toAi(RIJ, { entity: 'account' });
assert.match(normaal.name, /^text:[0-9a-f]{6}$/, 'voorwaarde: normaal is het een handle');
assert.equal(ruw.name, 'Spaarrekening bij de bank');
assert.equal(ruw.id, 7);
});
it('geeft een adres echt mee in plaats van als schijnwaarde', () => {
const ruw = onbewerkt().toAi(RIJ, { entity: 'account' });
assert.equal(ruw.address, RIJ.address);
assert.equal(isSchijnwaarde(ruw.address), false, 'er hoort geen onmogelijke letter in te zitten');
});
it('HOUDT DE ALLOWLIST OVEREIND: een ongedeclareerd veld gaat ook onbewerkt niet mee', () => {
const ruw = onbewerkt().toAi({ ...RIJ, ietsOnbekends: 'geheim' }, { entity: 'account' });
assert.equal('ietsOnbekends' in ruw, false, 'de allowlist is een ander mechanisme en blijft gelden');
assert.equal(ruw.name, 'Spaarrekening bij de bank', 'voorwaarde: de rest gaat wél mee');
});
it('blijft melden wat er gedeeld is, want het auditlog moet ook hier kloppen', () => {
const t = onbewerkt();
t.beginCall();
t.toAi(RIJ, { entity: 'account' });
const gedeeld = t.fieldsShared();
assert.ok(gedeeld.includes('amount'), 'een gedeeld bedrag hoort in het logboek te staan');
assert.ok(gedeeld.includes('name'));
assert.ok(gedeeld.includes('address'));
});
it('laat vrije tekst met rust bij het terugvertalen, want er is niets vervangen', () => {
const t = onbewerkt();
t.toAi(RIJ, { entity: 'account' });
const uit = t.fromAi('Het saldo van Spaarrekening bij de bank is 12345.678.');
assert.equal(uit.text, 'Het saldo van Spaarrekening bij de bank is 12345.678.');
assert.deepEqual(uit.unresolved, []);
});
});
+173
View File
@@ -0,0 +1,173 @@
// Generated, do not edit. sha256:ea3063d2dd3cc633da7f13f8cbd1705cc2b0b7e8797aeceebc91b788a0760522
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { credentialsStatus, setCredentials, clearCredentials, loadCredentials } from '../../server/auth.mjs';
import { writeActiveName } from '../../server/portfolios.mjs';
import { secretsFile, writeSecretsStore } from '../../server/keys.mjs';
let DIR;
let vorige;
let vorigeUser;
let vorigWachtwoord;
beforeEach(async () => {
vorige = process.env.ASSETAPP_DATA_DIR;
vorigeUser = process.env.ASSETAPP_USER;
vorigWachtwoord = process.env.ASSETAPP_PASSWORD;
delete process.env.ASSETAPP_USER;
delete process.env.ASSETAPP_PASSWORD;
DIR = await mkdtemp(join(tmpdir(), 'assetapp-auth-'));
process.env.ASSETAPP_DATA_DIR = DIR;
writeActiveName('Echt');
});
afterEach(async () => {
if (vorige === undefined) delete process.env.ASSETAPP_DATA_DIR; else process.env.ASSETAPP_DATA_DIR = vorige;
if (vorigeUser === undefined) delete process.env.ASSETAPP_USER; else process.env.ASSETAPP_USER = vorigeUser;
if (vorigWachtwoord === undefined) delete process.env.ASSETAPP_PASSWORD;
else process.env.ASSETAPP_PASSWORD = vorigWachtwoord;
await rm(DIR, { recursive: true, force: true });
});
const store = async () => JSON.parse(await readFile(secretsFile(), 'utf-8'));
describe('credentialsStatus', () => {
it('meldt "geen" als er niets staat', async () => {
assert.deepEqual(await credentialsStatus(), { ingesteld: false, bron: 'geen', wijzigbaar: true });
});
it('geeft het wachtwoord NOOIT terug', async () => {
await setCredentials('geheim');
const status = await credentialsStatus();
assert.equal(status.ingesteld, true);
assert.equal(JSON.stringify(status).includes('geheim'), false, 'het wachtwoord staat in de status');
});
it('meldt de omgeving als bron, en dat het dan niet te wijzigen is', async () => {
process.env.ASSETAPP_PASSWORD = 'ook';
const status = await credentialsStatus();
assert.equal(status.bron, 'omgeving');
assert.equal(status.wijzigbaar, false);
});
it('ZET DE TOEGANGSCONTROLE AAN met alleen ASSETAPP_PASSWORD', async () => {
process.env.ASSETAPP_PASSWORD = 'uit-de-omgeving';
assert.equal((await credentialsStatus()).ingesteld, true);
assert.deepEqual(await loadCredentials(), { password: 'uit-de-omgeving' });
});
it('negeert ASSETAPP_USER volledig', async () => {
process.env.ASSETAPP_USER = 'harmen';
assert.equal((await credentialsStatus()).ingesteld, false);
assert.equal(await loadCredentials(), null);
});
it('telt een leeg wachtwoord in het bestand niet als slot', async () => {
await writeSecretsStore({ assetapp_auth: { password: '' } });
assert.equal((await credentialsStatus()).ingesteld, false);
assert.equal(await loadCredentials(), null);
});
it('laat een BESTAANDE rij met een gebruikersnaam gewoon werken', async () => {
await writeSecretsStore({ assetapp_auth: { user: 'harmen', password: 'van-vroeger' } });
assert.equal((await credentialsStatus()).ingesteld, true);
assert.deepEqual(await loadCredentials(), { password: 'van-vroeger' });
});
});
describe('setCredentials', () => {
it('zet het, en loadCredentials vindt het terug', async () => {
const uit = await setCredentials('geheim');
assert.equal(uit.ingesteld, true);
assert.equal(uit.naHerstart, true, 'de UI hoort te melden dat het pas na een herstart geldt');
assert.deepEqual(await loadCredentials(), { password: 'geheim' });
});
it('wijzigen is dezelfde handeling als aanmaken', async () => {
await setCredentials('eerste');
await setCredentials('tweede');
assert.deepEqual(await loadCredentials(), { password: 'tweede' });
});
it('weigert een leeg wachtwoord in plaats van het te bewaren', async () => {
await assert.rejects(() => setCredentials(''), (e) => e.key === 'portfolios.passwordRequired');
await assert.rejects(() => setCredentials(null), (e) => e.key === 'portfolios.passwordRequired');
await assert.rejects(() => setCredentials(undefined), (e) => e.key === 'portfolios.passwordRequired');
assert.equal((await credentialsStatus()).ingesteld, false, 'en er is niets bewaard');
});
it('stelt GEEN eisen aan wat er wel in staat', async () => {
for (const w of ['a', ' ', '1234', 'áé€', 'x'.repeat(500)]) {
await setCredentials(w);
assert.deepEqual(await loadCredentials(), { password: w });
}
});
it('schrijft de rij ZONDER gebruikersnaam weg, ook als er een in stond', async () => {
await writeSecretsStore({ assetapp_auth: { user: 'harmen', password: 'van-vroeger' } });
await setCredentials('nieuw');
assert.deepEqual((await store()).assetapp_auth, { password: 'nieuw' });
});
it('laat de andere sleutels in het geheimenbestand ongemoeid', async () => {
await writeSecretsStore({ nwc_3: { uri: 'geheim' }, ai_handle_secret: 'a'.repeat(64) });
await setCredentials('geheim');
const s = await store();
assert.equal(s.nwc_3.uri, 'geheim');
assert.equal(s.ai_handle_secret, 'a'.repeat(64));
});
it('schrijft in het bestand van de ACTIEVE portefeuille', async () => {
await setCredentials('geheim');
writeActiveName('Demo');
assert.equal((await credentialsStatus()).ingesteld, false, 'Demo heeft zijn eigen toegangscontrole');
writeActiveName('Echt');
assert.equal((await credentialsStatus()).ingesteld, true);
});
});
describe('clearCredentials: de weg terug naar "alleen deze machine"', () => {
it('haalt het weg', async () => {
await setCredentials('geheim');
const uit = await clearCredentials();
assert.equal(uit.ingesteld, false);
assert.equal(uit.naHerstart, true);
assert.equal(await loadCredentials(), null);
});
it('doet niets als er niets stond, en meldt dat ook zo', async () => {
const uit = await clearCredentials();
assert.equal(uit.ingesteld, false);
assert.equal(uit.naHerstart, false, 'er valt niets te herstarten voor een wijziging die er niet was');
});
it('laat de andere sleutels staan', async () => {
await writeSecretsStore({ nwc_3: { uri: 'geheim' }, assetapp_auth: { password: 'w' } });
await clearCredentials();
const s = await store();
assert.deepEqual(Object.keys(s), ['nwc_3']);
});
});
+107
View File
@@ -0,0 +1,107 @@
// Generated, do not edit. sha256:47f02343d278744260374448334893708a776affbe44624b7fc8f089e85783c7
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { createRequestListener } from '../../server/app.mjs';
import { originOf, connectSrcOrigins, originAllowedByCsp } from '../../shared/csp.js';
import { CONFIG } from '../../shared/config.js';
describe('originOf', () => {
it('geeft het origin van een gewone URL', () => {
assert.equal(originOf('https://mempool.space/api/blocks/tip/height'), 'https://mempool.space');
assert.equal(originOf('https://pool.example.nl:3006/api'), 'https://pool.example.nl:3006');
});
it('geeft null bij iets dat geen URL is, in plaats van te gooien', () => {
for (const rommel of ['', ' ', 'pool.example.nl', 'niet eens een adres', null, undefined, 42]) {
assert.equal(originOf(rommel), null, `${rommel} hoort null te geven`);
}
});
});
describe('connectSrcOrigins', () => {
it('bevat altijd de publieke mempool.space, ook zonder verbindingen', () => {
assert.deepEqual(connectSrcOrigins(), [originOf(CONFIG.PRICE_SOURCES.MEMPOOL_PUBLIC_BASE)]);
});
it('neemt een eigen explorer-verbinding op, zonder codewijziging', () => {
const origins = connectSrcOrigins(['https://pool.example.nl:3006/', 'https://mempool.space']);
assert.deepEqual(origins, ['https://mempool.space', 'https://pool.example.nl:3006']);
});
it('laat met een verbindingsadres niets anders in de header komen dan een origin', () => {
const origins = connectSrcOrigins([
'javascript:alert(1)', 'data:text/plain,x', 'https://a.example; script-src *',
'https://b.example *', 'file:///etc/passwd', 'niet eens een adres',
]);
assert.deepEqual(origins, ['https://mempool.space']);
});
it('ontdubbelt en bevat geen null', () => {
const origins = connectSrcOrigins(['https://mempool.space/api', 'https://mempool.space']);
assert.equal(new Set(origins).size, origins.length);
assert.equal(origins.some(o => !o), false);
});
});
describe('originAllowedByCsp', () => {
it('staat een origin uit de allowlist toe', () => {
assert.equal(originAllowedByCsp(`${CONFIG.PRICE_SOURCES.MEMPOOL_PUBLIC_BASE}/api/blocks/tip/height`), true);
});
it('WEIGERT een profiel dat niet in de lijst van het document stond', () => {
assert.equal(originAllowedByCsp('https://mijn-eigen-explorer.example/api'), false);
});
it('staat een profiel toe dat bij het laden al een verbinding was', () => {
assert.equal(originAllowedByCsp('https://mijn-eigen-explorer.example/api/blocks/tip/height', null,
['https://mijn-eigen-explorer.example']), true);
});
it('weigert rommel in plaats van hem door te laten', () => {
for (const rommel of ['', 'pool.example.nl', null, undefined]) {
assert.equal(originAllowedByCsp(rommel), false);
}
});
it('staat de eigen origin toe, maar alleen als die is meegegeven', () => {
assert.equal(originAllowedByCsp('https://localhost:8080/api/data', 'https://localhost:8080'), true);
assert.equal(originAllowedByCsp('https://localhost:8080/api/data'), false);
});
});
describe('de CSP-header van een echt verzoek', () => {
let server, basis, csp;
before(async () => {
server = createServer(createRequestListener({ credentials: null }));
await new Promise(klaar => server.listen(0, '127.0.0.1', klaar));
basis = `http://127.0.0.1:${server.address().port}`;
const res = await fetch(`${basis}/index.html`);
assert.equal(res.status, 200, 'het inlaadpunt hoort gewoon geserveerd te worden');
csp = res.headers.get('content-security-policy');
});
after(() => server.close());
it('stuurt een header mee', () => {
assert.ok(csp, 'zonder header is er geen grens en zegt de rest van deze suite niets');
});
it('staat in connect-src precies de origins toe die de helper noemt', () => {
const directive = csp.split(';').map(d => d.trim()).find(d => d.startsWith('connect-src'));
const bronnen = directive.split(/\s+/).slice(1);
assert.deepEqual(bronnen, [`'self'`, ...connectSrcOrigins()]);
});
it('houdt script-src kaal op self, zonder enige uitzondering', () => {
const directive = csp.split(';').map(d => d.trim()).find(d => d.startsWith('script-src'));
assert.equal(directive, `script-src 'self'`);
});
it('sluit object-src en frame-ancestors helemaal af', () => {
assert.match(csp, /object-src 'none'/);
assert.match(csp, /frame-ancestors 'none'/);
});
});
+199
View File
@@ -0,0 +1,199 @@
// Generated, do not edit. sha256:9963d8baa8caaf89cbf056ba74cfb376898f7081b03f3679eb8d46496383efaf
import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { mkdtemp, rm, readFile, writeFile, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const DIR = await mkdtemp(join(tmpdir(), 'assetapp-backup-'));
process.env.ASSETAPP_DATA_DIR = DIR;
const { initDatabase, dbFile } = await import('../../server/db.js');
const { closeDatabase, run, one } = await import('../../server/db/conn.mjs');
const { secretsFile } = await import('../../server/keys.mjs');
const { prepareLayout } = await import('../../server/portfolios.mjs');
prepareLayout();
const DB_FILE = dbFile();
const SECRETS_FILE = secretsFile();
const { createBackup, restoreBackup, isBackupBestand, FORMAAT } = await import('../../server/db-backup.mjs');
const { CONFIG } = await import('../../shared/config.js');
const { SCHEMA } = await import('../../server/db/schema.js');
const WACHTWOORD = 'een-lang-genoeg-wachtwoord';
async function bundelVan(dbBytes, secretsTekst, manifestOver = null) {
const { randomBytes, scrypt, createCipheriv } = await import('node:crypto');
const { promisify } = await import('node:util');
const salt = randomBytes(FORMAAT.SALT_LEN);
const iv = randomBytes(FORMAAT.IV_LEN);
const sleutel = await promisify(scrypt)(WACHTWOORD, salt, 32, FORMAAT.SCRYPT);
const secrets = Buffer.from(secretsTekst, 'utf-8');
const manifest = Buffer.from(JSON.stringify(
{ v: 1, gemaakt: '2026-01-01T00:00:00.000Z', db: dbBytes.length, secrets: secrets.length, ...manifestOver })
+ '\n', 'utf-8');
const c = createCipheriv('aes-256-gcm', sleutel, iv);
const cijfer = Buffer.concat([c.update(Buffer.concat([manifest, dbBytes, secrets])), c.final()]);
return Buffer.concat([FORMAAT.MAGIC, Buffer.of(FORMAAT.VERSIE), salt, iv, c.getAuthTag(), cijfer]);
}
let fixtureTeller = 0;
async function geldigeDatabase(versie = CONFIG.SCHEMA_VERSION) {
const p = join(DIR, `fixture-${versie}-${++fixtureTeller}.sqlite`);
const db = new DatabaseSync(p);
db.exec(SCHEMA);
db.prepare(`INSERT INTO Schema (version) VALUES (?)`).run(BigInt(versie));
db.close();
return readFile(p);
}
const bestaat = async p => { try { await access(p); return true; } catch { return false; } };
const merk = () => { const r = one(`SELECT name FROM Label LIMIT 1`); return r ? r.name : null; };
const geheimen = async () => JSON.parse(await readFile(SECRETS_FILE, 'utf-8'));
async function opzet(naam) {
closeDatabase();
await rm(DB_FILE, { force: true });
for (const b of ['-wal', '-shm']) await rm(DB_FILE + b, { force: true });
initDatabase();
run(`DELETE FROM Label`);
run(`INSERT INTO Label (name, searchName) VALUES (?,?)`, [naam, naam]);
await writeFile(SECRETS_FILE, JSON.stringify({ [`nwc_${naam}`]: { uri: `geheim-van-${naam}` } }), { mode: 0o600 });
}
before(() => initDatabase());
after(async () => { closeDatabase(); await rm(DIR, { recursive: true, force: true }); });
beforeEach(() => opzet('origineel'));
describe('createBackup / restoreBackup: heen en terug', () => {
it('zet database én geheimen samen terug', async () => {
const { bytes } = await createBackup(WACHTWOORD);
await opzet('later');
assert.equal(merk(), 'later');
const r = await restoreBackup(bytes, WACHTWOORD);
assert.equal(merk(), 'origineel');
assert.deepEqual(await geheimen(), { nwc_origineel: { uri: 'geheim-van-origineel' } });
assert.equal(r.geheimen, 1);
assert.equal(r.versie, CONFIG.SCHEMA_VERSION);
});
it('bewaart van allebei de bestanden de vorige versie', async () => {
const { bytes } = await createBackup(WACHTWOORD);
await opzet('later');
const r = await restoreBackup(bytes, WACHTWOORD);
assert.ok(await bestaat(r.previousDb), 'de vorige database staat er nog');
assert.ok(await bestaat(r.previousSecrets), 'en de vorige geheimen ook');
assert.deepEqual(JSON.parse(await readFile(r.previousSecrets, 'utf-8')),
{ nwc_later: { uri: 'geheim-van-later' } });
});
it('werkt zonder geheimenbestand (verse installatie)', async () => {
await rm(SECRETS_FILE, { force: true });
const { bytes, secrets } = await createBackup(WACHTWOORD);
assert.ok(secrets > 0, 'er zit een lege bundel in, geen niets');
const r = await restoreBackup(bytes, WACHTWOORD);
assert.equal(r.geheimen, 0);
});
it('bevat de laatste schrijfacties, ook die nog in het WAL stonden', async () => {
run(`INSERT INTO Label (name, searchName) VALUES ('net-geschreven','net-geschreven')`);
const { bytes } = await createBackup(WACHTWOORD);
await opzet('later');
await restoreBackup(bytes, WACHTWOORD);
assert.equal(one(`SELECT COUNT(*) AS n FROM Label WHERE name = 'net-geschreven'`).n, 1);
});
});
describe('createBackup / restoreBackup: het formaat', () => {
it('is herkenbaar aan zijn kop', async () => {
const { bytes } = await createBackup(WACHTWOORD);
assert.ok(isBackupBestand(bytes));
assert.ok(!isBackupBestand(Buffer.from('SQLite format 3\0')), 'een losse database is geen back-up');
});
it('houdt de kop-indeling vast, zodat hij met de hand te openen blijft', async () => {
assert.equal(FORMAAT.MAGIC.toString('latin1'), 'ASSETAPP-BACKUP');
assert.deepEqual(
{ v: FORMAAT.VERSIE, salt: FORMAAT.SALT_LEN, iv: FORMAAT.IV_LEN, tag: FORMAAT.TAG_LEN, kop: FORMAAT.KOP_LEN },
{ v: 1, salt: 16, iv: 12, tag: 16, kop: 60 });
assert.deepEqual({ N: FORMAAT.SCRYPT.N, r: FORMAAT.SCRYPT.r, p: FORMAAT.SCRYPT.p }, { N: 32768, r: 8, p: 1 });
});
it('geeft bij dezelfde inhoud twee keer een ander bestand', async () => {
const a = await createBackup(WACHTWOORD);
const b = await createBackup(WACHTWOORD);
assert.ok(!a.bytes.equals(b.bytes));
const salt = x => x.bytes.subarray(16, 32).toString('hex');
const iv = x => x.bytes.subarray(32, 44).toString('hex');
assert.notEqual(salt(a), salt(b));
assert.notEqual(iv(a), iv(b));
});
it('bevat het geheim niet leesbaar', async () => {
const { bytes } = await createBackup(WACHTWOORD);
assert.equal(bytes.includes(Buffer.from('geheim-van-origineel')), false);
assert.equal(bytes.includes(Buffer.from('SQLite format 3')), false, 'ook de database is versleuteld');
});
it('weigert een te kort wachtwoord', async () => {
await assert.rejects(() => createBackup(''), (e) => e.key === 'dbfile.passTooShort');
await assert.rejects(() => createBackup('kort'), (e) => e.key === 'dbfile.passTooShort');
});
});
describe('restoreBackup: een afgekeurde bundel raakt niets aan', () => {
async function weigert(bytes, wachtwoord, patroon) {
await assert.rejects(() => restoreBackup(bytes, wachtwoord), patroon);
assert.equal(merk(), 'origineel', 'de live database is niet aangeraakt');
assert.deepEqual(await geheimen(), { nwc_origineel: { uri: 'geheim-van-origineel' } },
'en de geheimen ook niet');
assert.equal(await bestaat(DB_FILE + '.new'), false, 'geen half bestand achtergelaten');
}
it('weigert een verkeerd wachtwoord', async () => {
const { bytes } = await createBackup(WACHTWOORD);
await weigert(bytes, 'een-ander-wachtwoord', (e) => e.key === 'dbfile.decryptFailed');
});
it('weigert een bundel waar één byte aan veranderd is', async () => {
const { bytes } = await createBackup(WACHTWOORD);
const stuk = Buffer.from(bytes);
stuk[stuk.length - 1] ^= 0xff;
await weigert(stuk, WACHTWOORD, (e) => e.key === 'dbfile.decryptFailed');
});
it('weigert een afgekapte bundel', async () => {
const { bytes } = await createBackup(WACHTWOORD);
await weigert(bytes.subarray(0, Math.floor(bytes.length / 2)), WACHTWOORD, (e) => (e.key || '').startsWith('dbfile.'));
});
it('weigert een bestand dat geen back-up is, met een verwijzing naar de andere knop', async () => {
await weigert(Buffer.from('SQLite format 3\0hier staat een database'), WACHTWOORD, (e) => (e.key || '').startsWith('dbfile.notABackup'));
});
it('weigert een onbekende formaatversie', async () => {
const { bytes } = await createBackup(WACHTWOORD);
const toekomst = Buffer.from(bytes);
toekomst[15] = 99;
await weigert(toekomst, WACHTWOORD, (e) => e.key === 'dbfile.unknownBackupVersion');
});
it('weigert een bundel met een database die te oud is voor deze app', async () => {
const oud = join(DIR, 'oud.sqlite');
const db = new DatabaseSync(oud);
db.exec(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
db.prepare(`INSERT INTO Schema (version) VALUES (?)`).run(59n);
db.close();
await weigert(await bundelVan(await readFile(oud), '{}'), WACHTWOORD, /baseline-convert/);
});
it('weigert een bundel waarvan de lengtes in het manifest niet bij de inhoud passen', async () => {
const db = await geldigeDatabase();
await weigert(await bundelVan(db, '{}', { db: db.length - 100 }), WACHTWOORD, (e) => e.key === 'dbfile.manifestLengths');
});
it('weigert een bundel waarin het geheimenbestand geen JSON is', async () => {
await weigert(await bundelVan(await geldigeDatabase(), 'dit is geen json'), WACHTWOORD, (e) => e.key === 'dbfile.secretsUnreadable');
});
});
+160
View File
@@ -0,0 +1,160 @@
// Generated, do not edit. sha256:db9b5ce5c6a3dba8e3c23b58d01a079d11c9184ff6a8915c824e00ac45006d20
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
openDatabase, closeDatabase, database, pragmaStand,
run, all, one, lastId, BUSY_TIMEOUT_MS,
} from '../../server/db/conn.mjs';
const DDL = `
CREATE TABLE Ouder (id INTEGER PRIMARY KEY, naam TEXT NOT NULL) STRICT;
CREATE TABLE Kind (
id INTEGER PRIMARY KEY,
ouderId INTEGER NOT NULL REFERENCES Ouder(id) ON DELETE CASCADE,
tekst TEXT NOT NULL
) STRICT;
CREATE INDEX IxKindOuder ON Kind (ouderId);
`;
describe('server/db/conn: de verbinding en zijn PRAGMA\'s', () => {
beforeEach(() => { openDatabase(':memory:'); run(DDL); });
afterEach(() => closeDatabase());
it('dwingt foreign keys écht af, en niet alleen op papier', () => {
run(`INSERT INTO Ouder (naam) VALUES (?)`, ['een']);
const ouderId = lastId();
run(`INSERT INTO Kind (ouderId, tekst) VALUES (?,?)`, [ouderId, 'hoort erbij']);
assert.throws(
() => run(`INSERT INTO Kind (ouderId, tekst) VALUES (?,?)`, [9999, 'wees']),
/FOREIGN KEY constraint failed/);
run(`DELETE FROM Ouder WHERE id = ?`, [ouderId]);
assert.equal(all(`SELECT * FROM Kind`).length, 0);
});
it('TEGENPROEF: zonder handhaving slaagt exact dezelfde INSERT', () => {
const zonder = new DatabaseSync(':memory:', { enableForeignKeyConstraints: false });
zonder.exec(DDL);
zonder.prepare(`INSERT INTO Kind (ouderId, tekst) VALUES (?,?)`).run(9999n, 'wees');
assert.equal(zonder.prepare(`SELECT COUNT(*) AS n FROM Kind`).get().n, 1,
'zonder handhaving hoort de wees er gewoon in te staan; anders bewijst de test hierboven niets');
zonder.close();
});
it('zet de vier PRAGMA\'s op de verbinding', () => {
const stand = pragmaStand();
assert.equal(stand.foreignKeys, true);
assert.equal(stand.journalMode, 'memory');
assert.equal(stand.synchronous, 1);
assert.equal(stand.busyTimeout, BUSY_TIMEOUT_MS);
});
it('weigert een tweede verbinding zolang de eerste openstaat', () => {
assert.throws(() => openDatabase(':memory:'), /already open/);
});
it('geeft gewone objecten terug, geen rijen met null-prototype', () => {
run(`INSERT INTO Ouder (naam) VALUES (?)`, ['een']);
const rij = one(`SELECT * FROM Ouder`);
assert.equal(Object.getPrototypeOf(rij), Object.prototype);
assert.deepEqual(rij, { id: 1, naam: 'een' });
});
it('geeft null bij one() zonder treffer, en null uit lastId() zonder insert', () => {
assert.equal(one(`SELECT * FROM Ouder WHERE id = ?`, [404]), null);
});
});
describe('server/db/conn: bindwaarden gelijk aan wat de browser deed', () => {
beforeEach(() => {
openDatabase(':memory:');
run(`CREATE TABLE Waarde (id INTEGER PRIMARY KEY, tekst TEXT, getal INTEGER) STRICT;`);
});
afterEach(() => closeDatabase());
it('maakt van undefined een NULL in plaats van te gooien', () => {
run(`INSERT INTO Waarde (tekst, getal) VALUES (?,?)`, [undefined, 1]);
assert.equal(one(`SELECT tekst FROM Waarde`).tekst, null);
});
it('maakt van true/false een 1/0', () => {
run(`INSERT INTO Waarde (tekst, getal) VALUES (?,?)`, ['x', true]);
run(`INSERT INTO Waarde (tekst, getal) VALUES (?,?)`, ['y', false]);
assert.deepEqual(all(`SELECT getal FROM Waarde ORDER BY id`).map(r => r.getal), [1, 0]);
});
it('schrijft een heel getal in een TEXT-kolom als \'1\' en niet als \'1.0\'', () => {
run(`INSERT INTO Waarde (tekst) VALUES (?)`, [1]);
assert.equal(one(`SELECT tekst FROM Waarde`).tekst, '1');
});
});
describe('server/db/conn: run() zonder params voert meerdere statements uit', () => {
beforeEach(() => openDatabase(':memory:'));
afterEach(() => closeDatabase());
it('voert een meerdelige DDL-string volledig uit', () => {
run(DDL);
const tabellen = all(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`).map(r => r.name);
assert.deepEqual(tabellen, ['Kind', 'Ouder']);
});
it('voert met params alleen het eerste statement uit', () => {
run(`CREATE TABLE A (id INTEGER PRIMARY KEY, t TEXT) STRICT;`);
run(`INSERT INTO A (t) VALUES (?); INSERT INTO A (t) VALUES ('tweede');`, ['eerste']);
assert.deepEqual(all(`SELECT t FROM A`).map(r => r.t), ['eerste']);
});
});
describe('server/db/conn: op een echt bestand', () => {
let dir, pad;
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'assetapp-db-'));
pad = join(dir, 'test.sqlite');
});
after(async () => {
closeDatabase();
await rm(dir, { recursive: true, force: true });
});
it('zet WAL aan, en die blijft in het bestand staan', () => {
openDatabase(pad);
run(DDL);
assert.equal(pragmaStand().journalMode, 'wal');
closeDatabase();
const kaal = new DatabaseSync(pad);
assert.equal(kaal.prepare(`PRAGMA journal_mode`).get().journal_mode, 'wal');
kaal.close();
});
it('zet synchronous en busy_timeout élke keer opnieuw, want die zijn NIET persistent', () => {
const kaal = new DatabaseSync(pad);
assert.equal(kaal.prepare(`PRAGMA synchronous`).get().synchronous, 2);
assert.equal(kaal.prepare(`PRAGMA busy_timeout`).get().timeout, 0);
kaal.close();
openDatabase(pad);
const stand = pragmaStand();
assert.equal(stand.synchronous, 1);
assert.equal(stand.busyTimeout, BUSY_TIMEOUT_MS);
closeDatabase();
});
it('bewaart de data tussen twee sessies door, zonder persist()', () => {
openDatabase(pad);
run(`INSERT INTO Ouder (naam) VALUES (?)`, ['blijft staan']);
closeDatabase();
openDatabase(pad);
assert.equal(one(`SELECT naam FROM Ouder ORDER BY id DESC`).naam, 'blijft staan');
assert.equal(database().constructor.name, 'DatabaseSync');
closeDatabase();
});
});
+129
View File
@@ -0,0 +1,129 @@
// Generated, do not edit. sha256:819333deae77dc120b2355f624bcd66c41435cf53288c76e8c74c98bc181e6bc
import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { mkdtemp, rm, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const DIR = await mkdtemp(join(tmpdir(), 'assetapp-fresh-test-'));
process.env.ASSETAPP_DATA_DIR = DIR;
process.env.ASSETAPP_DB_FILE = join(DIR, 'db.sqlite');
const { initDatabase, dbFile } = await import('../../server/db.js');
const DB_FILE = dbFile();
const { closeDatabase, run, one } = await import('../../server/db/conn.mjs');
const { startFreshDatabase, chooseSetupCurrency } = await import('../../server/db-fresh.mjs');
const { CONFIG } = await import('../../shared/config.js');
const bestaat = async p => { try { await access(p); return true; } catch { return false; } };
const aantal = tabel => one(`SELECT COUNT(*) AS n FROM ${tabel}`).n;
before(() => initDatabase());
after(async () => { closeDatabase(); await rm(DIR, { recursive: true, force: true }); });
beforeEach(async () => {
closeDatabase();
for (const b of ['', '-wal', '-shm', '.previous', '.previous-wal', '.previous-shm']) {
await rm(DB_FILE + b, { force: true });
}
initDatabase();
run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt)
VALUES ('Kluis','kluis','vault',1,0,1,NULL)`);
run(`INSERT INTO Label (name, searchName) VALUES ('merkteken','merkteken')`);
});
describe('startFreshDatabase', () => {
it('laat een administratie achter zonder eigen gegevens', async () => {
const r = await startFreshDatabase();
assert.equal(r.versie, CONFIG.SCHEMA_VERSION);
for (const tabel of ['Account', 'Event', 'Valuation', 'Label']) {
assert.equal(aantal(tabel), 0, `${tabel} hoort leeg te zijn`);
}
});
it('levert een VERSE en geen LEGE database: de seeds staan erin', async () => {
await startFreshDatabase();
assert.ok(aantal('Asset') > 0, 'assets');
assert.ok(aantal('Provider') > 0, 'providers');
assert.ok(aantal('DashboardProfile') > 0, 'dashboardprofiel');
assert.equal(one(`SELECT version FROM Schema`).version, CONFIG.SCHEMA_VERSION);
assert.equal(one(`SELECT baseCurrency FROM Portfolio`).baseCurrency, CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY);
});
it('zet alleen fiat neer, geen crypto, geen metaal en geen metaalproducten', async () => {
await startFreshDatabase();
assert.equal(aantal('Asset'), one(`SELECT COUNT(*) AS n FROM Asset WHERE kind = 'fiat'`).n, 'alleen fiat');
assert.equal(aantal('MetalProduct'), 0, 'geen metaalproducten');
});
it('houdt de rekenvaluta van de portefeuille, en valt niet terug op de standaard', async () => {
closeDatabase();
for (const b of ['', '-wal', '-shm']) await rm(DB_FILE + b, { force: true });
initDatabase(DB_FILE, { baseCurrency: 'USD' });
await startFreshDatabase();
assert.equal(one(`SELECT baseCurrency FROM Portfolio`).baseCurrency, 'USD');
});
it('bewaart de oude administratie als .previous', async () => {
const r = await startFreshDatabase();
assert.ok(await bestaat(r.previous));
const oud = new DatabaseSync(r.previous, { readOnly: true });
assert.equal(oud.prepare(`SELECT name FROM Label LIMIT 1`).get().name, 'merkteken');
assert.equal(oud.prepare(`SELECT COUNT(*) AS n FROM Account`).get().n, 1);
oud.close();
});
it('laat een werkende verbinding achter', async () => {
await startFreshDatabase();
run(`INSERT INTO Label (name, searchName) VALUES ('erna','erna')`);
assert.equal(aantal('Label'), 1, 'er is te schrijven én te lezen na de wissel');
});
it('zegt dat de geheimen NIET zijn weggegooid', async () => {
const r = await startFreshDatabase();
assert.match(r.geheimenWaarschuwing, /secrets\.json/);
assert.match(r.geheimenWaarschuwing, /NIET weggegooid/);
});
});
describe('chooseSetupCurrency', () => {
const maagdelijk = async () => {
closeDatabase();
for (const b of ['', '-wal', '-shm', '.previous', '.previous-wal', '.previous-shm']) await rm(DB_FILE + b, { force: true });
initDatabase();
};
const munt = () => one(`SELECT baseCurrency FROM Portfolio`).baseCurrency;
it('zet een maagdelijke portefeuille in de gekozen munt', async () => {
await maagdelijk();
assert.deepEqual(await chooseSetupCurrency('USD'), { changed: true });
assert.equal(munt(), 'USD');
});
it('weigert zodra er een account staat, en laat de administratie staan', async () => {
await assert.rejects(() => chooseSetupCurrency('USD'), /holds nothing yet/);
assert.equal(munt(), CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY);
assert.equal(aantal('Account'), 1);
assert.equal(await bestaat(`${DB_FILE}.previous`), false, 'er is niets gewisseld');
});
it('weigert na afronden van het venster, ook op een lege portefeuille', async () => {
await maagdelijk();
run(`INSERT INTO Preference (scope, key, value) VALUES ('app','setup_done','1')
ON CONFLICT (scope, key) DO UPDATE SET value = excluded.value`);
await assert.rejects(() => chooseSetupCurrency('USD'), /holds nothing yet/);
assert.equal(munt(), CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY);
});
it('dezelfde munt wisselt niets, ook niet op een gevulde portefeuille', async () => {
assert.deepEqual(await chooseSetupCurrency(CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY), { changed: false });
assert.equal(aantal('Account'), 1);
});
it('weigert een munt buiten de lijst', async () => {
await maagdelijk();
await assert.rejects(() => chooseSetupCurrency('XYZ'), /Unknown unit of account/);
assert.equal(munt(), CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY);
});
});
+151
View File
@@ -0,0 +1,151 @@
// Generated, do not edit. sha256:b1e7db08e72ca29ddd3b364141b2e4cd3fc040df65fe9c6f17ee2547ebc6452c
import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { mkdtemp, mkdir, rm, readFile, writeFile, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const DIR = await mkdtemp(join(tmpdir(), 'assetapp-restore-'));
process.env.ASSETAPP_DATA_DIR = DIR;
process.env.ASSETAPP_DB_FILE = join(DIR, 'db.sqlite');
const { initDatabase, dbFile } = await import('../../server/db.js');
const DB_FILE = dbFile();
const { closeDatabase, run, one } = await import('../../server/db/conn.mjs');
const { restoreDatabase } = await import('../../server/db-restore.mjs');
const { SCHEMA } = await import('../../server/db/schema.js');
const { CONFIG } = await import('../../shared/config.js');
const bestaat = async p => { try { await access(p); return true; } catch { return false; } };
async function maakDatabase(pad, { versie = CONFIG.SCHEMA_VERSION, merk = 'nieuw' } = {}) {
const db = new DatabaseSync(pad);
db.exec(SCHEMA);
db.prepare(`INSERT INTO Schema (version) VALUES (?)`).run(BigInt(versie));
db.prepare(`INSERT INTO Label (name, searchName) VALUES (?,?)`).run(merk, merk);
db.close();
return readFile(pad);
}
const huidigMerk = () => { const r = one(`SELECT name FROM Label LIMIT 1`); return r ? r.name : null; };
before(() => initDatabase());
after(async () => { closeDatabase(); await rm(DIR, { recursive: true, force: true }); });
beforeEach(async () => {
closeDatabase();
await rm(DB_FILE, { force: true });
for (const b of ['-wal', '-shm']) await rm(DB_FILE + b, { force: true });
await maakDatabase(DB_FILE, { merk: 'origineel' });
initDatabase();
});
describe('restoreDatabase: het gelukkige pad', () => {
it('vervangt de database en opent de nieuwe', async () => {
const bytes = await maakDatabase(join(DIR, 'bron.sqlite'), { merk: 'teruggezet' });
const r = await restoreDatabase(bytes);
assert.equal(r.versie, CONFIG.SCHEMA_VERSION);
assert.equal(huidigMerk(), 'teruggezet');
});
it('bewaart de vorige database als .previous', async () => {
const bytes = await maakDatabase(join(DIR, 'bron2.sqlite'), { merk: 'teruggezet' });
const r = await restoreDatabase(bytes);
assert.ok(await bestaat(r.previous));
const oud = new DatabaseSync(r.previous, { readOnly: true });
assert.equal(oud.prepare(`SELECT name FROM Label LIMIT 1`).get().name, 'origineel');
oud.close();
});
it('laat geen WAL-buren van de vorige database naast de nieuwe staan', async () => {
run(`INSERT INTO Label (name, searchName) VALUES ('los','los')`);
assert.ok(await bestaat(DB_FILE + '-wal'), 'voorwaarde: er staat een write-ahead log');
const bytes = await maakDatabase(join(DIR, 'bron3.sqlite'), { merk: 'teruggezet' });
await restoreDatabase(bytes);
assert.equal(huidigMerk(), 'teruggezet');
assert.equal(one(`SELECT COUNT(*) AS n FROM Label WHERE name = 'los'`).n, 0,
'de teruggezette database bevat niets uit het log van zijn voorganger');
});
it('meldt verweesde verwijzingen in plaats van ze te verzwijgen', async () => {
const pad = join(DIR, 'wees.sqlite');
const db = new DatabaseSync(pad, { enableForeignKeyConstraints: false });
db.exec(SCHEMA);
db.prepare(`INSERT INTO Schema (version) VALUES (?)`).run(BigInt(CONFIG.SCHEMA_VERSION));
db.prepare(`INSERT INTO WalletSource (accountId, kind, value, createdAt) VALUES (?,?,?,?)`)
.run(999n, 'address', 'bc1q', '2026-01-01T00:00:00Z');
db.close();
const r = await restoreDatabase(await readFile(pad));
assert.ok(r.wezen > 0, 'de wees hoort geteld te worden');
});
it('noemt de geheimen, want die reizen niet mee', async () => {
const bytes = await maakDatabase(join(DIR, 'bron4.sqlite'));
const r = await restoreDatabase(bytes);
assert.match(r.geheimenWaarschuwing, /secrets\.json/);
});
});
describe('restoreDatabase: een afgekeurd bestand raakt de live database niet aan', () => {
async function weigert(bytes, patroon) {
await assert.rejects(() => restoreDatabase(bytes), patroon);
assert.equal(huidigMerk(), 'origineel', 'de live database is niet aangeraakt');
assert.equal(await bestaat(DB_FILE + '.new'), false, 'en er blijft geen half bestand achter');
}
it('weigert een leeg bestand', async () => {
await weigert(Buffer.alloc(0), (e) => e.key === 'dbfile.emptyFile');
});
it('weigert iets dat geen SQLite-bestand is', async () => {
await weigert(Buffer.from('dit is gewoon tekst, geen database'), (e) => e.key === 'dbfile.notSqlite');
});
it('weigert een SQLite-bestand zonder Schema-tabel', async () => {
const pad = join(DIR, 'leeg.sqlite');
const db = new DatabaseSync(pad);
db.exec(`CREATE TABLE Iets (id INTEGER PRIMARY KEY) STRICT;`);
db.close();
await weigert(await readFile(pad), (e) => e.key === 'dbfile.noSchemaTable');
});
it('weigert een database van vóór de baseline, en verwijst naar het conversiescript', async () => {
const bytes = await maakDatabase(join(DIR, 'oud.sqlite'), { versie: 59 });
await weigert(bytes, /baseline-convert/);
});
it('weigert een database die nieuwer is dan de app', async () => {
const bytes = await maakDatabase(join(DIR, 'toekomst.sqlite'), { versie: CONFIG.SCHEMA_VERSION + 1 });
await weigert(bytes, (e) => e.key === 'dbfile.versionNewerThanApp');
});
it('laat de database ook na een afkeuring gewoon bruikbaar', async () => {
await assert.rejects(() => restoreDatabase(Buffer.from('rommel')));
run(`INSERT INTO Label (name, searchName) VALUES ('na','na')`);
assert.equal(one(`SELECT COUNT(*) AS n FROM Label WHERE name = 'na'`).n, 1);
});
});
describe('restoreDatabase: de wissel loopt stuk', () => {
it('laat de app niet zonder database achter, en verandert niets', async () => {
const bytes = await maakDatabase(join(DIR, 'nieuw-bij-stuk.sqlite'), { merk: 'nieuw' });
await rm(`${DB_FILE}.previous`, { recursive: true, force: true });
await mkdir(`${DB_FILE}.previous`, { recursive: true });
await writeFile(join(`${DB_FILE}.previous`, 'bezet.txt'), 'x');
try {
await assert.rejects(() => restoreDatabase(bytes), 'de wissel hoort te falen');
assert.equal(huidigMerk(), 'origineel', 'de oude database staat er nog en is open');
run(`INSERT INTO Label (name, searchName) VALUES ('na','na')`);
assert.equal(one(`SELECT COUNT(*) AS n FROM Label WHERE name = 'na'`).n, 1);
assert.equal(await bestaat(`${DB_FILE}.new`), false, 'het tijdelijke bestand is weg');
} finally {
await rm(`${DB_FILE}.previous`, { recursive: true, force: true });
}
});
});
+43
View File
@@ -0,0 +1,43 @@
// Generated, do not edit. sha256:c2d25713d25824d31233a0929d6996ce933fb3b072e93edf82eb9834146a3761
import { describe, it, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const map = mkdtempSync(join(tmpdir(), 'gratis-licentie-'));
const vorige = process.env.ASSETAPP_DATA_DIR;
process.env.ASSETAPP_DATA_DIR = map;
const { CONFIG } = await import('../../shared/config.js');
const { parseLicense, verifySignature, readLicense } = await import('../../server/license.mjs');
const { marketServiceStatus, marketConfigured } = await import('../../server/market-service.mjs');
const { writeActiveName } = await import('../../server/portfolios.mjs');
const tekst = readFileSync(new URL(`../../server/${CONFIG.LICENSE.FREE_FILE_NAME}`, import.meta.url), 'utf8');
after(() => {
rmSync(map, { recursive: true, force: true });
if (vorige === undefined) delete process.env.ASSETAPP_DATA_DIR; else process.env.ASSETAPP_DATA_DIR = vorige;
});
describe('de meegeleverde gratis licentie', () => {
it('is getekend met de verkopersleutel van deze app, gratis, en geldt voor elke hoofdversie', () => {
const parsed = parseLicense(tekst);
assert.equal(verifySignature(parsed), true, 'de handtekening hoort niet bij de verkopersleutel in CONFIG');
assert.equal(parsed.license.level, 'free');
assert.equal(parsed.license.majorFrom, 1);
assert.equal(parsed.license.majorTo, CONFIG.LICENSE.UNLIMITED);
});
it('staat op het supportaccount van Atreides Services', () => {
assert.equal(parseLicense(tekst).license.buyer, CONFIG.LICENSE.SUPPORT.RECIPIENT);
});
it('geeft een installatie zonder eigen licentie iets om een token mee aan te vragen, zonder er haar licentie te worden', async () => {
writeActiveName('Leeg');
assert.equal(readLicense().state, 'none', 'de gratis licentie geldt als geïnstalleerde licentie');
assert.equal((await marketServiceStatus()).can_register, true);
assert.equal(await marketConfigured(), true);
});
});
+74
View File
@@ -0,0 +1,74 @@
// Generated, do not edit. sha256:d1b5492d54f83698bbcb8bdc3d04e455dbca8d3823702e08f9379c11b89bc2c0
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeActiveName } from '../../server/portfolios.mjs';
import { secretsFile } from '../../server/keys.mjs';
import { handleSecret, _resetHandleSecret } from '../../server/ai/handle-secret.mjs';
let DIR;
let vorige;
beforeEach(async () => {
vorige = process.env.ASSETAPP_DATA_DIR;
DIR = await mkdtemp(join(tmpdir(), 'assetapp-handle-'));
process.env.ASSETAPP_DATA_DIR = DIR;
_resetHandleSecret();
});
afterEach(async () => {
_resetHandleSecret();
if (vorige === undefined) delete process.env.ASSETAPP_DATA_DIR;
else process.env.ASSETAPP_DATA_DIR = vorige;
await rm(DIR, { recursive: true, force: true });
});
describe('handleSecret: één geheim per portefeuille', () => {
it('geeft twee portefeuilles een VERSCHILLEND geheim, ook binnen hetzelfde proces', async () => {
writeActiveName('Echt');
const echt = await handleSecret();
writeActiveName('Demo');
const demo = await handleSecret();
assert.notEqual(demo, echt, 'anders krijgt dezelfde rij-id in beide portefeuilles dezelfde handle');
assert.match(echt, /^[0-9a-f]{64}$/);
assert.match(demo, /^[0-9a-f]{64}$/);
});
it('geeft binnen één portefeuille elke keer hetzelfde geheim terug', async () => {
writeActiveName('Echt');
const eerste = await handleSecret();
assert.equal(await handleSecret(), eerste);
_resetHandleSecret();
assert.equal(await handleSecret(), eerste);
});
it('komt na heen-en-weer wisselen weer bij het eigen geheim uit', async () => {
writeActiveName('Echt');
const echt = await handleSecret();
writeActiveName('Demo');
await handleSecret();
writeActiveName('Echt');
assert.equal(await handleSecret(), echt);
});
it('schrijft het geheim in het geheimenbestand van díe portefeuille en nergens anders', async () => {
writeActiveName('Echt');
const echt = await handleSecret();
const echtPad = secretsFile();
writeActiveName('Demo');
await handleSecret();
const inEcht = JSON.parse(await readFile(echtPad, 'utf-8'));
const inDemo = JSON.parse(await readFile(secretsFile(), 'utf-8'));
assert.equal(inEcht.ai_handle_secret, echt);
assert.notEqual(inDemo.ai_handle_secret, echt);
});
});
+48
View File
@@ -0,0 +1,48 @@
// Generated, do not edit. sha256:3e61f67d82dbe1259a5c61b9f8a08c07ace908e30dd16d94e85cb5c611d6a98a
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CONFIG } from '../../shared/config.js';
import { installLocale, setInstallLocale, adoptInstallLocale } from '../../server/installation.mjs';
const bestand = () => join(process.env.ASSETAPP_DATA_DIR, CONFIG.INSTALLATION.SETTINGS_FILE_NAME);
beforeEach(() => { process.env.ASSETAPP_DATA_DIR = mkdtempSync(join(tmpdir(), 'assetapp-install-')); });
describe('installatie: de taal', () => {
it('begint een nieuwe installatie in de standaardtaal', () => {
assert.equal(installLocale(), CONFIG.INSTALLATION.DEFAULT_LOCALE);
adoptInstallLocale(undefined);
assert.equal(installLocale(), CONFIG.INSTALLATION.DEFAULT_LOCALE);
});
it('neemt de taal van een bestaande portefeuille één keer over', () => {
adoptInstallLocale('nl');
assert.equal(installLocale(), 'nl');
adoptInstallLocale('en');
assert.equal(installLocale(), 'nl', 'een volgende start overschrijft de gekozen taal niet');
});
it('neemt geen taal over die de app niet kent', () => {
adoptInstallLocale('xx');
assert.equal(installLocale(), CONFIG.INSTALLATION.DEFAULT_LOCALE);
});
it('weigert een onbekende taal en laat het bestand staan', () => {
setInstallLocale('nl');
assert.throws(() => setInstallLocale('xx'), /Unknown locale/);
assert.equal(JSON.parse(readFileSync(bestand(), 'utf8')).locale, 'nl');
});
it('houdt andere instellingen in het bestand', () => {
writeFileSync(bestand(), JSON.stringify({ toekomst: 1 }));
setInstallLocale('nl');
assert.deepEqual(JSON.parse(readFileSync(bestand(), 'utf8')), { toekomst: 1, locale: 'nl' });
});
it('gooit bij een kapot bestand in plaats van stil terug te vallen', () => {
writeFileSync(bestand(), '{ kapot');
assert.throws(() => installLocale(), SyntaxError);
});
});
+41
View File
@@ -0,0 +1,41 @@
// Generated, do not edit. sha256:dd1bcf99efd446812f29e83f4cccd6691a1368dbca957ce48876c8d396e81efa
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeActiveName } from '../../server/portfolios.mjs';
import { moveProviderKeys, readSecretsStore, writeSecretsStore } from '../../server/keys.mjs';
let DIR, vorige;
beforeEach(async () => {
vorige = process.env.ASSETAPP_DATA_DIR;
DIR = await mkdtemp(join(tmpdir(), 'assetapp-keys-'));
process.env.ASSETAPP_DATA_DIR = DIR;
writeActiveName('Echt');
});
afterEach(async () => {
if (vorige === undefined) delete process.env.ASSETAPP_DATA_DIR; else process.env.ASSETAPP_DATA_DIR = vorige;
await rm(DIR, { recursive: true, force: true });
});
describe('moveProviderKeys', () => {
it('verhuist de sleutel naar de samengevoegde bron en laat de rest staan', async () => {
await writeSecretsStore({ eulerpool_metal: { api_key: 'geheim' }, goldapi: { api_key: 'ander' } });
assert.equal(await moveProviderKeys('eulerpool_metal', 'eulerpool'), true);
assert.deepEqual(await readSecretsStore(), { eulerpool: { api_key: 'geheim' }, goldapi: { api_key: 'ander' } });
});
it('laat een sleutel die het doel al had winnen', async () => {
await writeSecretsStore({ eulerpool_metal: { api_key: 'oud' }, eulerpool: { api_key: 'nieuw' } });
await moveProviderKeys('eulerpool_metal', 'eulerpool');
assert.deepEqual(await readSecretsStore(), { eulerpool: { api_key: 'nieuw' } });
});
it('doet niets als er niets te verhuizen is, ook de tweede keer', async () => {
await writeSecretsStore({ eulerpool: { api_key: 'x' } });
assert.equal(await moveProviderKeys('eulerpool_metal', 'eulerpool'), false);
assert.deepEqual(await readSecretsStore(), { eulerpool: { api_key: 'x' } });
});
});
+28
View File
@@ -0,0 +1,28 @@
// Generated, do not edit. sha256:dc9b5240735aade2cd4727a1bd632ab45a0fa5cea12b50a575de8c45524b23a8
import { describe, it } 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 { licenseState } from '../../server/license.mjs';
const LEEG = mkdtempSync(join(tmpdir(), 'lic-editie-'));
describe('de stand zonder de volledige editie', () => {
it('is op 0.x de gratis stand, want develop bestaat hier niet', () => {
const s = licenseState('0.2.0', LEEG);
assert.equal(s.level, 'free');
assert.equal(s.enforced, true);
});
it('laat ASSETAPP_LICENSE_STATE=full ook op 0.x niets openzetten', () => {
const vorige = process.env.ASSETAPP_LICENSE_STATE;
try {
process.env.ASSETAPP_LICENSE_STATE = 'full';
assert.equal(licenseState('0.2.0', LEEG).level, 'free');
} finally {
if (vorige === undefined) delete process.env.ASSETAPP_LICENSE_STATE;
else process.env.ASSETAPP_LICENSE_STATE = vorige;
}
});
});
+80
View File
@@ -0,0 +1,80 @@
// Generated, do not edit. sha256:2e6bfe033c662af65c973904363a2a3b198b4658a4ee3fa7b11729b6619711bf
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CONFIG } from '../../shared/config.js';
let geldig = true;
const echt = await import('../../server/license.mjs');
mock.module('../../server/license.mjs', {
namedExports: { ...echt, verifySignature: () => geldig },
});
const { installLicense } = await import('../../server/data/license-install.js');
const BODY = { buyer: 'a'.repeat(64), level: 'full', issued: '2026-09-19', majorFrom: 1, majorTo: 'unlimited' };
const BESTAND = JSON.stringify({ format: CONFIG.LICENSE.FORMAT, license: BODY, signature: 'b'.repeat(128) });
let DIR;
let vorige;
const pad = () => join(DIR, CONFIG.LICENSE.FILE_NAME);
beforeEach(() => {
vorige = process.env.ASSETAPP_DATA_DIR;
DIR = mkdtempSync(join(tmpdir(), 'lic-inst-'));
process.env.ASSETAPP_DATA_DIR = DIR;
geldig = true;
});
afterEach(() => {
if (vorige === undefined) delete process.env.ASSETAPP_DATA_DIR;
else process.env.ASSETAPP_DATA_DIR = vorige;
rmSync(DIR, { recursive: true, force: true });
});
describe('een licentie installeren', () => {
it('schrijft hem weg en meldt of hij deze versie dekt', async () => {
const uit = await installLicense(BESTAND);
assert.deepEqual(uit.license, BODY);
assert.equal(typeof uit.covers, 'boolean');
assert.ok(existsSync(pad()));
const opSchijf = JSON.parse(readFileSync(pad(), 'utf8'));
assert.deepEqual(Object.keys(opSchijf), ['format', 'license', 'signature']);
});
it('weigert onleesbare invoer met de reden erbij, en schrijft NIETS', async () => {
await assert.rejects(() => installLicense('geen json'), (fout) => {
assert.equal(fout.key, 'license.installUnreadable');
assert.match(fout.params.reason, /JSON/);
return true;
});
assert.equal(existsSync(pad()), false);
});
it('weigert een verkeerde handtekening, en schrijft NIETS', async () => {
geldig = false;
await assert.rejects(() => installLicense(BESTAND), (fout) => {
assert.equal(fout.key, 'license.installBadSignature');
return true;
});
assert.equal(existsSync(pad()), false);
});
it('LAAT EEN WERKENDE LICENTIE STAAN als de nieuwe niet deugt', async () => {
await installLicense(BESTAND);
const voor = readFileSync(pad(), 'utf8');
geldig = false;
await assert.rejects(() => installLicense(JSON.stringify({ format: 1, license: { ...BODY, majorFrom: 9 }, signature: 'c'.repeat(128) })));
await assert.rejects(() => installLicense('{ kapot'));
assert.equal(readFileSync(pad(), 'utf8'), voor, 'de bestaande licentie is aangeraakt');
});
it('overschrijft een BESTAAND bestand wel als de nieuwe deugt', async () => {
writeFileSync(pad(), 'oude rommel');
await installLicense(BESTAND);
assert.deepEqual(JSON.parse(readFileSync(pad(), 'utf8')).license, BODY);
});
});
+70
View File
@@ -0,0 +1,70 @@
// Generated, do not edit. sha256:e7c1f8e0ef8ff3927bf8ff8a92bc2028aac07446bbf50cb1b0842db32ca8e5f3
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let stand = { level: 'free', enforced: true, why: 'none' };
const echt = await import('../../server/license.mjs');
mock.module('../../server/license.mjs', {
namedExports: {
...echt,
licenseState: () => stand,
capacityReached: (soort, huidig) => echt.capacityReached(soort, huidig, stand),
},
});
const { listPortfolios } = await import('../../server/portfolios.mjs');
const { createPortfolio, archivePortfolio } = await import('../../server/portfolios-admin.mjs');
let DIR;
let vorige;
beforeEach(async () => {
vorige = process.env.ASSETAPP_DATA_DIR;
DIR = await mkdtemp(join(tmpdir(), 'assetapp-lic-'));
process.env.ASSETAPP_DATA_DIR = DIR;
stand = { level: 'free', enforced: true, why: 'none' };
});
afterEach(async () => {
if (vorige === undefined) delete process.env.ASSETAPP_DATA_DIR;
else process.env.ASSETAPP_DATA_DIR = vorige;
await rm(DIR, { recursive: true, force: true });
});
describe('de gratis stand op portefeuilles', () => {
it('laat de EERSTE portefeuille gewoon aanmaken', async () => {
await createPortfolio('Eerste');
assert.equal(listPortfolios().length, 1);
});
it('weigert de TWEEDE, met een vertaalbare sleutel', async () => {
await createPortfolio('Eerste');
await assert.rejects(() => createPortfolio('Tweede'), (fout) => {
assert.equal(fout.key, 'portfolios.maxFree');
assert.equal(fout.params.max, 1);
return true;
});
assert.deepEqual(listPortfolios().map((p) => p.naam), ['Eerste']);
});
it('laat de tweede WEL toe in de betaalde stand', async () => {
await createPortfolio('Eerste');
stand = { level: 'full', enforced: true, why: 'licensed' };
await createPortfolio('Tweede');
assert.equal(listPortfolios().length, 2);
});
it('ARCHIVEREN blijft werken boven de grens, en geeft daarna weer ruimte', async () => {
stand = { level: 'full', enforced: true, why: 'licensed' };
await createPortfolio('Eerste');
await createPortfolio('Tweede');
stand = { level: 'free', enforced: true, why: 'none' };
await archivePortfolio('Tweede');
assert.equal(listPortfolios().length, 1);
await assert.rejects(() => createPortfolio('Derde'), (fout) => fout.key === 'portfolios.maxFree');
});
});
+170
View File
@@ -0,0 +1,170 @@
// Generated, do not edit. sha256:750cbb904d1f7cfd54fdb78681dde6a5150641198d16274559a18e9e1a72c8e2
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { schnorr } from '@noble/curves/secp256k1.js';
import { CONFIG } from '../../shared/config.js';
import {
parseLicense, verifySignature, coversVersion, licenseMessage, readLicense,
} from '../../server/license.mjs';
const SELLER_PRIV = Buffer.from('1'.repeat(64), 'hex');
const SELLER_PUB = Buffer.from(schnorr.getPublicKey(SELLER_PRIV)).toString('hex');
const KOPER = 'a'.repeat(64);
const GELDIG = { buyer: KOPER, level: 'full', issued: '2026-09-19', majorFrom: 1, majorTo: 1 };
function onderteken(body, priv = SELLER_PRIV) {
const hash = createHash('sha256').update(licenseMessage(body), 'utf-8').digest();
return Buffer.from(schnorr.sign(hash, priv)).toString('hex');
}
function bestand(body, extra = {}) {
return JSON.stringify({ format: CONFIG.LICENSE.FORMAT, license: body, signature: onderteken(body), ...extra });
}
describe('de vorm van het bestand', () => {
it('leest een geldige licentie', () => {
const p = parseLicense(bestand(GELDIG));
assert.deepEqual(p.license, GELDIG);
});
it('weigert een ONTBREKEND veld in plaats van er een standaard voor te nemen', () => {
for (const veld of ['buyer', 'level', 'issued', 'majorFrom', 'majorTo']) {
const body = { ...GELDIG };
delete body[veld];
assert.throws(() => parseLicense(bestand(body)), new RegExp(veld), `${veld} mag niet weggelaten kunnen worden`);
}
});
it('weigert een ONBEKEND veld, want dat kan een beperking zijn die deze versie nog niet kent', () => {
assert.throws(() => parseLicense(bestand({ ...GELDIG, seats: 99 })), /unknown fields: seats/);
});
it('weigert een ander formaatnummer, en zegt welke het las', () => {
assert.throws(() => parseLicense(JSON.stringify({ format: 3, license: GELDIG, signature: onderteken(GELDIG) })), /format 3/);
});
it('weigert rommel: geen JSON, geen object, geen handtekening van de juiste lengte', () => {
assert.throws(() => parseLicense('geen json'), /not valid JSON/);
assert.throws(() => parseLicense('[]'), /not an object/);
assert.throws(() => parseLicense(JSON.stringify({ format: 1, license: GELDIG, signature: 'kort' })), /128 hex/);
});
it('weigert een koper-sleutel die geen 64 hex is, en een niveau buiten de lijst', () => {
assert.throws(() => parseLicense(bestand({ ...GELDIG, buyer: 'npub1abc' })), /64 hex/);
assert.throws(() => parseLicense(bestand({ ...GELDIG, level: 'super' })), /Unknown level/);
});
it('weigert een bereik dat achterstevoren staat of op 0 begint', () => {
assert.throws(() => parseLicense(bestand({ ...GELDIG, majorFrom: 2, majorTo: 1 })), /majorTo/);
assert.throws(() => parseLicense(bestand({ ...GELDIG, majorFrom: 0 })), /majorFrom/);
});
it('accepteert ONBEPERKT als expliciete waarde', () => {
const eeuwig = { ...GELDIG, majorTo: CONFIG.LICENSE.UNLIMITED };
assert.equal(parseLicense(bestand(eeuwig)).license.majorTo, 'unlimited');
});
});
describe('de handtekening', () => {
it('klopt bij de sleutel van de verkoper', () => {
assert.equal(verifySignature(parseLicense(bestand(GELDIG)), SELLER_PUB), true);
});
it('valt om zodra er ÉÉN veld gewijzigd is, ook als dat veld er geldig uitziet', () => {
const parsed = parseLicense(bestand(GELDIG));
parsed.license.majorTo = CONFIG.LICENSE.UNLIMITED;
assert.equal(verifySignature(parsed, SELLER_PUB), false);
});
it('valt om bij een handtekening van een ANDERE sleutel', () => {
const vreemd = Buffer.from('2'.repeat(64), 'hex');
const ruw = JSON.stringify({ format: 1, license: GELDIG, signature: onderteken(GELDIG, vreemd) });
assert.equal(verifySignature(parseLicense(ruw), SELLER_PUB), false);
});
it('is ONGELDIG zonder verkopersleutel, en met een sleutel die geen sleutel is', () => {
for (const sleutel of ['', 'geen sleutel', 'a'.repeat(63)]) {
assert.equal(verifySignature(parseLicense(bestand(GELDIG)), sleutel), false, `sleutel ${JSON.stringify(sleutel)}`);
}
});
it('geeft NEE en geen uitzondering bij een handtekening die geen punt op de curve is', () => {
const ruw = JSON.stringify({ format: 1, license: GELDIG, signature: 'f'.repeat(128) });
assert.equal(verifySignature(parseLicense(ruw), SELLER_PUB), false);
});
it('ondertekent niet over de kale waarden: het voorvoegsel zit in het bericht', () => {
assert.ok(licenseMessage(GELDIG).startsWith(`${CONFIG.LICENSE.MESSAGE_PREFIX}\n`));
});
});
describe('het versiebereik', () => {
it('dekt de hoofdversie in het bereik en niets erbuiten', () => {
assert.equal(coversVersion({ majorFrom: 1, majorTo: 1 }, '1.4.2'), true);
assert.equal(coversVersion({ majorFrom: 1, majorTo: 1 }, '2.0.0'), false);
assert.equal(coversVersion({ majorFrom: 2, majorTo: 3 }, '1.9.9'), false);
assert.equal(coversVersion({ majorFrom: 2, majorTo: 3 }, '3.0.1'), true);
});
it('dekt ALLES vanaf de ondergrens bij een onbeperkt bereik', () => {
const eeuwig = { majorFrom: 1, majorTo: CONFIG.LICENSE.UNLIMITED };
assert.equal(coversVersion(eeuwig, '1.0.0'), true);
assert.equal(coversVersion(eeuwig, '9.3.1'), true);
});
it('dekt NOOIT een 0.x-versie, want dat is develop', () => {
assert.equal(coversVersion({ majorFrom: 1, majorTo: 1 }, '0.1.3'), false);
assert.equal(coversVersion({ majorFrom: 1, majorTo: CONFIG.LICENSE.UNLIMITED }, '0.9.9'), false);
assert.equal(coversVersion({ majorFrom: 0, majorTo: CONFIG.LICENSE.UNLIMITED }, '0.5.0'), false);
});
it('dekt niets bij een versie die niet de vorm x.y.z heeft', () => {
assert.equal(coversVersion({ majorFrom: 1, majorTo: 1 }, 'v1.0.0'), false);
assert.equal(coversVersion({ majorFrom: 1, majorTo: 1 }, undefined), false);
});
it('EEN ONTBREKENDE BOVENGRENS IS NIET EEUWIG', () => {
assert.equal(coversVersion({ majorFrom: 1 }, '1.0.0'), false);
const body = { ...GELDIG };
delete body.majorTo;
assert.throws(() => parseLicense(bestand(body)), /majorTo/);
});
});
describe('het bestand op schijf', () => {
it('meldt GEEN licentie als er geen bestand is, en dat is geen fout', () => {
const map = mkdtempSync(join(tmpdir(), 'lic-'));
try {
assert.deepEqual(readLicense(map), { state: 'none' });
} finally {
rmSync(map, { recursive: true, force: true });
}
});
it('meldt ONGELDIG met een reden bij een kapot bestand, en crasht niet', () => {
const map = mkdtempSync(join(tmpdir(), 'lic-'));
try {
writeFileSync(join(map, CONFIG.LICENSE.FILE_NAME), '{ half');
const uitkomst = readLicense(map);
assert.equal(uitkomst.state, 'invalid');
assert.match(uitkomst.reason, /JSON/);
} finally {
rmSync(map, { recursive: true, force: true });
}
});
it('meldt ONGELDIG bij een nette licentie zolang er geen verkopersleutel is', () => {
const map = mkdtempSync(join(tmpdir(), 'lic-'));
try {
writeFileSync(join(map, CONFIG.LICENSE.FILE_NAME), bestand(GELDIG));
assert.equal(readLicense(map).state, 'invalid');
} finally {
rmSync(map, { recursive: true, force: true });
}
});
});
+641
View File
@@ -0,0 +1,641 @@
// Generated, do not edit. sha256:0218bafc8f02c1e9ace72164cc21b81de083223f11fc50e42ec7fedf10c23058
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
marketServiceStatus, createMarketClient, isRetryLater,
marketConfigured,
} from '../../server/market-service.mjs';
import { writeActiveName } from '../../server/portfolios.mjs';
import { readSecretsStore, writeSecretsStore } from '../../server/keys.mjs';
import { CONFIG } from '../../shared/config.js';
const URL_OK = 'http://10.0.0.11:8091';
const TOKEN = 'ats_0123456789abcdef';
const HEALTH = { ok: true, app: 'Atreides Services', version: '0.1.0' };
const REGISTER = {
instruments: [
{ key: 'crypto:BTC', kind: 'crypto', symbol: 'BTC', quotes: ['EUR'], resolutions: ['minute', 'hour', 'day'] },
{ key: 'metal:XAU', kind: 'metal', symbol: 'XAU', quotes: ['EUR', 'USD'], resolutions: ['day'] },
],
};
const json = (body, status = 200, headers = {}) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } });
function fakeFetch(routes) {
const calls = [];
const fn = async (url, init = {}) => {
const u = new URL(url);
const rawUrl = u.href;
if (init.body) {
for (const [k, v] of Object.entries(JSON.parse(init.body))) u.searchParams.set(k, Array.isArray(v) ? v.join(',') : String(v));
}
calls.push({ url: u, rawUrl, headers: init.headers || {}, method: init.method || 'GET', body: init.body });
const route = routes[u.pathname];
if (!route) return json({ error: 'Not found', key: 'api.notFound' }, 404);
return route(u, init);
};
fn.calls = calls;
return fn;
}
const client = (routes, cfg = { url: URL_OK, token: TOKEN }, extra = {}) => {
const fetch = fakeFetch(routes);
return { fetch, c: createMarketClient({ fetch, config: async () => cfg, journal: null, ...extra }) };
};
function journaal({ kapot = false } = {}) {
const j = { rijen: [], tellingen: [] };
j.failure = async (entry, secrets) => { if (kapot) throw new Error('log kapot'); j.rijen.push({ entry, secrets }); };
j.count = async (c) => { if (kapot) throw new Error('log kapot'); j.tellingen.push(c); };
return j;
}
async function rejectsWith(promise, key) {
let fout = null;
await assert.rejects(promise, (e) => { fout = e; return e.key === key; });
return fout;
}
describe('createMarketClient: de verbindingstest', () => {
it('slaagt in twee stappen: health zonder token, het register mét', async () => {
const { fetch, c } = client({ '/v1/health': () => json(HEALTH), '/v1/instruments': () => json(REGISTER) });
const uit = await c.testConnection();
assert.deepEqual(uit, { url: URL_OK, app: 'Atreides Services', version: '0.1.0', instruments: 2 });
assert.deepEqual(fetch.calls.map(x => x.url.pathname), ['/v1/health', '/v1/instruments']);
assert.equal(fetch.calls[0].headers.Authorization, undefined, 'health hoort zonder token');
assert.equal(fetch.calls[1].headers.Authorization, `Bearer ${TOKEN}`);
for (const x of fetch.calls) assert.equal(x.rawUrl.includes(TOKEN) || String(x.body).includes(TOKEN), false, 'het token staat in de URL of de body');
assert.deepEqual([fetch.calls[0].method, fetch.calls[0].body], ['GET', undefined]);
assert.equal(fetch.calls[1].method, 'POST');
assert.equal(fetch.calls[1].headers['Content-Type'], 'application/json');
});
it('zet geen parameter in de URL van een POST, want een proxylog bewaart de URL', async () => {
const { fetch, c } = client({ '/v1/spot': () => json({ spot: [], missing: [], unreachable: [] }) });
await c.spots(['crypto:BTC', 'crypto:ETH'], 'EUR');
assert.equal(new URL(fetch.calls[0].rawUrl).search, '');
assert.deepEqual(JSON.parse(fetch.calls[0].body), { instruments: ['crypto:BTC', 'crypto:ETH'], quote: 'EUR' });
});
it('zonder token gaat het register de deur niet uit', async () => {
const { fetch, c } = client({ '/v1/health': () => json(HEALTH), '/v1/instruments': () => json(REGISTER) },
{ url: URL_OK, token: '' });
await rejectsWith(c.testConnection(), 'marketService.noToken');
assert.deepEqual(fetch.calls.map(x => x.url.pathname), ['/v1/health']);
});
it('herkent de beheer-app (of iets anders) op het opgegeven adres', async () => {
const html = () => new Response('<!doctype html><title>Beheer</title>', { status: 200, headers: { 'Content-Type': 'text/html' } });
await rejectsWith(client({ '/v1/health': html }).c.testConnection(), 'marketService.badAnswer');
const ander = () => json({ ok: true, app: 'Iets anders' });
await rejectsWith(client({ '/v1/health': ander }).c.testConnection(), 'marketService.badAnswer');
});
it('weigert een register dat geen lijst is', async () => {
const { c } = client({ '/v1/health': () => json(HEALTH), '/v1/instruments': () => json({ instruments: null }) });
await rejectsWith(c.testConnection(), 'marketService.badAnswer');
});
});
describe('createMarketClient: de onwelkome paden', () => {
const registerMet = (antwoord) => client({ '/v1/instruments': antwoord }).c;
it('dienst weg: "onbereikbaar", met de oorzaak, en later opnieuw', async () => {
const weg = () => { throw new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } }); };
const e = await rejectsWith(registerMet(weg).instruments(), 'marketService.unreachable');
assert.deepEqual(e.params, { url: URL_OK, reason: 'ECONNREFUSED' });
assert.equal(isRetryLater(e), true);
});
it('een dienst die niet antwoordt: timeout, en later opnieuw', async () => {
const hangt = (u, init) => new Promise((_, reject) => {
init.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
});
const { c } = client({ '/v1/instruments': hangt }, undefined, { timeoutMs: 20 });
const e = await rejectsWith(c.instruments(), 'marketService.timeout');
assert.equal(isRetryLater(e), true);
});
it('401: het token, en NIET later opnieuw', async () => {
const e = await rejectsWith(
registerMet(() => json({ error: 'Unauthorized', key: 'api.unauthorized' }, 401)).instruments(),
'marketService.unauthorized');
assert.equal(isRetryLater(e), false);
});
it('429: te veel verzoeken, met de wachttijd uit Retry-After', async () => {
const e = await rejectsWith(
registerMet(() => json({ error: 'Too many', key: 'api.tooManyRequests' }, 429, { 'Retry-After': '30' })).instruments(),
'marketService.tooManyRequests');
assert.deepEqual(e.params, { s: 30 });
assert.equal(isRetryLater(e), true);
});
it('503 api.monthUnreachable: een maand die nu niet te lezen is, NOOIT "geen koersen"', async () => {
const e = await rejectsWith(
registerMet(() => json({ error: 'Month unreachable', key: 'api.monthUnreachable', month: '2026-09' }, 503)).instruments(),
'marketService.monthUnreachable');
assert.deepEqual(e.params, { month: '2026-09' });
assert.equal(isRetryLater(e), true);
});
it('503 zonder die sleutel: de dienst start nog, later opnieuw', async () => {
const e = await rejectsWith(registerMet(() => json({ error: 'Starting' }, 503)).instruments(),
'marketService.unavailable');
assert.equal(isRetryLater(e), true);
});
it('een andere weigering draagt de sleutel van de dienst mee en is geen "later opnieuw"', async () => {
const e = await rejectsWith(
registerMet(() => json({ error: 'Bad time', key: 'api.badTime' }, 400)).instruments(),
'marketService.rejected');
assert.deepEqual(e.params, { status: 400, key: 'api.badTime', reason: 'Bad time' });
assert.equal(isRetryLater(e), false);
});
it('een fout zonder JSON-body levert toch een leesbare weigering op', async () => {
const kaal = () => new Response('Bad Gateway', { status: 502 });
const e = await rejectsWith(registerMet(kaal).instruments(), 'marketService.rejected');
assert.deepEqual(e.params, { status: 502, key: '-', reason: 'HTTP 502' });
});
it('isRetryLater zegt nee tegen een gewone fout en tegen niets', () => {
assert.equal(isRetryLater(new Error('x')), false);
assert.equal(isRetryLater(null), false);
});
});
describe('createMarketClient: de reeks (fase 2)', () => {
const FROM = Date.parse('2026-10-09T00:00:00Z');
const TO = Date.parse('2026-10-09T12:00:00Z');
const minuut = (rows, extra = {}) => json({
instrument: 'crypto:BTC', currency: 'EUR', source: 'bitvavo', kind: 'spot', resolution: 'minute',
columns: ['t', 'price'], rows, next: null, ...extra,
});
it('neemt de bron per rij mee als derde element, en laat een rij zonder bron op twee', async () => {
const { c } = client({ '/v1/series': () => minuut(
[[FROM + 60_000, 61000, 'bitvavo'], [FROM + 120_000, 61010, 'mempool'], [FROM + 180_000, 61020, null]],
{ columns: ['t', 'price', 'source'], source: 'mempool' }) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.deepEqual(uit.points, [[FROM + 60_000, 61000, 'bitvavo'], [FROM + 120_000, 61010, 'mempool'], [FROM + 180_000, 61020]]);
assert.equal(uit.source, 'mempool');
});
it('neemt de naam per bronsleutel mee, over alle pagina\'s, en negeert een naam die geen tekst is', async () => {
const MID = FROM + 6 * 3600_000;
const { c } = client({ '/v1/series': (u) => (Number(u.searchParams.get('from')) === FROM
? minuut([[FROM + 60_000, 1, 'bitvavo']], { columns: ['t', 'price', 'source'], next: MID,
sourceLabel: 'Een Nederlandse aanbieder', sourceLabels: { bitvavo: 'Een Nederlandse aanbieder', kapot: 7 } })
: minuut([[MID + 60_000, 2, 'mempool']], { columns: ['t', 'price', 'source'], sourceLabels: { mempool: 'mempool.space' } })) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.equal(uit.sourceLabel, 'Een Nederlandse aanbieder');
assert.deepEqual(uit.sourceLabels, { bitvavo: 'Een Nederlandse aanbieder', mempool: 'mempool.space' });
});
it('vraagt instrument, munt, resolutie en periode, en geeft [t, prijs] terug', async () => {
const { fetch, c } = client({ '/v1/series': () => minuut([[FROM + 60_000, 61000], [FROM + 120_000, 61010]]) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.deepEqual(uit, { instrument: 'crypto:BTC', currency: 'EUR', source: 'bitvavo', sourceLabel: null, resolution: 'minute',
sourceLabels: {}, points: [[FROM + 60_000, 61000], [FROM + 120_000, 61010]] });
const q = fetch.calls[0].url.searchParams;
assert.deepEqual(Object.fromEntries(q), { instrument: 'crypto:BTC', quote: 'EUR', resolution: 'minute', from: String(FROM), to: String(TO) });
});
it('neemt de munt uit het ANTWOORD: EUR gevraagd en USD gekregen blijft USD', async () => {
const { c } = client({ '/v1/series': () => minuut([[FROM + 60_000, 66000]], { currency: 'USD' }) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.equal(uit.currency, 'USD');
});
it('volgt `next` en plakt de pagina\'s aan elkaar', async () => {
const MID = FROM + 6 * 3600_000;
const { fetch, c } = client({ '/v1/series': (u) => (Number(u.searchParams.get('from')) === FROM
? minuut([[FROM + 60_000, 1]], { next: MID })
: minuut([[MID + 60_000, 2]])) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.deepEqual(uit.points, [[FROM + 60_000, 1], [MID + 60_000, 2]]);
assert.equal(fetch.calls.length, 2);
});
it('stopt bij een `next` die niet vooruit gaat, in plaats van eindeloos te vragen', async () => {
const { fetch, c } = client({ '/v1/series': () => minuut([[FROM + 60_000, 1]], { next: FROM }) });
await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.equal(fetch.calls.length, 1);
});
it('leest een samengevatte resolutie op `close`', async () => {
const { c } = client({ '/v1/series': () => minuut([[FROM, 1, 3, 0.5, 2, 60]],
{ resolution: 'hour', columns: ['t', 'open', 'high', 'low', 'close', 'samples'] }) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', resolution: 'hour', from: FROM, to: TO });
assert.deepEqual(uit.points, [[FROM, 2]]);
});
it('slaat rijen zonder bruikbare tijd of prijs over', async () => {
const { c } = client({ '/v1/series': () => minuut([[FROM, 1], [null, 2], [FROM + 1, 'x'], [FROM + 2, 0], [FROM + 3, 4]]) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.deepEqual(uit.points, [[FROM, 1], [FROM + 3, 4]]);
});
it('weigert een antwoord zonder munt of zonder prijskolom', async () => {
const zonderMunt = client({ '/v1/series': () => minuut([[FROM, 1]], { currency: undefined }) }).c;
await rejectsWith(zonderMunt.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer');
const zonderPrijs = client({ '/v1/series': () => minuut([[FROM, 1]], { columns: ['t', 'volume'] }) }).c;
await rejectsWith(zonderPrijs.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer');
});
it('weigert een munt die tussen twee pagina\'s verandert', async () => {
const MID = FROM + 3600_000;
const { c } = client({ '/v1/series': (u) => (Number(u.searchParams.get('from')) === FROM
? minuut([[FROM, 1]], { next: MID })
: minuut([[MID, 2]], { currency: 'USD' })) });
await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer');
});
it('503 api.monthUnreachable op een reeks: later opnieuw, met de maand', async () => {
const { c } = client({ '/v1/series': () => json({ error: 'x', key: 'api.monthUnreachable', month: '2026-09' }, 503) });
const e = await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.monthUnreachable');
assert.equal(isRetryLater(e), true);
});
});
describe('createMarketClient: spot per pakket (fase 3)', () => {
const spotAntwoord = (body) => json({ spot: [], missing: [], unreachable: [], ...body });
const punt = (instrument, price, currency = 'EUR') =>
({ instrument, price, currency, observedAt: 1791549296000, fetchedAt: 1791549301000, source: 'bitvavo', sourceLabel: 'Bitvavo', attribution: null });
it('vraagt het hele pakket in één aanroep, gesorteerd en zonder dubbelen, in de gevraagde munt', async () => {
const { fetch, c } = client({ '/v1/spot': () => spotAntwoord({ spot: [punt('crypto:BTC', 61000)] }) });
await c.spots(['crypto:ETH', 'crypto:BTC', 'crypto:ETH'], 'EUR');
assert.equal(fetch.calls.length, 1);
assert.deepEqual(Object.fromEntries(fetch.calls[0].url.searchParams), { instruments: 'crypto:BTC,crypto:ETH', quote: 'EUR' });
});
it('houdt een prijs, "geen koers" en "nu niet te lezen" uit elkaar', async () => {
const { c } = client({ '/v1/spot': () => spotAntwoord({
spot: [punt('crypto:BTC', 61000, 'USD')], missing: ['crypto:ETH'], unreachable: ['metal:XAG'] }) });
const r = await c.spots(['crypto:BTC', 'crypto:ETH', 'metal:XAG'], 'EUR');
assert.deepEqual(r.spot, [{ instrument: 'crypto:BTC', price: 61000, currency: 'USD', observedAt: 1791549296000, source: 'bitvavo', sourceLabel: 'Bitvavo' }]);
assert.deepEqual(r.missing, ['crypto:ETH']);
assert.deepEqual(r.unreachable, ['metal:XAG']);
});
it('slaat een punt zonder prijs of munt over in plaats van het als koers te geven', async () => {
const { c } = client({ '/v1/spot': () => spotAntwoord({ spot: [
punt('crypto:BTC', 0), { ...punt('crypto:ETH', 3000), currency: '' }, { ...punt('crypto:LTC', 'x') }, punt('crypto:DOT', 5)] }) });
const r = await c.spots(['crypto:BTC', 'crypto:ETH', 'crypto:LTC', 'crypto:DOT'], 'EUR');
assert.deepEqual(r.spot.map(s => s.instrument), ['crypto:DOT']);
});
it('splitst een pakket boven de grens van de dienst over meer aanroepen', async () => {
const max = CONFIG.MARKET_SERVICE.MAX_INSTRUMENTS_PER_SPOT;
const keys = Array.from({ length: max + 5 }, (_, i) => `crypto:T${String(i).padStart(4, '0')}`);
const { fetch, c } = client({ '/v1/spot': (u) => spotAntwoord({
spot: u.searchParams.get('instruments').split(',').map(k => punt(k, 1)) }) });
const r = await c.spots(keys, 'EUR');
assert.equal(fetch.calls.length, 2);
assert.ok(fetch.calls.every(x => x.url.searchParams.get('instruments').split(',').length <= max));
assert.equal(r.spot.length, max + 5);
});
it('één antwoord dient alle assets een korte tijd; een ander pakket of daarna wordt opnieuw gevraagd', async () => {
let nu = 0;
const fetch = fakeFetch({ '/v1/spot': () => spotAntwoord({ spot: [punt('crypto:BTC', 1)] }) });
const c = createMarketClient({ fetch, config: async () => ({ url: URL_OK, token: TOKEN }), now: () => nu });
await c.spots(['crypto:BTC', 'crypto:ETH'], 'EUR');
await c.spots(['crypto:ETH', 'crypto:BTC'], 'EUR');
assert.equal(fetch.calls.length, 1, 'zelfde pakket in een andere volgorde is dezelfde vraag');
await c.spots(['crypto:BTC'], 'EUR');
assert.equal(fetch.calls.length, 2, 'een ander pakket');
nu += CONFIG.MARKET_SERVICE.SPOT_TTL_MS;
await c.spots(['crypto:BTC'], 'EUR');
assert.equal(fetch.calls.length, 3, 'na de bewaartijd');
});
it('weigert een antwoord zonder lijst met koersen', async () => {
const { c } = client({ '/v1/spot': () => json({ missing: [] }) });
await rejectsWith(c.spots(['crypto:BTC'], 'EUR'), 'marketService.badAnswer');
});
});
describe('createMarketClient: het register wordt even bewaard', () => {
it('vraagt het register één keer binnen de bewaartijd, en opnieuw daarna', async () => {
let nu = 1_000_000;
const fetch = fakeFetch({ '/v1/instruments': () => json(REGISTER) });
const c = createMarketClient({ fetch, config: async () => ({ url: URL_OK, token: TOKEN }), now: () => nu });
await c.register();
await c.register();
assert.equal(fetch.calls.length, 1);
nu += CONFIG.MARKET_SERVICE.REGISTER_TTL_MS;
await c.register();
assert.equal(fetch.calls.length, 2);
});
it('vraagt opnieuw na een ander adres, want een andere portefeuille kan een andere dienst hebben', async () => {
let cfg = { url: URL_OK, token: TOKEN };
const fetch = fakeFetch({ '/v1/instruments': () => json(REGISTER) });
const c = createMarketClient({ fetch, config: async () => cfg, now: () => 0 });
await c.register();
cfg = { url: 'http://10.0.0.12:3855', token: TOKEN };
await c.register();
assert.equal(fetch.calls.length, 2);
});
it('bewaart een mislukte vraag niet', async () => {
let weg = true;
const fetch = fakeFetch({ '/v1/instruments': () => (weg ? json({ error: 'x' }, 503) : json(REGISTER)) });
const c = createMarketClient({ fetch, config: async () => ({ url: URL_OK, token: TOKEN }), now: () => 0 });
await rejectsWith(c.register(), 'marketService.unavailable');
weg = false;
assert.equal((await c.register()).length, 2);
});
});
describe('de instellingen van de installatie', () => {
let DIR;
let vorige;
beforeEach(async () => {
vorige = process.env.ASSETAPP_DATA_DIR;
DIR = await mkdtemp(join(tmpdir(), 'assetapp-market-'));
process.env.ASSETAPP_DATA_DIR = DIR;
writeActiveName('Echt');
});
afterEach(async () => {
if (vorige === undefined) delete process.env.ASSETAPP_DATA_DIR;
else process.env.ASSETAPP_DATA_DIR = vorige;
await rm(DIR, { recursive: true, force: true });
});
const bestand = () => join(DIR, CONFIG.MARKET_SERVICE.SETTINGS_FILE_NAME);
const bewaard = async () => {
try { return JSON.parse(await readFile(bestand(), 'utf8')); } catch { return undefined; }
};
it('gebruikt het vaste adres, en alleen ASSETAPP_SERVICE_URL overschrijft het', async () => {
await writeFile(bestand(), JSON.stringify({ url: 'http://oud:8091', token: TOKEN }));
const vast = await marketServiceStatus();
assert.equal(vast.url, CONFIG.MARKET_SERVICE.URL, 'een adres uit het bestand telt nog mee');
assert.equal(vast.override, false);
process.env.ASSETAPP_SERVICE_URL = ' http://10.0.0.11:8091/ ';
try {
const anders = await marketServiceStatus();
assert.deepEqual([anders.url, anders.override], [URL_OK, true]);
process.env.ASSETAPP_SERVICE_URL = '10.0.0.11:8091';
await rejectsWith(marketServiceStatus(), 'marketService.urlScheme');
} finally {
delete process.env.ASSETAPP_SERVICE_URL;
}
});
it('geeft het token NIET terug, en zegt waar het vandaan komt', async () => {
await writeFile(bestand(), JSON.stringify({ token: TOKEN }));
const st = await marketServiceStatus();
assert.equal(JSON.stringify(st).includes(TOKEN), false, 'het token staat in de status');
assert.deepEqual([st.token_set, st.token_source], [true, 'manual']);
});
it('kan altijd een token aanvragen, en vanuit de app is er niets in te stellen of los te koppelen', async () => {
assert.equal((await marketServiceStatus()).can_register, true);
assert.equal(await marketConfigured(), true);
const exports = await import('../../server/market-service.mjs');
assert.equal(Object.keys(exports).some(k => /forget|disconnect|setMarketService/i.test(k)), false,
'er is weer een manier om adres of token te zetten, of om te ontkoppelen');
});
it('is in elke portefeuille verbonden, ook in een nieuwe lege', async () => {
await writeFile(bestand(), JSON.stringify({ token: TOKEN }));
writeActiveName('Nieuw');
assert.equal((await marketServiceStatus()).token_set, true);
assert.equal(await marketConfigured(), true);
});
it('neemt het token één keer over uit de portefeuille als eigen token, en haalt het daar weg', async () => {
await writeSecretsStore({ market_service: { url: URL_OK, token: TOKEN }, lnd_2: { macaroon: 'bb' } });
assert.equal((await marketServiceStatus()).token_source, 'manual');
assert.deepEqual(await bewaard(), { token: TOKEN }, 'het oude adres ging mee');
const alles = await readSecretsStore();
assert.equal('market_service' in alles, false, 'de oude kopie bleef in de portefeuille staan');
assert.equal(alles.lnd_2.macaroon, 'bb');
writeActiveName('Ander');
assert.equal(await marketConfigured(), true, 'een andere portefeuille ziet het overgenomen token niet');
});
it('laat de installatie winnen van een achtergebleven kopie in een portefeuille', async () => {
await writeFile(bestand(), JSON.stringify({ token: TOKEN }));
writeActiveName('Oud');
await writeSecretsStore({ market_service: { url: 'http://oud:8091', token: 'ats_oud' } });
assert.equal((await marketServiceStatus()).token_set, true);
assert.deepEqual(await bewaard(), { token: TOKEN });
assert.equal('market_service' in (await readSecretsStore()), false);
});
it('weigert een kapot instellingenbestand in plaats van "niet ingesteld" te raden', async () => {
await writeFile(bestand(), '{ kapot');
await assert.rejects(marketServiceStatus(), SyntaxError);
});
});
describe('createMarketClient: het integratielog', () => {
const ID = 'a1b2c3d4-0000-4000-8000-000000000001';
const FROM = Date.parse('2026-10-09T00:00:00Z');
const metLog = (routes, cfg, extra = {}) => {
const j = journaal(extra.journaal);
const { fetch, c } = client(routes, cfg, { journal: j, requestId: () => ID, ...extra.client });
return { fetch, c, j };
};
it('stuurt bij elke aanroep een eigen willekeurige X-Request-Id mee, ook naar health', async () => {
const { fetch, c } = client({ '/v1/health': () => json(HEALTH), '/v1/instruments': () => json(REGISTER) },
undefined, { journal: null, requestId: undefined });
await c.testConnection();
const ids = fetch.calls.map(x => x.headers['X-Request-Id']);
for (const id of ids) assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
assert.notEqual(ids[0], ids[1]);
});
it('legt een weigering vast met de request-id, beide sleutels en de reden, en geeft het token mee om te schonen', async () => {
const { c, j } = metLog({ '/v1/series': () => json({ error: 'No series', key: 'api.noSeries', requestId: ID }, 404) });
const e = await rejectsWith(c.history({ instrument: 'metal:XAU', quote: 'EUR', from: FROM, to: FROM + 1 }), 'marketService.rejected');
assert.equal(e.requestId, ID, 'de fout draagt de id, zodat het antwoord aan de browser hem kan noemen');
assert.equal(j.rijen.length, 1);
const { entry, secrets } = j.rijen[0];
assert.deepEqual({ ...entry, latencyMs: typeof entry.latencyMs, occurredAt: typeof entry.occurredAt }, {
target: 'service', operation: '/v1/series', subject: 'metal:XAU', outcome: 'fail', httpStatus: 404,
errorKey: 'marketService.rejected api.noSeries', detail: 'No series', requestId: ID,
latencyMs: 'number', occurredAt: 'number',
});
assert.deepEqual(secrets, [TOKEN]);
assert.deepEqual(j.tellingen.map(t => [t.capability, t.ok]), [['daily_history', false]]);
});
it('beschrijft een antwoord dat geen JSON is: type en lengte, niet de inhoud', async () => {
const html = () => new Response('<html>proxy</html>', { status: 502, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
const { c, j } = metLog({ '/v1/spot': html });
await rejectsWith(c.spots(['crypto:BTC'], 'EUR'), 'marketService.rejected');
assert.equal(j.rijen[0].entry.detail, 'no JSON (text/html, 18 bytes)');
assert.equal(j.rijen[0].entry.errorKey, 'marketService.rejected');
assert.equal(j.rijen[0].entry.subject, '1', 'bij een pakket het aantal, niet de lijst');
const { c: c2, j: j2 } = metLog({ '/v1/spot': () => new Response('', { status: 200 }) });
await rejectsWith(c2.spots(['crypto:BTC'], 'EUR'), 'marketService.badAnswer');
assert.equal(j2.rijen[0].entry.detail, 'no JSON (text/plain, 0 bytes)');
assert.equal(j2.rijen[0].entry.httpStatus, 200);
});
it('dienst weg: geen status, de oorzaak als detail', async () => {
const weg = () => { throw new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } }); };
const { c, j } = metLog({ '/v1/instruments': weg });
await rejectsWith(c.instruments(), 'marketService.unreachable');
assert.deepEqual([j.rijen[0].entry.httpStatus, j.rijen[0].entry.detail], [null, 'ECONNREFUSED']);
assert.deepEqual(j.tellingen, [], 'het register is geen koersaanroep en telt niet');
});
it('telt een gelukte koersaanroep, en legt er geen rij voor vast', async () => {
const { c, j } = metLog({ '/v1/spot': () => json({ spot: [{ instrument: 'crypto:BTC', price: 1, currency: 'EUR' }] }) });
await c.spots(['crypto:BTC'], 'EUR');
assert.deepEqual(j.tellingen.map(t => [t.capability, t.ok]), [['spot', true]]);
assert.deepEqual(j.rijen, []);
});
it('een antwoord zonder bruikbare prijs krijgt een rij "empty"', async () => {
const { c, j } = metLog({
'/v1/series': () => json({ currency: 'EUR', columns: ['t', 'price', 'resolution'], rows: [], next: null }),
'/v1/spot': () => json({ spot: [], missing: ['crypto:BTC'] }),
});
await c.history({ instrument: 'metal:XAU', quote: 'EUR', from: FROM, to: FROM + 1 });
await c.spots(['crypto:BTC', 'crypto:ETH'], 'EUR');
assert.deepEqual(j.rijen.map(r => [r.entry.operation, r.entry.subject, r.entry.outcome, r.entry.requestId]),
[['/v1/series', 'metal:XAU', 'empty', ID], ['/v1/spot', '2', 'empty', ID]]);
});
it('zonder token: "refused", zonder request-id, en er gaat niets de deur uit', async () => {
const { fetch, c, j } = metLog({ '/v1/spot': () => json({ spot: [] }) }, { url: URL_OK, token: '' });
await rejectsWith(c.spots(['crypto:BTC'], 'EUR'), 'marketService.noToken');
assert.equal(fetch.calls.length, 0);
assert.deepEqual([j.rijen[0].entry.outcome, j.rijen[0].entry.requestId], ['refused', null]);
});
it('noemt een andere id die de dienst terugstuurt', async () => {
const { c, j } = metLog({ '/v1/series': () => json({ error: 'x', key: 'api.badTime' }, 400, { 'X-Request-Id': 'dienst-eigen-id' }) });
await rejectsWith(c.history({ instrument: 'metal:XAU', quote: 'EUR', from: FROM, to: FROM + 1 }), 'marketService.rejected');
assert.equal(j.rijen[0].entry.detail, 'x; service request id dienst-eigen-id');
});
it('een log dat zelf faalt breekt de aanroep niet', async () => {
const { c } = metLog({ '/v1/spot': () => json({ spot: [{ instrument: 'crypto:BTC', price: 1, currency: 'EUR' }] }) },
undefined, { journaal: { kapot: true } });
assert.equal((await c.spots(['crypto:BTC'], 'EUR')).spot.length, 1);
const { c: c2 } = metLog({ '/v1/spot': () => json({ error: 'x', key: 'api.badTime' }, 400) }, undefined, { journaal: { kapot: true } });
await rejectsWith(c2.spots(['crypto:BTC'], 'EUR'), 'marketService.rejected');
});
});
describe('createMarketClient: de verkoop', () => {
const NOSTR = 'Nostr eyJ0ZXN0Ijp0cnVlfQ==';
const ORDER = { id: 'a'.repeat(32), status: 'open', amountSat: 21000, paymentRequest: 'lnbc1', expiresAt: 1, paidAt: null, waived: false };
const KEY = 'b'.repeat(64);
it('plaatst een order met de handtekening zoals hij kwam, de body byte voor byte, en zonder token', async () => {
const body = '{"email":"a@b.nl"}';
const { fetch, c } = client({ '/v1/orders': () => json({ order: ORDER, readKey: KEY }, 201) });
assert.deepEqual(await c.placeOrder(NOSTR, body), { order: ORDER, readKey: KEY });
const x = fetch.calls[0];
assert.equal(x.method, 'POST');
assert.equal(x.headers.Authorization, NOSTR);
assert.equal(x.body, body, 'de body is onderweg veranderd, dan klopt de payload van de handtekening niet');
assert.equal(JSON.stringify(x).includes(TOKEN), false, 'het token van de portefeuille ging mee');
});
it('vraagt een order met het leesgeheim en de licenties met een handtekening, allebei als GET', async () => {
const { fetch, c } = client({
[`/v1/orders/${ORDER.id}`]: () => json({ order: { ...ORDER, status: 'paid' }, license: { x: 1 } }),
'/v1/license': () => json({ licenses: [] }),
'/v1/sale': () => json({ open: true, priceSat: 21000, major: 1 }),
});
assert.deepEqual(await c.orderStatus(ORDER.id, KEY), { order: { ...ORDER, status: 'paid' }, license: { x: 1 } });
assert.deepEqual(await c.buyerLicenses(NOSTR), []);
assert.deepEqual(await c.sale(), { open: true, priceSat: 21000, major: 1 });
assert.deepEqual(fetch.calls.map(x => [x.method, x.headers.Authorization]), [['GET', `Order ${KEY}`], ['GET', NOSTR], ['GET', undefined]]);
});
it('werkt zonder token: kopen kan ook een installatie die er nog geen heeft', async () => {
const { c } = client({ '/v1/sale': () => json({ open: false, priceSat: null, major: 1 }) }, { url: URL_OK, token: '' });
assert.equal((await c.sale()).open, false);
});
it('geeft elke weigering van de verkoop een eigen sleutel', async () => {
for (const [status, key, verwacht] of [
[503, 'api.salesClosed', 'marketService.salesClosed'], [503, 'api.checkoutUnavailable', 'marketService.checkoutUnavailable'],
[429, 'api.tooManyOrders', 'marketService.tooManyOrders'], [401, 'api.badAuth', 'marketService.signatureRefused'],
]) {
const { c } = client({ '/v1/orders': () => json({ error: 'nee', key }, status) });
await rejectsWith(c.placeOrder(NOSTR, '{}'), verwacht);
}
const { c } = client({});
await rejectsWith(c.orderStatus('c'.repeat(32), KEY), 'marketService.rejected');
const { c: c2 } = client({ [`/v1/orders/${ORDER.id}`]: () => json({ error: 'Unknown order.', key: 'api.unknownOrder' }, 404) });
await rejectsWith(c2.orderStatus(ORDER.id, KEY), 'marketService.unknownOrder');
});
it('geeft het integratielog de handtekening en het leesgeheim als geheim mee', async () => {
const j = journaal();
const fetch = fakeFetch({ [`/v1/orders/${ORDER.id}`]: () => json({ error: 'x', key: 'api.unknownOrder' }, 404) });
const c = createMarketClient({ fetch, config: async () => ({ url: URL_OK, token: TOKEN }), journal: j });
await rejectsWith(c.orderStatus(ORDER.id, KEY), 'marketService.unknownOrder');
assert.deepEqual(j.rijen[0].secrets, [TOKEN, `Order ${KEY}`]);
});
});
describe('createMarketClient: support', () => {
const NOSTR = 'Nostr eyJ0ZXN0Ijp0cnVlfQ==';
const MSG = { from: 'buyer', ai: false, text: 'hoi', at: 1, attachments: [] };
it('leest het gesprek, stuurt een bericht byte voor byte en haalt een screenshot op, zonder token', async () => {
const body = '{"text":"hoi","attachment":null}';
const id = 'c'.repeat(32);
const { fetch, c } = client({
'/v1/support/messages': (u, init) => (init.method === 'POST' ? json({ message: MSG }, 201) : json({ messages: [MSG] })),
[`/v1/support/attachments/${id}`]: () => json({ mime: 'image/png', data: 'iVBO' }),
});
assert.deepEqual(await c.supportMessages(NOSTR), [MSG]);
assert.deepEqual(await c.supportSend(NOSTR, body), MSG);
assert.deepEqual(await c.supportAttachment(NOSTR, id), { mime: 'image/png', data: 'iVBO' });
assert.deepEqual(fetch.calls.map(x => [x.method, x.headers.Authorization]), [['GET', NOSTR], ['POST', NOSTR], ['GET', NOSTR]]);
assert.equal(fetch.calls[1].body, body, 'de body is onderweg veranderd, dan klopt de payload van de handtekening niet');
assert.equal(JSON.stringify(fetch.calls).includes(TOKEN), false, 'het token van de portefeuille ging mee');
});
it('geeft elke weigering van een bericht een eigen sleutel, met de grens erin', async () => {
for (const [status, key, verwacht] of [
[400, 'support.empty', 'marketService.supportEmpty'], [400, 'support.tooLong', 'marketService.supportTooLong'],
[400, 'support.attachmentType', 'marketService.supportAttachmentType'], [400, 'support.attachmentEmpty', 'marketService.supportAttachmentType'],
[400, 'support.attachmentTooLarge', 'marketService.supportAttachmentTooLarge'], [429, 'support.tooMany', 'marketService.supportTooMany'],
]) {
const { c } = client({ '/v1/support/messages': () => json({ error: 'nee', key }, status) });
const e = await rejectsWith(c.supportSend(NOSTR, '{}'), verwacht);
if (verwacht === 'marketService.supportTooLong') assert.equal(e.params.max, 4000);
}
const { c } = client({ [`/v1/support/attachments/${'d'.repeat(32)}`]: () => json({ error: 'x', key: 'api.unknownAttachment' }, 404) });
await rejectsWith(c.supportAttachment(NOSTR, 'd'.repeat(32)), 'marketService.unknownAttachment');
});
it('weigert een antwoord dat geen gesprek of screenshot is', async () => {
const { c } = client({ '/v1/support/messages': () => json({ messages: 'nee' }) });
await rejectsWith(c.supportMessages(NOSTR), 'marketService.badAnswer');
});
});
+41
View File
@@ -0,0 +1,41 @@
// Generated, do not edit. sha256:3d2d04d4ac960f13ff53733a351f56fc7a4b8b944d9779af9fcc16cb1046a33f
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { mcpClientConfig } from '../../server/mcp/client-config.mjs';
import { CONFIG } from '../../shared/config.js';
describe('mcpClientConfig', () => {
it('wijst naar een stdio.mjs dat echt bestaat', () => {
const { path } = mcpClientConfig();
assert.ok(isAbsolute(path), `pad moet absoluut zijn, was: ${path}`);
assert.ok(existsSync(path), `pad bestaat niet: ${path}`);
assert.match(path, /stdio\.mjs$/);
});
it('gebruikt de servernaam uit CONFIG als sleutel', () => {
const { serverKey, snippet } = mcpClientConfig();
assert.equal(serverKey, CONFIG.AI.SERVER_NAME);
assert.ok(Object.hasOwn(JSON.parse(snippet).mcpServers, CONFIG.AI.SERVER_NAME));
});
it('start met node en niet met npm', () => {
const { snippet, serverKey } = mcpClientConfig();
assert.equal(JSON.parse(snippet).mcpServers[serverKey].command, 'node');
});
it('levert geldige JSON waar het pad ongeschonden uit terugkomt', () => {
const { snippet, serverKey, path } = mcpClientConfig();
const terug = JSON.parse(snippet);
assert.deepEqual(terug.mcpServers[serverKey].args, [path]);
});
it('bevat een pad met ontdubbelde backslashes zodra er backslashes in zitten', () => {
const { snippet, path } = mcpClientConfig();
if (!path.includes('\\')) return;
assert.ok(snippet.includes('\\\\'), 'backslashes horen in de JSON-tekst ontdubbeld te staan');
assert.ok(!/[^\\]\\[^\\]/.test(snippet), 'er staat een enkele backslash in de JSON-tekst');
});
});
+170
View File
@@ -0,0 +1,170 @@
// Generated, do not edit. sha256:e3545bf95485dc067f72c9e5c3b65d89fe5d9d0cb4f65e196bb3a28c383f4229
import { describe, it, beforeEach, mock } 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 { createDbHarness } from '../helpers/db-node.mjs';
process.env.ASSETAPP_DATA_DIR = mkdtempSync(join(tmpdir(), 'assetapp-control-'));
const { setInstallLocale } = await import('../../server/installation.mjs');
setInstallLocale('nl');
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const echteConfig = await import('../../shared/config.js');
mock.module('../../shared/config.js', {
namedExports: {
...echteConfig,
CONFIG: Object.freeze({
...echteConfig.CONFIG,
COMMANDS: Object.freeze({ ...echteConfig.CONFIG.COMMANDS, CLAIM_SECONDS: 1 }),
}),
},
});
const { CONTROL_TOOLS } = await import('../../server/mcp/control-tools.mjs');
const { claimNextCommand, resolveCommand } = await import('../../server/data/commands.js');
const { setPref } = await import('../../server/data/preferences.js');
const { TARGETS, targetHash } = await import('../../shared/config.js');
const tool = CONTROL_TOOLS.find(t => t.name === 'navigate_to');
const waar = { scope: tool.scope, run: () => tool.run({}) };
const zetAan = async (control = true) => {
await setPref('ai', 'allow_read', 1);
await setPref('ai', 'allow_control', control ? 1 : 0);
};
let busTijdensWachten = [];
function browserDoet(uitkomst, detail = null, na = 60) {
setTimeout(async () => {
busTijdensWachten = harness.exports.all(`SELECT name FROM BusSignal ORDER BY id`).map(r => r.name);
const c = await claimNextCommand('tab-1');
if (c) await resolveCommand(c.id, 'tab-1', uitkomst, detail);
}, na);
}
beforeEach(async () => { harness.recreate(); busTijdensWachten = []; await zetAan(); });
describe('navigate_to: de poort', () => {
it('declareert scope control, en niet read of write', () => {
assert.equal(tool.scope, 'control');
});
it('weigert als het besturen uit staat, en verandert dan niets', async () => {
await zetAan(false);
await assert.rejects(() => tool.run({ target: 'AI_ACTIVITY' }), /niet besturen/);
assert.equal(await claimNextCommand('tab-1'), null, 'er is geen opdracht klaargezet');
});
});
describe('navigate_to: het doel', () => {
it('zet een opdracht klaar met het adres van de gekozen plek', async () => {
browserDoet('done');
const r = await tool.run({ target: 'AI_ACTIVITY' });
assert.match(r.text, /Gedaan/);
assert.match(r.text, new RegExp(targetHash(TARGETS.AI_ACTIVITY).replace('#', '#')));
});
it('deelt geen enkel administratieveld', async () => {
browserDoet('done');
const r = await tool.run({ target: 'HOME' });
assert.deepEqual(r.fieldsShared, []);
});
it('wijst een onbekende plek af zonder om te vallen, en noemt wat er wél is', async () => {
const r = await tool.run({ target: 'DE_KEUKEN' });
assert.match(r.text, /geen bekende plek/);
assert.match(r.text, /AI_ACTIVITY/);
assert.match(r.text, /TAXES - Belastingen/, r.text);
assert.equal(await claimNextCommand('tab-1'), null, 'en er staat niets klaar');
});
it('wijst een leeg of verkeerd getypt doel net zo af als een verzonnen doel', async () => {
for (const args of [{ target: '' }, { target: 42 }, { target: null }]) {
const r = await tool.run(args);
assert.match(r.text, /geen bekende plek/, `${JSON.stringify(args)} hoort netjes afgewezen te worden`);
}
});
});
describe('navigate_to: het signaal gaat vóóruit', () => {
it('zet het signaal op de bus vóórdat hij gaat wachten', async () => {
browserDoet('done');
await tool.run({ target: 'AI_ACTIVITY' });
assert.ok(busTijdensWachten.includes('commands-changed'),
`de bus was ${JSON.stringify(busTijdensWachten)} toen het tabblad keek`);
});
});
describe('navigate_to: de uitkomst', () => {
it('meldt het als de gebruiker het tegenhoudt, mét de reden', async () => {
browserDoet('rejected', 'Tegengehouden door de gebruiker.');
const r = await tool.run({ target: 'AI_ACTIVITY' });
assert.match(r.text, /Niet uitgevoerd/);
assert.match(r.text, /Tegengehouden door de gebruiker/);
assert.match(r.text, /niet genavigeerd/);
});
it('geeft de schuld niet aan de gebruiker als het tabblad zelf afwees', async () => {
browserDoet('rejected', 'De opdracht wees niet naar een geldige plek in de app.');
const r = await tool.run({ target: 'HOME' });
assert.doesNotMatch(r.text, /de gebruiker/i);
assert.match(r.text, /geldige plek/);
});
it('zegt dat de app niet openstaat als niemand de opdracht oppakt', async () => {
const r = await tool.run({ target: 'HOME' });
assert.match(r.text, /niet open/);
assert.match(r.text, /niet genavigeerd/);
});
});
describe('navigate_to zonder target: waar de gebruiker kijkt', () => {
it('staat achter dezelfde schuif als navigeren', async () => {
assert.equal(waar.scope, 'control');
await zetAan(false);
await assert.rejects(() => waar.run({}), /niet besturen/);
});
it('geeft de NAAM terug die navigate_to kent, niet alleen het adres', async () => {
browserDoet('done', targetHash(TARGETS.CHARTS));
const r = await waar.run({});
assert.match(r.text, /CHARTS/);
assert.match(r.text, new RegExp(targetHash(TARGETS.CHARTS)));
});
it('noemt de belastingpagina bij naam en niet alleen bij adres', async () => {
browserDoet('done', targetHash(TARGETS.TAXES));
const r = await waar.run({});
assert.match(r.text, /Belastingen/, r.text);
assert.match(r.text, /TAXES/);
});
it('geeft bij een plek buiten de registry het adres, en zegt erbij dat het geen naam is', async () => {
browserDoet('done', '#/events/2026/detail');
const r = await waar.run({});
assert.match(r.text, /#\/events\/2026\/detail/);
assert.match(r.text, /geen van de plekken/);
});
it('deelt geen enkel administratieveld', async () => {
browserDoet('done', targetHash(TARGETS.HOME));
const r = await waar.run({});
assert.deepEqual(r.fieldsShared, []);
});
it('zegt dat de app niet openstaat als niemand antwoordt', async () => {
const r = await waar.run({});
assert.match(r.text, /niet open/);
});
it('verzint niets als het tabblad wel antwoordt maar zonder plek', async () => {
browserDoet('done', '');
const r = await waar.run({});
assert.match(r.text, /liet niet weten/);
});
});
+86
View File
@@ -0,0 +1,86 @@
// Generated, do not edit. sha256:19a3bb75110abcec8d0ce1494adf6296cb48fd73fb9943873caae31596ad520e
import { describe, it, before, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDbHarness } from '../helpers/db-node.mjs';
process.env.ASSETAPP_DATA_DIR = await mkdtemp(join(tmpdir(), 'assetapp-dienstdiagnose-'));
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
let DEKKING = { status: 'off' };
mock.module('../../server/data/market.js', { namedExports: { serviceCoverage: async () => DEKKING } });
const { READ_TOOLS } = await import('../../server/mcp/read-tools.mjs');
const { PRICE_TOOLS } = await import('../../server/mcp/price-tool.mjs');
const { _resetVertalers } = await import('../../server/mcp/session.mjs');
const { setPref } = await import('../../server/data/preferences.js');
const gezondheid = READ_TOOLS.find(t => t.name === 'integration_health');
const koersen = PRICE_TOOLS.find(t => t.name === 'price_data');
const ctx = { session: { id: 'dienst' } };
const { run, lastId } = harness.exports;
let btc;
before(() => {
harness.recreate();
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`);
btc = lastId();
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES ('bitvavo','Bitvavo','bitvavo','https://x','none',0,0,1,0,'2026-10-16T00:00:00Z')`);
const p = lastId();
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'BTC')`, [p]);
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, 'daily_history', 'GET', '/c')`, [p]);
});
beforeEach(async () => {
_resetVertalers();
DEKKING = { status: 'off' };
await setPref('app', 'market_spot', 'off');
});
const bronkeuzeRegel = text => text.split('\n').find(r => r.startsWith('- BTC (crypto):')) || '';
describe('integration_health: de bronkeuze met de dienst', () => {
it('met de stand aan en BTC gedekt: geen melding, wel dat de dienst de historie levert', async () => {
await setPref('app', 'market_spot', 'first');
DEKKING = { status: 'ok', assetIds: [btc] };
const r = bronkeuzeRegel((await gezondheid.run({}, ctx)).text);
assert.doesNotMatch(r, /staan uit/);
assert.match(r, /historie uit Atreides Services/);
});
it('met de stand uit, of zonder dekking, blijft de melding', async () => {
DEKKING = { status: 'ok', assetIds: [btc] };
assert.match(bronkeuzeRegel((await gezondheid.run({}, ctx)).text), /de dekkende bronnen staan uit \(Bitvavo\)/, 'stand uit');
await setPref('app', 'market_spot', 'first');
DEKKING = { status: 'ok', assetIds: [] };
const r = bronkeuzeRegel((await gezondheid.run({}, ctx)).text);
assert.match(r, /staan uit/, 'niet gedekt');
assert.doesNotMatch(r, /Atreides Services/);
});
});
describe('price_data sources: de diagnose met de dienst', () => {
it('met de stand aan en BTC gedekt: in orde, en de dienst genoemd', async () => {
await setPref('app', 'market_spot', 'fallback');
DEKKING = { status: 'ok', assetIds: [btc] };
const uit = (await koersen.run({ asset: 'BTC', view: 'sources' }, ctx)).text;
assert.match(uit, /Diagnose van de bronkeuze:\n- in orde/);
assert.match(uit, /historie uit Atreides Services/);
});
it('met een onbereikbare dienst blijft de melding', async () => {
await setPref('app', 'market_spot', 'first');
DEKKING = { status: 'unavailable' };
const uit = (await koersen.run({ asset: 'BTC', view: 'sources' }, ctx)).text;
assert.match(uit, /- allInactive \(Bitvavo\)/);
});
});
+107
View File
@@ -0,0 +1,107 @@
// Generated, do not edit. sha256:4b1eef4e0860bce440fbf0875965c32cd7b13b237d0ffc92ef0255b11fe9a950
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, rm, mkdir, writeFile, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
import { CONFIG } from '../../shared/config.js';
const WORTEL = join(fileURLToPath(import.meta.url), '..', '..', '..');
const STDIO = join(WORTEL, 'server', 'mcp', 'stdio.mjs');
let dir, proc, buffer = '', wachtenden = [];
let volgendeId = 100;
function stuur(bericht) {
return new Promise((resolve, reject) => {
const klaar = setTimeout(() => reject(new Error('geen antwoord binnen 10s')), 10_000);
wachtenden.push((a) => { clearTimeout(klaar); resolve(a); });
proc.stdin.write(`${JSON.stringify(bericht)}\n`);
});
}
const roep = (naam, args = {}) =>
stuur({ jsonrpc: '2.0', id: volgendeId++, method: 'tools/call', params: { name: naam, arguments: args } });
const wijsNaar = (naam) => writeFile(join(dir, 'active.json'), JSON.stringify({ portfolio: naam }), 'utf-8');
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'assetapp-mcp-wissel-'));
proc = spawn(process.execPath, [STDIO], {
env: { ...process.env, ASSETAPP_DATA_DIR: dir },
stdio: ['pipe', 'pipe', 'pipe'],
});
proc.stdout.on('data', (brok) => {
buffer += brok.toString();
let n;
while ((n = buffer.indexOf('\n')) >= 0) {
const regel = buffer.slice(0, n).trim();
buffer = buffer.slice(n + 1);
if (regel) (wachtenden.shift() || (() => {}))(JSON.parse(regel));
}
});
await new Promise((resolve) => proc.stderr.once('data', resolve));
});
after(async () => {
proc.stdin.end();
await new Promise((resolve) => proc.once('exit', resolve));
await rm(dir, { recursive: true, force: true });
});
describe('mcp-stdio: de actieve portefeuille wordt per bericht gelezen', () => {
it('werkt in de portefeuille die bij het starten actief was', async () => {
const a = await roep('connection_test');
assert.equal(a.error, undefined, `geen fout verwacht: ${JSON.stringify(a.error)}`);
assert.ok(a.result.content[0].text);
assert.ok((await readdir(join(dir, 'MyAssets'))).includes('db.sqlite'));
});
it('WEIGERT leesbaar zodra de wijzer naar een nooit-geopende portefeuille wijst', async () => {
await mkdir(join(dir, 'Demo'), { recursive: true });
await wijsNaar('Demo');
const a = await roep('connection_test');
assert.ok(a.error, 'hier hoort een fout te komen en geen antwoord');
assert.match(a.error.message, /Demo/, 'de melding noemt de portefeuille');
assert.match(a.error.message, /nog geen ingerichte database/);
assert.match(a.error.message, /Portefeuilles/, 'en zegt wat je eraan doet');
});
it('laat initialize en tools/list daar wél door, zodat de verbinding bruikbaar blijft', async () => {
const lijst = await stuur({ jsonrpc: '2.0', id: volgendeId++, method: 'tools/list', params: {} });
assert.equal(lijst.error, undefined);
assert.ok(lijst.result.tools.length > 0);
const init = await stuur({ jsonrpc: '2.0', id: volgendeId++, method: 'initialize', params: {} });
assert.equal(init.error, undefined);
assert.ok(init.result.serverInfo.name);
});
it('zegt bij een NIEUWERE database dat de koppeling herstart moet worden', async () => {
await mkdir(join(dir, 'Nieuwer'), { recursive: true });
const db = new DatabaseSync(join(dir, 'Nieuwer', 'db.sqlite'));
db.exec(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT; INSERT INTO Schema (version) VALUES (${CONFIG.SCHEMA_VERSION + 1})`);
db.close();
await wijsNaar('Nieuwer');
const a = await roep('connection_test');
assert.ok(a.error, 'hier hoort een fout te komen en geen antwoord');
assert.match(a.error.message, /Nieuwer/);
assert.match(a.error.message, /herstart dan de AI-koppeling/);
});
it('leest weer gewoon zodra de wijzer terugwijst naar een bruikbare portefeuille', async () => {
await wijsNaar('MyAssets');
const a = await roep('connection_test');
assert.equal(a.error, undefined, `geen fout verwacht: ${JSON.stringify(a.error)}`);
assert.ok(a.result.content[0].text);
});
});
+91
View File
@@ -0,0 +1,91 @@
// Generated, do not edit. sha256:7bb4b4b51bf14bf2fa32107d316282a41f1c7586c33fc94c43098199bfa75953
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { DatabaseSync } from 'node:sqlite';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const WORTEL = join(fileURLToPath(import.meta.url), '..', '..', '..');
const STDIO = join(WORTEL, 'server', 'mcp', 'stdio.mjs');
let dir, proc, buffer = '', wachtenden = [];
function stuur(bericht) {
return new Promise((resolve, reject) => {
wachtenden.push(resolve);
const klaar = setTimeout(() => reject(new Error('geen antwoord binnen 10s')), 10_000);
wachtenden[wachtenden.length - 1] = (a) => { clearTimeout(klaar); resolve(a); };
proc.stdin.write(`${JSON.stringify(bericht)}\n`);
});
}
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'assetapp-mcp-stdio-'));
proc = spawn(process.execPath, [STDIO], {
env: { ...process.env, ASSETAPP_DATA_DIR: dir, ASSETAPP_DB_FILE: join(dir, 'db.sqlite') },
stdio: ['pipe', 'pipe', 'pipe'],
});
proc.stdout.on('data', (brok) => {
buffer += brok.toString();
let n;
while ((n = buffer.indexOf('\n')) >= 0) {
const regel = buffer.slice(0, n).trim();
buffer = buffer.slice(n + 1);
if (regel) (wachtenden.shift() || (() => {}))(JSON.parse(regel));
}
});
await new Promise((resolve) => proc.stderr.once('data', resolve));
});
after(async () => {
proc.stdin.end();
await new Promise((resolve) => proc.once('exit', resolve));
await rm(dir, { recursive: true, force: true });
});
describe('mcp-stdio: het proces beantwoordt bericht na bericht', () => {
it('antwoordt op initialize', async () => {
const a = await stuur({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
assert.equal(a.id, 1);
assert.ok(a.result.serverInfo.name, 'er hoort een servernaam in te staan');
});
it('geeft de toollijst, en doet dat twee keer achter elkaar', async () => {
const een = await stuur({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
assert.ok(een.result.tools.length > 0, 'er hoort minstens één tool te zijn');
const twee = await stuur({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} });
assert.deepEqual(twee.result.tools.map(t => t.name), een.result.tools.map(t => t.name));
});
it('voert een tool uit en legt hem vast in het auditlog', async () => {
const a = await stuur({
jsonrpc: '2.0', id: 4, method: 'tools/call',
params: { name: 'connection_test', arguments: {} },
});
assert.equal(a.id, 4);
assert.equal(a.error, undefined, `geen fout verwacht: ${JSON.stringify(a.error)}`);
assert.ok(a.result.content[0].text, 'er hoort een antwoord in te staan');
});
});
describe('mcp-stdio: de schuif "Actief" zet dit proces uit', () => {
it('stopt bij het eerstvolgende bericht zodra de schuif uit gaat', async () => {
const db = new DatabaseSync(join(dir, 'db.sqlite'));
db.exec(`PRAGMA busy_timeout = 5000`);
db.exec(`INSERT INTO Preference (scope, key, value) VALUES ('ai', 'enabled', '0')
ON CONFLICT (scope, key) DO UPDATE SET value = '0'`);
db.close();
const antwoord = await stuur({ jsonrpc: '2.0', id: 99, method: 'tools/list', params: {} });
assert.ok(antwoord.error, 'het laatste bericht hoort nog een antwoord te krijgen');
assert.match(antwoord.error.message, /Actief/, 'en dat antwoord wijst de juiste schuif aan');
const code = await new Promise((klaar) => proc.once('exit', klaar));
assert.equal(code, 0, 'een uitgezette koppeling is een keuze en geen storing');
});
});
+380
View File
@@ -0,0 +1,380 @@
// Generated, do not edit. sha256:c1f8a76bc1578f782076e85f682098b660b8aa2852380e51aa99da1608e59043
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDbHarness } from '../helpers/db-node.mjs';
import { CONFIG, ENUMS } from '../../shared/config.js';
process.env.ASSETAPP_DATA_DIR = await mkdtemp(join(tmpdir(), 'assetapp-mcp-'));
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
harness.recreate();
const { createMcpServer } = await import('../../server/mcp/protocol.mjs');
const { TOOLS, allTools } = await import('../../server/mcp/tools.mjs');
await import('../../server/edition.mjs');
let logboek;
const maak = (tools = TOOLS, extra = {}) => {
logboek = [];
return createMcpServer({ tools, logCall: async (r) => { logboek.push(r); }, ...extra });
};
const verzoek = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, params });
beforeEach(() => { logboek = []; });
describe('mcp: elke tool declareert een scope (fase 1d)', () => {
const kaal = { name: 'naamloos', title: 'x', description: 'x', inputSchema: {}, run: async () => ({ text: 'x' }) };
it('weigert te bestaan met een tool zonder scope', () => {
assert.throws(() => createMcpServer({ tools: [kaal], logCall: async () => {} }), /scope/i);
});
it('weigert een verzonnen scope', () => {
assert.throws(
() => createMcpServer({ tools: [{ ...kaal, scope: 'admin' }], logCall: async () => {} }),
/scope/i);
});
it('noemt de tool bij naam, zodat de fout te vinden is', () => {
assert.throws(
() => createMcpServer({ tools: [{ ...kaal, name: 'mijn_tool' }], logCall: async () => {} }),
/mijn_tool/);
});
it('accepteert elke scope uit de SSOT', () => {
for (const scope of ENUMS.AI_SCOPE) {
assert.doesNotThrow(() => createMcpServer({ tools: [{ ...kaal, scope }], logCall: async () => {} }),
`${scope} wordt geweigerd terwijl hij in de enum staat`);
}
});
it('en de echte tool-lijst voldoet eraan', () => {
assert.doesNotThrow(() => createMcpServer({ tools: TOOLS, logCall: async () => {} }));
for (const tool of TOOLS) assert.ok(ENUMS.AI_SCOPE.includes(tool.scope), `${tool.name} mist een scope`);
});
});
describe('mcp: het vangnet op de lengte', () => {
const bulk = (n) => [{
...TOOLS.find(t => t.name === 'connection_test'),
run: async () => ({ text: 'x'.repeat(n), fieldsShared: [] }),
}];
it('kapt een te lang antwoord af vóór het de deur uit gaat', async () => {
const server = maak(bulk(CONFIG.AI.LIMITS.MAX_TEXT_CHARS * 3));
const a = await server.handle(verzoek('tools/call', { name: 'connection_test' }));
assert.ok(a.result.content[0].text.length <= CONFIG.AI.LIMITS.MAX_TEXT_CHARS);
assert.match(a.result.content[0].text, /Afgekapt/);
});
it('logt de INGEKORTE lengte, want dat is wat er verstuurd is', async () => {
const server = maak(bulk(CONFIG.AI.LIMITS.MAX_TEXT_CHARS * 3));
await server.handle(verzoek('tools/call', { name: 'connection_test' }));
assert.ok(logboek[0].charactersOut <= CONFIG.AI.LIMITS.MAX_TEXT_CHARS);
assert.match(logboek[0].detail, /ingekort/i);
});
it('laat een normaal antwoord met rust, en zet er geen bijzonderheid bij', async () => {
const server = maak();
await server.handle(verzoek('tools/call', { name: 'connection_test', arguments: { note: 'hoi' } }));
assert.equal(logboek[0].outcome, 'ok');
assert.equal(logboek[0].detail ?? null, null, 'een niet-ingekort antwoord hoort geen detail te krijgen');
});
});
describe('mcp: een geweigerde aanroep is iets anders dan een mislukte', () => {
it('logt een weigering van de poort als refused, niet als error', async () => {
const stuk = [{
...TOOLS.find(t => t.name === 'connection_test'),
run: async () => { const e = new Error('De AI-koppeling staat uit.'); e.code = 'ai_disabled'; throw e; },
}];
const server = maak(stuk);
const a = await server.handle(verzoek('tools/call', { name: 'connection_test' }));
assert.equal(a.error, undefined, 'geen protocolfout: het model hoort de reden te lezen');
assert.equal(a.result.isError, true);
assert.equal(logboek[0].outcome, 'refused');
});
it('logt een gewone tool-fout nog steeds als error', async () => {
const stuk = [{
...TOOLS.find(t => t.name === 'connection_test'),
run: async () => { throw new Error('de bron is onbereikbaar'); },
}];
await maak(stuk).handle(verzoek('tools/call', { name: 'connection_test' }));
assert.equal(logboek[0].outcome, 'error');
});
});
describe('mcp: de server kan niet zonder auditlog bestaan', () => {
it('weigert aangemaakt te worden zonder logCall', () => {
assert.throws(() => createMcpServer({ tools: TOOLS }), /without an audit log/);
assert.throws(() => createMcpServer({ tools: TOOLS, logCall: 'niet-een-functie' }), /without an audit log/);
});
it('weigert aangemaakt te worden zonder tools', () => {
assert.throws(() => createMcpServer({ tools: [], logCall: async () => {} }), /without tools/);
});
});
describe('mcp: initialize', () => {
it('echoot de protocolversie die de client vraagt', async () => {
const server = maak();
const a = await server.handle(verzoek('initialize', { protocolVersion: '2099-01-01' }));
assert.equal(a.result.protocolVersion, '2099-01-01');
});
it('valt terug op de SSOT-versie als de client er geen noemt', async () => {
const server = maak();
const a = await server.handle(verzoek('initialize', {}));
assert.equal(a.result.protocolVersion, CONFIG.AI.PROTOCOL_VERSION);
});
it('belooft alleen tools, en niets wat wij niet waar kunnen maken', async () => {
const server = maak();
const a = await server.handle(verzoek('initialize', {}));
assert.deepEqual(Object.keys(a.result.capabilities), ['tools']);
assert.equal(a.result.serverInfo.name, CONFIG.AI.SERVER_NAME);
assert.equal(a.result.serverInfo.version, CONFIG.AI.SERVER_VERSION);
assert.notEqual(a.result.serverInfo.version, String(CONFIG.SCHEMA_VERSION));
});
it('onthoudt welke client er aan de andere kant zit', async () => {
const server = maak();
await server.handle(verzoek('initialize', { clientInfo: { name: 'voorbeeld-client' } }));
assert.equal(server.session.client, 'voorbeeld-client');
await server.handle(verzoek('tools/call', { name: 'connection_test' }, 2));
assert.equal(logboek[0].client, 'voorbeeld-client', 'en zet hem in het logboek');
});
it('antwoordt op id 0, want dat is een geldige id en geen notificatie', async () => {
const server = maak();
const a = await server.handle(verzoek('initialize', { protocolVersion: '2025-06-18' }, 0));
assert.ok(a, 'er hoort een antwoord te komen');
assert.equal(a.id, 0, 'en met dezelfde id, niet met null');
const b = await server.handle(verzoek('tools/call', { name: 'connection_test' }, 0));
assert.equal(b.id, 0);
});
it('overleeft een initialize zonder clientInfo', async () => {
const server = maak();
const a = await server.handle(verzoek('initialize', { protocolVersion: '2025-06-18' }));
assert.ok(a.result);
assert.equal(server.session.client, null);
});
});
describe('mcp: notificaties krijgen geen antwoord', () => {
it('antwoordt niet op notifications/initialized', async () => {
const server = maak();
assert.equal(await server.handle({ jsonrpc: '2.0', method: 'notifications/initialized' }), null);
});
it('antwoordt niet op een onbekende notificatie', async () => {
const server = maak();
assert.equal(await server.handle({ jsonrpc: '2.0', method: 'iets/onbekends' }), null);
});
});
describe('mcp: tools/list', () => {
it('geeft naam, titel, beschrijving en schema van elke tool', async () => {
const server = maak();
const a = await server.handle(verzoek('tools/list'));
assert.equal(a.result.tools.length, TOOLS.length);
for (const t of a.result.tools) {
assert.ok(t.name && t.description && t.inputSchema, `${t.name} is niet volledig`);
assert.equal(t.run, undefined, 'de implementatie hoort niet over de lijn te gaan');
}
});
it('houdt het oppervlak klein', () => {
assert.equal(TOOLS.length, 18,'het oppervlak groeit alleen met een concrete use-case');
});
it('blijft binnen het tekenbudget, en dát is de maat die §6a noemt', () => {
const tekens = allTools().reduce((n, t) => n + JSON.stringify({
name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema,
}).length, 0);
assert.ok(tekens <= 16000,
`het tool-oppervlak kost ${tekens} tekens per verzoek; de grens is 16000`);
});
});
describe('mcp: het gesprek per aanroep', () => {
const gesprekVan = (regel) => regel.messages ?? [];
it('legt twee momenten vast: wat binnenkwam en wat terugging', async () => {
const server = maak();
const a = await server.handle(verzoek('tools/call', { name: 'connection_test', arguments: { note: 'hallo' } }));
const m = gesprekVan(logboek[0]);
assert.equal(m.length, 2);
assert.deepEqual(m.map(x => x.kind), ['request', 'response']);
assert.equal(m[1].text, a.result.content[0].text,
'het bewaarde antwoord is letterlijk wat de client kreeg');
assert.match(m[0].text, /hallo/, 'en de binnenkomst draagt de argumenten zoals ze binnenkwamen');
});
it('pakt de binnenkomst vóórdat de tool draait, en niet erna', async () => {
const traag = [{
...TOOLS.find(t => t.name === 'connection_test'),
run: async () => { await new Promise(r => setTimeout(r, 15)); return { text: 'x', fieldsShared: [] }; },
}];
const server = maak(traag);
await server.handle(verzoek('tools/call', { name: 'connection_test' }));
const [binnen, uit] = gesprekVan(logboek[0]);
assert.ok(binnen.occurredAt < uit.occurredAt,
`binnenkomst ${binnen.occurredAt} hoort vóór antwoord ${uit.occurredAt} te liggen`);
});
it('bewaart ook het gesprek van een geweigerde aanroep', async () => {
const server = maak();
await server.handle(verzoek('tools/call', { name: 'lees_alle_xpubs' }));
const m = gesprekVan(logboek[0]);
assert.equal(m.length, 2);
assert.match(m[1].text, /bestaat niet/);
});
it('bewaart bij een mislukte tool de melding die terugging', async () => {
const stuk = [{
...TOOLS.find(t => t.name === 'connection_test'),
run: async () => { throw new Error('de tool viel om'); },
}];
const server = maak(stuk);
await server.handle(verzoek('tools/call', { name: 'connection_test' }));
const m = gesprekVan(logboek[0]);
assert.equal(m[1].text, 'de tool viel om');
});
it('bewaart de INGEKORTE tekst en niet de volledige', async () => {
const server = maak([{
...TOOLS.find(t => t.name === 'connection_test'),
run: async () => ({ text: 'x'.repeat(CONFIG.AI.LIMITS.MAX_TEXT_CHARS * 2), fieldsShared: [] }),
}]);
const a = await server.handle(verzoek('tools/call', { name: 'connection_test' }));
const m = gesprekVan(logboek[0]);
assert.equal(m[1].text, a.result.content[0].text);
assert.ok(m[1].text.length <= CONFIG.AI.LIMITS.MAX_TEXT_CHARS);
});
it('schrijft geen gesprek bij een dry-run', async () => {
const server = maak();
await server.dryRun('connection_test', {});
assert.equal(logboek.length, 0);
});
});
describe('mcp: tools/call', () => {
it('voert de tool uit, geeft tekst terug en logt de aanroep', async () => {
const server = maak();
const a = await server.handle(verzoek('tools/call', { name: 'connection_test', arguments: { note: 'hallo' } }));
assert.equal(a.result.isError, undefined);
assert.match(a.result.content[0].text, /werkt/);
assert.match(a.result.content[0].text, /hallo/, 'de notitie komt terug, zodat de gebruiker hem kan herkennen');
assert.equal(logboek.length, 1);
assert.equal(logboek[0].tool, 'connection_test');
assert.equal(logboek[0].outcome, 'ok');
assert.deepEqual(logboek[0].fieldsShared, ['portfolio'],
'de naam van de portefeuille gaat mee en hoort dus gelogd te worden');
assert.equal(logboek[0].charactersOut, a.result.content[0].text.length,
'het aantal verstuurde tekens hoort te kloppen met wat er echt verstuurd is');
});
it('logt een aanroep zonder argumenten net zo goed', async () => {
const server = maak();
await server.handle(verzoek('tools/call', { name: 'connection_test' }));
assert.equal(logboek.length, 1);
assert.deepEqual(logboek[0].arguments, {});
});
it('weigert een onbekende tool, en legt die poging vast', async () => {
const server = maak();
const a = await server.handle(verzoek('tools/call', { name: 'lees_alle_xpubs' }));
assert.ok(a.error, 'een onbekende tool is een protocolfout');
assert.match(a.error.message, /Onbekende tool/);
assert.equal(logboek.length, 1);
assert.equal(logboek[0].tool, 'lees_alle_xpubs', 'mét de naam die verzonnen is');
assert.equal(logboek[0].outcome, 'refused');
assert.equal(logboek[0].fieldsShared, undefined, 'er ging niets naar buiten');
});
it('weigert een verzoek zonder toolnaam, en legt dat vast', async () => {
const server = maak();
const a = await server.handle(verzoek('tools/call', {}));
assert.ok(a.error);
assert.equal(logboek.length, 1);
assert.equal(logboek[0].outcome, 'refused');
});
it('maakt van een tool-fout een geslaagd antwoord met isError, en logt hem als error', async () => {
const echt = TOOLS.find(t => t.name === 'connection_test');
const stuk = [{ ...echt, run: async () => { throw new Error('de bron is onbereikbaar'); } }];
const server = maak(stuk);
const a = await server.handle(verzoek('tools/call', { name: 'connection_test' }));
assert.equal(a.error, undefined, 'geen protocolfout');
assert.equal(a.result.isError, true);
assert.match(a.result.content[0].text, /onbereikbaar/);
assert.equal(logboek[0].outcome, 'error');
assert.equal(logboek[0].detail, 'de bron is onbereikbaar');
});
it('laat een falend auditlog niet stil gebeuren', async () => {
const kapot = { tools: TOOLS, logCall: async () => { throw new Error('schijf vol'); } };
const gooit = createMcpServer(kapot);
await assert.rejects(() => gooit.handle(verzoek('tools/call', { name: 'connection_test' })), /schijf vol/);
const gezien = [];
const meldt = createMcpServer({ ...kapot, onLogError: (e) => gezien.push(e.message) });
const a = await meldt.handle(verzoek('tools/call', { name: 'connection_test' }));
assert.ok(a.result, 'de aanroep zelf slaagt nog');
assert.deepEqual(gezien, ['schijf vol']);
});
});
describe('mcp: het protocol zelf', () => {
it('antwoordt op ping, ook vóór initialize', async () => {
const server = maak();
const a = await server.handle(verzoek('ping'));
assert.deepEqual(a.result, {});
});
it('geeft een onbekende methode de juiste JSON-RPC-code', async () => {
const server = maak();
const a = await server.handle(verzoek('resources/list'));
assert.equal(a.error.code, -32601, 'method not found');
});
it('maakt van ongeldige JSON een parse-fout in plaats van een crash', async () => {
const server = maak();
const a = await server.handleRegel('{dit is geen json');
assert.equal(a.error.code, -32700);
assert.equal(a.id, null);
});
it('weigert iets dat geen JSON-RPC-object is', async () => {
const server = maak();
for (const rommel of [null, 42, 'tekst', [1, 2]]) {
const a = await server.handle(rommel);
assert.equal(a.error.code, -32600, `${JSON.stringify(rommel)} hoort geweigerd te worden`);
}
});
it('geeft elke sessie een eigen id', () => {
const a = maak(), b = maak();
assert.notEqual(a.session.id, b.session.id);
assert.match(a.session.id, /^[0-9a-f-]{36}$/);
});
it('laat de sessie niet van buiten aanpassen', async () => {
const server = maak();
await server.handle(verzoek('initialize', { clientInfo: { name: 'echt' } }));
server.session.client = 'gelogen';
await server.handle(verzoek('tools/call', { name: 'connection_test' }, 2));
assert.equal(logboek[0].client, 'echt');
});
});
+256
View File
@@ -0,0 +1,256 @@
// Generated, do not edit. sha256:7b65131858517607493b339c40ff58b8b81242aed88852e46fb2e02ffabfaa2b
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile, mkdir, readFile, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import {
installDir, pointerFile, portfolioDir, assertValidName, normalizeName, sameName,
readActiveName, writeActiveName, activeName, activeDir,
migrateLooseLayout, prepareLayout, DEFAULT_NAME, MIGRATION_NAME,
} from '../../server/portfolios.mjs';
import { dbFile } from '../../server/db.js';
import { secretsFile } from '../../server/keys.mjs';
let DIR;
let vorige;
beforeEach(async () => {
vorige = process.env.ASSETAPP_DATA_DIR;
DIR = await mkdtemp(join(tmpdir(), 'assetapp-portefeuilles-'));
process.env.ASSETAPP_DATA_DIR = DIR;
});
afterEach(async () => {
if (vorige === undefined) delete process.env.ASSETAPP_DATA_DIR;
else process.env.ASSETAPP_DATA_DIR = vorige;
await rm(DIR, { recursive: true, force: true });
});
const bestandenIn = async (pad) => (await readdir(pad)).sort((a, b) => a.localeCompare(b, 'nl'));
describe('portfolios: de naam als pad', () => {
it('leest de installatiemap bij elke aanroep, niet bij het laden', () => {
assert.equal(installDir(), DIR);
process.env.ASSETAPP_DATA_DIR = join(DIR, 'ergens-anders');
assert.equal(installDir(), join(DIR, 'ergens-anders'));
process.env.ASSETAPP_DATA_DIR = DIR;
});
it('weigert een naam die buiten de datamap wijst', () => {
for (const naam of ['..', '../buiten', '..\\buiten', '/etc/passwd', 'C:\\Windows']) {
assert.throws(() => portfolioDir(naam), (e) => e.key.startsWith('portfolios.name'), `'${naam}' hoort geweigerd te worden`);
}
});
it('weigert de gereserveerde apparaatnamen van Windows, ook met een extensie', () => {
for (const naam of ['CON', 'con', 'NUL', 'COM1', 'LPT9', 'CON.txt', 'aux.sqlite']) {
assert.throws(() => assertValidName(naam), (e) => e.key === 'portfolios.nameReserved', `'${naam}' hoort geweigerd te worden`);
}
for (const naam of ['Console', 'Com', 'Auxiliair', 'Nulmeting']) assertValidName(naam);
});
it('weigert een naam die op een spatie of punt eindigt, in plaats van hem stil af te knippen', () => {
assert.throws(() => assertValidName('Demo '), (e) => e.key === 'portfolios.nameTrailing');
assert.throws(() => assertValidName('Demo.'), (e) => e.key === 'portfolios.nameTrailing');
assert.throws(() => assertValidName(' Demo'), (e) => e.key === 'portfolios.nameLeadingSpace');
});
it('weigert de verboden tekens en de stuurtekens', () => {
for (const naam of ['a<b', 'a>b', 'a:b', 'a"b', 'a|b', 'a?b', 'a*b']) {
assert.throws(() => assertValidName(naam), (e) => e.key === 'portfolios.nameForbiddenChars', `'${naam}' hoort geweigerd te worden`);
}
assert.throws(() => assertValidName(`regel${String.fromCharCode(10)}twee`), (e) => e.key === 'portfolios.nameForbiddenChars');
assert.throws(() => assertValidName(`nul${String.fromCharCode(0)}byte`), (e) => e.key === 'portfolios.nameForbiddenChars');
});
it('weigert een lege naam en een naam die te lang is', () => {
assert.throws(() => assertValidName(''), (e) => e.key === 'portfolios.nameRequired');
assert.throws(() => assertValidName(null), (e) => e.key === 'portfolios.nameRequired');
assert.throws(() => assertValidName('x'.repeat(65)), (e) => e.key === 'portfolios.nameTooLong');
assertValidName('x'.repeat(64));
});
it('laat gewone namen met spaties, koppeltekens en accenten door', () => {
for (const naam of ['Echt', 'Demo van Jan', 'test-2026', 'Privé']) {
assert.equal(portfolioDir(naam), join(DIR, naam));
}
});
it('vergelijkt namen op hun genormaliseerde vorm, want niet elk bestandssysteem doet dat gelijk', () => {
assert.ok(sameName('Echt', 'echt'), 'hoofdletters maken op Windows en macOS geen verschil');
const samengesteld = `Priv${String.fromCharCode(101, 0x0301)}`;
assert.equal(normalizeName(samengesteld), normalizeName('Privé'));
assert.ok(!sameName('Echt', 'Demo'));
});
it('weigert alles wat geen tekst is, in plaats van er stil een tekst van te maken', () => {
assert.throws(() => sameName({ naam: 'Echt' }, 'Echt'), TypeError);
assert.throws(() => normalizeName(42), TypeError);
});
it('maakt twee verschillende objecten niet aan elkaar gelijk', () => {
assert.throws(() => sameName({ naam: 'Echt' }, { naam: 'Demo' }), TypeError,
'zonder deze guard gaven allebei "[object Object]" en was het antwoord true');
});
it('laat "nog geen wijzer" wél toe, want daar wordt rechtstreeks mee vergeleken', () => {
assert.equal(sameName('Echt', null), false);
assert.equal(sameName(null, null), false, 'niets is ook niet gelijk aan zichzelf');
assert.equal(sameName(undefined, 'Echt'), false);
});
});
describe('portfolios: de wijzer', () => {
it('geeft null als er nog geen wijzer is, en dan valt activeName op de standaard terug', () => {
assert.equal(readActiveName(), null);
assert.equal(activeName(), DEFAULT_NAME);
assert.equal(readActiveName(), null);
});
it('schrijft en leest dezelfde naam terug', () => {
writeActiveName('Demo');
assert.equal(readActiveName(), 'Demo');
assert.equal(activeDir(), join(DIR, 'Demo'));
});
it('weigert een wijzer die geen geldige JSON is', async () => {
await writeFile(pointerFile(), '{ dit is geen json', 'utf-8');
assert.throws(() => readActiveName(), /not valid JSON/);
});
it('weigert een wijzer zonder portefeuille, en een wijzer met een onmogelijke naam', async () => {
await writeFile(pointerFile(), JSON.stringify({ iets: 'anders' }), 'utf-8');
assert.throws(() => readActiveName(), /names no portfolio/);
await writeFile(pointerFile(), JSON.stringify({ portefeuille: '../buiten' }), 'utf-8');
assert.throws(() => readActiveName(), (e) => e.key === 'portfolios.nameForbiddenChars');
});
it('weigert een onmogelijke naam ook bij het schrijven', () => {
assert.throws(() => writeActiveName('CON'), (e) => e.key === 'portfolios.nameReserved');
assert.throws(() => writeActiveName('../buiten'), (e) => e.key === 'portfolios.nameForbiddenChars');
});
});
describe('portfolios: de eenmalige verhuizing', () => {
const legLosseInstallatieAan = async () => {
await writeFile(join(DIR, 'db.sqlite'), 'database', 'utf-8');
await writeFile(join(DIR, 'db.sqlite-wal'), 'wal', 'utf-8');
await writeFile(join(DIR, 'db.sqlite-shm'), 'shm', 'utf-8');
await writeFile(join(DIR, 'secrets.json'), '{"assetapp_auth":{"user":"ik"}}', 'utf-8');
await mkdir(join(DIR, 'tls'), { recursive: true });
await writeFile(join(DIR, 'tls', 'cert.pem'), 'certificaat', 'utf-8');
};
it('verhuist de database en de geheimen naar data/Test/, en laat tls/ staan', async () => {
await legLosseInstallatieAan();
assert.equal(migrateLooseLayout(), MIGRATION_NAME);
assert.deepEqual(await bestandenIn(join(DIR, MIGRATION_NAME)),
['db.sqlite', 'db.sqlite-shm', 'db.sqlite-wal', 'secrets.json']);
assert.deepEqual(await bestandenIn(DIR), [MIGRATION_NAME, 'tls']);
assert.equal(await readFile(join(DIR, MIGRATION_NAME, 'secrets.json'), 'utf-8'),
'{"assetapp_auth":{"user":"ik"}}');
});
it('doet niets als er niets los staat, en is dus veilig om elke start te draaien', async () => {
await mkdir(join(DIR, 'Echt'), { recursive: true });
await writeFile(join(DIR, 'Echt', 'db.sqlite'), 'database', 'utf-8');
assert.equal(migrateLooseLayout(), null);
assert.deepEqual(await bestandenIn(DIR), ['Echt']);
});
it('WEIGERT als er zowel losse bestanden als een database op de doelplek staan', async () => {
await legLosseInstallatieAan();
await mkdir(join(DIR, MIGRATION_NAME), { recursive: true });
await writeFile(join(DIR, MIGRATION_NAME, 'db.sqlite'), 'een andere database', 'utf-8');
assert.throws(() => migrateLooseLayout(), /does not guess/);
assert.equal(await readFile(join(DIR, 'db.sqlite'), 'utf-8'), 'database');
assert.equal(await readFile(join(DIR, MIGRATION_NAME, 'db.sqlite'), 'utf-8'), 'een andere database');
});
});
describe('portfolios: database en geheimen volgen de wijzer', () => {
it('wijst na een wissel naar de andere map, zonder dat er iets opnieuw geladen wordt', () => {
writeActiveName('Echt');
assert.equal(dbFile(), join(DIR, 'Echt', 'db.sqlite'));
assert.equal(secretsFile(), join(DIR, 'Echt', 'secrets.json'));
writeActiveName('Demo');
assert.equal(dbFile(), join(DIR, 'Demo', 'db.sqlite'));
assert.equal(secretsFile(), join(DIR, 'Demo', 'secrets.json'));
});
it('houdt database en geheimen in dezelfde map, want geheimen zijn op rij-id gesleuteld', () => {
writeActiveName('Demo');
assert.equal(dirname(dbFile()), dirname(secretsFile()));
});
it('laat ASSETAPP_DB_FILE winnen, want daar leunen container en test op', () => {
writeActiveName('Echt');
process.env.ASSETAPP_DB_FILE = join(DIR, 'ergens', 'anders.sqlite');
try {
assert.equal(dbFile(), join(DIR, 'ergens', 'anders.sqlite'));
} finally {
delete process.env.ASSETAPP_DB_FILE;
}
});
});
describe('portfolios: de installatie klaarzetten', () => {
it('maakt bij een verse installatie de standaardportefeuille en de wijzer', async () => {
const uitkomst = prepareLayout();
assert.equal(uitkomst.name, DEFAULT_NAME);
assert.equal(uitkomst.migrated, null);
assert.equal(readActiveName(), DEFAULT_NAME);
assert.deepEqual(await bestandenIn(DIR), ['active.json', DEFAULT_NAME]);
assert.match(uitkomst.regel, new RegExp(`portefeuille: ${DEFAULT_NAME}`));
});
it('wijst na een verhuizing naar de verhuisde portefeuille en niet naar de standaard', async () => {
await writeFile(join(DIR, 'db.sqlite'), 'database', 'utf-8');
const uitkomst = prepareLayout();
assert.equal(uitkomst.migrated, MIGRATION_NAME);
assert.equal(uitkomst.name, MIGRATION_NAME);
assert.equal(readActiveName(), MIGRATION_NAME);
assert.match(uitkomst.regel, /verhuisd/);
});
it('trekt de wijzer van de eerste dag recht, zonder de keuze te verliezen', async () => {
await writeFile(join(DIR, 'actief.json'), JSON.stringify({ portefeuille: 'Test' }), 'utf-8');
const uitkomst = prepareLayout();
assert.equal(uitkomst.name, 'Test');
assert.equal(readActiveName(), 'Test');
assert.deepEqual(await bestandenIn(DIR), ['active.json', 'Test'], 'en het oude bestand is weg');
});
it('laat een bestaande wijzer met rust', async () => {
writeActiveName('Demo');
const uitkomst = prepareLayout();
assert.equal(uitkomst.name, 'Demo');
assert.equal(readActiveName(), 'Demo');
});
it('is idempotent: twee keer draaien verandert niets aan de uitkomst', async () => {
await writeFile(join(DIR, 'db.sqlite'), 'database', 'utf-8');
const eerste = prepareLayout();
const tweede = prepareLayout();
assert.equal(tweede.name, eerste.name);
assert.equal(tweede.migrated, null, 'de tweede keer valt er niets meer te verhuizen');
assert.deepEqual(await bestandenIn(DIR), ['active.json', MIGRATION_NAME]);
});
});
+60
View File
@@ -0,0 +1,60 @@
// Generated, do not edit. sha256:3544f71e587319b418c7c4e6e7f8a5b1bdf589d2361fe0cbc903cf18ed737ac8
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
process.env.ASSETAPP_DATA_DIR = await mkdtemp(join(tmpdir(), 'assetapp-registers-'));
const { apiRoutes, dataModules } = await import('../../server/registers.mjs');
const { handleApi } = await import('../../server/api.mjs');
const { surface, isAllowed } = await import('../../server/data-api.mjs');
const { mcpTools } = await import('../../server/registers.mjs');
const { TOOLS, allTools } = await import('../../server/mcp/tools.mjs');
describe('de router en het oppervlak lezen het register', () => {
function verzoek(method, body) {
const handlers = {};
const req = {
method, socket: { remoteAddress: '127.0.0.1' },
on(ev, fn) { handlers[ev] = fn; if (ev === 'end') queueMicrotask(() => { if (body) handlers.data?.(Buffer.from(JSON.stringify(body))); fn(); }); return req; },
destroy() {},
};
const res = { status: null, body: null, writeHead(s) { this.status = s; }, end(t) { this.body = JSON.parse(t); } };
return { req, res };
}
it('een geregistreerde route antwoordt, met de body erbij, en een fout krijgt zijn eigen status', async () => {
apiRoutes.register({ id: 't.ok', method: 'POST', path: '/api/test/ok', run: ({ body }) => ({ echo: body.x }) });
apiRoutes.register({ id: 't.fout', method: 'POST', path: '/api/test/fout', errorStatus: 502, run: () => { throw new Error('weg'); } });
const ok = verzoek('POST', { x: 7 });
assert.equal(await handleApi(ok.req, ok.res, '/api/test/ok'), true);
assert.deepEqual([ok.res.status, ok.res.body], [200, { echo: 7 }]);
const fout = verzoek('POST', {});
await handleApi(fout.req, fout.res, '/api/test/fout');
assert.equal(fout.res.status, 502);
});
it('een andere methode of een onbekend pad is geen route', async () => {
const get = verzoek('GET');
assert.equal(await handleApi(get.req, get.res, '/api/test/ok'), false);
const onbekend = verzoek('POST', {});
assert.equal(await handleApi(onbekend.req, onbekend.res, '/api/test/bestaat-niet'), false);
});
it('een MCP-tool die zich aanmeldt, staat achter de kerntools; een tool met een kernnaam faalt', () => {
const tool = { name: 'test_tool', title: 't', description: 't', scope: 'none', inputSchema: { type: 'object' }, run: async () => ({ text: '', fieldsShared: [] }) };
assert.throws(() => mcpTools.register({ id: 'andere_naam', tool }), /carries the name/);
mcpTools.register({ id: 'test_tool', tool });
assert.deepEqual(allTools().map(t => t.name), [...TOOLS.map(t => t.name), 'test_tool']);
mcpTools.register({ id: 'connection_test', tool: { ...tool, name: 'connection_test' } });
assert.throws(() => allTools(), /also a core tool/);
});
it('een datamodule die zich NA het laden van data-api aanmeldt, staat toch in het oppervlak', () => {
assert.equal(isAllowed('test-laat', 'doe'), false);
dataModules.register({ id: 'test-laat', module: { doe: () => 1, NIET: 2 } });
assert.equal(isAllowed('test-laat', 'doe'), true);
assert.deepEqual(surface()['test-laat'], ['doe'], 'alleen functies, geen constanten');
});
});
+95
View File
@@ -0,0 +1,95 @@
// Generated, do not edit. sha256:832fcfe26d54d7a88bf8a52d6e8c9ca907eb53d0298d2cb26f675f06f2a31772
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createServer } from 'node:net';
import { mkdtemp, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const WORTEL = join(fileURLToPath(import.meta.url), '..', '..', '..');
const SUPERVISOR = join(WORTEL, 'server', 'supervisor.mjs');
let dir, proc, poort, basis;
const vrijePoort = () => new Promise((klaar, fout) => {
const s = createServer();
s.on('error', fout);
s.listen(0, '127.0.0.1', () => {
const p = s.address().port;
s.close(() => klaar(p));
});
});
const wacht = (ms) => new Promise(klaar => setTimeout(klaar, ms));
async function wachtOp(verwacht, timeoutMs = 20_000) {
const eind = Date.now() + timeoutMs;
while (Date.now() < eind) {
let leeft = false;
try {
const r = await fetch(`${basis}/api/health`);
leeft = r.ok;
} catch { leeft = false; }
if (leeft === verwacht) return true;
await wacht(100);
}
return false;
}
const haal = async (pad) => (await fetch(`${basis}${pad}`)).json();
const post = async (pad, body) => (await fetch(`${basis}${pad}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})).json();
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'assetapp-supervisor-'));
poort = await vrijePoort();
basis = `http://127.0.0.1:${poort}`;
proc = spawn(process.execPath, [SUPERVISOR], {
env: { ...process.env, ASSETAPP_DATA_DIR: dir, PORT: String(poort), ASSETAPP_OPEN: '' },
stdio: ['ignore', 'pipe', 'pipe'],
});
assert.ok(await wachtOp(true), 'de server kwam niet op');
});
after(async () => {
if (proc && !proc.killed) proc.kill('SIGKILL');
await wacht(200);
await rm(dir, { recursive: true, force: true });
});
describe('supervisor: wisselen is een herstart', () => {
it('komt na een wissel terug met de ándere portefeuille', async () => {
const voor = await haal('/api/portfolios');
assert.equal(voor.actief, 'MyAssets');
await mkdir(join(dir, 'Demo'), { recursive: true });
const uit = await post('/api/portfolios/activate', { naam: 'Demo' });
assert.equal(uit.gewisseld, true);
assert.equal(uit.supervised, true, 'de server hoort te weten dat hij terugkomt');
assert.ok(await wachtOp(false, 10_000), 'de server ging niet neer');
assert.ok(await wachtOp(true), 'de server kwam niet terug');
const na = await haal('/api/portfolios');
assert.equal(na.actief, 'Demo');
});
it('wisselen naar de portefeuille die al open is, herstart niet', async () => {
const uit = await post('/api/portfolios/activate', { naam: 'Demo' });
assert.equal(uit.gewisseld, false);
await wacht(500);
assert.equal((await haal('/api/portfolios')).actief, 'Demo');
});
it('laat de server NIET achter als de supervisor verdwijnt', async () => {
proc.kill('SIGKILL');
assert.ok(await wachtOp(false, 10_000), 'er draait nog een server nadat de supervisor weg is');
});
});
+60
View File
@@ -0,0 +1,60 @@
// Generated, do not edit. sha256:2e4d24591409f9e23b4c9506d97e49fbede02ae3761449cb3964ef0391f78258
import { describe, it, mock, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
const dienst = { gevraagd: [] };
mock.module('../../server/market-service.mjs', {
namedExports: {
marketServiceUrl: async () => 'https://services.example',
marketSupportMessages: async (header) => { dienst.gevraagd.push(['lees', header]); return [{ from: 'seller', text: 'hoi' }]; },
marketSupportSend: async (header, body) => { dienst.gevraagd.push(['stuur', header, body]); return { from: 'buyer', text: 'vraag' }; },
marketSupportAttachment: async (header, id) => { dienst.gevraagd.push(['bijlage', header, id]); return { mime: 'image/png', data: 'iVBO' }; },
},
});
const { CONFIG } = await import('../../shared/config.js');
const { supportInfo, supportMessages, sendSupport, supportAttachment } = await import('../../server/data/support.js');
const NOSTR = 'Nostr eyJ0ZXN0Ijp0cnVlfQ==';
beforeEach(() => { dienst.gevraagd = []; });
describe('support: wat de pagina weet', () => {
it('geeft het adres om voor te tekenen, de grenzen en het supportaccount op nostr', async () => {
const info = await supportInfo();
assert.equal(info.serviceUrl, 'https://services.example');
assert.equal(info.maxText, CONFIG.LICENSE.SUPPORT.MAX_TEXT);
assert.equal(info.maxAttachmentBytes, 2 * 1024 * 1024);
assert.deepEqual(info.attachmentTypes, ['image/png', 'image/jpeg', 'image/webp']);
assert.equal(info.nostr.pubkey, CONFIG.LICENSE.SUPPORT.RECIPIENT);
});
});
describe('support: doorgeven aan de dienst', () => {
it('geeft de handtekening en de body ongewijzigd door', async () => {
const body = '{"text":"vraag","attachment":{"type":"image/png","data":"iVBO"}}';
assert.deepEqual(await supportMessages(NOSTR), [{ from: 'seller', text: 'hoi' }]);
assert.deepEqual(await sendSupport(NOSTR, body), { from: 'buyer', text: 'vraag' });
assert.deepEqual(await supportAttachment(NOSTR, 'a'.repeat(32)), { mime: 'image/png', data: 'iVBO' });
assert.deepEqual(dienst.gevraagd, [['lees', NOSTR], ['stuur', NOSTR, body], ['bijlage', NOSTR, 'a'.repeat(32)]]);
});
it('weigert iets anders dan een NIP-98-header, zonder de dienst te vragen', async () => {
for (const fout of [null, '', 'Bearer abc', 'Order ' + 'b'.repeat(64), 'Nostr niet base64!']) {
await assert.rejects(supportMessages(fout), /Authorization: Nostr/);
await assert.rejects(sendSupport(fout, '{}'), /Authorization: Nostr/);
}
assert.deepEqual(dienst.gevraagd, []);
});
it('weigert een body die geen tekst is: dan is er niets getekend', async () => {
await assert.rejects(sendSupport(NOSTR, { text: 'vraag' }), /signed JSON text/);
assert.deepEqual(dienst.gevraagd, []);
});
it('weigert een bijlage-id die geen 32 hex is, zodat er geen pad uit invoer ontstaat', async () => {
for (const fout of ['../health', 'A'.repeat(32), 'a'.repeat(31), null]) {
await assert.rejects(supportAttachment(NOSTR, fout), /32 hex/);
}
assert.deepEqual(dienst.gevraagd, []);
});
});