Oregami
Repositories/oxedyne/fe2o3

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
16import https from 'node:https';
17import crypto from 'node:crypto';
18
19const PORT = process.env.RIG_PORT || '9443';
20const 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.
24const HOST = 'localhost';
25
26const norm = s => s.trim().split(/\s+/).join(' ');
27const USER = crypto.createHash('sha256').update(norm(PASS)).digest('hex');
28
29let pass = 0, fail = 0;
30const ok = m => { pass++; console.log(` PASS ${m}`); };
31const no = (m, d) => { fail++; console.log(` FAIL ${m}${d ? ' -- ' + d : ''}`); };
32const check = (m, got, want) => got === want ? ok(m) : no(m, `expected '${want}', got '${got}'`);
33const has = (m, hay, needle) => hay.includes(needle) ? ok(m) : no(m, `missing '${needle}'`);
34const 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 --- */
37function 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 --- */
70function 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 --- */
94function 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) --- */
105function 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
124async 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 --- */
130function csrfOf(html) {
131 const m = html.match(/name="csrf" value="([0-9a-f]+)"/);
132 return m ? m[1] : null;
133}
134
135async 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
313function finish() {
314 console.log(`\n${pass} passed, ${fail} failed`);
315 process.exit(fail === 0 ? 0 : 1);
316}
317
318main().catch(e => { console.error('rig error:', e.message); process.exit(1); });