oxedyne/fe2o3/fe2o3_steel/tests/rig/console_rig.mjs
14.7 KiB, 17 runs
created by r1870400018:14628, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* Drives the site console end to end against a real Steel. |
| 2 | * |
| 3 | * The console is gated on a *member* session, not the operator's, so this must |
| 4 | * do what a member's browser does: get an anonymous session, sign in over the |
| 5 | * WebSocket, and carry that one session's cookie into the console's HTTP pages. |
| 6 | * |
| 7 | * The WebSocket handshake is done by hand over the https module, rather than |
| 8 | * through a library, for one reason: the session cookie the console reads is the |
| 9 | * one issued on the upgrade, and only a hand-rolled upgrade lets this read the |
| 10 | * Set-Cookie off the 101 and then reuse it. A browser does this for free; here it |
| 11 | * is explicit. |
| 12 | * |
| 13 | * Env: RIG_PORT (default 9443), RIG_PASS (the member-admin's passphrase). |
| 14 | */ |
| 15 | |
| 16 | import https from 'node:https'; |
| 17 | import crypto from 'node:crypto'; |
| 18 | |
| 19 | const PORT = process.env.RIG_PORT || '9443'; |
| 20 | const PASS = process.env.RIG_PASS || 'rig member admin passphrase not a secret'; |
| 21 | // The vhost answers to 'localhost'; a request with any other Host is a 421. curl |
| 22 | // sends Host from the URL, so it never hit this -- a hand-built request must set |
| 23 | // it deliberately. |
| 24 | const HOST = 'localhost'; |
| 25 | |
| 26 | const norm = s => s.trim().split(/\s+/).join(' '); |
| 27 | const USER = crypto.createHash('sha256').update(norm(PASS)).digest('hex'); |
| 28 | |
| 29 | let pass = 0, fail = 0; |
| 30 | const ok = m => { pass++; console.log(` PASS ${m}`); }; |
| 31 | const no = (m, d) => { fail++; console.log(` FAIL ${m}${d ? ' -- ' + d : ''}`); }; |
| 32 | const check = (m, got, want) => got === want ? ok(m) : no(m, `expected '${want}', got '${got}'`); |
| 33 | const has = (m, hay, needle) => hay.includes(needle) ? ok(m) : no(m, `missing '${needle}'`); |
| 34 | const hasnt = (m, hay, needle) => hay.includes(needle) ? no(m, `contained '${needle}'`) : ok(m); |
| 35 | |
| 36 | /* --- a plain HTTP call, self-signed cert allowed, cookie carried by hand --- */ |
| 37 | function call(method, path, { cookie, body, form, headers: extra } = {}) { |
| 38 | return new Promise((resolve, reject) => { |
| 39 | const headers = Object.assign({}, extra); |
| 40 | let payload = null; |
| 41 | if (cookie) headers['Cookie'] = cookie; |
| 42 | if (form) { |
| 43 | payload = Object.entries(form) |
| 44 | .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&'); |
| 45 | headers['Content-Type'] = 'application/x-www-form-urlencoded'; |
| 46 | headers['Content-Length'] = Buffer.byteLength(payload); |
| 47 | } else if (body != null) { |
| 48 | payload = body; |
| 49 | headers['Content-Length'] = Buffer.byteLength(payload); |
| 50 | } |
| 51 | const req = https.request( |
| 52 | { host: HOST, port: PORT, path, method, headers, rejectUnauthorized: false }, |
| 53 | res => { |
| 54 | let data = ''; |
| 55 | res.on('data', d => data += d); |
| 56 | res.on('end', () => resolve({ |
| 57 | status: res.statusCode, |
| 58 | headers: res.headers, |
| 59 | body: data, |
| 60 | })); |
| 61 | }); |
| 62 | req.on('error', reject); |
| 63 | if (payload != null) req.write(payload); |
| 64 | req.end(); |
| 65 | }); |
| 66 | } |
| 67 | |
| 68 | /* --- the WebSocket upgrade, by hand, carrying the session cookie so login binds |
| 69 | the sid this side already knows --- */ |
| 70 | function upgrade(cookie) { |
| 71 | return new Promise((resolve, reject) => { |
| 72 | const key = crypto.randomBytes(16).toString('base64'); |
| 73 | const headers = { |
| 74 | 'Connection': 'Upgrade', |
| 75 | 'Upgrade': 'websocket', |
| 76 | 'Sec-WebSocket-Version': '13', |
| 77 | 'Sec-WebSocket-Key': key, |
| 78 | }; |
| 79 | if (cookie) headers['Cookie'] = cookie; |
| 80 | const req = https.request({ |
| 81 | host: HOST, port: PORT, path: '/', method: 'GET', rejectUnauthorized: false, headers, |
| 82 | }); |
| 83 | req.on('upgrade', (_res, socket) => resolve(socket)); |
| 84 | // A non-upgrade response means the handshake did not happen (a 421 on a |
| 85 | // bad Host, say). Reject loudly rather than let the promise hang, which |
| 86 | // would empty the event loop and exit 0 as though nothing was wrong. |
| 87 | req.on('response', res => reject(new Error(`upgrade got ${res.statusCode}, not 101`))); |
| 88 | req.on('error', reject); |
| 89 | req.end(); |
| 90 | }); |
| 91 | } |
| 92 | |
| 93 | /* --- client WS text frame, masked as the protocol requires of a client --- */ |
| 94 | function frame(text) { |
| 95 | const payload = Buffer.from(text, 'utf8'); |
| 96 | const len = payload.length; // our commands are short; no extended length needed |
| 97 | const mask = crypto.randomBytes(4); |
| 98 | const head = Buffer.from([0x81, 0x80 | len]); |
| 99 | const masked = Buffer.alloc(len); |
| 100 | for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i % 4]; |
| 101 | return Buffer.concat([head, mask, masked]); |
| 102 | } |
| 103 | |
| 104 | /* --- read one server text frame (server frames are not masked) --- */ |
| 105 | function readFrame(socket) { |
| 106 | return new Promise((resolve, reject) => { |
| 107 | let buf = Buffer.alloc(0); |
| 108 | const onData = d => { |
| 109 | buf = Buffer.concat([buf, d]); |
| 110 | if (buf.length < 2) return; |
| 111 | let len = buf[1] & 0x7f; |
| 112 | let off = 2; |
| 113 | if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4; } |
| 114 | if (buf.length < off + len) return; |
| 115 | socket.removeListener('data', onData); |
| 116 | resolve(buf.slice(off, off + len).toString('utf8')); |
| 117 | }; |
| 118 | socket.on('data', onData); |
| 119 | socket.on('error', reject); |
| 120 | setTimeout(() => { socket.removeListener('data', onData); reject(new Error('ws read timeout')); }, 8000); |
| 121 | }); |
| 122 | } |
| 123 | |
| 124 | async function send(socket, text) { |
| 125 | socket.write(frame(text)); |
| 126 | return readFrame(socket); |
| 127 | } |
| 128 | |
| 129 | /* --- pull the csrf token out of an edit form --- */ |
| 130 | function csrfOf(html) { |
| 131 | const m = html.match(/name="csrf" value="([0-9a-f]+)"/); |
| 132 | return m ? m[1] : null; |
| 133 | } |
| 134 | |
| 135 | async function main() { |
| 136 | console.log(`member username ${USER.slice(0, 12)}…`); |
| 137 | |
| 138 | console.log('\n== the console is closed to the anonymous =='); |
| 139 | let r = await call('GET', '/manage/status'); |
| 140 | has('status answers the anonymous', r.body, '"admin":false'); |
| 141 | r = await call('GET', '/manage'); |
| 142 | // Not a redirect: the console answers an anonymous visitor with its own themed |
| 143 | // passphrase login, in the site's skin. What matters is that it is the login and |
| 144 | // not the console. |
| 145 | check('the console shows the anonymous a login', r.status, 200); |
| 146 | has('and it is the login, not the console', r.body, 'name="passphrase"'); |
| 147 | r = await call('POST', '/manage/save', { form: { slug: 'x', source: 'y', csrf: 'z' } }); |
| 148 | check('an anonymous write is turned away', r.status, 303); |
| 149 | |
| 150 | console.log('\n== sign in as a member over the websocket =='); |
| 151 | // The console reads a member's session cookie over HTTP, so the sid must be |
| 152 | // one this side knows. It is issued on a normal HTTP request, not the upgrade, |
| 153 | // so: get it first, then carry it onto the upgrade so login binds it. |
| 154 | const anon = await call('GET', '/'); |
| 155 | let cookie = null; |
| 156 | for (const c of (anon.headers['set-cookie'] || [])) { |
| 157 | const m = c.match(/session_id=([^;]+)/); |
| 158 | if (m) cookie = 'session_id=' + m[1]; |
| 159 | } |
| 160 | if (!cookie) { no('a normal request issued a session cookie'); finish(); return; } |
| 161 | ok('a normal request issued a session cookie'); |
| 162 | const socket = await upgrade(cookie); |
| 163 | let reply = await send(socket, `register "${USER}" "${PASS}"`); |
| 164 | ok(`register replied (${reply.split('"')[0].trim() || reply.slice(0, 12)})`); |
| 165 | reply = await send(socket, `login "${USER}" "${PASS}"`); |
| 166 | // The authoritative proof of login is the status check below (the console reads |
| 167 | // the same session); the WS reply only needs to not be a refusal. |
| 168 | if (reply.startsWith('error')) no('login was refused', reply); else ok('login is not refused'); |
| 169 | socket.end(); |
| 170 | |
| 171 | console.log('\n== a signed-in member who is not on the list learns their id =='); |
| 172 | // The bootstrap: a member who is not an admin is shown their own id and told |
| 173 | // to ask for it, rather than sent silently home. A second account, on no list. |
| 174 | { |
| 175 | const other = 'rig second member not an admin'; |
| 176 | const otherUser = crypto.createHash('sha256').update(norm(other)).digest('hex'); |
| 177 | const a2 = await call('GET', '/'); |
| 178 | let c2 = null; |
| 179 | for (const c of (a2.headers['set-cookie'] || [])) { |
| 180 | const m = c.match(/session_id=([^;]+)/); |
| 181 | if (m) c2 = 'session_id=' + m[1]; |
| 182 | } |
| 183 | const s2 = await upgrade(c2); |
| 184 | await send(s2, `register "${otherUser}" "${other}"`); |
| 185 | await send(s2, `login "${otherUser}" "${other}"`); |
| 186 | s2.end(); |
| 187 | const r2 = await call('GET', '/manage', { cookie: c2 }); |
| 188 | check('a non-admin member is refused', r2.status, 403); |
| 189 | has('but is shown their own id', r2.body, otherUser); |
| 190 | has('and told what to ask for', r2.body, 'give an existing administrator this id'); |
| 191 | hasnt('status does not call them an admin', (await call('GET', '/manage/status', { cookie: c2 })).body, '"admin":true'); |
| 192 | } |
| 193 | |
| 194 | console.log('\n== now a member who is on the list is an admin =='); |
| 195 | r = await call('GET', '/manage/status', { cookie }); |
| 196 | has('status now says admin', r.body, '"admin":true'); |
| 197 | r = await call('GET', '/manage', { cookie }); |
| 198 | check('the console serves its page', r.status, 200); |
| 199 | has('the page is the console', r.body, 'Posts'); |
| 200 | has('in the site’s own chrome', r.body, 'manage'); |
| 201 | |
| 202 | console.log('\n== the app-facing JSON endpoints =='); |
| 203 | // The Manage tab renders from these, and writes with the token status hands it. |
| 204 | r = await call('GET', '/manage/status', { cookie }); |
| 205 | has('status gives an admin the csrf token', r.body, '"csrf"'); |
| 206 | const statusCsrf = (r.body.match(/"csrf":"([0-9a-f]+)"/) || [])[1]; |
| 207 | if (statusCsrf) ok('the token is a sha3 hex'); else no('no token in status'); |
| 208 | r = await call('GET', '/manage/list.json', { cookie }); |
| 209 | has('list.json returns a posts array', r.body, '"posts"'); |
| 210 | // A JSON write, as the app makes it: Accept application/json, token from status. |
| 211 | r = await call('POST', '/manage/save', { |
| 212 | cookie, headers: { Accept: 'application/json' }, |
| 213 | form: { slug: 'json-made', kind: 'note', state: 'draft', source: '# Via JSON\n\nx.', csrf: statusCsrf }, |
| 214 | }); |
| 215 | check('a json save answers 200, not a redirect', r.status, 200); |
| 216 | has('and says ok', r.body, '"ok":true'); |
| 217 | r = await call('GET', '/manage/post.json?slug=json-made', { cookie }); |
| 218 | has('post.json returns the source to edit', r.body, 'Via JSON'); |
| 219 | has('and it is a draft', r.body, '"state": "draft"'); |
| 220 | // A json save with a bad token is a json error, not a redirect. |
| 221 | r = await call('POST', '/manage/save', { |
| 222 | cookie, headers: { Accept: 'application/json' }, |
| 223 | form: { slug: 'json-made', source: 'x', csrf: 'bad' }, |
| 224 | }); |
| 225 | check('a bad-token json write is refused as json', r.status, 403); |
| 226 | has('with an error the app can read', r.body, 'error'); |
| 227 | await call('POST', '/manage/delete', { |
| 228 | cookie, headers: { Accept: 'application/json' }, form: { slug: 'json-made', csrf: statusCsrf } }); |
| 229 | |
| 230 | console.log('\n== write a post through the console =='); |
| 231 | r = await call('GET', '/manage/edit', { cookie }); |
| 232 | const csrf = csrfOf(r.body); |
| 233 | if (!csrf) { no('the editor carried a csrf token'); } else { ok('the editor carried a csrf token'); } |
| 234 | |
| 235 | // A save without the token is refused; with it, it goes through. |
| 236 | r = await call('POST', '/manage/save', { cookie, form: { |
| 237 | slug: 'console-made', date: '2026-07-20 09:15', kind: 'essay', state: 'live', |
| 238 | source: '# Made in the console\n\nWords, and a [link](https://example.com).', csrf: 'wrong', |
| 239 | }}); |
| 240 | check('a save with a bad token is refused (redirect, not written)', r.status, 303); |
| 241 | r = await call('GET', '/posts/console-made'); |
| 242 | check('and nothing was written', r.status, 404); |
| 243 | |
| 244 | r = await call('POST', '/manage/save', { cookie, form: { |
| 245 | slug: 'console-made', date: '2026-07-20 09:15', kind: 'essay', state: 'live', |
| 246 | source: '# Made in the console\n\nWords, and a [link](https://example.com).', csrf, |
| 247 | }}); |
| 248 | check('a save with the token redirects back', r.status, 303); |
| 249 | |
| 250 | console.log('\n== the post is live, and is what was written =='); |
| 251 | r = await call('GET', '/posts/console-made'); |
| 252 | check('the post is served to a reader', r.status, 200); |
| 253 | has('with its prose', r.body, 'Made in the console'); |
| 254 | has('and its Open Graph card', r.body, 'og:title'); |
| 255 | r = await call('GET', '/posts/index.json'); |
| 256 | has('the json says it is an essay', r.body, '"kind": "essay"'); |
| 257 | has('and dates it to the minute', r.body, '2026-07-20T09:15'); |
| 258 | r = await call('GET', '/posts/feed.xml'); |
| 259 | has('the feed dates it to the minute, not midnight', r.body, '2026-07-20T09:15:00Z'); |
| 260 | |
| 261 | console.log('\n== a Djot post names a box Markdown cannot =='); |
| 262 | // The whole reason Djot exists here: `:::` becomes a div, `{.class}` a span. |
| 263 | r = await call('POST', '/manage/save', { cookie, headers: { Accept: 'application/json' }, form: { |
| 264 | slug: 'djot-made', kind: 'note', state: 'live', markup: 'djot', csrf, |
| 265 | source: '# A Djot note\n\n::: warning\nMind the gap.\n:::\n\nA [bright]{.hl} word.', |
| 266 | }}); |
| 267 | check('a Djot save answers ok', r.status, 200); |
| 268 | r = await call('GET', '/posts/djot-made'); |
| 269 | has('the div became a box', r.body, '<div class="warning">'); |
| 270 | has('the span became a styled span', r.body, '<span class="hl">'); |
| 271 | r = await call('GET', '/manage/post.json?slug=djot-made', { cookie }); |
| 272 | has('post.json reports the markup', r.body, '"markup": "djot"'); |
| 273 | // The live-preview endpoint renders unsaved source the same way. |
| 274 | r = await call('POST', '/manage/render', { cookie, headers: { Accept: 'application/json' }, form: { |
| 275 | source: '::: tip\nHello.\n:::', markup: 'djot', csrf, |
| 276 | }}); |
| 277 | check('render answers 200', r.status, 200); |
| 278 | has('and returns the rendered box', r.body, '<div class=\\"tip\\">'); |
| 279 | r = await call('POST', '/manage/render', { cookie, headers: { Accept: 'application/json' }, form: { |
| 280 | source: '*bold* not swapped', markup: 'markdown', csrf, |
| 281 | }}); |
| 282 | has('markdown render keeps its markers', r.body, '<em>bold</em>'); |
| 283 | await call('POST', '/manage/delete', { cookie, headers: { Accept: 'application/json' }, form: { slug: 'djot-made', csrf } }); |
| 284 | |
| 285 | console.log('\n== a slug cannot leave its key =='); |
| 286 | r = await call('POST', '/manage/save', { cookie, form: { |
| 287 | slug: '../../publish/index', source: 'x', csrf, |
| 288 | }}); |
| 289 | check('a slug with a path in it is refused', r.status, 303); |
| 290 | hasnt('and wrote nothing under that key', (await call('GET', '/posts')).body, 'publish/index'); |
| 291 | |
| 292 | console.log('\n== import the directory =='); |
| 293 | r = await call('POST', '/manage/import', { cookie, form: { csrf } }); |
| 294 | check('the import redirects back', r.status, 303); |
| 295 | has('the directory post is now served', (await call('GET', '/posts')).body, 'The first post'); |
| 296 | |
| 297 | console.log('\n== delete a store-only post, and an import does not bring it back =='); |
| 298 | // console-made was written here, not from a file, so a re-import cannot re-add |
| 299 | // it. A directory post that is still a file *is* re-added -- that is import |
| 300 | // doing its job, not a resurrection. The bug the store guards against is a |
| 301 | // deleted key returning from a scan; a store-only post is the way to see it. |
| 302 | r = await call('POST', '/manage/delete', { cookie, form: { slug: 'console-made', csrf } }); |
| 303 | check('the delete redirects back', r.status, 303); |
| 304 | check('the store-only post is gone', (await call('GET', '/posts/console-made')).status, 404); |
| 305 | await call('POST', '/manage/import', { cookie, form: { csrf } }); |
| 306 | const after = await call('GET', '/posts'); |
| 307 | hasnt('the deleted store-only post is not resurrected', after.body, 'Made in the console'); |
| 308 | has('and the directory post is re-added, as import should', after.body, 'The first post'); |
| 309 | |
| 310 | finish(); |
| 311 | } |
| 312 | |
| 313 | function finish() { |
| 314 | console.log(`\n${pass} passed, ${fail} failed`); |
| 315 | process.exit(fail === 0 ? 0 : 1); |
| 316 | } |
| 317 | |
| 318 | main().catch(e => { console.error('rig error:', e.message); process.exit(1); }); |