diff --git a/scripts/fedex-harness-seed.py b/scripts/fedex-harness-seed.py new file mode 100755 index 0000000..1142f8c --- /dev/null +++ b/scripts/fedex-harness-seed.py @@ -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() diff --git a/scripts/fedex-test-harness.sh b/scripts/fedex-test-harness.sh new file mode 100755 index 0000000..3801035 --- /dev/null +++ b/scripts/fedex-test-harness.sh @@ -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-.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 </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