Skip to main content

max / makenotwork

Send the OAuth token exchange as a form, and walk the write paths to prove it mt posted /oauth/token as JSON. The server takes axum::Form (RFC 6749 4.1.3) and has since April, so it answered 415 and every login died as token_exchange_failed. Production is affected the same way, not only the astra harness box. The tests missed it because the wiremock stand-in matched on path alone and answered any content type, and because the success path of the code exchange had no test at all: every callback test asserted a failure slug. Both are closed - the mocks pin the content type and the body shape, and callback_exchanges_the_code_as_a_form_and_logs_in covers the login. deploy/harness-walk.sh is the check that found it: a real OAuth login against testnot as two harness accounts, then thread create, reply, flag and a moderation removal, each read back rather than trusted from its redirect.
Author: Max Johnson <me@maxj.phd> · 2026-08-16 00:34 UTC
Signed with PGP, not checked
Commit: 09d411fcfa7a8dc907c981308db0ea908b7e6232
Parent: 92fc617
6 files changed, +425 insertions, -29 deletions
@@ -2760,6 +2760,7 @@
2760 2760 "rustls-platform-verifier",
2761 2761 "serde",
2762 2762 "serde_json",
2763 + "serde_urlencoded",
2763 2764 "sync_wrapper",
2764 2765 "tokio",
2765 2766 "tokio-rustls",
@@ -4862,25 +4863,25 @@
4862 4863 source = "registry+https://github.com/rust-lang/crates.io-index"
4863 4864 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
4864 4865
4865 - [[patch.unused]]
4866 - name = "synckit-client"
4867 - version = "0.8.0"
4868 -
4869 - [[patch.unused]]
4870 - name = "synckit-config"
4871 - version = "0.2.0"
4872 -
4873 4866 [[patch.unused]]
4874 4867 name = "quasi-axum"
4875 - version = "0.1.0"
4868 + version = "0.11.0"
4869 +
4870 + [[patch.unused]]
4871 + name = "quasi-basics"
4872 + version = "0.11.0"
4876 4873
4877 4874 [[patch.unused]]
4878 4875 name = "quasi-http"
4879 - version = "0.1.0"
4876 + version = "0.11.0"
4877 +
4878 + [[patch.unused]]
4879 + name = "quasi-immediate"
4880 + version = "0.11.0"
4880 4881
4881 4882 [[patch.unused]]
4882 4883 name = "quasi-router"
4883 - version = "0.1.0"
4884 + version = "0.11.0"
4884 4885
4885 4886 [[patch.unused]]
4886 4887 name = "quasi-store"
@@ -4888,16 +4889,28 @@
4888 4889
4889 4890 [[patch.unused]]
4890 4891 name = "quasi-tauri"
4891 - version = "0.1.0"
4892 + version = "0.11.0"
4892 4893
4893 4894 [[patch.unused]]
4894 4895 name = "quasi-webview"
4895 - version = "0.1.0"
4896 + version = "0.11.0"
4896 4897
4897 4898 [[patch.unused]]
4898 4899 name = "kberg"
4899 4900 version = "0.1.0"
4900 4901
4902 + [[patch.unused]]
4903 + name = "ops-status"
4904 + version = "0.1.0"
4905 +
4901 4906 [[patch.unused]]
4902 4907 name = "painhours"
4903 4908 version = "0.1.0"
4909 +
4910 + [[patch.unused]]
4911 + name = "synckit-client"
4912 + version = "0.8.0"
4913 +
4914 + [[patch.unused]]
4915 + name = "synckit-config"
4916 + version = "0.2.0"
@@ -32,7 +32,10 @@
32 32 # `rustls-no-provider` rather than `rustls`: the latter is an alias for
33 33 # `__rustls-aws-lc-rs`, which links a C crypto backend. The provider is ring
34 34 # (pure Rust), installed process-wide by `crate::tls` before any client is built.
35 - reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider"] }
35 + # `form` is what the OAuth token exchange is sent as (RFC 6749 §4.1.3, and what
36 + # the MNW server's `axum::Form` extractor accepts); `json` is for reading the
37 + # responses back.
38 + reqwest = { version = "0.13", default-features = false, features = ["form", "json", "rustls-no-provider"] }
36 39 rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "logging", "ring"] }
37 40 # Reads the host trust store, which is what reqwest's platform verifier does on
38 41 # Linux and what the AWS client behind s3-storage calls directly. Already in the
@@ -180,6 +180,29 @@
180 180 membership. The two id lists have to be edited together; the MNW side has a test
181 181 pinning the literals.
182 182
183 + ### Walking it
184 +
185 + ```
186 + deploy/harness-walk.sh
187 + ```
188 +
189 + Logs in over OAuth as two harness accounts and walks the write paths the
190 + instance exists to cover: thread create, reply, flag, and one moderation action,
191 + each read back afterwards rather than trusted from its redirect. Run it after a
192 + deploy to this box, and after any change to the login path on either side.
193 +
194 + It is the check that covers what unit tests structurally cannot. The first run
195 + of it found that mt had been sending the OAuth token exchange as JSON while the
196 + server's `/oauth/token` takes `axum::Form`, so every login answered
197 + `token_exchange_failed` on a 415 — in production too, since the server changed
198 + extractors in April. mt's own tests passed throughout, because the wiremock
199 + stand-in matched on path alone and answered any content type. The mocks now pin
200 + the content type, and `callback_exchanges_the_code_as_a_form_and_logs_in` covers
201 + the success path that had no test at all.
202 +
203 + Credentials come from `~/.config/mnw/mt-harness.env` (mode 600, not in the
204 + repo). Exit 0 means a browser lens can log in and write here.
205 +
183 206 ### Resetting it
184 207
185 208 ```
@@ -334,11 +334,11 @@
334 334 let res = state
335 335 .http
336 336 .post(&url)
337 - .json(&serde_json::json!({
338 - "grant_type": "refresh_token",
339 - "refresh_token": refresh_token,
340 - "client_id": state.config.oauth_client_id,
341 - }))
337 + .form(&[
338 + ("grant_type", "refresh_token"),
339 + ("refresh_token", refresh_token),
340 + ("client_id", state.config.oauth_client_id.as_str()),
341 + ])
342 342 .timeout(OAUTH_REQUEST_TIMEOUT)
343 343 .send()
344 344 .await
@@ -506,6 +506,11 @@
506 506
507 507 /// Exchange the authorization code for a token, retrying on transport/5xx.
508 508 ///
509 + /// The request body is form-encoded, which RFC 6749 §4.1.3 requires and the
510 + /// server's `/oauth/token` enforces by taking `axum::Form`. A JSON body comes
511 + /// back 415 with no `error` field, so it reads as a transport failure rather
512 + /// than as the wrong content type, which is how it went unnoticed here.
513 + ///
509 514 /// Returns the parsed token on success, or the `?error=` slug to redirect with.
510 515 /// Parsing happens here so the caller never holds an un-parsed response, there
511 516 /// is no post-loop `unwrap()` to trip if the retry logic ever changes.
@@ -523,13 +528,13 @@
523 528 for attempt in 0..=OAUTH_BACKOFFS.len() {
524 529 let res = http
525 530 .post(&token_url)
526 - .json(&serde_json::json!({
527 - "grant_type": "authorization_code",
528 - "code": code,
529 - "redirect_uri": config.oauth_redirect_uri,
530 - "code_verifier": verifier,
531 - "client_id": config.oauth_client_id,
532 - }))
531 + .form(&[
532 + ("grant_type", "authorization_code"),
533 + ("code", code),
534 + ("redirect_uri", config.oauth_redirect_uri.as_str()),
535 + ("code_verifier", verifier),
536 + ("client_id", config.oauth_client_id.as_str()),
537 + ])
533 538 .send()
534 539 .await;
535 540
@@ -1,8 +1,12 @@
1 1 use crate::harness::{HarnessOptions, TestHarness};
2 2 use axum::http::StatusCode;
3 - use wiremock::matchers::{body_partial_json, header, method, path};
3 + use wiremock::matchers::{body_string_contains, header, method, path};
4 4 use wiremock::{Mock, MockServer, ResponseTemplate};
5 5
6 + /// What `POST /oauth/token` must be sent as. RFC 6749 §4.1.3, and what the MNW
7 + /// server enforces with `axum::Form`.
8 + const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
9 +
6 10 #[tokio::test]
7 11 async fn unauthenticated_sees_login_link() {
8 12 let mut h = TestHarness::new().await;
@@ -174,6 +178,75 @@
174 178 );
175 179 }
176 180
181 + #[tokio::test]
182 + async fn callback_exchanges_the_code_as_a_form_and_logs_in() {
183 + // The success path of the code exchange, which had no test at all: every
184 + // callback test above asserts a failure slug, so the request mt actually
185 + // sends was never looked at. It sent JSON, the server takes `axum::Form`,
186 + // and login had been answering `token_exchange_failed` on a 415 since the
187 + // server switched extractors. The matchers below are the contract.
188 + let (mut h, mock) = harness_with_mock_mnw().await;
189 + let user_id = uuid::Uuid::new_v4();
190 +
191 + Mock::given(method("POST"))
192 + .and(path("/oauth/token"))
193 + .and(header("content-type", FORM_CONTENT_TYPE))
194 + .and(body_string_contains("grant_type=authorization_code"))
195 + .and(body_string_contains("code=the-code"))
196 + .and(body_string_contains("code_verifier="))
197 + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
198 + "access_token": "acc", "token_type": "Bearer", "expires_in": 300,
199 + "refresh_token": "rt", "scope": "profile:read perks:read offline_access",
200 + })))
201 + .expect(1)
202 + .mount(&mock)
203 + .await;
204 + Mock::given(method("GET"))
205 + .and(path("/oauth/userinfo"))
206 + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
207 + "user_id": user_id, "username": "callbackuser", "display_name": null,
208 + "avatar_url": null,
209 + "perks": { "fan_plus": false, "is_creator": false, "creator_tier": null },
210 + })))
211 + .mount(&mock)
212 + .await;
213 +
214 + // /auth/login mints the state and PKCE verifier into the session; the
215 + // callback only works with the state that round trip produced.
216 + let login = h.client.get("/auth/login").await;
217 + let location = login
218 + .headers
219 + .get("location")
220 + .and_then(|v| v.to_str().ok())
221 + .expect("login should redirect");
222 + let state_start = location.find("state=").expect("state in URL") + 6;
223 + let state_end = location[state_start..]
224 + .find('&')
225 + .map_or(location.len(), |i| state_start + i);
226 + let state = location[state_start..state_end].to_string();
227 +
228 + let resp = h
229 + .client
230 + .get(&format!("/auth/callback?code=the-code&state={state}"))
231 + .await;
232 + let cb_location = resp
233 + .headers
234 + .get("location")
235 + .and_then(|v| v.to_str().ok())
236 + .unwrap_or_default()
237 + .to_string();
238 + assert_eq!(
239 + cb_location, "/",
240 + "callback should land logged in, got: {cb_location}"
241 + );
242 +
243 + let home = h.client.get("/").await;
244 + assert!(
245 + home.text.contains("callbackuser"),
246 + "the session should carry the logged-in username"
247 + );
248 + }
249 +
177 250 // --- perks refresh (`POST /auth/refresh`)
178 251 //
179 252 // Refresh re-hits MNW's `/oauth/userinfo` using the cached access token and
@@ -224,9 +297,15 @@
224 297
225 298 /// Mount a `POST /oauth/token` refresh-grant responder returning the given
226 299 /// access + (rotated) refresh token.
300 + ///
301 + /// Matches on the form content type as well as the path, because that is the
302 + /// half of the contract a mock is otherwise free to ignore: the real endpoint
303 + /// takes `axum::Form` and answers 415 to anything else, and mt sent JSON here
304 + /// for months while every one of these tests passed.
227 305 async fn mock_refresh_grant(mock: &MockServer, access_token: &str, new_refresh_token: &str) {
228 306 Mock::given(method("POST"))
229 307 .and(path("/oauth/token"))
308 + .and(header("content-type", FORM_CONTENT_TYPE))
230 309 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
231 310 "access_token": access_token,
232 311 "token_type": "Bearer",
@@ -295,7 +374,8 @@
295 374 // First refresh: presents rt-1, gets rt-2.
296 375 Mock::given(method("POST"))
297 376 .and(path("/oauth/token"))
298 - .and(body_partial_json(serde_json::json!({ "refresh_token": "rt-1" })))
377 + .and(header("content-type", FORM_CONTENT_TYPE))
378 + .and(body_string_contains("refresh_token=rt-1"))
299 379 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
300 380 "access_token": "acc-1", "token_type": "Bearer", "expires_in": 300, "refresh_token": "rt-2",
301 381 })))
@@ -308,7 +388,8 @@
308 388 // Second refresh must present rt-2 (the rotated token), not rt-1.
309 389 Mock::given(method("POST"))
310 390 .and(path("/oauth/token"))
311 - .and(body_partial_json(serde_json::json!({ "refresh_token": "rt-2" })))
391 + .and(header("content-type", FORM_CONTENT_TYPE))
392 + .and(body_string_contains("refresh_token=rt-2"))
312 393 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
313 394 "access_token": "acc-2", "token_type": "Bearer", "expires_in": 300, "refresh_token": "rt-3",
314 395 })))
@@ -1,0 +1,271 @@
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