|
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
|