Skip to main content

max / makenotwork

12.3 KB · 272 lines History Blame Raw
1 #!/usr/bin/env bash
2 # Walk the write paths on the astra harness instance, as a logged-in user.
3 #
4 # This is the verification the write-enabled instance exists for. The public
5 # testnot demo is read-only and no-login, so every mutation path is unverified
6 # against a running box: the CSRF token round-tripping through a real form
7 # submission, the redirect a handler answers with, whether a moderation grant
8 # seeded before anyone logged in actually applies once a session exists. Unit
9 # tests cover the handlers; nothing covered the deployed surface.
10 #
11 # What it walks, which is the DONE WHEN of the task that built this instance:
12 # thread create, post reply, flag, and one moderation action. It logs in for
13 # real over OAuth against testnot's MNW (PKCE, no client secret), so a broken
14 # OAuth client, a missing harness account, an expired redirect registration or
15 # an unseeded community membership all surface here rather than in an audit run.
16 #
17 # Two accounts, because one is not enough to walk the loop: a post cannot be
18 # flagged by its own author, and the moderation action has to be taken by
19 # somebody holding the grant. `harness_fan` writes, `harness_owner` moderates
20 # (Owner of `rust`, Moderator of `music` — see multithreaded/src/seed.rs).
21 #
22 # deploy/reset-astra.sh && deploy/harness-walk.sh
23 #
24 # Run the reset first when you want a clean read of the mod queue: the walk
25 # leaves its thread, its flag and its removal behind on purpose, so a failure is
26 # still there to look at afterwards.
27 #
28 # Credentials come from $MT_HARNESS_ENV (default ~/.config/mnw/mt-harness.env,
29 # mode 600, not in the repo). It carries MT_HARNESS_URL, MT_HARNESS_USERS and
30 # MT_HARNESS_PASSWORD; the password is the one seeded onto testnot by the MNW
31 # example seed's harness phase (server/src/seed/harness.rs).
32 #
33 # Exit 0 = a browser lens can log in and write here. Exit 1 = it cannot, and the
34 # FAIL line names the step.
35 set -uo pipefail
36
37 ENV_FILE="${MT_HARNESS_ENV:-$HOME/.config/mnw/mt-harness.env}"
38 COMMUNITY="${MT_HARNESS_COMMUNITY:-rust}"
39 CATEGORY="${MT_HARNESS_CATEGORY:-general}"
40
41 if [ ! -r "$ENV_FILE" ]; then
42 echo "FAIL: no credentials at $ENV_FILE (see multithreaded/deploy/README.md)" >&2
43 exit 1
44 fi
45 # shellcheck disable=SC1090
46 . "$ENV_FILE"
47
48 : "${MT_HARNESS_URL:?MT_HARNESS_URL not set in $ENV_FILE}"
49 : "${MT_HARNESS_PASSWORD:?MT_HARNESS_PASSWORD not set in $ENV_FILE}"
50 MT="${MT_HARNESS_URL%/}"
51
52 FAILURES=0
53 fail() { echo "FAIL: $*" >&2; FAILURES=$((FAILURES + 1)); }
54 ok() { echo " ok: $*"; }
55 step() { echo; echo "== $*"; }
56
57 WORK="$(mktemp -d)"
58 trap 'rm -rf "$WORK"' EXIT
59
60 # ── HTTP helpers ────────────────────────────────────────────────────────────
61 #
62 # One cookie jar per logged-in identity. The jar spans both hosts on purpose:
63 # the login round trip is mt -> testnot -> mt, and the mt session that holds the
64 # PKCE verifier has to still be there when the callback comes back.
65
66 # Value of a hidden form input, as rendered by askama.
67 hidden_field() { # <file> <name>
68 grep -o "name=\"$2\"[^>]*value=\"[^\"]*\"" "$1" | head -1 |
69 sed -E 's/.*value="([^"]*)".*/\1/'
70 }
71
72 # The per-session CSRF token, from the meta tag base.html renders (src/csrf.rs).
73 csrf_token() { # <jar> <path>
74 curl -sS -b "$1" -c "$1" "$MT$2" |
75 grep -o 'name="csrf-token" content="[0-9a-f]*"' | head -1 |
76 sed -E 's/.*content="([0-9a-f]*)".*/\1/'
77 }
78
79 # POST a form to mt and print "<status> <redirect-url>". Takes the token
80 # explicitly rather than fetching one per call, so a caller that already has a
81 # page in hand does not pay for a second render.
82 mt_post() { # <jar> <token> <path> [--data-urlencode k=v ...]
83 local jar="$1" token="$2" path="$3"
84 shift 3
85 curl -sS -b "$jar" -c "$jar" -o /dev/null -w '%{http_code} %{redirect_url}' \
86 -H "X-CSRF-Token: $token" -X POST "$@" "$MT$path"
87 }
88
89 # Log in as one harness account and leave the session in <jar>.
90 #
91 # mt's /auth/login mints the PKCE pair into its session and redirects to MNW's
92 # /oauth/authorize, which renders a combined login-and-consent form. Posting
93 # that form with valid credentials answers a redirect back to mt's /auth/callback
94 # carrying the code, and following it is what establishes the mt session.
95 login() { # <jar> <username>
96 local jar="$1" user="$2"
97 local page="$WORK/authorize-$user.html"
98
99 local authorize_url
100 authorize_url=$(curl -sS -b "$jar" -c "$jar" -L -o "$page" -w '%{url_effective}' \
101 "$MT/auth/login")
102 case "$authorize_url" in
103 *"/oauth/authorize"*) ;;
104 *) fail "login($user): /auth/login did not reach an authorize page (landed on $authorize_url)"
105 return 1 ;;
106 esac
107
108 # Everything the consent form round-trips. Losing any one of these is the
109 # difference between "the client is unregistered" and "the challenge did not
110 # verify", and the server reports them very differently.
111 local mnw_base="${authorize_url%%/oauth/authorize*}"
112 local csrf client_id redirect_uri state challenge method scope
113 csrf=$(hidden_field "$page" _csrf)
114 client_id=$(hidden_field "$page" client_id)
115 redirect_uri=$(hidden_field "$page" redirect_uri)
116 state=$(hidden_field "$page" state)
117 challenge=$(hidden_field "$page" code_challenge)
118 method=$(hidden_field "$page" code_challenge_method)
119 scope=$(hidden_field "$page" scope)
120
121 if [ -z "$client_id" ] || [ -z "$challenge" ]; then
122 fail "login($user): authorize page carried no client_id/code_challenge — is the harness client seeded on $mnw_base?"
123 return 1
124 fi
125
126 local callback
127 callback=$(curl -sS -b "$jar" -c "$jar" -o "$WORK/consent-$user.html" -w '%{redirect_url}' \
128 -X POST \
129 --data-urlencode "login=$user" \
130 --data-urlencode "password=$MT_HARNESS_PASSWORD" \
131 --data-urlencode "_csrf=$csrf" \
132 --data-urlencode "client_id=$client_id" \
133 --data-urlencode "redirect_uri=$redirect_uri" \
134 --data-urlencode "state=$state" \
135 --data-urlencode "code_challenge=$challenge" \
136 --data-urlencode "code_challenge_method=$method" \
137 --data-urlencode "scope=$scope" \
138 "$mnw_base/oauth/authorize")
139
140 case "$callback" in
141 *"/auth/callback?"*) ;;
142 *) fail "login($user): authorize did not answer a callback redirect (got '${callback:-no redirect}')"
143 return 1 ;;
144 esac
145
146 curl -sS -b "$jar" -c "$jar" -L -o /dev/null "$callback"
147
148 # Proof of session, not proof of redirect: the account page renders the
149 # signed-in username, and a failed login would have redirected away from it.
150 if curl -sS -b "$jar" -c "$jar" "$MT/account" | grep -q "$user"; then
151 ok "logged in as $user"
152 return 0
153 fi
154 fail "login($user): no session after the callback"
155 return 1
156 }
157
158 echo "harness walk against $MT (community: $COMMUNITY/$CATEGORY)"
159
160 FAN_JAR="$WORK/fan.jar"
161 OWNER_JAR="$WORK/owner.jar"
162
163 step "reachability"
164 health=$(curl -sS -o /dev/null -w '%{http_code}' "$MT/api/health" || echo 000)
165 if [ "$health" = "200" ]; then
166 ok "/api/health 200"
167 else
168 fail "/api/health returned $health — is the tailnet proxy up and mt running?"
169 echo; echo "harness walk: $FAILURES failure(s)"; exit 1
170 fi
171
172 step "login"
173 login "$FAN_JAR" harness_fan || { echo; echo "harness walk: $FAILURES failure(s)"; exit 1; }
174 login "$OWNER_JAR" harness_owner || { echo; echo "harness walk: $FAILURES failure(s)"; exit 1; }
175
176 # ── 1. thread create ────────────────────────────────────────────────────────
177 step "thread create (harness_fan)"
178 STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
179 TITLE="Harness walk $STAMP"
180 token=$(csrf_token "$FAN_JAR" "/p/$COMMUNITY/$CATEGORY/new")
181 read -r code location < <(mt_post "$FAN_JAR" "$token" "/p/$COMMUNITY/$CATEGORY/new" \
182 --data-urlencode "title=$TITLE" \
183 --data-urlencode "body=Written by deploy/harness-walk.sh at $STAMP. Safe to delete.")
184
185 THREAD_ID=$(echo "$location" | sed -nE "s#.*/p/$COMMUNITY/$CATEGORY/([0-9a-f-]{36}).*#\1#p")
186 if [ -n "$THREAD_ID" ]; then
187 ok "thread $THREAD_ID created (HTTP $code)"
188 else
189 fail "thread create answered $code, redirect '$location'"
190 echo; echo "harness walk: $FAILURES failure(s)"; exit 1
191 fi
192
193 # ── 2. reply ────────────────────────────────────────────────────────────────
194 step "reply (harness_fan)"
195 THREAD_PATH="/p/$COMMUNITY/$CATEGORY/$THREAD_ID"
196 token=$(csrf_token "$FAN_JAR" "$THREAD_PATH")
197 read -r code location < <(mt_post "$FAN_JAR" "$token" "$THREAD_PATH/reply" \
198 --data-urlencode "body=Reply from the harness walk at $STAMP.")
199 case "$code" in
200 30*) ok "reply posted (HTTP $code)" ;;
201 *) fail "reply answered $code" ;;
202 esac
203
204 # The reply is the second post on the thread, and it is what gets flagged: the
205 # OP belongs to the same account, and a post cannot be flagged by its author.
206 curl -sS -b "$FAN_JAR" -c "$FAN_JAR" -o "$WORK/thread.html" "$MT$THREAD_PATH"
207 POST_ID=$(grep -o 'data-post-id="[0-9a-f-]\{36\}"' "$WORK/thread.html" |
208 sed -E 's/.*"([0-9a-f-]*)".*/\1/' | sed -n 2p)
209 if [ -n "$POST_ID" ]; then
210 ok "reply rendered as post $POST_ID"
211 else
212 fail "the thread page shows no second post — the reply did not land"
213 echo; echo "harness walk: $FAILURES failure(s)"; exit 1
214 fi
215
216 # ── 3. flag ─────────────────────────────────────────────────────────────────
217 step "flag (harness_owner flags harness_fan's reply)"
218 token=$(csrf_token "$OWNER_JAR" "$THREAD_PATH")
219 read -r code location < <(mt_post "$OWNER_JAR" "$token" "$THREAD_PATH/posts/$POST_ID/flag" \
220 --data-urlencode "reason=off_topic" \
221 --data-urlencode "detail=Filed by deploy/harness-walk.sh at $STAMP.")
222 case "$code" in
223 30*) ok "flag accepted (HTTP $code)" ;;
224 *) fail "flag answered $code" ;;
225 esac
226
227 # ── 4. moderation ───────────────────────────────────────────────────────────
228 #
229 # Removing the flagged post from the queue rather than pinning a thread, because
230 # it exercises the grant and the queue in one move: the flag has to have reached
231 # the moderation page for its id to be here at all.
232 step "moderation (harness_owner removes the flagged post)"
233 curl -sS -b "$OWNER_JAR" -c "$OWNER_JAR" -o "$WORK/moderation.html" "$MT/p/$COMMUNITY/moderation"
234 FLAG_ID=$(grep -o "moderation/flags/[0-9a-f-]\{36\}/remove" "$WORK/moderation.html" | head -1 |
235 sed -E 's#.*/flags/([0-9a-f-]*)/remove#\1#')
236 if [ -n "$FLAG_ID" ]; then
237 ok "flag $FLAG_ID is in the moderation queue"
238 else
239 fail "the moderation queue shows no flag — either the flag did not land or the Owner grant did not apply"
240 echo; echo "harness walk: $FAILURES failure(s)"; exit 1
241 fi
242
243 token=$(csrf_token "$OWNER_JAR" "/p/$COMMUNITY/moderation")
244 read -r code location < <(mt_post "$OWNER_JAR" "$token" "/p/$COMMUNITY/moderation/flags/$FLAG_ID/remove")
245 case "$code" in
246 30*) ok "post removed through the flag (HTTP $code)" ;;
247 *) fail "flag removal answered $code" ;;
248 esac
249
250 # ── 5. the writes are visible ───────────────────────────────────────────────
251 #
252 # Every step above can answer a redirect without changing anything a reader
253 # sees, which is the whole reason the browser axis exists. Read the result back.
254 step "read-back"
255 curl -sS -b "$OWNER_JAR" -c "$OWNER_JAR" -o "$WORK/thread-after.html" "$MT$THREAD_PATH"
256 grep -q "Harness walk $STAMP" "$WORK/thread-after.html" &&
257 ok "thread title renders" || fail "the thread page does not show the title we wrote"
258 grep -q "post-removed" "$WORK/thread-after.html" &&
259 ok "the removed post renders as removed" || fail "the flagged post does not render as removed"
260
261 curl -sS -b "$OWNER_JAR" -c "$OWNER_JAR" -o "$WORK/modlog.html" "$MT/p/$COMMUNITY/moderation/log"
262 grep -qi "harness_owner" "$WORK/modlog.html" &&
263 ok "the moderation log records the actor" || fail "the moderation log does not name harness_owner"
264
265 echo
266 if [ "$FAILURES" -eq 0 ]; then
267 echo "harness walk: all steps passed — a browser lens can log in and write here"
268 exit 0
269 fi
270 echo "harness walk: $FAILURES failure(s)"
271 exit 1
272