Repository navigation
Expand file tree
/
Copy pathvalidate.sh
More file actions
executable file
·298 lines (276 loc) · 13.3 KB
/
Copy pathvalidate.sh
File metadata and controls
executable file
·298 lines (276 loc) · 13.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
#!/bin/bash
# Variables
# Overridable so the same scripts can deploy to real Azure, where the API Management service, the
# storage account and the Function App all need globally unique names:
# PREFIX=apimdemo SUFFIX=$RANDOM bash scripts/deploy.sh
PREFIX="${PREFIX:-local}"
SUFFIX="${SUFFIX:-test}"
RESOURCE_GROUP_NAME="${PREFIX}-rg"
FUNCTION_APP_NAME="${PREFIX}-inventory-functionapp-${SUFFIX}"
APIM_NAME="${PREFIX}-inventory-apim-${SUFFIX}"
APIM_API_VERSION='2022-08-01'
API_ID='inventory-api'
API_PATH='inventory'
APIM_SUBSCRIPTION_ID='partner-subscription'
RATE_LIMIT_CALLS=10
# On Windows, Git Bash and MSYS2 shells pass a path such as /tmp/... unconverted to Windows programs
# (az, curl, jq, python, docker) when MSYS_NO_PATHCONV=1 is set, as the README asks. cygpath, which
# those shells provide, returns the C:/... form those programs can open; elsewhere the path is unchanged.
native_path() {
if command -v cygpath >/dev/null 2>&1; then
cygpath -m "$1"
else
echo "$1"
fi
}
# Where curl discards a response body. Git Bash and MSYS2 shells hand a Windows curl /dev/null
# unchanged when MSYS_NO_PATHCONV=1 is set, as the README asks, and it cannot open that path, so it
# gets the NUL device instead; MSYS2's own curl, under /usr/bin, opens /dev/null as on Linux and macOS.
null_device() {
if command -v cygpath >/dev/null 2>&1 && [[ "$(command -v curl)" != /usr/bin/* ]]; then
echo NUL
else
echo /dev/null
fi
}
# mktemp rather than fixed paths: two runs of this script must not write the same file, and
# `curl -o` would follow a symlink a third party had left at a predictable name.
BODY_FILE="$(native_path "$(mktemp "${TMPDIR:-/tmp}/inventory_body.XXXXXX.json")")"
HEADERS_FILE="$(native_path "$(mktemp "${TMPDIR:-/tmp}/inventory_headers.XXXXXX.txt")")"
trap 'rm -f "$BODY_FILE" "$HEADERS_FILE"' EXIT
FAILED=0
# Retrieve the API Management service
APIM_ID=$(az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv)
if [[ -z "$APIM_ID" ]]; then
echo "Failed to retrieve the [$APIM_NAME] API Management service; run scripts/deploy.sh first. Exiting."
exit 1
fi
# Resolve the two hosts. The emulator's Function App and gateway answer on plain HTTP under their
# own hostnames; on Azure both are HTTPS. The emulator also reports Azure's *.azure-api.net address
# in gatewayUrl, but that name only resolves once LocalStack's DNS is in front of the machine, so
# the gateway is called through its local alias instead.
ENVIRONMENT_NAME=$(az account show --query environmentName --output tsv)
FUNCTION_APP_HOSTNAME=$(az functionapp show --name $FUNCTION_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query defaultHostName --output tsv)
if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
BACKEND_URL="http://$FUNCTION_APP_HOSTNAME/api"
GATEWAY_URL="http://${APIM_NAME}.apim.azure.localhost.localstack.cloud:4566"
else
BACKEND_URL="https://$FUNCTION_APP_HOSTNAME/api"
GATEWAY_URL=$(az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME --query gatewayUrl --output tsv)
fi
API_URL="$GATEWAY_URL/$API_PATH"
echo "Backend URL: $BACKEND_URL"
echo "Gateway URL: $API_URL"
# 1. Wait for the Function App: the zip deployment is asynchronous and the host takes a moment to
# start. A 401 is the answer we want from a direct call: the backend refuses anything that does
# not carry the shared secret, so the gateway is the only way in.
echo "Waiting for the [$FUNCTION_APP_NAME] function app to answer..."
BACKEND_STATUS=""
for i in $(seq 1 60); do
BACKEND_STATUS=$(curl -s -m 10 -o "$(null_device)" -w "%{http_code}" "$BACKEND_URL/items")
[[ "$BACKEND_STATUS" == "401" ]] && break
sleep 5
done
echo "Direct call to the backend without the shared secret: HTTP $BACKEND_STATUS"
if [[ "$BACKEND_STATUS" == "401" ]]; then
echo "The backend refuses direct calls"
else
echo "Expected the backend to answer 401 to a direct call"
FAILED=1
fi
# 2. The OpenAPI import produced the operations.
echo "Listing the operations of the [$API_ID] API..."
OPERATIONS=$(az apim api operation list \
--api-id $API_ID \
--service-name $APIM_NAME \
--resource-group $RESOURCE_GROUP_NAME \
--query "[].name" \
--output tsv | sort | tr '\n' ' ')
echo "Operations: $OPERATIONS"
# API Management normalises operationId into the operation's resource name: it replaces characters
# that are not allowed and truncates at 76. Microsoft's import-restrictions page also lists "convert
# to lower case", but a probe against real Azure kept the casing -- getItems stayed getItems, and
# "Get Items" became "Get-Items" -- so this is matched case-insensitively rather than depending on
# either behaviour.
# https://learn.microsoft.com/azure/api-management/api-management-api-import-restrictions
for OPERATION in getitem listitems whoami; do
if ! echo "$OPERATIONS" | grep -qwi "$OPERATION"; then
echo "Operation [$OPERATION] was not imported"
FAILED=1
fi
done
# 3. A call without a subscription key is refused with Azure's own message.
echo "Calling [$API_URL/items] without a subscription key..."
NO_KEY_STATUS=$(curl -s -m 10 -o "$BODY_FILE" -w "%{http_code}" "$API_URL/items")
echo "HTTP $NO_KEY_STATUS: $(cat "$BODY_FILE")"
if [[ "$NO_KEY_STATUS" == "401" ]] && grep -q "missing subscription key" "$BODY_FILE"; then
echo "Keyless calls are refused"
else
echo "Expected 401 with the missing-subscription-key message"
FAILED=1
fi
# 4. Take the partner subscription's key from the control plane. It is the only credential the
# client needs; the backend secret stays inside API Management.
echo "Reading the [$APIM_SUBSCRIPTION_ID] subscription key..."
KEY=$(az rest --method post \
--url "$APIM_ID/subscriptions/$APIM_SUBSCRIPTION_ID/listSecrets?api-version=$APIM_API_VERSION" \
--query primaryKey \
--output tsv)
if [[ -z "$KEY" ]]; then
echo "Failed to read the [$APIM_SUBSCRIPTION_ID] subscription key. Exiting."
exit 1
fi
echo "Subscription key retrieved (${#KEY} characters)"
# 5. A wrong key is refused too, with a different message.
echo "Calling [$API_URL/items] with a wrong subscription key..."
BAD_KEY_STATUS=$(curl -s -m 10 -o "$BODY_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: not-a-real-key" "$API_URL/items")
echo "HTTP $BAD_KEY_STATUS: $(cat "$BODY_FILE")"
if [[ "$BAD_KEY_STATUS" == "401" ]] && grep -q "invalid subscription key" "$BODY_FILE"; then
echo "Wrong keys are refused"
else
echo "Expected 401 with the invalid-subscription-key message"
FAILED=1
fi
# 6. listItems: the request is authorised, the policies run, and the Function App answers.
echo "Calling [$API_URL/items] with the subscription key..."
LIST_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -D "$HEADERS_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/items")
cat "$BODY_FILE"
echo
COUNT=$(jq -r '.count' "$BODY_FILE" 2>/dev/null)
if [[ "$LIST_STATUS" == "200" && "$COUNT" == "3" ]]; then
echo "listItems answered 200 with $COUNT items"
else
echo "Expected 200 with 3 items (got HTTP $LIST_STATUS, count=$COUNT)"
FAILED=1
fi
if grep -qi "^x-served-by: Azure API Management" "$HEADERS_FILE"; then
echo "The outbound policy added the X-Served-By header"
else
echo "The X-Served-By header is missing from the response"
FAILED=1
fi
# 7. getItem: the {id} template parameter is matched by the gateway and read by the backend. A
# 404 from the backend passes through the gateway untouched.
echo "Calling [$API_URL/items/2]..."
ITEM_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/items/2")
SKU=$(jq -r '.sku' "$BODY_FILE" 2>/dev/null)
echo "HTTP $ITEM_STATUS: $(jq -c . "$BODY_FILE" 2>/dev/null || cat "$BODY_FILE")"
if [[ "$ITEM_STATUS" == "200" && "$SKU" == "APIM-002" ]]; then
echo "getItem answered 200 with the requested item"
else
echo "Expected 200 with sku APIM-002 (got HTTP $ITEM_STATUS, sku=$SKU)"
FAILED=1
fi
echo "Calling [$API_URL/items/99]..."
MISSING_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/items/99")
echo "HTTP $MISSING_STATUS: $(jq -c . "$BODY_FILE" 2>/dev/null || cat "$BODY_FILE")"
if [[ "$MISSING_STATUS" == "404" ]] && grep -q "No item with id 99" "$BODY_FILE"; then
echo "The backend's 404 passes through the gateway"
else
echo "Expected the backend's 404 for a missing item (got HTTP $MISSING_STATUS)"
FAILED=1
fi
# 8. whoAmI: what reached the backend shows the policies at work. The shared secret arrived, the
# subscription key did not, and the backend was told which subscription is calling.
echo "Calling [$API_URL/whoami]..."
WHOAMI_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/whoami")
cat "$BODY_FILE"
echo
SECRET_RECEIVED=$(jq -r '.backend_secret_received' "$BODY_FILE" 2>/dev/null)
FORWARDED_KEY=$(jq -r '.headers["ocp-apim-subscription-key"] // "absent"' "$BODY_FILE" 2>/dev/null)
CALLER=$(jq -r '.headers["x-caller-subscription"] // ""' "$BODY_FILE" 2>/dev/null)
if [[ "$WHOAMI_STATUS" == "200" && "$SECRET_RECEIVED" == "true" ]]; then
echo "The gateway injected the backend secret from the named value"
else
echo "Expected the backend to receive the shared secret (got HTTP $WHOAMI_STATUS, backend_secret_received=$SECRET_RECEIVED)"
FAILED=1
fi
if [[ "$FORWARDED_KEY" == "absent" ]]; then
echo "The subscription key was stripped before the backend"
else
echo "The subscription key reached the backend"
FAILED=1
fi
if [[ "$CALLER" == "$APIM_SUBSCRIPTION_ID" ]]; then
echo "The backend was told the calling subscription: $CALLER"
else
echo "Expected X-Caller-Subscription to be [$APIM_SUBSCRIPTION_ID] (got: $CALLER)"
FAILED=1
fi
# 8b. The key passed as the query parameter is also stripped before the backend. The policy
# deletes both forms, so neither reaches the function nor its logs.
echo "Calling [$API_URL/whoami] with the key as the subscription-key query parameter..."
QUERY_KEY_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" "$API_URL/whoami?subscription-key=$KEY")
FORWARDED_URL=$(jq -r '.url // ""' "$BODY_FILE" 2>/dev/null)
echo "HTTP $QUERY_KEY_STATUS, backend saw: $FORWARDED_URL"
if [[ "$QUERY_KEY_STATUS" == "200" && "$FORWARDED_URL" != *"subscription-key"* ]]; then
echo "The query-parameter key authenticated the call and was stripped before the backend"
else
echo "Expected 200 with no subscription-key in the URL the backend received"
FAILED=1
fi
# 9. A browser preflight is answered by the gateway itself, from the cors policy, without a key.
# On the emulator this is not yet observable: LocalStack answers CORS for every hostname it serves
# (its own origin allow-list, see EXTRA_CORS_ALLOWED_ORIGINS), the API Management gateway included,
# so the preflight never reaches the API's cors policy. Asserted against Azure only.
# TODO: assert this unconditionally once the emulator lets the gateway's cors policy answer.
echo "Sending a CORS preflight to [$API_URL/items]..."
PREFLIGHT_STATUS=$(curl -s -m 10 -o "$(null_device)" -D "$HEADERS_FILE" -w "%{http_code}" \
-X OPTIONS \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: GET" \
"$API_URL/items")
ALLOW_ORIGIN=$(grep -i "^access-control-allow-origin:" "$HEADERS_FILE" | tr -d '\r')
echo "HTTP $PREFLIGHT_STATUS ${ALLOW_ORIGIN:-(no Access-Control-Allow-Origin header)}"
if [[ "$PREFLIGHT_STATUS" == "200" && -n "$ALLOW_ORIGIN" ]]; then
echo "The preflight is answered by the gateway's cors policy"
elif [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
echo "Not asserted on the emulator: LocalStack's own CORS layer answered before the gateway's cors policy could"
else
echo "Expected a 200 preflight response with Access-Control-Allow-Origin"
FAILED=1
fi
# 10. A path that matches no operation gets Azure's 404 body.
echo "Calling [$API_URL/nothing-here]..."
UNKNOWN_STATUS=$(curl -s -m 10 -o "$BODY_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/nothing-here")
echo "HTTP $UNKNOWN_STATUS: $(cat "$BODY_FILE")"
# Case-insensitive like the two 401 checks above: Microsoft's own troubleshooting page renders
# this as "Resource Not Found" while the JSON body is conventionally lower case.
if [[ "$UNKNOWN_STATUS" == "404" ]] && grep -qi "Resource not found" "$BODY_FILE"; then
echo "Unknown operations get the gateway's 404"
else
echo "Expected the gateway's 404 for an unknown operation"
FAILED=1
fi
# 11. The rate limit. The policy allows RATE_LIMIT_CALLS calls a minute per subscription; the calls
# above count towards it, and the loop below runs until the gateway answers 429 with a Retry-After.
# This check runs last on purpose: the subscription stays limited for the rest of the window.
echo "Calling [$API_URL/items] until the rate limit trips (limit: $RATE_LIMIT_CALLS calls a minute)..."
LIMITED=0
for i in $(seq 1 $((RATE_LIMIT_CALLS + 2))); do
STATUS=$(curl -s -m 10 -o "$BODY_FILE" -D "$HEADERS_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/items")
echo " call $i: HTTP $STATUS"
if [[ "$STATUS" == "429" ]]; then
LIMITED=1
break
fi
done
if [[ $LIMITED == 1 ]]; then
echo "Rate limit response: $(cat "$BODY_FILE")"
RETRY_AFTER=$(grep -i "^retry-after:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
if [[ -n "$RETRY_AFTER" ]]; then
echo "The rate limit tripped with Retry-After: $RETRY_AFTER seconds"
else
echo "The 429 response carries no Retry-After header"
FAILED=1
fi
else
echo "The rate limit did not trip after $((RATE_LIMIT_CALLS + 2)) calls"
FAILED=1
fi
if [[ $FAILED == 0 ]]; then
echo "All validation checks passed"
else
echo "Some validation checks failed"
fi
exit $FAILED