Authorised by rulings D7 and T1-T3 in docs/parity/DECISIONS.md. This is the CLAUDE.md section 3 carve-out: matching the reference here would keep a money write path open to anonymous callers. Closed, each behind the rid the legacy vocabulary already had: GET accounting/customers/:id/balance -> wallet.list POST accounting/customers/:id/reconcile -> wallet.charge POST accounting/repayments/gold -> document.create POST accounting/repayments/rial -> document.create POST accounting/entries/:id/confirm -> document.list.confirm PATCH orders/:id/status -> order.list.sts.submit Four of the accounting routes WRITE to the ledger. The order-status route was worse in kind: its DTO takes a client-supplied actorAdminUserId, so an anonymous caller could move any order and pin it on any admin. It had no caller - the admin panel posts to admin/orders/status. GET /customers kept its public status (both apps' login still matches the phone number in the browser) but now selects only id/mobile/isActive/ isArchived. It previously returned every active customer's full name, national code, both debts, credit limit and the URLs of their identity-document scans. CustomerSummary narrowed in BOTH user apps in the same commit, per the v1/v2 parity rule. Not changed: /uploads stays public per ruling T2. The filename check requested alongside it passed unchanged - md5(time + name + uuid), no directory listing. Tests: apps/api/scripts/auth-smoke.sh asserts 401 without a token, 403 with a low-privilege token (ut 88, 5 unrelated rids) and pass-through for a super admin on every closed route, plus that the public routes still answer 200 and that GET /customers exposes only the four login fields. 23 passed, 0 failed against a live server. Typecheck clean on api, user-app and user-app-v2. [ref: henza_server/routes/admin.php:12,272; ApiMobileController.php:39-78] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
3.4 KiB
Bash
97 lines
3.4 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# Auth smoke test for the routes closed in the security hotfix.
|
|
#
|
|
# For every route that was made non-public, asserts:
|
|
# - no token -> 401
|
|
# - low-privilege token -> 403
|
|
# - super-admin token -> anything but 401/403 (the guard let it through)
|
|
#
|
|
# And asserts the genuinely-public routes still answer 200, plus that
|
|
# GET /customers no longer leaks anything beyond the four login-lookup fields.
|
|
#
|
|
# Usage: API=http://localhost:3000/api bash scripts/auth-smoke.sh
|
|
# Needs the API running and the dev seed applied (npx prisma db seed).
|
|
|
|
set -uo pipefail
|
|
API="${API:-http://localhost:3000/api}"
|
|
|
|
SUPER_PHONE=09128081392; SUPER_PASS=09128081392 # ut 85, all rids
|
|
LOW_PHONE=09123334444; LOW_PASS=admin1234 # ut 88, 5 unrelated rids
|
|
|
|
pass=0; fail=0
|
|
ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass+1)); }
|
|
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); }
|
|
|
|
login() {
|
|
curl -s -X POST "$API/admin/auth/login" -H 'Content-Type: application/json' \
|
|
-d "{\"phone\":\"$1\",\"password\":\"$2\"}" |
|
|
sed -n 's/.*"api_token":"\([^"]*\)".*/\1/p'
|
|
}
|
|
|
|
code() { # method path [token]
|
|
local m=$1 p=$2 t=${3:-}
|
|
if [ -n "$t" ]; then
|
|
curl -s -o /dev/null -w '%{http_code}' -X "$m" "$API/$p" \
|
|
-H "Authorization: Bearer $t" -H 'Content-Type: application/json' -d '{}'
|
|
else
|
|
curl -s -o /dev/null -w '%{http_code}' -X "$m" "$API/$p" \
|
|
-H 'Content-Type: application/json' -d '{}'
|
|
fi
|
|
}
|
|
|
|
SUPER=$(login "$SUPER_PHONE" "$SUPER_PASS")
|
|
LOW=$(login "$LOW_PHONE" "$LOW_PASS")
|
|
[ -z "$SUPER" ] && { echo "super-admin login failed — is the seed applied?"; exit 1; }
|
|
[ -z "$LOW" ] && { echo "low-priv login failed — is the seed applied?"; exit 1; }
|
|
|
|
# route | required rid
|
|
CLOSED=(
|
|
"GET|accounting/customers/x/balance|wallet.list"
|
|
"POST|accounting/customers/x/reconcile|wallet.charge"
|
|
"POST|accounting/repayments/gold|document.create"
|
|
"POST|accounting/repayments/rial|document.create"
|
|
"POST|accounting/entries/x/confirm|document.list.confirm"
|
|
"PATCH|orders/x/status|order.list.sts.submit"
|
|
)
|
|
|
|
echo "Closed routes — no token must be 401:"
|
|
for row in "${CLOSED[@]}"; do
|
|
IFS='|' read -r m p _ <<<"$row"
|
|
c=$(code "$m" "$p")
|
|
[ "$c" = 401 ] && ok "$m $p -> 401" || bad "$m $p -> $c (want 401)"
|
|
done
|
|
|
|
echo "Closed routes — low-privilege token must be 403:"
|
|
for row in "${CLOSED[@]}"; do
|
|
IFS='|' read -r m p rid <<<"$row"
|
|
c=$(code "$m" "$p" "$LOW")
|
|
[ "$c" = 403 ] && ok "$m $p [$rid] -> 403" || bad "$m $p [$rid] -> $c (want 403)"
|
|
done
|
|
|
|
echo "Closed routes — super admin must get past the guard:"
|
|
for row in "${CLOSED[@]}"; do
|
|
IFS='|' read -r m p _ <<<"$row"
|
|
c=$(code "$m" "$p" "$SUPER")
|
|
case "$c" in
|
|
401|403) bad "$m $p -> $c (guard rejected a super admin)" ;;
|
|
*) ok "$m $p -> $c (through the guard)" ;;
|
|
esac
|
|
done
|
|
|
|
echo "Public routes must stay reachable:"
|
|
for p in "catalog/products" "pricing/latest?karatPerMille=750" "home/slider" "customers"; do
|
|
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p")
|
|
[ "$c" = 200 ] && ok "GET $p -> 200" || bad "GET $p -> $c (want 200)"
|
|
done
|
|
|
|
echo "GET /customers must expose only the login-lookup fields:"
|
|
keys=$(curl -s "$API/customers" | sed -n 's/.*\({[^}]*}\).*/\1/p' | head -1 |
|
|
grep -oE '"[a-zA-Z]+":' | tr -d '":' | sort -u | tr '\n' ' ')
|
|
want="id isActive isArchived mobile "
|
|
[ "$keys" = "$want" ] && ok "fields = $keys" || bad "fields = [$keys] want [$want]"
|
|
|
|
echo
|
|
echo "passed: $pass failed: $fail"
|
|
[ "$fail" -eq 0 ]
|