|
7 | 7 | # Every one of these is read by someone else's software, so a wrong status code |
8 | 8 | # or content type is a silent outage — nothing on the human site would break. |
9 | 9 | # CI runs this after each deploy. |
| 10 | +# |
| 11 | +# Each URL is fetched once and asserted against many times. Fetching per |
| 12 | +# assertion instead made ~50 requests at a deployment that was still |
| 13 | +# propagating, and read every transient failure as a missing header. |
10 | 14 |
|
11 | 15 | set -uo pipefail |
12 | 16 |
|
13 | 17 | BASE="${1:-https://www.aethersdr.com}" |
14 | 18 | BASE="${BASE%/}" |
15 | 19 | FAILURES=0 |
16 | 20 |
|
| 21 | +CACHE=$(mktemp -d) |
| 22 | +trap 'rm -rf "$CACHE"' EXIT |
| 23 | + |
17 | 24 | fail() { printf ' FAIL %s\n' "$*"; FAILURES=$((FAILURES + 1)); } |
18 | 25 | pass() { printf ' ok %s\n' "$*"; } |
19 | | - |
20 | 26 | lower() { tr '[:upper:]' '[:lower:]'; } |
21 | 27 |
|
22 | | -# expect_header <path> <header-name> <substring> [extra curl args...] |
23 | | -# An empty substring means "present and non-empty". |
| 28 | +# Headers of the final response only — with -L the dump holds every hop. |
| 29 | +last_headers() { |
| 30 | + awk '/^HTTP\//{buf=""} {buf = buf $0 "\n"} END{printf "%s", buf}' "$1" | tr -d '\r' |
| 31 | +} |
| 32 | + |
| 33 | +# fetch <key> <path> [curl args...] |
| 34 | +# Retries transient failures: a deployment that has just gone out is not |
| 35 | +# uniformly available yet, and a one-off connection error is not a finding. |
| 36 | +fetch() { |
| 37 | + local key="$1" path="$2"; shift 2 |
| 38 | + local body="$CACHE/$key.body" hdr="$CACHE/$key.hdr" code |
| 39 | + local attempt |
| 40 | + |
| 41 | + for attempt in 1 2 3 4 5; do |
| 42 | + code=$(curl -sS -L --max-time 25 -o "$body" -D "$hdr" -w '%{http_code}' \ |
| 43 | + "$@" "$BASE$path" 2>"$CACHE/$key.err") |
| 44 | + if [[ "$code" == 2* ]]; then |
| 45 | + printf '%s' "$code" > "$CACHE/$key.code" |
| 46 | + printf '%s' "$path" > "$CACHE/$key.path" |
| 47 | + return 0 |
| 48 | + fi |
| 49 | + sleep $((attempt * 3)) |
| 50 | + done |
| 51 | + |
| 52 | + printf '%s' "${code:-000}" > "$CACHE/$key.code" |
| 53 | + printf '%s' "$path" > "$CACHE/$key.path" |
| 54 | +} |
| 55 | + |
| 56 | +label() { cat "$CACHE/$1.path" 2>/dev/null || printf '%s' "$1"; } |
| 57 | + |
| 58 | +# expect_ok <key> |
| 59 | +expect_ok() { |
| 60 | + local code; code=$(cat "$CACHE/$1.code") |
| 61 | + if [[ "$code" == 2* ]]; then |
| 62 | + pass "$(label "$1") — HTTP $code" |
| 63 | + else |
| 64 | + fail "$(label "$1") — HTTP $code$( [[ -s "$CACHE/$1.err" ]] && printf ' (%s)' "$(head -1 "$CACHE/$1.err")" )" |
| 65 | + fi |
| 66 | +} |
| 67 | + |
| 68 | +# expect_header <key> <header-name> <substring> ("" = present and non-empty) |
24 | 69 | expect_header() { |
25 | | - local path="$1" header="$2" want="$3"; shift 3 |
26 | | - local got value |
27 | | - got=$(curl -sSL --max-time 20 -o /dev/null -D - "$@" "$BASE$path" 2>/dev/null \ |
28 | | - | tr -d '\r' | grep -i "^$header:" | tail -1) |
29 | | - value=$(printf '%s' "${got#*:}" | sed 's/^ *//') |
| 70 | + local key="$1" header="$2" want="$3" |
| 71 | + local value |
| 72 | + value=$(last_headers "$CACHE/$key.hdr" | grep -i "^$header:" \ |
| 73 | + | sed 's/^[^:]*: *//' | paste -sd, - ) |
30 | 74 |
|
31 | 75 | if [[ -z "$want" ]]; then |
32 | | - if [[ -n "$value" ]]; then pass "$path — $header present ($value)" |
33 | | - else fail "$path — $header missing"; fi |
| 76 | + if [[ -n "$value" ]]; then pass "$(label "$key") — $header: $value" |
| 77 | + else fail "$(label "$key") — $header missing"; fi |
34 | 78 | return |
35 | 79 | fi |
36 | 80 |
|
37 | 81 | if [[ "$(printf '%s' "$value" | lower)" == *"$(printf '%s' "$want" | lower)"* ]]; then |
38 | | - pass "$path — $header contains '$want'" |
| 82 | + pass "$(label "$key") — $header contains '$want'" |
39 | 83 | else |
40 | | - fail "$path — $header expected '$want', got '${value:-<none>}'" |
| 84 | + fail "$(label "$key") — $header expected '$want', got '${value:-<none>}'" |
41 | 85 | fi |
42 | 86 | } |
43 | 87 |
|
44 | | -# expect_body <path> <substring> [extra curl args...] |
| 88 | +# expect_body <key> <substring> |
45 | 89 | expect_body() { |
46 | | - local path="$1" want="$2"; shift 2 |
47 | | - local code body |
48 | | - body=$(curl -sSL --max-time 20 -w '\n%{http_code}' "$@" "$BASE$path" 2>/dev/null) |
49 | | - code="${body##*$'\n'}" |
50 | | - body="${body%$'\n'*}" |
51 | | - if [[ "$code" != "200" ]]; then |
52 | | - fail "$path — HTTP $code" |
53 | | - elif [[ "$body" == *"$want"* ]]; then |
54 | | - pass "$path — 200, contains '$want'" |
| 90 | + local key="$1" want="$2" |
| 91 | + if grep -qF -- "$want" "$CACHE/$key.body" 2>/dev/null; then |
| 92 | + pass "$(label "$key") — body contains '$want'" |
55 | 93 | else |
56 | | - fail "$path — 200 but missing '$want'" |
| 94 | + fail "$(label "$key") — body missing '$want'" |
57 | 95 | fi |
58 | 96 | } |
59 | 97 |
|
| 98 | +MD='Accept: text/markdown' |
| 99 | +HTML='Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' |
| 100 | + |
60 | 101 | echo "Checking $BASE" |
61 | 102 |
|
| 103 | +# A deployment that has just gone out needs a moment before every edge has it. |
| 104 | +printf 'Waiting for the deployment to answer' |
| 105 | +for attempt in $(seq 30); do |
| 106 | + if curl -sS -o /dev/null --max-time 10 -f "$BASE/" 2>/dev/null; then |
| 107 | + printf ' ready\n' |
| 108 | + break |
| 109 | + fi |
| 110 | + printf '.' |
| 111 | + sleep 4 |
| 112 | +done |
| 113 | + |
| 114 | +echo |
| 115 | +echo "Fetching" |
| 116 | +fetch home_html / -H "$HTML" |
| 117 | +fetch home_md / -H "$MD" |
| 118 | +fetch blog_html /blog -H "$HTML" |
| 119 | +fetch blog_md /blog -H "$MD" |
| 120 | +fetch lineage_html /lineage -H "$HTML" |
| 121 | +fetch lineage_md /lineage -H "$MD" |
| 122 | +fetch index_md /index.md |
| 123 | +fetch robots /robots.txt |
| 124 | +fetch sitemap /sitemap.xml |
| 125 | +fetch catalog /.well-known/api-catalog |
| 126 | +fetch openapi /api/v1/openapi.json |
| 127 | +fetch site /api/v1/site.json |
| 128 | +fetch posts /api/v1/posts.json |
| 129 | +fetch status /api/v1/status.json |
| 130 | +fetch apidocs /api/v1/docs.md |
| 131 | +fetch skills /.well-known/agent-skills/index.json |
| 132 | +fetch skill_dl /.well-known/agent-skills/aethersdr-downloads/SKILL.md |
| 133 | +fetch skill_facts /.well-known/agent-skills/aethersdr-project-facts/SKILL.md |
| 134 | +fetch skill_rel /.well-known/agent-skills/aethersdr-release-notes/SKILL.md |
| 135 | + |
62 | 136 | echo |
63 | 137 | echo "Discoverability" |
64 | | -expect_body /robots.txt "Sitemap: https://www.aethersdr.com/sitemap.xml" |
65 | | -expect_body /sitemap.xml "<urlset" |
66 | | -expect_body /sitemap.xml "<loc>https://www.aethersdr.com/</loc>" |
67 | | -expect_header /sitemap.xml content-type xml |
68 | | -expect_header / link 'rel="api-catalog"' |
69 | | -expect_header / link 'rel="service-desc"' |
70 | | -expect_header / link 'rel="service-doc"' |
71 | | -expect_header / link 'rel="describedby"' |
72 | | -expect_header / link 'rel="sitemap"' |
| 138 | +expect_ok robots |
| 139 | +expect_body robots "Sitemap: https://www.aethersdr.com/sitemap.xml" |
| 140 | +expect_ok sitemap |
| 141 | +expect_body sitemap "<urlset" |
| 142 | +expect_body sitemap "<loc>https://www.aethersdr.com/</loc>" |
| 143 | +expect_header sitemap content-type xml |
| 144 | +for rel in api-catalog service-desc service-doc describedby sitemap; do |
| 145 | + expect_header home_html link "rel=\"$rel\"" |
| 146 | +done |
73 | 147 |
|
74 | 148 | echo |
75 | 149 | echo "API catalog + content API" |
76 | | -expect_body /.well-known/api-catalog '"linkset"' |
77 | | -expect_header /.well-known/api-catalog content-type application/linkset+json |
78 | | -expect_body /api/v1/openapi.json '"openapi"' |
79 | | -expect_body /api/v1/site.json '"name": "AetherSDR"' |
80 | | -expect_body /api/v1/posts.json '"posts"' |
81 | | -expect_body /api/v1/status.json '"status"' |
82 | | -expect_body /api/v1/docs.md '# AetherSDR site content API' |
83 | | -expect_header /api/v1/site.json content-type application/json |
84 | | -expect_header /api/v1/site.json access-control-allow-origin '*' |
| 150 | +expect_ok catalog |
| 151 | +expect_body catalog '"linkset"' |
| 152 | +expect_header catalog content-type application/linkset+json |
| 153 | +expect_ok openapi |
| 154 | +expect_body openapi '"openapi"' |
| 155 | +expect_body site '"name": "AetherSDR"' |
| 156 | +expect_body posts '"posts"' |
| 157 | +expect_body status '"status"' |
| 158 | +expect_body apidocs '# AetherSDR site content API' |
| 159 | +expect_header site content-type application/json |
| 160 | +expect_header site access-control-allow-origin '*' |
85 | 161 |
|
86 | 162 | echo |
87 | 163 | echo "Agent skills" |
88 | | -expect_body /.well-known/agent-skills/index.json 'schemas.agentskills.io' |
89 | | -expect_header /.well-known/agent-skills/index.json content-type application/json |
90 | | -for skill in aethersdr-downloads aethersdr-project-facts aethersdr-release-notes; do |
91 | | - expect_body "/.well-known/agent-skills/$skill/SKILL.md" "name: $skill" |
92 | | -done |
| 164 | +expect_ok skills |
| 165 | +expect_body skills 'schemas.agentskills.io' |
| 166 | +expect_header skills content-type application/json |
| 167 | +expect_body skill_dl 'name: aethersdr-downloads' |
| 168 | +expect_body skill_facts 'name: aethersdr-project-facts' |
| 169 | +expect_body skill_rel 'name: aethersdr-release-notes' |
93 | 170 |
|
94 | 171 | echo |
95 | 172 | echo "Markdown negotiation" |
96 | | -for path in / /blog /lineage; do |
97 | | - expect_header "$path" content-type text/markdown -H 'Accept: text/markdown' |
98 | | - expect_header "$path" x-markdown-tokens '' -H 'Accept: text/markdown' |
99 | | - expect_header "$path" vary Accept -H 'Accept: text/markdown' |
100 | | - # The human site must be untouched by any of this. |
101 | | - expect_header "$path" content-type text/html \ |
102 | | - -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' |
| 173 | +for key in home_md blog_md lineage_md; do |
| 174 | + expect_ok "$key" |
| 175 | + expect_header "$key" content-type text/markdown |
| 176 | + expect_header "$key" x-markdown-tokens '' |
| 177 | + expect_header "$key" vary Accept |
| 178 | +done |
| 179 | +expect_body home_md '# AetherSDR' |
| 180 | +expect_body blog_md '## v26' |
| 181 | +expect_body lineage_md '# Lineage' |
| 182 | + |
| 183 | +echo |
| 184 | +echo "The human site is unaffected" |
| 185 | +for key in home_html blog_html lineage_html; do |
| 186 | + expect_ok "$key" |
| 187 | + expect_header "$key" content-type text/html |
103 | 188 | done |
104 | | -expect_body / '# AetherSDR' -H 'Accept: text/markdown' |
105 | | -expect_body /blog '## v26' -H 'Accept: text/markdown' |
| 189 | +expect_body home_html '<!doctype html' |
| 190 | +expect_header home_html link 'rel="alternate"' |
| 191 | +expect_header index_md content-type text/markdown |
106 | 192 |
|
107 | 193 | echo |
108 | | -echo "Security headers still applied on negotiated paths" |
109 | | -expect_header / content-security-policy "default-src 'self'" -H 'Accept: text/markdown' |
110 | | -expect_header / x-frame-options DENY -H 'Accept: text/markdown' |
111 | | -expect_header / x-content-type-options nosniff |
| 194 | +echo "Security headers survive the Pages Function" |
| 195 | +for key in home_html home_md; do |
| 196 | + expect_header "$key" content-security-policy "default-src 'self'" |
| 197 | + expect_header "$key" x-frame-options DENY |
| 198 | + expect_header "$key" x-content-type-options nosniff |
| 199 | +done |
112 | 200 |
|
113 | 201 | echo |
114 | 202 | if [[ $FAILURES -eq 0 ]]; then |
|
0 commit comments