#!/usr/bin/env bash set -euo pipefail BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-workflow-actions}" EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}" MANAGER_IDENTIFIER="${MANAGER_IDENTIFIER:-manager@hyinda.com}" MANAGER_PASSWORD="${MANAGER_PASSWORD:-ManagerPassword1234}" FINANCE_IDENTIFIER="${FINANCE_IDENTIFIER:-finance@hyinda.com}" FINANCE_PASSWORD="${FINANCE_PASSWORD:-FinancePassword1234}" CEO_IDENTIFIER="${CEO_IDENTIFIER:-ceo@hyinda.com}" CEO_PASSWORD="${CEO_PASSWORD:-CeoPassword1234}" RUN_PAY_CONCURRENCY_CHECK="${RUN_PAY_CONCURRENCY_CHECK:-0}" RUN_DELEGATION_CONCURRENCY_CHECK="${RUN_DELEGATION_CONCURRENCY_CHECK:-0}" 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 if ! command -v node >/dev/null 2>&1; then echo "[ERROR] node not found" >&2 exit 1 fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then set -a # shellcheck disable=SC1091 source .env set +a fi if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 exit 1 fi TMP_DIR="$(mktemp -d)" RUN_SUFFIX="$(date +%s)-$RANDOM" EMPLOYEE_ID="" ORIGINAL_EMPLOYEE_MANAGER_ID="__NULL__" EMPLOYEE_MANAGER_PATCHED=0 TMP_APPROVAL_RULE_ID="" cleanup() { if [[ -n "$TMP_APPROVAL_RULE_ID" ]]; then delete_approval_rule "$TMP_APPROVAL_RULE_ID" || true fi if [[ "$EMPLOYEE_MANAGER_PATCHED" == "1" ]] && [[ -n "$EMPLOYEE_ID" ]]; then set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID" || true fi rm -rf "$TMP_DIR" } trap cleanup EXIT 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="$2" local name="$3" local actual actual="$(jq -r '.error.code // empty' "$file_path")" if [[ "$actual" != "$expected" ]]; then echo "[ERROR] $name expected error code $expected but got $actual" >&2 cat "$file_path" >&2 exit 1 fi } assert_error_detail() { local file_path="$1" local jq_path="$2" local expected="$3" local name="$4" local actual actual="$(jq -r "$jq_path // empty" "$file_path")" if [[ "$actual" != "$expected" ]]; then echo "[ERROR] $name expected $jq_path=$expected but got $actual" >&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 assertion failed: $jq_expr" >&2 cat "$file_path" >&2 exit 1 fi } api_json_auth() { local method="$1" local url="$2" local token="$3" local out_file="$4" local body="${5:-}" if [[ -n "$body" ]]; then curl -sS -o "$out_file" -w "%{http_code}" \ -X "$method" "$url" \ -H "authorization: Bearer $token" \ -H "content-type: application/json" \ -d "$body" else curl -sS -o "$out_file" -w "%{http_code}" \ -X "$method" "$url" \ -H "authorization: Bearer $token" fi } upload_invoice() { local token="$1" local file_path="$2" local out_file="$3" curl -sS -o "$out_file" -w "%{http_code}" \ -X POST "$BASE_URL/api/invoice-pool/upload" \ -H "authorization: Bearer $token" \ -F "file=@$file_path" } login_user() { local identifier="$1" local password="$2" local fingerprint="$3" local out_file="$4" local status status="$(curl -sS -o "$out_file" -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" "$out_file" jq -er '.data.accessToken' "$out_file" } create_reimbursement() { local token="$1" local out_file="$2" local status status="$(api_json_auth POST "$BASE_URL/api/reimbursements" "$token" "$out_file" '{}')" assert_status "$status" "200" "create reimbursement" "$out_file" jq -er '.data.id' "$out_file" } submit_reimbursement() { local token="$1" local reimbursement_id="$2" local out_file="$3" local status status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/submit" "$token" "$out_file" '{}')" assert_status "$status" "200" "submit reimbursement $reimbursement_id" "$out_file" } pay_reimbursement() { local token="$1" local reimbursement_id="$2" local out_file="$3" local body="${4:-}" if [[ -z "$body" ]]; then body='{}' fi api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/pay" "$token" "$out_file" "$body" } transfer_task() { local token="$1" local task_id="$2" local to_approver_id="$3" local reason="$4" local out_file="$5" api_json_auth POST "$BASE_URL/api/workflow/tasks/$task_id/transfer" "$token" "$out_file" "{\"toApproverId\":\"$to_approver_id\",\"reason\":\"$reason\"}" } withdraw_reimbursement() { local token="$1" local reimbursement_id="$2" local comment="$3" local out_file="$4" local status status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/withdraw" "$token" "$out_file" "{\"comment\":\"$comment\"}")" assert_status "$status" "200" "withdraw reimbursement $reimbursement_id" "$out_file" } create_delegation() { local token="$1" local delegate_id="$2" local scope="$3" local valid_from="$4" local valid_to="$5" local out_file="$6" api_json_auth POST "$BASE_URL/api/delegations" "$token" "$out_file" "{\"delegateEmployeeId\":\"$delegate_id\",\"scope\":\"$scope\",\"validFrom\":\"$valid_from\",\"validTo\":\"$valid_to\"}" } revoke_delegation() { local token="$1" local delegation_id="$2" local out_file="$3" api_json_auth POST "$BASE_URL/api/delegations/$delegation_id/revoke" "$token" "$out_file" '{}' } create_and_bind_invoice() { local token="$1" local reimbursement_id="$2" local label="$3" local file_path="$TMP_DIR/${label}-${RUN_SUFFIX}-${RANDOM}.pdf" local upload_body="$TMP_DIR/${label}-upload.json" local bind_body="$TMP_DIR/${label}-bind.json" printf '%%PDF-1.4\n%s\n' "$label-$RUN_SUFFIX-$(date +%s%N)-$RANDOM" > "$file_path" local status status="$(upload_invoice "$token" "$file_path" "$upload_body")" assert_status "$status" "200" "upload invoice $label" "$upload_body" local invoice_id invoice_id="$(jq -er '.data.invoicePoolId' "$upload_body")" status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/bind" "$token" "$bind_body" "{\"reimbursementId\":\"$reimbursement_id\"}")" assert_status "$status" "200" "bind invoice $label" "$bind_body" echo "$invoice_id" } set_reimbursement_total_amount() { local reimbursement_id="$1" local total_amount="$2" DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$total_amount" <<'NODE' const reimbursementId = process.argv[2]; const totalAmount = process.argv[3]; const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { await prisma.reimbursementForm.update({ where: { id: reimbursementId }, data: { total_amount: totalAmount }, }); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } set_reimbursement_status() { local reimbursement_id="$1" local status="$2" DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$status" <<'NODE' const reimbursementId = process.argv[2]; const status = process.argv[3]; const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { await prisma.reimbursementForm.update({ where: { id: reimbursementId }, data: { status }, }); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } set_employee_direct_manager() { local employee_id="$1" local manager_id="$2" DATABASE_URL="$DATABASE_URL" node - "$employee_id" "$manager_id" <<'NODE' const employeeId = process.argv[2]; const managerIdRaw = process.argv[3]; const managerId = managerIdRaw === '__NULL__' ? null : managerIdRaw; const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { await prisma.employee.update({ where: { id: employeeId }, data: { direct_manager_id: managerId }, }); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } create_temp_manager_only_rule() { local name="$1" local min_amount="$2" local max_amount="$3" local priority="$4" DATABASE_URL="$DATABASE_URL" node - "$name" "$min_amount" "$max_amount" "$priority" <<'NODE' const [name, minAmount, maxAmount, priorityRaw] = process.argv.slice(2); const priority = Number(priorityRaw); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const rule = await prisma.approvalRule.create({ data: { name, min_amount: minAmount, max_amount: maxAmount, level1_role: 'manager', level2_role: null, level3_role: null, enabled: true, priority, }, select: { id: true }, }); process.stdout.write(rule.id); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } delete_approval_rule() { local rule_id="$1" DATABASE_URL="$DATABASE_URL" node - "$rule_id" <<'NODE' const ruleId = process.argv[2]; const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { await prisma.approvalRule.deleteMany({ where: { id: ruleId } }); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_instance_status() { local instance_id="$1" local expected_status="$2" local scenario="$3" DATABASE_URL="$DATABASE_URL" node - "$instance_id" "$expected_status" "$scenario" <<'NODE' const [instanceId, expectedStatus, scenario] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const instance = await prisma.workflowInstance.findUnique({ where: { id: instanceId }, select: { id: true, current_status: true }, }); if (!instance) { console.error(`[ERROR] ${scenario} workflow instance not found: ${instanceId}`); process.exit(1); } if (instance.current_status !== expectedStatus) { console.error( `[ERROR] ${scenario} expected workflow instance status ${expectedStatus} but got ${instance.current_status}`, ); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_reimbursement_status_db() { local reimbursement_id="$1" local expected_status="$2" local scenario="$3" DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$expected_status" "$scenario" <<'NODE' const [reimbursementId, expectedStatus, scenario] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const reimbursement = await prisma.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, status: true }, }); if (!reimbursement) { console.error(`[ERROR] ${scenario} reimbursement not found: ${reimbursementId}`); process.exit(1); } if (reimbursement.status !== expectedStatus) { console.error( `[ERROR] ${scenario} expected reimbursement status ${expectedStatus} but got ${reimbursement.status}`, ); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_skip_history_record() { local instance_id="$1" local expected_step_id="$2" local expected_reason="$3" local scenario="$4" DATABASE_URL="$DATABASE_URL" node - "$instance_id" "$expected_step_id" "$expected_reason" "$scenario" <<'NODE' const [instanceId, expectedStepId, expectedReason, scenario] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const history = await prisma.workflowHistory.findFirst({ where: { instance_id: instanceId, action: 'skip', }, orderBy: { created_at: 'asc' }, select: { payload: true }, }); if (!history) { console.error(`[ERROR] ${scenario} expected skip history record but none found`); process.exit(1); } const payload = history.payload ?? {}; const actualStepId = payload.step_id ?? null; const actualReason = payload.reason ?? null; if (actualStepId !== expectedStepId || actualReason !== expectedReason) { console.error( `[ERROR] ${scenario} expected skip payload step_id=${expectedStepId}, reason=${expectedReason} but got step_id=${actualStepId}, reason=${actualReason}`, ); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_task_step_and_role() { local task_id="$1" local expected_step_id="$2" local expected_role="$3" local scenario="$4" DATABASE_URL="$DATABASE_URL" node - "$task_id" "$expected_step_id" "$expected_role" "$scenario" <<'NODE' const [taskId, expectedStepId, expectedRole, scenario] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const task = await prisma.workflowTask.findUnique({ where: { id: taskId }, select: { id: true, step_id: true, assignee_role: true, assignee_id: true, }, }); if (!task) { console.error(`[ERROR] ${scenario} task not found: ${taskId}`); process.exit(1); } if (task.step_id !== expectedStepId) { console.error( `[ERROR] ${scenario} expected step_id ${expectedStepId} but got ${task.step_id}`, ); process.exit(1); } if (task.assignee_role !== expectedRole || task.assignee_id !== null) { console.error( `[ERROR] ${scenario} expected role assignee ${expectedRole} with null assignee_id but got assignee_role=${task.assignee_role}, assignee_id=${task.assignee_id}`, ); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_no_workflow_instance() { local reimbursement_id="$1" local scenario="$2" DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$scenario" <<'NODE' const [reimbursementId, scenario] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const count = await prisma.workflowInstance.count({ where: { entity_type: 'reimbursement', entity_id: reimbursementId, }, }); if (count !== 0) { console.error(`[ERROR] ${scenario} expected no workflow instance but found ${count}`); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_invoices_archived_used_complete() { local reimbursement_id="$1" local scenario="$2" DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$scenario" <<'NODE' const [reimbursementId, scenario] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const invoices = await prisma.invoicePool.findMany({ where: { linked_reimbursement_id: reimbursementId, }, select: { id: true, status: true, archived_reason: true, archived_at: true, }, }); if (!invoices.length) { console.error(`[ERROR] ${scenario} expected linked invoices but found none`); process.exit(1); } const invalid = invoices.find( (invoice) => invoice.status !== 'archived' || invoice.archived_reason !== 'used_complete' || invoice.archived_at == null, ); if (invalid) { console.error( `[ERROR] ${scenario} invoice ${invalid.id} expected archived/used_complete/archived_at but got status=${invalid.status}, reason=${invalid.archived_reason}, archived_at=${invalid.archived_at}`, ); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_transfer_evidence() { local task_id="$1" local reimbursement_id="$2" local from_approver_id="$3" local to_approver_id="$4" local reason="$5" local expected_transfer_count="$6" local scenario="$7" DATABASE_URL="$DATABASE_URL" node - "$task_id" "$reimbursement_id" "$from_approver_id" "$to_approver_id" "$reason" "$expected_transfer_count" "$scenario" <<'NODE' const [ taskId, reimbursementId, fromApproverId, toApproverId, reason, expectedTransferCountRaw, scenario, ] = process.argv.slice(2); const expectedTransferCount = Number(expectedTransferCountRaw); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const task = await prisma.workflowTask.findUnique({ where: { id: taskId }, select: { assignee_id: true, assignee_role: true, }, }); if (!task) { console.error(`[ERROR] ${scenario} task not found: ${taskId}`); process.exit(1); } if (task.assignee_id !== toApproverId || task.assignee_role !== null) { console.error( `[ERROR] ${scenario} expected task assignee_id=${toApproverId}, assignee_role=null but got assignee_id=${task.assignee_id}, assignee_role=${task.assignee_role}`, ); process.exit(1); } const transfer = await prisma.approvalTransfer.findFirst({ where: { reimbursement_id: reimbursementId, from_approver_id: fromApproverId, to_approver_id: toApproverId, reason, status: 'active', }, orderBy: { created_at: 'desc' }, select: { id: true }, }); if (!transfer) { console.error(`[ERROR] ${scenario} approval_transfers evidence not found`); process.exit(1); } const history = await prisma.workflowHistory.findFirst({ where: { task_id: taskId, action: 'transfer', actor_id: fromApproverId, }, orderBy: { created_at: 'desc' }, select: { comment: true, payload: true, }, }); if (!history) { console.error(`[ERROR] ${scenario} workflow_history transfer evidence not found`); process.exit(1); } const payload = history.payload ?? {}; const payloadFrom = payload.from_approver_id ?? null; const payloadTo = payload.to_approver_id ?? null; const payloadReason = payload.reason ?? null; const payloadTransferCount = Number(payload.transfer_count); if ( history.comment !== reason || payloadFrom !== fromApproverId || payloadTo !== toApproverId || payloadReason !== reason || payloadTransferCount !== expectedTransferCount ) { console.error( `[ERROR] ${scenario} workflow_history payload mismatch: comment=${history.comment}, from=${payloadFrom}, to=${payloadTo}, reason=${payloadReason}, transfer_count=${payloadTransferCount}`, ); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_delegation_history_evidence() { local task_id="$1" local delegation_id="$2" local principal_id="$3" local delegate_id="$4" local scenario="$5" DATABASE_URL="$DATABASE_URL" node - "$task_id" "$delegation_id" "$principal_id" "$delegate_id" "$scenario" <<'NODE' const [taskId, delegationId, principalId, delegateId, scenario] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const history = await prisma.workflowHistory.findFirst({ where: { task_id: taskId }, orderBy: { created_at: 'desc' }, select: { payload: true }, }); if (!history) { console.error(`[ERROR] ${scenario} history not found`); process.exit(1); } const payload = history.payload ?? {}; if ( payload.delegation_id !== delegationId || payload.principal_id !== principalId || payload.delegate_id !== delegateId ) { console.error( `[ERROR] ${scenario} delegation payload mismatch: delegation_id=${payload.delegation_id}, principal_id=${payload.principal_id}, delegate_id=${payload.delegate_id}`, ); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_delegation_revoked_db() { local delegation_id="$1" local scenario="$2" DATABASE_URL="$DATABASE_URL" node - "$delegation_id" "$scenario" <<'NODE' const [delegationId, scenario] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const delegation = await prisma.delegationGrant.findUnique({ where: { id: delegationId }, select: { revoked_at: true }, }); if (!delegation || delegation.revoked_at == null) { console.error(`[ERROR] ${scenario} expected revoked delegation but got none`); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_delegation_auto_revoke_audit_count() { local delegation_id="$1" local expected_count="$2" local scenario="$3" DATABASE_URL="$DATABASE_URL" node - "$delegation_id" "$expected_count" "$scenario" <<'NODE' const [delegationId, expectedCountRaw, scenario] = process.argv.slice(2); const expectedCount = Number(expectedCountRaw); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const count = await prisma.auditLog.count({ where: { action: 'DELEGATION_AUTO_REVOKED', target_id: delegationId, }, }); if (count !== expectedCount) { console.error(`[ERROR] ${scenario} expected audit count=${expectedCount} but got ${count}`); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } echo "[1/23] Health check + login baseline users" health_status="$(curl -sS -o "$TMP_DIR/health.json" -w "%{http_code}" "$BASE_URL/health")" assert_status "$health_status" "200" "health check" "$TMP_DIR/health.json" EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$DEVICE_FINGERPRINT-employee" "$TMP_DIR/login-employee.json")" MANAGER_TOKEN="$(login_user "$MANAGER_IDENTIFIER" "$MANAGER_PASSWORD" "$DEVICE_FINGERPRINT-manager" "$TMP_DIR/login-manager.json")" FINANCE_TOKEN="$(login_user "$FINANCE_IDENTIFIER" "$FINANCE_PASSWORD" "$DEVICE_FINGERPRINT-finance" "$TMP_DIR/login-finance.json")" CEO_TOKEN="$(login_user "$CEO_IDENTIFIER" "$CEO_PASSWORD" "$DEVICE_FINGERPRINT-ceo" "$TMP_DIR/login-ceo.json")" me_status="$(api_json_auth GET "$BASE_URL/api/me" "$EMPLOYEE_TOKEN" "$TMP_DIR/me.json")" assert_status "$me_status" "200" "get /api/me" "$TMP_DIR/me.json" EMPLOYEE_ID="$(jq -er '.data.id' "$TMP_DIR/me.json")" ORIGINAL_EMPLOYEE_MANAGER_ID="$(jq -r '.data.directManagerId // "__NULL__"' "$TMP_DIR/me.json")" manager_me_status="$(api_json_auth GET "$BASE_URL/api/me" "$MANAGER_TOKEN" "$TMP_DIR/me-manager.json")" assert_status "$manager_me_status" "200" "get /api/me manager" "$TMP_DIR/me-manager.json" MANAGER_ID="$(jq -er '.data.id' "$TMP_DIR/me-manager.json")" ceo_me_status="$(api_json_auth GET "$BASE_URL/api/me" "$CEO_TOKEN" "$TMP_DIR/me-ceo.json")" assert_status "$ceo_me_status" "200" "get /api/me ceo" "$TMP_DIR/me-ceo.json" CEO_ID="$(jq -er '.data.id' "$TMP_DIR/me-ceo.json")" echo "[2/23] reject: instance=rejected, reimbursement=rejected" RID_REJECT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/reject-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_REJECT" "reject" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_REJECT" "$TMP_DIR/reject-submit.json" TASK_REJECT="$(jq -er '.data.currentTaskId' "$TMP_DIR/reject-submit.json")" INSTANCE_REJECT="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/reject-submit.json")" reject_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_REJECT/reject" "$MANAGER_TOKEN" "$TMP_DIR/reject-action.json" '{"comment":"reject by regression script"}')" assert_status "$reject_status" "200" "workflow reject" "$TMP_DIR/reject-action.json" assert_jq "$TMP_DIR/reject-action.json" '.data.outcome == "rejected" and .data.reimbursementStatus == "rejected"' "reject action response" assert_instance_status "$INSTANCE_REJECT" "rejected" "reject scenario" assert_reimbursement_status_db "$RID_REJECT" "rejected" "reject scenario" echo "[3/23] return + resubmit: terminated/draft and new instance" RID_RETURN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/return-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_RETURN" "return" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "$TMP_DIR/return-submit-1.json" TASK_RETURN_1="$(jq -er '.data.currentTaskId' "$TMP_DIR/return-submit-1.json")" INSTANCE_RETURN_1="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/return-submit-1.json")" return_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_RETURN_1/return" "$MANAGER_TOKEN" "$TMP_DIR/return-action.json" '{"comment":"return by regression script"}')" assert_status "$return_status" "200" "workflow return" "$TMP_DIR/return-action.json" assert_jq "$TMP_DIR/return-action.json" '.data.outcome == "returned" and .data.reimbursementStatus == "draft"' "return action response" assert_instance_status "$INSTANCE_RETURN_1" "terminated" "return scenario" assert_reimbursement_status_db "$RID_RETURN" "draft" "return scenario" submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "$TMP_DIR/return-submit-2.json" INSTANCE_RETURN_2="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/return-submit-2.json")" if [[ "$INSTANCE_RETURN_2" == "$INSTANCE_RETURN_1" ]]; then echo "[ERROR] return resubmit expected new workflow instance but got same id" >&2 cat "$TMP_DIR/return-submit-2.json" >&2 exit 1 fi withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "cleanup after return-resubmit" "$TMP_DIR/return-withdraw-cleanup.json" assert_instance_status "$INSTANCE_RETURN_2" "cancelled" "return resubmit cleanup" assert_reimbursement_status_db "$RID_RETURN" "draft" "return resubmit cleanup" echo "[4/23] withdraw: before approver processing" RID_WITHDRAW="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/withdraw-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "withdraw" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "$TMP_DIR/withdraw-submit.json" INSTANCE_WITHDRAW="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/withdraw-submit.json")" withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "withdraw before approver handle" "$TMP_DIR/withdraw-action.json" assert_jq "$TMP_DIR/withdraw-action.json" '.data.outcome == "withdrawn" and .data.reimbursementStatus == "draft"' "withdraw response" assert_instance_status "$INSTANCE_WITHDRAW" "cancelled" "withdraw scenario" assert_reimbursement_status_db "$RID_WITHDRAW" "draft" "withdraw scenario" echo "[5/23] PERM_002: non-assignee cannot operate" RID_PERM="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/perm-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PERM" "perm" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "$TMP_DIR/perm-submit.json" TASK_PERM="$(jq -er '.data.currentTaskId' "$TMP_DIR/perm-submit.json")" INSTANCE_PERM="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/perm-submit.json")" perm_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_PERM/approve" "$FINANCE_TOKEN" "$TMP_DIR/perm-finance-approve.json" '{}')" assert_status "$perm_status" "403" "PERM_002 non-assignee approve" "$TMP_DIR/perm-finance-approve.json" assert_error_code "$TMP_DIR/perm-finance-approve.json" "PERM_002" "PERM_002 non-assignee approve" withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "cleanup after PERM_002" "$TMP_DIR/perm-withdraw-cleanup.json" assert_instance_status "$INSTANCE_PERM" "cancelled" "PERM_002 cleanup" assert_reimbursement_status_db "$RID_PERM" "draft" "PERM_002 cleanup" echo "[6/23] negative_not_allowed: submit blocked when total_amount < 0" RID_NEGATIVE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NEGATIVE" "negative" >/dev/null set_reimbursement_total_amount "$RID_NEGATIVE" "-1.00" negative_submit_status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$RID_NEGATIVE/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-submit.json" '{}')" assert_status "$negative_submit_status" "400" "negative_not_allowed submit" "$TMP_DIR/negative-submit.json" assert_error_code "$TMP_DIR/negative-submit.json" "VALID_001" "negative_not_allowed submit" assert_error_detail "$TMP_DIR/negative-submit.json" '.error.details.total_amount' "negative_not_allowed" "negative_not_allowed submit" assert_no_workflow_instance "$RID_NEGATIVE" "negative_not_allowed" echo "[7/23] Prepare manager-missing context" set_employee_direct_manager "$EMPLOYEE_ID" "__NULL__" EMPLOYEE_MANAGER_PATCHED=1 echo "[8/23] manager skip: missing manager -> skip with history + finance task" RID_SKIP="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/skip-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_SKIP" "skip" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_SKIP" "$TMP_DIR/skip-submit.json" TASK_SKIP="$(jq -er '.data.currentTaskId' "$TMP_DIR/skip-submit.json")" INSTANCE_SKIP="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/skip-submit.json")" assert_task_step_and_role "$TASK_SKIP" "approval_2_finance" "finance" "manager skip scenario" assert_skip_history_record "$INSTANCE_SKIP" "approval_1_manager" "no_manager_found" "manager skip scenario" skip_finance_approve_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_SKIP/approve" "$FINANCE_TOKEN" "$TMP_DIR/skip-finance-approve.json" '{}')" assert_status "$skip_finance_approve_status" "200" "manager skip finance approve" "$TMP_DIR/skip-finance-approve.json" assert_reimbursement_status_db "$RID_SKIP" "approved" "manager skip scenario" echo "[9/23] no_actionable_approver: full chain non-actionable" TMP_APPROVAL_RULE_ID="$(create_temp_manager_only_rule "tmp-manager-only-$RUN_SUFFIX" "9000.00" "9001.00" "1")" RID_NO_ACTIONABLE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/no-actionable-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NO_ACTIONABLE" "no-actionable" >/dev/null set_reimbursement_total_amount "$RID_NO_ACTIONABLE" "9000.50" no_actionable_submit_status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$RID_NO_ACTIONABLE/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/no-actionable-submit.json" '{}')" assert_status "$no_actionable_submit_status" "409" "no_actionable_approver submit" "$TMP_DIR/no-actionable-submit.json" assert_error_code "$TMP_DIR/no-actionable-submit.json" "VALID_001" "no_actionable_approver submit" assert_error_detail "$TMP_DIR/no-actionable-submit.json" '.error.details.workflow' "no_actionable_approver" "no_actionable_approver submit" assert_no_workflow_instance "$RID_NO_ACTIONABLE" "no_actionable_approver" delete_approval_rule "$TMP_APPROVAL_RULE_ID" TMP_APPROVAL_RULE_ID="" echo "[10/23] restore manager relation before transfer/pay scenarios" set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID" EMPLOYEE_MANAGER_PATCHED=0 echo "[11/23] transfer success: manager -> ceo with 3 evidences" RID_TRANSFER_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-ok-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_OK" "transfer-ok" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_OK" "$TMP_DIR/transfer-ok-submit.json" TASK_TRANSFER_OK="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-ok-submit.json")" INSTANCE_TRANSFER_OK="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/transfer-ok-submit.json")" transfer_ok_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_OK" "$CEO_ID" "transfer to ceo for regression" "$TMP_DIR/transfer-ok-action.json")" assert_status "$transfer_ok_status" "200" "transfer success" "$TMP_DIR/transfer-ok-action.json" assert_jq "$TMP_DIR/transfer-ok-action.json" '.data.outcome == "transferred" and .data.reimbursementStatus == "reviewing" and .data.transferCount == 1' "transfer success response" assert_transfer_evidence "$TASK_TRANSFER_OK" "$RID_TRANSFER_OK" "$MANAGER_ID" "$CEO_ID" "transfer to ceo for regression" "1" "transfer success" transfer_after_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_OK" "$CEO_ID" "second transfer should fail by perm" "$TMP_DIR/transfer-after-perm.json")" assert_status "$transfer_after_status" "403" "transfer after reassignment should be forbidden" "$TMP_DIR/transfer-after-perm.json" assert_error_code "$TMP_DIR/transfer-after-perm.json" "PERM_002" "transfer after reassignment should be forbidden" ceo_approve_after_transfer_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_TRANSFER_OK/approve" "$CEO_TOKEN" "$TMP_DIR/transfer-ok-ceo-approve.json" '{}')" assert_status "$ceo_approve_after_transfer_status" "200" "ceo approve transferred task" "$TMP_DIR/transfer-ok-ceo-approve.json" NEXT_TASK_TRANSFER_OK="$(jq -r '.data.nextTaskId // empty' "$TMP_DIR/transfer-ok-ceo-approve.json")" if [[ -n "$NEXT_TASK_TRANSFER_OK" ]]; then finance_approve_after_transfer_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$NEXT_TASK_TRANSFER_OK/approve" "$FINANCE_TOKEN" "$TMP_DIR/transfer-ok-finance-approve-next.json" '{}')" assert_status "$finance_approve_after_transfer_status" "200" "finance approve transferred next task" "$TMP_DIR/transfer-ok-finance-approve-next.json" fi assert_reimbursement_status_db "$RID_TRANSFER_OK" "approved" "transfer success approval" echo "[12/23] transfer reason too short => VALID_002" RID_TRANSFER_SHORT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-short-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "transfer-short" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "$TMP_DIR/transfer-short-submit.json" TASK_TRANSFER_SHORT="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-short-submit.json")" transfer_short_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_SHORT" "$CEO_ID" "too short" "$TMP_DIR/transfer-short-action.json")" assert_status "$transfer_short_status" "400" "transfer reason too short" "$TMP_DIR/transfer-short-action.json" assert_error_code "$TMP_DIR/transfer-short-action.json" "VALID_002" "transfer reason too short" withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "cleanup transfer short" "$TMP_DIR/transfer-short-withdraw-cleanup.json" assert_reimbursement_status_db "$RID_TRANSFER_SHORT" "draft" "transfer short cleanup" echo "[13/23] transfer target not approver => VALID_001" RID_TRANSFER_TARGET="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-target-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "transfer-target" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "$TMP_DIR/transfer-target-submit.json" TASK_TRANSFER_TARGET="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-target-submit.json")" transfer_target_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_TARGET" "$EMPLOYEE_ID" "transfer target is not approver" "$TMP_DIR/transfer-target-action.json")" assert_status "$transfer_target_status" "409" "transfer target not approver" "$TMP_DIR/transfer-target-action.json" assert_error_code "$TMP_DIR/transfer-target-action.json" "VALID_001" "transfer target not approver" assert_error_detail "$TMP_DIR/transfer-target-action.json" '.error.details.toApproverId' "target_not_approver" "transfer target not approver" withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "cleanup transfer target" "$TMP_DIR/transfer-target-withdraw-cleanup.json" assert_reimbursement_status_db "$RID_TRANSFER_TARGET" "draft" "transfer target cleanup" echo "[14/23] transfer same assignee => VALID_001" RID_TRANSFER_SAME="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-same-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "transfer-same" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "$TMP_DIR/transfer-same-submit.json" TASK_TRANSFER_SAME="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-same-submit.json")" transfer_same_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_SAME" "$MANAGER_ID" "transfer to same assignee should fail" "$TMP_DIR/transfer-same-action.json")" assert_status "$transfer_same_status" "409" "transfer same assignee" "$TMP_DIR/transfer-same-action.json" assert_error_code "$TMP_DIR/transfer-same-action.json" "VALID_001" "transfer same assignee" assert_error_detail "$TMP_DIR/transfer-same-action.json" '.error.details.transfer' "same_assignee_not_allowed" "transfer same assignee" withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "cleanup transfer same assignee" "$TMP_DIR/transfer-same-withdraw-cleanup.json" assert_reimbursement_status_db "$RID_TRANSFER_SAME" "draft" "transfer same cleanup" echo "[15/23] transfer chain limit: 4th transfer blocked" RID_TRANSFER_CHAIN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-chain-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "transfer-chain" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "$TMP_DIR/transfer-chain-submit.json" TASK_TRANSFER_CHAIN="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-chain-submit.json")" transfer_chain_1_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_CHAIN" "$CEO_ID" "transfer chain step one" "$TMP_DIR/transfer-chain-1.json")" assert_status "$transfer_chain_1_status" "200" "transfer chain step 1" "$TMP_DIR/transfer-chain-1.json" assert_transfer_evidence "$TASK_TRANSFER_CHAIN" "$RID_TRANSFER_CHAIN" "$MANAGER_ID" "$CEO_ID" "transfer chain step one" "1" "transfer chain step 1" transfer_chain_2_status="$(transfer_task "$CEO_TOKEN" "$TASK_TRANSFER_CHAIN" "$MANAGER_ID" "transfer chain step two" "$TMP_DIR/transfer-chain-2.json")" assert_status "$transfer_chain_2_status" "200" "transfer chain step 2" "$TMP_DIR/transfer-chain-2.json" assert_transfer_evidence "$TASK_TRANSFER_CHAIN" "$RID_TRANSFER_CHAIN" "$CEO_ID" "$MANAGER_ID" "transfer chain step two" "2" "transfer chain step 2" transfer_chain_3_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_CHAIN" "$CEO_ID" "transfer chain step three" "$TMP_DIR/transfer-chain-3.json")" assert_status "$transfer_chain_3_status" "200" "transfer chain step 3" "$TMP_DIR/transfer-chain-3.json" assert_transfer_evidence "$TASK_TRANSFER_CHAIN" "$RID_TRANSFER_CHAIN" "$MANAGER_ID" "$CEO_ID" "transfer chain step three" "3" "transfer chain step 3" transfer_chain_4_status="$(transfer_task "$CEO_TOKEN" "$TASK_TRANSFER_CHAIN" "$MANAGER_ID" "transfer chain step four should fail" "$TMP_DIR/transfer-chain-4.json")" assert_status "$transfer_chain_4_status" "409" "transfer chain step 4" "$TMP_DIR/transfer-chain-4.json" assert_error_code "$TMP_DIR/transfer-chain-4.json" "VALID_001" "transfer chain step 4" assert_error_detail "$TMP_DIR/transfer-chain-4.json" '.error.details.transfer' "chain_limit_exceeded" "transfer chain step 4" ceo_approve_chain_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_TRANSFER_CHAIN/approve" "$CEO_TOKEN" "$TMP_DIR/transfer-chain-approve-ceo.json" '{}')" assert_status "$ceo_approve_chain_status" "200" "transfer chain final approval" "$TMP_DIR/transfer-chain-approve-ceo.json" NEXT_TASK_TRANSFER_CHAIN="$(jq -r '.data.nextTaskId // empty' "$TMP_DIR/transfer-chain-approve-ceo.json")" if [[ -n "$NEXT_TASK_TRANSFER_CHAIN" ]]; then finance_approve_chain_next_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$NEXT_TASK_TRANSFER_CHAIN/approve" "$FINANCE_TOKEN" "$TMP_DIR/transfer-chain-approve-finance-next.json" '{}')" assert_status "$finance_approve_chain_next_status" "200" "transfer chain finance next approval" "$TMP_DIR/transfer-chain-approve-finance-next.json" fi assert_reimbursement_status_db "$RID_TRANSFER_CHAIN" "approved" "transfer chain final approval" echo "[17/23] delegation active window: delegate can approve" RID_DELEGATION_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/delegation-ok-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_DELEGATION_OK" "delegation-ok" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_DELEGATION_OK" "$TMP_DIR/delegation-ok-submit.json" TASK_DELEGATION_OK="$(jq -er '.data.currentTaskId' "$TMP_DIR/delegation-ok-submit.json")" DELEGATION_VALID_FROM="$(date -u -d '-5 minutes' '+%Y-%m-%dT%H:%M:%SZ')" DELEGATION_VALID_TO="$(date -u -d '+1 hour' '+%Y-%m-%dT%H:%M:%SZ')" delegation_create_status="$(create_delegation "$MANAGER_TOKEN" "$CEO_ID" "all" "$DELEGATION_VALID_FROM" "$DELEGATION_VALID_TO" "$TMP_DIR/delegation-create-ok.json")" assert_status "$delegation_create_status" "200" "create delegation active" "$TMP_DIR/delegation-create-ok.json" DELEGATION_OK_ID="$(jq -er '.data.delegation.id' "$TMP_DIR/delegation-create-ok.json")" ceo_approve_via_delegation_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_OK/approve" "$CEO_TOKEN" "$TMP_DIR/delegation-ok-ceo-approve.json" '{}')" assert_status "$ceo_approve_via_delegation_status" "200" "delegated approve by ceo" "$TMP_DIR/delegation-ok-ceo-approve.json" assert_delegation_history_evidence "$TASK_DELEGATION_OK" "$DELEGATION_OK_ID" "$MANAGER_ID" "$CEO_ID" "delegation active approve" revoke_delegation_status="$(revoke_delegation "$MANAGER_TOKEN" "$DELEGATION_OK_ID" "$TMP_DIR/delegation-revoke-ok.json")" assert_status "$revoke_delegation_status" "200" "revoke delegation" "$TMP_DIR/delegation-revoke-ok.json" assert_delegation_revoked_db "$DELEGATION_OK_ID" "delegation revoke" echo "[18/23] delegation expired: delegate forbidden" DELEGATION_EXPIRED_FROM="$(date -u -d '-2 hours' '+%Y-%m-%dT%H:%M:%SZ')" DELEGATION_EXPIRED_TO="$(date -u -d '-1 hour' '+%Y-%m-%dT%H:%M:%SZ')" delegation_expired_create_status="$(create_delegation "$MANAGER_TOKEN" "$CEO_ID" "all" "$DELEGATION_EXPIRED_FROM" "$DELEGATION_EXPIRED_TO" "$TMP_DIR/delegation-create-expired.json")" assert_status "$delegation_expired_create_status" "200" "create expired delegation" "$TMP_DIR/delegation-create-expired.json" DELEGATION_EXPIRED_ID="$(jq -er '.data.delegation.id' "$TMP_DIR/delegation-create-expired.json")" RID_DELEGATION_EXPIRED="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/delegation-expired-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_DELEGATION_EXPIRED" "delegation-expired" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_DELEGATION_EXPIRED" "$TMP_DIR/delegation-expired-submit.json" TASK_DELEGATION_EXPIRED="$(jq -er '.data.currentTaskId' "$TMP_DIR/delegation-expired-submit.json")" ceo_approve_expired_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_EXPIRED/approve" "$CEO_TOKEN" "$TMP_DIR/delegation-expired-ceo-approve.json" '{}')" assert_status "$ceo_approve_expired_status" "403" "expired delegation should be forbidden" "$TMP_DIR/delegation-expired-ceo-approve.json" assert_error_code "$TMP_DIR/delegation-expired-ceo-approve.json" "PERM_002" "expired delegation should be forbidden" if [[ "$RUN_DELEGATION_CONCURRENCY_CHECK" == "1" ]]; then echo "[18.1/23] optional delegation revoke concurrency check" ( npm run --silent job:revoke-expired-delegations > "$TMP_DIR/delegation-revoke-a.out" ) & revoke_pid_a=$! ( npm run --silent job:revoke-expired-delegations > "$TMP_DIR/delegation-revoke-b.out" ) & revoke_pid_b=$! wait "$revoke_pid_a" wait "$revoke_pid_b" revoke_job_output_a="$(cat "$TMP_DIR/delegation-revoke-a.out")" revoke_job_output_b="$(cat "$TMP_DIR/delegation-revoke-b.out")" revoke_job_status_a="$(printf '%s\n' "$revoke_job_output_a" | tail -n 1)" revoke_job_status_b="$(printf '%s\n' "$revoke_job_output_b" | tail -n 1)" if [[ "$revoke_job_status_a" != revoked=* ]]; then echo "[ERROR] revoke concurrency output A invalid: $revoke_job_output_a" >&2 exit 1 fi if [[ "$revoke_job_status_b" != revoked=* ]]; then echo "[ERROR] revoke concurrency output B invalid: $revoke_job_output_b" >&2 exit 1 fi assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke concurrency" assert_delegation_auto_revoke_audit_count "$DELEGATION_EXPIRED_ID" "1" "delegation auto revoke concurrency" else revoke_job_output="$(npm run --silent job:revoke-expired-delegations)" revoke_job_status="$(printf '%s\n' "$revoke_job_output" | tail -n 1)" if [[ "$revoke_job_status" != revoked=* ]]; then echo "[ERROR] revoke expired delegations script output invalid: $revoke_job_output" >&2 exit 1 fi assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke" fi manager_approve_after_expired_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_EXPIRED/approve" "$MANAGER_TOKEN" "$TMP_DIR/delegation-expired-manager-approve.json" '{}')" assert_status "$manager_approve_after_expired_status" "200" "manager approve after expired delegation" "$TMP_DIR/delegation-expired-manager-approve.json" echo "[19/23] pay success: approved -> paid and invoices archived" RID_PAY_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-ok-create.json")" IID_PAY_OK="$(create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "pay-ok")" submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-submit.json" TASK_PAY_OK="$(jq -er '.data.currentTaskId' "$TMP_DIR/pay-ok-submit.json")" approve_pay_ok_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_PAY_OK/approve" "$MANAGER_TOKEN" "$TMP_DIR/pay-ok-approve-manager.json" '{}')" assert_status "$approve_pay_ok_status" "200" "approve for pay success scenario" "$TMP_DIR/pay-ok-approve-manager.json" NEXT_TASK_PAY_OK="$(jq -r '.data.nextTaskId // empty' "$TMP_DIR/pay-ok-approve-manager.json")" if [[ -n "$NEXT_TASK_PAY_OK" ]]; then approve_pay_ok_finance_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$NEXT_TASK_PAY_OK/approve" "$FINANCE_TOKEN" "$TMP_DIR/pay-ok-approve-finance.json" '{}')" assert_status "$approve_pay_ok_finance_status" "200" "approve finance step for pay success" "$TMP_DIR/pay-ok-approve-finance.json" fi assert_reimbursement_status_db "$RID_PAY_OK" "approved" "pay success precondition" pay_ok_body="{\"paymentVoucherNo\":\"VOUCHER-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-07\",\"paymentChannel\":\"bank\"}" pay_ok_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-pay.json" "$pay_ok_body")" assert_status "$pay_ok_status" "200" "pay approved reimbursement" "$TMP_DIR/pay-ok-pay.json" assert_jq "$TMP_DIR/pay-ok-pay.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 1 and .data.payment.idempotent == false and .data.payment.paymentChannel == "bank"' "pay success response" assert_reimbursement_status_db "$RID_PAY_OK" "paid" "pay success" assert_invoices_archived_used_complete "$RID_PAY_OK" "pay success" echo "[20/23] pay invalid status: draft/reviewing/rejected/cancelled" pay_invalid_draft_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_NEGATIVE" "$TMP_DIR/pay-invalid-draft.json")" assert_status "$pay_invalid_draft_status" "409" "pay invalid status draft" "$TMP_DIR/pay-invalid-draft.json" assert_error_code "$TMP_DIR/pay-invalid-draft.json" "VALID_001" "pay invalid status draft" assert_error_detail "$TMP_DIR/pay-invalid-draft.json" '.error.details.status' "invalid_pay_status" "pay invalid status draft" RID_PAY_REVIEWING="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-reviewing-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "pay-reviewing" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "$TMP_DIR/pay-reviewing-submit.json" pay_invalid_reviewing_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_REVIEWING" "$TMP_DIR/pay-invalid-reviewing.json")" assert_status "$pay_invalid_reviewing_status" "409" "pay invalid status reviewing" "$TMP_DIR/pay-invalid-reviewing.json" assert_error_code "$TMP_DIR/pay-invalid-reviewing.json" "VALID_001" "pay invalid status reviewing" assert_error_detail "$TMP_DIR/pay-invalid-reviewing.json" '.error.details.status' "invalid_pay_status" "pay invalid status reviewing" withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "cleanup pay invalid reviewing" "$TMP_DIR/pay-reviewing-withdraw-cleanup.json" assert_reimbursement_status_db "$RID_PAY_REVIEWING" "draft" "pay invalid reviewing cleanup" pay_invalid_rejected_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_REJECT" "$TMP_DIR/pay-invalid-rejected.json")" assert_status "$pay_invalid_rejected_status" "409" "pay invalid status rejected" "$TMP_DIR/pay-invalid-rejected.json" assert_error_code "$TMP_DIR/pay-invalid-rejected.json" "VALID_001" "pay invalid status rejected" assert_error_detail "$TMP_DIR/pay-invalid-rejected.json" '.error.details.status' "invalid_pay_status" "pay invalid status rejected" RID_PAY_CANCELLED="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-cancelled-create.json")" set_reimbursement_status "$RID_PAY_CANCELLED" "cancelled" pay_invalid_cancelled_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_CANCELLED" "$TMP_DIR/pay-invalid-cancelled.json")" assert_status "$pay_invalid_cancelled_status" "409" "pay invalid status cancelled" "$TMP_DIR/pay-invalid-cancelled.json" assert_error_code "$TMP_DIR/pay-invalid-cancelled.json" "VALID_001" "pay invalid status cancelled" assert_error_detail "$TMP_DIR/pay-invalid-cancelled.json" '.error.details.status' "invalid_pay_status" "pay invalid status cancelled" echo "[21/23] pay permission: non-finance forbidden" pay_perm_status="$(pay_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-perm-employee.json")" assert_status "$pay_perm_status" "403" "pay forbidden for non-finance" "$TMP_DIR/pay-perm-employee.json" assert_error_code "$TMP_DIR/pay-perm-employee.json" "PERM_001" "pay forbidden for non-finance" echo "[22/23] pay idempotent: paid reimbursement can be paid again" pay_idempotent_body="{\"paymentVoucherNo\":\"VOUCHER-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-07\",\"paymentChannel\":\"bank\"}" pay_idempotent_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-idempotent.json" "$pay_idempotent_body")" assert_status "$pay_idempotent_status" "200" "pay idempotent" "$TMP_DIR/pay-idempotent.json" assert_jq "$TMP_DIR/pay-idempotent.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 0 and .data.payment.idempotent == true and .data.payment.paymentVoucherNo != null' "pay idempotent response" assert_reimbursement_status_db "$RID_PAY_OK" "paid" "pay idempotent" assert_invoices_archived_used_complete "$RID_PAY_OK" "pay idempotent" pay_conflict_body="{\"paymentVoucherNo\":\"VOUCHER-CONFLICT-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-06\",\"paymentChannel\":\"alipay\"}" pay_conflict_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-conflict.json" "$pay_conflict_body")" assert_status "$pay_conflict_status" "409" "pay idempotent conflict" "$TMP_DIR/pay-conflict.json" assert_error_code "$TMP_DIR/pay-conflict.json" "VALID_001" "pay idempotent conflict" assert_error_detail "$TMP_DIR/pay-conflict.json" '.error.details.payment' "already_paid_conflict" "pay idempotent conflict" if [[ "$RUN_PAY_CONCURRENCY_CHECK" == "1" ]]; then echo "[22.1/23] optional pay concurrency check" RID_PAY_CONCURRENCY="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-concurrency-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_CONCURRENCY" "pay-concurrency" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-submit.json" TASK_PAY_CONCURRENCY="$(jq -er '.data.currentTaskId' "$TMP_DIR/pay-concurrency-submit.json")" approve_pay_concurrency_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_PAY_CONCURRENCY/approve" "$MANAGER_TOKEN" "$TMP_DIR/pay-concurrency-approve.json" '{}')" assert_status "$approve_pay_concurrency_status" "200" "approve pay concurrency scenario" "$TMP_DIR/pay-concurrency-approve.json" ( pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-a.json" > "$TMP_DIR/pay-concurrency-a.status" ) & pid_a=$! ( pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-b.json" > "$TMP_DIR/pay-concurrency-b.status" ) & pid_b=$! wait "$pid_a" wait "$pid_b" status_a="$(cat "$TMP_DIR/pay-concurrency-a.status")" status_b="$(cat "$TMP_DIR/pay-concurrency-b.status")" assert_status "$status_a" "200" "pay concurrency request a" "$TMP_DIR/pay-concurrency-a.json" assert_status "$status_b" "200" "pay concurrency request b" "$TMP_DIR/pay-concurrency-b.json" assert_reimbursement_status_db "$RID_PAY_CONCURRENCY" "paid" "pay concurrency" assert_invoices_archived_used_complete "$RID_PAY_CONCURRENCY" "pay concurrency" fi echo "[23/23] Final manager relation restore" set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID" EMPLOYEE_MANAGER_PATCHED=0 echo "[OK] Workflow action regression passed" echo "- reject reimbursementId: $RID_REJECT" echo "- return reimbursementId: $RID_RETURN" echo "- withdraw reimbursementId: $RID_WITHDRAW" echo "- perm reimbursementId: $RID_PERM" echo "- negative reimbursementId: $RID_NEGATIVE" echo "- manager-skip reimbursementId: $RID_SKIP" echo "- no-actionable reimbursementId: $RID_NO_ACTIONABLE" echo "- transfer-ok reimbursementId: $RID_TRANSFER_OK" echo "- transfer-chain reimbursementId: $RID_TRANSFER_CHAIN" echo "- pay reimbursementId: $RID_PAY_OK"