diff --git a/.env.example b/.env.example index 53ae5c0..04e8697 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,25 @@ CLOUDINARY_API_KEY=your_api_key CLOUDINARY_API_SECRET=your_api_secret CLOUDINARY_URL=cloudinary://api_key:api_secret@cloud_name +# EmailJS configuration for sending emails +EMAILJS_API_URL=https://api.emailjs.com/api/v1.0/email/send +EMAILJS_PRIVATE_KEY=your_private_key +EMAILJS_PUBLIC_KEY=your_public_key +EMAILJS_SERVICE_ID=your_service_id +EMAILJS_TEMPLATE_ID=your_template_id + +# Stellar blockchain network (testnet or mainnet) +EMAILJS_PRIVATE_KEY=your_emailjs_private_key +EMAILJS_PUBLIC_KEY=your_emailjs_public_key +EMAILJS_SERVICE_ID=your_emailjs_service_id +EMAILJS_TEMPLATE_ID=your_emailjs_template_id +EMAILJS_RECEIPT_TEMPLATE_ID=your_emailjs_receipt_template_id +EMAILJS_PRIVATE_KEY=your_private_key +EMAILJS_PUBLIC_KEY=your_public_key +EMAILJS_SERVICE_ID=your_service_id +EMAILJS_TEMPLATE_ID=your_template_id + +# Stellar blockchain network (testnet or mainnet) # SendLib for transactional email (verification, OTP, receipts). Used by # services/emails/sendMail.js over HTTPS, so it works on hosts that block @@ -171,6 +190,28 @@ JOBS_ENABLED=true QUEUE_DRIVER=mongo JOBS_DASHBOARD_TOKEN=replace_with_a_long_random_token +# SEP-24 anchor integration (comma-separated home domain allowlist; no scheme) +ANCHOR_HOME_DOMAINS=testanchor.stellar.org +# How long to cache a resolved stellar.toml, in seconds +ANCHOR_TOML_CACHE_TTL=3600 + +# Redis Configuration (optional - app works without Redis but with reduced performance) +# Option 1: Use REDIS_URL for full connection string (recommended for cloud services) +# REDIS_URL=redis://username:password@host:port + +# Option 2: Use separate credentials +REDIS_HOST=localhost +REDIS_PORT=6379 +# REDIS_USERNAME=default +# REDIS_PASSWORD=your_password + +# Jitsi configuration for video calls (optional) +# JITSI_MEET_DOMAIN=your_jitsi_domain +# JITSI_APP_ID=your_app_id +# JITSI_PRIVATE_KEY=your_private_key +# JITSI_PUBLIC_KEY_ID=your_public_key_id +# JITSI_KID=your_kid +# JITSI_TENANT=your_tenant # Service-to-service auth for the AI service (dnb-ai). A JSON array of signed, # scoped, rotatable keys keyed by `kid`. REQUIRED in production (boot fails # fast if missing); optional in dev/test. Keep >=1 entry active; to rotate, diff --git a/app.js b/app.js index 2c00fd7..1ae4e78 100644 --- a/app.js +++ b/app.js @@ -38,6 +38,7 @@ import callRoutes from "./src/routes/callRoutes.js"; import stellarWalletRoutes from "./src/routes/stellar/walletRoutes.js"; import stellarPaymentRoutes from "./src/routes/stellar/paymentRoutes.js"; import stellarDonationRoutes from "./src/routes/stellar/donationRoutes.js"; +import stellarAnchorRoutes from "./src/routes/stellar/anchorRoutes.js"; import stellarPledgeRoutes from "./src/routes/stellar/pledgeRoutes.js"; import stellarGiftRoutes from "./src/routes/stellar/giftRoutes.js"; import stellarReportsRoutes from "./src/routes/stellar/reportsRoutes.js"; @@ -177,6 +178,23 @@ app.use("/api-docs", apiDocsRoutes); // Auth routes — strict app.use("/api/auth", authLimiter, authRoutes); +// Other API routes +app.use("/api/courses", courseRoutes); +app.use("/api/reels", reelsRoute); +app.use("/api/books", bookRoutes); +app.use("/api/books", recommendedBooksRoutes); +app.use("/api/spaces", spacesRoutes); +app.use("/api/users", userRoutes); +app.use("/api/email", emailRoutes); +app.use("/api/purchase", purchaseRoutes); +app.use("/api/search", searchRoutes); +app.use("/api/calls", callRoutes); +app.use("/api/stellar/wallet", stellarWalletRoutes); +app.use("/api/stellar/payment", stellarPaymentRoutes); +app.use("/api/stellar/donation", stellarDonationRoutes); +app.use("/api/stellar/anchor", stellarAnchorRoutes); +app.use("/api/payouts", payoutRoutes); +app.use("/api/uploads", uploadRoutes); // Mutation routes — standard limiter app.use("/api/email", standardLimiter, emailRoutes); app.use("/api/purchase", standardLimiter, purchaseRoutes); diff --git a/docs/anchors.md b/docs/anchors.md new file mode 100644 index 0000000..d7058c6 --- /dev/null +++ b/docs/anchors.md @@ -0,0 +1,135 @@ +# SEP-24 Anchor Integration + +Non-custodial fiat on/off-ramp for USDC via Stellar SEP-24 (interactive +deposit/withdrawal), relayed through SEP-10 (web authentication) and +SEP-1 (`stellar.toml` discovery). + +## Trust model + +This integration is strictly non-custodial. The backend never holds a +Stellar secret key and never signs a transaction on the user's behalf. + +- **Wallet keys never leave the browser.** Every transaction the backend + builds (the SEP-10 challenge relay, the trustline `changeTrust` operation) + is returned to the client as **unsigned XDR**. The user's wallet signs it + client-side; the backend only ever sees the signed result, exactly like + the existing `paymentController.js` build → sign → submit flow. +- **Anchor JWTs are the one server-held credential**, and they are not + Stellar keys - they're bearer tokens for the user's session with a + specific anchor, structurally similar to a cookie. They're: + - stored in Redis only, keyed by `anchor:jwt:{userId}:{homeDomain}`, + with the Redis key's own TTL set from the JWT's `exp` claim (so it + disappears from storage the moment it would have expired anyway); + - never included in any API response body, header, or log line + (test-proven in `test/anchorJwtCustody.test.js`); + - never verified with an anchor's private key (we don't have one) - only + decoded to read `exp`. Trust in the JWT's authenticity comes from having + obtained it directly from the anchor's HTTPS endpoint immediately after + a validated SEP-10 challenge, not from a local signature check. +- **The SEP-10 challenge is fully validated server-side before it is ever + handed to the client for signing.** A challenge that fails validation + (wrong sequence number, not signed by the anchor's published `SIGNING_KEY`, + wrong network, wrong home domain, or issued for a different account) is + rejected with a 502 and nothing resembling the challenge is returned. See + the rejection matrix in `test/anchorAuth.test.js`. +- **The USDC issuer is cross-checked, never trusted from the anchor alone.** + Before any anchor is used, its self-reported `stellar.toml` currency entry + for USDC must have an issuer matching the platform's own `USDC_ISSUER` + constant (`src/services/stellar/stellarService.js`). A mismatch is refused + outright, regardless of anything else the anchor claims. + +## Allowlisting + +Only anchors on the `ANCHOR_HOME_DOMAINS` allowlist are ever contacted - +including for `stellar.toml` resolution. A request for a non-allowlisted +domain is rejected with `403` before any network call is made. + +``` +# .env +ANCHOR_HOME_DOMAINS=testanchor.stellar.org,anchor.example.com +ANCHOR_TOML_CACHE_TTL=3600 +``` + +- `ANCHOR_HOME_DOMAINS` is a comma-separated list of bare domains (no + scheme). On testnet it defaults to `testanchor.stellar.org` if left unset; + on mainnet it is empty by default and the whole anchor feature returns + `503` until an operator explicitly opts in. +- `ANCHOR_TOML_CACHE_TTL` controls how long a resolved `stellar.toml` is + cached (seconds). Redis-backed; if Redis is unavailable the cache + silently no-ops and each request resolves fresh, same as every other + cache use in this codebase. + +### Adding a mainnet anchor + +1. Confirm the anchor is SEP-1/SEP-10/SEP-24 compliant and publishes a + `stellar.toml` with `TRANSFER_SERVER_SEP0024`, `WEB_AUTH_ENDPOINT`, and + `SIGNING_KEY`. +2. Confirm its published USDC `CURRENCIES` entry uses the same issuer as + this platform's `USDC_ISSUER` (mainnet: + `GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`). If it + doesn't, the integration will refuse the anchor automatically - this + isn't configurable, by design. +3. Add the anchor's bare domain to `ANCHOR_HOME_DOMAINS` in production + config and redeploy. No code change is required. + +## API flow + +1. `GET /api/stellar/anchor/info?homeDomain=...` - resolve and validate an + anchor, returning its deposit/withdraw limits and fees. +2. `POST /api/stellar/anchor/auth/challenge` `{ homeDomain }` - fetch and + fully validate a SEP-10 challenge; returns unsigned XDR for the wallet to + sign. +3. `POST /api/stellar/anchor/auth/verify` `{ homeDomain, signedXdr }` - + submit the signed challenge; the anchor's JWT is stored server-side and + never returned. +4. `POST /api/stellar/anchor/deposits` / `POST /api/stellar/anchor/withdrawals` + `{ homeDomain }` - starts a SEP-24 interactive flow using the stored JWT; + returns the anchor's interactive `url` and `id`. Deposit responses also + include an unsigned `trustlineXdr` (a `changeTrust` operation) if the + user's wallet doesn't yet hold a USDC trustline - sign and submit this + before or alongside the deposit. +5. `GET /api/stellar/anchor/transactions` / `GET /api/stellar/anchor/transactions/:id` - + the user's own anchor transaction records, refreshed live from the anchor + on read if stale. A background poller also refreshes non-terminal + records independently (`src/jobs/anchorPoller.js`). + +Anchor-reported statuses are stored and returned **verbatim** - the full +SEP-24 vocabulary (`incomplete`, `pending_user_transfer_start`, +`pending_anchor`, `pending_stellar`, `completed`, `error`, etc.), not a +collapsed subset. + +## Testnet demo script + +Uses Stellar's public test anchor, `testanchor.stellar.org`, which is the +default allowlisted domain on testnet. + +```bash +# 1. Connect a testnet wallet (see walletController.connectWallet) and log in +# to get an access token, then: + +# 2. Resolve the anchor +curl -H "Authorization: Bearer $TOKEN" \ + "http://localhost:5000/api/stellar/anchor/info?homeDomain=testanchor.stellar.org" + +# 3. Get a SEP-10 challenge +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"homeDomain":"testanchor.stellar.org"}' \ + http://localhost:5000/api/stellar/anchor/auth/challenge +# -> sign the returned XDR with your wallet's secret key, client-side + +# 4. Submit the signed challenge to establish a session +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"homeDomain":"testanchor.stellar.org","signedXdr":""}' \ + http://localhost:5000/api/stellar/anchor/auth/verify + +# 5. Start a deposit +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"homeDomain":"testanchor.stellar.org"}' \ + http://localhost:5000/api/stellar/anchor/deposits +# -> open the returned `url` in a browser to complete the anchor's KYC/deposit +# flow. If a `trustlineXdr` was returned, sign and submit it first. + +# 6. Poll status (or wait for the background poller) +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:5000/api/stellar/anchor/transactions +``` diff --git a/server.js b/server.js index 8bf53f3..430eb2c 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ import validateEnv from "./src/config/validateEnv.js"; import { initRedis, closeRedis } from "./src/config/redis.js"; import { initSockets, closeSockets } from "./src/sockets/index.js"; import { startJobs, stopJobs } from "./src/jobs/queue.js"; +import { startAnchorPoller, stopAnchorPoller } from "./src/jobs/anchorPoller.js"; import { handleUncaughtException, handleUnhandledRejection, @@ -40,6 +41,7 @@ server.listen(PORT, () => { }); startJobs().catch((err) => logger.error(err, "Background job startup failed")); +startAnchorPoller(); // Start payment ingestion worker if enabled let stopIngestionWorker; @@ -87,6 +89,7 @@ const gracefulShutdown = async (signal) => { logger.info("HTTP server closed"); await stopJobs(); + await stopAnchorPoller(); if (stopIngestionWorker) { await stopIngestionWorker(); diff --git a/src/config/logger.js b/src/config/logger.js index 0e32ed3..dc8b127 100644 --- a/src/config/logger.js +++ b/src/config/logger.js @@ -27,6 +27,8 @@ const logger = pino({ "token", "signedXdr", "JWT_SECRET", + "jwt", + "anchorJwt", ], censor: "[REDACTED]", }, diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index 515b751..ade8935 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -48,6 +48,15 @@ const optionalEnvVars = [ "QUEUE_DRIVER", "JOBS_ENABLED", "JOBS_DASHBOARD_TOKEN", + "EMAILJS_RECEIPT_TEMPLATE_ID", + "ANCHOR_HOME_DOMAINS", + "ANCHOR_TOML_CACHE_TTL", + // Redis configuration (optional - app works without Redis) + "REDIS_URL", + "REDIS_HOST", + "REDIS_PORT", + "REDIS_USERNAME", + "REDIS_PASSWORD", "STELLAR_PLATFORM_PUBLIC_KEY", "ORG_NAME", "ORG_URL", @@ -94,6 +103,17 @@ export const validateEnv = () => { process.env.ACCESS_TOKEN_TTL = process.env.ACCESS_TOKEN_TTL || "15m"; process.env.REFRESH_TOKEN_TTL = process.env.REFRESH_TOKEN_TTL || "30d"; + // Anchor allowlist defaults to Stellar's public test anchor on testnet only; + // mainnet requires an operator to explicitly opt in to a real anchor domain. + if ( + !process.env.ANCHOR_HOME_DOMAINS && + (process.env.STELLAR_NETWORK || "testnet") === "testnet" + ) { + process.env.ANCHOR_HOME_DOMAINS = "testanchor.stellar.org"; + } + process.env.ANCHOR_TOML_CACHE_TTL = process.env.ANCHOR_TOML_CACHE_TTL || "3600"; + // Default values for Horizon resilient client if not provided + const network = process.env.STELLAR_NETWORK || "testnet"; // Default values for Horizon resilient client if not provided. The // network-aware default comes from the single source of truth // (config/stellar.js), so the env var always reflects what the app will diff --git a/src/controllers/stellar/anchorController.js b/src/controllers/stellar/anchorController.js new file mode 100644 index 0000000..819efd7 --- /dev/null +++ b/src/controllers/stellar/anchorController.js @@ -0,0 +1,390 @@ +// controllers/stellar/anchorController.js +import User from "../../models/User.js"; +import AnchorTransaction from "../../models/AnchorTransaction.js"; +import { + getAnchorInfo, + fetchAndValidateChallenge, + submitChallengeResponse, + storeAnchorJwt, + getStoredAnchorJwt, + startInteractiveFlow, + fetchAnchorTransactionStatus, + mapAnchorTransactionFields, + isAnchorConfigured, + ANCHOR_TERMINAL_STATUSES, +} from "../../services/stellar/anchorService.js"; +import { + getAccountBalance, + buildChangeTrustTransaction, +} from "../../services/stellar/stellarService.js"; +import logger from "../../config/logger.js"; + +/** + * Resolve anchor info: allowlist check, stellar.toml, issuer verification, SEP-24 /info + * GET /api/stellar/anchor/info?homeDomain=... + */ +export const getInfo = async (req, res) => { + try { + if (!isAnchorConfigured()) { + return res.status(503).json({ + success: false, + message: "Anchor integration is not available right now. Please try again later.", + }); + } + + const { homeDomain } = req.query; + if (!homeDomain) { + return res + .status(400) + .json({ success: false, message: "homeDomain is required" }); + } + + const info = await getAnchorInfo(homeDomain); + res.status(200).json({ + success: true, + message: "Anchor info fetched", + data: { anchor: info }, + }); + } catch (error) { + logger.error("Get anchor info error:", error); + res.status(error.statusCode || 500).json({ + success: false, + message: error.isOperational + ? error.message + : "Failed to fetch anchor info", + }); + } +}; + +/** + * Fetch and fully validate a SEP-10 challenge before returning it for signing + * POST /api/stellar/anchor/auth/challenge + */ +export const requestChallenge = async (req, res) => { + try { + if (!isAnchorConfigured()) { + return res.status(503).json({ + success: false, + message: "Anchor integration is not available right now. Please try again later.", + }); + } + + const { homeDomain } = req.body; + if (!homeDomain) { + return res + .status(400) + .json({ success: false, message: "homeDomain is required" }); + } + + const user = await User.findById(req.user._id).select("stellarWallet"); + if (!user?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + const { challengeXdr, networkPassphrase, webAuthEndpoint } = + await fetchAndValidateChallenge({ + homeDomain, + account: user.stellarWallet.publicKey, + }); + + res.status(200).json({ + success: true, + message: "Anchor challenge fetched", + data: { + challenge: { + xdr: challengeXdr, + networkPassphrase, + homeDomain, + webAuthEndpoint, + }, + }, + }); + } catch (error) { + logger.error("Request anchor challenge error:", error); + res.status(error.statusCode || 500).json({ + success: false, + message: error.isOperational + ? error.message + : "Failed to fetch anchor challenge", + }); + } +}; + +/** + * Submit a client-signed SEP-10 challenge and store the resulting anchor + * JWT server-side. The JWT itself is never included in this (or any) API + * response - it lives only in Redis, keyed by (userId, homeDomain). + * POST /api/stellar/anchor/auth/verify + */ +export const verifyChallenge = async (req, res) => { + try { + if (!isAnchorConfigured()) { + return res.status(503).json({ + success: false, + message: "Anchor integration is not available right now. Please try again later.", + }); + } + + const { homeDomain, signedXdr } = req.body; + if (!homeDomain || !signedXdr) { + return res.status(400).json({ + success: false, + message: "homeDomain and signedXdr are required", + }); + } + + const { token, exp } = await submitChallengeResponse({ homeDomain, signedXdr }); + await storeAnchorJwt(req.user._id.toString(), homeDomain, token, exp); + + logger.info( + `Anchor session established for user ${req.user._id} with ${homeDomain}` + ); + + res.status(200).json({ + success: true, + message: "Anchor authentication successful", + data: null, + }); + } catch (error) { + logger.error("Verify anchor challenge error:", error); + res.status(error.statusCode || 500).json({ + success: false, + message: error.isOperational + ? error.message + : "Failed to verify anchor challenge", + }); + } +}; + +/** + * Shared logic for starting a SEP-24 interactive deposit or withdrawal. + * Trustline handling (changeTrust XDR) only applies to deposits. + */ +const initiateInteractiveFlow = async (req, res, kind) => { + try { + if (!isAnchorConfigured()) { + return res.status(503).json({ + success: false, + message: "Anchor integration is not available right now. Please try again later.", + }); + } + + const { homeDomain } = req.body; + if (!homeDomain) { + return res + .status(400) + .json({ success: false, message: "homeDomain is required" }); + } + + const user = await User.findById(req.user._id).select("stellarWallet"); + if (!user?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + const publicKey = user.stellarWallet.publicKey; + + const jwtToken = await getStoredAnchorJwt(req.user._id.toString(), homeDomain); + if (!jwtToken) { + return res.status(401).json({ + success: false, + message: `Your session with '${homeDomain}' has expired or was never established. Please authenticate with this anchor again.`, + requiresReauth: true, + }); + } + + const anchorInfo = await getAnchorInfo(homeDomain); + + const { url, id } = await startInteractiveFlow({ + transferServer: anchorInfo.transferServer, + jwtToken, + kind, + account: publicKey, + assetCode: "USDC", + }); + + await AnchorTransaction.create({ + user: req.user._id, + homeDomain, + kind, + anchorTransactionId: id, + assetCode: "USDC", + status: "incomplete", + interactiveUrl: url, + }); + + let trustlineXdr; + if (kind === "deposit") { + try { + const balance = await getAccountBalance(publicKey); + if (!balance.hasTrustline) { + const trustlineTx = await buildChangeTrustTransaction({ publicKey }); + trustlineXdr = trustlineTx.xdr; + } + } catch (error) { + // The deposit is already created and persisted at the anchor above - + // a failure here isn't fatal - fall through and return the + // successful url/id without a trustline XDR rather than erroring + // out a deposit the anchor already knows about. + logger.warn(`Trustline build failed for anchor deposit ${id}:`, error); + } + } + + res.status(200).json({ + success: true, + message: `Anchor ${kind} started`, + data: { + [kind]: { + url, + id, + ...(trustlineXdr && { trustlineXdr }), + }, + }, + }); + } catch (error) { + logger.error(`Initiate anchor ${kind} error:`, error); + res.status(error.statusCode || 500).json({ + success: false, + message: error.isOperational + ? error.message + : `Failed to start ${kind} with the anchor`, + }); + } +}; + +/** + * POST /api/stellar/anchor/deposits + */ +export const initiateDeposit = (req, res) => initiateInteractiveFlow(req, res, "deposit"); + +/** + * POST /api/stellar/anchor/withdrawals + */ +export const initiateWithdrawal = (req, res) => initiateInteractiveFlow(req, res, "withdrawal"); + +// A record read after this long without a poll is refreshed live on read. +const LIVE_REFRESH_STALE_MS = 60 * 1000; + +/** + * List the requesting user's own anchor transactions, paginated. + * GET /api/stellar/anchor/transactions + */ +const MAX_TRANSACTIONS_PAGE_LIMIT = 100; +const DEFAULT_TRANSACTIONS_PAGE_LIMIT = 20; + +// Clamps rather than rejects: an out-of-range page/limit is a client mistake +// we can recover from silently, not a request we need to bounce with a 400. +const parsePage = (value) => { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 1 ? parsed : 1; +}; + +const parseLimit = (value) => { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_TRANSACTIONS_PAGE_LIMIT; + return Math.min(parsed, MAX_TRANSACTIONS_PAGE_LIMIT); +}; + +export const getTransactions = async (req, res) => { + try { + const page = parsePage(req.query.page); + const limit = parseLimit(req.query.limit); + const query = { user: req.user._id }; + + const transactions = await AnchorTransaction.find(query) + .sort({ createdAt: -1 }) + .skip((page - 1) * limit) + .limit(limit); + const total = await AnchorTransaction.countDocuments(query); + + res.status(200).json({ + success: true, + message: "Anchor transactions fetched", + data: { + transactions, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit), + }, + }, + }); + } catch (error) { + logger.error("Get anchor transactions error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch anchor transactions", + }); + } +}; + +/** + * Fetch a single anchor transaction owned by the requesting user, refreshing + * it live from the anchor first if it's non-terminal and stale. + * GET /api/stellar/anchor/transactions/:id + */ +export const getTransactionById = async (req, res) => { + try { + const { id } = req.params; + // Scoping the lookup to `user: req.user._id` is what makes this an + // ownership check: another user's transaction simply won't match and + // this returns a generic 404, same as Transaction/cancelTransaction. + const transaction = await AnchorTransaction.findOne({ + _id: id, + user: req.user._id, + }); + + if (!transaction) { + return res.status(404).json({ + success: false, + message: "Anchor transaction not found", + }); + } + + const isTerminal = ANCHOR_TERMINAL_STATUSES.includes(transaction.status); + const isStale = + !transaction.lastPolledAt || + Date.now() - transaction.lastPolledAt.getTime() > LIVE_REFRESH_STALE_MS; + + if (!isTerminal && isStale) { + try { + const jwtToken = await getStoredAnchorJwt( + req.user._id.toString(), + transaction.homeDomain + ); + if (jwtToken) { + const anchorInfo = await getAnchorInfo(transaction.homeDomain); + const tx = await fetchAnchorTransactionStatus({ + transferServer: anchorInfo.transferServer, + jwtToken, + anchorTransactionId: transaction.anchorTransactionId, + }); + Object.assign(transaction, mapAnchorTransactionFields(tx)); + transaction.lastPolledAt = new Date(); + await transaction.save(); + } + } catch (error) { + // A failed live refresh isn't fatal - fall through and return the + // last known state rather than erroring the read. + logger.warn(`Live refresh failed for anchor transaction ${id}:`, error); + } + } + + res.status(200).json({ + success: true, + message: "Anchor transaction fetched", + data: { transaction }, + }); + } catch (error) { + logger.error("Get anchor transaction error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch anchor transaction", + }); + } +}; diff --git a/src/jobs/anchorPoller.js b/src/jobs/anchorPoller.js new file mode 100644 index 0000000..b476e26 --- /dev/null +++ b/src/jobs/anchorPoller.js @@ -0,0 +1,118 @@ +// jobs/anchorPoller.js +import AnchorTransaction from "../models/AnchorTransaction.js"; +import { + getAnchorInfo, + getStoredAnchorJwt, + fetchAnchorTransactionStatus, + mapAnchorTransactionFields, + ANCHOR_TERMINAL_STATUSES, +} from "../services/stellar/anchorService.js"; +import logger from "../config/logger.js"; + +const POLL_TICK_MS = 5000; // how often the poller wakes up to look for due records +const POLL_REFRESH_MS = 15000; // re-check interval for a non-terminal record after a successful poll +const POLL_BACKOFF_BASE_MS = 5000; +const POLL_BACKOFF_MAX_ATTEMPTS = 6; // caps exponential growth + +let pollTimer; +let accepting = true; +let inFlight = Promise.resolve(); + +const backoffDelay = (attempt) => { + const capped = Math.min(attempt, POLL_BACKOFF_MAX_ATTEMPTS); + const exponential = POLL_BACKOFF_BASE_MS * 2 ** Math.max(0, capped - 1); + return exponential + Math.floor(Math.random() * Math.max(1, exponential * 0.2)); +}; + +/** + * Atomically claim the next due, non-terminal record by pushing its + * nextPollAt forward immediately. This is what makes the poller restart-safe + * and safe under concurrent ticks/instances without a separate lock field: + * there is no in-memory queue to lose on restart, every tick reads state + * fresh from the DB, and the atomic findOneAndUpdate prevents two ticks (or + * two processes) from claiming the same record. + */ +const claimDueRecord = async () => { + const now = new Date(); + return AnchorTransaction.findOneAndUpdate( + { status: { $nin: ANCHOR_TERMINAL_STATUSES }, nextPollAt: { $lte: now } }, + { $set: { nextPollAt: new Date(now.getTime() + POLL_REFRESH_MS) } }, + { sort: { nextPollAt: 1 }, new: true } + ); +}; + +const refreshRecord = async (record) => { + try { + const jwtToken = await getStoredAnchorJwt(record.user.toString(), record.homeDomain); + if (!jwtToken) { + // No live anchor session to poll with (expired/never authenticated). + // Back off without treating it as an anchor-side error. + await AnchorTransaction.updateOne( + { _id: record._id }, + { + $set: { nextPollAt: new Date(Date.now() + backoffDelay(record.pollAttempts + 1)) }, + $inc: { pollAttempts: 1 }, + } + ); + return; + } + + const anchorInfo = await getAnchorInfo(record.homeDomain); + const tx = await fetchAnchorTransactionStatus({ + transferServer: anchorInfo.transferServer, + jwtToken, + anchorTransactionId: record.anchorTransactionId, + }); + + Object.assign(record, mapAnchorTransactionFields(tx)); + record.lastPolledAt = new Date(); + record.pollAttempts = 0; + record.lastError = undefined; + // Terminal statuses are excluded from claimDueRecord's query going + // forward, so nextPollAt no longer matters once one is reached. + record.nextPollAt = new Date(Date.now() + POLL_REFRESH_MS); + await record.save(); + } catch (error) { + const attempts = record.pollAttempts + 1; + await AnchorTransaction.updateOne( + { _id: record._id }, + { + $set: { + lastError: error.message, + nextPollAt: new Date(Date.now() + backoffDelay(attempts)), + }, + $inc: { pollAttempts: 1 }, + } + ); + logger.warn( + { anchorTransactionId: record.anchorTransactionId, attempts, error: error.message }, + "Anchor transaction poll failed, backing off" + ); + } +}; + +// Exported so tests can drive a single poll cycle deterministically instead +// of waiting on the real setInterval cadence. +export const tick = async () => { + if (!accepting) return; + const record = await claimDueRecord(); + if (!record) return; + await refreshRecord(record); +}; + +export const startAnchorPoller = () => { + if (pollTimer) return; + accepting = true; + pollTimer = setInterval(() => { + inFlight = tick().catch((error) => logger.error(error, "Anchor poller tick failed")); + }, POLL_TICK_MS); + pollTimer.unref?.(); + logger.info("Anchor transaction poller started"); +}; + +export const stopAnchorPoller = async () => { + accepting = false; + if (pollTimer) clearInterval(pollTimer); + pollTimer = undefined; + await inFlight.catch(() => {}); +}; diff --git a/src/models/AnchorTransaction.js b/src/models/AnchorTransaction.js new file mode 100644 index 0000000..0dcf481 --- /dev/null +++ b/src/models/AnchorTransaction.js @@ -0,0 +1,66 @@ +// models/AnchorTransaction.js +import mongoose from "mongoose"; + +const anchorTransactionSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + homeDomain: { + type: String, + required: true, + }, + kind: { + type: String, + enum: ["deposit", "withdrawal"], + required: true, + }, + // The anchor's own transaction id (SEP-24 `id`). Scoped to the anchor, + // not globally unique across anchors, hence the compound unique index. + anchorTransactionId: { + type: String, + required: true, + }, + assetCode: { + type: String, + default: "USDC", + }, + // SEP-24 statuses are stored verbatim, not normalized - the frontend + // needs the full anchor-reported vocabulary (incomplete, pending_user_transfer_start, + // pending_anchor, pending_stellar, completed, error, etc.), not a collapsed subset. + status: { + type: String, + required: true, + default: "incomplete", + index: true, + }, + interactiveUrl: { + type: String, + }, + // Amounts/fees stored as strings to preserve precision, matching Transaction.js. + amountIn: { type: String }, + amountOut: { type: String }, + amountFee: { type: String }, + stellarTxHash: { + type: String, + }, + // Poller bookkeeping + lastPolledAt: { type: Date }, + nextPollAt: { type: Date, default: Date.now, index: true }, + pollAttempts: { type: Number, default: 0 }, + lastError: { type: String }, + }, + { timestamps: true } +); + +anchorTransactionSchema.index({ user: 1, status: 1 }); +anchorTransactionSchema.index( + { homeDomain: 1, anchorTransactionId: 1 }, + { unique: true } +); +anchorTransactionSchema.index({ status: 1, nextPollAt: 1 }); + +export default mongoose.model("AnchorTransaction", anchorTransactionSchema); diff --git a/src/routes/searchRoutes.js b/src/routes/searchRoutes.js index 51929a6..26905fa 100644 --- a/src/routes/searchRoutes.js +++ b/src/routes/searchRoutes.js @@ -1,4 +1,5 @@ import express from "express"; +import { searchAll } from "../controllers/searchController.js"; import { searchAll, searchEducatorsHandler } from "../controllers/searchController.js"; import { cacheMiddleware } from "../middlewares/cache.js"; import { CACHE_TTL, CACHE_KEYS } from "../utils/cache.js"; @@ -9,6 +10,11 @@ const router = express.Router(); const searchCacheKey = (req) => { const query = req.query.q || req.query.query || ""; const type = req.query.type || "all"; + return `${CACHE_KEYS.SEARCH}${type}:${query.toLowerCase().trim()}`; +}; + +// Main search endpoint - cached for 5 minutes +router.get("/", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchAll); const page = req.query.page || 1; const limit = req.query.limit || 10; const filterKeys = ['minPrice', 'maxPrice', 'free', 'category', 'minRating', 'interest', 'sort']; diff --git a/src/routes/stellar/anchorRoutes.js b/src/routes/stellar/anchorRoutes.js new file mode 100644 index 0000000..9c5ae64 --- /dev/null +++ b/src/routes/stellar/anchorRoutes.js @@ -0,0 +1,24 @@ +// routes/stellar/anchorRoutes.js +import express from "express"; +import { protect } from "../../middlewares/authMiddleware.js"; +import { + getInfo, + requestChallenge, + verifyChallenge, + initiateDeposit, + initiateWithdrawal, + getTransactions, + getTransactionById, +} from "../../controllers/stellar/anchorController.js"; + +const router = express.Router(); + +router.get("/info", protect, getInfo); +router.post("/auth/challenge", protect, requestChallenge); +router.post("/auth/verify", protect, verifyChallenge); +router.post("/deposits", protect, initiateDeposit); +router.post("/withdrawals", protect, initiateWithdrawal); +router.get("/transactions", protect, getTransactions); +router.get("/transactions/:id", protect, getTransactionById); + +export default router; diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 0879725..d171760 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -47,6 +47,7 @@ router.get( router.put( "/update/:id", protect, + upload.single("avatar"), (req, res, next) => { if (req.user.role !== "admin" && req.user._id.toString() !== req.params.id) { return res.status(403).json({ success: false, message: "Not authorized to update this profile", data: null }); diff --git a/src/services/stellar/anchorService.js b/src/services/stellar/anchorService.js new file mode 100644 index 0000000..e2615ab --- /dev/null +++ b/src/services/stellar/anchorService.js @@ -0,0 +1,332 @@ +// services/stellar/anchorService.js +import axios from "axios"; +import jwt from "jsonwebtoken"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { APIError } from "../../middlewares/errorHandler.js"; +import { getCacheOrSet, getCache, setCacheExpireAt } from "../../utils/cache.js"; +import { USDC_ISSUER, networkPassphrase } from "./stellarService.js"; + +const anchorJwtCacheKey = (userId, homeDomain) => `anchor:jwt:${userId}:${homeDomain}`; + +// No project-wide axios timeout convention exists elsewhere (bookController's +// file-proxy call is unbounded too), so this picks a value deliberately: SEP-24 +// anchors are third-party servers we don't control, and both the request path +// and the background poller (anchorPoller) call into them - an unresponsive +// anchor must not be able to hang either. 10s is generous enough for a real +// anchor's /info, web-auth, or /transaction round trip, but bounded enough +// that a hung anchor fails fast instead of stalling a request or poll cycle. +const ANCHOR_HTTP_TIMEOUT_MS = 10_000; + +// SEP-24 terminal statuses: the poller stops refreshing a record once it +// reaches one of these. Every other anchor-reported status is stored and +// surfaced verbatim. +export const ANCHOR_TERMINAL_STATUSES = ["completed", "refunded", "expired", "error"]; + +const anchorTomlCacheTtl = () => + Number(process.env.ANCHOR_TOML_CACHE_TTL) || 3600; + +const allowedHomeDomains = () => + (process.env.ANCHOR_HOME_DOMAINS || "") + .split(",") + .map((domain) => domain.trim()) + .filter(Boolean); + +export const isAllowedHomeDomain = (homeDomain) => + allowedHomeDomains().includes(homeDomain); + +export const isAnchorConfigured = () => allowedHomeDomains().length > 0; + +const normalizeAssetInfo = (asset) => ({ + enabled: !!asset?.enabled, + minAmount: asset?.min_amount ?? null, + maxAmount: asset?.max_amount ?? null, + feeFixed: asset?.fee_fixed ?? null, + feePercent: asset?.fee_percent ?? null, +}); + +/** + * Resolve stellar.toml for an anchor, verify its USDC issuer matches the + * platform's own USDC_ISSUER (never trust the anchor's self-reported + * currency entry), and fetch SEP-24 /info. + * + * The allowlist check runs before any await, so a non-allowlisted domain + * never reaches stellar.toml resolution or any other network call. + */ +export const getAnchorInfo = async (homeDomain) => { + if (!isAllowedHomeDomain(homeDomain)) { + throw new APIError(`Anchor domain '${homeDomain}' is not allowlisted`, 403); + } + + const toml = await getCacheOrSet( + `anchor:toml:${homeDomain}`, + () => StellarSdk.StellarToml.Resolver.resolve(homeDomain), + anchorTomlCacheTtl() + ); + + const transferServer = toml.TRANSFER_SERVER_SEP0024; + const webAuthEndpoint = toml.WEB_AUTH_ENDPOINT; + const signingKey = toml.SIGNING_KEY; + + if (!transferServer || !webAuthEndpoint || !signingKey) { + throw new APIError( + `Anchor '${homeDomain}' stellar.toml is missing TRANSFER_SERVER_SEP0024, WEB_AUTH_ENDPOINT, or SIGNING_KEY`, + 502 + ); + } + + const usdcCurrency = (toml.CURRENCIES || []).find((c) => c.code === "USDC"); + if (!usdcCurrency) { + throw new APIError( + `Anchor '${homeDomain}' does not publish a USDC currency in stellar.toml`, + 502 + ); + } + // The anchor's self-reported currency entry is never trusted on its own - + // its issuer must match the platform's own USDC issuer constant. + if (usdcCurrency.issuer !== USDC_ISSUER) { + throw new APIError( + `Anchor '${homeDomain}' USDC issuer (${usdcCurrency.issuer}) does not match the platform's USDC issuer (${USDC_ISSUER}); refusing to trust this anchor`, + 502 + ); + } + + let sep24Info; + try { + const response = await axios.get(`${transferServer}/info`, { + timeout: ANCHOR_HTTP_TIMEOUT_MS, + }); + sep24Info = response.data; + } catch (error) { + throw new APIError( + `Failed to fetch /info from anchor '${homeDomain}': ${error.message}`, + 502 + ); + } + + return { + homeDomain, + transferServer, + webAuthEndpoint, + signingKey, + currency: usdcCurrency, + deposit: normalizeAssetInfo(sep24Info?.deposit?.USDC), + withdraw: normalizeAssetInfo(sep24Info?.withdraw?.USDC), + }; +}; + +/** + * Fetch a SEP-10 challenge transaction from the anchor and fully validate + * it before returning anything to the caller. A challenge is only ever + * handed back once it is confirmed to be: sequence 0, signed by the TOML's + * SIGNING_KEY, built for our network passphrase, scoped to the requested + * home domain, and issued for the requesting account. Any failure throws + * and nothing is returned for the client to sign. + */ +export const fetchAndValidateChallenge = async ({ homeDomain, account }) => { + const anchorInfo = await getAnchorInfo(homeDomain); + + let response; + try { + response = await axios.get(anchorInfo.webAuthEndpoint, { + params: { account }, + timeout: ANCHOR_HTTP_TIMEOUT_MS, + }); + } catch (error) { + throw new APIError( + `Failed to reach '${homeDomain}' web auth endpoint: ${error.message}`, + 502 + ); + } + + const challengeXdr = response.data?.transaction; + if (!challengeXdr || typeof challengeXdr !== "string") { + throw new APIError( + `Anchor '${homeDomain}' did not return a challenge transaction`, + 502 + ); + } + + const webAuthDomain = new URL(anchorInfo.webAuthEndpoint).host; + + let details; + try { + details = StellarSdk.WebAuth.readChallengeTx( + challengeXdr, + anchorInfo.signingKey, + networkPassphrase, + homeDomain, + webAuthDomain + ); + } catch (error) { + throw new APIError(`Challenge validation failed: ${error.message}`, 502); + } + + if (details.clientAccountID !== account) { + throw new APIError( + `Challenge transaction was issued for a different account than requested`, + 502 + ); + } + + return { + challengeXdr, + networkPassphrase, + homeDomain, + webAuthEndpoint: anchorInfo.webAuthEndpoint, + }; +}; + +/** + * Submit a client-signed SEP-10 challenge to the anchor and return the JWT + * it issues, along with the JWT's own expiry claim. The JWT's signature is + * the anchor's, not ours - we don't hold a key to verify it and don't need + * to; we only decode it to read `exp` so the token can be cached with a + * matching TTL. + */ +export const submitChallengeResponse = async ({ homeDomain, signedXdr }) => { + const anchorInfo = await getAnchorInfo(homeDomain); + + let response; + try { + response = await axios.post( + anchorInfo.webAuthEndpoint, + { transaction: signedXdr }, + { timeout: ANCHOR_HTTP_TIMEOUT_MS } + ); + } catch (error) { + throw new APIError( + `Failed to submit signed challenge to '${homeDomain}': ${error.message}`, + 502 + ); + } + + const token = response.data?.token; + if (!token || typeof token !== "string") { + throw new APIError(`Anchor '${homeDomain}' did not return a JWT`, 502); + } + + const decoded = jwt.decode(token); + if (!decoded?.exp) { + throw new APIError( + `Anchor '${homeDomain}' returned a JWT with no expiry claim`, + 502 + ); + } + + return { token, exp: decoded.exp }; +}; + +/** + * Store an anchor JWT server-side, keyed by (userId, homeDomain), with its + * Redis TTL set from the token's own `exp` claim. If Redis is unavailable + * this silently no-ops (matching every other cache use in this codebase) - + * the session is simply not persisted, which surfaces to the caller as + * "no stored session" and triggers re-auth rather than a stale credential. + */ +export const storeAnchorJwt = async (userId, homeDomain, token, exp) => { + await setCacheExpireAt(anchorJwtCacheKey(userId, homeDomain), { token }, exp); +}; + +/** + * Fetch a previously stored anchor JWT. Returns null if none was stored, or + * if it has expired (Redis drops the key once its TTL from `exp` lapses) - + * either way the caller should treat this as "not authenticated" and prompt + * re-auth, not as an anchor-side error. + */ +export const getStoredAnchorJwt = async (userId, homeDomain) => { + const cached = await getCache(anchorJwtCacheKey(userId, homeDomain)); + return cached?.token || null; +}; + +/** + * Start a SEP-24 interactive deposit or withdrawal. Uses multipart/form-data + * as required by the SEP-24 spec for these endpoints. + */ +export const startInteractiveFlow = async ({ + transferServer, + jwtToken, + kind, + account, + assetCode = "USDC", +}) => { + const form = new FormData(); + form.append("asset_code", assetCode); + form.append("account", account); + + let response; + try { + response = await axios.post( + `${transferServer}/transactions/${kind}/interactive`, + form, + { + headers: { Authorization: `Bearer ${jwtToken}` }, + timeout: ANCHOR_HTTP_TIMEOUT_MS, + } + ); + } catch (error) { + throw new APIError( + `Failed to start ${kind} with the anchor: ${error.message}`, + 502 + ); + } + + const { url, id } = response.data || {}; + if (!url || !id) { + throw new APIError( + `Anchor did not return an interactive URL and transaction id`, + 502 + ); + } + + return { url, id }; +}; + +/** + * Fetch the current status of a single SEP-24 transaction from the anchor. + */ +export const fetchAnchorTransactionStatus = async ({ + transferServer, + jwtToken, + anchorTransactionId, +}) => { + let response; + try { + response = await axios.get(`${transferServer}/transaction`, { + params: { id: anchorTransactionId }, + headers: { Authorization: `Bearer ${jwtToken}` }, + timeout: ANCHOR_HTTP_TIMEOUT_MS, + }); + } catch (error) { + throw new APIError( + `Failed to fetch transaction status from the anchor: ${error.message}`, + 502 + ); + } + + const tx = response.data?.transaction; + if (!tx) { + throw new APIError(`Anchor did not return a transaction record`, 502); + } + + return tx; +}; + +/** + * Map a raw SEP-24 transaction record onto AnchorTransaction fields. Status + * is passed through verbatim - the frontend needs the full anchor-reported + * vocabulary, not a normalized subset. Only defined fields are included, so + * callers can safely Object.assign this onto an existing record without + * clobbering previously known values with undefined. + */ +export const mapAnchorTransactionFields = (tx) => { + const fields = { + status: tx.status, + amountIn: tx.amount_in, + amountOut: tx.amount_out, + amountFee: tx.amount_fee, + stellarTxHash: tx.stellar_transaction_id, + }; + return Object.fromEntries( + Object.entries(fields).filter(([, value]) => value !== undefined) + ); +}; diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index 3d25dcf..3ca06c5 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -347,6 +347,34 @@ export const getAccountBalance = async (publicKey) => { } }; +/** + * Build an unsigned changeTrust transaction so a wallet without a USDC + * trustline can add one before (or alongside) an anchor deposit. + */ +export const buildChangeTrustTransaction = async ({ publicKey, asset = USDC }) => { + try { + const account = await timedHorizonCall("loadAccount", () => + server.loadAccount(publicKey) + ); + + const transaction = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }) + .addOperation(StellarSdk.Operation.changeTrust({ asset })) + .setTimeout(300) + .build(); + + return { + xdr: transaction.toXDR(), + hash: transaction.hash().toString("hex"), + networkPassphrase, + }; + } catch (error) { + logger.error("Error building change trust transaction:", error); +// SEP-29: an account opts into requiring a memo on incoming payments by +// setting a manageData entry with key "config.memo_required" (value is +// conventionally "1", base64-encoded by Horizon like all data_attr values). export const MEMO_REQUIRED_DATA_KEY = "config.memo_required"; export const isMemoRequired = (account) => { diff --git a/test/anchorAuth.test.js b/test/anchorAuth.test.js new file mode 100644 index 0000000..d1e7dd8 --- /dev/null +++ b/test/anchorAuth.test.js @@ -0,0 +1,196 @@ +import { jest } from "@jest/globals"; +import crypto from "node:crypto"; +import axios from "axios"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { fetchAndValidateChallenge } from "../src/services/stellar/anchorService.js"; +import { networkPassphrase } from "../src/services/stellar/stellarService.js"; +import { USDC_ISSUER } from "../src/services/stellar/stellarService.js"; + +const HOME_DOMAIN = "testanchor.stellar.org"; +const TRANSFER_SERVER = `https://${HOME_DOMAIN}/sep24`; +const WEB_AUTH_ENDPOINT = `https://${HOME_DOMAIN}/auth`; +const WEB_AUTH_DOMAIN = HOME_DOMAIN; + +const serverKeypair = StellarSdk.Keypair.random(); +const impostorKeypair = StellarSdk.Keypair.random(); +const clientKeypair = StellarSdk.Keypair.random(); + +const mockToml = (overrides = {}) => ({ + TRANSFER_SERVER_SEP0024: TRANSFER_SERVER, + WEB_AUTH_ENDPOINT, + SIGNING_KEY: serverKeypair.publicKey(), + CURRENCIES: [{ code: "USDC", issuer: USDC_ISSUER }], + ...overrides, +}); + +const mockChallengeResponse = (xdr) => (url) => { + if (url === WEB_AUTH_ENDPOINT) return Promise.resolve({ data: { transaction: xdr } }); + if (url === `${TRANSFER_SERVER}/info`) return Promise.resolve({ data: {} }); + return Promise.reject(new Error(`Unexpected axios.get(${url})`)); +}; + +const setupMocks = ({ toml = mockToml(), challengeXdr } = {}) => { + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue(toml); + jest.spyOn(axios, "get").mockImplementation(mockChallengeResponse(challengeXdr)); +}; + +/** Builds a raw SEP-10 style challenge without relying on buildChallengeTx's defaults, so tests can violate one rule at a time. */ +const buildRawChallenge = ({ + sourceKeypair = serverKeypair, + signWith = sourceKeypair, + sourceSequence = "-1", + homeDomain = HOME_DOMAIN, + webAuthDomain = WEB_AUTH_DOMAIN, + passphrase = networkPassphrase, + clientAccountId = clientKeypair.publicKey(), + timeout = 300, +} = {}) => { + const account = new StellarSdk.Account(sourceKeypair.publicKey(), sourceSequence); + const now = Math.floor(Date.now() / 1000); + const randomValue = crypto.randomBytes(48).toString("base64"); + + const builder = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: passphrase, + timebounds: { minTime: now, maxTime: now + timeout }, + }) + .addOperation( + StellarSdk.Operation.manageData({ + name: `${homeDomain} auth`, + value: randomValue, + source: clientAccountId, + }) + ) + .addOperation( + StellarSdk.Operation.manageData({ + name: "web_auth_domain", + value: webAuthDomain, + source: sourceKeypair.publicKey(), + }) + ); + + const tx = builder.build(); + tx.sign(signWith); + return tx.toEnvelope().toXDR("base64").toString(); +}; + +describe("fetchAndValidateChallenge - rejection matrix", () => { + afterEach(() => { + jest.restoreAllMocks(); + delete process.env.ANCHOR_HOME_DOMAINS; + }); + + beforeEach(() => { + process.env.ANCHOR_HOME_DOMAINS = HOME_DOMAIN; + }); + + it("rejects a challenge with a non-zero sequence number", async () => { + const xdr = buildRawChallenge({ sourceSequence: "0" }); // builds to sequence 1 + setupMocks({ challengeXdr: xdr }); + + await expect( + fetchAndValidateChallenge({ homeDomain: HOME_DOMAIN, account: clientKeypair.publicKey() }) + ).rejects.toMatchObject({ + statusCode: 502, + message: expect.stringContaining("sequence number should be zero"), + }); + }); + + it("rejects a challenge not signed by the TOML's SIGNING_KEY", async () => { + // Source account matches the declared SIGNING_KEY (so the source-account + // check passes), but the envelope is actually signed by an impostor - + // isolates the signature check itself. + const xdr = buildRawChallenge({ sourceKeypair: serverKeypair, signWith: impostorKeypair }); + setupMocks({ challengeXdr: xdr }); + + await expect( + fetchAndValidateChallenge({ homeDomain: HOME_DOMAIN, account: clientKeypair.publicKey() }) + ).rejects.toMatchObject({ + statusCode: 502, + message: expect.stringContaining("not signed by server"), + }); + }); + + it("rejects a challenge built for the wrong network passphrase", async () => { + // Signed correctly by the server keypair, but for a different network - + // the signature is network-scoped so it fails verification against ours. + const xdr = buildRawChallenge({ passphrase: StellarSdk.Networks.PUBLIC }); + setupMocks({ challengeXdr: xdr }); + + await expect( + fetchAndValidateChallenge({ homeDomain: HOME_DOMAIN, account: clientKeypair.publicKey() }) + ).rejects.toMatchObject({ + statusCode: 502, + message: expect.stringContaining("not signed by server"), + }); + }); + + it("rejects a challenge whose manage_data operation names the wrong home domain", async () => { + const xdr = buildRawChallenge({ homeDomain: "attacker-domain.example.com" }); + setupMocks({ challengeXdr: xdr }); + + await expect( + fetchAndValidateChallenge({ homeDomain: HOME_DOMAIN, account: clientKeypair.publicKey() }) + ).rejects.toMatchObject({ + statusCode: 502, + message: expect.stringContaining("does not match the expected home domain"), + }); + }); + + it("rejects a challenge issued for a different account than requested", async () => { + const someoneElse = StellarSdk.Keypair.random(); + const xdr = buildRawChallenge({ clientAccountId: someoneElse.publicKey() }); + setupMocks({ challengeXdr: xdr }); + + await expect( + fetchAndValidateChallenge({ homeDomain: HOME_DOMAIN, account: clientKeypair.publicKey() }) + ).rejects.toMatchObject({ + statusCode: 502, + message: expect.stringContaining("different account"), + }); + }); + + it("rejects when the anchor returns no transaction field at all", async () => { + setupMocks({ challengeXdr: undefined }); + jest.spyOn(axios, "get").mockImplementation((url) => { + if (url === WEB_AUTH_ENDPOINT) return Promise.resolve({ data: {} }); + if (url === `${TRANSFER_SERVER}/info`) return Promise.resolve({ data: {} }); + return Promise.reject(new Error(`Unexpected axios.get(${url})`)); + }); + + await expect( + fetchAndValidateChallenge({ homeDomain: HOME_DOMAIN, account: clientKeypair.publicKey() }) + ).rejects.toMatchObject({ + statusCode: 502, + message: expect.stringContaining("did not return a challenge transaction"), + }); + }); + + it("never returns a challenge to the caller on any rejection path", async () => { + const xdr = buildRawChallenge({ homeDomain: "attacker-domain.example.com" }); + setupMocks({ challengeXdr: xdr }); + + let caught; + try { + await fetchAndValidateChallenge({ homeDomain: HOME_DOMAIN, account: clientKeypair.publicKey() }); + } catch (error) { + caught = error; + } + expect(caught).toBeDefined(); + expect(caught.challengeXdr).toBeUndefined(); + }); + + it("accepts a fully valid challenge and returns it for client-side signing", async () => { + const xdr = buildRawChallenge(); + setupMocks({ challengeXdr: xdr }); + + const result = await fetchAndValidateChallenge({ + homeDomain: HOME_DOMAIN, + account: clientKeypair.publicKey(), + }); + + expect(result.challengeXdr).toBe(xdr); + expect(result.networkPassphrase).toBe(networkPassphrase); + expect(result.homeDomain).toBe(HOME_DOMAIN); + }); +}); diff --git a/test/anchorDiscovery.test.js b/test/anchorDiscovery.test.js new file mode 100644 index 0000000..173f1aa --- /dev/null +++ b/test/anchorDiscovery.test.js @@ -0,0 +1,190 @@ +import { jest } from "@jest/globals"; +import axios from "axios"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { + isAllowedHomeDomain, + getAnchorInfo, +} from "../src/services/stellar/anchorService.js"; +import { USDC_ISSUER } from "../src/services/stellar/stellarService.js"; + +const ALLOWED_DOMAIN = "testanchor.stellar.org"; + +describe("mocking seam sanity", () => { + it("jest.spyOn works on StellarToml.Resolver.resolve", async () => { + const spy = jest + .spyOn(StellarSdk.StellarToml.Resolver, "resolve") + .mockResolvedValue({ TRANSFER_SERVER_SEP0024: "https://x" }); + const result = await StellarSdk.StellarToml.Resolver.resolve("x"); + expect(result.TRANSFER_SERVER_SEP0024).toBe("https://x"); + spy.mockRestore(); + }); + + it("jest.spyOn works on axios.get", async () => { + const spy = jest + .spyOn(axios, "get") + .mockResolvedValue({ data: { ok: true } }); + const result = await axios.get("https://x"); + expect(result.data.ok).toBe(true); + spy.mockRestore(); + }); +}); + +describe("isAllowedHomeDomain", () => { + const originalDomains = process.env.ANCHOR_HOME_DOMAINS; + + beforeEach(() => { + process.env.ANCHOR_HOME_DOMAINS = ALLOWED_DOMAIN; + }); + + afterEach(() => { + process.env.ANCHOR_HOME_DOMAINS = originalDomains; + jest.restoreAllMocks(); + }); + + it("allows a domain on the allowlist", () => { + expect(isAllowedHomeDomain(ALLOWED_DOMAIN)).toBe(true); + }); + + it("rejects a domain not on the allowlist", () => { + expect(isAllowedHomeDomain("evil-anchor.example.com")).toBe(false); + }); +}); + +describe("getAnchorInfo", () => { + const originalDomains = process.env.ANCHOR_HOME_DOMAINS; + + beforeEach(() => { + process.env.ANCHOR_HOME_DOMAINS = ALLOWED_DOMAIN; + }); + + afterEach(() => { + process.env.ANCHOR_HOME_DOMAINS = originalDomains; + jest.restoreAllMocks(); + }); + + it("rejects a non-allowlisted domain with 403 and never touches the network", async () => { + const tomlSpy = jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve"); + const axiosSpy = jest.spyOn(axios, "get"); + + await expect(getAnchorInfo("evil-anchor.example.com")).rejects.toMatchObject({ + statusCode: 403, + }); + + expect(tomlSpy).not.toHaveBeenCalled(); + expect(axiosSpy).not.toHaveBeenCalled(); + }); + + it("refuses an anchor whose USDC issuer does not match the platform issuer", async () => { + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: "https://testanchor.stellar.org/sep24", + WEB_AUTH_ENDPOINT: "https://testanchor.stellar.org/auth", + SIGNING_KEY: "GDMOCKSIGNINGKEY0000000000000000000000000000000000000", + CURRENCIES: [ + { + code: "USDC", + issuer: "GATTACKERISSUER00000000000000000000000000000000000000", + }, + ], + }); + const axiosSpy = jest.spyOn(axios, "get"); + + await expect(getAnchorInfo(ALLOWED_DOMAIN)).rejects.toMatchObject({ + statusCode: 502, + }); + expect(axiosSpy).not.toHaveBeenCalled(); + }); + + it("refuses an anchor whose toml does not list a USDC currency at all", async () => { + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: "https://testanchor.stellar.org/sep24", + WEB_AUTH_ENDPOINT: "https://testanchor.stellar.org/auth", + SIGNING_KEY: "GDMOCKSIGNINGKEY0000000000000000000000000000000000000", + CURRENCIES: [{ code: "EURC", issuer: "GSOMEOTHERISSUER" }], + }); + + await expect(getAnchorInfo(ALLOWED_DOMAIN)).rejects.toMatchObject({ + statusCode: 502, + }); + }); + + it("refuses an anchor missing required toml fields", async () => { + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + SIGNING_KEY: "GDMOCKSIGNINGKEY0000000000000000000000000000000000000", + }); + + await expect(getAnchorInfo(ALLOWED_DOMAIN)).rejects.toMatchObject({ + statusCode: 502, + }); + }); + + it("returns normalized deposit/withdraw info when the anchor's issuer matches", async () => { + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: "https://testanchor.stellar.org/sep24", + WEB_AUTH_ENDPOINT: "https://testanchor.stellar.org/auth", + SIGNING_KEY: "GDMOCKSIGNINGKEY0000000000000000000000000000000000000", + CURRENCIES: [{ code: "USDC", issuer: USDC_ISSUER }], + }); + jest.spyOn(axios, "get").mockResolvedValue({ + data: { + deposit: { USDC: { enabled: true, min_amount: 1, max_amount: 1000 } }, + withdraw: { USDC: { enabled: false } }, + }, + }); + + const info = await getAnchorInfo(ALLOWED_DOMAIN); + expect(info.currency.issuer).toBe(USDC_ISSUER); + expect(info.deposit).toEqual({ + enabled: true, + minAmount: 1, + maxAmount: 1000, + feeFixed: null, + feePercent: null, + }); + expect(info.withdraw.enabled).toBe(false); + }); + + it("passes a bounded timeout to the /info call so a hung anchor rejects instead of hanging the request", async () => { + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: "https://testanchor.stellar.org/sep24", + WEB_AUTH_ENDPOINT: "https://testanchor.stellar.org/auth", + SIGNING_KEY: "GDMOCKSIGNINGKEY0000000000000000000000000000000000000", + CURRENCIES: [{ code: "USDC", issuer: USDC_ISSUER }], + }); + const axiosSpy = jest.spyOn(axios, "get").mockResolvedValue({ data: {} }); + + await getAnchorInfo(ALLOWED_DOMAIN); + + expect(axiosSpy).toHaveBeenCalledWith( + "https://testanchor.stellar.org/sep24/info", + expect.objectContaining({ timeout: expect.any(Number) }) + ); + const [, options] = axiosSpy.mock.calls[0]; + expect(options.timeout).toBeGreaterThan(0); + }); + + it("surfaces a hung /info call as a 502 rather than hanging forever", async () => { + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: "https://testanchor.stellar.org/sep24", + WEB_AUTH_ENDPOINT: "https://testanchor.stellar.org/auth", + SIGNING_KEY: "GDMOCKSIGNINGKEY0000000000000000000000000000000000000", + CURRENCIES: [{ code: "USDC", issuer: USDC_ISSUER }], + }); + // Simulate the real axios timeout behavior: a request that never resolves + // on its own is rejected by axios once the configured timeout elapses. + jest.spyOn(axios, "get").mockImplementation((url, options) => { + expect(options.timeout).toBeGreaterThan(0); + return new Promise((_resolve, reject) => { + const timer = setTimeout(() => { + const err = new Error("timeout of " + options.timeout + "ms exceeded"); + err.code = "ECONNABORTED"; + reject(err); + }, 50); + timer.unref?.(); + }); + }); + + await expect(getAnchorInfo(ALLOWED_DOMAIN)).rejects.toMatchObject({ + statusCode: 502, + }); + }); +}); diff --git a/test/anchorInteractive.test.js b/test/anchorInteractive.test.js new file mode 100644 index 0000000..a5b2b25 --- /dev/null +++ b/test/anchorInteractive.test.js @@ -0,0 +1,255 @@ +import { jest } from "@jest/globals"; + +// Interactive-flow endpoints need a "stored anchor JWT" to exist, which +// normally lives in Redis. Redis isn't available in this environment/CI, so +// (as with anchorJwtStorage.test.js) we substitute a fake in-memory cache via +// jest.unstable_mockModule - jest.spyOn cannot mutate a local ESM module's +// live-bound named exports, so this is the only way to make storeAnchorJwt/ +// getStoredAnchorJwt actually round-trip in a test. +const fakeStore = new Map(); + +// This test is the only one in the suite that also dynamically imports +// app.js below, which pulls in the full route graph (bookRoutes, courseRoutes, +// searchRoutes, userRoutes, spaceRoutes) - several of those import CACHE_TTL/ +// CACHE_KEYS from this same file. jest.unstable_mockModule fully replaces the +// module in the registry for every subsequent importer, not just the one +// under test, so a mock factory that only returns the 3 functions this suite +// needs left every other consumer of this file without CACHE_TTL/CACHE_KEYS, +// throwing "does not provide an export named 'CACHE_TTL'" the moment app.js's +// route graph loaded. Spreading the real module's exports here keeps every +// other named export intact and only overrides the 3 this suite fakes. +const actualCache = await import("../src/utils/cache.js"); + +jest.unstable_mockModule("../src/utils/cache.js", () => ({ + ...actualCache, + setCacheExpireAt: jest.fn(async (key, value, timestamp) => { + fakeStore.set(key, { value, expiresAt: timestamp }); + return true; + }), + getCache: jest.fn(async (key) => { + const entry = fakeStore.get(key); + if (!entry) return null; + if (entry.expiresAt * 1000 <= Date.now()) { + fakeStore.delete(key); + return null; + } + return entry.value; + }), + getCacheOrSet: jest.fn(async (key, fallbackFn) => fallbackFn()), +})); + +const request = (await import("supertest")).default; +const mongoose = (await import("mongoose")).default; +const axios = (await import("axios")).default; +const StellarSdk = await import("@stellar/stellar-sdk"); +const { default: app } = await import("../app.js"); +const { default: User } = await import("../src/models/User.js"); +const { default: AnchorTransaction } = await import( + "../src/models/AnchorTransaction.js" +); +const { storeAnchorJwt } = await import( + "../src/services/stellar/anchorService.js" +); +const { server, USDC_ISSUER } = await import( + "../src/services/stellar/stellarService.js" +); + +const HOME_DOMAIN = "testanchor.stellar.org"; +const TRANSFER_SERVER = `https://${HOME_DOMAIN}/sep24`; +const WEB_AUTH_ENDPOINT = `https://${HOME_DOMAIN}/auth`; + +const testUser = { + name: "Interactive Flow Test User", + email: "anchor_interactive_test@example.com", + password: "password123", + role: "student", +}; + +beforeAll(async () => { + await mongoose.connect(process.env.MONGO_URI); +}); + +afterAll(async () => { + await User.deleteMany({ email: testUser.email }); + await AnchorTransaction.deleteMany({ homeDomain: HOME_DOMAIN }); + await mongoose.disconnect(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + fakeStore.clear(); +}); + +const makeAccount = (publicKey, { hasTrustline }) => { + const account = new StellarSdk.Account(publicKey, "1000"); + account.balances = [ + { asset_type: "native", balance: "100" }, + ...(hasTrustline + ? [ + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: USDC_ISSUER, + balance: "50", + }, + ] + : []), + ]; + return account; +}; + +const mockAnchorDiscovery = () => { + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: TRANSFER_SERVER, + WEB_AUTH_ENDPOINT, + SIGNING_KEY: StellarSdk.Keypair.random().publicKey(), + CURRENCIES: [{ code: "USDC", issuer: USDC_ISSUER }], + }); + jest.spyOn(axios, "get").mockResolvedValue({ data: {} }); +}; + +const setupUserWithSession = async () => { + process.env.ANCHOR_HOME_DOMAINS = HOME_DOMAIN; + await User.deleteMany({ email: testUser.email }); + const registerRes = await request(app).post("/api/auth/register").send(testUser); + const publicKey = StellarSdk.Keypair.random().publicKey(); + await User.findByIdAndUpdate(registerRes.body.user.id, { + stellarWallet: { publicKey, connectedAt: new Date(), network: "testnet" }, + }); + await storeAnchorJwt( + registerRes.body.user.id, + HOME_DOMAIN, + "fake-anchor-jwt", + Math.floor(Date.now() / 1000) + 3600 + ); + return { accessToken: registerRes.body.accessToken, userId: registerRes.body.user.id, publicKey }; +}; + +describe("Anchor interactive deposit/withdrawal flows", () => { + it("returns 401 with requiresReauth when no anchor session is stored", async () => { + process.env.ANCHOR_HOME_DOMAINS = HOME_DOMAIN; + await User.deleteMany({ email: testUser.email }); + const registerRes = await request(app).post("/api/auth/register").send(testUser); + await User.findByIdAndUpdate(registerRes.body.user.id, { + stellarWallet: { + publicKey: StellarSdk.Keypair.random().publicKey(), + connectedAt: new Date(), + network: "testnet", + }, + }); + + const res = await request(app) + .post("/api/stellar/anchor/deposits") + .set("Authorization", `Bearer ${registerRes.body.accessToken}`) + .send({ homeDomain: HOME_DOMAIN }); + + expect(res.statusCode).toBe(401); + expect(res.body.requiresReauth).toBe(true); + }); + + it("starts a deposit and includes an unsigned trustline XDR when the account has no USDC trustline", async () => { + const { accessToken, publicKey } = await setupUserWithSession(); + mockAnchorDiscovery(); + jest + .spyOn(server, "loadAccount") + .mockImplementation(async (key) => makeAccount(key, { hasTrustline: false })); + jest.spyOn(axios, "post").mockResolvedValue({ + data: { type: "interactive_customer_info_needed", url: "https://anchor/interactive/abc", id: "anchor-tx-1" }, + }); + + const res = await request(app) + .post("/api/stellar/anchor/deposits") + .set("Authorization", `Bearer ${accessToken}`) + .send({ homeDomain: HOME_DOMAIN }); + + expect(res.statusCode).toBe(200); + expect(res.body.data.deposit.url).toBe("https://anchor/interactive/abc"); + expect(res.body.data.deposit.id).toBe("anchor-tx-1"); + expect(typeof res.body.data.deposit.trustlineXdr).toBe("string"); + + const parsed = StellarSdk.TransactionBuilder.fromXDR( + res.body.data.deposit.trustlineXdr, + StellarSdk.Networks.TESTNET + ); + expect(parsed.operations).toHaveLength(1); + expect(parsed.operations[0].type).toBe("changeTrust"); + expect(parsed.operations[0].line.code).toBe("USDC"); + expect(parsed.operations[0].line.issuer).toBe(USDC_ISSUER); + + const persisted = await AnchorTransaction.findOne({ anchorTransactionId: "anchor-tx-1" }); + expect(persisted).not.toBeNull(); + expect(persisted.kind).toBe("deposit"); + expect(persisted.status).toBe("incomplete"); + }); + + it("starts a deposit without a trustline XDR when the account already has a USDC trustline", async () => { + const { accessToken } = await setupUserWithSession(); + mockAnchorDiscovery(); + jest + .spyOn(server, "loadAccount") + .mockImplementation(async (key) => makeAccount(key, { hasTrustline: true })); + jest.spyOn(axios, "post").mockResolvedValue({ + data: { type: "interactive_customer_info_needed", url: "https://anchor/interactive/def", id: "anchor-tx-2" }, + }); + + const res = await request(app) + .post("/api/stellar/anchor/deposits") + .set("Authorization", `Bearer ${accessToken}`) + .send({ homeDomain: HOME_DOMAIN }); + + expect(res.statusCode).toBe(200); + expect(res.body.data.deposit.trustlineXdr).toBeUndefined(); + }); + + it("still returns the deposit url/id when trustline building throws after the deposit is created at the anchor", async () => { + const { accessToken } = await setupUserWithSession(); + mockAnchorDiscovery(); + jest + .spyOn(server, "loadAccount") + .mockRejectedValue(new Error("horizon unreachable")); + jest.spyOn(axios, "post").mockResolvedValue({ + data: { + type: "interactive_customer_info_needed", + url: "https://anchor/interactive/jkl", + id: "anchor-tx-4", + }, + }); + + const res = await request(app) + .post("/api/stellar/anchor/deposits") + .set("Authorization", `Bearer ${accessToken}`) + .send({ homeDomain: HOME_DOMAIN }); + + expect(res.statusCode).toBe(200); + expect(res.body.data.deposit.url).toBe("https://anchor/interactive/jkl"); + expect(res.body.data.deposit.id).toBe("anchor-tx-4"); + expect(res.body.data.deposit.trustlineXdr).toBeUndefined(); + + const persisted = await AnchorTransaction.findOne({ anchorTransactionId: "anchor-tx-4" }); + expect(persisted).not.toBeNull(); + expect(persisted.status).toBe("incomplete"); + }); + + it("starts a withdrawal and never includes a trustline XDR", async () => { + const { accessToken } = await setupUserWithSession(); + mockAnchorDiscovery(); + jest + .spyOn(server, "loadAccount") + .mockImplementation(async (key) => makeAccount(key, { hasTrustline: false })); + jest.spyOn(axios, "post").mockResolvedValue({ + data: { type: "interactive_customer_info_needed", url: "https://anchor/interactive/ghi", id: "anchor-tx-3" }, + }); + + const res = await request(app) + .post("/api/stellar/anchor/withdrawals") + .set("Authorization", `Bearer ${accessToken}`) + .send({ homeDomain: HOME_DOMAIN }); + + expect(res.statusCode).toBe(200); + expect(res.body.data.withdrawal.url).toBe("https://anchor/interactive/ghi"); + expect(res.body.data.withdrawal.trustlineXdr).toBeUndefined(); + + const persisted = await AnchorTransaction.findOne({ anchorTransactionId: "anchor-tx-3" }); + expect(persisted.kind).toBe("withdrawal"); + }); +}); diff --git a/test/anchorJwtCustody.test.js b/test/anchorJwtCustody.test.js new file mode 100644 index 0000000..1382d0a --- /dev/null +++ b/test/anchorJwtCustody.test.js @@ -0,0 +1,130 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import jsonwebtoken from "jsonwebtoken"; +import crypto from "crypto"; +import axios from "axios"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import logger from "../src/config/logger.js"; +import { USDC_ISSUER } from "../src/services/stellar/stellarService.js"; + +const HOME_DOMAIN = "testanchor.stellar.org"; +const WEB_AUTH_ENDPOINT = `https://${HOME_DOMAIN}/auth`; +const TRANSFER_SERVER = `https://${HOME_DOMAIN}/sep24`; + +const testUser = { + name: "JWT Custody Test User", + email: "anchor_jwt_test@example.com", + password: "password123", + role: "student", +}; + +// The anchor's own JWT (we never verify its signature, only decode `exp`). +// Signed with an ephemeral, per-run secret rather than a literal in source - +// this test only needs a token with an `exp` claim, never the secret itself. +const ANCHOR_TEST_SIGNING_SECRET = crypto.randomBytes(32).toString("hex"); +const FAKE_ANCHOR_JWT = jsonwebtoken.sign( + { sub: "GABC", iss: HOME_DOMAIN }, + ANCHOR_TEST_SIGNING_SECRET, + { expiresIn: "1h" } +); + +beforeAll(async () => { + await mongoose.connect(process.env.MONGO_URI); +}); + +afterAll(async () => { + await User.deleteMany({ email: testUser.email }); + await mongoose.disconnect(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const registerAndConnectWallet = async () => { + await User.deleteMany({ email: testUser.email }); + const res = await request(app).post("/api/auth/register").send(testUser); + const publicKey = StellarSdk.Keypair.random().publicKey(); + await User.findByIdAndUpdate(res.body.user.id, { + stellarWallet: { publicKey, connectedAt: new Date(), network: "testnet" }, + }); + return { accessToken: res.body.accessToken, userId: res.body.user.id, publicKey }; +}; + +describe("Anchor JWT custody - redaction", () => { + it("never returns the anchor JWT in the /auth/verify response, and never logs it", async () => { + process.env.ANCHOR_HOME_DOMAINS = HOME_DOMAIN; + const { accessToken } = await registerAndConnectWallet(); + + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: TRANSFER_SERVER, + WEB_AUTH_ENDPOINT, + SIGNING_KEY: StellarSdk.Keypair.random().publicKey(), + CURRENCIES: [{ code: "USDC", issuer: USDC_ISSUER }], + }); + jest.spyOn(axios, "get").mockResolvedValue({ data: {} }); + jest.spyOn(axios, "post").mockResolvedValue({ data: { token: FAKE_ANCHOR_JWT } }); + + const loggedArgs = []; + for (const level of ["info", "warn", "error", "debug"]) { + jest.spyOn(logger, level).mockImplementation((...args) => { + loggedArgs.push(args); + }); + } + + const res = await request(app) + .post("/api/stellar/anchor/auth/verify") + .set("Authorization", `Bearer ${accessToken}`) + .send({ homeDomain: HOME_DOMAIN, signedXdr: "fake-signed-xdr" }); + + expect(res.statusCode).toBe(200); + + // The JWT must not appear anywhere in the response: body or headers. + const bodyText = JSON.stringify(res.body); + expect(bodyText).not.toContain(FAKE_ANCHOR_JWT); + expect(bodyText.toLowerCase()).not.toContain("token"); + const headerText = JSON.stringify(res.headers); + expect(headerText).not.toContain(FAKE_ANCHOR_JWT); + + // The JWT must not appear in any log line emitted during the request. + const logText = JSON.stringify(loggedArgs); + expect(logText).not.toContain(FAKE_ANCHOR_JWT); + }); + + it("returns 502 without leaking anything if the anchor omits the token field", async () => { + process.env.ANCHOR_HOME_DOMAINS = HOME_DOMAIN; + const { accessToken } = await registerAndConnectWallet(); + + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: TRANSFER_SERVER, + WEB_AUTH_ENDPOINT, + SIGNING_KEY: StellarSdk.Keypair.random().publicKey(), + CURRENCIES: [{ code: "USDC", issuer: USDC_ISSUER }], + }); + jest.spyOn(axios, "get").mockResolvedValue({ data: {} }); + jest.spyOn(axios, "post").mockResolvedValue({ data: {} }); + + const res = await request(app) + .post("/api/stellar/anchor/auth/verify") + .set("Authorization", `Bearer ${accessToken}`) + .send({ homeDomain: HOME_DOMAIN, signedXdr: "fake-signed-xdr" }); + + expect(res.statusCode).toBe(502); + expect(res.body.success).toBe(false); + }); + + it("requires both homeDomain and signedXdr", async () => { + process.env.ANCHOR_HOME_DOMAINS = HOME_DOMAIN; + const { accessToken } = await registerAndConnectWallet(); + + const res = await request(app) + .post("/api/stellar/anchor/auth/verify") + .set("Authorization", `Bearer ${accessToken}`) + .send({ homeDomain: HOME_DOMAIN }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/test/anchorJwtStorage.test.js b/test/anchorJwtStorage.test.js new file mode 100644 index 0000000..451ac1e --- /dev/null +++ b/test/anchorJwtStorage.test.js @@ -0,0 +1,81 @@ +import { jest } from "@jest/globals"; + +// storeAnchorJwt/getStoredAnchorJwt are thin wrappers around utils/cache.js. +// cache.js's own Redis-backed behavior is exercised by its existing callers +// elsewhere; here we verify OUR key construction and exp-based expiry +// contract against a fake in-memory cache, since jest.spyOn cannot mutate a +// local ESM module's live-bound named exports (only jest.unstable_mockModule +// can substitute the whole module). +const fakeStore = new Map(); + +jest.unstable_mockModule("../src/utils/cache.js", () => ({ + setCacheExpireAt: jest.fn(async (key, value, timestamp) => { + fakeStore.set(key, { value, expiresAt: timestamp }); + return true; + }), + getCache: jest.fn(async (key) => { + const entry = fakeStore.get(key); + if (!entry) return null; + if (entry.expiresAt * 1000 <= Date.now()) { + fakeStore.delete(key); + return null; + } + return entry.value; + }), + getCacheOrSet: jest.fn(async (key, fallbackFn) => fallbackFn()), +})); + +const { storeAnchorJwt, getStoredAnchorJwt } = await import( + "../src/services/stellar/anchorService.js" +); +const cache = await import("../src/utils/cache.js"); + +describe("anchor JWT storage", () => { + beforeEach(() => { + fakeStore.clear(); + jest.clearAllMocks(); + }); + + it("stores the JWT keyed by (userId, homeDomain) with the token's own exp as TTL", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + await storeAnchorJwt("user-1", "testanchor.stellar.org", "the-jwt-value", exp); + + expect(cache.setCacheExpireAt).toHaveBeenCalledWith( + "anchor:jwt:user-1:testanchor.stellar.org", + { token: "the-jwt-value" }, + exp + ); + }); + + it("round-trips a stored token back out", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + await storeAnchorJwt("user-1", "testanchor.stellar.org", "the-jwt-value", exp); + + const token = await getStoredAnchorJwt("user-1", "testanchor.stellar.org"); + expect(token).toBe("the-jwt-value"); + }); + + it("keys are isolated per (userId, homeDomain) pair", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + await storeAnchorJwt("user-1", "anchor-a.example.com", "token-a", exp); + await storeAnchorJwt("user-1", "anchor-b.example.com", "token-b", exp); + await storeAnchorJwt("user-2", "anchor-a.example.com", "token-c", exp); + + expect(await getStoredAnchorJwt("user-1", "anchor-a.example.com")).toBe("token-a"); + expect(await getStoredAnchorJwt("user-1", "anchor-b.example.com")).toBe("token-b"); + expect(await getStoredAnchorJwt("user-2", "anchor-a.example.com")).toBe("token-c"); + }); + + it("returns null (not an error) for a token that has never been stored", async () => { + const token = await getStoredAnchorJwt("stranger", "testanchor.stellar.org"); + expect(token).toBeNull(); + }); + + it("returns null once the stored token's exp has elapsed, exactly like never-stored - the caller can't distinguish 'expired' from 'no session' and treats both as 're-authenticate'", async () => { + const alreadyExpired = Math.floor(Date.now() / 1000) - 10; + await storeAnchorJwt("user-1", "testanchor.stellar.org", "stale-jwt", alreadyExpired); + + const token = await getStoredAnchorJwt("user-1", "testanchor.stellar.org"); + expect(token).toBeNull(); + }); +}); diff --git a/test/anchorPoller.test.js b/test/anchorPoller.test.js new file mode 100644 index 0000000..c203b54 --- /dev/null +++ b/test/anchorPoller.test.js @@ -0,0 +1,191 @@ +import { jest } from "@jest/globals"; + +// The poller resolves the anchor JWT via getStoredAnchorJwt, which is +// backed by Redis. Redis isn't available in this environment/CI, so - as in +// the other JWT-dependent test files - we substitute a fake in-memory cache +// via jest.unstable_mockModule (jest.spyOn cannot mutate a local ESM +// module's live-bound named exports). +const fakeStore = new Map(); + +jest.unstable_mockModule("../src/utils/cache.js", () => ({ + setCacheExpireAt: jest.fn(async (key, value, timestamp) => { + fakeStore.set(key, { value, expiresAt: timestamp }); + return true; + }), + getCache: jest.fn(async (key) => { + const entry = fakeStore.get(key); + if (!entry) return null; + if (entry.expiresAt * 1000 <= Date.now()) { + fakeStore.delete(key); + return null; + } + return entry.value; + }), + getCacheOrSet: jest.fn(async (key, fallbackFn) => fallbackFn()), +})); + +const mongoose = (await import("mongoose")).default; +const axios = (await import("axios")).default; +const StellarSdk = await import("@stellar/stellar-sdk"); +const { default: AnchorTransaction } = await import( + "../src/models/AnchorTransaction.js" +); +const { storeAnchorJwt } = await import("../src/services/stellar/anchorService.js"); +const { USDC_ISSUER } = await import("../src/services/stellar/stellarService.js"); +const { tick } = await import("../src/jobs/anchorPoller.js"); + +const HOME_DOMAIN = "testanchor.stellar.org"; +const TRANSFER_SERVER = `https://${HOME_DOMAIN}/sep24`; +const WEB_AUTH_ENDPOINT = `https://${HOME_DOMAIN}/auth`; + +beforeAll(async () => { + await mongoose.connect(process.env.MONGO_URI); +}); + +afterAll(async () => { + await AnchorTransaction.deleteMany({ homeDomain: HOME_DOMAIN }); + await mongoose.disconnect(); +}); + +afterEach(async () => { + jest.restoreAllMocks(); + fakeStore.clear(); + await AnchorTransaction.deleteMany({ homeDomain: HOME_DOMAIN }); +}); + +const mockAnchorDiscovery = () => { + process.env.ANCHOR_HOME_DOMAINS = HOME_DOMAIN; + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: TRANSFER_SERVER, + WEB_AUTH_ENDPOINT, + SIGNING_KEY: StellarSdk.Keypair.random().publicKey(), + CURRENCIES: [{ code: "USDC", issuer: USDC_ISSUER }], + }); +}; + +/** getAnchorInfo's /info fetch and the poller's /transaction fetch both go + * through axios.get, so the mock must dispatch on URL rather than using a + * single mockResolvedValueOnce (which would only satisfy whichever call + * happens first). + */ +const mockTransactionStatus = (transaction) => { + jest.spyOn(axios, "get").mockImplementation((url) => { + if (url === `${TRANSFER_SERVER}/info`) return Promise.resolve({ data: {} }); + if (url === `${TRANSFER_SERVER}/transaction`) return Promise.resolve({ data: { transaction } }); + return Promise.reject(new Error(`Unexpected axios.get(${url})`)); + }); +}; + +const mockTransactionStatusError = (error) => { + jest.spyOn(axios, "get").mockImplementation((url) => { + if (url === `${TRANSFER_SERVER}/info`) return Promise.resolve({ data: {} }); + if (url === `${TRANSFER_SERVER}/transaction`) return Promise.reject(error); + return Promise.reject(new Error(`Unexpected axios.get(${url})`)); + }); +}; + +const seedRecord = async (overrides = {}) => { + const userId = new mongoose.Types.ObjectId(); + await storeAnchorJwt( + userId.toString(), + HOME_DOMAIN, + "fake-anchor-jwt", + Math.floor(Date.now() / 1000) + 3600 + ); + const record = await AnchorTransaction.create({ + user: userId, + homeDomain: HOME_DOMAIN, + kind: "deposit", + anchorTransactionId: `poll-test-${Date.now()}-${Math.random()}`, + status: "incomplete", + nextPollAt: new Date(Date.now() - 1000), // already due + ...overrides, + }); + return record; +}; + +describe("Anchor transaction poller", () => { + it("progresses a record through the SEP-24 lifecycle to a terminal state, storing statuses verbatim", async () => { + mockAnchorDiscovery(); + const record = await seedRecord(); + + mockTransactionStatus({ status: "pending_anchor", amount_in: "100" }); + await tick(); + let updated = await AnchorTransaction.findById(record._id); + expect(updated.status).toBe("pending_anchor"); + expect(updated.amountIn).toBe("100"); + expect(updated.pollAttempts).toBe(0); + + // Push it due again to simulate the next tick without waiting real time. + await AnchorTransaction.updateOne({ _id: record._id }, { nextPollAt: new Date(Date.now() - 1000) }); + mockTransactionStatus({ + status: "completed", + amount_in: "100", + amount_out: "98", + amount_fee: "2", + stellar_transaction_id: "deadbeef", + }); + await tick(); + updated = await AnchorTransaction.findById(record._id); + expect(updated.status).toBe("completed"); + expect(updated.stellarTxHash).toBe("deadbeef"); + }); + + it("stops touching a record once it reaches a terminal status", async () => { + mockAnchorDiscovery(); + const record = await seedRecord({ status: "completed" }); + + const axiosSpy = jest.spyOn(axios, "get"); + await tick(); + + expect(axiosSpy).not.toHaveBeenCalled(); + const unchanged = await AnchorTransaction.findById(record._id); + expect(unchanged.status).toBe("completed"); + }); + + it("backs off and records the error when the anchor call fails, without crashing", async () => { + mockAnchorDiscovery(); + const record = await seedRecord(); + const before = record.nextPollAt.getTime(); + + mockTransactionStatusError(new Error("anchor unreachable")); + await tick(); + + const updated = await AnchorTransaction.findById(record._id); + expect(updated.status).toBe("incomplete"); // unchanged + expect(updated.pollAttempts).toBe(1); + expect(updated.lastError).toContain("anchor unreachable"); + expect(updated.nextPollAt.getTime()).toBeGreaterThan(before); + }); + + it("is a no-op when no record is due", async () => { + mockAnchorDiscovery(); + await seedRecord({ nextPollAt: new Date(Date.now() + 60000) }); // not due yet + + const axiosSpy = jest.spyOn(axios, "get"); + await tick(); + + expect(axiosSpy).not.toHaveBeenCalled(); + }); + + it("backs off without an anchor error when no JWT session is stored for the record's user", async () => { + mockAnchorDiscovery(); + const orphanUserId = new mongoose.Types.ObjectId(); + const record = await AnchorTransaction.create({ + user: orphanUserId, + homeDomain: HOME_DOMAIN, + kind: "deposit", + anchorTransactionId: `poll-orphan-${Date.now()}`, + status: "incomplete", + nextPollAt: new Date(Date.now() - 1000), + }); + + const axiosGetSpy = jest.spyOn(axios, "get"); + await tick(); + + expect(axiosGetSpy).not.toHaveBeenCalled(); + const updated = await AnchorTransaction.findById(record._id); + expect(updated.pollAttempts).toBe(1); + expect(updated.status).toBe("incomplete"); + }); +}); diff --git a/test/anchorRoutes.test.js b/test/anchorRoutes.test.js new file mode 100644 index 0000000..d6ff430 --- /dev/null +++ b/test/anchorRoutes.test.js @@ -0,0 +1,176 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import axios from "axios"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import { USDC_ISSUER } from "../src/services/stellar/stellarService.js"; + +const HOME_DOMAIN = "testanchor.stellar.org"; + +const testUser = { + name: "Anchor Route Test User", + email: "anchor_route_test@example.com", + password: "password123", + role: "student", +}; + +// app.js skips connectDB() under NODE_ENV=test; this suite manages its own connection. +beforeAll(async () => { + await mongoose.connect(process.env.MONGO_URI); +}); + +afterAll(async () => { + await User.deleteMany({ email: testUser.email }); + await mongoose.disconnect(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const registerAndGetToken = async () => { + await User.deleteMany({ email: testUser.email }); + const res = await request(app).post("/api/auth/register").send(testUser); + return { accessToken: res.body.accessToken, userId: res.body.user?.id }; +}; + +describe("Anchor routes (integration, mocked anchor HTTP)", () => { + it("returns 400 when the user has no connected Stellar wallet", async () => { + const { accessToken } = await registerAndGetToken(); + + const res = await request(app) + .post("/api/stellar/anchor/auth/challenge") + .set("Authorization", `Bearer ${accessToken}`) + .send({ homeDomain: HOME_DOMAIN }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("returns 403 for a non-allowlisted domain without contacting the network", async () => { + const { accessToken, userId } = await registerAndGetToken(); + await User.findByIdAndUpdate(userId, { + stellarWallet: { + publicKey: StellarSdk.Keypair.random().publicKey(), + connectedAt: new Date(), + network: "testnet", + }, + }); + + const tomlSpy = jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve"); + const axiosSpy = jest.spyOn(axios, "get"); + + const res = await request(app) + .post("/api/stellar/anchor/auth/challenge") + .set("Authorization", `Bearer ${accessToken}`) + .send({ homeDomain: "evil-anchor.example.com" }); + + expect(res.statusCode).toBe(403); + expect(tomlSpy).not.toHaveBeenCalled(); + expect(axiosSpy).not.toHaveBeenCalled(); + }); + + it("returns 503 when the anchor feature is not configured", async () => { + const original = process.env.ANCHOR_HOME_DOMAINS; + delete process.env.ANCHOR_HOME_DOMAINS; + + const { accessToken } = await registerAndGetToken(); + + const res = await request(app) + .get("/api/stellar/anchor/info") + .set("Authorization", `Bearer ${accessToken}`) + .query({ homeDomain: HOME_DOMAIN }); + + expect(res.statusCode).toBe(503); + expect(res.body.success).toBe(false); + + process.env.ANCHOR_HOME_DOMAINS = original; + }); + + it("returns normalized anchor info for GET /api/stellar/anchor/info when allowlisted and issuer matches", async () => { + const { accessToken } = await registerAndGetToken(); + process.env.ANCHOR_HOME_DOMAINS = HOME_DOMAIN; + + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: `https://${HOME_DOMAIN}/sep24`, + WEB_AUTH_ENDPOINT: `https://${HOME_DOMAIN}/auth`, + SIGNING_KEY: StellarSdk.Keypair.random().publicKey(), + CURRENCIES: [{ code: "USDC", issuer: USDC_ISSUER }], + }); + jest.spyOn(axios, "get").mockResolvedValue({ + data: { deposit: { USDC: { enabled: true } }, withdraw: { USDC: { enabled: true } } }, + }); + + const res = await request(app) + .get("/api/stellar/anchor/info") + .set("Authorization", `Bearer ${accessToken}`) + .query({ homeDomain: HOME_DOMAIN }); + + expect(res.statusCode).toBe(200); + expect(res.body.data.anchor.currency.issuer).toBe(USDC_ISSUER); + expect(res.body.data.anchor.deposit.enabled).toBe(true); + expect(typeof res.body.message).toBe("string"); + }); + + it("returns the { success, message, data } contract for every anchor route", async () => { + const { accessToken, userId } = await registerAndGetToken(); + const publicKey = StellarSdk.Keypair.random().publicKey(); + await User.findByIdAndUpdate(userId, { + stellarWallet: { publicKey, connectedAt: new Date(), network: "testnet" }, + }); + process.env.ANCHOR_HOME_DOMAINS = HOME_DOMAIN; + + jest.spyOn(StellarSdk.StellarToml.Resolver, "resolve").mockResolvedValue({ + TRANSFER_SERVER_SEP0024: `https://${HOME_DOMAIN}/sep24`, + WEB_AUTH_ENDPOINT: `https://${HOME_DOMAIN}/auth`, + SIGNING_KEY: StellarSdk.Keypair.random().publicKey(), + CURRENCIES: [{ code: "USDC", issuer: USDC_ISSUER }], + }); + jest.spyOn(axios, "get").mockResolvedValue({ + data: { deposit: { USDC: { enabled: true } }, withdraw: { USDC: { enabled: true } } }, + }); + + const infoRes = await request(app) + .get("/api/stellar/anchor/info") + .set("Authorization", `Bearer ${accessToken}`) + .query({ homeDomain: HOME_DOMAIN }); + expect(infoRes.body).toEqual( + expect.objectContaining({ + success: true, + message: expect.any(String), + data: expect.any(Object), + }) + ); + + // The challenge route's success path requires a real SEP-10 challenge + // (StellarSdk.WebAuth.readChallengeTx is a read-only ESM export and can't + // be jest.spyOn'd), so its contract is asserted here on the validation + // error path instead - same route, same response shape rules. + const challengeRes = await request(app) + .post("/api/stellar/anchor/auth/challenge") + .set("Authorization", `Bearer ${accessToken}`) + .send({}); + expect(challengeRes.body).toEqual( + expect.objectContaining({ + success: false, + message: expect.any(String), + }) + ); + + const transactionsRes = await request(app) + .get("/api/stellar/anchor/transactions") + .set("Authorization", `Bearer ${accessToken}`); + expect(transactionsRes.body).toEqual( + expect.objectContaining({ + success: true, + message: expect.any(String), + data: expect.objectContaining({ + transactions: expect.any(Array), + pagination: expect.any(Object), + }), + }) + ); + }); +}); diff --git a/test/anchorTracking.test.js b/test/anchorTracking.test.js new file mode 100644 index 0000000..f7624b8 --- /dev/null +++ b/test/anchorTracking.test.js @@ -0,0 +1,149 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import AnchorTransaction from "../src/models/AnchorTransaction.js"; + +const HOME_DOMAIN = "testanchor.stellar.org"; + +const userA = { + name: "Anchor Tracking User A", + email: "anchor_tracking_a@example.com", + password: "password123", + role: "student", +}; +const userB = { + name: "Anchor Tracking User B", + email: "anchor_tracking_b@example.com", + password: "password123", + role: "student", +}; + +beforeAll(async () => { + await mongoose.connect(process.env.MONGO_URI); +}); + +afterAll(async () => { + await User.deleteMany({ email: { $in: [userA.email, userB.email] } }); + await AnchorTransaction.deleteMany({ homeDomain: HOME_DOMAIN }); + await mongoose.disconnect(); +}); + +const registerAndGetToken = async (user) => { + await User.deleteMany({ email: user.email }); + const res = await request(app).post("/api/auth/register").send(user); + return { accessToken: res.body.accessToken, userId: res.body.user.id }; +}; + +describe("Anchor transaction ownership isolation", () => { + it("a user cannot read another user's anchor transaction by id", async () => { + const a = await registerAndGetToken(userA); + const b = await registerAndGetToken(userB); + + const ownedByA = await AnchorTransaction.create({ + user: a.userId, + homeDomain: HOME_DOMAIN, + kind: "deposit", + anchorTransactionId: `owned-by-a-${Date.now()}`, + status: "completed", + }); + + const resAsOwner = await request(app) + .get(`/api/stellar/anchor/transactions/${ownedByA._id}`) + .set("Authorization", `Bearer ${a.accessToken}`); + expect(resAsOwner.statusCode).toBe(200); + expect(String(resAsOwner.body.data.transaction._id)).toBe(String(ownedByA._id)); + + const resAsStranger = await request(app) + .get(`/api/stellar/anchor/transactions/${ownedByA._id}`) + .set("Authorization", `Bearer ${b.accessToken}`); + expect(resAsStranger.statusCode).toBe(404); + }); + + it("GET /transactions only returns the requesting user's own records", async () => { + const a = await registerAndGetToken(userA); + const b = await registerAndGetToken(userB); + + await AnchorTransaction.create([ + { + user: a.userId, + homeDomain: HOME_DOMAIN, + kind: "deposit", + anchorTransactionId: `a-list-1-${Date.now()}`, + status: "completed", + }, + { + user: b.userId, + homeDomain: HOME_DOMAIN, + kind: "withdrawal", + anchorTransactionId: `b-list-1-${Date.now()}`, + status: "completed", + }, + ]); + + const res = await request(app) + .get("/api/stellar/anchor/transactions") + .set("Authorization", `Bearer ${a.accessToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.data.transactions.length).toBeGreaterThan(0); + for (const tx of res.body.data.transactions) { + expect(String(tx.user)).toBe(String(a.userId)); + } + }); + + it("requires auth for both transaction endpoints", async () => { + const listRes = await request(app).get("/api/stellar/anchor/transactions"); + expect(listRes.statusCode).toBe(401); + + const detailRes = await request(app).get( + `/api/stellar/anchor/transactions/${new mongoose.Types.ObjectId()}` + ); + expect(detailRes.statusCode).toBe(401); + }); + + it("returns 404 (not a crash) for a well-formed id that doesn't exist", async () => { + const a = await registerAndGetToken(userA); + const res = await request(app) + .get(`/api/stellar/anchor/transactions/${new mongoose.Types.ObjectId()}`) + .set("Authorization", `Bearer ${a.accessToken}`); + expect(res.statusCode).toBe(404); + }); + + it("clamps an out-of-range limit instead of passing it straight to Mongo", async () => { + const a = await registerAndGetToken(userA); + + const res = await request(app) + .get("/api/stellar/anchor/transactions") + .query({ limit: "999999" }) + .set("Authorization", `Bearer ${a.accessToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.data.pagination.limit).toBe(100); + }); + + it("falls back to page 1 for a negative page instead of producing a negative skip", async () => { + const a = await registerAndGetToken(userA); + + const res = await request(app) + .get("/api/stellar/anchor/transactions") + .query({ page: "-5" }) + .set("Authorization", `Bearer ${a.accessToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.data.pagination.page).toBe(1); + }); + + it("falls back to defaults for non-numeric page/limit", async () => { + const a = await registerAndGetToken(userA); + + const res = await request(app) + .get("/api/stellar/anchor/transactions") + .query({ page: "not-a-number", limit: "not-a-number" }) + .set("Authorization", `Bearer ${a.accessToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.data.pagination.page).toBe(1); + expect(res.body.data.pagination.limit).toBe(20); + }); +}); diff --git a/test/app.test.js b/test/app.test.js index 9cbfdb7..3711cb6 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -221,6 +221,20 @@ describe("Stellar donations", () => { }); }); +describe("Stellar anchor", () => { + it("should require auth for GET /api/stellar/anchor/info", async () => { + const res = await request(app).get("/api/stellar/anchor/info"); + expect(res.statusCode).toBe(401); + }); + + it("should require auth for POST /api/stellar/anchor/auth/challenge", async () => { + const res = await request(app) + .post("/api/stellar/anchor/auth/challenge") + .send({ homeDomain: "testanchor.stellar.org" }); + expect(res.statusCode).toBe(401); + }); +}); + describe("Stellar service (unit, no network)", () => { const PLATFORM_WALLET = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";