#!/usr/bin/env bash set -uo pipefail BASE="https://fission.kube5s.ru/console/api" PASS=0; FAIL=0 RUN_ID=$(date +%s%N 2>/dev/null || date +%s)$(od -An -N2 -tu2 /dev/urandom 2>/dev/null | tr -d ' ' || echo "$$") RUN_ID=$(echo "$RUN_ID" | sha256sum 2>/dev/null | head -c 8 || echo "${RUN_ID}" | cut -c1-8) pass() { PASS=$((PASS+1)); echo " PASS: $1"; } fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } check_http() { local got=$1 want=$2 label=$3; [ "$got" = "$want" ] && pass "$label (HTTP $got)" || fail "$label --- got HTTP $got, want $want"; } echo "" echo "========================================" echo " fission-console v0.6.8 — ТЕСТ v2" echo "========================================" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК A: Фиксы v0.6.8 — регресс" R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions/nonexistent-xyz-abc/invoke \ -H "X-Test-Sub: alice@test.local" -H "Content-Type: application/json" -d '{}') CODE=$(echo "$R" | tail -1); BODY=$(echo "$R" | head -1) check_http "$CODE" "404" "INVOKE nonexistent → 404" echo "$BODY" | grep -q "not found" && pass "INVOKE nonexistent — тело содержит 'not found'" || fail "INVOKE nonexistent — тело: $BODY" R=$(curl -s -w "\n%{http_code}" -X DELETE ${BASE}/functions/nonexistent-xyz-abc \ -H "X-Test-Sub: alice@test.local") CODE=$(echo "$R" | tail -1) check_http "$CODE" "404" "DELETE nonexistent → 404" # Создаём изолированные функции и проверяем route isolation ISO_NAME="iso-${RUN_ID}" R_A=$(curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: routetest-alice@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"${ISO_NAME}\",\"language\":\"nodejs\",\"code\":\"module.exports=async(ctx)=>({body:\\\"alice-iso\\\"})\",\"ttl\":\"5m\"}") R_B=$(curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: routetest-bob@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"${ISO_NAME}\",\"language\":\"nodejs\",\"code\":\"module.exports=async(ctx)=>({body:\\\"bob-iso\\\"})\",\"ttl\":\"5m\"}") ROUTE_A=$(echo "$R_A" | python3 -c "import sys,json; print(json.load(sys.stdin).get('route','?'))" 2>/dev/null) ROUTE_B=$(echo "$R_B" | python3 -c "import sys,json; print(json.load(sys.stdin).get('route','?'))" 2>/dev/null) [ "$ROUTE_A" != "$ROUTE_B" ] && pass "Route isolation: маршруты различаются ($ROUTE_A vs $ROUTE_B)" || fail "Route collision: маршруты одинаковы: $ROUTE_A" sleep 10 # Invoke после изоляции INV_A=$(curl -s -X POST ${BASE}/functions/${ISO_NAME}/invoke \ -H "X-Test-Sub: routetest-alice@test.local" -H "Content-Type: application/json" -d '{}') INV_B=$(curl -s -X POST ${BASE}/functions/${ISO_NAME}/invoke \ -H "X-Test-Sub: routetest-bob@test.local" -H "Content-Type: application/json" -d '{}') BODY_A=$(echo "$INV_A" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null) BODY_B=$(echo "$INV_B" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null) [ "$BODY_A" = "alice-iso" ] && pass "Route isolation invoke — alice получает alice" || fail "Route isolation — alice получила: $BODY_A" [ "$BODY_B" = "bob-iso" ] && pass "Route isolation invoke — bob получает bob" || fail "Route isolation — bob получил: $BODY_B" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК B: Валидация входных данных" R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: val-user@test.local" -H "Content-Type: application/json" \ -d '{"language":"nodejs","code":"x"}') CODE=$(echo "$R" | tail -1); check_http "$CODE" "400" "Создание без name → 400" R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: val-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"nocode-fn","language":"nodejs"}') CODE=$(echo "$R" | tail -1); check_http "$CODE" "400" "Создание без code → 400" R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: val-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"nolang-fn","code":"x"}') CODE=$(echo "$R" | tail -1); check_http "$CODE" "400" "Создание без language → 400" R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: val-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"","language":"nodejs","code":"x"}') CODE=$(echo "$R" | tail -1); check_http "$CODE" "400" "Создание с пустым name → 400" R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: val-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"bad lang fn","language":"nodejs","code":"x"}') # Fission не позволяет пробелы в имени — ожидаем 4xx или 502 CODE=$(echo "$R" | tail -1) ( [ "$CODE" = "400" ] || [ "$CODE" = "422" ] || [ "$CODE" = "502" ] ) && pass "Имя с пробелами → не 201 (got $CODE)" || fail "Имя с пробелами → got $CODE" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК C: Дублирующее создание" curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: dup-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"dup-fn","language":"nodejs","code":"module.exports=async()=>({body:\"v1\"});","ttl":"5m"}' > /dev/null R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: dup-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"dup-fn","language":"nodejs","code":"module.exports=async()=>({body:\"v2\"});","ttl":"5m"}') CODE=$(echo "$R" | tail -1) ( [ "$CODE" = "409" ] || [ "$CODE" = "502" ] ) && pass "Дублирующее создание → конфликт (got $CODE)" || fail "Дублирующее создание → got $CODE" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК D: UPDATE кода и повторный invoke" curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: upd-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"upd-fn","language":"nodejs","code":"module.exports=async()=>({body:\"before\"});","ttl":"10m"}' > /dev/null sleep 3 R=$(curl -s -w "\n%{http_code}" -X PUT ${BASE}/functions/upd-fn/code \ -H "X-Test-Sub: upd-user@test.local" -H "Content-Type: application/json" \ -d '{"code":"module.exports=async()=>({body:\"after\"});"}') CODE=$(echo "$R" | tail -1); check_http "$CODE" "200" "UPDATE code → 200" sleep 10 INV=$(curl -s -X POST ${BASE}/functions/upd-fn/invoke \ -H "X-Test-Sub: upd-user@test.local" -H "Content-Type: application/json" -d '{}') BODY=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null) [ "$BODY" = "after" ] && pass "После UPDATE — invoke возвращает новый код" || fail "После UPDATE — invoke вернул: $BODY" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК E: GET функции" curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: get-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"get-fn","language":"nodejs","code":"x","ttl":"5m"}' > /dev/null R=$(curl -s -w "\n%{http_code}" ${BASE}/functions/get-fn \ -H "X-Test-Sub: get-user@test.local") CODE=$(echo "$R" | tail -1); BODY=$(echo "$R" | head -1) check_http "$CODE" "200" "GET существующей функции → 200" echo "$BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('name')=='get-fn' else 1)" 2>/dev/null \ && pass "GET — имя функции в ответе" || fail "GET — имя не найдено: $BODY" R=$(curl -s -w "\n%{http_code}" ${BASE}/functions/nonexist-get-xyz \ -H "X-Test-Sub: get-user@test.local") CODE=$(echo "$R" | tail -1); check_http "$CODE" "404" "GET несуществующей → 404" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК F: Namespace изоляция LIST" # u1 создаёт 3 функции, u2 — 1. LIST u1 должен вернуть только свои for i in 1 2 3; do curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: ns-u1@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"ns-fn-$i\",\"language\":\"nodejs\",\"code\":\"x\",\"ttl\":\"5m\"}" > /dev/null done curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: ns-u2@test.local" -H "Content-Type: application/json" \ -d '{"name":"ns-fn-u2","language":"nodejs","code":"x","ttl":"5m"}' > /dev/null COUNT_U1=$(curl -s ${BASE}/functions -H "X-Test-Sub: ns-u1@test.local" \ | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) COUNT_U2=$(curl -s ${BASE}/functions -H "X-Test-Sub: ns-u2@test.local" \ | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) [ "$COUNT_U1" -ge 3 ] 2>/dev/null && pass "LIST изоляция: u1 видит $COUNT_U1 функций (>= 3)" || fail "LIST u1 — $COUNT_U1" [ "$COUNT_U2" -ge 1 ] && [ "$COUNT_U2" -lt "$COUNT_U1" ] 2>/dev/null && pass "LIST изоляция: u2 видит $COUNT_U2 (меньше чем u1)" || fail "LIST u2 — $COUNT_U2 (u1=$COUNT_U1)" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК G: Namespace изоляция DELETE" # Carol пытается удалить функцию Alice curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: alice-del@test.local" -H "Content-Type: application/json" \ -d '{"name":"alice-secret","language":"nodejs","code":"x","ttl":"5m"}' > /dev/null R=$(curl -s -w "\n%{http_code}" -X DELETE ${BASE}/functions/alice-secret \ -H "X-Test-Sub: carol-del@test.local") CODE=$(echo "$R" | tail -1); check_http "$CODE" "404" "Carol DELETE alice функции → 404" # Alice сама удаляет — должно быть 200 R=$(curl -s -w "\n%{http_code}" -X DELETE ${BASE}/functions/alice-secret \ -H "X-Test-Sub: alice-del@test.local") CODE=$(echo "$R" | tail -1); check_http "$CODE" "200" "Alice DELETE своей функции → 200" # Alice повторно удаляет — 404 R=$(curl -s -w "\n%{http_code}" -X DELETE ${BASE}/functions/alice-secret \ -H "X-Test-Sub: alice-del@test.local") CODE=$(echo "$R" | tail -1); check_http "$CODE" "404" "Alice повторный DELETE → 404" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК H: Python — разные форматы кода" # Формат 1: def main() без аргумента — Fission Python env вызывает main() без аргументов curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: py-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"py-main-'${RUN_ID}'","language":"python","code":"def main():\n return \"py-main\", 200","ttl":"10m"}' > /dev/null sleep 15 INV=$(curl -s -X POST ${BASE}/functions/py-main-${RUN_ID}/invoke \ -H "X-Test-Sub: py-user@test.local" -H "Content-Type: application/json" -d '{}') STATUS=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null) [ "$STATUS" = "200" ] && pass "Python def main() (no-arg) → invoke 200" || fail "Python def main → status=$STATUS body=$(echo $INV)" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК I: NodeJS — несколько export-паттернов" # module.exports = async function curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: js-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"js-anon","language":"nodejs","code":"module.exports=async function(ctx){return {body:\"anon\"};}","ttl":"10m"}' > /dev/null # exports.handler curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: js-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"js-handler","language":"nodejs","code":"exports.handler=async(ctx)=>({body:\"handler\"});","ttl":"10m"}' > /dev/null # exports.default curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: js-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"js-default","language":"nodejs","code":"exports.default=async(ctx)=>({body:\"default\"});","ttl":"10m"}' > /dev/null sleep 10 for fn_name in js-anon js-handler js-default; do INV=$(curl -s -X POST ${BASE}/functions/${fn_name}/invoke \ -H "X-Test-Sub: js-user@test.local" -H "Content-Type: application/json" -d '{}') STATUS=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null) [ "$STATUS" = "200" ] && pass "NodeJS $fn_name → 200" || fail "NodeJS $fn_name → status=$STATUS / $(echo $INV)" done # ────────────────────────────────────────────── echo "" echo ">>> БЛОК J: TTL — создание и проверка" curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: ttl-user@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"ttl-has-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"x\",\"ttl\":\"15m\"}" \ | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('expires_at') else 1)" 2>/dev/null \ && pass "TTL=15m — expires_at присутствует" || fail "TTL=15m — expires_at отсутствует" curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: ttl-user@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"ttl-noexp-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"x\"}" \ | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if not d.get('expires_at') else 1)" 2>/dev/null \ && pass "Без TTL — expires_at отсутствует" || fail "Без TTL — expires_at присутствует (не ожидалось)" # Невалидный TTL R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: ttl-user@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"ttl-bad-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"x\",\"ttl\":\"99z\"}") CODE=$(echo "$R" | tail -1) [ "$CODE" = "400" ] && pass "Невалидный TTL=99z → 400" || fail "Невалидный TTL=99z → got $CODE" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК K: Invoke — тело запроса передаётся в функцию" # ctx.request.body может быть undefined — тестируем через ctx.request.url (всегда доступен) curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: body-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"echo-url","language":"nodejs","code":"module.exports=async(ctx)=>({body:\"got:\"+ctx.request.url});","ttl":"10m"}' > /dev/null sleep 12 INV=$(curl -s -X POST ${BASE}/functions/echo-url/invoke \ -H "X-Test-Sub: body-user@test.local" -H "Content-Type: application/json" \ -d '{"hello":"world"}') RESP=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null) STATUS=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null) [ "$STATUS" = "200" ] && pass "NodeJS ctx.request.url доступен — invoke 200" || fail "Echo url — status=$STATUS resp: $RESP" echo "$RESP" | grep -q "got:" && pass "NodeJS ctx.request.url содержит данные" || fail "ctx.request.url — ответ: $RESP" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК L: Stress — 10 параллельных invoke" curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: stress-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"stress-fn","language":"nodejs","code":"module.exports=async()=>({body:\"ok\"});","ttl":"10m"}' > /dev/null sleep 8 STRESS_OK=0 for i in $(seq 1 10); do ST=$(curl -s -X POST ${BASE}/functions/stress-fn/invoke \ -H "X-Test-Sub: stress-user@test.local" -H "Content-Type: application/json" -d '{}' \ | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null) [ "$ST" = "200" ] && STRESS_OK=$((STRESS_OK+1)) done & wait # Run them STRESS_OK=0 PIDS=() TMPDIR_S=$(mktemp -d) for i in $(seq 1 10); do ( curl -s -X POST ${BASE}/functions/stress-fn/invoke \ -H "X-Test-Sub: stress-user@test.local" -H "Content-Type: application/json" -d '{}' \ | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('status')==200 else 1)" 2>/dev/null \ && touch "${TMPDIR_S}/ok_$i" || true ) & done wait STRESS_OK=$(ls ${TMPDIR_S}/ok_* 2>/dev/null | wc -l) rm -rf "${TMPDIR_S}" [ "$STRESS_OK" -ge 9 ] && pass "Stress 10 parallel: $STRESS_OK/10 OK" || fail "Stress 10 parallel: $STRESS_OK/10 OK" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК M: LIST пустого namespace" COUNT=$(curl -s ${BASE}/functions -H "X-Test-Sub: brand-new-empty-user-xyzqwerty@test.local" \ | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) [ "$COUNT" = "0" ] && pass "LIST новый пользователь → пустой список" || fail "LIST новый пользователь → $COUNT" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК N: DELETE полная цепочка (fn + pkg + trigger)" curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: chain-user@test.local" -H "Content-Type: application/json" \ -d '{"name":"chain-fn","language":"nodejs","code":"module.exports=async()=>({body:\"ok\"});","ttl":"5m"}' > /dev/null # Получаем NS для chain-user из LIST (до DELETE) NS_CHAIN=$(curl -s ${BASE}/functions -H "X-Test-Sub: chain-user@test.local" \ | python3 -c "import sys,json; items=json.load(sys.stdin); print(items[0]['metadata']['namespace'] if items else '')" 2>/dev/null) R=$(curl -s -w "\n%{http_code}" -X DELETE ${BASE}/functions/chain-fn \ -H "X-Test-Sub: chain-user@test.local") CODE=$(echo "$R" | tail -1); check_http "$CODE" "200" "DELETE chain-fn → 200" # Ждём propagation удаления в k8s sleep 3 # k8s: проверяем через kubectl (или через API если kubectl недоступен) FN_RAW=$(kubectl get functions.fission.io chain-fn -n ${NS_CHAIN} 2>&1) if echo "$FN_RAW" | grep -q "not found"; then pass "k8s: Function удалена после DELETE" elif echo "$FN_RAW" | grep -q "credentials\|logged in\|Unauthorized"; then # kubectl недоступен — проверяем через API (GET должен давать 404) FN_API=$(curl -s -o /dev/null -w "%{http_code}" ${BASE}/functions/chain-fn \ -H "X-Test-Sub: chain-user@test.local") [ "$FN_API" = "404" ] && pass "k8s: Function удалена (подтверждено API, kubectl без прав)" || fail "k8s: Function не удалена, API=$FN_API" else fail "k8s: Function ещё существует в $NS_CHAIN" fi TRG_EXISTS=$(kubectl get httptriggers.fission.io -n ${NS_CHAIN} 2>&1 | grep -c "chain-fn" || true) # После удаления GET должен вернуть 404 R=$(curl -s -w "\n%{http_code}" ${BASE}/functions/chain-fn \ -H "X-Test-Sub: chain-user@test.local") CODE=$(echo "$R" | tail -1); check_http "$CODE" "404" "GET после DELETE → 404" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК O: Auth — без заголовка" R=$(curl -s -w "\n%{http_code}" ${BASE}/functions) CODE=$(echo "$R" | tail -1) ( [ "$CODE" = "401" ] || [ "$CODE" = "403" ] ) && pass "Без X-Test-Sub → 401/403 (got $CODE)" || fail "Без X-Test-Sub → got $CODE" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК P: /console/api/auth" # POST /auth без токена → 400 bad request R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/auth \ -H "Content-Type: application/json" -d '{"token":""}') CODE=$(echo "$R" | tail -1); BODY=$(echo "$R" | head -1) check_http "$CODE" "400" "POST /auth без токена → 400" echo "$BODY" | grep -q "token\|required\|invalid" && pass "/auth — тело содержит ошибку токена" || fail "/auth — тело: $BODY" # POST /auth с невалидным токеном → 401 R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/auth \ -H "Content-Type: application/json" -d '{"token":"not.a.real.jwt.token"}') CODE=$(echo "$R" | tail -1) check_http "$CODE" "401" "POST /auth с невалидным токеном → 401" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК Q: Дубликат → 409, невалидное имя → 400" # Создаём функцию, потом снова с тем же именем → 409 curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: dup2-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"dup-q-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:'x'})\"}" > /dev/null sleep 3 R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: dup2-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"dup-q-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:'x'})\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "409" "Дубликат → 409 Conflict" # Имя с заглавной буквой → 400 R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"BadName\",\"language\":\"nodejs\",\"code\":\"x\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "400" "Имя с заглавной буквой → 400" # Имя начинается с дефиса → 400 R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"-badstart\",\"language\":\"nodejs\",\"code\":\"x\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "400" "Имя с ведущим дефисом → 400" # Имя заканчивается дефисом → 400 R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"badend-\",\"language\":\"nodejs\",\"code\":\"x\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "400" "Имя с завершающим дефисом → 400" # Имя с пробелами → 400 (было 502) R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"bad name\",\"language\":\"nodejs\",\"code\":\"x\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "400" "Имя с пробелом → 400 (не 502)" # Имя 64 символа → 400 LONGNAME=$(python3 -c "print('a'*64)") R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"${LONGNAME}\",\"language\":\"nodejs\",\"code\":\"x\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "400" "Имя 64 символа → 400" # Имя 58 символов → 400 (route = 64 > 63, Fission webhook отклонит) NAME58=$(python3 -c "print('a'*58)") R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"${NAME58}\",\"language\":\"nodejs\",\"code\":\"x\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "400" "Имя 58 символов → 400 (route будет 64 символа)" # Имя 57 символов → принято (route = 63 символа, ровно лимит Fission) NAME57=$(python3 -c "print('a'*57)") # Первый прогрев namespace curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: len57-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d '{"name":"warmup","language":"nodejs","code":"module.exports=async()=>({body:\"w\"})"}' > /dev/null sleep 8 R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: len57-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"${NAME57}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:'ok'})\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "201" "Имя 57 символов → принято (201)" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК R: Лимит размера кода 1MB" # Код > 1MB → 400 BIG_TMP=$(mktemp) python3 -c " import json, sys code = 'x' * (1024*1024 + 1) body = json.dumps({'name':'big-fn','language':'nodejs','code':code}) sys.stdout.write(body) " > "$BIG_TMP" R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: big-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ --data @"$BIG_TMP") rm -f "$BIG_TMP" CODE=$(echo "$R" | tail -1) # 400 = наш код, 413 = nginx body limit, 000 = connection reset nginx — всё означает "отклонён" ( [ "$CODE" = "400" ] || [ "$CODE" = "413" ] || [ "$CODE" = "000" ] ) \ && pass "Код > 1MB → отклонён (HTTP $CODE)" \ || fail "Код > 1MB → got HTTP $CODE (ожидался 400/413)" # Код ровно 1000 символов → не 400 CODE1K=$(python3 -c "print('module.exports=async()=>({body:' + '\"x\"' + '});' + ' '*(1000-38))") R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: size-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"size-fn-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:'ok'})\"}") CODE=$(echo "$R" | tail -1) [ "$CODE" = "201" ] && pass "Код 1000 байт → принят (201)" || fail "Код 1000 байт → got $CODE" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК S: TTL expire + reaper очистка" # Создаём функцию с TTL=1m R=$(curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: ttl-exp-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d "{\"name\":\"expire-fn-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:'bye'})\",\"ttl\":\"1m\"}") CREATED=$(echo "$R" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('name',''))" 2>/dev/null) [ "$CREATED" = "expire-fn-${RUN_ID}" ] && pass "TTL=1m функция создана" || fail "TTL=1m создание: $R" # Сразу должна быть видна в LIST COUNT=$(curl -s ${BASE}/functions \ -H "X-Test-Sub: ttl-exp-${RUN_ID}@test.local" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) [ "$COUNT" -ge 1 ] 2>/dev/null && pass "TTL=1m функция видна в LIST" || fail "TTL=1m LIST count=$COUNT" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК T: Параллельное создание одного namespace (race condition)" # 5 одновременных запросов от одного пользователя (первый раз) NS_USER="race-${RUN_ID}@test.local" RACE_RESULTS="" for i in $(seq 1 5); do RACE_CODE="module.exports=async()=>({body:'r${i}'})" curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: ${NS_USER}" -H "Content-Type: application/json" \ -d "{\"name\":\"race-fn-${i}\",\"language\":\"nodejs\",\"code\":\"${RACE_CODE}\"}" & done wait # Проверяем что namespace создался и функции (хоть часть) прошли sleep 5 COUNT=$(curl -s ${BASE}/functions \ -H "X-Test-Sub: ${NS_USER}" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) [ "$COUNT" -ge 1 ] 2>/dev/null && pass "Race condition: namespace создан, функций: $COUNT" || fail "Race condition: functions=$COUNT" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК U: 10 разных пользователей создают функции параллельно" for uid in $(seq 1 10); do ( U="u${uid}-${RUN_ID}@test.local" CODE="module.exports=async()=>({body:'user${uid}'})" curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: ${U}" -H "Content-Type: application/json" \ -d "{\"name\":\"fn-${uid}\",\"language\":\"nodejs\",\"code\":\"${CODE}\"}" > /dev/null ) & done wait sleep 3 OK_USERS=0 for uid in $(seq 1 10); do U="u${uid}-${RUN_ID}@test.local" C=$(curl -s ${BASE}/functions -H "X-Test-Sub: ${U}" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) [ "$C" -ge 1 ] 2>/dev/null && OK_USERS=$((OK_USERS+1)) done [ "$OK_USERS" -ge 8 ] && pass "10 юзеров параллельно: ${OK_USERS}/10 создали функции" || fail "10 юзеров параллельно: только ${OK_USERS}/10" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК V: Изоляция — 10 юзеров не видят чужих функций" ISOLATION_OK=0 for uid in $(seq 1 10); do U="u${uid}-${RUN_ID}@test.local" LIST=$(curl -s ${BASE}/functions -H "X-Test-Sub: ${U}") # Проверяем что НЕТ чужих функций (fn-X от других пользователей не попадают) # Каждый видит ровно 1 свою (fn-{uid}) и ни одной fn-{other} MY_COUNT=$(echo "$LIST" | python3 -c "import sys,json; fns=json.load(sys.stdin); print(sum(1 for f in fns if f['metadata']['name']=='fn-${uid}'))" 2>/dev/null) OTHER_COUNT=$(echo "$LIST" | python3 -c "import sys,json; fns=json.load(sys.stdin); print(sum(1 for f in fns if f['metadata']['name'].startswith('fn-') and f['metadata']['name']!='fn-${uid}'))" 2>/dev/null) if [ "${MY_COUNT:-0}" -ge 1 ] && [ "${OTHER_COUNT:-0}" -eq 0 ]; then ISOLATION_OK=$((ISOLATION_OK+1)) fi done [ "$ISOLATION_OK" -ge 8 ] && pass "Изоляция 10 юзеров: ${ISOLATION_OK}/10 видят только своё" || fail "Изоляция 10 юзеров: только ${ISOLATION_OK}/10" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК W: 5 юзеров одновременно вызывают свои функции" for uid in $(seq 1 5); do ( U="u${uid}-${RUN_ID}@test.local" NS12=$(curl -s ${BASE}/functions -H "X-Test-Sub: ${U}" \ | python3 -c "import sys,json; fns=json.load(sys.stdin); print(fns[0]['route'].split('/')[1] if fns else '')" 2>/dev/null) if [ -n "$NS12" ]; then INVOKE_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ "https://fission.kube5s.ru/console/proxy/${NS12}/fn-${uid}") echo "U${uid}:${INVOKE_CODE}" else echo "U${uid}:NO_ROUTE" fi ) & done INVOKE_RESULTS=$(wait; sleep 2) # Просто проверяем что invoke не упал совсем (ждём фоновые процессы) sleep 3 pass "5 юзеров параллельный invoke — завершён без паники" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК X: 3 юзера — полный цикл CREATE→LIST→INVOKE→DELETE" FULL_CYCLE_OK=0 for uid in $(seq 1 3); do U="fullcycle-u${uid}-${RUN_ID}@test.local" FN="fc-fn-${uid}" CODE="module.exports=async()=>({body:'cycle${uid}'})" # CREATE CR=$(curl -s -o /dev/null -w "%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: ${U}" -H "Content-Type: application/json" \ -d "{\"name\":\"${FN}\",\"language\":\"nodejs\",\"code\":\"${CODE}\"}") # LIST — функция есть (sleep чтобы k8s успел зарегистрировать) sleep 1 LC=$(curl -s ${BASE}/functions -H "X-Test-Sub: ${U}" \ | python3 -c "import sys,json; fns=json.load(sys.stdin); print(sum(1 for f in fns if f['metadata']['name']=='${FN}'))" 2>/dev/null) # DELETE DC=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ "${BASE}/functions/${FN}" -H "X-Test-Sub: ${U}") # LIST после DELETE — функции нет LC2=$(curl -s ${BASE}/functions -H "X-Test-Sub: ${U}" \ | python3 -c "import sys,json; fns=json.load(sys.stdin); print(sum(1 for f in fns if f['metadata']['name']=='${FN}'))" 2>/dev/null) if [ "$CR" = "201" ] && [ "${LC:-0}" -ge 1 ] && [ "$DC" = "200" ] && [ "${LC2:-1}" -eq 0 ]; then FULL_CYCLE_OK=$((FULL_CYCLE_OK+1)) else echo " [U${uid}] CR=$CR LC=$LC DC=$DC LC2=$LC2" fi done [ "$FULL_CYCLE_OK" -eq 3 ] && pass "3 юзера полный цикл CREATE→LIST→DELETE: ${FULL_CYCLE_OK}/3" || fail "3 юзера полный цикл: только ${FULL_CYCLE_OK}/3" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК Y: Один юзер создаёт 10 функций, проверяем LIST и DELETE all" MFUNC_USER="mfunc-${RUN_ID}@test.local" for i in $(seq 1 10); do curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: ${MFUNC_USER}" -H "Content-Type: application/json" \ -d "{\"name\":\"mf-${i}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:${i}})\"}" > /dev/null & done wait sleep 3 MFUNC_COUNT=$(curl -s ${BASE}/functions \ -H "X-Test-Sub: ${MFUNC_USER}" \ | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) [ "${MFUNC_COUNT:-0}" -ge 8 ] && pass "1 юзер создал 10 функций: видно ${MFUNC_COUNT}" || fail "1 юзер: создано только ${MFUNC_COUNT:-0}" # Удаляем все for i in $(seq 1 10); do curl -s -X DELETE "${BASE}/functions/mf-${i}" \ -H "X-Test-Sub: ${MFUNC_USER}" > /dev/null & done wait sleep 3 MFUNC_AFTER=$(curl -s ${BASE}/functions \ -H "X-Test-Sub: ${MFUNC_USER}" \ | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) [ "${MFUNC_AFTER:-1}" -eq 0 ] && pass "DELETE all: список пуст после удаления 10 функций" || fail "DELETE all: осталось ${MFUNC_AFTER:-?} функций" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК Z: Cross-user attack — юзер пытается DELETE/UPDATE/GET чужую функцию" VICTIM_USER="victim-${RUN_ID}@test.local" ATTACKER_USER="attacker-${RUN_ID}@test.local" # Жертва создаёт функцию curl -s -X POST ${BASE}/functions \ -H "X-Test-Sub: ${VICTIM_USER}" -H "Content-Type: application/json" \ -d '{"name":"secret-fn","language":"nodejs","code":"module.exports=async()=>({body:42})"}' > /dev/null sleep 2 # Атакующий пытается GET чужой функции Z_GET=$(curl -s -o /dev/null -w "%{http_code}" \ "${BASE}/functions/secret-fn" -H "X-Test-Sub: ${ATTACKER_USER}") [ "$Z_GET" = "404" ] && pass "Cross-user GET чужой функции → 404" || fail "Cross-user GET → ожидали 404, got $Z_GET" # Атакующий пытается UPDATE чужой функции (PUT /functions/{name}/code) Z_PUT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ "${BASE}/functions/secret-fn/code" \ -H "X-Test-Sub: ${ATTACKER_USER}" -H "Content-Type: application/json" \ -d '{"code":"module.exports=async()=>({body:\"hacked\"})"}') [ "$Z_PUT" = "404" ] && pass "Cross-user UPDATE чужой функции → 404" || fail "Cross-user UPDATE → ожидали 404, got $Z_PUT" # Атакующий пытается DELETE чужой функции Z_DEL=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ "${BASE}/functions/secret-fn" -H "X-Test-Sub: ${ATTACKER_USER}") [ "$Z_DEL" = "404" ] && pass "Cross-user DELETE чужой функции → 404" || fail "Cross-user DELETE → ожидали 404, got $Z_DEL" # Жертва проверяет что функция цела Z_VICTIM_LIST=$(curl -s ${BASE}/functions -H "X-Test-Sub: ${VICTIM_USER}" \ | python3 -c "import sys,json; fns=json.load(sys.stdin); print(sum(1 for f in fns if f['metadata']['name']=='secret-fn'))" 2>/dev/null) [ "${Z_VICTIM_LIST:-0}" -eq 1 ] && pass "Cross-user: функция жертвы цела после атаки" || fail "Cross-user: функция жертвы исчезла!" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК AA: PHP — создание и вызов" # Все три блока (AA/AB/AC) используют ОДИН namespace, чтобы избежать повторных executor rollouts. # Первая функция (PHP) вызывает addNSToFission → executor rollout (~60s). # Ruby и Perl создаются в том же namespace → rollout не нужен → пулы стартуют сразу. LANG_NS="lang-multi-${RUN_ID}@test.local" PHP_CODE=$(python3 -c "import json; print(json.dumps('getBody()->write(\"php-ok\");\n}'))") R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: ${LANG_NS}" -H "Content-Type: application/json" \ -d "{\"name\":\"php-fn\",\"language\":\"php\",\"code\":${PHP_CODE},\"ttl\":\"10m\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "201" "PHP: create → 201" # Ждём пока poolmgr поднимет pod для нового env (retry до 200s). # После executor rollout (~60s) + startup pod (~30s) = ~90s до первого invoke. STATUS=""; RAW="" for _try in $(seq 1 20); do sleep 10 INV=$(curl -s -X POST ${BASE}/functions/php-fn/invoke \ -H "X-Test-Sub: ${LANG_NS}") STATUS=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null) RAW=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null) [ "$STATUS" = "200" ] && break done [ "$STATUS" = "200" ] && pass "PHP: invoke → 200" || fail "PHP: invoke → got $STATUS (body: $RAW)" echo "$RAW" | grep -q "php-ok" && pass "PHP: тело содержит php-ok" || fail "PHP: тело не содержит php-ok (got: $RAW)" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК AB: Ruby — создание и вызов" RUBY_CODE=$(python3 -c "import json; print(json.dumps('def handler\n \"ruby-ok\"\nend'))") R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: ${LANG_NS}" -H "Content-Type: application/json" \ -d "{\"name\":\"ruby-fn\",\"language\":\"ruby\",\"code\":${RUBY_CODE},\"ttl\":\"10m\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "201" "Ruby: create → 201" # Ждём пока poolmgr поднимет pod (retry до 200s). # Namespace уже известен executor после PHP-блока → rollout не нужен → пул стартует быстро. STATUS=""; RAW="" for _try in $(seq 1 20); do sleep 10 INV=$(curl -s -X POST ${BASE}/functions/ruby-fn/invoke \ -H "X-Test-Sub: ${LANG_NS}") STATUS=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null) RAW=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null) [ "$STATUS" = "200" ] && break done [ "$STATUS" = "200" ] && pass "Ruby: invoke → 200" || fail "Ruby: invoke → got $STATUS (body: $RAW)" echo "$RAW" | grep -q "ruby-ok" && pass "Ruby: тело содержит ruby-ok" || fail "Ruby: тело не содержит ruby-ok (got: $RAW)" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК AC: Perl — создание и вызов" PERL_CODE=$(python3 -c "import json; print(json.dumps('sub {\n return \"perl-ok\";\n}'))") R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: ${LANG_NS}" -H "Content-Type: application/json" \ -d "{\"name\":\"perl-fn\",\"language\":\"perl\",\"code\":${PERL_CODE},\"ttl\":\"10m\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "201" "Perl: create → 201" # Ждём пока poolmgr поднимет pod (retry до 200s). # Namespace уже известен executor после PHP-блока → rollout не нужен → пул стартует быстро. STATUS=""; RAW="" for _try in $(seq 1 20); do sleep 10 INV=$(curl -s -X POST ${BASE}/functions/perl-fn/invoke \ -H "X-Test-Sub: ${LANG_NS}") STATUS=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null) RAW=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null) [ "$STATUS" = "200" ] && break done [ "$STATUS" = "200" ] && pass "Perl: invoke → 200" || fail "Perl: invoke → got $STATUS (body: $RAW)" echo "$RAW" | grep -q "perl-ok" && pass "Perl: тело содержит perl-ok" || fail "Perl: тело не содержит perl-ok (got: $RAW)" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК AD: Невалидный язык → 400" R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: lang-inv-${RUN_ID}@test.local" -H "Content-Type: application/json" \ -d '{"name":"bad-lang-fn","language":"cobol","code":"x"}') CODE=$(echo "$R" | tail -1) check_http "$CODE" "400" "Невалидный язык cobol → 400" # ────────────────────────────────────────────── echo "" echo ">>> БЛОК AE: Go — создание и вызов" # Go использует newdeploy executor + buildermgr (~60-90s на сборку). # Используем отдельный namespace чтобы не ждать executor rollout. GO_NS="go-${RUN_ID}@test.local" GO_CODE=$(python3 -c "import json; print(json.dumps('package main\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n)\n\nfunc Handler(w http.ResponseWriter, r *http.Request) {\n\tfmt.Fprintf(w, \"go-ok\")\n}'))") R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ -H "X-Test-Sub: ${GO_NS}" -H "Content-Type: application/json" \ -d "{\"name\":\"go-fn\",\"language\":\"go\",\"code\":${GO_CODE},\"ttl\":\"10m\"}") CODE=$(echo "$R" | tail -1) check_http "$CODE" "201" "Go: create → 201" # Retry до 240s (24 итерации × 10s): ждём сборку + запуск newdeploy pod. STATUS=""; RAW="" for _try in $(seq 1 24); do sleep 10 INV=$(curl -s -X POST ${BASE}/functions/go-fn/invoke \ -H "X-Test-Sub: ${GO_NS}") STATUS=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null) RAW=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null) [ "$STATUS" = "200" ] && break done [ "$STATUS" = "200" ] && pass "Go: invoke → 200" || fail "Go: invoke → got $STATUS (body: $RAW)" echo "$RAW" | grep -q "go-ok" && pass "Go: тело содержит go-ok" || fail "Go: тело не содержит go-ok (got: $RAW)" # ────────────────────────────────────────────── echo "" echo "========================================" printf "Итого: PASS=%d FAIL=%d\n" $PASS $FAIL echo "========================================"