Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/tests/rig/run.sh

23.1 KiB, 32 runs, executable

created by r1870400018:14553, 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#!/usr/bin/env bash
2#
3# Stands a real Steel up in a temporary directory, drives it over HTTPS with a
4# real dashboard session, and tears it down. See README.md.
5#
6# fe2o3_steel/tests/rig/run.sh # run it
7# RIG_PORT=9444 fe2o3_steel/tests/rig/run.sh
8# RIG_KEEP=1 fe2o3_steel/tests/rig/run.sh # leave the directory behind
9#
10# Exits non-zero if any check fails.
11
12set -u
13
14HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15ROOT="$(cd "$HERE/../../.." && pwd)"
16PORT="${RIG_PORT:-9443}"
17PASS='rig-test-passphrase-not-a-secret'
18B="https://localhost:$PORT"
19
20RIG_DIR="$(mktemp -d -t steel-rig-XXXXXX)"
21export RIG_DIR
22J="$RIG_DIR/jar"
23
24cleanup() {
25 # `exec` below makes STEEL_PID the server itself rather than the subshell
26 # that launched it, so this reaches the thing that holds the port. Killing a
27 # subshell leaves its child serving, which is how a test harness ends up
28 # squatting on 9443 long after it claimed to have finished.
29 [ -n "${STEEL_PID:-}" ] && kill "$STEEL_PID" 2>/dev/null
30 [ -n "${HOLD_PID:-}" ] && kill "$HOLD_PID" 2>/dev/null
31 [ -n "${STEEL_PID:-}" ] && wait "$STEEL_PID" 2>/dev/null
32 if [ "${RIG_KEEP:-0}" = "1" ]; then
33 echo "rig left at $RIG_DIR"
34 else
35 rm -rf "$RIG_DIR"
36 fi
37}
38trap cleanup EXIT INT TERM
39
40pass=0; fail=0
41ok() { pass=$((pass+1)); echo " PASS $1"; }
42no() { fail=$((fail+1)); echo " FAIL $1${2:+ -- $2}"; }
43check() { if [ "$2" = "$3" ]; then ok "$1"; else no "$1" "expected '$3', got '$2'"; fi; }
44has() { if echo "$2" | grep -q "$3"; then ok "$1"; else no "$1" "did not contain '$3'"; fi; }
45hasnt() { if echo "$2" | grep -q "$3"; then no "$1" "contained '$3'"; else ok "$1"; fi; }
46
47echo "== building =="
48cargo build --release -p oxedyne_fe2o3_steel --bin steel --manifest-path "$ROOT/Cargo.toml" \
49 2>&1 | grep -E "^error|Finished" | tail -1
50[ -x "$ROOT/target/release/steel" ] || { echo "no binary"; exit 1; }
51
52echo "== laying out $RIG_DIR =="
53mkdir -p "$RIG_DIR/www/public/content/posts" "$RIG_DIR/www/src/styles"
54cp "$ROOT/target/release/steel" "$RIG_DIR/steel"
55printf '# The first post\n\nWritten in a directory, imported into a store.\n' \
56 > "$RIG_DIR/www/public/content/posts/2026-07-01-from-the-dir.md"
57sed -e "s|@PORT@|$PORT|g" "$HERE/config.jdat.in" > "$RIG_DIR/config.jdat"
58
59# Wallet creation wants a terminal, not a pipe. See README.
60echo "== wallet =="
61python3 "$HERE/make_wallet.py" > "$RIG_DIR/wallet.out" 2>&1
62[ -f "$RIG_DIR/wallet.jdat" ] || { echo "no wallet; see $RIG_DIR/wallet.out"; RIG_KEEP=1; exit 1; }
63echo " made"
64
65# `-d` or the first run refuses production mode and exits 0 without saying why.
66# The fifo keeps stdin open: the server keeps a shell beside the listener, and an
67# EOF there ends the process. Dev mode also generates the self-signed cert.
68echo "== starting =="
69mkfifo "$RIG_DIR/ctl"
70sleep 600 > "$RIG_DIR/ctl" &
71HOLD_PID=$!
72( cd "$RIG_DIR" && STEEL_ADMIN_PASS="$PASS" exec ./steel server -d < ctl > server.log 2>&1 ) &
73STEEL_PID=$!
74
75for _ in $(seq 1 30); do
76 sleep 1
77 curl -sk -o /dev/null --max-time 2 "$B/admin/login" && break
78done
79if ! curl -sk -o /dev/null --max-time 2 "$B/admin/login"; then
80 echo "server did not come up; see $RIG_DIR/server.log"
81 RIG_KEEP=1
82 exit 1
83fi
84echo " up on $PORT"
85
86echo
87echo "== /admin and /admin/ both reach the dashboard =="
88# A trailing slash is not an unknown sub-route. Both are the root, both redirect
89# an unauthenticated visitor to the login form rather than 404ing one of them.
90check "/admin redirects to login" \
91 "$(curl -sk -o /dev/null -w '%{http_code}' "$B/admin")" "303"
92loc=$(curl -sk -D - -o /dev/null "$B/admin" | grep -io "location: [^[:space:]]*" | tr -d '\r')
93has "and the login is where it points" "$loc" "/admin/login"
94check "/admin/ with a slash is the same, not a 404" \
95 "$(curl -sk -o /dev/null -w '%{http_code}' "$B/admin/")" "303"
96has "the login form is served" "$(curl -sk "$B/admin/login")" "passphrase"
97
98echo
99echo "== the operator dashboard still works, and is a separate tier =="
100# The operator login is not the site console's login. It stays Path=/admin and
101# SameSite=Strict; the console is reached with a member's Path=/ cookie instead.
102hdrs=$(curl -sk -D - -o /dev/null -X POST -d "passphrase=$PASS" "$B/admin/login")
103has "the operator login sets a session cookie" "$hdrs" "[Ss]et-[Cc]ookie"
104has "scoped to /admin, not the whole site" "$hdrs" "Path=/admin"
105has "and SameSite=Strict" "$hdrs" "SameSite=Strict"
106curl -sk -c $J -o /dev/null -X POST -d "passphrase=$PASS" "$B/admin/login"
107has "the operator reaches the dashboard" "$(curl -sk -b $J "$B/admin")" "Overview"
108hasnt "and content authoring has left the dashboard" "$(curl -sk -b $J "$B/admin")" "/admin/publish"
109
110echo
111echo "== the site console, driven as a member admin over its own login =="
112# The whole point: a member on the site's list manages the site from within it,
113# with a member session, never touching the operator dashboard or the wallet.
114# WebSocket login plus HTTP console -- so a small node driver, not curl.
115RIG_PORT="$PORT" RIG_PASS="rig member admin passphrase not a secret" \
116 node --experimental-websocket "$HERE/console_rig.mjs" 2>&1 | grep -v "ExperimentalWarning\|--trace-warnings"
117console_status=${PIPESTATUS[0]}
118if [ "$console_status" = "0" ]; then ok "the console rig passed"; else no "the console rig failed"; fi
119
120echo
121echo "== the dashboard reads a query =="
122# The path and the query are parsed apart. Reading the query out of the path
123# finds nothing, silently, and the database page did exactly that for months.
124body=$(curl -sk -b $J "$B/admin/database?prefix=publish/index&limit=2")
125has "the prefix box echoes what was asked" "$body" 'id="prefix" name="prefix" value="publish/index"'
126has "the limit box echoes what was asked" "$body" 'value="2"'
127hasnt "and the scan actually filtered" "$body" "publish/post/from-the-dir"
128
129echo
130echo "== the reports page =="
131# Reports read the subscriber store and the send history and aggregate them. A
132# site that has sent nothing must say so rather than draw an empty table, and the
133# page is a read behind the same gate as the rest of the console.
134MJ="$RIG_DIR/mjar"
135curl -sk -c $MJ -o /dev/null -X POST -d "passphrase=$PASS" "$B/manage/login"
136anon=$(curl -sk "$B/manage/reports")
137has "anonymous gets the login, not the numbers" "$anon" 'name="passphrase"'
138# The class names all appear in the stylesheet every console page inlines, so a
139# leak shows as a class being *used*, not merely defined.
140hasnt "and no subscriber figures leak to it" "$anon" 'class="mc-stat-n"'
141body=$(curl -sk -b $MJ "$B/manage/reports")
142has "an admin gets the reports page" "$body" "<h1>Reports</h1>"
143has "it reports on the list" "$body" "The list"
144has "and on the sends" "$body" "Newsletter sends"
145has "an empty list says so" "$body" "Nobody has subscribed yet"
146has "and an unsent newsletter says so" "$body" "No post has been mailed"
147hasnt "rather than drawing an empty table" "$body" 'class="mc-bar-fill"'
148has "the console links to it" "$(curl -sk -b $MJ "$B/manage")" "/manage/reports"
149
150echo
151echo "== read counts =="
152# A read is counted when a post is served to somebody who is neither the author
153# nor an obvious machine. All three of those exclusions can only be proved from
154# outside, by actually fetching the post as each of them in turn.
155BROWSER='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
156body=$(curl -sk -b $MJ "$B/manage/reports")
157has "an unread site says so" "$body" "Nothing has been read yet"
158
159# curl announces itself, and is therefore not a reader. Ten fetches, no count.
160for _ in $(seq 1 10); do curl -sk -o /dev/null "$B/posts/from-the-dir"; done
161body=$(curl -sk -b $MJ "$B/manage/reports")
162has "ten fetches by a machine count for nothing" "$body" "Nothing has been read yet"
163
164# The author, carrying a management session, is not a reader of their own post.
165curl -sk -b $MJ -o /dev/null -A "$BROWSER" "$B/posts/from-the-dir"
166body=$(curl -sk -b $MJ "$B/manage/reports")
167has "nor does the author reading their own post" "$body" "Nothing has been read yet"
168
169# A browser with no management session is a reader, and is counted.
170curl -sk -o /dev/null -A "$BROWSER" "$B/posts/from-the-dir"
171body=$(curl -sk -b $MJ "$B/manage/reports")
172hasnt "a reader is counted" "$body" "Nothing has been read yet"
173has "and the post is named with its tally" "$body" "from-the-dir"
174
175# The index is not a post, so browsing it does not count as reading everything on it.
176before=$(curl -sk -b $MJ "$B/manage/reports")
177curl -sk -o /dev/null -A "$BROWSER" "$B/posts"
178curl -sk -o /dev/null -A "$BROWSER" "$B/posts/feed.xml"
179after=$(curl -sk -b $MJ "$B/manage/reports")
180if [ "$before" = "$after" ]; then ok "the index and the feed are not reads"
181else no "the index or the feed counted as a read"; fi
182
183# The page states what it cannot know, rather than leaving the absence to be read
184# as an oversight.
185has "the page says a read is not a reader" "$after" "a reading, not a reader"
186
187echo
188echo "== comments =="
189# The whole pipeline from outside: a stranger posts, the comment does not appear,
190# the queue holds it, an admin approves, and it appears. Nothing here can be
191# proved from inside the process -- the point is what a reader actually sees.
192POST_URL="$B/posts/from-the-dir"
193# A comment names a post, and a post that exists. Without this the endpoint wrote a
194# record under any name a sender chose -- an unauthenticated write to storage keyed
195# on a string from outside. Measured against a live site: it answered 303 and stored.
196code=$(curl -sk -o /dev/null -w '%{http_code}' -X POST \
197 --data-urlencode "name=Probe" --data-urlencode "body=Against a post that is not there." \
198 --data-urlencode "website=" "$B/posts/no-such-post-at-all/comment")
199check "a comment on a post that does not exist is refused" "$code" "404"
200q=$(curl -sk -b $MJ "$B/manage/comments?state=any")
201hasnt "and nothing was stored for it" "$q" "Against a post that is not there"
202# The console's write token, off a console page the session can already open.
203MCSRF=$(curl -sk -b $MJ "$B/manage/edit?slug=from-the-dir" \
204 | grep -o 'name="csrf" value="[^"]*"' | head -1 | sed 's/.*value="//;s/"//')
205if [ -n "$MCSRF" ]; then ok "the console offers a write token"
206else no "no console write token could be read"; fi
207CHAL=$(curl -sk "$POST_URL" | grep -o 'id="comment-challenge" value="[a-f0-9]*"' | head -1 | sed 's/.*value="//;s/"//')
208if [ -n "$CHAL" ]; then ok "a post offers a comment form with a challenge"
209else no "a post offers no comment challenge"; fi
210
211# A comment with no proof: accepted, and held rather than refused, because a
212# reader without scripting is still a reader.
213curl -sk -o /dev/null -X POST \
214 --data-urlencode "name=Ada" --data-urlencode "email=ada@example.com" \
215 --data-urlencode "body=A first remark from a stranger." \
216 --data-urlencode "challenge=$CHAL" --data-urlencode "nonce=" \
217 --data-urlencode "website=" "$POST_URL/comment"
218page=$(curl -sk "$POST_URL")
219hasnt "an unapproved comment is not shown to a reader" "$page" "A first remark from a stranger"
220q=$(curl -sk -b $MJ "$B/manage/comments")
221has "but it is waiting in the queue" "$q" "A first remark from a stranger"
222has "and the queue says why it is waiting" "$q" "mc-comment-why"
223
224# A real proof, computed here with python's SHA-256 rather than by this program.
225# The server verified with SHA3 once while the browser hashed SHA-256, so no proof
226# ever passed and every comment from a reader with scripting was refused before it
227# was stored -- and the reader was thanked for it. Only an outside digest catches
228# that; a check that hashed the same way the server does would have agreed happily.
229NONCE=$(python3 -c "
230import hashlib,sys
231ch=sys.argv[1]; bits=18; n=0
232while True:
233 d=hashlib.sha256((ch+str(n)).encode()).digest()
234 if int.from_bytes(d,'big') >> (256-bits) == 0: print(n); break
235 n+=1
236" "$CHAL")
237curl -sk -o /dev/null -X POST \
238 --data-urlencode "name=Proven" --data-urlencode "email=proven@example.com" \
239 --data-urlencode "body=A comment carrying a genuine proof of work." \
240 --data-urlencode "challenge=$CHAL" --data-urlencode "nonce=$NONCE" \
241 --data-urlencode "website=" "$POST_URL/comment"
242q=$(curl -sk -b $MJ "$B/manage/comments?state=any")
243has "a comment with a real proof is stored" "$q" "A comment carrying a genuine proof"
244hasnt "and it is not refused for its proof" "$q" "the proof does not meet the width"
245
246# A nonce that does not solve the challenge is refused.
247curl -sk -o /dev/null -X POST \
248 --data-urlencode "name=Liar" --data-urlencode "body=A nonce that solves nothing." \
249 --data-urlencode "challenge=$CHAL" --data-urlencode "nonce=1" \
250 --data-urlencode "website=" "$POST_URL/comment"
251q=$(curl -sk -b $MJ "$B/manage/comments?state=any")
252hasnt "a nonce that solves nothing is refused" "$q" "A nonce that solves nothing"
253
254# The form's script is a file, not an inline block, so a site can run a
255# Content-Security-Policy without unsafe-inline -- the layer that would contain a
256# mistake in the render policy, and the same untrusted prose reaches the console.
257page=$(curl -sk "$POST_URL")
258hasnt "the post page carries no inline script" "$page" "<script>"
259has "it references the form script as a file" "$page" "/posts/comments.js"
260js=$(curl -sk "$B/posts/comments.js")
261has "which is served" "$js" "comment-nonce"
262has "and computes SHA-256, as the server verifies" "$js" "SHA-256"
263check "with a JavaScript content type" \
264 "$(curl -sk -o /dev/null -w '%{content_type}' "$B/posts/comments.js")" \
265 "text/javascript; charset=utf-8"
266
267# The switch: a site opens and closes comments from its console, without a
268# config edit or a restart.
269sw=$(curl -sk -b $MJ "$B/manage/comments")
270has "the console says whether comments are open" "$sw" "Comments are open"
271curl -sk -o /dev/null -b $MJ -X POST -d "csrf=$MCSRF" -d "action=shut" "$B/manage/comments/action"
272page=$(curl -sk "$POST_URL")
273hasnt "a closed site shows no form" "$page" 'id="comment-body"'
274has "and says so" "$page" "Comments are closed on this post"
275code=$(curl -sk -o /dev/null -w '%{http_code}' -X POST --data-urlencode "name=X" \
276 --data-urlencode "body=Sent while comments were closed." \
277 --data-urlencode "website=" "$POST_URL/comment")
278check "a comment sent anyway is refused" "$code" "404"
279q=$(curl -sk -b $MJ "$B/manage/comments?state=any")
280hasnt "and nothing was stored for it" "$q" "Sent while comments were closed"
281curl -sk -o /dev/null -b $MJ -X POST -d "csrf=$MCSRF" -d "action=open" "$B/manage/comments/action"
282has "and opening again brings the form back" "$(curl -sk "$POST_URL")" 'id="comment-body"'
283
284# A reader may preview their prose before posting it, through the same renderer
285# and the same policy the page uses.
286pv=$(curl -sk -X POST --data-urlencode "body=**bold** and [a link](javascript:alert(1))" \
287 "$POST_URL/comment/preview")
288has "a preview renders the markup" "$pv" "<strong>bold</strong>"
289hasnt "and applies the policy to it" "$pv" "javascript:alert"
290
291# A commenter may correct what they just wrote, and only they may.
292CJ="$RIG_DIR/cjar"
293curl -sk -c $CJ -o /dev/null -X POST --data-urlencode "name=Corrector" \
294 --data-urlencode "body=A commnet with a typo in it." \
295 --data-urlencode "challenge=$CHAL" --data-urlencode "website=" "$POST_URL/comment"
296has "posting hands back an edit token" "$(cat $CJ)" "comment_edit"
297EDIT=$(grep comment_edit $CJ | awk '{print $7}')
298ECID=${EDIT%%.*}
299ETOK=${EDIT#*.}
300curl -sk -o /dev/null -X POST --data-urlencode "id=$ECID" --data-urlencode "token=$ETOK" \
301 --data-urlencode "body=A comment with the typo fixed." "$POST_URL/comment/edit"
302q=$(curl -sk -b $MJ "$B/manage/comments?state=any")
303has "the correction is stored" "$q" "A comment with the typo fixed"
304hasnt "and the typo is gone" "$q" "A commnet with a typo"
305
306# Somebody else's token does not work on this comment.
307curl -sk -o /dev/null -X POST --data-urlencode "id=$ECID" \
308 --data-urlencode "token=0000000000000000000000000000000f" \
309 --data-urlencode "body=Edited by somebody who did not write it." "$POST_URL/comment/edit"
310q=$(curl -sk -b $MJ "$B/manage/comments?state=any")
311hasnt "a wrong token changes nothing" "$q" "Edited by somebody who did not write it"
312
313# The honeypot: filled, and nothing is stored.
314curl -sk -o /dev/null -X POST \
315 --data-urlencode "name=Bot" --data-urlencode "body=Buy things at example.com" \
316 --data-urlencode "challenge=$CHAL" --data-urlencode "website=http://spam.example" \
317 "$POST_URL/comment"
318q=$(curl -sk -b $MJ "$B/manage/comments?state=any")
319hasnt "a comment that filled the honeypot is not stored at all" "$q" "Buy things at example.com"
320
321# A proof answering a challenge this site never set is refused.
322curl -sk -o /dev/null -X POST \
323 --data-urlencode "name=Forger" --data-urlencode "body=A forged proof attempt." \
324 --data-urlencode "challenge=0000000000000000" --data-urlencode "nonce=1" \
325 --data-urlencode "website=" "$POST_URL/comment"
326q=$(curl -sk -b $MJ "$B/manage/comments?state=any")
327hasnt "a proof for a challenge the site never set is refused" "$q" "A forged proof attempt"
328
329# A stranger's script does not reach the page, and their words do.
330curl -sk -o /dev/null -X POST \
331 --data-urlencode "name=Mallory" \
332 --data-urlencode "body=Nice. [click](javascript:alert(1)) <script>steal()</script> ![x](https://tracker.example/p.gif)" \
333 --data-urlencode "challenge=$CHAL" --data-urlencode "website=" "$POST_URL/comment"
334# The id belongs to a card, and a card has several forms in it, so the id is taken
335# from the first one *after* the author's name rather than by position in the page.
336cid=$(curl -sk -b $MJ "$B/manage/comments" | tr '\n' ' ' \
337 | grep -o 'Mallory.*' | grep -o 'name="id" value="[a-z0-9]*"' | head -1 \
338 | sed 's/.*value="//;s/"//')
339curl -sk -o /dev/null -b $MJ -X POST -d "csrf=$MCSRF" -d "slug=from-the-dir" -d "id=$cid" \
340 -d "action=approve" "$B/manage/comments/action"
341page=$(curl -sk "$POST_URL")
342has "an approved comment appears to a reader" "$page" "Nice."
343hasnt "and its script destination does not" "$page" "javascript:alert"
344hasnt "nor its script tag" "$page" "<script>steal"
345hasnt "nor its tracking image" "$page" "tracker.example"
346
347# The commenter is now known, so their next comment does not wait.
348curl -sk -o /dev/null -X POST \
349 --data-urlencode "name=Ada" --data-urlencode "email=ada@example.com" \
350 --data-urlencode "body=A second remark, from somebody now known." \
351 --data-urlencode "challenge=$CHAL" --data-urlencode "website=" "$POST_URL/comment"
352q=$(curl -sk -b $MJ "$B/manage/comments?state=any")
353has "a second comment from a known commenter is recorded" "$q" "A second remark"
354
355echo
356echo "== no management surface invites a crawler =="
357# A login page indexes nothing worth having and advertises where the dashboard
358# is. The console had this and the dashboard did not, which is how oxedyne.com's
359# /admin/login came to be indexable. Both are pages a crawler can reach without
360# a session, so both must say so themselves.
361has "the dashboard login says noindex" "$(curl -sk "$B/admin/login")" 'name="robots" content="noindex"'
362has "and the console login too" "$(curl -sk "$B/manage")" 'name="robots" content="noindex"'
363has "as does the console itself" "$(curl -sk -b $MJ "$B/manage")" 'name="robots" content="noindex"'
364has "and the dashboard itself" "$(curl -sk -b $J "$B/admin")" 'name="robots" content="noindex"'
365# The prose is the opposite case: it exists to be found, and must not be told
366# otherwise by a stray blanket rule.
367hasnt "the posts stay findable" "$(curl -sk "$B/posts")" 'name="robots" content="noindex"'
368
369echo
370echo "== the JSON an app draws its own console from =="
371# The app's Manage tab draws the subscribers and the reports itself rather than
372# opening a page of the server's, so both must be available as data and both must
373# be behind the same gate as the pages.
374anon=$(curl -sk "$B/manage/subscribers.json")
375hasnt "anonymous gets no subscriber data" "$anon" '"subscribers"'
376anon=$(curl -sk "$B/manage/reports.json")
377hasnt "anonymous gets no report data" "$anon" '"reads"'
378body=$(curl -sk -b $MJ "$B/manage/subscribers.json")
379has "an admin gets the subscriber list as JSON" "$body" '"subscribers"'
380has "with the counts beside it" "$body" '"confirmed"'
381body=$(curl -sk -b $MJ "$B/manage/reports.json")
382has "an admin gets the reports as JSON" "$body" '"list"'
383has "including the sends" "$body" '"sends"'
384has "and the reads" "$body" '"reads"'
385has "the reads name each post" "$body" '"from-the-dir"'
386
387echo
388echo "== the destinations page =="
389# The server-rendered twin of the app's Destinations panel: the only one a site
390# without the app has. Every secret is write-only, so a stored secret must never
391# come back down the wire -- which is the whole point of the page and the one
392# thing a check from outside can prove.
393anon=$(curl -sk "$B/manage/destinations")
394has "anonymous gets the login, not the settings" "$anon" 'name="passphrase"'
395hasnt "and no destination form leaks to it" "$anon" 'name="dest" value="mastodon"'
396body=$(curl -sk -b $MJ "$B/manage/destinations")
397has "an admin gets the destinations page" "$body" "<h1>Destinations</h1>"
398has "it offers Mastodon" "$body" 'name="dest" value="mastodon"'
399has "and Bluesky" "$body" 'name="dest" value="bluesky"'
400has "each form carries the write token" "$body" 'name="csrf"'
401has "a secret field is masked" "$body" 'type="password"'
402has "and never autofilled from the browser" "$body" 'autocomplete="new-password"'
403has "an unset remote says so" "$body" "Not set."
404hasnt "and offers nothing to clear" "$body" 'name="clear" value="1"'
405has "the console links to it" "$(curl -sk -b $MJ "$B/manage")" "/manage/destinations"
406
407echo
408echo "== the editor is an editor, not a form with three verbs =="
409# The editor's only verb is Save: leaving is a close, and deleting belongs beside the post in
410# the list. It carries a live preview pane, so the separate preview page is not the only way
411# to see the prose rendered.
412ed=$(curl -sk -b $MJ "$B/manage/edit?slug=from-the-dir")
413has "the editor has a live preview pane" "$ed" 'id="mc-preview"'
414has "and posts its source to the renderer" "$ed" "/manage/render"
415has "leaving is a close in the corner" "$ed" 'class="mc-close"'
416hasnt "not a Cancel button" "$ed" ">Cancel<"
417hasnt "and there is no Delete in the editor" "$ed" 'class="mc-btn mc-btn-danger"'
418has "Save is the one verb" "$ed" ">Save</button>"
419
420echo
421echo "== the list copes with more than fits on a screen =="
422# A filter and a pager, and a delete beside each post rather than buried in the editor.
423ls=$(curl -sk -b $MJ "$B/manage")
424has "the list can be searched" "$ls" 'name="q"'
425has "and filtered by state" "$ls" 'name="state"'
426has "each row can be deleted, with a confirm" "$ls" "There is no undo"
427has "and deleting is an icon, not a word" "$ls" "mc-ico-danger"
428has "the reader's view is an icon too" "$ls" 'class="mc-ico"'
429one=$(curl -sk -b $MJ "$B/manage?q=zzzznothingmatchesthis")
430has "a search that matches nothing says so" "$one" "No post matches that"
431
432echo
433echo "$pass passed, $fail failed"
434
435# A check reads the markup; only a browser renders it, and a defect class that
436# hides behind correct markup -- a modifier that never applies, a control row of
437# stepped heights -- is visible nowhere else. `RIG_HOLD=1` keeps the server up so
438# a browser can be pointed at it, rather than tearing down the one thing worth
439# looking at. Ctrl-C ends it, and cleanup still runs.
440if [ "${RIG_HOLD:-0}" = "1" ]; then
441 echo
442 echo "holding at $B (passphrase: $PASS)"
443 echo "the manage session cookie jar is at $MJ"
444 echo "Ctrl-C to stop"
445 wait "$STEEL_PID" 2>/dev/null || true
446fi
447
448[ $fail -eq 0 ]