Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions scripts/fedex-harness-seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Seed the FedEx test harness: log in, (re)create both sandbox credentials from .env.local,
and (re)create the four test workflows. Idempotent — deletes any existing harness objects by
name first, so re-running always yields a clean, known set. Invoked by fedex-test-harness.sh."""
import json
import os
import urllib.error
import urllib.request

BASE = os.environ["HARNESS_BASE"]
EMAIL = os.environ["HARNESS_EMAIL"]
PASSWORD = os.environ["HARNESS_PASSWORD"]
ENV_FILE = os.environ["HARNESS_ENV_FILE"]

# Sandbox account + canonical test inputs proven to return real data against apis-sandbox.fedex.com.
ACCOUNT = "130125136"
SHIPPER = {
"shipperStreetLines": "3610 Hacks Cross Rd", "shipperCity": "Memphis",
"shipperStateOrProvinceCode": "TN", "shipperPostalCode": "38125", "shipperCountryCode": "US",
"shipperPersonName": "Test Shipper", "shipperPhoneNumber": "9015551234",
}
RECIPIENT = {
"recipientStreetLines": "1600 Amphitheatre Pkwy", "recipientCity": "Mountain View",
"recipientStateOrProvinceCode": "CA", "recipientPostalCode": "94043", "recipientCountryCode": "US",
"recipientPersonName": "Test Recipient", "recipientPhoneNumber": "6505551234",
}

SHIP_CRED_NAME = "FedEx Shipping — Sandbox (harness)"
TRACK_CRED_NAME = "FedEx Track — Sandbox (harness)"

_cookie = None


def _load_env():
env = {}
with open(ENV_FILE) as fh:
for line in fh:
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
env[k] = v
for key in ("TRACK_API_CLIENT", "TRACK_API_SECRET", "SHIP_API_CLIENT", "SHIP_API_SECRET"):
if not env.get(key):
raise SystemExit(f"✖ {key} missing from {ENV_FILE}")
return env


def call(method, path, data=None):
global _cookie
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(BASE + path, data=body, method=method)
req.add_header("Content-Type", "application/json")
if _cookie:
req.add_header("Cookie", _cookie)
try:
resp = urllib.request.urlopen(req)
sc = resp.headers.get_all("Set-Cookie")
if sc:
_cookie = "; ".join(c.split(";")[0] for c in sc)
raw = resp.read().decode() or "{}"
return resp.status, json.loads(raw)
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode()[:200]


def login():
# n8n restarts (install step) flip /healthz to 200 before REST routes are mounted, so
# /rest/login can briefly 404/401 — retry past that race.
import time
last = None
for _ in range(30):
sc, _r = call("POST", "/rest/login", {"emailOrLdapLoginId": EMAIL, "password": PASSWORD})
if sc == 200:
return
last = sc
time.sleep(1)
raise SystemExit(f"✖ login failed (last={last}) — is the owner seeded?")


def delete_existing(names):
"""Remove any credentials/workflows whose name is in `names` so re-seeding is clean."""
_, creds = call("GET", "/rest/credentials")
for c in creds.get("data", []) if isinstance(creds, dict) else []:
if c["name"] in names:
call("DELETE", f"/rest/credentials/{c['id']}")
_, wfs = call("GET", "/rest/workflows")
for w in wfs.get("data", []) if isinstance(wfs, dict) else []:
if w["name"] in names:
# n8n 2.x requires a workflow be archived before it can be deleted.
call("POST", f"/rest/workflows/{w['id']}/archive")
call("DELETE", f"/rest/workflows/{w['id']}")


def make_cred(name, ctype, client_id, secret):
data = {
"environment": "sandbox", "clientId": client_id, "clientSecret": secret,
"grantType": "clientCredentials",
"accessTokenUrl": "https://apis-sandbox.fedex.com/oauth/token",
"scope": "", "authQueryParameters": "", "authentication": "body",
}
sc, r = call("POST", "/rest/credentials", {"name": name, "type": ctype, "data": data})
if sc != 200:
raise SystemExit(f"✖ credential '{name}' failed ({sc}): {r}")
print(f" ✓ credential: {name}")
return r["data"]["id"]


def fedex_node(params, cred_type, cred_id, cred_name):
return {
"parameters": params, "id": "b2222222-2222-2222-2222-222222222222", "name": "FedEx",
"type": "@nodrel-dev/n8n-nodes-fedex.fedex", "typeVersion": 1, "position": [560, 300],
"credentials": {cred_type: {"id": cred_id, "name": cred_name}},
}


def make_workflow(name, node):
trigger = {
"parameters": {}, "id": "a1111111-1111-1111-1111-111111111111",
"name": "When clicking 'Test workflow'", "type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1, "position": [300, 300],
}
body = {
"name": name, "nodes": [trigger, node],
"connections": {"When clicking 'Test workflow'": {"main": [[{"node": "FedEx", "type": "main", "index": 0}]]}},
"settings": {"executionOrder": "v1"},
}
sc, r = call("POST", "/rest/workflows", body)
if sc != 200:
raise SystemExit(f"✖ workflow '{name}' failed ({sc}): {r}")
print(f" ✓ workflow: {name} → {BASE}/workflow/{r['data']['id']}")


def main():
env = _load_env()
login()
delete_existing({SHIP_CRED_NAME, TRACK_CRED_NAME,
"FedEx 1 — Track shipment", "FedEx 2 — Validate address",
"FedEx 3 — Get rates", "FedEx 4 — Create shipment + label"})

ship_id = make_cred(SHIP_CRED_NAME, "fedexShippingOAuth2Api",
env["SHIP_API_CLIENT"], env["SHIP_API_SECRET"])
track_id = make_cred(TRACK_CRED_NAME, "fedexTrackOAuth2Api",
env["TRACK_API_CLIENT"], env["TRACK_API_SECRET"])

make_workflow("FedEx 1 — Track shipment", fedex_node(
{"resource": "tracking", "operation": "track", "authentication": "fedexTrackOAuth2Api",
"trackingMultiple": False, "trackingNumber": "123456789012", "includeDetailedScans": True},
"fedexTrackOAuth2Api", track_id, TRACK_CRED_NAME))

make_workflow("FedEx 2 — Validate address", fedex_node(
{"resource": "shipping", "operation": "validate", "authentication": "fedexShippingOAuth2Api",
"addressStreetLines": "7372 PARKRIDGE BLVD", "addressCity": "IRVING",
"addressStateOrProvinceCode": "TX", "addressPostalCode": "75063", "addressCountryCode": "US"},
"fedexShippingOAuth2Api", ship_id, SHIP_CRED_NAME))

make_workflow("FedEx 3 — Get rates", fedex_node(
{"resource": "shipping", "operation": "getRates", "authentication": "fedexShippingOAuth2Api",
"shippingAccountNumber": ACCOUNT, **SHIPPER, **RECIPIENT,
"serviceType": "", "packageWeight": 5, "weightUnit": "LB", "additionalFields": {}},
"fedexShippingOAuth2Api", ship_id, SHIP_CRED_NAME))

make_workflow("FedEx 4 — Create shipment + label", fedex_node(
{"resource": "shipping", "operation": "create", "authentication": "fedexShippingOAuth2Api",
"shippingAccountNumber": ACCOUNT, **SHIPPER, **RECIPIENT, "serviceType": "FEDEX_GROUND",
"packageWeight": 5, "weightUnit": "LB", "labelImageType": "PDF", "additionalFields": {}},
"fedexShippingOAuth2Api", ship_id, SHIP_CRED_NAME))


if __name__ == "__main__":
main()
167 changes: 167 additions & 0 deletions scripts/fedex-test-harness.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
#
# fedex-test-harness.sh — one-command, fully-seeded local n8n for testing the FedEx node.
#
# Unlike n8n-demo.sh (which leaves owner-setup + node install + credentials to you), this
# harness does EVERYTHING automatically and idempotently:
# • boots a persistent n8n container (pinned encryption key, so credentials survive restarts)
# • auto-creates a PERMANENT owner login (no manual setup screen — see creds below)
# • builds + packs THIS repo's node and installs it into the container
# • seeds both FedEx sandbox credentials from .env.local
# • loads the four test workflows (Track / Validate / Get Rates / Create)
#
# Permanent login (always created for you):
# email: admin@fedex.test
# password: FedexTest123
#
# Usage:
# ./scripts/fedex-test-harness.sh up # provision everything (idempotent; re-run anytime)
# ./scripts/fedex-test-harness.sh login # print the URL + login + workflow links
# ./scripts/fedex-test-harness.sh seed # re-install node + re-seed creds/workflows only
# ./scripts/fedex-test-harness.sh status # container/volume state
# ./scripts/fedex-test-harness.sh logs # tail n8n logs
# ./scripts/fedex-test-harness.sh reset # remove container + volume (next 'up' is clean)
#
set -euo pipefail

# ── Permanent config (the login is fixed on purpose — never asked for again) ──────────────
OWNER_EMAIL="${HARNESS_EMAIL:-admin@fedex.test}"
OWNER_PASSWORD="${HARNESS_PASSWORD:-FedexTest123}"
OWNER_FIRST="FedEx"
OWNER_LAST="Admin"

NAME="${HARNESS_NAME:-n8n-fedex-uxtest}"
PORT="${HARNESS_PORT:-5690}"
VOLUME="${HARNESS_VOLUME:-n8n-fedex-uxdata}"
IMAGE="${HARNESS_IMAGE:-docker.n8n.io/n8nio/n8n}"
# Pinned so seeded credentials decrypt across restarts/recreation (dev-only key; sandbox creds).
ENC_KEY="${HARNESS_ENC_KEY:-fedex-harness-dev-encryption-key-0001}"

REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="$REPO_DIR/.env.local"
BASE="http://localhost:${PORT}"

die() { echo "✖ $*" >&2; exit 1; }
need_docker() {
command -v docker >/dev/null 2>&1 || die "Docker not found. Install Docker Desktop."
docker info >/dev/null 2>&1 || die "Docker daemon not running. Start Docker Desktop."
}
container_exists() { docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; }
container_running() { docker ps --format '{{.Names}}' | grep -qx "$NAME"; }

wait_healthy() {
echo -n " waiting for n8n…"
for _ in $(seq 1 60); do
if [ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/healthz" 2>/dev/null)" = "200" ]; then
echo " ready"; return 0
fi
echo -n "."; sleep 1
done
die "n8n did not become healthy on $BASE"
}

ensure_container() {
need_docker
[ -f "$ENV_FILE" ] || die ".env.local not found — needs TRACK_API_CLIENT/SECRET + SHIP_API_CLIENT/SECRET"
if container_running; then echo "✓ '$NAME' already running"; return; fi
if container_exists; then echo "↻ starting existing '$NAME'…"; docker start "$NAME" >/dev/null; wait_healthy; return; fi
echo "⬆ creating '$NAME' on port $PORT (volume $VOLUME)…"
docker volume create "$VOLUME" >/dev/null
docker run -d --name "$NAME" \
-p "${PORT}:5678" \
-v "${VOLUME}:/home/node/.n8n" \
-e N8N_ENCRYPTION_KEY="$ENC_KEY" \
-e N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true \
-e N8N_DIAGNOSTICS_ENABLED=false \
"$IMAGE" >/dev/null
wait_healthy
}

seed_owner() {
# /healthz flips to 200 before user-management is ready, so the first owner-setup can fail —
# retry past that race (known n8n gotcha). Terminal states: setup 200 (created) or login 200
# (owner already exists on a reused volume).
local setup login
for _ in $(seq 1 25); do
setup=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/rest/owner/setup" \
-H 'Content-Type: application/json' \
--data "{\"email\":\"$OWNER_EMAIL\",\"firstName\":\"$OWNER_FIRST\",\"lastName\":\"$OWNER_LAST\",\"password\":\"$OWNER_PASSWORD\"}" || true)
if [ "$setup" = "200" ]; then echo "✓ owner created: $OWNER_EMAIL"; return; fi
login=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/rest/login" \
-H 'Content-Type: application/json' \
--data "{\"emailOrLdapLoginId\":\"$OWNER_EMAIL\",\"password\":\"$OWNER_PASSWORD\"}" || true)
if [ "$login" = "200" ]; then echo "✓ owner already set up ($OWNER_EMAIL)"; return; fi
sleep 1
done
die "owner setup did not succeed after retries (setup=$setup login=$login)"
}

install_node() {
echo "🔧 building + packing the node…"
# Clear stray *.tgz artifacts (incl. a directory Docker may auto-create from a dangling
# bind-mount source) so npm pack can write its tarball.
rm -rf "$REPO_DIR"/*.tgz 2>/dev/null || true
( cd "$REPO_DIR" && pnpm build >/dev/null 2>&1 ) || die "pnpm build failed"
# npm prints the tarball name to stderr in some versions, so derive it deterministically from
# package.json (npm's scoped-name convention: @scope/name → scope-name-<version>.tgz).
local tgz; tgz=$(cd "$REPO_DIR" && python3 -c "import json;d=json.load(open('package.json'));print(d['name'].lstrip('@').replace('/','-')+'-'+d['version']+'.tgz')")
( cd "$REPO_DIR" && npm pack >/dev/null 2>&1 ) || die "npm pack failed"
[ -f "$REPO_DIR/$tgz" ] || die "packed tarball not found: $tgz"
local remote="/tmp/fedex-node-$(date +%s).tgz"
docker cp "$REPO_DIR/$tgz" "$NAME:$remote" >/dev/null
echo "📦 installing into container…"
docker exec "$NAME" sh -c "mkdir -p ~/.n8n/nodes && cd ~/.n8n/nodes && npm install $remote >/dev/null 2>&1" \
|| die "node install failed inside container"
rm -f "$REPO_DIR/$tgz"
echo "↻ restarting n8n to load the node…"
docker restart "$NAME" >/dev/null
wait_healthy
}

seed_rest() {
echo "🔑 seeding credentials + workflows…"
HARNESS_BASE="$BASE" \
HARNESS_EMAIL="$OWNER_EMAIL" HARNESS_PASSWORD="$OWNER_PASSWORD" \
HARNESS_ENV_FILE="$ENV_FILE" \
python3 "$REPO_DIR/scripts/fedex-harness-seed.py"
}

cmd_up() { ensure_container; seed_owner; install_node; seed_rest; cmd_login; }
cmd_seed() { ensure_container; seed_owner; install_node; seed_rest; cmd_login; }
cmd_login() {
cat <<EOF

────────────────────────────────────────────────────────────
✓ FedEx test harness ready
URL: $BASE
Email: $OWNER_EMAIL
Password: $OWNER_PASSWORD

Open any "FedEx 1–4" workflow → click "Test workflow".
Field values & expected results: docs/test-workflows/MANUAL-TEST-GUIDE.md
────────────────────────────────────────────────────────────
EOF
}
cmd_status() {
need_docker
echo "container: $(container_running && echo running || { container_exists && echo stopped || echo absent; })"
echo "volume: $(docker volume ls --format '{{.Name}}' | grep -qx "$VOLUME" && echo "$VOLUME (present)" || echo absent)"
echo "url: $BASE"
}
cmd_logs() { need_docker; docker logs -f "$NAME"; }
cmd_reset() {
need_docker
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker volume rm "$VOLUME" >/dev/null 2>&1 || true
echo "🗑 removed container + volume — next 'up' is a clean, fully-seeded instance."
}

case "${1:-}" in
up) cmd_up ;;
seed) cmd_seed ;;
login) cmd_login ;;
status) cmd_status ;;
logs) cmd_logs ;;
reset) cmd_reset ;;
*) echo "usage: $0 {up|seed|login|status|logs|reset}"; exit 1 ;;
esac