#!/usr/bin/env bash set -euo pipefail API_URL="${AIHR_PERSONAL_API_URL:-https://wygj-api.localhost}" QDRANT_URL="${AIHR_QDRANT_URL:-http://127.0.0.1:6333}" QDRANT_COLLECTION="${AIHR_PERSONAL_QDRANT_COLLECTION:-aihr_personal_knowledge}" DB_CONTAINER="${AIHR_DB_CONTAINER:-wygj-mysql}" DB_NAME="${AIHR_DB_NAME:-ry-vue}" SMS_CODE="${AIHR_SMS_DEV_CODE:-123456}" DOCKER_NETWORK="${AIHR_DOCKER_NETWORK:-wygj_default}" RUN_ID="$(date +%Y%m%d%H%M%S)-$$-${RANDOM}" PHONE_SUFFIX=$(( (10#$(date +%s) + $$ + RANDOM) % 99999998 + 1 )) PHONE_A="138$(printf '%08d' "$PHONE_SUFFIX")" PHONE_B="138$(printf '%08d' $((PHONE_SUFFIX % 99999999 + 1)))" TITLE="personal-assistant-smoke-$RUN_ID" CONTENT="个人助理隔离烟测唯一标记 ${RUN_ID},物业催费沟通资料。" TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/wygj-personal-smoke.XXXXXX")" chmod 700 "$TMP_ROOT" TOKEN_A="" CLIENT_A="" TOKEN_B="" CLIENT_B="" USER_A="" USER_B="" ITEM_ID="" OSS_ID="" OBJECT_KEY="" OSS_URL="" JOB_ID="" SESSION_ID="" OWN_PHONE_A=0 OWN_PHONE_B=0 CLEANED=0 HTTP_STATUS="" HTTP_BODY="" LOGIN_TOKEN="" LOGIN_CLIENT="" TEMP_FILES=() fail() { echo "FAIL: $*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || fail "missing command: $1"; } expect_code() { local actual="$1" expected="$2" label="$3" [[ "$actual" == "$expected" ]] || fail "$label expected=$expected actual=$actual" } mysql() { docker exec "$DB_CONTAINER" mysql -uroot -proot --default-character-set=utf8mb4 -N -B -D "$DB_NAME" -e "$1" 2>/dev/null } register_tmp() { local path="$1" chmod 600 "$path" TEMP_FILES+=("$path") } cleanup_once() { [[ "$CLEANED" == 0 ]] || return 0 CLEANED=1 set +e [[ ${#TEMP_FILES[@]} == 0 ]] || rm -f -- "${TEMP_FILES[@]}" rm -rf -- "$TMP_ROOT" [[ "${AIHR_SMOKE_CLEANUP_DRY_RUN:-0}" != 1 ]] || return 0 if [[ "$OWN_PHONE_A" == 1 && ! "$USER_A" =~ ^[0-9]+$ ]]; then USER_A="$(mysql "select user_id from sys_user where phonenumber='$PHONE_A' and remark='移动端短信自动注册' order by user_id desc limit 1" | head -1)" fi if [[ "$OWN_PHONE_B" == 1 && ! "$USER_B" =~ ^[0-9]+$ ]]; then USER_B="$(mysql "select user_id from sys_user where phonenumber='$PHONE_B' and remark='移动端短信自动注册' order by user_id desc limit 1" | head -1)" fi if [[ "$USER_A" =~ ^[0-9]+$ && ! "$ITEM_ID" =~ ^[0-9]+$ ]]; then ITEM_ID="$(mysql "select id from aihr_personal_item where tenant_id='000000' and owner_user_id=$USER_A and title='$TITLE' order by id desc limit 1" | head -1)" fi if [[ "$USER_A" =~ ^[0-9]+$ && "$ITEM_ID" =~ ^[0-9]+$ ]]; then if [[ ! "$OSS_ID" =~ ^[0-9]+$ || -z "$OBJECT_KEY" ]]; then read -r OSS_ID OBJECT_KEY OSS_URL <<<"$(mysql "select o.oss_id,o.file_name,o.url from sys_oss o join aihr_personal_item i on i.oss_id=o.oss_id where i.id=$ITEM_ID and i.owner_user_id=$USER_A and i.title='$TITLE' limit 1")" fi [[ "$JOB_ID" =~ ^[0-9]+$ ]] || JOB_ID="$(mysql "select id from aihr_personal_cleanup_job where tenant_id='000000' and owner_user_id=$USER_A and item_id=$ITEM_ID order by id desc limit 1" | head -1)" fi if [[ "$USER_A" =~ ^[0-9]+$ && ! "$SESSION_ID" =~ ^[0-9]+$ ]]; then SESSION_ID="$(mysql "select s.id from aihr_personal_chat_session s join aihr_personal_chat_message m on m.session_id=s.id and m.owner_user_id=s.owner_user_id where s.tenant_id='000000' and s.owner_user_id=$USER_A and m.content like '%$RUN_ID%' order by s.id desc limit 1" | head -1)" fi if [[ "$USER_A" =~ ^[0-9]+$ && "$ITEM_ID" =~ ^[0-9]+$ ]]; then curl -sS -X POST "$QDRANT_URL/collections/$QDRANT_COLLECTION/points/delete?wait=true" \ -H 'Content-Type: application/json' \ --data "{\"filter\":{\"must\":[{\"key\":\"tenant_id\",\"match\":{\"value\":\"000000\"}},{\"key\":\"owner_user_id\",\"match\":{\"value\":$USER_A}},{\"key\":\"item_id\",\"match\":{\"value\":$ITEM_ID}}]}}" >/dev/null 2>&1 || true fi if [[ "$OSS_ID" =~ ^[0-9]+$ && "$USER_A" =~ ^[0-9]+$ && "$ITEM_ID" =~ ^[0-9]+$ \ && "$OBJECT_KEY" =~ ^personal/000000/$USER_A/$ITEM_ID/[A-Za-z0-9._/-]+$ \ && "$OBJECT_KEY" != *".."* ]]; then docker run --rm --network "$DOCKER_NETWORK" \ -e 'MC_HOST_local=http://ruoyi:ruoyi123@wygj-minio:9000' minio/mc \ rm --force "local/ruoyi-personal/$OBJECT_KEY" >/dev/null 2>&1 || true mysql "delete from sys_oss where oss_id=$OSS_ID and service='personal-minio' and file_name='$OBJECT_KEY'" >/dev/null 2>&1 || true fi if [[ "$ITEM_ID" =~ ^[0-9]+$ && "$USER_A" =~ ^[0-9]+$ ]]; then mysql "delete from aihr_personal_fragment where tenant_id='000000' and owner_user_id=$USER_A and item_id=$ITEM_ID; delete from aihr_personal_cleanup_job where tenant_id='000000' and owner_user_id=$USER_A and item_id=$ITEM_ID; delete from aihr_personal_item where tenant_id='000000' and owner_user_id=$USER_A and id=$ITEM_ID and title='$TITLE';" >/dev/null 2>&1 || true fi if [[ "$SESSION_ID" =~ ^[0-9]+$ && "$USER_A" =~ ^[0-9]+$ ]]; then mysql "delete from aihr_personal_chat_message where tenant_id='000000' and owner_user_id=$USER_A and session_id=$SESSION_ID; delete from aihr_personal_chat_session where tenant_id='000000' and owner_user_id=$USER_A and id=$SESSION_ID;" >/dev/null 2>&1 || true fi for owner in "$USER_A" "$USER_B"; do [[ "$owner" =~ ^[0-9]+$ ]] || continue mysql "delete from aihr_personal_space where tenant_id='000000' and owner_user_id=$owner and not exists (select 1 from aihr_personal_item where tenant_id='000000' and owner_user_id=$owner);" >/dev/null 2>&1 || true done if [[ "$OWN_PHONE_A" == 1 && "$USER_A" =~ ^[0-9]+$ ]]; then mysql "delete from sys_user_role where user_id=$USER_A; delete from sys_user_post where user_id=$USER_A; delete from sys_user where user_id=$USER_A and phonenumber='$PHONE_A' and remark='移动端短信自动注册'; delete from sys_logininfor where user_name='$PHONE_A';" >/dev/null 2>&1 || true fi if [[ "$OWN_PHONE_B" == 1 && "$USER_B" =~ ^[0-9]+$ ]]; then mysql "delete from sys_user_role where user_id=$USER_B; delete from sys_user_post where user_id=$USER_B; delete from sys_user where user_id=$USER_B and phonenumber='$PHONE_B' and remark='移动端短信自动注册'; delete from sys_logininfor where user_name='$PHONE_B';" >/dev/null 2>&1 || true fi for phone in "$PHONE_A" "$PHONE_B"; do redis-cli -h 127.0.0.1 -p 16379 -a ruoyi123 DEL "global:captcha_codes:$phone" >/dev/null 2>&1 || true while IFS= read -r key; do [[ -n "$key" ]] && redis-cli -h 127.0.0.1 -p 16379 -a ruoyi123 DEL "$key" >/dev/null 2>&1 || true done < <(redis-cli -h 127.0.0.1 -p 16379 -a ruoyi123 --scan --pattern "*resource/sms/code:$phone*" 2>/dev/null) done } on_exit() { local code=$?; trap - EXIT INT TERM; cleanup_once; exit "$code"; } on_int() { trap - EXIT INT TERM; cleanup_once; exit 130; } on_term() { trap - EXIT INT TERM; cleanup_once; exit 143; } trap on_exit EXIT trap on_int INT trap on_term TERM if [[ -n "${AIHR_SMOKE_SIGNAL_PROBE:-}" ]]; then kill -s "$AIHR_SMOKE_SIGNAL_PROBE" "$$" exit 99 fi if [[ "${1:-}" == "--signal-self-test" ]]; then set +e AIHR_SMOKE_CLEANUP_DRY_RUN=1 AIHR_SMOKE_SIGNAL_PROBE=INT "$0" >/dev/null 2>&1; int_code=$? AIHR_SMOKE_CLEANUP_DRY_RUN=1 AIHR_SMOKE_SIGNAL_PROBE=TERM "$0" >/dev/null 2>&1; term_code=$? set -e expect_code "$int_code" 130 "INT exit status" expect_code "$term_code" 143 "TERM exit status" echo "PASS: signal exit statuses" exit 0 fi request() { local method="$1" path="$2" token="${3:-}" client="${4:-}" body="${5:-}" tmp tmp="$(mktemp "$TMP_ROOT/http.XXXXXX")" register_tmp "$tmp" local args=(-k -sS -X "$method" "$API_URL$path" -o "$tmp" -w '%{http_code}') [[ -z "$token" ]] || args+=(-H "Authorization: Bearer $token" -H "clientid: $client") [[ -z "$body" ]] || args+=(-H 'Content-Type: application/json' --data "$body") HTTP_STATUS="$(curl "${args[@]}" || printf '000')" HTTP_BODY="$(cat "$tmp")" rm -f "$tmp" } expect_success() { local label="$1" expect_code "$HTTP_STATUS" 200 "$label HTTP" expect_code "$(jq -r '.code // empty' <<<"$HTTP_BODY")" 200 "$label business" } expect_error() { local expected="$1" label="$2" expect_code "$HTTP_STATUS" 200 "$label HTTP" [[ "$(jq -r '.code // empty' <<<"$HTTP_BODY")" != 200 ]] || fail "$label unexpectedly succeeded" expect_code "$(jq -r '.msg // empty' <<<"$HTTP_BODY")" "$expected" "$label message" } login() { local phone="$1" request GET "/resource/sms/code?phonenumber=$phone" expect_success "send sms code" request POST /auth/mobile/sms-login "" "" \ "{\"tenantId\":\"000000\",\"phonenumber\":\"$phone\",\"smsCode\":\"$SMS_CODE\"}" expect_success "mobile sms login" LOGIN_TOKEN="$(jq -er '.data.access_token' <<<"$HTTP_BODY")" LOGIN_CLIENT="$(jq -er '.data.client_id' <<<"$HTTP_BODY")" } need curl; need jq; need docker; need redis-cli request GET /auth/tenant/list expect_success "backend health" docker ps --format '{{.Names}}' | grep -qx "$DB_CONTAINER" || fail "database container not running: $DB_CONTAINER" mysql "select 1 from aihr_personal_item limit 1" >/dev/null || fail "personal schema missing; run COMPOSE_PROJECT_NAME=wygj ./scripts/reset-dev-db.sh" expect_code "$(mysql "select access_policy from sys_oss_config where tenant_id='000000' and config_key='minio'")" 1 "shared minio policy unchanged" expect_code "$(mysql "select concat(bucket_name,':',access_policy) from sys_oss_config where tenant_id='000000' and config_key='personal-minio'")" "ruoyi-personal:0" "personal private storage config" expect_code "$(mysql "select count(*) from sys_user where phonenumber in ('$PHONE_A','$PHONE_B')")" 0 "unique smoke users must not preexist" OWN_PHONE_A=1 OWN_PHONE_B=1 bucket_policy="$(docker run --rm --network "$DOCKER_NETWORK" \ -e 'MC_HOST_local=http://ruoyi:ruoyi123@wygj-minio:9000' minio/mc anonymous get local/ruoyi-personal 2>/dev/null)" [[ "$bucket_policy" == *"private"* ]] || fail "personal bucket anonymous policy is not private" login "$PHONE_A"; TOKEN_A="$LOGIN_TOKEN"; CLIENT_A="$LOGIN_CLIENT" USER_A="$(mysql "select user_id from sys_user where phonenumber='$PHONE_A' and remark='移动端短信自动注册'")" [[ "$USER_A" =~ ^[0-9]+$ ]] || fail "A smoke user was not created" login "$PHONE_B"; TOKEN_B="$LOGIN_TOKEN"; CLIENT_B="$LOGIN_CLIENT" USER_B="$(mysql "select user_id from sys_user where phonenumber='$PHONE_B' and remark='移动端短信自动注册'")" [[ "$USER_B" =~ ^[0-9]+$ && "$USER_A" != "$USER_B" ]] || fail "B smoke user was not created independently" request POST /api/aihr/personal-assistant/items/text "$TOKEN_A" "$CLIENT_A" \ "{\"title\":\"$TITLE\",\"content\":\"$CONTENT\",\"tags\":[\"smoke\",\"$RUN_ID\"]}" expect_success "A create text item" ITEM_ID="$(jq -er '.data.itemId | tostring' <<<"$HTTP_BODY")" read -r OSS_ID OBJECT_KEY OSS_URL <<<"$(mysql "select o.oss_id,o.file_name,o.url from sys_oss o join aihr_personal_item i on i.oss_id=o.oss_id where i.id=$ITEM_ID and i.owner_user_id=$USER_A and i.title='$TITLE'")" [[ "$OSS_ID" =~ ^[0-9]+$ && "$OBJECT_KEY" =~ ^personal/000000/$USER_A/$ITEM_ID/[A-Za-z0-9._/-]+$ ]] || fail "unexpected personal OSS ownership metadata" for _ in {1..40}; do request GET "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_A" "$CLIENT_A" [[ "$(jq -r '.data.status // empty' <<<"$HTTP_BODY")" == READY ]] && break sleep 1 done expect_success "A item detail" expect_code "$(jq -r '.data.status' <<<"$HTTP_BODY")" READY "A text item processing" anon_tmp="$(mktemp "$TMP_ROOT/anonymous.XXXXXX")"; register_tmp "$anon_tmp" anon_status="$(curl -sS -o "$anon_tmp" -w '%{http_code}' "$OSS_URL" || printf '000')" expect_code "$anon_status" 403 "personal object anonymous GET" request POST /api/aihr/personal-assistant/search "$TOKEN_A" "$CLIENT_A" \ "{\"queryText\":\"隔离烟测\",\"scope\":[\"PERSONAL\"],\"itemIds\":[\"$ITEM_ID\"],\"limit\":10}" expect_success "A itemIds search" [[ "$(jq --arg item "$ITEM_ID" '[.data.hits[] | select((.itemId|tostring)==$item)]|length' <<<"$HTTP_BODY")" -ge 1 ]] || fail "A search did not return own item" request POST /api/aihr/personal-assistant/ask "$TOKEN_A" "$CLIENT_A" \ "{\"queryText\":\"隔离烟测资料是什么 $RUN_ID\",\"scope\":[\"PERSONAL\"],\"itemIds\":[\"$ITEM_ID\"]}" expect_success "A personal answer" SESSION_ID="$(jq -er '.data.sessionId | tostring' <<<"$HTTP_BODY")" [[ "$SESSION_ID" =~ ^[0-9]+$ ]] || fail "A answer did not persist session" request GET "/api/aihr/personal-assistant/sessions/$SESSION_ID" "$TOKEN_B" "$CLIENT_B" expect_error PERSONAL_SESSION_NOT_FOUND "B session isolation" for operation in detail download retry delete; do case "$operation" in detail) request GET "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_B" "$CLIENT_B" ;; download) request GET "/api/aihr/personal-assistant/items/$ITEM_ID/download-url" "$TOKEN_B" "$CLIENT_B" ;; retry) request POST "/api/aihr/personal-assistant/items/$ITEM_ID/retry" "$TOKEN_B" "$CLIENT_B" ;; delete) request DELETE "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_B" "$CLIENT_B" ;; esac expect_error PERSONAL_ITEM_NOT_FOUND "B $operation A item" done request POST /api/aihr/personal-assistant/search "$TOKEN_B" "$CLIENT_B" \ "{\"queryText\":\"隔离烟测\",\"scope\":[\"PERSONAL\"],\"itemIds\":[\"$ITEM_ID\"],\"limit\":10}" expect_error PERSONAL_ITEM_NOT_FOUND "B itemIds search isolation" for blocked_url in 'http://127.0.0.1/admin' 'http://169.254.169.254/latest/meta-data/'; do request POST /api/aihr/personal-assistant/items/url "$TOKEN_A" "$CLIENT_A" \ "{\"url\":\"$blocked_url\",\"title\":\"$TITLE\"}" expect_error PERSONAL_URL_BLOCKED "SSRF $blocked_url" done request DELETE "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_A" "$CLIENT_A"; expect_success "A delete" JOB_ID="$(jq -er '.data.cleanupJobId | tostring' <<<"$HTTP_BODY")" request DELETE "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_A" "$CLIENT_A"; expect_success "A repeat delete" expect_code "$(jq -r '.data.cleanupJobId | tostring' <<<"$HTTP_BODY")" "$JOB_ID" "idempotent delete job" request GET "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_A" "$CLIENT_A"; expect_error PERSONAL_ITEM_NOT_FOUND "A item hidden immediately" echo "PASS: personal assistant isolation, SSRF, readiness and deletion gates run=$RUN_ID"