| [4ae254a] | 1 | // De bezorgwachtrij voor een Klonkt die niet altijd aan staat (shaer-xzab).
|
|---|
| 2 | //
|
|---|
| 3 | // Wat hier bewaakt wordt, elk met zijn eigen toets:
|
|---|
| 4 | // .1 een bezorging verloopt op LEEFTIJD (5 dagen), niet na zes pogingen, en
|
|---|
| 5 | // de treden lopen door tot een dag;
|
|---|
| 6 | // .2 offline zijn kost geen pogingen: een beurt die naar meer dan één server
|
|---|
| 7 | // op netwerkniveau faalt, schuift op zonder te tellen en laat niets
|
|---|
| 8 | // verlopen -- maar een SSRF-weigering of één dode server telt gewoon;
|
|---|
| 9 | // .3 één mislukking naar een server schuift al zijn rijen op, en per inbox
|
|---|
| 10 | // gaat de oudste eerst, ook bij het directe versturen.
|
|---|
| 11 | //
|
|---|
| 12 | // Tegen echte HTTP-servers op 127.0.0.1, toegelaten via AP_ALLOW_HOSTS, en twee
|
|---|
| 13 | // poorten waar niets luistert als "dode server".
|
|---|
| 14 | import { test } from 'node:test';
|
|---|
| 15 | import assert from 'node:assert/strict';
|
|---|
| 16 | import http from 'http';
|
|---|
| 17 |
|
|---|
| 18 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 19 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 20 |
|
|---|
| 21 | // Twee inboxen die antwoorden, en wat ze kregen.
|
|---|
| 22 | const ontvangen = []; // { host, type, id }
|
|---|
| 23 | const status = new Map(); // host -> HTTP-status (standaard 202)
|
|---|
| 24 | function inbox() {
|
|---|
| 25 | const s = http.createServer((req, res) => {
|
|---|
| 26 | let body = '';
|
|---|
| 27 | req.on('data', (c) => { body += c; });
|
|---|
| 28 | req.on('end', () => {
|
|---|
| 29 | const host = req.headers.host;
|
|---|
| 30 | try { const a = JSON.parse(body); ontvangen.push({ host, type: a.type, id: a.id }); } catch { /* geen json */ }
|
|---|
| 31 | res.statusCode = status.get(host) || 202; res.end();
|
|---|
| 32 | });
|
|---|
| 33 | });
|
|---|
| 34 | return new Promise((r) => s.listen(0, '127.0.0.1', () => r(s)));
|
|---|
| 35 | }
|
|---|
| 36 | async function dichtePoort() {
|
|---|
| 37 | const s = http.createServer();
|
|---|
| 38 | await new Promise((r) => s.listen(0, '127.0.0.1', r));
|
|---|
| 39 | const p = s.address().port;
|
|---|
| 40 | await new Promise((r) => s.close(r));
|
|---|
| 41 | return p;
|
|---|
| 42 | }
|
|---|
| 43 | const A = await inbox();
|
|---|
| 44 | const B = await inbox();
|
|---|
| 45 | const hostA = `127.0.0.1:${A.address().port}`;
|
|---|
| 46 | const hostB = `127.0.0.1:${B.address().port}`;
|
|---|
| 47 | const dood1 = `127.0.0.1:${await dichtePoort()}`;
|
|---|
| 48 | const dood2 = `127.0.0.1:${await dichtePoort()}`;
|
|---|
| 49 | process.env.AP_ALLOW_HOSTS = [hostA, hostB, dood1, dood2].join(',');
|
|---|
| 50 | test.after(() => { A.close(); B.close(); });
|
|---|
| 51 |
|
|---|
| 52 | const dbMod = await import('../src/config/database.js');
|
|---|
| 53 | const db = dbMod.default;
|
|---|
| 54 | { const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
|
|---|
| 55 | const T = await import('../src/services/ap-transport.js');
|
|---|
| 56 | const stilWarn = (f) => async () => { const w = console.warn; console.warn = () => {}; try { await f(); } finally { console.warn = w; } };
|
|---|
| 57 |
|
|---|
| 58 | const url = (host, pad = '/inbox') => `http://${host}${pad}`;
|
|---|
| 59 | const leeg = () => { db.prepare('DELETE FROM ap_delivery').run(); ontvangen.length = 0; status.clear(); };
|
|---|
| 60 | const rij = (id) => db.prepare('SELECT * FROM ap_delivery WHERE id = ?').get(id);
|
|---|
| 61 | const alle = () => db.prepare('SELECT * FROM ap_delivery ORDER BY id').all();
|
|---|
| 62 | function zet(inboxUrl, activity, { dagenOud = 0, attempts = 0, over = 0 } = {}) {
|
|---|
| 63 | T.enqueueDelivery('kind', inboxUrl, activity);
|
|---|
| 64 | const id = db.prepare('SELECT max(id) id FROM ap_delivery').get().id;
|
|---|
| 65 | db.prepare(`UPDATE ap_delivery SET attempts = ?,
|
|---|
| 66 | created_at = datetime('now', ?), next_at = datetime('now', ?) WHERE id = ?`)
|
|---|
| 67 | .run(attempts, `-${dagenOud * 24 * 60} minutes`, `${over >= 0 ? '+' : ''}${over} minutes`, id);
|
|---|
| 68 | return id;
|
|---|
| 69 | }
|
|---|
| 70 | const minutenTot = (iso) => (Date.parse(String(iso).replace(' ', 'T') + (/Z$/.test(iso) ? '' : 'Z')) - Date.now()) / 60000;
|
|---|
| 71 |
|
|---|
| 72 | // ── .1 Verlopen op leeftijd ──────────────────────────────────────
|
|---|
| 73 |
|
|---|
| 74 | test('de treden lopen door tot een dag, en de laatste herhaalt', () => {
|
|---|
| 75 | assert.deepEqual([0, 1, 2, 3, 4, 5, 6, 7, 30].map(T.deliveryBackoffMinutes), [1, 5, 15, 60, 180, 360, 720, 1440, 1440]);
|
|---|
| 76 | });
|
|---|
| 77 |
|
|---|
| 78 | test('zes pogingen is geen reden meer om op te geven; vijf dagen wel', stilWarn(async () => {
|
|---|
| 79 | leeg();
|
|---|
| 80 | status.set(hostA, 500);
|
|---|
| 81 | const jong = zet(url(hostA, '/jong'), { type: 'Create', id: 'n1' }, { dagenOud: 4, attempts: 6 });
|
|---|
| 82 | await T.processDeliveryQueue();
|
|---|
| 83 | assert.ok(rij(jong), 'vier dagen oud met zes pogingen: blijft staan');
|
|---|
| 84 | assert.equal(rij(jong).attempts, 7);
|
|---|
| 85 | assert.ok(Math.abs(minutenTot(rij(jong).next_at) - 720) < 2, 'en de trede van twaalf uur wordt gebruikt');
|
|---|
| 86 |
|
|---|
| 87 | leeg();
|
|---|
| 88 | status.set(hostA, 500);
|
|---|
| 89 | const oud = zet(url(hostA, '/oud'), { type: 'Create', id: 'n2' }, { dagenOud: 6, attempts: 2 });
|
|---|
| 90 | await T.processDeliveryQueue();
|
|---|
| 91 | assert.equal(rij(oud), undefined, 'zes dagen oud: opgegeven');
|
|---|
| 92 | }));
|
|---|
| 93 |
|
|---|
| 94 | test('de leeftijd leest SQLite-UTC zoals hij is', () => {
|
|---|
| 95 | const nu = Date.parse('2026-10-26T12:00:00Z');
|
|---|
| 96 | assert.equal(T.deliveryExpired('2026-10-21 12:00:01', nu), false);
|
|---|
| 97 | assert.equal(T.deliveryExpired('2026-10-21 12:00:00', nu), true);
|
|---|
| 98 | });
|
|---|
| 99 |
|
|---|
| 100 | // ── .2 Offline kost niets ────────────────────────────────────────
|
|---|
| 101 |
|
|---|
| 102 | test('geen netwerk naar twee servers: opschuiven, niet tellen, niet verlopen', stilWarn(async () => {
|
|---|
| 103 | leeg();
|
|---|
| 104 | const een = zet(url(dood1), { type: 'Create', id: 'n3' }, { attempts: 3, dagenOud: 6 });
|
|---|
| 105 | const twee = zet(url(dood2), { type: 'Create', id: 'n4' }, { attempts: 1 });
|
|---|
| 106 | await T.processDeliveryQueue();
|
|---|
| 107 | assert.ok(rij(een) && rij(twee), 'niets opgegeven, ook niet wat oud was');
|
|---|
| 108 | assert.equal(rij(een).attempts, 3);
|
|---|
| 109 | assert.equal(rij(twee).attempts, 1);
|
|---|
| 110 | assert.ok(Math.abs(minutenTot(rij(een).next_at) - 2) < 1, 'een paar minuten opgeschoven');
|
|---|
| 111 | }));
|
|---|
| 112 |
|
|---|
| 113 | test('één dode server is gewoon een dode server, en telt', stilWarn(async () => {
|
|---|
| 114 | leeg();
|
|---|
| 115 | const id = zet(url(dood1), { type: 'Create', id: 'n5' }, { attempts: 2 });
|
|---|
| 116 | await T.processDeliveryQueue();
|
|---|
| 117 | assert.equal(rij(id).attempts, 3);
|
|---|
| 118 | }));
|
|---|
| 119 |
|
|---|
| 120 | test('een SSRF-weigering is een besluit, geen offline', stilWarn(async () => {
|
|---|
| 121 | leeg();
|
|---|
| 122 | const een = zet('http://127.0.0.2:9/inbox', { type: 'Create', id: 'n6' });
|
|---|
| 123 | const twee = zet('http://127.0.0.3:9/inbox', { type: 'Create', id: 'n7' });
|
|---|
| 124 | await T.processDeliveryQueue();
|
|---|
| 125 | assert.equal(rij(een).attempts, 1, 'geteld, ook al antwoordde niemand');
|
|---|
| 126 | assert.equal(rij(twee).attempts, 1);
|
|---|
| 127 | }));
|
|---|
| 128 |
|
|---|
| 129 | // ── .3 Per server en per inbox ───────────────────────────────────
|
|---|
| 130 |
|
|---|
| 131 | test('één mislukking naar een server schuift al zijn rijen op', stilWarn(async () => {
|
|---|
| 132 | leeg();
|
|---|
| 133 | status.set(hostA, 503);
|
|---|
| 134 | const ids = ['/x', '/y', '/z'].map((p, i) => zet(url(hostA, p), { type: 'Create', id: `h${i}` }));
|
|---|
| 135 | const elders = zet(url(hostB), { type: 'Create', id: 'b1' });
|
|---|
| 136 | await T.processDeliveryQueue();
|
|---|
| 137 | assert.equal(ontvangen.filter((o) => o.host === hostA).length, 1, 'één verzoek naar de dode server, niet drie');
|
|---|
| 138 | const eerste = rij(ids[0]);
|
|---|
| 139 | for (const id of ids) assert.ok(minutenTot(rij(id).next_at) >= minutenTot(eerste.next_at) - 0.1, 'allemaal niet eerder dan de mislukte');
|
|---|
| 140 | assert.deepEqual(ids.slice(1).map((id) => rij(id).attempts), [0, 0], 'wat niet geprobeerd is, telt niet');
|
|---|
| 141 | assert.equal(rij(elders), undefined, 'een andere server gaat gewoon door');
|
|---|
| 142 | }));
|
|---|
| 143 |
|
|---|
| 144 | test('per inbox de oudste eerst: geen Delete voor de Create die nog wacht', async () => {
|
|---|
| 145 | leeg();
|
|---|
| 146 | const create = zet(url(hostA), { type: 'Create', id: 'p1' }, { over: 30 }); // wacht nog
|
|---|
| 147 | zet(url(hostA), { type: 'Delete', id: 'p1-del' }); // al aan de beurt
|
|---|
| 148 | await T.processDeliveryQueue();
|
|---|
| 149 | assert.deepEqual(ontvangen, [], 'de Delete wacht op de Create');
|
|---|
| 150 | db.prepare("UPDATE ap_delivery SET next_at = datetime('now', '-1 minutes') WHERE id = ?").run(create);
|
|---|
| 151 | await T.processDeliveryQueue();
|
|---|
| 152 | await T.processDeliveryQueue();
|
|---|
| 153 | assert.deepEqual(ontvangen.map((o) => o.type), ['Create', 'Delete']);
|
|---|
| 154 | assert.deepEqual(alle(), []);
|
|---|
| 155 | });
|
|---|
| 156 |
|
|---|
| 157 | test('direct versturen gaat niet voorbij wat er voor die inbox wacht', async () => {
|
|---|
| 158 | leeg();
|
|---|
| 159 | const { private_pem } = T.getOrCreateKeys('kind');
|
|---|
| 160 | // De Create is AL aan de beurt, alleen nog niet opgepakt: de server zit dus
|
|---|
| 161 | // niet in een backoff, en alleen de volgorde per inbox houdt de Delete tegen.
|
|---|
| 162 | zet(url(hostA), { type: 'Create', id: 'q1' }, { over: -1 });
|
|---|
| 163 | await T.deliverWithRetry('kind', url(hostA), { type: 'Delete', id: 'q1-del' }, 'https://test.example/ap/users/kind#main-key', private_pem);
|
|---|
| 164 | assert.deepEqual(ontvangen, [], 'niet verstuurd');
|
|---|
| 165 | assert.deepEqual(alle().map((r) => JSON.parse(r.body).type), ['Create', 'Delete'], 'maar achteraan in de rij');
|
|---|
| 166 | });
|
|---|
| 167 |
|
|---|
| 168 | test('en niet naar een server die in zijn backoff zit', async () => {
|
|---|
| 169 | leeg();
|
|---|
| 170 | const { private_pem } = T.getOrCreateKeys('kind');
|
|---|
| 171 | zet(url(hostA, '/ander'), { type: 'Create', id: 'r1' }, { over: 30 });
|
|---|
| 172 | await T.deliverWithRetry('kind', url(hostA, '/nog-een'), { type: 'Create', id: 'r2' }, 'https://test.example/ap/users/kind#main-key', private_pem);
|
|---|
| 173 | assert.deepEqual(ontvangen, [], 'geen acht seconden time-out om hetzelfde te horen');
|
|---|
| 174 | assert.equal(alle().length, 2);
|
|---|
| 175 | });
|
|---|
| 176 |
|
|---|
| 177 | test('zonder wachtrij gaat direct versturen gewoon direct', async () => {
|
|---|
| 178 | leeg();
|
|---|
| 179 | const { private_pem } = T.getOrCreateKeys('kind');
|
|---|
| 180 | await T.deliverWithRetry('kind', url(hostB), { type: 'Create', id: 's1' }, 'https://test.example/ap/users/kind#main-key', private_pem);
|
|---|
| 181 | assert.deepEqual(ontvangen.map((o) => o.id), ['s1']);
|
|---|
| 182 | assert.deepEqual(alle(), []);
|
|---|
| 183 | });
|
|---|
| 184 |
|
|---|
| 185 | test('rijen van voor de host-kolom krijgen hun host bij het opstarten', () => {
|
|---|
| 186 | leeg();
|
|---|
| 187 | db.prepare("INSERT INTO ap_delivery (slug, inbox, body) VALUES ('kind', ?, '{}')").run(url(hostB, '/oud'));
|
|---|
| 188 | db.prepare('UPDATE ap_delivery SET host = NULL').run();
|
|---|
| 189 | { const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
|
|---|
| 190 | assert.equal(alle()[0].host, hostB);
|
|---|
| 191 | });
|
|---|