feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)
- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
这个提交包含在:
@@ -4,34 +4,54 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RUN_AUDIT_REGRESSION="${RUN_AUDIT_REGRESSION:-1}"
|
||||
RUN_OCR_REGRESSION="${RUN_OCR_REGRESSION:-1}"
|
||||
RUN_ORG_SETTINGS_REGRESSION="${RUN_ORG_SETTINGS_REGRESSION:-0}"
|
||||
RUN_AUTH_SECURITY_REGRESSION="${RUN_AUTH_SECURITY_REGRESSION:-0}"
|
||||
|
||||
echo "=== [1/5] Smoke Test ==="
|
||||
echo "=== [1/7] Smoke Test ==="
|
||||
"$SCRIPT_DIR/smoke-test.sh"
|
||||
echo ""
|
||||
|
||||
echo "=== [2/5] Dedup Regression ==="
|
||||
echo "=== [2/7] Dedup Regression ==="
|
||||
"$SCRIPT_DIR/regression-dedup.sh"
|
||||
echo ""
|
||||
|
||||
echo "=== [3/5] Workflow Actions Regression ==="
|
||||
echo "=== [3/7] Workflow Actions Regression ==="
|
||||
"$SCRIPT_DIR/regression-workflow-actions.sh"
|
||||
echo ""
|
||||
|
||||
if [[ "$RUN_AUDIT_REGRESSION" == "1" ]]; then
|
||||
echo "=== [4/5] Audit Regression ==="
|
||||
echo "=== [4/7] Audit Regression ==="
|
||||
"$SCRIPT_DIR/regression-audit.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [4/5] Audit Regression (skipped, RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION) ==="
|
||||
echo "=== [4/7] Audit Regression (skipped, RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$RUN_OCR_REGRESSION" == "1" ]]; then
|
||||
echo "=== [5/5] OCR Regression ==="
|
||||
echo "=== [5/7] OCR Regression ==="
|
||||
"$SCRIPT_DIR/regression-ocr.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [5/5] OCR Regression (skipped, RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION) ==="
|
||||
echo "=== [5/7] OCR Regression (skipped, RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$RUN_ORG_SETTINGS_REGRESSION" == "1" ]]; then
|
||||
echo "=== [6/7] Org + Settings Regression ==="
|
||||
"$SCRIPT_DIR/regression-org-settings.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [6/7] Org + Settings Regression (skipped, RUN_ORG_SETTINGS_REGRESSION=$RUN_ORG_SETTINGS_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$RUN_AUTH_SECURITY_REGRESSION" == "1" ]]; then
|
||||
echo "=== [7/7] Auth Security Regression ==="
|
||||
"$SCRIPT_DIR/regression-auth-security.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [7/7] Auth Security Regression (skipped, RUN_AUTH_SECURITY_REGRESSION=$RUN_AUTH_SECURITY_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-auth-security}"
|
||||
ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-admin@hyinda.com}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-AdminPassword1234}"
|
||||
|
||||
LOGIN_FAIL_MAX_ATTEMPTS="${LOGIN_FAIL_MAX_ATTEMPTS:-${AUTH_LOGIN_FAIL_MAX_ATTEMPTS:-5}}"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "[ERROR] curl not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "[ERROR] jq not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
extract_json_field() {
|
||||
local file_path="$1"
|
||||
local jq_expr="$2"
|
||||
jq -er "$jq_expr" "$file_path"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
local name="$3"
|
||||
local body_file="${4:-}"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
|
||||
if [[ -n "$body_file" && -f "$body_file" ]]; then
|
||||
cat "$body_file" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_error_code() {
|
||||
local file_path="$1"
|
||||
local expected_code="$2"
|
||||
local name="$3"
|
||||
local actual_code
|
||||
actual_code="$(jq -r '.error.code // empty' "$file_path")"
|
||||
if [[ "$actual_code" != "$expected_code" ]]; then
|
||||
echo "[ERROR] $name expected error code $expected_code but got $actual_code" >&2
|
||||
cat "$file_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
login() {
|
||||
local identifier="$1"
|
||||
local password="$2"
|
||||
local fingerprint="$3"
|
||||
local out_body="$4"
|
||||
|
||||
local status
|
||||
status="$(curl -sS -o "$out_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/login" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: $fingerprint" \
|
||||
-d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")"
|
||||
echo "$status"
|
||||
}
|
||||
|
||||
echo "[1/8] Login admin"
|
||||
admin_login_body="$TMP_DIR/admin-login.json"
|
||||
admin_login_status="$(login "$ADMIN_IDENTIFIER" "$ADMIN_PASSWORD" "${DEVICE_FINGERPRINT}-admin" "$admin_login_body")"
|
||||
assert_status "$admin_login_status" "200" "admin login" "$admin_login_body"
|
||||
ADMIN_TOKEN="$(extract_json_field "$admin_login_body" '.data.accessToken')"
|
||||
|
||||
echo "[2/8] Weak password should be rejected"
|
||||
weak_employee_no="W$(date +%s)$RANDOM"
|
||||
weak_employee_email="weak_${weak_employee_no}@hyinda.com"
|
||||
weak_employee_phone="139${RANDOM}${RANDOM}"
|
||||
weak_create_body="$TMP_DIR/weak-create.json"
|
||||
weak_create_status="$(curl -sS -o "$weak_create_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"employeeNo\":\"$weak_employee_no\",\"name\":\"Weak User\",\"phone\":\"$weak_employee_phone\",\"email\":\"$weak_employee_email\",\"title\":\"测试\",\"directManagerId\":null,\"hireDate\":\"2020-01-01\",\"roles\":[\"employee\"],\"password\":\"Password123456\"}")"
|
||||
assert_status "$weak_create_status" "400" "weak password rejected" "$weak_create_body"
|
||||
assert_error_code "$weak_create_body" "VALID_001" "weak password rejected"
|
||||
if ! jq -e '.error.details.password | type == "array" and (map(tostring) | join(" ") | contains("常见"))' "$weak_create_body" >/dev/null; then
|
||||
echo "[ERROR] weak password error message missing" >&2
|
||||
cat "$weak_create_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[3/8] Create employee (initial password => mustChangePassword)"
|
||||
employee_no="A$(date +%s)$RANDOM"
|
||||
employee_email="auth_${employee_no}@hyinda.com"
|
||||
employee_phone="138${RANDOM}${RANDOM}"
|
||||
INITIAL_PASSWORD="InitA1$(date +%s%N)Xy"
|
||||
create_body="$TMP_DIR/create.json"
|
||||
create_status="$(curl -sS -o "$create_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"employeeNo\":\"$employee_no\",\"name\":\"Auth User\",\"phone\":\"$employee_phone\",\"email\":\"$employee_email\",\"title\":\"测试\",\"directManagerId\":null,\"hireDate\":\"2020-01-01\",\"roles\":[\"employee\"],\"password\":\"$INITIAL_PASSWORD\"}")"
|
||||
assert_status "$create_status" "200" "create employee" "$create_body"
|
||||
EMPLOYEE_ID="$(extract_json_field "$create_body" '.data.id')"
|
||||
|
||||
login_body_1="$TMP_DIR/login1.json"
|
||||
login_status_1="$(login "$employee_email" "$INITIAL_PASSWORD" "${DEVICE_FINGERPRINT}-employee" "$login_body_1")"
|
||||
assert_status "$login_status_1" "200" "initial login" "$login_body_1"
|
||||
if [[ "$(jq -r '.data.mustChangePassword' "$login_body_1")" != "true" ]]; then
|
||||
echo "[ERROR] expected mustChangePassword=true on initial login" >&2
|
||||
cat "$login_body_1" >&2
|
||||
exit 1
|
||||
fi
|
||||
ACCESS_TOKEN_1="$(extract_json_field "$login_body_1" '.data.accessToken')"
|
||||
|
||||
echo "[4/8] Change password should revoke old token"
|
||||
NEW_PASSWORD="NewA1$(date +%s%N)Zz"
|
||||
change_body="$TMP_DIR/change.json"
|
||||
change_status="$(curl -sS -o "$change_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/change-password" \
|
||||
-H "authorization: Bearer $ACCESS_TOKEN_1" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: ${DEVICE_FINGERPRINT}-employee" \
|
||||
-d "{\"oldPassword\":\"$INITIAL_PASSWORD\",\"newPassword\":\"$NEW_PASSWORD\"}")"
|
||||
assert_status "$change_status" "200" "change password" "$change_body"
|
||||
ACCESS_TOKEN_2="$(extract_json_field "$change_body" '.data.accessToken')"
|
||||
if [[ "$(jq -r '.data.mustChangePassword' "$change_body")" != "false" ]]; then
|
||||
echo "[ERROR] expected mustChangePassword=false after change" >&2
|
||||
cat "$change_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
old_me_body="$TMP_DIR/old-me.json"
|
||||
old_me_status="$(curl -sS -o "$old_me_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/me" \
|
||||
-H "authorization: Bearer $ACCESS_TOKEN_1")"
|
||||
assert_status "$old_me_status" "401" "old token revoked" "$old_me_body"
|
||||
assert_error_code "$old_me_body" "AUTH_004" "old token revoked"
|
||||
|
||||
new_me_body="$TMP_DIR/new-me.json"
|
||||
new_me_status="$(curl -sS -o "$new_me_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/me" \
|
||||
-H "authorization: Bearer $ACCESS_TOKEN_2")"
|
||||
assert_status "$new_me_status" "200" "new token works" "$new_me_body"
|
||||
|
||||
echo "[5/8] Admin reset password => mustChangePassword=true"
|
||||
reset_body="$TMP_DIR/reset.json"
|
||||
reset_status="$(curl -sS -o "$reset_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users/$EMPLOYEE_ID/reset-password" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d '{}')"
|
||||
assert_status "$reset_status" "200" "admin reset password" "$reset_body"
|
||||
ONE_TIME_PASSWORD="$(extract_json_field "$reset_body" '.data.oneTimePassword')"
|
||||
|
||||
login_body_2="$TMP_DIR/login2.json"
|
||||
login_status_2="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-employee-reset" "$login_body_2")"
|
||||
assert_status "$login_status_2" "200" "login with reset password" "$login_body_2"
|
||||
if [[ "$(jq -r '.data.mustChangePassword' "$login_body_2")" != "true" ]]; then
|
||||
echo "[ERROR] expected mustChangePassword=true after admin reset" >&2
|
||||
cat "$login_body_2" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[6/8] Login failure lockout (AUTH_002)"
|
||||
wrong_password="WrongPassword1234"
|
||||
locked=0
|
||||
for i in $(seq 1 "$LOGIN_FAIL_MAX_ATTEMPTS"); do
|
||||
attempt_body="$TMP_DIR/lock-attempt-$i.json"
|
||||
attempt_status="$(login "$employee_email" "$wrong_password" "${DEVICE_FINGERPRINT}-lock-$i" "$attempt_body")"
|
||||
if [[ "$i" -lt "$LOGIN_FAIL_MAX_ATTEMPTS" ]]; then
|
||||
assert_status "$attempt_status" "401" "lock attempt $i" "$attempt_body"
|
||||
assert_error_code "$attempt_body" "AUTH_001" "lock attempt $i"
|
||||
else
|
||||
assert_status "$attempt_status" "423" "lock attempt $i triggers lock" "$attempt_body"
|
||||
assert_error_code "$attempt_body" "AUTH_002" "lock attempt $i triggers lock"
|
||||
if [[ "$(jq -r '.error.details.lockedUntil // empty' "$attempt_body")" == "" ]]; then
|
||||
echo "[ERROR] expected lockedUntil in AUTH_002 details" >&2
|
||||
cat "$attempt_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
locked=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$locked" != "1" ]]; then
|
||||
echo "[ERROR] expected account to be locked" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[7/8] Locked account cannot login, unlock restores"
|
||||
locked_login_body="$TMP_DIR/locked-login.json"
|
||||
locked_login_status="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-locked-correct" "$locked_login_body")"
|
||||
assert_status "$locked_login_status" "423" "locked account login blocked" "$locked_login_body"
|
||||
assert_error_code "$locked_login_body" "AUTH_002" "locked account login blocked"
|
||||
|
||||
unlock_body="$TMP_DIR/unlock.json"
|
||||
unlock_status="$(curl -sS -o "$unlock_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users/$EMPLOYEE_ID/unlock" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$unlock_status" "200" "unlock user" "$unlock_body"
|
||||
|
||||
post_unlock_login_body="$TMP_DIR/post-unlock-login.json"
|
||||
post_unlock_login_status="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-post-unlock" "$post_unlock_login_body")"
|
||||
assert_status "$post_unlock_login_status" "200" "login after unlock" "$post_unlock_login_body"
|
||||
|
||||
echo "[8/8] Done"
|
||||
echo "[OK] Auth security regression passed"
|
||||
@@ -136,15 +136,22 @@ NODE
|
||||
}
|
||||
|
||||
create_tmp_non_dedup_index() {
|
||||
DATABASE_URL="$DATABASE_URL" node <<'NODE'
|
||||
local reimbursement_id="$1"
|
||||
DATABASE_URL="$DATABASE_URL" REIMBURSEMENT_ID="$reimbursement_id" node <<'NODE'
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const reimbursementId = process.env.REIMBURSEMENT_ID;
|
||||
if (!reimbursementId) {
|
||||
throw new Error('REIMBURSEMENT_ID is required');
|
||||
}
|
||||
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
await prisma.$executeRawUnsafe('DROP INDEX IF EXISTS "tmp_unique_linked_reim"');
|
||||
await prisma.$executeRawUnsafe(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS tmp_unique_linked_reim ON "invoice_pool" ("linked_reimbursement_id") WHERE "linked_reimbursement_id" IS NOT NULL',
|
||||
`CREATE UNIQUE INDEX "tmp_unique_linked_reim" ON "invoice_pool" ("linked_reimbursement_id") WHERE "linked_reimbursement_id" = '${reimbursementId}'`,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
@@ -247,9 +254,6 @@ assert_status "$status" "409" "code+number dedup bind" "$TMP_DIR/bD.json"
|
||||
assert_error_code "$TMP_DIR/bD.json" "VALID_003"
|
||||
|
||||
echo "[4/7] Non-dedup P2002 should not map to VALID_003"
|
||||
create_tmp_non_dedup_index
|
||||
TMP_INDEX_CREATED=1
|
||||
|
||||
cat > "$TMP_DIR/non-dedup-1.pdf" <<PDF
|
||||
%PDF-1.4
|
||||
non-dedup-1-$RUN_SUFFIX
|
||||
@@ -260,6 +264,8 @@ non-dedup-2-$RUN_SUFFIX
|
||||
PDF
|
||||
|
||||
RID_E="$(create_reimbursement "$TMP_DIR/rE.json")"
|
||||
create_tmp_non_dedup_index "$RID_E"
|
||||
TMP_INDEX_CREATED=1
|
||||
status="$(upload_file "$TMP_DIR/non-dedup-1.pdf" "$TMP_DIR/uE1.json")"
|
||||
assert_status "$status" "200" "upload non-dedup-1" "$TMP_DIR/uE1.json"
|
||||
IID_E1="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uE1.json")"
|
||||
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-org-settings}"
|
||||
|
||||
ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-admin@hyinda.com}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-AdminPassword1234}"
|
||||
EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}"
|
||||
EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}"
|
||||
ADMINISTRATION_IDENTIFIER="${ADMINISTRATION_IDENTIFIER:-administration@hyinda.com}"
|
||||
ADMINISTRATION_PASSWORD="${ADMINISTRATION_PASSWORD:-AdministrationPassword1234}"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "[ERROR] curl not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "[ERROR] jq not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
extract_json_field() {
|
||||
local file_path="$1"
|
||||
local jq_expr="$2"
|
||||
jq -er "$jq_expr" "$file_path"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
local name="$3"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_error_code() {
|
||||
local file_path="$1"
|
||||
local expected_code="$2"
|
||||
local name="$3"
|
||||
local actual_code
|
||||
actual_code="$(jq -r '.error.code // empty' "$file_path")"
|
||||
if [[ "$actual_code" != "$expected_code" ]]; then
|
||||
echo "[ERROR] $name expected error code $expected_code but got $actual_code" >&2
|
||||
cat "$file_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_jq() {
|
||||
local file_path="$1"
|
||||
local jq_expr="$2"
|
||||
local name="$3"
|
||||
if ! jq -e "$jq_expr" "$file_path" >/dev/null; then
|
||||
echo "[ERROR] $name jq assertion failed: $jq_expr" >&2
|
||||
cat "$file_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
login() {
|
||||
local identifier="$1"
|
||||
local password="$2"
|
||||
local fingerprint="$3"
|
||||
local out_body="$4"
|
||||
|
||||
local status
|
||||
status="$(curl -sS -o "$out_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/login" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: $fingerprint" \
|
||||
-d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")"
|
||||
assert_status "$status" "200" "login $identifier"
|
||||
extract_json_field "$out_body" '.data.accessToken'
|
||||
}
|
||||
|
||||
echo "[1/12] Login admin/employee/administration"
|
||||
admin_login_body="$TMP_DIR/admin-login.json"
|
||||
ADMIN_TOKEN="$(login "$ADMIN_IDENTIFIER" "$ADMIN_PASSWORD" "${DEVICE_FINGERPRINT}-admin" "$admin_login_body")"
|
||||
admin_me_body="$TMP_DIR/admin-me.json"
|
||||
admin_me_status="$(curl -sS -o "$admin_me_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/me" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$admin_me_status" "200" "admin me"
|
||||
ADMIN_ID="$(extract_json_field "$admin_me_body" '.data.id')"
|
||||
|
||||
employee_login_body="$TMP_DIR/employee-login.json"
|
||||
EMPLOYEE_TOKEN="$(login "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "${DEVICE_FINGERPRINT}-employee" "$employee_login_body")"
|
||||
employee_me_body="$TMP_DIR/employee-me.json"
|
||||
employee_me_status="$(curl -sS -o "$employee_me_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/me" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$employee_me_status" "200" "employee me"
|
||||
EMPLOYEE_ID="$(extract_json_field "$employee_me_body" '.data.id')"
|
||||
|
||||
administration_login_body="$TMP_DIR/administration-login.json"
|
||||
ADMINISTRATION_TOKEN="$(login "$ADMINISTRATION_IDENTIFIER" "$ADMINISTRATION_PASSWORD" "${DEVICE_FINGERPRINT}-administration" "$administration_login_body")"
|
||||
administration_me_body="$TMP_DIR/administration-me.json"
|
||||
administration_me_status="$(curl -sS -o "$administration_me_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/me" \
|
||||
-H "authorization: Bearer $ADMINISTRATION_TOKEN")"
|
||||
assert_status "$administration_me_status" "200" "administration me"
|
||||
ADMINISTRATION_ID="$(extract_json_field "$administration_me_body" '.data.id')"
|
||||
|
||||
echo "[2/12] Expense types (read-only)"
|
||||
expense_list_body="$TMP_DIR/expense-types.json"
|
||||
expense_list_status="$(curl -sS -o "$expense_list_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/expense-types?enabled=true" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$expense_list_status" "200" "expense types list"
|
||||
assert_jq "$expense_list_body" '.data.items | length > 0' "expense types non-empty"
|
||||
assert_jq "$expense_list_body" '([.data.items[].sortOrder] as $a | ($a|length) as $n | if $n <= 1 then true else [range(1; $n) | $a[.-1] <= $a[.]] | all end)' "expense types sorted"
|
||||
|
||||
echo "[3/12] Expense types (management + audit)"
|
||||
expense_code="TEST_$(date +%s)_$RANDOM"
|
||||
expense_create_body="$TMP_DIR/expense-create.json"
|
||||
expense_create_status="$(curl -sS -o "$expense_create_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/expense-types" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"code\":\"$expense_code\",\"name\":\"$expense_code\",\"category\":\"other\",\"sortOrder\":9999,\"enabled\":true}")"
|
||||
assert_status "$expense_create_status" "200" "expense type create"
|
||||
EXPENSE_TYPE_ID="$(extract_json_field "$expense_create_body" '.data.id')"
|
||||
|
||||
expense_patch_body="$TMP_DIR/expense-patch.json"
|
||||
expense_patch_status="$(curl -sS -o "$expense_patch_body" -w "%{http_code}" \
|
||||
-X PATCH "$BASE_URL/api/expense-types/$EXPENSE_TYPE_ID" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d '{"enabled":false}')"
|
||||
assert_status "$expense_patch_status" "200" "expense type disable"
|
||||
|
||||
expense_enabled_body="$TMP_DIR/expense-enabled.json"
|
||||
expense_enabled_status="$(curl -sS -o "$expense_enabled_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/expense-types?enabled=true" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$expense_enabled_status" "200" "expense types enabled list"
|
||||
assert_jq "$expense_enabled_body" "([.data.items[].id] | index(\"$EXPENSE_TYPE_ID\") == null)" "disabled expense type filtered"
|
||||
|
||||
audit_expense_body="$TMP_DIR/audit-expense.json"
|
||||
audit_expense_status="$(curl -sS -o "$audit_expense_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/audit-logs?action=EXPENSE_TYPE_CREATED&targetType=expense_type&pageSize=50" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$audit_expense_status" "200" "audit expense type created"
|
||||
assert_jq "$audit_expense_body" "([.data.items[].targetId] | index(\"$EXPENSE_TYPE_ID\") != null)" "audit contains created expense type"
|
||||
|
||||
echo "[4/12] Config read: approval rules (login required)"
|
||||
approval_rules_body="$TMP_DIR/approval-rules.json"
|
||||
approval_rules_status="$(curl -sS -o "$approval_rules_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/approval-rules?enabled=true" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$approval_rules_status" "200" "approval rules list"
|
||||
assert_jq "$approval_rules_body" '.data.items | length > 0' "approval rules non-empty"
|
||||
|
||||
echo "[5/12] Config read: document sequence (system.settings)"
|
||||
sequence_body="$TMP_DIR/document-sequence.json"
|
||||
sequence_status="$(curl -sS -o "$sequence_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/document-sequences/reimbursement" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$sequence_status" "200" "document sequence read"
|
||||
assert_jq "$sequence_body" '.data.documentType == "reimbursement"' "document sequence type"
|
||||
|
||||
echo "[6/12] Departments (list)"
|
||||
departments_list_body="$TMP_DIR/departments-list.json"
|
||||
departments_list_status="$(curl -sS -o "$departments_list_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/departments" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$departments_list_status" "200" "departments list"
|
||||
assert_jq "$departments_list_body" '(.data.items | type) == "array"' "departments items array"
|
||||
assert_jq "$departments_list_body" '(.data.tree | type) == "array"' "departments tree array"
|
||||
|
||||
echo "[7/12] Departments (archive constraints)"
|
||||
dept_parent_name="TEST_DEPT_PARENT_$(date +%s)_$RANDOM"
|
||||
dept_child_name="TEST_DEPT_CHILD_$(date +%s)_$RANDOM"
|
||||
dept_leaf_name="TEST_DEPT_LEAF_$(date +%s)_$RANDOM"
|
||||
dept_parent_body="$TMP_DIR/dept-parent.json"
|
||||
dept_parent_status="$(curl -sS -o "$dept_parent_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"name\":\"$dept_parent_name\"}")"
|
||||
assert_status "$dept_parent_status" "200" "department create parent"
|
||||
DEPT_PARENT_ID="$(extract_json_field "$dept_parent_body" '.data.department.id')"
|
||||
|
||||
dept_child_body="$TMP_DIR/dept-child.json"
|
||||
dept_child_status="$(curl -sS -o "$dept_child_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"name\":\"$dept_child_name\",\"parentId\":\"$DEPT_PARENT_ID\"}")"
|
||||
assert_status "$dept_child_status" "200" "department create child"
|
||||
DEPT_CHILD_ID="$(extract_json_field "$dept_child_body" '.data.department.id')"
|
||||
|
||||
dept_archive_parent_body="$TMP_DIR/dept-archive-parent.json"
|
||||
dept_archive_parent_status="$(curl -sS -o "$dept_archive_parent_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments/$DEPT_PARENT_ID/archive" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$dept_archive_parent_status" "409" "department archive parent blocked"
|
||||
assert_error_code "$dept_archive_parent_body" "VALID_001" "department archive parent blocked"
|
||||
assert_jq "$dept_archive_parent_body" '.error.details.department == "has_children"' "department archive parent has_children"
|
||||
|
||||
dept_archive_child_body="$TMP_DIR/dept-archive-child.json"
|
||||
dept_archive_child_status="$(curl -sS -o "$dept_archive_child_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments/$DEPT_CHILD_ID/archive" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$dept_archive_child_status" "200" "department archive child"
|
||||
|
||||
dept_leaf_body="$TMP_DIR/dept-leaf.json"
|
||||
dept_leaf_status="$(curl -sS -o "$dept_leaf_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"name\":\"$dept_leaf_name\"}")"
|
||||
assert_status "$dept_leaf_status" "200" "department create leaf"
|
||||
DEPT_LEAF_ID="$(extract_json_field "$dept_leaf_body" '.data.department.id')"
|
||||
|
||||
dept_archive_leaf_body="$TMP_DIR/dept-archive-leaf.json"
|
||||
dept_archive_leaf_status="$(curl -sS -o "$dept_archive_leaf_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments/$DEPT_LEAF_ID/archive" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$dept_archive_leaf_status" "200" "department archive leaf"
|
||||
|
||||
echo "[8/12] Employees alias (/users vs /employees)"
|
||||
users_list_body="$TMP_DIR/users-list.json"
|
||||
users_list_status="$(curl -sS -o "$users_list_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/users?page=1&pageSize=5" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$users_list_status" "200" "users list"
|
||||
|
||||
employees_list_body="$TMP_DIR/employees-list.json"
|
||||
employees_list_status="$(curl -sS -o "$employees_list_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/employees?page=1&pageSize=5" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$employees_list_status" "200" "employees list"
|
||||
assert_jq "$employees_list_body" '(.data.total | type) == "number"' "employees list total"
|
||||
assert_jq "$employees_list_body" "(.data.total == $(jq -r '.data.total' "$users_list_body"))" "employees list total matches users"
|
||||
|
||||
echo "[9/12] Employee can read self but not others"
|
||||
employee_self_body="$TMP_DIR/employee-self.json"
|
||||
employee_self_status="$(curl -sS -o "$employee_self_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/employees/$EMPLOYEE_ID" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$employee_self_status" "200" "employee get self"
|
||||
|
||||
employee_other_body="$TMP_DIR/employee-other.json"
|
||||
employee_other_status="$(curl -sS -o "$employee_other_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/employees/$ADMIN_ID" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$employee_other_status" "403" "employee get other forbidden"
|
||||
assert_error_code "$employee_other_body" "PERM_001" "employee get other forbidden"
|
||||
|
||||
echo "[10/12] Department archive blocks active employees"
|
||||
dept_emp_name="TEST_DEPT_EMP_$(date +%s)_$RANDOM"
|
||||
dept_emp_body="$TMP_DIR/dept-emp.json"
|
||||
dept_emp_status="$(curl -sS -o "$dept_emp_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"name\":\"$dept_emp_name\"}")"
|
||||
assert_status "$dept_emp_status" "200" "department create for employee"
|
||||
DEPT_EMP_ID="$(extract_json_field "$dept_emp_body" '.data.department.id')"
|
||||
|
||||
new_employee_no="T$(date +%s)$RANDOM"
|
||||
new_employee_email="test_${new_employee_no}@hyinda.com"
|
||||
new_employee_phone="139${RANDOM}${RANDOM}"
|
||||
employee_create_body="$TMP_DIR/employee-create.json"
|
||||
employee_create_status="$(curl -sS -o "$employee_create_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"employeeNo\":\"$new_employee_no\",\"name\":\"Test User\",\"phone\":\"$new_employee_phone\",\"email\":\"$new_employee_email\",\"departmentId\":\"$DEPT_EMP_ID\",\"title\":\"测试\",\"directManagerId\":null,\"hireDate\":\"2020-01-01\",\"roles\":[\"employee\"],\"password\":\"TestPassword1234\"}")"
|
||||
assert_status "$employee_create_status" "200" "employee create"
|
||||
NEW_EMPLOYEE_ID="$(extract_json_field "$employee_create_body" '.data.id')"
|
||||
|
||||
dept_archive_blocked_body="$TMP_DIR/dept-archive-blocked.json"
|
||||
dept_archive_blocked_status="$(curl -sS -o "$dept_archive_blocked_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments/$DEPT_EMP_ID/archive" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$dept_archive_blocked_status" "409" "department archive blocked by active employees"
|
||||
assert_error_code "$dept_archive_blocked_body" "VALID_001" "department archive blocked by active employees"
|
||||
assert_jq "$dept_archive_blocked_body" '.error.details.department == "has_active_employees"' "department archive has_active_employees"
|
||||
|
||||
user_disable_body="$TMP_DIR/user-disable.json"
|
||||
user_disable_status="$(curl -sS -o "$user_disable_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users/$NEW_EMPLOYEE_ID/disable" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$user_disable_status" "200" "user disable"
|
||||
|
||||
dept_archive_after_disable_body="$TMP_DIR/dept-archive-after-disable.json"
|
||||
dept_archive_after_disable_status="$(curl -sS -o "$dept_archive_after_disable_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments/$DEPT_EMP_ID/archive" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$dept_archive_after_disable_status" "200" "department archive after disable"
|
||||
|
||||
echo "[11/12] Delegation grants: admin maintain principal + alias consistency"
|
||||
delegation_create_body="$TMP_DIR/delegation-create.json"
|
||||
delegation_create_status="$(curl -sS -o "$delegation_create_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/delegation-grants" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"principalEmployeeId\":\"$EMPLOYEE_ID\",\"delegateEmployeeId\":\"$ADMINISTRATION_ID\",\"scope\":\"reimbursement_submit\"}")"
|
||||
assert_status "$delegation_create_status" "200" "delegation grant create"
|
||||
DELEGATION_ID="$(extract_json_field "$delegation_create_body" '.data.delegation.id')"
|
||||
assert_jq "$delegation_create_body" ".data.delegation.principal.id == \"$EMPLOYEE_ID\"" "delegation principal embedded"
|
||||
assert_jq "$delegation_create_body" ".data.delegation.delegate.id == \"$ADMINISTRATION_ID\"" "delegation delegate embedded"
|
||||
|
||||
employee_delegations_body="$TMP_DIR/delegations-mine.json"
|
||||
employee_delegations_status="$(curl -sS -o "$employee_delegations_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/delegations/mine?status=active" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$employee_delegations_status" "200" "delegations mine"
|
||||
assert_jq "$employee_delegations_body" "([.data.initiated[].id] | index(\"$DELEGATION_ID\") != null)" "delegations mine contains created"
|
||||
|
||||
delegation_list_body="$TMP_DIR/delegation-grants-list.json"
|
||||
delegation_list_status="$(curl -sS -o "$delegation_list_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/delegation-grants?includeInactive=1" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$delegation_list_status" "200" "delegation grants list (employee view)"
|
||||
assert_jq "$delegation_list_body" "([.data.items[].id] | index(\"$DELEGATION_ID\") != null)" "delegation grants list contains created"
|
||||
|
||||
delegation_forbidden_body="$TMP_DIR/delegation-forbidden.json"
|
||||
delegation_forbidden_status="$(curl -sS -o "$delegation_forbidden_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/delegation-grants" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"principalEmployeeId\":\"$ADMIN_ID\",\"delegateEmployeeId\":\"$ADMINISTRATION_ID\",\"scope\":\"reimbursement_submit\"}")"
|
||||
assert_status "$delegation_forbidden_status" "403" "delegation grant forbidden"
|
||||
assert_error_code "$delegation_forbidden_body" "PERM_001" "delegation grant forbidden"
|
||||
|
||||
echo "[12/12] Revoke delegation (cleanup)"
|
||||
delegation_revoke_body="$TMP_DIR/delegation-revoke.json"
|
||||
delegation_revoke_status="$(curl -sS -o "$delegation_revoke_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/delegation-grants/$DELEGATION_ID/revoke" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$delegation_revoke_status" "200" "delegation revoke"
|
||||
|
||||
echo "[OK] Org/Settings regression passed"
|
||||
在新工单中引用
屏蔽一个用户