Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(), []);
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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'/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user