#!/usr/bin/env bash set -u -o pipefail ENV_FILE="${1:-.vk-smoke.env}" failures=0 skips=0 TMP_DIR="" result() { local name="$1" status="$2" detail="${3:-}" printf '%s %s' "$status" "$name" [[ -z "$detail" ]] || printf ' — %s' "$detail" printf '\n' [[ "$status" != FAIL ]] || failures=$((failures + 1)) [[ "$status" != SKIP && "$status" != MANUAL ]] || skips=$((skips + 1)) } if [[ ! -f "$ENV_FILE" || -L "$ENV_FILE" ]]; then printf 'FAIL config — expected a regular, non-symlink 0600 file: %s\n' "$ENV_FILE" exit 2 fi mode="$(stat -c '%a' -- "$ENV_FILE" 2>/dev/null || true)" if [[ "$mode" != 600 ]]; then printf 'FAIL config — permissions must be 0600 (found %s)\n' "${mode:-unknown}" exit 2 fi declare -A config=() while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue [[ "$line" == *=* ]] || { printf 'FAIL config — malformed line\n'; exit 2; } key="${line%%=*}" value="${line#*=}" case "$key" in VK_SMOKE_BASE_URL|VK_SMOKE_SESSION_COOKIE|VK_SMOKE_PROXY_AUTH|VK_SMOKE_PUBLIC_POST_URL|VK_SMOKE_PRIVATE_POST_URL|VK_SMOKE_GROUP_ID|VK_SMOKE_CREATE_GIVEAWAY) ;; *) printf 'FAIL config — unsupported key %s\n' "$key"; exit 2 ;; esac [[ -z "${config[$key]+x}" ]] || { printf 'FAIL config — duplicate key %s\n' "$key"; exit 2; } [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || { printf 'FAIL config — multiline values are forbidden\n'; exit 2; } config[$key]="$value" done < "$ENV_FILE" BASE_URL="${config[VK_SMOKE_BASE_URL]:-}" SESSION_COOKIE="${config[VK_SMOKE_SESSION_COOKIE]:-}" PUBLIC_POST_URL="${config[VK_SMOKE_PUBLIC_POST_URL]:-}" PRIVATE_POST_URL="${config[VK_SMOKE_PRIVATE_POST_URL]:-}" GROUP_ID="${config[VK_SMOKE_GROUP_ID]:-}" ALLOW_CREATE="${config[VK_SMOKE_CREATE_GIVEAWAY]:-NO}" if [[ ! "$BASE_URL" =~ ^http://(localhost|127\.0\.0\.1)(:[0-9]+)?$ && "$BASE_URL" != 'https://random.ochenstarik.ru' ]]; then printf 'FAIL config — base URL must be loopback HTTP or https://random.ochenstarik.ru\n' exit 2 fi if [[ -n "$SESSION_COOKIE" && ! "$SESSION_COOKIE" =~ ^randomayzer_session=[A-Za-z0-9._~-]+$ ]]; then printf 'FAIL config — session cookie format is invalid\n' exit 2 fi PROXY_AUTH="${config[VK_SMOKE_PROXY_AUTH]:-}" if [[ -n "$PROXY_AUTH" && ! "$PROXY_AUTH" =~ ^[A-Za-z0-9._~-]+:[A-Za-z0-9._~-]+$ ]]; then printf 'FAIL config — proxy auth must use safe username:password characters\n' exit 2 fi if [[ "$BASE_URL" == 'https://random.ochenstarik.ru' && -z "$PROXY_AUTH" ]]; then printf 'FAIL config — VK_SMOKE_PROXY_AUTH is required for the domain smoke test\n' exit 2 fi umask 077 TMP_DIR="$(mktemp -d)" || { printf 'FAIL setup — could not create a private temporary directory\n' exit 2 } trap 'rm -rf -- "$TMP_DIR"' EXIT BODY="$TMP_DIR/body.json" HEADERS="$TMP_DIR/headers.txt" CURL_CONFIG="$TMP_DIR/curl.cfg" printf 'silent\nshow-error\n' > "$CURL_CONFIG" [[ -z "$SESSION_COOKIE" ]] || printf 'header = "Cookie: %s"\n' "$SESSION_COOKIE" >> "$CURL_CONFIG" [[ -z "$PROXY_AUTH" ]] || printf 'user = "%s"\n' "$PROXY_AUTH" >> "$CURL_CONFIG" request() { local method="$1" path="$2" payload="${3:-}" local args=(--config "$CURL_CONFIG" --connect-timeout 5 --max-time 120 --request "$method" --output "$BODY" --dump-header "$HEADERS" --write-out '%{http_code}') if [[ "$method" != GET ]]; then args+=(--header "Origin: $BASE_URL" --header 'Content-Type: application/json' --data "$payload") fi HTTP_CODE="$(curl "${args[@]}" "$BASE_URL$path" 2>/dev/null || true)" } json_success() { node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(d?.success === true ? 0 : 1)' "$BODY" >/dev/null 2>&1 } # Marker is emitted by the server for the actual provider instance, not inferred from access mode. json_live_success() { node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(d?.success === true && d.providerMode === "LIVE" ? 0 : 1)' "$BODY" >/dev/null 2>&1 } json_live_import_success() { node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(d?.success === true && d.providerMode === "LIVE" && Number.isSafeInteger(d.totalCount) && d.totalCount > 0 ? 0 : 1)' "$BODY" >/dev/null 2>&1 } # A: OAuth start creates a VK ID authorize redirect with PKCE S256. request GET /api/auth/vk/start if [[ "$HTTP_CODE" =~ ^30[1278]$ ]] && grep -Eiq '^location: https://id\.vk\.com/authorize[^\r]*code_challenge_method=S256' "$HEADERS"; then result A PASS 'VK ID authorize redirect and PKCE S256 found' else result A FAIL "expected VK ID 3xx authorize redirect with PKCE S256 (HTTP ${HTTP_CODE:-curl-error})" fi # B: consent, redirect completion, and cookie delivery require a human in a browser. result B MANUAL 'open /api/auth/vk/start, approve consent in VK, and confirm callback returns to the app with a session cookie' # C: authenticated session lookup. No response body or cookie is printed. if [[ -z "$SESSION_COOKIE" ]]; then result C SKIP 'set VK_SMOKE_SESSION_COOKIE after completing manual consent' else request GET /api/auth/me if [[ "$HTTP_CODE" == 200 ]] && node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(d?.authenticated === true ? 0 : 1)' "$BODY" >/dev/null 2>&1; then result C PASS 'authenticated session endpoint returned success'; else result C FAIL "session endpoint returned HTTP ${HTTP_CODE:-curl-error}"; fi fi POST_JSON="" GIVEAWAY_ID="" if [[ -z "$SESSION_COOKIE" || -z "$PUBLIC_POST_URL" ]]; then result D SKIP 'authenticated session and VK_SMOKE_PUBLIC_POST_URL are required' else payload="$(node -e 'process.stdout.write(JSON.stringify({url:process.argv[1],platform:"VK"}))' "$PUBLIC_POST_URL")" request POST /api/posts/preview "$payload" if [[ "$HTTP_CODE" == 200 ]] && json_live_success; then access_mode="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(d.effectiveCapabilities?.accessMode||"")' "$BODY")" if [[ "$access_mode" != PUBLIC_SERVICE ]]; then result D FAIL 'public post preview did not report PUBLIC_SERVICE access mode' else result D PASS 'public VK post preview succeeded with PUBLIC_SERVICE access mode' POST_JSON="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(JSON.stringify(d.post))' "$BODY")" fi else result D FAIL "public post preview requires LIVE provider mode (HTTP ${HTTP_CODE:-curl-error})"; fi fi if [[ -z "$SESSION_COOKIE" || -z "$PRIVATE_POST_URL" ]]; then result E SKIP 'authenticated session and VK_SMOKE_PRIVATE_POST_URL are required' else payload="$(node -e 'process.stdout.write(JSON.stringify({url:process.argv[1],platform:"VK"}))' "$PRIVATE_POST_URL")" request POST /api/posts/preview "$payload" if [[ "$HTTP_CODE" == 200 ]] && json_live_success; then mode="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(d.post?.resolvedAuthType||"")' "$BODY")" if [[ "$mode" == USER ]]; then result E PASS 'restricted post preview used organizer authorization'; else result E FAIL 'restricted post did not resolve with USER authorization'; fi else result E FAIL "restricted post preview requires LIVE provider mode (HTTP ${HTTP_CODE:-curl-error})"; fi fi if [[ "$ALLOW_CREATE" != YES ]]; then result F SKIP 'set VK_SMOKE_CREATE_GIVEAWAY=YES to create a test giveaway' result G SKIP 'requires a giveaway created by check F' result H SKIP 'requires a giveaway and VK_SMOKE_GROUP_ID' elif [[ -z "$SESSION_COOKIE" || -z "$POST_JSON" ]]; then result F FAIL 'cannot create without a successful public preview and authenticated session' result G SKIP 'requires a giveaway created by check F' result H SKIP 'requires a giveaway and VK_SMOKE_GROUP_ID' else create_payload="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(JSON.stringify({sourceUrl:p.sourceUrl,post:p,filterRules:{requireLike:true,requireComment:false,requireRepost:false,requireSubscription:false,excludeAdmins:false,excludeBlacklistedIds:[],minEligibleParticipants:1}}))' "$POST_JSON")" request POST /api/giveaways "$create_payload" if [[ "$HTTP_CODE" == 201 ]] && json_success; then GIVEAWAY_ID="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(d.giveaway?.id||"")' "$BODY")" if [[ -n "$GIVEAWAY_ID" ]]; then result F PASS 'test giveaway created'; else result F FAIL 'create response omitted giveaway id'; fi else result F FAIL "giveaway create returned HTTP ${HTTP_CODE:-curl-error}"; fi if [[ -n "$GIVEAWAY_ID" ]]; then import_payload='{"filterRules":{"requireLike":true,"requireComment":true,"requireRepost":false,"requireSubscription":false,"excludeAdmins":false,"excludeBlacklistedIds":[],"minEligibleParticipants":1}}' request POST "/api/giveaways/$GIVEAWAY_ID/participants" "$import_payload" if [[ "$HTTP_CODE" == 200 ]] && json_live_import_success; then result G PASS 'participant import succeeded'; else result G FAIL "participant import requires LIVE mode and nonempty participants (HTTP ${HTTP_CODE:-curl-error})"; fi if [[ -z "$GROUP_ID" ]]; then result H SKIP 'set VK_SMOKE_GROUP_ID to run subscription verification' else subscription_payload="$(node -e 'const p=JSON.parse(process.argv[1]); p.filterRules.requireSubscription=true; p.filterRules.targetGroupId=process.argv[2]; process.stdout.write(JSON.stringify(p))' "$import_payload" "$GROUP_ID")" request POST "/api/giveaways/$GIVEAWAY_ID/participants" "$subscription_payload" if [[ "$HTTP_CODE" == 200 ]] && json_live_import_success; then result H PASS 'subscription verification returned a complete result'; else result H FAIL "subscription verification requires LIVE mode and nonempty participants (HTTP ${HTTP_CODE:-curl-error})"; fi fi else result G SKIP 'requires a giveaway created by check F' result H SKIP 'requires a giveaway and VK_SMOKE_GROUP_ID' fi fi printf 'Smoke checks: %d failure(s), %d skipped/manual check(s).\n' "$failures" "$skips" [[ "$failures" -eq 0 ]]