Versie 0.3.2
This commit is contained in:
1 parent
e77d8fd373
commit
1cf6eeed29
16 files changed
+551
-44
No files matched your search
@@ -0,0 +1,451 @@
|
||||
// Generated, do not edit. sha256:b010e85d240007ea64c04ad8392259f493d1479d73de4754cba27fd27e215811
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequestListener } from '../../server/app.mjs';
|
||||
import {
|
||||
passwordMatches, parseCookies, sessionCookie, clearedSessionCookie, sessionTokenOf, resolveBinding,
|
||||
} from '../../server/auth.mjs';
|
||||
import { createSessionStore, createAttemptLimiter } from '../../server/sessions.mjs';
|
||||
import { CONFIG } from '../../shared/config.js';
|
||||
|
||||
const GELDIG = { password: 'geheim' };
|
||||
|
||||
function serverMet(opties) {
|
||||
const ctx = {};
|
||||
before(async () => {
|
||||
ctx.server = createServer(createRequestListener(opties));
|
||||
await new Promise(klaar => ctx.server.listen(0, '127.0.0.1', klaar));
|
||||
ctx.basis = `http://127.0.0.1:${ctx.server.address().port}`;
|
||||
});
|
||||
after(() => new Promise(klaar => ctx.server.close(klaar)));
|
||||
ctx.haal = (pad, init = {}) => fetch(`${ctx.basis}${pad}`, { redirect: 'manual', ...init });
|
||||
ctx.login = (wachtwoord) => ctx.haal('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: wachtwoord }),
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const cookieUit = (res) => (res.headers.get('set-cookie') || '').split(';')[0];
|
||||
|
||||
describe('passwordMatches', () => {
|
||||
it('accepteert het juiste wachtwoord en weigert een verkeerd', () => {
|
||||
assert.equal(passwordMatches('geheim', GELDIG), true);
|
||||
assert.equal(passwordMatches('fout', GELDIG), false);
|
||||
});
|
||||
|
||||
it('weigert alles zodra er niets is ingesteld of het ingestelde leeg is', () => {
|
||||
assert.equal(passwordMatches('', { password: '' }), false);
|
||||
assert.equal(passwordMatches('watdanook', { password: '' }), false);
|
||||
assert.equal(passwordMatches('watdanook', null), false);
|
||||
assert.equal(passwordMatches(null, GELDIG), false);
|
||||
assert.equal(passwordMatches(undefined, GELDIG), false);
|
||||
});
|
||||
|
||||
it('accepteert geen niet-string die toevallig gelijk oogt', () => {
|
||||
assert.equal(passwordMatches({ toString: () => 'geheim' }, GELDIG), false);
|
||||
assert.equal(passwordMatches(['geheim'], GELDIG), false);
|
||||
});
|
||||
|
||||
it('behandelt niet-ASCII en dubbele punten gewoon als tekens', () => {
|
||||
assert.equal(passwordMatches('a:b:c', { password: 'a:b:c' }), true);
|
||||
assert.equal(passwordMatches('wachtwoord€', { password: 'wachtwoord€' }), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCookies', () => {
|
||||
it('leest een enkel cookie en meerdere naast elkaar', () => {
|
||||
assert.deepEqual(parseCookies('a=1'), { a: '1' });
|
||||
assert.deepEqual(parseCookies('a=1; b=2'), { a: '1', b: '2' });
|
||||
});
|
||||
|
||||
it('slaat brokken zonder = over in plaats van om te vallen', () => {
|
||||
assert.deepEqual(parseCookies('rommel; a=1'), { a: '1' });
|
||||
assert.deepEqual(parseCookies(''), {});
|
||||
assert.deepEqual(parseCookies(undefined), {});
|
||||
});
|
||||
|
||||
it('laat de EERSTE waarde winnen bij een dubbele naam', () => {
|
||||
assert.equal(parseCookies('s=echt; s=vals').s, 'echt');
|
||||
});
|
||||
|
||||
it('laat een waarde met een = erin heel', () => {
|
||||
assert.equal(parseCookies('s=a=b').s, 'a=b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('het sessiecookie', () => {
|
||||
it('draagt HttpOnly, SameSite en een Max-Age die bij de sessie past', () => {
|
||||
const c = sessionCookie('abc');
|
||||
assert.match(c, /^assetapp_session=abc;/);
|
||||
assert.match(c, /HttpOnly/);
|
||||
assert.match(c, /SameSite=Lax/);
|
||||
assert.match(c, new RegExp(`Max-Age=${Math.floor(CONFIG.AUTH.SESSION_TTL_MS / 1000)}\\b`));
|
||||
});
|
||||
|
||||
it('krijgt Secure alleen mét TLS', () => {
|
||||
assert.doesNotMatch(sessionCookie('abc', { secure: false }), /Secure/);
|
||||
assert.match(sessionCookie('abc', { secure: true }), /Secure/);
|
||||
});
|
||||
|
||||
it('haalt zichzelf weg met dezelfde vlaggen als waarmee hij gezet is', () => {
|
||||
const gezet = sessionCookie('abc', { secure: true });
|
||||
const weg = clearedSessionCookie({ secure: true });
|
||||
const vlaggenVan = (c) => c.split(';').slice(1).map(d => d.trim()).filter(d => !d.startsWith('Max-Age'));
|
||||
assert.deepEqual(vlaggenVan(weg), vlaggenVan(gezet));
|
||||
assert.match(weg, /Max-Age=0/);
|
||||
});
|
||||
|
||||
it('leest het token terug uit een verzoek, en geeft null zonder cookie', () => {
|
||||
assert.equal(sessionTokenOf({ headers: { cookie: 'assetapp_session=xyz' } }), 'xyz');
|
||||
assert.equal(sessionTokenOf({ headers: { cookie: 'iets=anders' } }), null);
|
||||
assert.equal(sessionTokenOf({ headers: {} }), null);
|
||||
assert.equal(sessionTokenOf(undefined), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSessionStore', () => {
|
||||
it('geeft een vers token dat geldig is, en twee keer niet hetzelfde', () => {
|
||||
const s = createSessionStore();
|
||||
const a = s.create();
|
||||
const b = s.create();
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(s.isValid(a), true);
|
||||
assert.equal(s.isValid(b), true);
|
||||
});
|
||||
|
||||
it('weigert een token dat hij nooit heeft uitgegeven', () => {
|
||||
const s = createSessionStore();
|
||||
s.create();
|
||||
assert.equal(s.isValid('a'.repeat(64)), false);
|
||||
assert.equal(s.isValid(''), false);
|
||||
assert.equal(s.isValid(undefined), false);
|
||||
assert.equal(s.isValid(null), false);
|
||||
assert.equal(s.isValid(123), false);
|
||||
});
|
||||
|
||||
it('laat een token vervallen zodra de tijd om is', () => {
|
||||
let nu = 1000;
|
||||
const s = createSessionStore({ ttlMs: 100, now: () => nu });
|
||||
const token = s.create();
|
||||
|
||||
nu = 1099;
|
||||
assert.equal(s.isValid(token), true, 'net vóór de grens telt hij nog');
|
||||
nu = 1100;
|
||||
assert.equal(s.isValid(token), false, 'op de grens is hij voorbij');
|
||||
});
|
||||
|
||||
it('verlengt een sessie NIET door hem te gebruiken', () => {
|
||||
let nu = 0;
|
||||
const s = createSessionStore({ ttlMs: 100, now: () => nu });
|
||||
const token = s.create();
|
||||
for (nu = 10; nu < 100; nu += 10) assert.equal(s.isValid(token), true);
|
||||
nu = 100;
|
||||
assert.equal(s.isValid(token), false);
|
||||
});
|
||||
|
||||
it('ruimt een verlopen token op in plaats van hem te laten liggen', () => {
|
||||
let nu = 0;
|
||||
const s = createSessionStore({ ttlMs: 100, now: () => nu });
|
||||
s.create();
|
||||
nu = 200;
|
||||
s.create();
|
||||
assert.equal(s.size(), 1, 'de verlopen sessie hoort bij het aanmaken opgeruimd te zijn');
|
||||
});
|
||||
|
||||
it('beëindigt een sessie met drop, en een onbekend token is geen fout', () => {
|
||||
const s = createSessionStore();
|
||||
const token = s.create();
|
||||
assert.equal(s.drop(token), true);
|
||||
assert.equal(s.isValid(token), false);
|
||||
assert.equal(s.drop('bestaat-niet'), false);
|
||||
});
|
||||
|
||||
it('deelt niets tussen twee opslagen', () => {
|
||||
const a = createSessionStore();
|
||||
const b = createSessionStore();
|
||||
assert.equal(b.isValid(a.create()), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAttemptLimiter', () => {
|
||||
it('laat de eerste pogingen vrij en gaat daarna oplopend wachten', () => {
|
||||
let nu = 0;
|
||||
const l = createAttemptLimiter({ free: 3, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
|
||||
const adres = '127.0.0.1';
|
||||
|
||||
for (let i = 0; i < 3; i++) { l.fail(adres); assert.equal(l.retryAfterMs(adres), 0); }
|
||||
assert.equal(l.fail(adres), 100, 'de vierde misser kost de basiswachttijd');
|
||||
assert.equal(l.fail(adres), 200, 'en daarna verdubbelt het');
|
||||
assert.equal(l.fail(adres), 400);
|
||||
});
|
||||
|
||||
it('laat de wachttijd aflopen met de tijd', () => {
|
||||
let nu = 0;
|
||||
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
|
||||
l.fail('a');
|
||||
assert.equal(l.retryAfterMs('a'), 100);
|
||||
nu = 60;
|
||||
assert.equal(l.retryAfterMs('a'), 40);
|
||||
nu = 100;
|
||||
assert.equal(l.retryAfterMs('a'), 0);
|
||||
});
|
||||
|
||||
it('zet de teller NIET terug door de wachttijd uit te zitten', () => {
|
||||
let nu = 0;
|
||||
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
|
||||
l.fail('a');
|
||||
nu = 100;
|
||||
assert.equal(l.retryAfterMs('a'), 0);
|
||||
assert.equal(l.fail('a'), 200, 'de volgende misser bouwt voort op de vorige');
|
||||
});
|
||||
|
||||
it('gaat niet boven het maximum uit', () => {
|
||||
let nu = 0;
|
||||
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 250, now: () => nu });
|
||||
for (let i = 0; i < 20; i++) l.fail('a');
|
||||
assert.equal(l.retryAfterMs('a'), 250);
|
||||
});
|
||||
|
||||
it('telt per adres en niet over adressen heen', () => {
|
||||
let nu = 0;
|
||||
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
|
||||
l.fail('a'); l.fail('a'); l.fail('a');
|
||||
assert.equal(l.retryAfterMs('b'), 0);
|
||||
});
|
||||
|
||||
it('vergeet een adres na een geslaagde poging', () => {
|
||||
let nu = 0;
|
||||
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
|
||||
l.fail('a');
|
||||
l.succeed('a');
|
||||
assert.equal(l.retryAfterMs('a'), 0);
|
||||
assert.equal(l.size(), 0);
|
||||
});
|
||||
|
||||
it('vergeet een adres dat lang genoeg stil is, zodat de lijst niet eindeloos groeit', () => {
|
||||
let nu = 0;
|
||||
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 250, now: () => nu });
|
||||
l.fail('a');
|
||||
nu = 100;
|
||||
assert.equal(l.size(), 1, 'net uitgezeten is nog niet vergeten');
|
||||
nu = 100000;
|
||||
l.retryAfterMs('a');
|
||||
assert.equal(l.size(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBinding', () => {
|
||||
it('bindt op alle interfaces zodra TLS én inloggegevens er zijn', () => {
|
||||
assert.deepEqual(resolveBinding({ tls: true, credentials: GELDIG }), { host: '0.0.0.0', waarschuwing: null });
|
||||
});
|
||||
|
||||
it('bindt op loopback zolang een van de twee ontbreekt', () => {
|
||||
assert.equal(resolveBinding({ tls: false, credentials: GELDIG }).host, '127.0.0.1');
|
||||
assert.equal(resolveBinding({ tls: true, credentials: null }).host, '127.0.0.1');
|
||||
assert.equal(resolveBinding({}).host, '127.0.0.1');
|
||||
});
|
||||
|
||||
it('volgt een expliciete host, maar waarschuwt als die onbeschermd buiten loopback ligt', () => {
|
||||
const uit = resolveBinding({ tls: false, credentials: null, host: '0.0.0.0' });
|
||||
assert.equal(uit.host, '0.0.0.0');
|
||||
assert.match(uit.waarschuwing, /TLS en inloggegevens/);
|
||||
});
|
||||
|
||||
it('noemt in de waarschuwing alleen wat er werkelijk ontbreekt', () => {
|
||||
const uit = resolveBinding({ tls: true, credentials: null, host: '192.168.1.50' });
|
||||
assert.match(uit.waarschuwing, /inloggegevens ontbreekt/);
|
||||
assert.doesNotMatch(uit.waarschuwing, /TLS/);
|
||||
});
|
||||
|
||||
it('waarschuwt niet bij een expliciete loopback-host', () => {
|
||||
for (const host of ['127.0.0.1', '::1', 'localhost']) {
|
||||
assert.equal(resolveBinding({ tls: false, credentials: null, host }).waarschuwing, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequestListener: alles zit achter de sessiecontrole', () => {
|
||||
const ctx = serverMet({ credentials: GELDIG });
|
||||
|
||||
it('weigert /api/health zonder cookie, met een kale 401', async () => {
|
||||
const r = await ctx.haal('/api/health');
|
||||
assert.equal(r.status, 401);
|
||||
});
|
||||
|
||||
it('stuurt op een DOCUMENTverzoek om naar het inlogscherm', async () => {
|
||||
const r = await ctx.haal('/');
|
||||
assert.equal(r.status, 302);
|
||||
assert.equal(r.headers.get('location'), CONFIG.AUTH.LOGIN_PATH);
|
||||
});
|
||||
|
||||
it('stuurt op een API-verzoek NIET om, want fetch zou die omleiding volgen', async () => {
|
||||
const r = await ctx.haal('/api/data/assets/listAssets', { method: 'POST' });
|
||||
assert.equal(r.status, 401);
|
||||
assert.equal(r.headers.get('location'), null);
|
||||
});
|
||||
|
||||
it('weigert een onbekend /api-pad ook vóór de router, dus zonder 404 te verklappen', async () => {
|
||||
const r = await ctx.haal('/api/bestaat-niet');
|
||||
assert.equal(r.status, 401);
|
||||
});
|
||||
|
||||
it('draagt NERGENS een WWW-Authenticate-header', async () => {
|
||||
for (const pad of ['/', '/api/health', '/api/bestaat-niet', '/css/styles.css']) {
|
||||
const r = await ctx.haal(pad);
|
||||
assert.equal(r.headers.get('www-authenticate'), null, `${pad} draagt de header nog`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequestListener: inloggen en uitloggen', () => {
|
||||
const ctx = serverMet({ credentials: GELDIG });
|
||||
|
||||
it('weigert een verkeerd wachtwoord zonder cookie te zetten', async () => {
|
||||
const r = await ctx.login('fout');
|
||||
assert.equal(r.status, 401);
|
||||
assert.equal(r.headers.get('set-cookie'), null);
|
||||
});
|
||||
|
||||
it('zet bij het juiste wachtwoord een HttpOnly-sessiecookie', async () => {
|
||||
const r = await ctx.login('geheim');
|
||||
assert.equal(r.status, 200);
|
||||
const cookie = r.headers.get('set-cookie');
|
||||
assert.match(cookie, /^assetapp_session=[0-9a-f]{64};/);
|
||||
assert.match(cookie, /HttpOnly/);
|
||||
});
|
||||
|
||||
it('opent daarmee de app én de API', async () => {
|
||||
const cookie = cookieUit(await ctx.login('geheim'));
|
||||
const app = await ctx.haal('/', { headers: { cookie } });
|
||||
assert.equal(app.status, 200);
|
||||
const api = await ctx.haal('/api/health', { headers: { cookie } });
|
||||
assert.equal(api.status, 200);
|
||||
assert.deepEqual(await api.json(), { ok: true });
|
||||
});
|
||||
|
||||
it('weigert een verzonnen cookie', async () => {
|
||||
const r = await ctx.haal('/api/health', { headers: { cookie: `assetapp_session=${'a'.repeat(64)}` } });
|
||||
assert.equal(r.status, 401);
|
||||
});
|
||||
|
||||
it('maakt het cookie ongeldig bij uitloggen, aan beide kanten', async () => {
|
||||
const cookie = cookieUit(await ctx.login('geheim'));
|
||||
const uit = await ctx.haal('/api/logout', { method: 'POST', headers: { cookie } });
|
||||
|
||||
assert.equal(uit.status, 200);
|
||||
assert.match(uit.headers.get('set-cookie'), /Max-Age=0/, 'de browser hoort het cookie weg te gooien');
|
||||
const na = await ctx.haal('/api/health', { headers: { cookie } });
|
||||
assert.equal(na.status, 401);
|
||||
});
|
||||
|
||||
it('stuurt een ingelogde bezoeker van het inlogscherm door naar de app', async () => {
|
||||
const cookie = cookieUit(await ctx.login('geheim'));
|
||||
const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH, { headers: { cookie } });
|
||||
assert.equal(r.status, 302);
|
||||
assert.equal(r.headers.get('location'), '/');
|
||||
});
|
||||
|
||||
it('weigert GET op de inlog- en uitlogroute', async () => {
|
||||
assert.equal((await ctx.haal('/api/login')).status, 405);
|
||||
assert.equal((await ctx.haal('/api/logout')).status, 405);
|
||||
});
|
||||
|
||||
it('laat het formulier ook zonder JavaScript geen wachtwoord in de URL zetten', async () => {
|
||||
const html = readFileSync(new URL('../../client/login.html', import.meta.url), 'utf8');
|
||||
const form = /<form[^>]*id="login-form"[^>]*>/.exec(html);
|
||||
assert.ok(form, 'geen formulier met id login-form gevonden');
|
||||
assert.match(form[0], /method="post"/i);
|
||||
assert.match(form[0], /action="\/api\/login"/);
|
||||
});
|
||||
|
||||
it('weigert een body die geen JSON is, zonder de teller te verstoren', async () => {
|
||||
const r = await ctx.haal('/api/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'geen json',
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal((await ctx.login('geheim')).status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequestListener: de uitzondering voor het inlogscherm', () => {
|
||||
const ctx = serverMet({ credentials: GELDIG });
|
||||
|
||||
it('laat het inlogscherm zelf zien', async () => {
|
||||
const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH);
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.headers.get('content-type'), /text\/html/);
|
||||
assert.match(await r.text(), /login-form/);
|
||||
});
|
||||
|
||||
it('laat ELK bestand door dat login.html noemt', async () => {
|
||||
const html = readFileSync(new URL('../../client/login.html', import.meta.url), 'utf8');
|
||||
const verwijzingen = [...html.matchAll(/(?:src|href)\s*=\s*"([^"]+)"/g)].map(m => m[1])
|
||||
.filter(pad => !/^(https?:|data:|#|\/\/)/.test(pad));
|
||||
assert.ok(verwijzingen.length >= 3, `verwacht minstens drie verwijzingen, gevonden ${verwijzingen.length}`);
|
||||
|
||||
for (const pad of verwijzingen) {
|
||||
assert.equal((await ctx.haal(`/${pad}`)).status, 200, `/${pad} staat in login.html maar is dicht`);
|
||||
}
|
||||
});
|
||||
|
||||
it('laat de lettertypebestanden door waar die stylesheet naar wijst', async () => {
|
||||
const css = readFileSync(new URL('../../client/vendor/inter/inter.css', import.meta.url), 'utf8');
|
||||
const eerste = /url\(\.\/(files\/[^)]+)\)/.exec(css);
|
||||
assert.ok(eerste, 'geen enkel woff2-bestand gevonden in inter.css');
|
||||
assert.equal((await ctx.haal(`/vendor/inter/${eerste[1]}`)).status, 200);
|
||||
});
|
||||
|
||||
it('houdt de app-schil dicht, en dat is de reden dat dit een eigen pagina is', async () => {
|
||||
for (const pad of ['/index.html', '/app.js', '/boot-gate.js', '/shared/config.js',
|
||||
'/portfolio.js', '/i18n/nl.js', '/data/_rpc.js']) {
|
||||
const r = await ctx.haal(pad);
|
||||
assert.equal(r.status, 302, `${pad} is bereikbaar zonder in te loggen`);
|
||||
}
|
||||
});
|
||||
|
||||
it('laat geen ander bestand uit de font-map van de app door dan de fonts zelf', async () => {
|
||||
assert.equal((await ctx.haal('/vendor/crypto/noble-scure.js')).status, 302);
|
||||
});
|
||||
|
||||
it('laat de uitzondering niet door een gecodeerd pad misbruiken', async () => {
|
||||
for (const pad of ['/css/%2e%2e/app.js', '/vendor/inter/files/%2e%2e/%2e%2e/%2e%2e/app.js']) {
|
||||
const r = await ctx.haal(pad);
|
||||
assert.equal(r.status, 302, `${pad} kwam langs de uitzondering`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequestListener: de pogingenlimiet', () => {
|
||||
const ctx = serverMet({ credentials: GELDIG });
|
||||
|
||||
it('gaat na een reeks missers wachten en meldt hoe lang', async () => {
|
||||
let laatste;
|
||||
for (let i = 0; i < CONFIG.AUTH.LOGIN_ATTEMPTS.FREE + 1; i++) laatste = await ctx.login('fout');
|
||||
|
||||
const json = await laatste.json();
|
||||
assert.equal(laatste.status, 401);
|
||||
assert.ok(json.retryAfterMs > 0, 'de wachttijd hoort in het antwoord te staan');
|
||||
|
||||
const geblokkeerd = await ctx.login('geheim');
|
||||
assert.equal(geblokkeerd.status, 429);
|
||||
assert.ok(Number(geblokkeerd.headers.get('retry-after')) >= 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequestListener zonder inloggegevens: bewust open', () => {
|
||||
const ctx = serverMet({});
|
||||
|
||||
it('serveert zonder cookie, want dit is de modus die resolveBinding op loopback houdt', async () => {
|
||||
assert.equal((await ctx.haal('/api/health')).status, 200);
|
||||
});
|
||||
|
||||
it('toont geen inlogscherm, want er valt niets te ontsluiten', async () => {
|
||||
const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH);
|
||||
assert.equal(r.status, 302);
|
||||
assert.equal(r.headers.get('location'), '/');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated, do not edit. sha256:0218bafc8f02c1e9ace72164cc21b81de083223f11fc50e42ec7fedf10c23058
|
||||
// Generated, do not edit. sha256:90b8d22d967cee3d2817f9d1b730418df66b8dba385cbdf2b3f5d3091a0a67ef
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
|
||||
@@ -263,6 +263,16 @@ describe('createMarketClient: de reeks (fase 2)', () => {
|
||||
await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer');
|
||||
});
|
||||
|
||||
it('de historie neemt de bron per rij mee als vierde element, met de naam per bronsleutel', async () => {
|
||||
const { c } = client({ '/v1/series': () => json({
|
||||
instrument: 'crypto:BTC', currency: 'EUR', source: 'bitvavo', kind: 'history',
|
||||
columns: ['t', 'price', 'resolution', 'source'], sourceLabels: { bitvavo: 'Een Nederlandse aanbieder' },
|
||||
rows: [[FROM, 61000, 'day', 'bitvavo'], [FROM + 86_400_000, 61010, 'day', null]], next: null }) });
|
||||
const uit = await c.history({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
|
||||
assert.deepEqual(uit.points, [[FROM, 61000, 'day', 'bitvavo'], [FROM + 86_400_000, 61010, 'day']]);
|
||||
assert.deepEqual(uit.sourceLabels, { bitvavo: 'Een Nederlandse aanbieder' });
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user