|
| 1 | +// The money shot: ONE JavaScript Solid Server from npm, EVERY plugin in |
| 2 | +// this repo loaded from pure config, each surface exercised over the wire, |
| 3 | +// pods + WAC intact beside them all. |
| 4 | + |
| 5 | +import { describe, it, after } from 'node:test'; |
| 6 | +import assert from 'node:assert'; |
| 7 | +import fs from 'node:fs'; |
| 8 | +import os from 'node:os'; |
| 9 | +import path from 'node:path'; |
| 10 | +import { fileURLToPath } from 'node:url'; |
| 11 | +import { WebSocket } from 'ws'; |
| 12 | +import { probePort, startJss } from './helpers.js'; |
| 13 | +import { finalizeEvent, generateSecretKey } from './relay/nip01.js'; |
| 14 | + |
| 15 | +const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url))); |
| 16 | +const at = (p) => path.join(__dirname, p); |
| 17 | + |
| 18 | +function openWs(url, headers) { |
| 19 | + const socket = new WebSocket(url, headers ? { headers } : undefined); |
| 20 | + return new Promise((resolve, reject) => { |
| 21 | + const lines = []; |
| 22 | + socket.on('message', (d) => lines.push(String(d))); |
| 23 | + socket.on('open', () => resolve({ socket, lines })); |
| 24 | + socket.on('error', reject); |
| 25 | + }); |
| 26 | +} |
| 27 | + |
| 28 | +function waitFor(lines, predicate, timeoutMs = 5000) { |
| 29 | + return new Promise((resolve, reject) => { |
| 30 | + const started = Date.now(); |
| 31 | + const timer = setInterval(() => { |
| 32 | + const hit = lines.find(predicate); |
| 33 | + if (hit) { clearInterval(timer); resolve(hit); } |
| 34 | + else if (Date.now() - started > timeoutMs) { |
| 35 | + clearInterval(timer); |
| 36 | + reject(new Error(`timeout; lines: ${JSON.stringify(lines.slice(0, 10))}`)); |
| 37 | + } |
| 38 | + }, 25); |
| 39 | + }); |
| 40 | +} |
| 41 | + |
| 42 | +describe('composition: every plugin on one server', () => { |
| 43 | + let jss; |
| 44 | + let base; |
| 45 | + let wsBase; |
| 46 | + |
| 47 | + after(async () => { if (jss) await jss.close(); }); |
| 48 | + |
| 49 | + it('boots pods + idp + six plugins from config', async () => { |
| 50 | + const port = await probePort(); |
| 51 | + base = `http://127.0.0.1:${port}`; |
| 52 | + wsBase = `ws://127.0.0.1:${port}`; |
| 53 | + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jss-compose-')); |
| 54 | + jss = await startJss({ |
| 55 | + port, |
| 56 | + root, |
| 57 | + idp: true, |
| 58 | + // Explicit ids: the <name>/plugin.js convention makes every basename |
| 59 | + // reduce to 'plugin' — the loader's duplicate-id guard requires ids |
| 60 | + // here (finding: derive from the parent dir for generic basenames). |
| 61 | + plugins: [ |
| 62 | + { id: 'relay', module: at('relay/plugin.js'), prefix: '/relay' }, |
| 63 | + { id: 'webrtc', module: at('webrtc/plugin.js'), prefix: '/webrtc' }, |
| 64 | + { id: 'terminal', module: at('terminal/plugin.js'), prefix: '/terminal', config: { token: 'compose-secret' } }, |
| 65 | + { id: 'tunnel', module: at('tunnel/plugin.js'), prefix: '/tunnel' }, |
| 66 | + { |
| 67 | + id: 'notifications', |
| 68 | + module: at('notifications/plugin.js'), |
| 69 | + prefix: '/.notifications', |
| 70 | + config: { podsRoot: root, baseUrl: base }, |
| 71 | + }, |
| 72 | + { id: 'pay', module: at('pay/plugin.js'), prefix: '/paid', config: { cost: 2, address: 'x' } }, |
| 73 | + ], |
| 74 | + }); |
| 75 | + assert.ok(jss.base); |
| 76 | + }); |
| 77 | + |
| 78 | + it('relay: signed event round-trips', async () => { |
| 79 | + const { socket, lines } = await openWs(`${wsBase}/relay`); |
| 80 | + const event = finalizeEvent({ kind: 1, content: 'compose' }, generateSecretKey()); |
| 81 | + socket.send(JSON.stringify(['EVENT', event])); |
| 82 | + await waitFor(lines, (l) => { |
| 83 | + const m = JSON.parse(l); |
| 84 | + return m[0] === 'OK' && m[1] === event.id && m[2] === true; |
| 85 | + }); |
| 86 | + socket.close(); |
| 87 | + }); |
| 88 | + |
| 89 | + it('webrtc: content-addressed room relays an offer between peers', async () => { |
| 90 | + // Anonymous content-addressed dialect (no credentials needed). |
| 91 | + const room = 'a'.repeat(64); // hex hash "resource" |
| 92 | + const a = await openWs(`${wsBase}/webrtc`); |
| 93 | + const b = await openWs(`${wsBase}/webrtc`); |
| 94 | + a.socket.send(JSON.stringify({ type: 'announce', resource: room, offers: [] })); |
| 95 | + await waitFor(a.lines, (l) => JSON.parse(l).type === 'resource-peers'); |
| 96 | + b.socket.send(JSON.stringify({ |
| 97 | + type: 'announce', resource: room, |
| 98 | + offers: [{ sdp: 'compose-offer', offer_id: 'o1' }], |
| 99 | + })); |
| 100 | + await waitFor(a.lines, (l) => { |
| 101 | + const m = JSON.parse(l); |
| 102 | + return m.type === 'offer' && m.offer_id === 'o1'; |
| 103 | + }); |
| 104 | + a.socket.close(); |
| 105 | + b.socket.close(); |
| 106 | + }); |
| 107 | + |
| 108 | + it('terminal: token-gated shell echoes', async () => { |
| 109 | + const socket = new WebSocket(`${wsBase}/terminal?token=compose-secret`); |
| 110 | + let buf = ''; |
| 111 | + socket.on('message', (d) => { buf += String(d); }); |
| 112 | + await new Promise((resolve, reject) => { |
| 113 | + socket.on('open', resolve); |
| 114 | + socket.on('error', reject); |
| 115 | + }); |
| 116 | + await new Promise((r) => setTimeout(r, 400)); // let the shell spawn |
| 117 | + socket.send('echo compose-ok\n'); |
| 118 | + await new Promise((resolve, reject) => { |
| 119 | + const t = setTimeout(() => reject(new Error(`no echo; got ${JSON.stringify(buf)}`)), 5000); |
| 120 | + const iv = setInterval(() => { |
| 121 | + if (buf.includes('compose-ok')) { clearTimeout(t); clearInterval(iv); resolve(); } |
| 122 | + }, 25); |
| 123 | + }); |
| 124 | + socket.close(); |
| 125 | + }); |
| 126 | + |
| 127 | + it('notifications: pod write fans out to a subscriber', async () => { |
| 128 | + fs.writeFileSync( |
| 129 | + path.join(jss.root, 'note.txt.acl'), |
| 130 | + `@prefix acl: <http://www.w3.org/ns/auth/acl#>. |
| 131 | +@prefix foaf: <http://xmlns.com/foaf/0.1/>. |
| 132 | +<#public> a acl:Authorization; acl:agentClass foaf:Agent; |
| 133 | + acl:accessTo <./note.txt>; acl:mode acl:Read. |
| 134 | +`, |
| 135 | + ); |
| 136 | + const { socket, lines } = await openWs(`${wsBase}/.notifications`); |
| 137 | + await waitFor(lines, (l) => l === 'protocol solid-0.1'); |
| 138 | + socket.send(`sub ${base}/note.txt`); |
| 139 | + await waitFor(lines, (l) => l === `ack ${base}/note.txt`); |
| 140 | + fs.writeFileSync(path.join(jss.root, 'note.txt'), 'hello'); |
| 141 | + await waitFor(lines, (l) => l === `pub ${base}/note.txt`); |
| 142 | + socket.close(); |
| 143 | + }); |
| 144 | + |
| 145 | + it('pay: 402 then paid content', async () => { |
| 146 | + let res = await fetch(`${base}/paid/demo`); |
| 147 | + assert.strictEqual(res.status, 402); |
| 148 | + res = await fetch(`${base}/paid/demo`, { headers: { 'x-payment-proof': 'demo-proof-of-payment' } }); |
| 149 | + assert.strictEqual(res.status, 200); |
| 150 | + }); |
| 151 | + |
| 152 | + it('pods still work beside all of it (idp register + WAC)', async () => { |
| 153 | + let res = await fetch(`${base}/idp/register`, { |
| 154 | + method: 'POST', |
| 155 | + headers: { 'content-type': 'application/json' }, |
| 156 | + body: JSON.stringify({ username: 'composer', password: 'compose-pass', confirmPassword: 'compose-pass' }), |
| 157 | + }); |
| 158 | + assert.ok(res.status < 400, `register: ${res.status}`); |
| 159 | + res = await fetch(`${base}/composer/private/x`, { method: 'PUT', body: 'nope' }); |
| 160 | + assert.ok([401, 403].includes(res.status), `WAC: ${res.status}`); |
| 161 | + }); |
| 162 | +}); |
0 commit comments