diff --git a/client/app.js b/client/app.js index fc18295..8336f1d 100644 --- a/client/app.js +++ b/client/app.js @@ -1,4 +1,4 @@ -// Generated, do not edit. sha256:8874091aa075a54dd49f5d1aad9244d40ef61ac47ce7c83d7d4e346d8133207a +// Generated, do not edit. sha256:98f4651e5cb7036965f92ae8206b5dd6cbf62ce4e42388c68825bc8d9de6ca9d import { configureSharedUi } from './ui/shared-setup.js'; import './edition.js'; import { assertConfig, ROUTE_KEYS, hashFromLocation } from '../shared/config.js'; @@ -253,8 +253,7 @@ async function boot() { initCommands(); await renderPage(); document.documentElement.dataset.app = 'ready'; - finishSplash(); - maybeStartSetup(); + finishSplash().then(maybeStartSetup); refreshAttention(); listAssets() diff --git a/client/css/styles.css b/client/css/styles.css index bca118b..cd7a935 100644 --- a/client/css/styles.css +++ b/client/css/styles.css @@ -1,4 +1,4 @@ -/* Generated, do not edit. sha256:36518f0a81f251fc91cc7a50cccd80f8e26c117fb0b6017e85098cc76621857c */ +/* Generated, do not edit. sha256:d875d85b0d41e419eb6bae7090608b0914629b07925648a2d82dc7a8e8eb4309 */ :root { --logo-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'%3E%3Cpath d='M 46.108 37.099 L 79.826 68.032 L 113.068 37.288 L 142.067 37.244 L 92.564 85.773 L 92.367 123.544 L 80.04 135.851 L 67.262 123.517 L 67.425 85.773 L 17.176 37.092 L 46.108 37.099 Z'/%3E%3Cpath d='M 49.901 91.273 L 0 43.722 L 0 24.666 L 64.78 87.731 L 64.689 118.25 L 50.234 105.711 L 49.901 91.273 Z'/%3E%3Cpath d='M 95.221 87.779 L 160 24.149 L 160 43.383 L 109.554 93.918 L 109.584 105.756 L 94.924 118.584 L 95.221 87.779 Z'/%3E%3C/svg%3E"); --radius-sm: 8px; @@ -2019,6 +2019,7 @@ html[data-pagetitle="off"] { --guide-top:1.2rem; } border:0; border-radius:50%; background:transparent; color:var(--text-ter); cursor:pointer; } .dialog-eye:hover, .dialog-eye:focus-visible { background:var(--accent-soft); color:var(--accent-text); } .dialog-eye svg { width:1rem; height:1rem; } +.dialog:not(:has(.val)) .dialog-eye { display:none; } .dialog:has(.dialog-close) { position:relative; } .dialog-close { position:absolute; top:0.65rem; right:0.65rem; z-index:3; diff --git a/client/i18n/en.js b/client/i18n/en.js index d8f35de..55f324b 100644 --- a/client/i18n/en.js +++ b/client/i18n/en.js @@ -1,4 +1,4 @@ -// Generated, do not edit. sha256:13240cf3e3de47963beb3e5b97f3a5cb83beb4a9a635ce540f88a745df4d753d +// Generated, do not edit. sha256:a167e18e55f858b6191c5f1c50bdab5b3bf485873744837c0e096e77f639cd0e import { nl } from './nl.js'; export const en = { @@ -60,7 +60,7 @@ export const en = { license: { lockedTitle: 'Part of the paid version', - counter: '{n} of {max}', + counter: '{naam} in the free edition: {n} of {max}', whatIsPaid: 'What does the paid version include?', why_develop: 'Development version: nothing is restricted below 1.0.0', why_forced: 'Forced via ASSETAPP_LICENSE_STATE (development only)', @@ -210,6 +210,9 @@ export const en = { infoPrivacy: 'Your npub and the orders with it. An email address only if you provide one, and then ' + 'made unrecognizable, so it can only be matched if you come forward with it yourself.', name_fiatAccounts: 'Fiat accounts', + name_assets: 'Assets besides currencies', + name_valuations: 'Balances', + name_portfolios: 'Portfolios', featTitle: 'What the editions include', featFull: 'Full', featFree: 'Free', @@ -1219,7 +1222,7 @@ export const en = { groupBy: 'Group', standing: 'Current position', toWealth: 'To Wealth', - standingNone: 'No tax-relevant positions yet.', + standingNone: 'No positions yet. Book a purchase or record a balance.', showToday: 'Show return', }, @@ -2084,6 +2087,7 @@ export const en = { scanning: 'Scanning…', scanningTx: 'Fetching transactions', scanError: 'Update failed', + scanErrorWhy: 'Update failed: {reden}', scanNotOnchain: 'Cannot be scanned on-chain', lightningMovements: 'Lightning transactions updated', addressesScanned: 'addresses scanned', diff --git a/client/i18n/nl.js b/client/i18n/nl.js index d711667..13504b3 100644 --- a/client/i18n/nl.js +++ b/client/i18n/nl.js @@ -1,4 +1,4 @@ -// Generated, do not edit. sha256:5337809d822cab071fd28371d6976e564034e1f27296fd058188237da0027ce9 +// Generated, do not edit. sha256:f025d672341c54d23ebad8e10876b424ac570cd500126582bf4b9c905f2cd672 export const nl = { app: { name: 'Atreides Assets' }, @@ -58,7 +58,7 @@ export const nl = { license: { lockedTitle: 'Hoort bij de betaalde versie', - counter: '{n} van {max}', + counter: '{naam} in de gratis editie: {n} van {max}', whatIsPaid: 'Wat zit er in de betaalde versie?', why_develop: 'Ontwikkelversie: er wordt niets beperkt onder 1.0.0', why_forced: 'Geforceerd via ASSETAPP_LICENSE_STATE (alleen bij ontwikkelen)', @@ -210,6 +210,9 @@ export const nl = { infoPrivacy: 'Uw npub en de orders erbij. Een e-mailadres alleen als u dat opgeeft, en dan onherkenbaar ' + 'gemaakt, zodat het alleen terug te vinden is als u zich er zelf mee meldt.', name_fiatAccounts: 'Fiatrekeningen', + name_assets: 'Assets naast valuta', + name_valuations: 'Balansen', + name_portfolios: 'Portefeuilles', featTitle: 'Wat de edities bieden', featFull: 'Volledig', featFree: 'Gratis', @@ -1235,7 +1238,7 @@ export const nl = { groupBy: 'Groeperen', standing: 'Huidige stand', toWealth: 'Naar Vermogen', - standingNone: 'Nog geen tax-relevante posities.', + standingNone: 'Nog geen posities. Boek een aankoop of leg een balans vast.', showToday: 'Rendement tonen', }, @@ -2108,6 +2111,7 @@ export const nl = { scanning: 'Scannen…', scanningTx: 'Transacties ophalen', scanError: 'Bijwerken mislukt', + scanErrorWhy: 'Bijwerken mislukt: {reden}', scanNotOnchain: 'Niet via de keten te scannen', lightningMovements: 'lightning-bewegingen bijgewerkt', addressesScanned: 'adressen gescand', diff --git a/client/license.js b/client/license.js index d7b3226..1551dbd 100644 --- a/client/license.js +++ b/client/license.js @@ -1,4 +1,4 @@ -// Generated, do not edit. sha256:e2056779c9285b8b0c7aa5d702fc44b067694e358ed2b791b9b58447551ece12 +// Generated, do not edit. sha256:1df2c5fd2e650856e801961b8f9b2f63fd3868b6f612cd0c41498384a12aea9b import { licenseStatus } from './data/license-status.js'; import { getPref, setPref } from './data/preferences.js'; import { t } from './i18n/index.js'; @@ -24,7 +24,7 @@ export function limitFor(kind) { export function counterText(kind, current) { const max = limitFor(kind); - return max === null ? null : t('license.counter', { n: current, max }); + return max === null ? null : t('license.counter', { naam: t(`license.name_${kind}`), n: current, max }); } const PREF_SCOPE = 'license'; diff --git a/client/pages/accounts.js b/client/pages/accounts.js index 91cbcaa..2d451d5 100644 --- a/client/pages/accounts.js +++ b/client/pages/accounts.js @@ -1,4 +1,4 @@ -// Generated, do not edit. sha256:51ab737fb6604c8493f238a8893184408226a1a90ddd465fcd92bf0040b5a50e +// Generated, do not edit. sha256:9b4688b90b1d72ebe6c49262c3d23557d132d9066d8379a911157bbf3dae8ae8 import { mergeCheck, mergeAccounts } from '../data/accounts-merge.js'; import { listAccounts, listSelectableAccounts, createAccount, updateAccount, deleteAccount } from '../data/accounts.js'; import { listConnections } from '../data/connections.js'; @@ -102,7 +102,7 @@ async function buildTable(el) { tableApi = renderTable(el.querySelector('#tbl'), { columns, rows: rijen, ...(limitFor('fiatAccounts') !== null - ? { toolbarNote: () => `${t('license.name_fiatAccounts')}: ${counterText('fiatAccounts', bankCount)}` } : {}), + ? { toolbarNote: () => counterText('fiatAccounts', bankCount) } : {}), ...(await tableViewCfg('accounts', { key: 'name', dir: 'asc' })), emptyText: t('accounts.none'), noneLabel: t('table.none'), @@ -317,6 +317,8 @@ async function mountSources(host, accountId, allowed) { }; let api = null; const reloadSrc = async () => api.refresh(await listWalletSources(accountId)); + const rows = await listWalletSources(accountId); + const nodeColumn = !locked('lightning') || rows.some(r => r.connectionId != null); api = renderTable(host.querySelector('[data-src-table]'), { title: t('accounts.sources'), @@ -348,8 +350,8 @@ async function mountSources(host, accountId, allowed) { { key: 'scriptType', label: t('accounts.colScriptType'), render: s => s.kind === 'xpub' ? t('accounts.script_' + s.scriptType) : '-', edit: { type: 'select', options: scriptTypeOpts, visible: v => v.kind === 'xpub' } }, - ], - rows: await listWalletSources(accountId), + ].filter(c => nodeColumn || c.key !== 'connectionId'), + rows, ...(await tableViewCfg('accounts.sources', { key: 'name', dir: 'asc' })), emptyText: t('accounts.noSources'), newLabel: t('table.new'), diff --git a/client/pages/assets.js b/client/pages/assets.js index f3acd9d..f65f054 100644 --- a/client/pages/assets.js +++ b/client/pages/assets.js @@ -1,4 +1,4 @@ -// Generated, do not edit. sha256:7de3dc53eab20f070a6ac8c7162fee1bd0695f724c6a13e314a994181782906e +// Generated, do not edit. sha256:26e9a2b26a7c0fa9e0b6979080609ba55baa351ce8440240a006def072475893 import { listAssets, listBookableAssets, createAsset, updateAsset, deleteAsset } from '../data/assets.js'; import { hasMetalAssets } from '../data/metals.js'; import { tableViewCfg } from '../data/preferences.js'; @@ -132,7 +132,14 @@ async function buildTable(el) { }; } - const rijen = await listAssets(); + const nieuw = new Set(); + const zichtbaar = async () => { + const b = await listBookableAssets(); + if (!nieuw.size) return b; + const ids = new Set(b.map(a => a.id)); + return [...b, ...(await listAssets()).filter(a => nieuw.has(a.id) && !ids.has(a.id))]; + }; + const rijen = await zichtbaar(); const bookable = async () => (await listBookableAssets()).filter(r => r.symbol !== baseCurrency()).length; let used = await bookable(); tableApi = renderTable(el.querySelector('#tbl'), { @@ -145,11 +152,11 @@ async function buildTable(el) { newLabel: t('table.new'), newDefaults: { kind: 'crypto', decimals: '', displayUnit: 'unit', taxCategory: '', taxScope: '', balanceCapture: '' }, saveLabel: t('assets.save'), cancelLabel: t('assets.cancel'), - onCreate: async v => { const id = await createAsset(toPayload(v)); used = await bookable(); tableApi.refresh(await listAssets(), { locate: id }); }, - onSave: async (id, v) => { await updateAsset(id, toPayload(v)); tableApi.refresh(await listAssets()); }, + onCreate: async v => { const id = await createAsset(toPayload(v)); nieuw.add(id); used = await bookable(); tableApi.refresh(await zichtbaar(), { locate: id }); }, + onSave: async (id, v) => { await updateAsset(id, toPayload(v)); tableApi.refresh(await zichtbaar()); }, onDelete: async id => { if (!(await dialog.confirm({ message: t('assets.confirmDelete'), danger: true }))) return; - try { await deleteAsset(id); used = await bookable(); tableApi.refresh(await listAssets()); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } + try { await deleteAsset(id); used = await bookable(); tableApi.refresh(await zichtbaar()); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } }, }); tableApi.focusRow(takeRowFocus('assets')); diff --git a/client/pages/tracking.js b/client/pages/tracking.js index de58d17..975b684 100644 --- a/client/pages/tracking.js +++ b/client/pages/tracking.js @@ -1,4 +1,4 @@ -// Generated, do not edit. sha256:fcc1131e05f7794fbe4f303c8c1237f96201e5ee9ed17491fd18d55b64828d40 +// Generated, do not edit. sha256:bcb22b0408193ecc7c34d5e55a86c15a89be89fdd2d96b8d21f03771f4179be1 import { localDate } from '../../shared/time.js'; import { listAccounts } from '../data/accounts.js'; import { listLabels, createLabel } from '../data/labels.js'; @@ -553,18 +553,18 @@ async function scanAll(el) { const r = await scanLightningSource(s); ln += r.count; if (r.channelTxids.length) kanalen.push([s.accountId, r.channelTxids]); - } catch (err) { console.error('[lightning-scan]', err); error = true; } + } catch (err) { console.error('[lightning-scan]', err); error = err.message || true; } continue; } if (!isOnchainSource(s)) { geslagen++; continue; } try { const r = await scanOne(el, s, g.id); count += r.count; failed += r.failed; voorstellen.set(s.accountId, r.proposals || 0); - } catch (err) { console.error('[onchain-scan-all]', err); error = true; } + } catch (err) { console.error('[onchain-scan-all]', err); error = err.message || true; } } const skipped = geslagen && !lnBronnen && !count && !failed; if (error) nMislukt++; else if (!skipped) nWallets++; - results[g.id] = error ? { error: true } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen }); + results[g.id] = error ? { error } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen }); } if (Object.values(results).some(r => r.lnBronnen)) await syncAddressLinks().catch(err => console.error('[lightning-links]', err)); for (const [accountId, txids] of kanalen) { @@ -582,7 +582,8 @@ async function scanAll(el) { zetStatus(scanSummary(nWallets, nVoorstellen, nMislukt)); for (const id in results) { const r = results[id]; - if (r.error) setLogRow(el, id, 't-neg', t('tracking.scanError')); + if (r.error) setLogRow(el, id, 't-neg', typeof r.error === 'string' + ? t('tracking.scanErrorWhy', { reden: r.error }) : t('tracking.scanError')); else if (r.skipped) setLogRow(el, id, 't-muted', t('tracking.scanNotOnchain')); else setLogRow(el, id, r.failed ? 't-neg' : 't-pos', scanDoneText(r)); } diff --git a/client/ui/lock.js b/client/ui/lock.js index f88ed29..33757d6 100644 --- a/client/ui/lock.js +++ b/client/ui/lock.js @@ -1,9 +1,9 @@ -// Generated, do not edit. sha256:4bf6f8ef0a0bf339d290a677844034b6b894f341f060833980b2dc5ccb489b09 +// Generated, do not edit. sha256:d2bc926ea0bee8d8465cb325428dc8ec0f61a809df8e09696d2ce30fb50111c2 import { esc } from './escape.js'; import { SHIELD } from './icons.js'; import { t } from '../i18n/index.js'; import { TARGETS, targetHash } from '../../shared/config.js'; -import { limitFor } from '../license.js'; +import { limitFor, counterText } from '../license.js'; export function lockIcon(title = t('license.lockedTitle')) { return `${SHIELD}`; @@ -31,7 +31,7 @@ export function capacityCfg(kind, used) { const max = limitFor(kind); if (max === null) return {}; return { - toolbarNote: () => t('license.counter', { n: used(), max }), + toolbarNote: () => counterText(kind, used()), createHidden: () => used() >= max, }; } diff --git a/client/ui/splash.js b/client/ui/splash.js index 005b895..3a37cf2 100644 --- a/client/ui/splash.js +++ b/client/ui/splash.js @@ -1,4 +1,4 @@ -// Generated, do not edit. sha256:a433ae61cf1d51dd5a396f13922a2aaa329bfea96e00b35d9456a671a84c5e04 +// Generated, do not edit. sha256:45c5f1058c49379b8b6b1e8f53d554bf4f5826219c9349649a60020eb36c8064 import { CONFIG } from '../../shared/config.js'; import { t } from '../i18n/index.js'; @@ -24,10 +24,10 @@ let typeTimer = 0; const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; -function fadeOut(splash) { +function fadeOut(splash, after = () => {}) { splash.style.transitionDuration = `${CONFIG.SPLASH.FADE_MS}ms`; splash.classList.add('is-done'); - setTimeout(() => reset(splash), CONFIG.SPLASH.FADE_MS + 50); + setTimeout(() => { reset(splash); after(); }, CONFIG.SPLASH.FADE_MS + 50); } function reset(splash) { @@ -41,9 +41,9 @@ function reset(splash) { export function finishSplash() { const splash = document.getElementById('splash'); - if (!splash || document.documentElement.dataset.splash !== 'on') return; + if (!splash || document.documentElement.dataset.splash !== 'on') return Promise.resolve(); const wait = Math.max(0, CONFIG.SPLASH.MIN_MS - (performance.now() - bootT0)); - setTimeout(() => fadeOut(splash), wait); + return new Promise(resolve => setTimeout(() => fadeOut(splash, resolve), wait)); } function showManualSplash() { diff --git a/package-lock.json b/package-lock.json index d89e4e1..6d29bca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "assetapp", - "version": "0.2.0", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "assetapp", - "version": "0.2.0", + "version": "0.3.2", "dependencies": { "@noble/curves": "2.4.0" }, diff --git a/package.json b/package.json index 338db86..12ecc70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assetapp", - "version": "0.2.0", + "version": "0.3.2", "private": true, "type": "module", "scripts": { diff --git a/server/market-service.mjs b/server/market-service.mjs index ddbd063..283dc32 100644 --- a/server/market-service.mjs +++ b/server/market-service.mjs @@ -1,4 +1,4 @@ -// Generated, do not edit. sha256:31a0448818ec43a013271278ccfca034b12a6cd78e023ce301c87ce6d267b944 +// Generated, do not edit. sha256:30f785c6890a03397971aad68496979bd9657deab8cb4a7a61fd0569a7cf87a8 import { randomUUID, createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; @@ -342,18 +342,23 @@ export function createMarketClient({ const { json, requestId } = await exchange('/v1/series', { instrument, quote, kind: 'history', from: cursor, to }); rid = requestId; const cols = Array.isArray(json.columns) ? json.columns : []; - const [ti, pi, ri] = ['t', 'price', 'resolution'].map(c => cols.indexOf(c)); + const [ti, pi, ri, si] = ['t', 'price', 'resolution', 'source'].map(c => cols.indexOf(c)); if (ti < 0 || pi < 0 || ri < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) { fail('marketService.badAnswer', { url: (await config()).url }, 'The history in the answer has no time, price, resolution or currency.'); } if (meta && json.currency !== meta.currency) { fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.'); } - meta ??= { instrument, currency: json.currency, source: json.source || null }; + meta ??= { instrument, currency: json.currency, source: json.source || null, sourceLabels: {} }; + if (json.sourceLabels && typeof json.sourceLabels === 'object') { + for (const [key, label] of Object.entries(json.sourceLabels)) if (typeof label === 'string' && label) meta.sourceLabels[key] = label; + } for (const r of json.rows) { const t = Number(r?.[ti]); const p = Number(r?.[pi]); - if (Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0) points.push([t, p, r[ri]]); + if (!(Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0)) continue; + const src = si >= 0 && typeof r[si] === 'string' && r[si] ? r[si] : null; + points.push(src ? [t, p, r[ri], src] : [t, p, r[ri]]); } if (json.next == null || !(Number(json.next) > cursor)) break; cursor = Number(json.next); diff --git a/tests/server/auth.test.mjs b/tests/server/auth.test.mjs new file mode 100644 index 0000000..0893bbb --- /dev/null +++ b/tests/server/auth.test.mjs @@ -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 = /]*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'), '/'); + }); +}); diff --git a/tests/server/market-service.test.mjs b/tests/server/market-service.test.mjs index 3250dc8..8697382 100644 --- a/tests/server/market-service.test.mjs +++ b/tests/server/market-service.test.mjs @@ -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'); diff --git a/tests/ui/license-notice.test.mjs b/tests/ui/license-notice.test.mjs index 4583555..a03b6cc 100644 --- a/tests/ui/license-notice.test.mjs +++ b/tests/ui/license-notice.test.mjs @@ -1,4 +1,4 @@ -// Generated, do not edit. sha256:6129a8bbffc98b951f97ab835b37848de841b315ab86079ee5c45996f7fe3fe5 +// Generated, do not edit. sha256:48e4432f919c88e3f0adaad52e46a88287459c06124c6a46c43d717bfbc8d8d8 import { describe, it, beforeEach, mock } from 'node:test'; import assert from 'node:assert/strict'; @@ -27,8 +27,10 @@ mock.module('../../client/data/_rpc.js', { }, }); -const { loadLicense, licenseFingerprint, licenseChangedSinceSeen, markLicenseSeen } = +const { loadLicense, licenseFingerprint, licenseChangedSinceSeen, markLicenseSeen, counterText } = await import('../../client/license.js'); +const { setLocale } = await import('../../client/i18n/index.js'); +const { CONFIG } = await import('../../shared/config.js'); const GRATIS = { level: 'free', why: 'none', enforced: true, version: '1.0.0', installed: null, @@ -94,3 +96,24 @@ describe('is er iets te melden', () => { assert.equal(await licenseChangedSinceSeen(), false); }); }); + +describe('de teller naast de plus', () => { + it('noemt wat hij telt, voor elke grens van de gratis editie en in elke taal', async () => { + const max = Object.fromEntries(Object.keys(CONFIG.LICENSE.FREE.MAX).map(k => [k, 1])); + stand = { ...GRATIS, max }; + await loadLicense(); + try { + for (const taal of ['nl', 'en']) { + setLocale(taal); + for (const soort of Object.keys(max)) assert.match(counterText(soort, 0), /^\S.*: 0 \S+ 1$/, `${taal}, ${soort}`); + } + } finally { setLocale('nl'); } + assert.equal(counterText('assets', 1), 'Assets naast valuta in de gratis editie: 1 van 1'); + }); + + it('valt weg zonder grens', async () => { + stand = BETAALD; + await loadLicense(); + assert.equal(counterText('assets', 1), null); + }); +});