diff --git a/.env.example b/.env.example index 7cf7c417..b1ea6cd4 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,11 @@ 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 STELLAR_NETWORK=testnet # Resilient Horizon Client Configuration (Optional) @@ -42,6 +47,17 @@ DONATION_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX PLATFORM_FEE_PERCENT=0 PLATFORM_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# SEP-1 stellar.toml (/.well-known/stellar.toml) — optional, omitted when blank +STELLAR_PLATFORM_PUBLIC_KEY= +ORG_NAME= +ORG_URL= +ORG_DESCRIPTION= +ORG_LOGO= +ORG_TWITTER= +ORG_GITHUB= +# SEP-10 signing key (leave blank until #25 is implemented) +SIGNING_KEY= + # Token TTLs ACCESS_TOKEN_TTL=15m REFRESH_TOKEN_TTL=30d @@ -63,3 +79,8 @@ REDIS_PORT=6379 # JITSI_PUBLIC_KEY_ID=your_public_key_id # JITSI_KID=your_kid # JITSI_TENANT=your_tenant +# Durable Mongo-backed background jobs (tests use inline) +JOBS_ENABLED=true +QUEUE_DRIVER=mongo +JOBS_DASHBOARD_TOKEN=replace_with_a_long_random_token + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60c9e5c0..09b7f9b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: MONGO_URI: mongodb://localhost:27017/deenbridge_test JWT_SECRET: test-secret-key-for-ci-minimum-32-chars STELLAR_NETWORK: testnet + CLOUDINARY_CLOUD_NAME: ci_test_cloud + CLOUDINARY_API_KEY: ci_test_key + CLOUDINARY_API_SECRET: ci_test_secret_that_should_not_leak build: name: Syntax and Boot Check @@ -88,3 +91,6 @@ jobs: MONGO_URI: mongodb://localhost:27017/deenbridge_boot JWT_SECRET: test-secret-key-for-ci-minimum-32-chars STELLAR_NETWORK: testnet + CLOUDINARY_CLOUD_NAME: ci_test_cloud + CLOUDINARY_API_KEY: ci_test_key + CLOUDINARY_API_SECRET: ci_test_secret_that_should_not_leak diff --git a/.gitignore b/.gitignore index 1fd22efb..0262ea20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,36 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Dependency directories -node_modules/ -jspm_packages/ - -# Environment variables -.env - -# Build output -dist/ -build/ - -# IDE files -.vscode/ -.DS_Store \ No newline at end of file +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Dependency directories +node_modules/ +jspm_packages/ + +# Environment variables +.env +.env.test + +# Build output +dist/ +build/ + +# IDE files +.vscode/ +.DS_Store + +# Spec & Agent temporary docs +Tasks.md +Memory.md +Handoff.md +PRD.md +Architecture.md +scratch/ \ No newline at end of file diff --git a/QUICK_START.md b/QUICK_START.md index 8c2e38fc..8f288613 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -62,6 +62,18 @@ curl -X POST http://localhost:5000/api/auth/login \ --- +## 🌐 **Stellar TOML (SEP-1)** + +The API serves ecosystem metadata at `/.well-known/stellar.toml`: + +```bash +curl http://localhost:5000/.well-known/stellar.toml +``` + +Optional env vars for the TOML: `STELLAR_PLATFORM_PUBLIC_KEY`, `ORG_NAME`, `ORG_URL`, `ORG_DESCRIPTION`, `ORG_LOGO`, `ORG_TWITTER`, `ORG_GITHUB`. (`SIGNING_KEY` is a placeholder that must remain unset until SEP-10 #25). See `.env.example`. + +--- + ## 🔍 **Monitor Logs** ```bash diff --git a/README.md b/README.md index 8092f998..c797b859 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,23 @@ The API runs at `http://localhost:5000`. | `JWT_SECRET` | Secret for signing tokens (32+ chars) | | `STELLAR_NETWORK` | `testnet` or `mainnet` | | `CLOUDINARY_*` | Cloudinary credentials for media uploads | +| `QUEUE_DRIVER` | `mongo` (durable production default) or `inline` (tests/CI) | +| `JOBS_ENABLED` | Start background workers; defaults to `true` | +| `JOBS_DASHBOARD_TOKEN` | Bearer token protecting `/admin/jobs` | +| `STELLAR_PLATFORM_PUBLIC_KEY` | Public key published in `stellar.toml` `ACCOUNTS[]` | See `.env.example` for the full list. +### Background jobs + +Slow email and Stellar verification work uses the thin `src/jobs/queue.js` +abstraction. The production `mongo` driver persists jobs in the mandatory +MongoDB deployment, so work and idempotency keys survive restarts without +making optional Redis a new requirement. The `inline` driver uses the same +handlers in tests and CI. Jobs retry with exponential backoff and jitter, +terminal failures are queryable at `/admin/jobs/dead`, and the token-protected +dashboard is available at `/admin/jobs`. + ### Scripts | Command | Purpose | @@ -94,6 +108,7 @@ See `.env.example` for the full list. | Area | Base Route | |------|-----------| +| SEP-1 Metadata | `/.well-known/stellar.toml` | | Auth & Users | `/api/auth`, `/api/users` | | Courses & Books | `/api/courses`, `/api/books` | | Spaces & Reels | `/api/spaces`, `/api/reels` | diff --git a/app.js b/app.js index 6dd44dcc..52651c5d 100644 --- a/app.js +++ b/app.js @@ -4,6 +4,7 @@ import cookieParser from "cookie-parser"; import compression from "compression"; import dotenv from "dotenv"; import crypto from "crypto"; +import "./src/jobs/handlers.js"; dotenv.config(); @@ -43,6 +44,9 @@ 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 payoutRoutes from "./src/routes/payoutRoutes.js"; +import uploadRoutes from "./src/routes/uploadRoutes.js"; +import jobsRoutes from "./src/routes/jobsRoutes.js"; +import wellKnownRoutes from "./src/routes/wellKnownRoutes.js"; handleUncaughtException(); validateEnv(); @@ -159,6 +163,9 @@ app.get("/health", (req, res) => { }); }); +// SEP-1 stellar.toml — must be outside /api rate limiter +app.use("/.well-known", wellKnownRoutes); + app.use("/api", apiLimiter); // Auth routes @@ -179,6 +186,8 @@ app.use("/api/stellar/wallet", stellarWalletRoutes); app.use("/api/stellar/payment", stellarPaymentRoutes); app.use("/api/stellar/donation", stellarDonationRoutes); app.use("/api/payouts", payoutRoutes); +app.use("/api/uploads", uploadRoutes); +app.use("/admin/jobs", jobsRoutes); // ====================== // ERROR HANDLING diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 00000000..e234ab94 --- /dev/null +++ b/docs/caching.md @@ -0,0 +1,75 @@ +# Redis Caching + +This documents which endpoints are cached, their TTLs, and the invalidation +strategy. The underlying helpers live in `src/utils/cache.js` (Redis +primitives, `CACHE_TTL`/`CACHE_KEYS` presets) and `src/middlewares/cache.js` +(`cacheMiddleware`, `smartCache`, `invalidateCacheMiddleware`). + +All caching degrades gracefully: `getCache`/`setCache`/`deleteCachePattern` +check `isRedisReady()` first and no-op (falling through to Mongo) when Redis +is unavailable, so a down Redis instance never breaks a request. + +## Cached read endpoints + +| Endpoint | Cache key | TTL | Notes | +|---|---|---|---| +| `GET /api/books` | `books:list` | 15 min (`CACHE_TTL.BOOKS`) | | +| `GET /api/books/:id` | `book:` | 15 min | | +| `GET /api/books/by-author/:authorId` | `books:author:` | 15 min | | +| `GET /api/books/recom` | `books:recommended` | 3 min (`CACHE_TTL.SHORT`) | shared key regardless of `interests`; the controller (`fetchRecommendedBooks`) also has a pre-existing bug (undefined `category` reference) that makes it non-functional today - out of scope here | +| `POST /api/books/recommended` | `books:recommended:` | 15 min (`CACHE_TTL.BOOKS`) | added in this change; keyed explicitly in the controller since `cacheMiddleware` only intercepts GET | +| `GET /api/courses` | `courses:list` | 15 min (`CACHE_TTL.COURSES`) | | +| `GET /api/courses/:id` | `course:` | 15 min | | +| `GET /api/courses/user` | `courses:user:` | 15 min | | +| `POST /api/courses/recommended` | `courses:recommended:` | 15 min (`CACHE_TTL.COURSES`) | added in this change; previously explicitly left uncached ("POST routes not cached") | +| `GET /api/spaces` | `spaces:list` | 5 min (`CACHE_TTL.SPACES`) | | +| `GET /api/spaces/:id` | `space:` | 5 min | | +| `GET /api/spaces/by-host/:hostId` | `spaces:host:` | 5 min | | +| `GET /api/search` | `search::` | 5 min (`CACHE_TTL.SEARCH`) | | +| `GET /api/users/:id` | `user:` | 10 min (`CACHE_TTL.USERS`) | | +| `GET /api/users/recommendations` | `user::recommendations` | 10 min | keyed per requesting user | +| `GET /api/users/:userId/followers` | `user::followers` | 10 min | | +| `GET /api/users/:userId/following` | `user::following` | 10 min | | + +The books/courses/spaces/search/users list and detail endpoints above were +wired up separately (see `feat: configure Redis cache layer for production +use`, merged to `main`). This change adds the two `POST /recommended` +entries, which that work left uncached, plus this document. + +## Invalidation + +Writes call `invalidateCacheMiddleware` with wildcard patterns so a create, +update, delete, or review clears the relevant cached list/detail entries +(`deleteCachePattern` runs a Redis `KEYS` scan against the pattern): + +- Books: `createBook` invalidates `books:*`; `deleteBook` and `addBookReview` invalidate `books:*` and `book:*`. +- Courses: `createCourse` invalidates `courses:*`; `enrollInCourse`/`addCourseReview` invalidate `course:*`; `updateCourse` invalidates `courses:*` and `course:*`. +- Spaces: `createSpace` invalidates `spaces:*`; `joinWaitList` invalidates `space:*`; `updateSpace`/`deleteSpace` invalidate `spaces:*` and `space:*`. +- Users: profile/follow writes invalidate `user:*` (and `user:*:followers` / `user:*:following` on follow/unfollow). + +`books:*` and `courses:*` also cover the new `books:recommended:*` / +`courses:recommended:*` keys added in this change, since they share the same +prefix - no separate invalidation wiring was needed for them. + +Search has no dedicated invalidation hook (it spans five collections); its +5-minute TTL is the staleness bound instead. + +## Deliberately not cached + +- **Bookmarks** (`GET /api/books/bookmarks`, `GET /api/courses/bookmarks`, + and the `bookmark/check` routes) are per-user and are left uncached rather + than risk leaking one user's bookmark state into another user's response + under a shared key. +- **Reels** (`GET /api/reels`, `GET /api/reels/:id`) embed the requesting + user's `viewerState` (liked/loved) directly in each reel object. Caching + the response as-is would leak one viewer's like/love state to every other + viewer who hits the same cache key, so these are left uncached until the + response shape separates viewer-specific state from the shared reel data. + +## User-specific data + +`cacheMiddleware`/`smartCache` support a per-request key generator (and +`smartCache`'s `includeUser` option) for endpoints that must vary per user - +see `GET /api/users/recommendations` above, which folds the requester's id +into the key. Any future authenticated, user-specific GET endpoint should do +the same rather than caching under a shared key. diff --git a/jest.config.js b/jest.config.js index 5114fe6c..6a73f8bf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ export default { testEnvironment: "node", transform: {}, + setupFiles: ["/test/jest.setup.js"], }; diff --git a/package-lock.json b/package-lock.json index 9485dc1e..64618b92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "express-mongo-sanitize": "^2.2.0", "express-rate-limit": "^8.1.0", "express-validator": "^7.2.1", + "file-type": "^22.0.1", "fs": "^0.0.1-security", "helmet": "^8.1.0", "hpp": "^0.2.3", @@ -43,6 +44,7 @@ "xss-clean": "^0.1.4" }, "devDependencies": { + "@iarna/toml": "^2.2.5", "concurrently": "^9.1.2", "jest": "^29.7.0", "mongodb-memory-server": "^11.2.0", @@ -616,6 +618,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -645,6 +657,13 @@ "node": ">=14.0.0" } }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "dev": true, + "license": "ISC" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1258,6 +1277,52 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/inflate/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@tokenizer/inflate/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3429,6 +3494,24 @@ "moment": "^2.29.1" } }, + "node_modules/file-type": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz", + "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -7558,6 +7641,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -7804,6 +7903,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/touch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", diff --git a/package.json b/package.json index 82ca0652..65e451f6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "express-mongo-sanitize": "^2.2.0", "express-rate-limit": "^8.1.0", "express-validator": "^7.2.1", + "file-type": "^22.0.1", "fs": "^0.0.1-security", "helmet": "^8.1.0", "hpp": "^0.2.3", @@ -49,6 +50,7 @@ "xss-clean": "^0.1.4" }, "devDependencies": { + "@iarna/toml": "^2.2.5", "concurrently": "^9.1.2", "jest": "^29.7.0", "mongodb-memory-server": "^11.2.0", diff --git a/server.js b/server.js index c60f0bbb..7a848019 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,8 @@ import app from "./app.js"; import logger from "./src/config/logger.js"; import { initRedis, closeRedis } from "./src/config/redis.js"; +import { startJobs, stopJobs } from "./src/jobs/queue.js"; +import "./src/jobs/handlers.js"; const PORT = process.env.PORT || 5000; @@ -18,6 +20,8 @@ const server = app.listen(PORT, () => { logger.info(`Process ID: ${process.pid}`); }); +startJobs().catch((err) => logger.error(err, "Background job startup failed")); + // Graceful shutdown const gracefulShutdown = async (signal) => { logger.info(`${signal} received. Starting graceful shutdown...`); @@ -25,6 +29,8 @@ const gracefulShutdown = async (signal) => { server.close(async () => { logger.info("HTTP server closed"); + await stopJobs(); + // Close Redis connection await closeRedis(); diff --git a/services/emails/sendMail.js b/services/emails/sendMail.js index 30b19df7..2558e5e3 100644 --- a/services/emails/sendMail.js +++ b/services/emails/sendMail.js @@ -1,26 +1,27 @@ import axios from "axios"; import logger from "../../src/config/logger.js"; -const EMAILJS_SERVICE_ID = process.env.EMAILJS_SERVICE_ID || "service_5cyu19r"; -const EMAILJS_TEMPLATE_ID = process.env.EMAILJS_TEMPLATE_ID || "template_ph4f0rl"; -const EMAILJS_PRIVATE_KEY = process.env.EMAILJS_PRIVATE_KEY || "xsR_t0uapauk8d_6Tk0Y9"; -const EMAILJS_PUBLIC_KEY = process.env.EMAILJS_PUBLIC_KEY || "p_hFoYPb6o0w7206-"; +const EMAILJS_SERVICE_ID = process.env.EMAILJS_SERVICE_ID; +const EMAILJS_TEMPLATE_ID = process.env.EMAILJS_TEMPLATE_ID; +const EMAILJS_RECEIPT_TEMPLATE_ID = process.env.EMAILJS_RECEIPT_TEMPLATE_ID || EMAILJS_TEMPLATE_ID; +const EMAILJS_PRIVATE_KEY = process.env.EMAILJS_PRIVATE_KEY; +const EMAILJS_PUBLIC_KEY = process.env.EMAILJS_PUBLIC_KEY; const EMAILJS_API_URL = process.env.EMAILJS_API_URL || "https://api.emailjs.com/api/v1.0/email/send"; -const sendMail = async (otp, email) => { - if (!email || !otp) { - throw new Error("Email and OTP are required to send the email."); +const sendTemplate = async (templateId, email, templateParams) => { + if ( + process.env.NODE_ENV !== "test" && + (!EMAILJS_SERVICE_ID || !templateId || !EMAILJS_PUBLIC_KEY) + ) { + throw new Error("EmailJS environment variables are not configured"); } - logger.info(`Sending OTP email to: ${email}`); try { const response = await axios.post(EMAILJS_API_URL, { service_id: EMAILJS_SERVICE_ID, - template_id: EMAILJS_TEMPLATE_ID, + template_id: templateId, user_id: EMAILJS_PUBLIC_KEY, - template_params: { - "otp": otp, - "email": email, - }, + accessToken: EMAILJS_PRIVATE_KEY, + template_params: { ...templateParams, email }, }); return { status: response.status, text: response.statusText }; } catch (error) { @@ -29,4 +30,18 @@ const sendMail = async (otp, email) => { } }; +export const sendOtpEmail = async (otp, email) => { + if (!email || !otp) throw new Error("Email and OTP are required to send the email."); + logger.info(`Sending OTP email to: ${email}`); + return sendTemplate(EMAILJS_TEMPLATE_ID, email, { otp }); +}; + +export const sendReceiptEmail = async (receipt) => { + if (!receipt.email || !receipt.txHash) throw new Error("Receipt email and transaction hash are required"); + logger.info(`Sending receipt for ${receipt.txHash} to: ${receipt.email}`); + return sendTemplate(EMAILJS_RECEIPT_TEMPLATE_ID, receipt.email, receipt); +}; + +const sendMail = sendOtpEmail; + export default sendMail; diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index 8537e28f..fd18be10 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -35,6 +35,18 @@ const optionalEnvVars = [ "HORIZON_MAX_RETRIES", "HORIZON_CB_THRESHOLD", "HORIZON_CB_COOLDOWN_MS", + "QUEUE_DRIVER", + "JOBS_ENABLED", + "JOBS_DASHBOARD_TOKEN", + "EMAILJS_RECEIPT_TEMPLATE_ID", + "STELLAR_PLATFORM_PUBLIC_KEY", + "ORG_NAME", + "ORG_URL", + "ORG_DESCRIPTION", + "ORG_LOGO", + "ORG_TWITTER", + "ORG_GITHUB", + "SIGNING_KEY", ]; export const validateEnv = () => { diff --git a/src/controllers/authController.js b/src/controllers/authController.js index 388a9997..beb3d865 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -7,6 +7,7 @@ import Session from "../models/Session.js"; import sendMail from "../../services/emails/sendMail.js"; import { generatedOtp } from "../routes/emailRoutes.js"; import logger from "../config/logger.js"; +import { enqueue } from "../jobs/queue.js"; import { catchAsync, APIError } from "../middlewares/errorHandler.js"; @@ -88,10 +89,11 @@ const createSessionAndTokens = async (user, req, res) => { ); // Set Cookie scoped to refresh path + const isProd = process.env.NODE_ENV === "production"; res.cookie("refreshToken", rawRefreshToken, { httpOnly: true, - secure: true, - sameSite: "None", + secure: isProd, + sameSite: isProd ? "None" : "Lax", path: "/api/auth/refresh", maxAge: refreshDurationMs, }); @@ -111,25 +113,27 @@ export const registerUser = catchAsync(async (req, res, next) => { return next(new APIError("Email already exists", 400)); } - // Send OTP email - try { - await sendMail(generatedOtp, email); - logger.info(`📧 OTP sent to: ${email}`); - } catch (error) { - logger.error(`Email sending failed for: ${email}`, error); - } - // Hash password const hashedPassword = await bcrypt.hash(password, 12); + // Prevent self-assignment of privileged roles (admin, arbiter) + const allowedSelfRegistrationRoles = ["student", "tutor", "mentor"]; + const assignedRole = allowedSelfRegistrationRoles.includes(role) ? role : "student"; + // Create user const user = await User.create({ name, email, password: hashedPassword, - role: role || "student", + role: assignedRole, }); + await enqueue( + "sendOtpEmail", + { userId: user._id.toString(), otp: generatedOtp }, + { attempts: 5, backoffMs: 1000, idempotencyKey: `otp:${user._id}:${generatedOtp}` } + ); + logger.info(`✅ User registered successfully: ${email} (ID: ${user._id})`); // Generate session and tokens @@ -347,10 +351,11 @@ export const refreshSession = catchAsync(async (req, res, next) => { { expiresIn: accessTokenTtl } ); + const isProd = process.env.NODE_ENV === "production"; res.cookie("refreshToken", newRawToken, { httpOnly: true, - secure: true, - sameSite: "None", + secure: isProd, + sameSite: isProd ? "None" : "Lax", path: "/api/auth/refresh", maxAge: refreshDurationMs, }); @@ -454,10 +459,11 @@ export const logoutUser = catchAsync(async (req, res, next) => { ); } + const isProd = process.env.NODE_ENV === "production"; res.clearCookie("refreshToken", { httpOnly: true, - secure: true, - sameSite: "None", + secure: isProd, + sameSite: isProd ? "None" : "Lax", path: "/api/auth/refresh", }); diff --git a/src/controllers/books/bookController.js b/src/controllers/books/bookController.js index 08b44edc..38c27727 100644 --- a/src/controllers/books/bookController.js +++ b/src/controllers/books/bookController.js @@ -3,6 +3,8 @@ import Book from "../../models/Book.js"; import User from "../../models/User.js"; import cloudinary from "../../utils/cloudinary.js"; import logger from "../../config/logger.js"; +import { validateMagicBytes } from "../../utils/fileValidation.js"; +import { createNewBookNotification } from "../notificationController.js"; //cretae a book export const createBook = async (req, res) => { @@ -23,6 +25,13 @@ export const createBook = async (req, res) => { }); } + const isThumbnailValid = await validateMagicBytes(req.files.thumbnail[0].buffer, ["image/jpeg", "image/png", "image/webp"]); + const isFileValid = await validateMagicBytes(req.files.file[0].buffer, ["application/pdf", "application/epub+zip"]); + + if (!isThumbnailValid || !isFileValid) { + return res.status(400).json({ success: false, message: "Invalid file content detected. Magic bytes do not match expected types.", data: null }); + } + // Upload thumbnail to Cloudinary const thumbnailUpload = await new Promise((resolve, reject) => { const stream = cloudinary.uploader.upload_stream( @@ -38,7 +47,7 @@ export const createBook = async (req, res) => { // Upload book file to Cloudinary (as raw file) const fileUpload = await new Promise((resolve, reject) => { const stream = cloudinary.uploader.upload_stream( - { folder: "library-books/files", resource_type: "raw" }, + { folder: "library-books/files", resource_type: "raw", type: "authenticated" }, (error, result) => { if (error) reject(error); else resolve(result); @@ -62,10 +71,18 @@ export const createBook = async (req, res) => { rating, image: thumbnailUpload.secure_url, fileUrl: fileUpload.secure_url, + filePublicId: fileUpload.public_id, }); - res.status(201).json({ success: true, book }); - } catch (err) { + + // Emit new book notification asynchronously to followers + createNewBookNotification(book._id, req.user._id, book.title).catch((err) => + logger.error("Error creating book notification:", err) + ); + + res.status(201).json({ success: true, message: "Book created successfully", data: book }); + + } catch (err) { logger.error("Book creation error:", err); res.status(500).json({ success: false, error: err.message }); } @@ -109,8 +126,24 @@ export const getBooksByAuthor = async (req, res) => { // delete book by id export const deleteBook = async (req, res) => { - await Book.findByIdAndDelete(req.params.id); - res.json({ message: "Book deleted" }); + try { + const book = await Book.findById(req.params.id); + if (!book) { + return res.status(404).json({ success: false, message: "Book not found" }); + } + + if (req.user.role !== "admin" && book.author.toString() !== req.user._id.toString()) { + return res.status(403).json({ + success: false, + message: "Not authorized to delete this book", + }); + } + + await Book.findByIdAndDelete(req.params.id); + res.json({ success: true, message: "Book deleted" }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } }; // review books @@ -198,6 +231,13 @@ export const streamBookPreview = async (req, res) => { .json({ success: false, message: "You do not have access to this book." }); } + if (book.filePublicId) { + const signedUrl = cloudinary.utils.private_download_url(book.filePublicId, "raw", { + expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour + }); + return res.redirect(302, signedUrl); + } + const fileResponse = await axios.get(book.fileUrl, { responseType: "stream", }); diff --git a/src/controllers/books/recommendedBooksController.js b/src/controllers/books/recommendedBooksController.js index cfb1588c..e1181326 100644 --- a/src/controllers/books/recommendedBooksController.js +++ b/src/controllers/books/recommendedBooksController.js @@ -1,5 +1,6 @@ import logger from "../../config/logger.js"; import Book from "../../models/Book.js"; +import { getCacheOrSet, CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js"; /** * Get recommended books based on user interests @@ -8,34 +9,44 @@ import Book from "../../models/Book.js"; export const getRecommendedBooks = async (req, res) => { try { const { interests } = req.body; + const hasInterests = Array.isArray(interests) && interests.length > 0; - if (!interests || !Array.isArray(interests) || interests.length === 0) { - // If no interests provided, return recent/popular books - const books = await Book.find() - .populate("author", "name email avatar") - .sort({ readCount: -1 }) // Sort by popularity - .limit(10); + // This endpoint is POST (interests come in the body), so the shared + // cacheMiddleware (GET-only) can't key off req.query - cache explicitly here instead. + const cacheKey = hasInterests + ? `${CACHE_KEYS.BOOKS}recommended:${[...interests].sort().join(",")}` + : `${CACHE_KEYS.BOOKS}recommended:popular`; - return res.status(200).json({ - success: true, - books, - message: "Popular books", - }); - } + const payload = await getCacheOrSet( + cacheKey, + async () => { + if (!hasInterests) { + // If no interests provided, return recent/popular books + const books = await Book.find() + .populate("author", "name email avatar") + .sort({ readCount: -1 }) // Sort by popularity + .limit(10); - // Find books that match user interests - const recommended = await Book.find({ - category: { $in: interests }, - }) - .populate("author", "name email avatar") - .sort({ readCount: -1 }) - .limit(10); + return { books, message: "Popular books" }; + } - res.status(200).json({ - success: true, - recommended, - message: "Books recommended based on your interests", - }); + // Find books that match user interests + const recommended = await Book.find({ + category: { $in: interests }, + }) + .populate("author", "name email avatar") + .sort({ readCount: -1 }) + .limit(10); + + return { + recommended, + message: "Books recommended based on your interests", + }; + }, + CACHE_TTL.BOOKS + ); + + res.status(200).json({ success: true, ...payload }); } catch (error) { logger.error("Error fetching recommended books:", error); res.status(500).json({ diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js index d783da66..3399eb43 100644 --- a/src/controllers/courses/courseController.js +++ b/src/controllers/courses/courseController.js @@ -2,6 +2,8 @@ import Course from "../../models/Course.js"; import mongoose from "mongoose"; import logger from "../../config/logger.js"; import { catchAsync, APIError } from "../../middlewares/errorHandler.js"; +import { getCacheOrSet, CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js"; +import { createNewCourseNotification } from "../notificationController.js"; /** * Create a new course @@ -33,6 +35,11 @@ export const createCourse = catchAsync(async (req, res, next) => { logger.info(`✅ Course created successfully: ${course._id} - ${title}`); + // Emit new course notification asynchronously to followers + createNewCourseNotification(course._id, req.user._id, course.title).catch((err) => + logger.error("Error creating course notification:", err) + ); + res.status(201).json({ success: true, message: "Course created successfully", @@ -167,8 +174,8 @@ export const updateCourse = catchAsync(async (req, res, next) => { return next(new APIError("Course not found", 404)); } - // Check if user is the creator (authorization) - if (course.createdBy.toString() !== req.user._id.toString()) { + // Check if user is the creator or admin (authorization) + if (req.user.role !== "admin" && course.createdBy.toString() !== req.user._id.toString()) { logger.warn(`Unauthorized course update attempt by user: ${req.user._id}`); return next( new APIError("You are not authorized to update this course", 403) @@ -240,7 +247,20 @@ export const addCourseReview = async (req, res) => { export const fetchRecommendedCourses = async (req, res) => { try { const { interests } = req.body; - const recommended = await Course.find({ category: { $in: interests } }); + const hasInterests = Array.isArray(interests) && interests.length > 0; + + // This endpoint is POST (interests come in the body), so the shared + // cacheMiddleware (GET-only) can't key off req.query - cache explicitly here instead. + const cacheKey = hasInterests + ? `${CACHE_KEYS.COURSES}recommended:${[...interests].sort().join(",")}` + : `${CACHE_KEYS.COURSES}recommended:none`; + + const recommended = await getCacheOrSet( + cacheKey, + () => Course.find({ category: { $in: interests } }), + CACHE_TTL.COURSES + ); + res.status(200).json({ success: true, recommended }); } catch (e) { res.status(500).json({ success: false, message: e.message }); diff --git a/src/controllers/notificationController.js b/src/controllers/notificationController.js index 326f87dd..811bfac2 100644 --- a/src/controllers/notificationController.js +++ b/src/controllers/notificationController.js @@ -1,42 +1,151 @@ import Notification from "../models/Notification.js"; import logger from "../config/logger.js"; import User from "../models/User.js"; +import { getRedisClient, isRedisReady } from "../config/redis.js"; -// Store active SSE connections +/** + * SSE Connections Map + * Maps userId (string) -> Set of active Express Response streams. + * Allows multiple concurrent browser tabs/sessions per user and proper auth-scoped cleanup on disconnect. + */ const sseConnections = new Map(); +/** + * Multi-Instance SSE Delivery via Redis Pub/Sub: + * When horizontally scaled (e.g. on Render), an instance producing a notification + * publishes the event to the Redis channel 'notifications:sse'. All backend instances + * listen on this channel and deliver the payload to any locally connected client SSE stream. + * If Redis is unavailable, the system falls back seamlessly to in-memory local delivery. + */ +let isSubscriberInit = false; +export const initRedisSubscriber = async () => { + if (isSubscriberInit) return; + if (!isRedisReady()) return; + + try { + const mainClient = getRedisClient(); + if (!mainClient) return; + + const subClient = mainClient.duplicate(); + await subClient.connect(); + + await subClient.subscribe("notifications:sse", (message) => { + try { + const { recipientId, notification } = JSON.parse(message); + deliverLocalSSENotification(recipientId, notification); + } catch (err) { + logger.error("Error handling Redis SSE pub/sub message:", err); + } + }); + + isSubscriberInit = true; + logger.info("✅ Redis SSE Pub/Sub subscriber initialized"); + } catch (err) { + logger.error("Failed to initialize Redis SSE subscriber:", err); + } +}; + +/** + * Deliver SSE payload to active local connections for a given user + */ +export const deliverLocalSSENotification = (recipientId, notification) => { + const userConns = sseConnections.get(recipientId.toString()); + if (userConns && userConns.size > 0) { + const payload = `data: ${JSON.stringify({ + type: "new_notification", + notification, + })}\n\n`; + + userConns.forEach((res) => { + try { + res.write(payload); + } catch (err) { + logger.error(`Error writing SSE payload to user ${recipientId}:`, err); + } + }); + } +}; + +/** + * Dispatch SSE notification across multi-instance deployment via Redis or local Map + */ +export const dispatchSSENotification = async (recipientId, notification) => { + if (isRedisReady()) { + try { + await initRedisSubscriber(); + const client = getRedisClient(); + if (client) { + await client.publish( + "notifications:sse", + JSON.stringify({ recipientId: recipientId.toString(), notification }) + ); + return; + } + } catch (err) { + logger.error("Error publishing SSE notification to Redis:", err); + } + } + + // Fallback to local delivery if Redis is not active or fails + deliverLocalSSENotification(recipientId, notification); +}; + // SSE endpoint for real-time notifications export const sseNotifications = async (req, res) => { const userId = req.user._id.toString(); - + // Set SSE headers res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Cache-Control' + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Cache-Control", }); + // Send initial connection message - res.write(`data: ${JSON.stringify({ type: 'connection', message: 'Connected to notifications' })}\n\n`); + res.write( + `data: ${JSON.stringify({ + type: "connection", + message: "Connected to notifications", + })}\n\n` + ); + + // Store connection in user's connection set + if (!sseConnections.has(userId)) { + sseConnections.set(userId, new Set()); + } + const userConns = sseConnections.get(userId); + userConns.add(res); - // Store connection - sseConnections.set(userId, res); + // Initialize Redis subscriber if available + initRedisSubscriber().catch((err) => + logger.error("Redis subscriber init error:", err) + ); // Handle client disconnect - req.on('close', () => { - sseConnections.delete(userId); + req.on("close", () => { + const conns = sseConnections.get(userId); + if (conns) { + conns.delete(res); + if (conns.size === 0) { + sseConnections.delete(userId); + } + } logger.info(`SSE connection closed for user: ${userId}`); }); // Keep connection alive const keepAlive = setInterval(() => { - if (sseConnections.has(userId)) { - res.write(`data: ${JSON.stringify({ type: 'ping', timestamp: Date.now() })}\n\n`); + const conns = sseConnections.get(userId); + if (conns && conns.has(res)) { + res.write( + `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n` + ); } else { clearInterval(keepAlive); } - }, 30000); // Send ping every 30 seconds + }, 30000); }; // Send notification to specific user (for real-time updates) @@ -44,138 +153,177 @@ export const sendNotificationToUser = async (userId, notificationData) => { try { const notification = await Notification.create({ recipient: userId, - ...notificationData + ...notificationData, }); // Populate sender info - await notification.populate('sender', 'name avatar'); - - // Send real-time notification via SSE - const connection = sseConnections.get(userId.toString()); - if (connection) { - connection.write(`data: ${JSON.stringify({ - type: 'new_notification', - notification: notification - })}\n\n`); - } + await notification.populate("sender", "name avatar"); + + // Dispatch via SSE + await dispatchSSENotification(userId, notification); return notification; } catch (error) { - logger.error('Error sending notification:', error); + logger.error("Error sending notification:", error); throw error; } }; // Create follow notification export const createFollowNotification = async (followerId, followedId) => { - const follower = await User.findById(followerId).select('name avatar'); - - await sendNotificationToUser(followedId, { + const follower = await User.findById(followerId).select("name avatar"); + if (!follower) return; + + return sendNotificationToUser(followedId, { sender: followerId, - type: 'follow', - title: 'New Follower', + type: "follow", + title: "New Follower", message: `${follower.name} started following you`, - priority: 'medium' + priority: "medium", }); }; // Create unfollow notification export const createUnfollowNotification = async (unfollowerId, unfollowedId) => { - const unfollower = await User.findById(unfollowerId).select('name avatar'); - - await sendNotificationToUser(unfollowedId, { + const unfollower = await User.findById(unfollowerId).select("name avatar"); + if (!unfollower) return; + + return sendNotificationToUser(unfollowedId, { sender: unfollowerId, - type: 'unfollow', - title: 'User Unfollowed', + type: "unfollow", + title: "User Unfollowed", message: `${unfollower.name} unfollowed you`, - priority: 'low' + priority: "low", }); }; -// Create new course notification for followers +// Create new course notification for followers (batched fan-out) export const createNewCourseNotification = async (courseId, creatorId, courseTitle) => { - const creator = await User.findById(creatorId); - const followers = creator.followers; + try { + const creator = await User.findById(creatorId).select("name avatar followers"); + if (!creator || !creator.followers || creator.followers.length === 0) return []; - for (const followerId of followers) { - await sendNotificationToUser(followerId, { + const docs = creator.followers.map((followerId) => ({ + recipient: followerId, sender: creatorId, - type: 'new_course', - title: 'New Course Available', + type: "new_course", + title: "New Course Available", message: `${creator.name} created a new course: ${courseTitle}`, data: { courseId }, - priority: 'medium' - }); + priority: "medium", + })); + + // Batched DB insert in one write operation + const createdNotifications = await Notification.insertMany(docs); + + // Broadcast SSE notifications with pre-populated sender info + const senderPayload = { _id: creatorId, name: creator.name, avatar: creator.avatar }; + for (const notification of createdNotifications) { + const plainNotif = notification.toObject ? notification.toObject() : { ...notification }; + plainNotif.sender = senderPayload; + dispatchSSENotification(plainNotif.recipient, plainNotif); + } + + return createdNotifications; + } catch (error) { + logger.error("Error creating new course notifications:", error); + throw error; } }; -// Create new book notification for followers +// Create new book notification for followers (batched fan-out) export const createNewBookNotification = async (bookId, authorId, bookTitle) => { - const author = await User.findById(authorId); - const followers = author.followers; + try { + const author = await User.findById(authorId).select("name avatar followers"); + if (!author || !author.followers || author.followers.length === 0) return []; - for (const followerId of followers) { - await sendNotificationToUser(followerId, { + const docs = author.followers.map((followerId) => ({ + recipient: followerId, sender: authorId, - type: 'new_book', - title: 'New Book Available', + type: "new_book", + title: "New Book Available", message: `${author.name} published a new book: ${bookTitle}`, data: { bookId }, - priority: 'medium' - }); + priority: "medium", + })); + + // Batched DB insert in one write operation + const createdNotifications = await Notification.insertMany(docs); + + // Broadcast SSE notifications with pre-populated author info + const senderPayload = { _id: authorId, name: author.name, avatar: author.avatar }; + for (const notification of createdNotifications) { + const plainNotif = notification.toObject ? notification.toObject() : { ...notification }; + plainNotif.sender = senderPayload; + dispatchSSENotification(plainNotif.recipient, plainNotif); + } + + return createdNotifications; + } catch (error) { + logger.error("Error creating new book notifications:", error); + throw error; } }; -// Get user notifications +// Get user notifications (bounded pagination) export const getUserNotifications = async (req, res) => { try { const userId = req.user._id; - const { page = 1, limit = 20, unreadOnly = false } = req.query; + + let page = parseInt(req.query.page, 10); + if (isNaN(page) || page < 1) page = 1; + + let limit = parseInt(req.query.limit, 10); + if (isNaN(limit) || limit < 1) limit = 20; + if (limit > 100) limit = 100; // Enforce pagination cap of 100 + + const unreadOnly = req.query.unreadOnly === "true"; const query = { recipient: userId, - isDeleted: false + isDeleted: false, }; - if (unreadOnly === 'true') { + if (unreadOnly) { query.isRead = false; } + const skip = (page - 1) * limit; + const notifications = await Notification.find(query) - .populate('sender', 'name avatar') - .populate('data.courseId', 'title thumbnail') - .populate('data.bookId', 'title image') + .populate("sender", "name avatar") + .populate("data.courseId", "title thumbnail") + .populate("data.bookId", "title image") .sort({ createdAt: -1 }) - .limit(limit * 1) - .skip((page - 1) * limit) + .skip(skip) + .limit(limit) .lean(); const total = await Notification.countDocuments(query); const unreadCount = await Notification.countDocuments({ recipient: userId, isRead: false, - isDeleted: false + isDeleted: false, }); res.status(200).json({ success: true, notifications, pagination: { - currentPage: parseInt(page), - totalPages: Math.ceil(total / limit), + currentPage: page, + totalPages: Math.ceil(total / limit) || 1, totalNotifications: total, hasNextPage: page * limit < total, - hasPrevPage: page > 1 + hasPrevPage: page > 1, }, - unreadCount + unreadCount, }); - } catch (error) { - logger.error('Get notifications error:', error); + logger.error("Get notifications error:", error); res.status(500).json({ success: false, - message: 'Failed to fetch notifications', - error: error.message + message: "Failed to fetch notifications", + error: error.message, }); } }; @@ -195,21 +343,20 @@ export const markNotificationAsRead = async (req, res) => { if (!notification) { return res.status(404).json({ success: false, - message: 'Notification not found' + message: "Notification not found", }); } res.status(200).json({ success: true, - notification + notification, }); - } catch (error) { - logger.error('Mark notification read error:', error); + logger.error("Mark notification read error:", error); res.status(500).json({ success: false, - message: 'Failed to mark notification as read', - error: error.message + message: "Failed to mark notification as read", + error: error.message, }); } }; @@ -226,15 +373,14 @@ export const markAllNotificationsAsRead = async (req, res) => { res.status(200).json({ success: true, - message: 'All notifications marked as read' + message: "All notifications marked as read", }); - } catch (error) { - logger.error('Mark all notifications read error:', error); + logger.error("Mark all notifications read error:", error); res.status(500).json({ success: false, - message: 'Failed to mark notifications as read', - error: error.message + message: "Failed to mark notifications as read", + error: error.message, }); } }; @@ -254,21 +400,20 @@ export const deleteNotification = async (req, res) => { if (!notification) { return res.status(404).json({ success: false, - message: 'Notification not found' + message: "Notification not found", }); } res.status(200).json({ success: true, - message: 'Notification deleted' + message: "Notification deleted", }); - } catch (error) { - logger.error('Delete notification error:', error); + logger.error("Delete notification error:", error); res.status(500).json({ success: false, - message: 'Failed to delete notification', - error: error.message + message: "Failed to delete notification", + error: error.message, }); } }; @@ -277,25 +422,24 @@ export const deleteNotification = async (req, res) => { export const getNotificationSettings = async (req, res) => { try { const userId = req.user._id; - const user = await User.findById(userId).select('notificationSettings'); + const user = await User.findById(userId).select("notificationSettings"); res.status(200).json({ success: true, - settings: user.notificationSettings || { + settings: user?.notificationSettings || { follow: true, newContent: true, likes: true, comments: true, - system: true - } + system: true, + }, }); - } catch (error) { - logger.error('Get notification settings error:', error); + logger.error("Get notification settings error:", error); res.status(500).json({ success: false, - message: 'Failed to fetch notification settings', - error: error.message + message: "Failed to fetch notification settings", + error: error.message, }); } -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/controllers/searchController.js b/src/controllers/searchController.js index 92923d57..72d61130 100644 --- a/src/controllers/searchController.js +++ b/src/controllers/searchController.js @@ -1,96 +1,38 @@ -import Course from "../models/Course.js"; -import Book from "../models/Book.js"; -import User from "../models/User.js"; -import Space from "../models/Space.js"; -import Reel from "../models/Reel.js"; -import mongoose from "mongoose"; +import { searchCollections, searchEducators } from "../services/search/searchService.js"; import logger from "../config/logger.js"; export const searchAll = async (req, res) => { try { - const { q } = req.query; - if (!q || q.trim() === "") { - return res.status(400).json({ error: "Query string is required." }); + const { q, type = "all", page = 1, limit = 10, sort, minPrice, maxPrice, free, category, minRating, interest } = req.query; + + if (q && q.trim().length > 100) { + return res.status(400).json({ success: false, error: "Query string is too long." }); } - const queryRegex = new RegExp(q.trim(), "i"); - logger.info("Search query:", q); - - // Search Courses - const courses = await Course.find({ title: queryRegex }) - .select("_id title description price thumbnail") - .lean(); - logger.info("Courses found:", courses); - // Search Books - const books = await Book.find({ title: queryRegex }) - .select("_id title description price image author") - .lean(); - logger.info("Books found:", books); - // Search Users - const users = await User.find({ name: queryRegex }) - .select("_id name email avatar role ") - .lean(); - logger.info("Users found:", users); - // Search Spaces - const spaces = await Space.find({ title: queryRegex }) - .select( - "_id title description price status eventDate duration host" - ) - .lean(); - logger.info("Spaces found:", spaces); - // Search Reels (by description) - const reels = await Reel.find({ description: queryRegex }) - .select("_id description createdBy") - .lean(); - logger.info("Reels found:", reels); - - const results = [ - ...courses.map((c) => ({ - type: "course", - id: c._id, - title: c.title, - description: c.description, - price: c.price, - thumbnail: c.thumbnail, - })), - ...books.map((b) => ({ - type: "book", - id: b._id, - title: b.title, - description: b.description, - category: b.category, - price: b.price, - image: b.image, - author: b.author, - })), - ...users.map((u) => ({ - type: "user", - id: u._id, - name: u.name, - avatar: u.avatar, - role: u.role, - })), - ...spaces.map((s) => ({ - type: "space", - id: s._id, - title: s.title, - description: s.description, - price: s.price, - status: s.status, - eventDate: s.eventDate, - duration: s.duration, - host: s.host, - })), - ...reels.map((r) => ({ - type: "reel", - id: r._id, - description: r.description, - })), - ]; - - res.json(results); + const filters = { minPrice, maxPrice, free, category, minRating, interest }; + + const result = await searchCollections({ q: q ? q.trim() : "", type, page, limit, sort, filters }); + + res.json({ success: true, ...result }); } catch (err) { logger.error("Search error:", err); - res.status(500).json({ error: "Server error", details: err.message }); + res.status(500).json({ success: false, error: "Server error" }); + } +}; + +export const searchEducatorsHandler = async (req, res) => { + try { + const { q, interest, page = 1, limit = 10 } = req.query; + + if (q && q.trim().length > 100) { + return res.status(400).json({ success: false, error: "Query string is too long." }); + } + + const result = await searchEducators({ q: q ? q.trim() : "", interest, page, limit }); + + res.json({ success: true, ...result }); + } catch (err) { + logger.error("Search educators error:", err); + res.status(500).json({ success: false, error: "Server error" }); } }; diff --git a/src/controllers/spaceController.js b/src/controllers/spaceController.js index 23a826d3..147cb6fd 100644 --- a/src/controllers/spaceController.js +++ b/src/controllers/spaceController.js @@ -90,14 +90,22 @@ export const updateSpace = async (req, res) => { if (req.body[key] !== undefined) updates[key] = req.body[key]; } + const existingSpace = await Space.findById(id); + if (!existingSpace) { + return res.status(404).json({ success: false, message: "Space not found" }); + } + + if (req.user.role !== "admin" && existingSpace.host.toString() !== req.user._id.toString()) { + return res.status(403).json({ + success: false, + message: "Not authorized to update this space", + }); + } + const space = await Space.findByIdAndUpdate(id, updates, { new: true, }).populate("host", "name email avatar"); - if (!space) - return res - .status(404) - .json({ success: false, message: "Space not found" }); res.status(200).json({ success: true, space }); } catch (error) { res.status(500).json({ success: false, message: error.message }); @@ -150,11 +158,19 @@ export const getSpacesByHost = async (req, res) => { export const deleteSpace = async (req, res) => { try { const { id } = req.params; - const space = await Space.findByIdAndDelete(id); - if (!space) - return res - .status(404) - .json({ success: false, message: "Space not found" }); + const space = await Space.findById(id); + if (!space) { + return res.status(404).json({ success: false, message: "Space not found" }); + } + + if (req.user.role !== "admin" && space.host.toString() !== req.user._id.toString()) { + return res.status(403).json({ + success: false, + message: "Not authorized to delete this space", + }); + } + + await Space.findByIdAndDelete(id); res.status(200).json({ success: true, message: "Space deleted" }); } catch (error) { res.status(500).json({ success: false, message: error.message }); diff --git a/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js index b16056bf..50b05be2 100644 --- a/src/controllers/stellar/donationController.js +++ b/src/controllers/stellar/donationController.js @@ -13,6 +13,7 @@ import { DONATION_WALLET_PUBLIC_KEY, } from "../../services/stellar/stellarService.js"; import logger from "../../config/logger.js"; +import { enqueue } from "../../jobs/queue.js"; import { paymentsInitialized, paymentsSubmitted, @@ -193,9 +194,32 @@ export const submitDonation = async (req, res) => { ]); if (!verification.verified) { + donation.stellarTxHash = result.hash; + if (verification.transient) { + donation.status = "retrying"; + donation.failureReason = verification.reason; + await donation.save({ session }); + await enqueue( + "verifyPaymentOnChain", + { transactionId: donation._id.toString() }, + { + attempts: 5, + backoffMs: 1000, + idempotencyKey: `verify:${result.hash}`, + session, + } + ); + await session.commitTransaction(); + return res.status(202).json({ + success: true, + message: "Donation submitted; confirmation is in progress", + donationId: donation._id, + txHash: result.hash, + status: "retrying", + }); + } donation.status = "failed"; donation.failureReason = `On-chain verification failed: ${verification.reason}`; - donation.stellarTxHash = result.hash; await donation.save({ session }); await session.commitTransaction(); paymentsFailed.inc({ type: "donation", reason: "verification_failed" }); @@ -217,6 +241,16 @@ export const submitDonation = async (req, res) => { donation.status = "confirmed"; donation.confirmedAt = new Date(); await donation.save({ session }); + await enqueue( + "generateReceipt", + { transactionId: donation._id.toString() }, + { + attempts: 5, + backoffMs: 1000, + idempotencyKey: `receipt:${result.hash}`, + session, + } + ); await session.commitTransaction(); paymentsConfirmed.inc({ type: "donation" }); diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index 97c61e63..d06a589b 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -6,15 +6,23 @@ import Course from "../../models/Course.js"; import Transaction from "../../models/Transaction.js"; import { buildPaymentTransaction, + buildPathPaymentTransaction, buildSep7Uri, + calculateFeeSplit, + preflightPayment, submitTransaction, verifyTransaction, verifyPaymentOperations, + findPaymentPaths, + applySlippage, NETWORK, getExplorerUrl, + USDC, PLATFORM_WALLET_PUBLIC_KEY, } from "../../services/stellar/stellarService.js"; +import * as StellarSdk from "@stellar/stellar-sdk"; import { recordSaleEarnings } from "../../services/payoutService.js"; +import { enqueue } from "../../jobs/queue.js"; import logger from "../../config/logger.js"; import { paymentsInitialized, @@ -23,6 +31,247 @@ import { paymentsFailed, } from "../../config/metrics.js"; +/** + * Resolve the item, its creator, and the settlement destination wallet for a + * purchase. Shared by initializePayment and the pre-flight endpoint so both + * look up the same destination the same way. + */ +const resolvePaymentDestination = async ({ itemType, itemId, session }) => { + const Model = itemType === "book" ? Book : Course; + const populateField = itemType === "book" ? "author" : "createdBy"; + + const query = Model.findById(itemId).populate(populateField, "stellarWallet name"); + const item = session ? await query.session(session) : await query; + + if (!item) { + return { error: { status: 404, message: `${itemType} not found` } }; + } + + const creator = itemType === "book" ? item.author : item.createdBy; + const platformCollectEnabled = process.env.PLATFORM_COLLECT_ENABLED === "true"; + let destinationPublicKey; + let settlementMode = "direct"; + + if (!creator?.stellarWallet?.publicKey) { + if (!platformCollectEnabled) { + return { + error: { + status: 400, + message: "Creator has not connected their Stellar wallet yet", + }, + }; + } + + const platformWalletKey = + process.env.PLATFORM_WALLET_PUBLIC_KEY || PLATFORM_WALLET_PUBLIC_KEY; + if (!platformWalletKey) { + return { + error: { + status: 500, + message: "Platform wallet is not configured for platform-collect mode", + }, + }; + } + + destinationPublicKey = platformWalletKey; + settlementMode = "platform_collect"; + } else { + destinationPublicKey = creator.stellarWallet.publicKey; + } + + return { item, creator, destinationPublicKey, settlementMode }; +}; + +/** + * Platform memo convention: purchases are tagged DNB--, always as a text memo. This is always non-empty, so + * it already satisfies SEP-29 "some memo present" destinations; it does not + * substitute for a destination-specific memo (e.g. an exchange deposit id). + */ +const buildPurchaseMemo = (itemType, itemId) => + `DNB-${itemType.toUpperCase()}-${itemId.toString().slice(-8)}`; + +/** + * Get a quote for paying with a non-USDC asset via path payment + * POST /api/stellar/payment/quote + */ +export const getQuote = async (req, res) => { + try { + const { itemType, itemId, sendAssetCode, sendAssetIssuer } = req.body; + + if (!["book", "course"].includes(itemType)) { + return res.status(400).json({ + success: false, + message: "Invalid item type. Must be 'book' or 'course'", + }); + } + + const Model = itemType === "book" ? Book : Course; + const item = await Model.findById(itemId); + + if (!item) { + return res.status(404).json({ + success: false, + message: `${itemType} not found`, + }); + } + + if (!item.price || item.price === 0) { + return res.status(400).json({ + success: false, + message: "This item is free, no quote needed", + }); + } + + if (sendAssetCode && !sendAssetIssuer && sendAssetCode !== "XLM" && sendAssetCode !== "native") { + return res.status(400).json({ + success: false, + message: "Non-native assets require an issuer. Omit sendAssetIssuer only for native XLM.", + }); + } + + const sendAsset = sendAssetIssuer + ? new StellarSdk.Asset(sendAssetCode, sendAssetIssuer) + : StellarSdk.Asset.native(); + + const destAmount = item.price.toString(); + const paths = await findPaymentPaths(sendAsset, destAmount); + + if (!paths || paths.length === 0) { + return res.status(404).json({ + success: false, + message: "No payment path found for the given asset", + }); + } + + const bestPath = paths[0]; + const slippageBps = Math.min( + 500, + Math.max(10, Number(req.body.slippageBps) || 100) + ); + const sendMax = applySlippage(bestPath.source_amount, slippageBps); + + const sourceAsset = { + asset_type: bestPath.source_asset_type, + ...(bestPath.source_asset_type !== "native" && { + asset_code: bestPath.source_asset_code, + asset_issuer: bestPath.source_asset_issuer, + }), + }; + + const pathAssets = (bestPath.path || []).map((a) => ({ + asset_type: a.asset_type, + ...(a.asset_type !== "native" && { + asset_code: a.asset_code, + asset_issuer: a.asset_issuer, + }), + })); + + const expiresAt = new Date(Date.now() + 30 * 1000).toISOString(); + + res.status(200).json({ + success: true, + quote: { + source_asset: sourceAsset, + source_amount: bestPath.source_amount, + destination_asset: { asset_type: "credit_alphanum4", asset_code: "USDC", asset_issuer: USDC.getIssuer() }, + destination_amount: destAmount, + path: pathAssets, + sendMax, + slippageBps, + expiresAt, + note: "Quote is an estimate. The on-chain bound enforced is sendMax, not the quoted source_amount.", + }, + }); + } catch (error) { + logger.error("Quote error:", error); + if ( + error.message?.includes("Invalid asset") || + error.message?.includes("bad asset") + ) { + return res.status(400).json({ + success: false, + message: "Unknown or invalid asset", + }); + } + res.status(500).json({ + success: false, + message: "Failed to get quote", + error: process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + +/** + * Run pre-flight payment safety checks (destination existence, USDC + * trustline, source balance/reserve, SEP-29 memo-required) before the + * frontend prompts the wallet to sign anything. + * POST /api/stellar/payment/preflight + */ +export const getPaymentPreflight = async (req, res) => { + try { + const buyerId = req.user._id; + const { itemType, itemId } = req.body; + + if (!["book", "course"].includes(itemType)) { + return res.status(400).json({ + success: false, + message: "Invalid item type. Must be 'book' or 'course'", + }); + } + + const buyer = await User.findById(buyerId); + if (!buyer?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + const resolved = await resolvePaymentDestination({ itemType, itemId }); + if (resolved.error) { + return res.status(resolved.error.status).json({ + success: false, + message: resolved.error.message, + }); + } + + const { item, destinationPublicKey, settlementMode } = resolved; + + if (!item.price || item.price === 0) { + return res.status(400).json({ + success: false, + message: "This item is free, no payment required", + }); + } + + const memo = buildPurchaseMemo(itemType, itemId); + const feeSplitPreview = + settlementMode === "direct" ? calculateFeeSplit(item.price) : null; + + const preflight = await preflightPayment({ + sourcePublicKey: buyer.stellarWallet.publicKey, + destinationPublicKey, + amount: item.price.toString(), + memo, + operationCount: feeSplitPreview ? 2 : 1, + }); + + res.status(200).json({ + success: true, + preflight, + }); + } catch (error) { + logger.error("Payment preflight error:", error); + res.status(500).json({ + success: false, + message: "Failed to run payment pre-flight checks", + error: + process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + /** * Initialize a payment - creates pending transaction and returns XDR to sign * POST /api/stellar/payment/initialize @@ -33,7 +282,7 @@ export const initializePayment = async (req, res) => { try { const buyerId = req.user._id; - const { itemType, itemId, buyerWallet } = req.body; + const { itemType, itemId, buyerWallet, sendAsset: sendAssetInput, sendMax, path: pathInput } = req.body; // Validate item type if (!["book", "course"].includes(itemType)) { @@ -63,53 +312,17 @@ export const initializePayment = async (req, res) => { }); } - // Get item details - const Model = itemType === "book" ? Book : Course; - const populateField = itemType === "book" ? "author" : "createdBy"; - - const item = await Model.findById(itemId) - .populate(populateField, "stellarWallet name") - .session(session); - - if (!item) { + // Get item details and resolve the settlement destination + const resolved = await resolvePaymentDestination({ itemType, itemId, session }); + if (resolved.error) { await session.abortTransaction(); - return res.status(404).json({ + return res.status(resolved.error.status).json({ success: false, - message: `${itemType} not found`, + message: resolved.error.message, }); } - const creator = itemType === "book" ? item.author : item.createdBy; - const platformCollectEnabled = - process.env.PLATFORM_COLLECT_ENABLED === "true"; - let destinationPublicKey; - let settlementMode = "direct"; - - // Check creator has wallet or platform-collect mode is enabled - if (!creator?.stellarWallet?.publicKey) { - if (!platformCollectEnabled) { - await session.abortTransaction(); - return res.status(400).json({ - success: false, - message: "Creator has not connected their Stellar wallet yet", - }); - } - - const platformWalletKey = - process.env.PLATFORM_WALLET_PUBLIC_KEY || PLATFORM_WALLET_PUBLIC_KEY; - if (!platformWalletKey) { - await session.abortTransaction(); - return res.status(500).json({ - success: false, - message: "Platform wallet is not configured for platform-collect mode", - }); - } - - destinationPublicKey = platformWalletKey; - settlementMode = "platform_collect"; - } else { - destinationPublicKey = creator.stellarWallet.publicKey; - } + const { item, creator, destinationPublicKey, settlementMode } = resolved; // Check if item is free if (!item.price || item.price === 0) { @@ -154,23 +367,71 @@ export const initializePayment = async (req, res) => { } // Generate unique memo for this transaction - const memo = `DNB-${itemType.toUpperCase()}-${itemId.toString().slice(-8)}`; + const memo = buildPurchaseMemo(itemType, itemId); // Build the payment transaction (single op full amount for platform collect, split for direct if fee configured) - const paymentTx = await buildPaymentTransaction({ - sourcePublicKey: buyer.stellarWallet.publicKey, - destinationPublicKey, - amount: item.price.toString(), - memo, - applyPlatformFee: settlementMode === "direct", - }); + const isPathPayment = sendAssetInput && sendMax; + let paymentTx; + let sep7Uri = null; + + if (isPathPayment) { + const sendAsset = sendAssetInput.issuer + ? new StellarSdk.Asset(sendAssetInput.code, sendAssetInput.issuer) + : StellarSdk.Asset.native(); + + const path = (pathInput || []).map((a) => ({ + asset_type: a.asset_type, + ...(a.asset_type !== "native" && { + asset_code: a.asset_code, + asset_issuer: a.asset_issuer, + }), + })); + + paymentTx = await buildPathPaymentTransaction({ + sourcePublicKey: buyer.stellarWallet.publicKey, + destinationPublicKey, + destAmount: item.price.toString(), + sendAsset, + sendMax, + path, + memo, + applyPlatformFee: settlementMode === "direct", + }); + } else { + const feeSplitPreview = + settlementMode === "direct" ? calculateFeeSplit(item.price) : null; + + const preflight = await preflightPayment({ + sourcePublicKey: buyer.stellarWallet.publicKey, + destinationPublicKey, + amount: item.price.toString(), + memo, + operationCount: feeSplitPreview ? 2 : 1, + }); - // SEP-7 URI so wallets can deep-link the payment - const sep7Uri = buildSep7Uri({ - destination: destinationPublicKey, - amount: item.price.toString(), - memo, - }); + if (!preflight.ok) { + await session.abortTransaction(); + return res.status(400).json({ + success: false, + message: "Payment failed pre-flight safety checks", + reasons: preflight.reasons, + }); + } + + paymentTx = await buildPaymentTransaction({ + sourcePublicKey: buyer.stellarWallet.publicKey, + destinationPublicKey, + amount: item.price.toString(), + memo, + applyPlatformFee: settlementMode === "direct", + }); + + sep7Uri = buildSep7Uri({ + destination: destinationPublicKey, + amount: item.price.toString(), + memo, + }); + } const feeSplit = paymentTx.feeSplit; @@ -188,7 +449,11 @@ export const initializePayment = async (req, res) => { network: NETWORK, status: "pending", settlement: settlementMode, - stellarTxHash: paymentTx.hash, // Temporary hash, will be replaced with actual + stellarTxHash: paymentTx.hash, + ...(sendAssetInput && { + sendAsset: sendAssetInput, + sendMax, + }), ...(feeSplit && { platformFee: { feePercent: feeSplit.feePercent, @@ -215,7 +480,11 @@ export const initializePayment = async (req, res) => { networkPassphrase: paymentTx.networkPassphrase, expectedHash: paymentTx.hash, }, - sep7Uri, + ...(sep7Uri && { sep7Uri }), + ...(isPathPayment && { + pathPaymentNote: + "Path payment XDR provided. SEP-7 URI is not available for path payments; use the XDR signing flow.", + }), item: { title: item.title, price: item.price, @@ -328,9 +597,32 @@ export const submitPayment = async (req, res) => { ); if (!verification.verified) { + transaction.stellarTxHash = result.hash; + if (verification.transient) { + transaction.status = "retrying"; + transaction.failureReason = verification.reason; + await transaction.save({ session }); + await enqueue( + "verifyPaymentOnChain", + { transactionId: transaction._id.toString() }, + { + attempts: 5, + backoffMs: 1000, + idempotencyKey: `verify:${result.hash}`, + session, + } + ); + await session.commitTransaction(); + return res.status(202).json({ + success: true, + message: "Payment submitted; confirmation is in progress", + transactionId: transaction._id, + txHash: result.hash, + status: "retrying", + }); + } transaction.status = "failed"; transaction.failureReason = `On-chain verification failed: ${verification.reason}`; - transaction.stellarTxHash = result.hash; await transaction.save({ session }); await session.commitTransaction(); paymentsFailed.inc({ type: "purchase", reason: "verification_failed" }); @@ -386,6 +678,16 @@ export const submitPayment = async (req, res) => { } await buyer.save({ session }); + await enqueue( + "generateReceipt", + { transactionId: transaction._id.toString() }, + { + attempts: 5, + backoffMs: 1000, + idempotencyKey: `receipt:${result.hash}`, + session, + } + ); await session.commitTransaction(); logger.info( diff --git a/src/controllers/stellar/refundController.js b/src/controllers/stellar/refundController.js new file mode 100644 index 00000000..1540e181 --- /dev/null +++ b/src/controllers/stellar/refundController.js @@ -0,0 +1,490 @@ +// controllers/stellar/refundController.js +import mongoose from "mongoose"; +import User from "../../models/User.js"; +import Book from "../../models/Book.js"; +import Course from "../../models/Course.js"; +import Transaction from "../../models/Transaction.js"; +import Refund from "../../models/Refund.js"; +import { + buildReversePaymentTransaction, + submitTransaction, + verifyTransaction, +} from "../../services/stellar/stellarService.js"; +import logger from "../../config/logger.js"; + +const REFUND_WINDOW_DAYS = parseInt(process.env.REFUND_WINDOW_DAYS || "14", 10); + +/** + * Buyer requests a refund + * POST /api/stellar/payment/transactions/:id/refund-request + */ +export const requestRefund = async (req, res) => { + try { + const { id: transactionId } = req.params; + const { reason } = req.body; + const buyerId = req.user._id; + + if (!reason || typeof reason !== "string" || !reason.trim()) { + return res.status(400).json({ + success: false, + message: "A valid refund reason is required", + }); + } + + // Find original transaction + const transaction = await Transaction.findById(transactionId); + if (!transaction) { + return res.status(404).json({ + success: false, + message: "Transaction not found", + }); + } + + // Guard: Only the original buyer + if (transaction.buyer.toString() !== buyerId.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: You are not the purchaser of this item", + }); + } + + // Guard: Only confirmed transactions + if (transaction.status !== "confirmed") { + return res.status(400).json({ + success: false, + message: `Cannot request refund for transaction in '${transaction.status}' status`, + }); + } + + // Guard: Within configurable refund window + const confirmedTime = new Date( + transaction.confirmedAt || transaction.updatedAt + ).getTime(); + const windowMs = REFUND_WINDOW_DAYS * 24 * 60 * 60 * 1000; + if (Date.now() - confirmedTime > windowMs) { + return res.status(400).json({ + success: false, + message: `Refund window of ${REFUND_WINDOW_DAYS} days has expired for this transaction`, + }); + } + + // Guard: Idempotency - check for existing open/active refund request + const existingRefund = await Refund.findOne({ + originalTransaction: transaction._id, + status: { $in: ["requested", "approved", "submitted", "confirmed", "disputed"] }, + }); + + if (existingRefund) { + return res.status(400).json({ + success: false, + message: "An active or completed refund request already exists for this transaction", + refundId: existingRefund._id, + }); + } + + // Create refund request + const refund = await Refund.create({ + originalTransaction: transaction._id, + buyer: transaction.buyer, + educator: transaction.creator, + itemType: transaction.itemType, + itemId: transaction.itemId, + amount: transaction.amount, + currency: transaction.currency || "USDC", + reason: reason.trim(), + status: "requested", + expiresAt: new Date(Date.now() + windowMs), + }); + + // Cross-link on transaction + transaction.refund = refund._id; + await transaction.save(); + + logger.info(`Refund requested for transaction ${transaction._id} by buyer ${buyerId}`); + + return res.status(201).json({ + success: true, + message: "Refund request submitted successfully", + refund, + }); + } catch (error) { + logger.error("Error requesting refund:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to request refund", + }); + } +}; + +/** + * Educator approves refund & builds reverse payment XDR + * POST /api/stellar/payment/refunds/:refundId/build + */ +export const buildRefundXdr = async (req, res) => { + try { + const { refundId } = req.params; + const educatorId = req.user._id; + + const refund = await Refund.findById(refundId).populate("originalTransaction"); + if (!refund) { + return res.status(404).json({ + success: false, + message: "Refund request not found", + }); + } + + // Guard: Only the educator + if (refund.educator.toString() !== educatorId.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: Only the educator can approve this refund", + }); + } + + // Guard: Status must be requested + if (refund.status !== "requested") { + return res.status(400).json({ + success: false, + message: `Cannot build reverse payment for refund in '${refund.status}' status`, + }); + } + + // Fetch buyer & educator wallet info + const buyer = await User.findById(refund.buyer); + const educator = await User.findById(refund.educator); + + const buyerWallet = buyer?.stellarWallet?.publicKey; + const educatorWallet = educator?.stellarWallet?.publicKey; + + if (!buyerWallet || !educatorWallet) { + return res.status(400).json({ + success: false, + message: "Missing wallet information for buyer or educator", + }); + } + + const originalTxHash = refund.originalTransaction?.stellarTxHash || ""; + + // Build reverse payment transaction (educator -> buyer) + const result = await buildReversePaymentTransaction({ + sourcePublicKey: educatorWallet, + destinationPublicKey: buyerWallet, + amount: refund.amount, + originalTxHash, + }); + + refund.status = "approved"; + await refund.save(); + + logger.info(`Reverse payment XDR built for refund ${refund._id} by educator ${educatorId}`); + + return res.status(200).json({ + success: true, + message: "Unsigned reverse payment XDR built successfully", + refund, + unsignedXdr: result.xdr, + networkPassphrase: result.networkPassphrase, + }); + } catch (error) { + logger.error("Error building refund XDR:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to build refund XDR", + }); + } +}; + +/** + * Educator submits signed reverse payment XDR & triggers atomic revocation + * POST /api/stellar/payment/refunds/:refundId/submit + */ +export const submitRefund = async (req, res) => { + try { + const { refundId } = req.params; + const { signedXdr } = req.body; + const educatorId = req.user._id; + + if (!signedXdr) { + return res.status(400).json({ + success: false, + message: "signedXdr is required", + }); + } + + const refund = await Refund.findById(refundId); + if (!refund) { + return res.status(404).json({ + success: false, + message: "Refund request not found", + }); + } + + // Guard: Only the educator + if (refund.educator.toString() !== educatorId.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: Only the educator can submit this refund", + }); + } + + // Guard: Enforce transition order (must be approved first) + if (refund.status !== "approved") { + return res.status(400).json({ + success: false, + message: `Cannot submit refund in '${refund.status}' status. Must be 'approved' first.`, + }); + } + + // Submit transaction to Stellar network + let submissionResult; + try { + submissionResult = await submitTransaction(signedXdr); + } catch (submitErr) { + refund.status = "failed"; + await refund.save(); + return res.status(400).json({ + success: false, + message: `Stellar transaction failed: ${submitErr.message}`, + }); + } + + // On-Chain Truth Verification via Horizon + const verification = await verifyTransaction(submissionResult.hash); + if (!verification.exists || !verification.successful) { + refund.status = "failed"; + await refund.save(); + return res.status(400).json({ + success: false, + message: "Reverse payment transaction could not be verified on Horizon", + }); + } + + // Access Revocation — sequential writes (no session/transaction required; + // the Stellar on-chain verification above is the source of truth) + try { + const buyer = await User.findById(refund.buyer); + + if (refund.itemType === "course") { + // Remove course from buyer's purchased list + if (buyer) { + buyer.purchasedCourses = (buyer.purchasedCourses || []).filter( + (cId) => cId.toString() !== refund.itemId.toString() + ); + await buyer.save(); + } + + // Remove buyer from Course.enrolledUsers + const course = await Course.findById(refund.itemId); + if (course) { + course.enrolledUsers = (course.enrolledUsers || []).filter( + (uId) => uId.toString() !== refund.buyer.toString() + ); + await course.save(); + } + } else if (refund.itemType === "book") { + // Remove book from buyer's purchased list + if (buyer) { + buyer.purchasedBooks = (buyer.purchasedBooks || []).filter( + (bId) => bId.toString() !== refund.itemId.toString() + ); + await buyer.save(); + } + } + + // Update refund & transaction status + refund.status = "confirmed"; + refund.refundTxHash = submissionResult.hash; + refund.refundLedger = submissionResult.ledger; + await refund.save(); + + await Transaction.findByIdAndUpdate( + refund.originalTransaction, + { status: "refunded", refund: refund._id } + ); + + logger.info(`Refund confirmed and access revoked atomically for refund ${refund._id}`); + } catch (revokeErr) { + logger.error("Error during atomic access revocation:", revokeErr); + throw revokeErr; + } + + return res.status(200).json({ + success: true, + message: "Refund confirmed on-chain and item access revoked successfully", + refund, + txHash: submissionResult.hash, + }); + } catch (error) { + logger.error("Error submitting refund:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to submit refund", + }); + } +}; + +/** + * Educator rejects a refund request + * POST /api/stellar/payment/refunds/:refundId/reject + */ +export const rejectRefund = async (req, res) => { + try { + const { refundId } = req.params; + const { rejectionReason } = req.body; + const educatorId = req.user._id; + + const refund = await Refund.findById(refundId); + if (!refund) { + return res.status(404).json({ + success: false, + message: "Refund request not found", + }); + } + + if (refund.educator.toString() !== educatorId.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: Only the educator can reject this refund", + }); + } + + if (refund.status !== "requested") { + return res.status(400).json({ + success: false, + message: `Cannot reject refund in '${refund.status}' status`, + }); + } + + refund.status = "rejected"; + refund.rejectionReason = rejectionReason || "Refund request rejected by educator"; + await refund.save(); + + logger.info(`Refund ${refund._id} rejected by educator ${educatorId}`); + + return res.status(200).json({ + success: true, + message: "Refund request rejected", + refund, + }); + } catch (error) { + logger.error("Error rejecting refund:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to reject refund", + }); + } +}; + +/** + * Buyer escalates refund to dispute + * POST /api/stellar/payment/refunds/:refundId/dispute + */ +export const escalateDispute = async (req, res) => { + try { + const { refundId } = req.params; + const buyerId = req.user._id; + + const refund = await Refund.findById(refundId); + if (!refund) { + return res.status(404).json({ + success: false, + message: "Refund request not found", + }); + } + + if (refund.buyer.toString() !== buyerId.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: Only the buyer can escalate this dispute", + }); + } + + if (!["requested", "rejected"].includes(refund.status)) { + return res.status(400).json({ + success: false, + message: `Cannot escalate refund in '${refund.status}' status`, + }); + } + + refund.status = "disputed"; + await refund.save(); + + await Transaction.findByIdAndUpdate(refund.originalTransaction, { + status: "disputed", + }); + + logger.info(`Refund ${refund._id} escalated to dispute by buyer ${buyerId}`); + + return res.status(200).json({ + success: true, + message: "Refund request escalated to dispute for admin review", + refund, + }); + } catch (error) { + logger.error("Error escalating dispute:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to escalate dispute", + }); + } +}; + +/** + * Admin / Arbiter resolves a dispute + * PATCH /api/stellar/payment/refunds/:refundId/arbitrate + */ +export const arbitrateDispute = async (req, res) => { + try { + const { refundId } = req.params; + const { decision, notes } = req.body; + const adminId = req.user._id; + + if (!["approved", "rejected", "off_chain_resolved"].includes(decision)) { + return res.status(400).json({ + success: false, + message: "Invalid decision. Must be 'approved', 'rejected', or 'off_chain_resolved'", + }); + } + + const refund = await Refund.findById(refundId); + if (!refund) { + return res.status(404).json({ + success: false, + message: "Refund request not found", + }); + } + + if (refund.status !== "disputed") { + return res.status(400).json({ + success: false, + message: `Cannot arbitrate refund in '${refund.status}' status. Must be 'disputed'`, + }); + } + + refund.resolution = { + decision, + notes: notes || "", + resolvedBy: adminId, + resolvedAt: new Date(), + }; + refund.status = "resolved"; + await refund.save(); + + logger.info(`Dispute for refund ${refund._id} arbitrated by admin ${adminId}`); + + return res.status(200).json({ + success: true, + message: + "Dispute resolution recorded successfully. Note: DeenBridge is a non-custodial platform; on-chain funds transfers require the creator's wallet signature.", + refund, + disclaimer: + "Non-custodial Limitation: The platform wallet does not hold buyer or creator funds and cannot unilaterally move Stellar assets on-chain without the educator's signed transaction.", + }); + } catch (error) { + logger.error("Error arbitrating dispute:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to arbitrate dispute", + }); + } +}; diff --git a/src/controllers/uploadController.js b/src/controllers/uploadController.js new file mode 100644 index 00000000..902fa0d3 --- /dev/null +++ b/src/controllers/uploadController.js @@ -0,0 +1,30 @@ +import cloudinary from "../utils/cloudinary.js"; +import logger from "../config/logger.js"; + +export const generateSignature = async (req, res) => { + try { + const timestamp = Math.round(new Date().getTime() / 1000); + const config = cloudinary.config(); + const signature = cloudinary.utils.api_sign_request( + { + timestamp, + folder: "direct-uploads", + }, + config.api_secret + ); + + res.status(200).json({ + success: true, + message: "Signature generated successfully", + data: { + timestamp, + signature, + cloudName: config.cloud_name, + apiKey: config.api_key, + } + }); + } catch (error) { + logger.error("Error generating Cloudinary signature:", error); + res.status(500).json({ success: false, message: "Failed to generate upload signature", data: null }); + } +}; diff --git a/src/controllers/userController.js b/src/controllers/userController.js index b2175a21..3d9b0439 100644 --- a/src/controllers/userController.js +++ b/src/controllers/userController.js @@ -3,15 +3,29 @@ import cloudinary from "../utils/cloudinary.js"; import Course from "../models/Course.js"; import Book from "../models/Book.js"; import logger from "../config/logger.js"; +import { validateMagicBytes } from "../utils/fileValidation.js"; +import { createFollowNotification, createUnfollowNotification } from "./notificationController.js"; // Update user profile (including avatar upload to Cloudinary) export const updateUser = async (req, res) => { try { + 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", + }); + } + const updates = req.body; let avatarUrl = updates.avatar; // If avatar file is uploaded, upload to Cloudinary with timeout if (req.file) { + const isValid = await validateMagicBytes(req.file.buffer, ["image/jpeg", "image/png", "image/webp"]); + if (!isValid) { + return res.status(400).json({ success: false, message: "Invalid file content. Magic bytes do not match expected image types.", data: null }); + } + try { const result = await Promise.race([ new Promise((resolve, reject) => { @@ -115,6 +129,13 @@ export const getUser = async (req, res) => { // Delete user export const deleteUser = async (req, res) => { try { + if (req.user.role !== "admin" && req.user._id.toString() !== req.params.id) { + return res.status(403).json({ + success: false, + message: "Not authorized to delete this user", + }); + } + const user = await User.findByIdAndDelete(req.params.id); if (!user) { return res.status(404).json({ @@ -177,6 +198,11 @@ export const followUser = async (req, res) => { $push: { followers: currentUserId }, }); + // Emit follow notification asynchronously + createFollowNotification(currentUserId, userId).catch((err) => + logger.error("Error creating follow notification:", err) + ); + res.status(200).json({ success: true, message: "Successfully followed user", @@ -232,6 +258,11 @@ export const unfollowUser = async (req, res) => { $pull: { followers: currentUserId }, }); + // Emit unfollow notification asynchronously + createUnfollowNotification(currentUserId, userId).catch((err) => + logger.error("Error creating unfollow notification:", err) + ); + res.status(200).json({ success: true, message: "Successfully unfollowed user", diff --git a/src/jobs/handlers.js b/src/jobs/handlers.js new file mode 100644 index 00000000..c45d3fce --- /dev/null +++ b/src/jobs/handlers.js @@ -0,0 +1,92 @@ +import Transaction from "../models/Transaction.js"; +import User from "../models/User.js"; +import Course from "../models/Course.js"; +import { sendOtpEmail, sendReceiptEmail } from "../../services/emails/sendMail.js"; +import { verifyPaymentOperations, getExplorerUrl } from "../services/stellar/stellarService.js"; +import { recordSaleEarnings } from "../services/payoutService.js"; +import { registerJob, enqueue } from "./queue.js"; + +const expectedPaymentsFor = (transaction) => + transaction.type === "donation" + ? [{ destination: transaction.creatorWallet, amount: transaction.amount }] + : transaction.platformFee?.platformAmount + ? [ + { destination: transaction.creatorWallet, amount: transaction.platformFee.creatorAmount }, + { destination: transaction.platformFee.platformWallet, amount: transaction.platformFee.platformAmount }, + ] + : [{ destination: transaction.creatorWallet, amount: transaction.amount }]; + +const queueReceipt = (transaction) => + enqueue( + "generateReceipt", + { transactionId: transaction._id.toString() }, + { attempts: 5, backoffMs: 1000, idempotencyKey: `receipt:${transaction.stellarTxHash}` } + ); + +registerJob("sendOtpEmail", async ({ userId, otp }) => { + const user = await User.findById(userId).select("email"); + if (!user) throw new Error("OTP recipient no longer exists"); + await sendOtpEmail(otp, user.email); +}); + +registerJob("verifyPaymentOnChain", async ({ transactionId }, context) => { + const transaction = await Transaction.findById(transactionId); + if (!transaction || transaction.status === "failed") return; + if (transaction.status === "confirmed") { + await queueReceipt(transaction); + return; + } + + const verification = await verifyPaymentOperations( + transaction.stellarTxHash, + expectedPaymentsFor(transaction) + ); + if (!verification.verified) { + transaction.retryCount = context.attempt; + if (verification.transient && context.attempt < context.maxAttempts) { + await transaction.save(); + throw new Error(verification.reason); + } + transaction.status = "failed"; + transaction.failureReason = `On-chain verification failed: ${verification.reason}`; + await transaction.save(); + return; + } + + transaction.status = "confirmed"; + transaction.confirmedAt = new Date(); + transaction.failureReason = undefined; + await transaction.save(); + + if (transaction.type === "purchase") { + await recordSaleEarnings(transaction); + const purchase = { purchaseDate: transaction.confirmedAt }; + if (transaction.itemType === "book") { + purchase.bookId = transaction.itemId; + await User.updateOne({ _id: transaction.buyer }, { $addToSet: { purchasedBooks: purchase } }); + } else { + purchase.courseId = transaction.itemId; + await User.updateOne({ _id: transaction.buyer }, { $addToSet: { purchasedCourses: purchase } }); + await Course.updateOne({ _id: transaction.itemId }, { $addToSet: { enrolledUsers: transaction.buyer } }); + } + } + await queueReceipt(transaction); +}); + +registerJob("generateReceipt", async ({ transactionId }) => { + const transaction = await Transaction.findById(transactionId).populate("buyer", "email name"); + if (!transaction || transaction.status !== "confirmed") return; + await sendReceiptEmail({ + email: transaction.buyer.email, + name: transaction.buyer.name, + title: transaction.itemTitle || "Sadaqah donation", + amount: transaction.amount, + currency: transaction.currency, + platformAmount: transaction.platformFee?.platformAmount || "0", + creatorAmount: transaction.platformFee?.creatorAmount || transaction.amount, + txHash: transaction.stellarTxHash, + explorerUrl: getExplorerUrl(transaction.stellarTxHash), + }); +}); + +export { expectedPaymentsFor, queueReceipt }; diff --git a/src/jobs/queue.js b/src/jobs/queue.js new file mode 100644 index 00000000..6334b265 --- /dev/null +++ b/src/jobs/queue.js @@ -0,0 +1,147 @@ +import crypto from "crypto"; +import Job from "../models/Job.js"; +import logger from "../config/logger.js"; + +const handlers = new Map(); +const inlineKeys = new Set(); +const inFlight = new Set(); +let accepting = true; +let pollTimer; + +const driver = () => + process.env.QUEUE_DRIVER || (process.env.NODE_ENV === "test" ? "inline" : "mongo"); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const retryDelay = (base, attempt) => { + const exponential = base * 2 ** Math.max(0, attempt - 1); + return exponential + Math.floor(Math.random() * Math.max(1, exponential * 0.2)); +}; + +export const registerJob = (name, handler) => handlers.set(name, handler); + +const executeInline = async (name, payload, options) => { + const handler = handlers.get(name); + if (!handler) throw new Error(`No handler registered for job ${name}`); + let attempt = 0; + while (attempt < options.attempts) { + attempt += 1; + try { + await handler(payload, { attempt, maxAttempts: options.attempts }); + return; + } catch (error) { + if (attempt >= options.attempts) { + logger.error({ job: name, error: error.message }, "Inline job exhausted retries"); + return; + } + await delay(retryDelay(options.backoffMs, attempt)); + } + } +}; + +export const enqueue = async (name, payload, opts = {}) => { + if (!accepting) throw new Error("Job queue is shutting down"); + const options = { + attempts: opts.attempts || 1, + backoffMs: opts.backoffMs || 1000, + idempotencyKey: + opts.idempotencyKey || `${name}:${crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex")}`, + session: opts.session, + }; + + if (driver() === "inline") { + if (inlineKeys.has(options.idempotencyKey)) return { duplicate: true }; + inlineKeys.add(options.idempotencyKey); + const promise = new Promise((resolve) => setImmediate(resolve)) + .then(() => executeInline(name, payload, options)) + .finally(() => inFlight.delete(promise)); + inFlight.add(promise); + return { queued: true, idempotencyKey: options.idempotencyKey }; + } + + const job = await Job.findOneAndUpdate( + { idempotencyKey: options.idempotencyKey }, + { + $setOnInsert: { + name, + payload, + idempotencyKey: options.idempotencyKey, + maxAttempts: options.attempts, + backoffMs: options.backoffMs, + status: "queued", + runAt: new Date(), + }, + }, + { upsert: true, new: true, session: options.session } + ); + return { queued: true, id: job._id, duplicate: job.attemptsMade > 0 }; +}; + +const processNext = async () => { + const job = await Job.findOneAndUpdate( + { status: { $in: ["queued", "retrying"] }, runAt: { $lte: new Date() } }, + { $set: { status: "active", lockedAt: new Date() }, $inc: { attemptsMade: 1 } }, + { sort: { runAt: 1 }, new: true } + ); + if (!job) return; + + const promise = (async () => { + try { + const handler = handlers.get(job.name); + if (!handler) throw new Error(`No handler registered for job ${job.name}`); + await handler(job.payload, { + attempt: job.attemptsMade, + maxAttempts: job.maxAttempts, + }); + await Job.updateOne( + { _id: job._id }, + { $set: { status: "completed", completedAt: new Date() }, $unset: { lockedAt: 1 } } + ); + } catch (error) { + const exhausted = job.attemptsMade >= job.maxAttempts; + await Job.updateOne( + { _id: job._id }, + { + $set: exhausted + ? { status: "dead", failedAt: new Date(), lastError: error.message } + : { + status: "retrying", + runAt: new Date(Date.now() + retryDelay(job.backoffMs, job.attemptsMade)), + lastError: error.message, + }, + $unset: { lockedAt: 1 }, + } + ); + logger[exhausted ? "error" : "warn"]( + { job: job.name, jobId: job._id, attempt: job.attemptsMade, error: error.message }, + exhausted ? "Job moved to dead letter" : "Job scheduled for retry" + ); + } + })().finally(() => inFlight.delete(promise)); + inFlight.add(promise); +}; + +export const startJobs = async () => { + if (process.env.JOBS_ENABLED === "false" || driver() === "inline" || pollTimer) return; + accepting = true; + await Job.updateMany( + { status: "active" }, + { $set: { status: "retrying", runAt: new Date() }, $unset: { lockedAt: 1 } } + ); + pollTimer = setInterval(() => processNext().catch((error) => logger.error(error, "Job poll failed")), 500); + pollTimer.unref?.(); + logger.info({ driver: driver() }, "Background jobs started"); +}; + +export const stopJobs = async () => { + accepting = false; + if (pollTimer) clearInterval(pollTimer); + pollTimer = undefined; + await Promise.allSettled([...inFlight]); +}; + +export const waitForIdle = async () => Promise.allSettled([...inFlight]); + +export const resetInlineQueueForTests = () => { + inlineKeys.clear(); + accepting = true; +}; diff --git a/src/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js index dec77023..fd7c43cd 100644 --- a/src/middlewares/authMiddleware.js +++ b/src/middlewares/authMiddleware.js @@ -40,3 +40,18 @@ export const protect = async (req, res, next) => { .json({ success: false, message: "No token, authorization denied" }); } }; + +export const authorizeRoles = (...roles) => { + return (req, res, next) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ + success: false, + message: `Forbidden: Access requires one of the following roles: ${roles.join(", ")}`, + }); + } + next(); + }; +}; + +export const restrictTo = (...roles) => authorizeRoles(...roles); +export const authorize = (...roles) => authorizeRoles(...roles); diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js index 9679ac5d..c92dd9ab 100644 --- a/src/middlewares/errorHandler.js +++ b/src/middlewares/errorHandler.js @@ -78,6 +78,15 @@ export const errorHandler = (err, req, res, next) => { err.statusCode = err.statusCode || 500; err.status = err.status || "error"; + if (err.name === "MulterError") { + err.statusCode = 400; + err.status = "fail"; + err.isOperational = true; + if (err.code === "LIMIT_FILE_SIZE") { + err.message = "File too large. Please upload a smaller file."; + } + } + if (process.env.NODE_ENV === "development") { if (err.name === "AllEndpointsOpenError") { return res.status(503).json({ diff --git a/src/middlewares/upload.js b/src/middlewares/upload.js index 57dc7c0e..d02f9936 100644 --- a/src/middlewares/upload.js +++ b/src/middlewares/upload.js @@ -1,5 +1,48 @@ -// middleware/upload.js import multer from "multer"; -const storage = multer.memoryStorage(); // In-memory buffer -const upload = multer({ storage }); -export default upload; +import { APIError } from "./errorHandler.js"; + +const storage = multer.memoryStorage(); + +const imageFilter = (req, file, cb) => { + if (file.mimetype.startsWith("image/")) { + cb(null, true); + } else { + cb(new APIError("Not an image! Please upload only images.", 400)); + } +}; + +const bookFilter = (req, file, cb) => { + if ( + file.fieldname === "thumbnail" && + file.mimetype.startsWith("image/") + ) { + cb(null, true); + } else if ( + file.fieldname === "file" && + (file.mimetype === "application/pdf" || + file.mimetype === "application/epub+zip") + ) { + cb(null, true); + } else { + cb(new APIError("Invalid file format. Thumbnail must be an image, file must be PDF/EPUB.", 400)); + } +}; + +export const uploadImage = multer({ + storage, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit + }, + fileFilter: imageFilter, +}); + +export const uploadBook = multer({ + storage, + limits: { + fileSize: 50 * 1024 * 1024, // 50MB limit + }, + fileFilter: bookFilter, +}); + +// Default export for backward compatibility where not yet replaced +export default uploadImage; diff --git a/src/models/Book.js b/src/models/Book.js index 53169ec8..a3e55ce1 100644 --- a/src/models/Book.js +++ b/src/models/Book.js @@ -43,6 +43,9 @@ const bookSchema = new mongoose.Schema({ type: String, required: true, }, + filePublicId: { + type: String, + }, createdAt: { type: Date, default: Date.now, @@ -53,6 +56,8 @@ const bookSchema = new mongoose.Schema({ }, }); +bookSchema.index({ title: "text", description: "text", category: "text" }, { weights: { title: 5 } }); + const Book = mongoose.model("Book", bookSchema); export default Book; diff --git a/src/models/Course.js b/src/models/Course.js index 6ce5f415..db43f1cf 100644 --- a/src/models/Course.js +++ b/src/models/Course.js @@ -48,4 +48,6 @@ const courseSchema = new mongoose.Schema( { timestamps: true } ); +courseSchema.index({ title: "text", description: "text", category: "text" }, { weights: { title: 5 } }); + export default mongoose.model("Course", courseSchema); diff --git a/src/models/Job.js b/src/models/Job.js new file mode 100644 index 00000000..05e9171e --- /dev/null +++ b/src/models/Job.js @@ -0,0 +1,28 @@ +import mongoose from "mongoose"; + +const jobSchema = new mongoose.Schema( + { + name: { type: String, required: true, index: true }, + payload: { type: mongoose.Schema.Types.Mixed, required: true }, + idempotencyKey: { type: String, required: true, unique: true, index: true }, + status: { + type: String, + enum: ["queued", "active", "retrying", "completed", "dead"], + default: "queued", + index: true, + }, + attemptsMade: { type: Number, default: 0 }, + maxAttempts: { type: Number, default: 1 }, + backoffMs: { type: Number, default: 1000 }, + runAt: { type: Date, default: Date.now, index: true }, + lockedAt: Date, + completedAt: Date, + failedAt: Date, + lastError: String, + }, + { timestamps: true } +); + +jobSchema.index({ status: 1, runAt: 1 }); + +export default mongoose.model("Job", jobSchema); diff --git a/src/models/Notification.js b/src/models/Notification.js index 452af9a9..e76cd33f 100644 --- a/src/models/Notification.js +++ b/src/models/Notification.js @@ -74,13 +74,12 @@ const notificationSchema = new mongoose.Schema( }, { timestamps: true, - // Index for efficient queries - indexes: [ - { recipient: 1, createdAt: -1 }, - { recipient: 1, isRead: 1 }, - { recipient: 1, isDeleted: 1 }, - ], } ); +// Indexes for efficient querying +notificationSchema.index({ recipient: 1, createdAt: -1 }); +notificationSchema.index({ recipient: 1, isRead: 1 }); +notificationSchema.index({ recipient: 1, isDeleted: 1 }); + export default mongoose.model("Notification", notificationSchema); diff --git a/src/models/Reel.js b/src/models/Reel.js index d58ebfa9..cb696516 100644 --- a/src/models/Reel.js +++ b/src/models/Reel.js @@ -37,6 +37,7 @@ const reelSchema = new Schema( ); reelSchema.index({ createdAt: -1 }); +reelSchema.index({ description: "text" }); reelSchema.virtual("likeCount").get(function () { return this.likes?.length || 0; diff --git a/src/models/Refund.js b/src/models/Refund.js new file mode 100644 index 00000000..ab5e5f1f --- /dev/null +++ b/src/models/Refund.js @@ -0,0 +1,103 @@ +// models/Refund.js +import mongoose from "mongoose"; + +const refundSchema = new mongoose.Schema( + { + originalTransaction: { + type: mongoose.Schema.Types.ObjectId, + ref: "Transaction", + required: true, + }, + buyer: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + educator: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + itemType: { + type: String, + enum: ["book", "course"], + required: true, + }, + itemId: { + type: mongoose.Schema.Types.ObjectId, + required: true, + }, + amount: { + type: String, + required: true, + }, + currency: { + type: String, + default: "USDC", + }, + reason: { + type: String, + required: [true, "Refund reason is required"], + trim: true, + }, + status: { + type: String, + enum: [ + "requested", + "approved", + "submitted", + "confirmed", + "rejected", + "failed", + "disputed", + "resolved", + ], + default: "requested", + index: true, + }, + refundTxHash: { + type: String, + default: null, + index: true, + }, + refundLedger: { + type: Number, + default: null, + }, + rejectionReason: { + type: String, + default: null, + }, + resolution: { + decision: { + type: String, + enum: ["approved", "rejected", "off_chain_resolved"], + }, + notes: String, + resolvedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + resolvedAt: Date, + }, + expiresAt: { + type: Date, + }, + }, + { timestamps: true } +); + +// Ensure one active refund request per original transaction +refundSchema.index( + { originalTransaction: 1 }, + { + unique: true, + partialFilterExpression: { + status: { $in: ["requested", "approved", "submitted", "disputed"] }, + }, + } +); + +export default mongoose.model("Refund", refundSchema); diff --git a/src/models/Space.js b/src/models/Space.js index 925e3efa..fa2fd4b8 100644 --- a/src/models/Space.js +++ b/src/models/Space.js @@ -83,4 +83,6 @@ spaceSchema.pre("validate", function (next) { next(); }); +spaceSchema.index({ title: "text", description: "text", category: "text" }); + export default mongoose.model("Space", spaceSchema); diff --git a/src/models/Transaction.js b/src/models/Transaction.js index 4652c75a..9321a42e 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -85,6 +85,11 @@ const transactionSchema = new mongoose.Schema( default: "USDC", enum: ["USDC"], }, + sendAsset: { + code: { type: String }, + issuer: { type: String }, + }, + sendMax: { type: String }, network: { type: String, enum: ["testnet", "mainnet"], @@ -110,11 +115,18 @@ const transactionSchema = new mongoose.Schema( // Status tracking status: { type: String, - enum: ["pending", "submitted", "confirmed", "failed", "expired"], + enum: ["pending", "submitted", "retrying", "confirmed", "failed", "expired", "refunded", "disputed"], default: "pending", index: true, }, + // Refund linkage + refund: { + type: mongoose.Schema.Types.ObjectId, + ref: "Refund", + default: null, + }, + // Error handling failureReason: { type: String, diff --git a/src/models/User.js b/src/models/User.js index fec9b438..532caa5d 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -41,7 +41,7 @@ const userSchema = new mongoose.Schema( }, role: { type: String, - enum: ["student", "tutor"], + enum: ["student", "tutor", "mentor", "admin", "arbiter"], default: "student", }, isActive: { @@ -135,4 +135,6 @@ const userSchema = new mongoose.Schema( { timestamps: true } ); +userSchema.index({ name: "text", bio: "text", interests: "text" }); + export default mongoose.model("User", userSchema); diff --git a/src/routes/books/bookRoutes.js b/src/routes/books/bookRoutes.js index 78b04e8e..53e0c2d0 100644 --- a/src/routes/books/bookRoutes.js +++ b/src/routes/books/bookRoutes.js @@ -1,5 +1,5 @@ import express from "express"; -import upload from "../../middlewares/upload.js"; +import { uploadBook } from "../../middlewares/upload.js"; import { createBook, getBooks, @@ -35,7 +35,7 @@ const booksByAuthorCacheKey = (req) => router.post( "/", protect, - upload.fields([ + uploadBook.fields([ { name: "thumbnail", maxCount: 1 }, { name: "file", maxCount: 1 }, ]), @@ -77,6 +77,7 @@ router.get( // delete a book - invalidates book caches router.delete( "/:id", + protect, invalidateCacheMiddleware([`${CACHE_KEYS.BOOKS}*`, `${CACHE_KEYS.BOOK}*`]), deleteBook ); diff --git a/src/routes/jobsRoutes.js b/src/routes/jobsRoutes.js new file mode 100644 index 00000000..ef55df21 --- /dev/null +++ b/src/routes/jobsRoutes.js @@ -0,0 +1,38 @@ +import express from "express"; +import Job from "../models/Job.js"; + +const router = express.Router(); +const escapeHtml = (value) => + String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +router.use((req, res, next) => { + const token = process.env.JOBS_DASHBOARD_TOKEN; + if (!token) return res.status(404).json({ success: false, message: "Not found" }); + if (req.headers.authorization !== `Bearer ${token}`) { + return res.status(401).json({ success: false, message: "Unauthorized" }); + } + next(); +}); + +router.get("/", async (req, res) => { + const jobs = await Job.find().sort({ createdAt: -1 }).limit(100).lean(); + if (req.accepts(["html", "json"]) === "html") { + const rows = jobs + .map((job) => `${escapeHtml(job.name)}${escapeHtml(job.status)}${job.attemptsMade}/${job.maxAttempts}${escapeHtml(job.lastError || "")}`) + .join(""); + return res.type("html").send(`

DeenBridge Jobs

${rows}
NameStatusAttemptsError
`); + } + res.json({ success: true, jobs }); +}); + +router.get("/dead", async (req, res) => { + const jobs = await Job.find({ status: "dead" }).sort({ failedAt: -1 }).lean(); + res.json({ success: true, jobs }); +}); + +export default router; diff --git a/src/routes/searchRoutes.js b/src/routes/searchRoutes.js index 40e753fd..51929a66 100644 --- a/src/routes/searchRoutes.js +++ b/src/routes/searchRoutes.js @@ -1,5 +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,10 +9,17 @@ 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()}`; + const page = req.query.page || 1; + const limit = req.query.limit || 10; + const filterKeys = ['minPrice', 'maxPrice', 'free', 'category', 'minRating', 'interest', 'sort']; + const filtersStr = filterKeys.map(k => `${k}=${req.query[k] || ''}`).join('&'); + return `${CACHE_KEYS.SEARCH}${req.path}:${type}:${query.toLowerCase().trim()}:page=${page}:limit=${limit}:${filtersStr}`; }; -// Main search endpoint - cached for 5 minutes +// Main search endpoint router.get("/", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchAll); +// Dedicated educators endpoint +router.get("/educators", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchEducatorsHandler); + export default router; diff --git a/src/routes/stellar/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js index 554889cb..4ac207bc 100644 --- a/src/routes/stellar/paymentRoutes.js +++ b/src/routes/stellar/paymentRoutes.js @@ -1,13 +1,23 @@ // routes/stellar/paymentRoutes.js import express from "express"; -import { protect } from "../../middlewares/authMiddleware.js"; +import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; import { initializePayment, submitPayment, + getQuote, + getPaymentPreflight, getTransactionHistory, getTransaction, cancelTransaction, } from "../../controllers/stellar/paymentController.js"; +import { + requestRefund, + buildRefundXdr, + submitRefund, + rejectRefund, + escalateDispute, + arbitrateDispute, +} from "../../controllers/stellar/refundController.js"; const router = express.Router(); @@ -15,6 +25,8 @@ const router = express.Router(); router.use(protect); // Payment flow +router.post("/quote", getQuote); +router.post("/preflight", getPaymentPreflight); router.post("/initialize", initializePayment); router.post("/submit", submitPayment); @@ -23,4 +35,16 @@ router.get("/transactions", getTransactionHistory); router.get("/transactions/:transactionId", getTransaction); router.delete("/transactions/:transactionId", cancelTransaction); +// Refund & Dispute flow +router.post("/transactions/:id/refund-request", requestRefund); +router.post("/refunds/:refundId/build", buildRefundXdr); +router.post("/refunds/:refundId/submit", submitRefund); +router.post("/refunds/:refundId/reject", rejectRefund); +router.post("/refunds/:refundId/dispute", escalateDispute); +router.patch( + "/refunds/:refundId/arbitrate", + authorizeRoles("admin", "arbiter"), + arbitrateDispute +); + export default router; diff --git a/src/routes/uploadRoutes.js b/src/routes/uploadRoutes.js new file mode 100644 index 00000000..ea002090 --- /dev/null +++ b/src/routes/uploadRoutes.js @@ -0,0 +1,9 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { generateSignature } from "../controllers/uploadController.js"; + +const router = express.Router(); + +router.post("/signature", protect, generateSignature); + +export default router; diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 552fc6fe..2d38387f 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -1,6 +1,6 @@ import express from "express"; import { protect } from "../middlewares/authMiddleware.js"; -import upload from "../middlewares/upload.js"; +import { uploadImage } from "../middlewares/upload.js"; import { updateUser, getUser, @@ -46,7 +46,13 @@ router.get( router.put( "/update/:id", protect, - upload.single("avatar"), + (req, res, next) => { + if (req.user._id.toString() !== req.params.id) { + return res.status(403).json({ success: false, message: "Not authorized to update this profile", data: null }); + } + next(); + }, + uploadImage.single("avatar"), invalidateCacheMiddleware([`${CACHE_KEYS.USER}*`]), updateUser ); diff --git a/src/routes/wellKnownRoutes.js b/src/routes/wellKnownRoutes.js new file mode 100644 index 00000000..11818887 --- /dev/null +++ b/src/routes/wellKnownRoutes.js @@ -0,0 +1,15 @@ +import express from "express"; +import { buildStellarToml } from "../services/stellar/stellarTomlService.js"; + +const router = express.Router(); + +router.get("/stellar.toml", (req, res) => { + // SEP-1 mandates CORS * and text/toml content type + res.set("Access-Control-Allow-Origin", "*"); + res.set("Content-Type", "text/toml; charset=utf-8"); + // Cache for 5 minutes — values are env-derived and rarely change at runtime + res.set("Cache-Control", "public, max-age=300"); + res.status(200).send(buildStellarToml()); +}); + +export default router; diff --git a/src/services/search/searchService.js b/src/services/search/searchService.js new file mode 100644 index 00000000..b79af29c --- /dev/null +++ b/src/services/search/searchService.js @@ -0,0 +1,167 @@ +import Course from "../../models/Course.js"; +import Book from "../../models/Book.js"; +import User from "../../models/User.js"; +import Space from "../../models/Space.js"; +import Reel from "../../models/Reel.js"; +import logger from "../../config/logger.js"; + +const escapeRegex = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const getSearchQuery = (q) => { + if (!q) return {}; + if (q.length < 3) { + const safeQ = escapeRegex(q); + return { + $or: [ + { title: { $regex: new RegExp(`^${safeQ}`, "i") } }, + { name: { $regex: new RegExp(`^${safeQ}`, "i") } }, + { description: { $regex: new RegExp(`^${safeQ}`, "i") } } + ] + }; + } + return { $text: { $search: q } }; +}; + +const applyFilters = (baseQuery, filters, allowedFilters) => { + const query = { ...baseQuery }; + if (allowedFilters.includes("category") && filters.category) { + query.category = filters.category; + } + if (allowedFilters.includes("price")) { + if (filters.free === "true") { + query.price = 0; + } else { + if (filters.minPrice) query.price = { ...query.price, $gte: Number(filters.minPrice) }; + if (filters.maxPrice) query.price = { ...query.price, $lte: Number(filters.maxPrice) }; + } + } + if (allowedFilters.includes("rating") && filters.minRating) { + query.rating = { $gte: Number(filters.minRating) }; + } + return query; +}; + +const getSortOption = (q, sort) => { + let sortOption = {}; + if (sort === "price") sortOption.price = 1; + else if (sort === "price_desc") sortOption.price = -1; + else if (sort === "rating") sortOption.rating = -1; + else if (q && q.length >= 3) { + sortOption.score = { $meta: "textScore" }; + } else { + sortOption.createdAt = -1; + } + return sortOption; +}; + +const getProjection = (q, baseProjection) => { + if (q && q.length >= 3) { + return { ...baseProjection, score: { $meta: "textScore" } }; + } + return baseProjection; +}; + +export const searchCollections = async ({ q, type = "all", page = 1, limit = 10, sort, filters = {} }) => { + const validPage = Math.max(1, Number(page) || 1); + const validLimit = Math.min(100, Math.max(1, Number(limit) || 10)); + const skip = (validPage - 1) * validLimit; + const parsedLimit = validLimit; + const searchQuery = getSearchQuery(q); + const sortOption = getSortOption(q, sort); + + const results = {}; + const pagination = {}; + + const searchModel = async (Model, queryBase, allowedFilters, projection, typeName) => { + const finalQuery = applyFilters({ ...queryBase, ...searchQuery }, filters, allowedFilters); + const finalProjection = getProjection(q, projection); + + let dbQuery = Model.find(finalQuery, finalProjection); + if (Object.keys(sortOption).length > 0) { + dbQuery = dbQuery.sort(sortOption); + } + + const [items, total] = await Promise.all([ + dbQuery.skip(skip).limit(parsedLimit).lean(), + Model.countDocuments(finalQuery) + ]); + + results[typeName] = items; + pagination[typeName] = { + total, + page: validPage, + limit: parsedLimit, + pages: Math.ceil(total / parsedLimit), + }; + }; + + const tasks = []; + + if (type === "all" || type === "courses") { + tasks.push(searchModel(Course, {}, ["category", "price"], { title: 1, description: 1, price: 1, thumbnail: 1, category: 1 }, "courses")); + } + if (type === "all" || type === "books") { + tasks.push(searchModel(Book, {}, ["category", "price", "rating"], { title: 1, description: 1, category: 1, price: 1, image: 1, author: 1 }, "books")); + } + if (type === "all" || type === "spaces") { + tasks.push(searchModel(Space, {}, ["category", "price"], { title: 1, description: 1, price: 1, status: 1, eventDate: 1, duration: 1, host: 1, category: 1 }, "spaces")); + } + if (type === "all" || type === "reels") { + tasks.push(searchModel(Reel, {}, [], { description: 1, createdBy: 1 }, "reels")); + } + if (type === "all" || type === "educators") { + tasks.push((async () => { + const educatorsRes = await searchEducators({ q, interest: filters.interest, page, limit }); + results.educators = educatorsRes.results; + pagination.educators = educatorsRes.pagination; + })()); + } + + await Promise.all(tasks); + + return { results, pagination }; +}; + +export const searchEducators = async ({ q, interest, page = 1, limit = 10 }) => { + const validPage = Math.max(1, Number(page) || 1); + const validLimit = Math.min(100, Math.max(1, Number(limit) || 10)); + const skip = (validPage - 1) * validLimit; + const parsedLimit = validLimit; + + let query = { role: "tutor" }; + if (q) { + if (q.length < 3) { + const safeQ = escapeRegex(q); + query.$or = [ + { name: { $regex: new RegExp(`^${safeQ}`, "i") } } + ]; + } else { + query.$text = { $search: q }; + } + } + if (interest) { + query.interests = interest; + } + + let dbQuery = User.find(query, getProjection(q, { name: 1, avatar: 1, bio: 1, stat: 1, interests: 1 })); + + const sortOption = getSortOption(q, "relevance"); + if (Object.keys(sortOption).length > 0) { + dbQuery = dbQuery.sort(sortOption); + } + + const [educators, total] = await Promise.all([ + dbQuery.skip(skip).limit(parsedLimit).lean(), + User.countDocuments(query) + ]); + + return { + results: educators, + pagination: { + total, + page: validPage, + limit: parsedLimit, + pages: Math.ceil(total / parsedLimit), + } + }; +}; diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index 4b3dc641..255aafc5 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -35,7 +35,7 @@ const PLATFORM_FEE_PERCENT = (() => { return percent; })(); -const STROOPS_PER_UNIT = 10000000n; +export const STROOPS_PER_UNIT = 10000000n; async function timedHorizonCall(operation, fn) { const start = Date.now(); @@ -76,6 +76,123 @@ export const fromStroops = (stroops) => { return frac ? `${whole}.${frac}` : whole.toString(); }; +export const applySlippage = (amount, bps) => { + const stroops = toStroops(amount); + const extra = (stroops * BigInt(bps)) / 10000n; + return fromStroops(stroops + extra); +}; + +const applySlippageStroops = (stroops, bps) => { + return stroops + (stroops * BigInt(bps)) / 10000n; +}; + +export const findPaymentPaths = async (sendAsset, destAmount) => { + try { + const records = await timedHorizonCall("strictReceivePaths", () => + server.strictReceivePaths([sendAsset], USDC, destAmount.toString()).call() + ); + return records.records; + } catch (error) { + if (error.response?.status === 400 || error.response?.status === 404) { + return []; + } + logger.error("Error finding payment paths:", error); + throw error; + } +}; + +const assetFromHorizonRecord = (record) => { + if (record.asset_type === "native") { + return StellarSdk.Asset.native(); + } + return new StellarSdk.Asset(record.asset_code, record.asset_issuer); +}; + +export const buildPathPaymentTransaction = async ({ + sourcePublicKey, + destinationPublicKey, + destAmount, + sendAsset, + sendMax, + path = [], + memo, + applyPlatformFee = false, +}) => { + try { + const sourceAccount = await timedHorizonCall("loadAccount", () => + server.loadAccount(sourcePublicKey) + ); + + const feeSplit = applyPlatformFee ? calculateFeeSplit(destAmount) : null; + const totalDestStroops = toStroops(destAmount); + const sendMaxStroops = toStroops(sendMax); + + const pathAssets = path.map(assetFromHorizonRecord); + + const builder = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }); + + if (feeSplit) { + const creatorDestStroops = toStroops(feeSplit.creatorAmount); + const creatorSendMaxStroops = + (sendMaxStroops * creatorDestStroops) / totalDestStroops; + const platformSendMaxStroops = sendMaxStroops - creatorSendMaxStroops; + + builder.addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax: fromStroops(creatorSendMaxStroops), + destination: destinationPublicKey, + destAsset: USDC, + destAmount: feeSplit.creatorAmount, + path: pathAssets, + }) + ); + + if (platformSendMaxStroops > 0n) { + builder.addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax: fromStroops(platformSendMaxStroops), + destination: feeSplit.platformWallet, + destAsset: USDC, + destAmount: feeSplit.platformAmount, + path: pathAssets, + }) + ); + } + } else { + builder.addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax: sendMax.toString(), + destination: destinationPublicKey, + destAsset: USDC, + destAmount: destAmount.toString(), + path: pathAssets, + }) + ); + } + + const transaction = builder + .addMemo(StellarSdk.Memo.text(memo || "DeenBridge Purchase")) + .setTimeout(300) + .build(); + + return { + xdr: transaction.toXDR(), + hash: transaction.hash().toString("hex"), + networkPassphrase, + feeSplit, + }; + } catch (error) { + logger.error("Error building path payment transaction:", error); + throw error; + } +}; + export const calculateFeeSplit = ( amount, feePercent = PLATFORM_FEE_PERCENT, @@ -127,21 +244,32 @@ export const isValidPublicKey = (publicKey) => { } }; +const parseAccountSummary = (account) => { + const usdcBalance = account.balances?.find( + (b) => b.asset_code === "USDC" && b.asset_issuer === USDC_ISSUER + ); + const xlmBalance = account.balances?.find((b) => b.asset_type === "native"); + + return { + xlmBalance: xlmBalance?.balance || "0", + usdcBalance: usdcBalance?.balance || "0", + hasTrustline: !!usdcBalance, + subentryCount: account.subentry_count ?? 0, + }; +}; + export const getAccountBalance = async (publicKey) => { try { const account = await timedHorizonCall("loadAccount", () => client.execute(server => server.loadAccount(publicKey)) ); - const usdcBalance = account.balances.find( - (b) => b.asset_code === "USDC" && b.asset_issuer === USDC_ISSUER - ); + const summary = parseAccountSummary(account); return { exists: true, - xlmBalance: - account.balances.find((b) => b.asset_type === "native")?.balance || "0", - usdcBalance: usdcBalance?.balance || "0", - hasTrustline: !!usdcBalance, + xlmBalance: summary.xlmBalance, + usdcBalance: summary.usdcBalance, + hasTrustline: summary.hasTrustline, }; } catch (error) { if (error.response?.status === 404) { @@ -157,6 +285,132 @@ export const getAccountBalance = async (publicKey) => { } }; +// 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) => { + const raw = account?.data_attr?.[MEMO_REQUIRED_DATA_KEY]; + if (!raw) return false; + try { + const decoded = Buffer.from(raw, "base64").toString("utf8").trim(); + return decoded === "1" || decoded.toLowerCase() === "true"; + } catch { + return false; + } +}; + +export const PREFLIGHT_REASON_CODES = Object.freeze({ + SOURCE_ACCOUNT_MISSING: "source_account_missing", + DESTINATION_ACCOUNT_MISSING: "destination_account_missing", + DESTINATION_NO_TRUSTLINE: "destination_no_trustline", + SOURCE_INSUFFICIENT_BALANCE: "source_insufficient_balance", + SOURCE_INSUFFICIENT_RESERVE: "source_insufficient_reserve", + DESTINATION_MEMO_REQUIRED: "destination_memo_required", +}); + +// Stellar protocol base reserve is 0.5 XLM per ledger entry (2 base entries +// per account, plus one more per subentry: trustlines, offers, signers, data). +const BASE_RESERVE_STROOPS = 5000000n; + +const loadAccountOrNull = async (publicKey) => { + try { + return await timedHorizonCall("loadAccount", () => + server.loadAccount(publicKey) + ); + } catch (error) { + if (error.response?.status === 404) { + return null; + } + throw error; + } +}; + +/** + * Validate a prospective USDC payment before an unsigned XDR is built, so the + * wallet is never asked to sign something that will bounce on submission. + * Only account-not-found is treated as a structured reason; other Horizon + * errors (network, rate limit) propagate to the caller. + */ +export const preflightPayment = async ({ + sourcePublicKey, + destinationPublicKey, + amount, + memo, + operationCount = 1, +}) => { + const reasons = []; + const warnings = []; + + const [sourceAccount, destinationAccount] = await Promise.all([ + loadAccountOrNull(sourcePublicKey), + loadAccountOrNull(destinationPublicKey), + ]); + + if (!sourceAccount) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.SOURCE_ACCOUNT_MISSING, + message: "Your Stellar account does not exist or is unfunded on the network.", + }); + } else { + const summary = parseAccountSummary(sourceAccount); + const requiredStroops = toStroops(amount); + const availableStroops = toStroops(summary.usdcBalance); + + if (availableStroops < requiredStroops) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_BALANCE, + message: "Your wallet does not hold enough USDC to complete this payment.", + }); + } + + const minReserveStroops = + (2n + BigInt(summary.subentryCount)) * BASE_RESERVE_STROOPS; + const feeStroops = BigInt(StellarSdk.BASE_FEE) * BigInt(operationCount); + const xlmAvailableStroops = toStroops(summary.xlmBalance); + + if (xlmAvailableStroops < minReserveStroops + feeStroops) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_RESERVE, + message: + "Your wallet does not hold enough XLM to cover the minimum reserve and network fee.", + }); + } + } + + if (!destinationAccount) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.DESTINATION_ACCOUNT_MISSING, + message: "Recipient account does not exist or is unfunded on the network.", + }); + } else { + const summary = parseAccountSummary(destinationAccount); + + if (!summary.hasTrustline) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.DESTINATION_NO_TRUSTLINE, + message: + "Recipient needs to add a USDC trustline to their wallet before they can receive this payment.", + }); + } + + if (isMemoRequired(destinationAccount) && !memo) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.DESTINATION_MEMO_REQUIRED, + message: + "Recipient requires a memo on incoming payments (SEP-29), commonly the case for exchange or custodial wallets.", + }); + } + } + + return { + ok: reasons.length === 0, + reasons, + warnings, + }; +}; + export const buildPaymentTransaction = async ({ sourcePublicKey, destinationPublicKey, @@ -219,6 +473,50 @@ export const buildPaymentTransaction = async ({ } }; +export const buildReversePaymentTransaction = async ({ + sourcePublicKey, + destinationPublicKey, + amount, + originalTxHash, +}) => { + try { + const sourceAccount = await timedHorizonCall("loadAccount", () => + server.loadAccount(sourcePublicKey) + ); + + const builder = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }); + + builder.addOperation( + StellarSdk.Operation.payment({ + destination: destinationPublicKey, + asset: USDC, + amount: amount.toString(), + }) + ); + + const memoText = originalTxHash + ? `RFND:${originalTxHash.slice(0, 20)}` + : "DeenBridge Refund"; + + const transaction = builder + .addMemo(StellarSdk.Memo.text(memoText)) + .setTimeout(300) + .build(); + + return { + xdr: transaction.toXDR(), + hash: transaction.hash().toString("hex"), + networkPassphrase, + }; + } catch (error) { + logger.error("Error building reverse payment transaction:", error); + throw error; + } +}; + export const submitTransaction = async (signedXdr) => { try { const transaction = StellarSdk.TransactionBuilder.fromXDR( @@ -251,6 +549,15 @@ export const submitTransaction = async (signedXdr) => { if (codes.operations?.includes("op_underfunded")) { throw new Error("Insufficient USDC balance"); } + if ( + codes.operations?.some( + (c) => + typeof c === "string" && + (c.includes("over_sendmax") || c.includes("over_source_max")) + ) + ) { + throw new Error("Price moved, request a new quote"); + } if (codes.operations?.includes("op_no_trust")) { throw new Error( "Recipient does not have a USDC trustline. They need to add USDC to their wallet first." @@ -295,25 +602,37 @@ export const verifyPaymentOperations = async (txHash, expectedPayments) => { const verification = await verifyTransaction(txHash); if (!verification.exists) { - return { verified: false, reason: "Transaction not found on network" }; + return { verified: false, transient: true, reason: "Transaction not found on network" }; } if (!verification.successful) { return { verified: false, reason: "Transaction was not successful" }; } - const paymentOps = verification.operations.filter( - (op) => - op.type === "payment" && - op.asset_code === "USDC" && - op.asset_issuer === USDC_ISSUER - ); + const paymentOps = verification.operations.filter((op) => { + if (op.type === "payment") { + return ( + op.asset_code === "USDC" && op.asset_issuer === USDC_ISSUER + ); + } + if (op.type === "path_payment_strict_receive") { + return ( + op.destination_asset_code === "USDC" && + op.destination_asset_issuer === USDC_ISSUER + ); + } + return false; + }); for (const expected of expectedPayments) { - const match = paymentOps.find( - (op) => - op.to === expected.destination && - toStroops(op.amount) === toStroops(expected.amount) - ); + const match = paymentOps.find((op) => { + if (op.to !== expected.destination) return false; + + const opAmount = + op.type === "path_payment_strict_receive" + ? op.destination_amount + : op.amount; + return toStroops(opAmount) === toStroops(expected.amount); + }); if (!match) { return { verified: false, @@ -325,7 +644,7 @@ export const verifyPaymentOperations = async (txHash, expectedPayments) => { return { verified: true }; } catch (error) { logger.error("Error verifying payment operations:", error); - return { verified: false, reason: "Verification failed" }; + return { verified: false, transient: true, reason: "Verification failed" }; } }; diff --git a/src/services/stellar/stellarTomlService.js b/src/services/stellar/stellarTomlService.js new file mode 100644 index 00000000..ebbeefd7 --- /dev/null +++ b/src/services/stellar/stellarTomlService.js @@ -0,0 +1,90 @@ +import { + USDC_ISSUER, + networkPassphrase, +} from "./stellarService.js"; + +const CURRENCIES = [ + { + code: "USDC", + issuer: USDC_ISSUER, + status: "live", + is_asset_anchored: true, + anchor_asset_type: "fiat", + anchor_asset: "USD", + desc: "USD Coin — a regulated stablecoin pegged 1:1 to the US dollar", + display_decimals: 7, + name: "USD Coin", + }, +]; + +const quoteTomlString = (val) => JSON.stringify(String(val)); + +export function buildStellarToml() { + const lines = []; + + // ── Header ── + lines.push(`# DeenBridge Stellar TOML (SEP-1)`); + lines.push(`VERSION = "2.6.0"`); + lines.push(`NETWORK_PASSPHRASE = "${networkPassphrase}"`); + lines.push(``); + + // ── ACCOUNTS ── + // Use strictly STELLAR_PLATFORM_PUBLIC_KEY; omit ACCOUNTS entirely if unset/blank + const platformKey = process.env.STELLAR_PLATFORM_PUBLIC_KEY; + if (platformKey && platformKey.trim() !== "") { + lines.push(`ACCOUNTS = ["${platformKey.trim()}"]`); + lines.push(``); + } + + // ── SEP endpoint hooks ── + // Emit SIGNING_KEY only if it is a valid public key starting with G; never emit secret seeds or invalid values + const signingKey = process.env.SIGNING_KEY; + const isValidPublicKey = + typeof signingKey === "string" && /^G[A-Z2-7]{55}$/.test(signingKey.trim()); + + if (isValidPublicKey) { + lines.push(`SIGNING_KEY = "${signingKey.trim()}"`); + } else { + lines.push(`# SIGNING_KEY = "G..." # Populated by SEP-10 (#25)`); + } + lines.push(`# WEB_AUTH_ENDPOINT = "..." # Populated by SEP-10 (#25)`); + lines.push(`# TRANSFER_SERVER_SEP0024 = "..." # Populated by SEP-24 (#46)`); + lines.push(``); + + // ── [DOCUMENTATION] — all fields env-driven, block omitted if nothing set ── + const docFields = [ + ["ORG_NAME", process.env.ORG_NAME], + ["ORG_URL", process.env.ORG_URL], + ["ORG_DESCRIPTION", process.env.ORG_DESCRIPTION], + ["ORG_LOGO", process.env.ORG_LOGO], + ["ORG_TWITTER", process.env.ORG_TWITTER], + ["ORG_GITHUB", process.env.ORG_GITHUB], + ]; + + const setDocFields = docFields.filter(([, value]) => value !== undefined && value !== null && value !== ""); + + if (setDocFields.length > 0) { + lines.push(`[DOCUMENTATION]`); + for (const [key, value] of setDocFields) { + lines.push(`${key} = ${quoteTomlString(value)}`); + } + lines.push(``); + } + + // ── [[CURRENCIES]] — iterable for future multi-asset support (#18) ── + for (const currency of CURRENCIES) { + lines.push(`[[CURRENCIES]]`); + lines.push(`code = "${currency.code}"`); + lines.push(`issuer = "${currency.issuer}"`); + lines.push(`status = "${currency.status}"`); + lines.push(`is_asset_anchored = ${currency.is_asset_anchored}`); + lines.push(`anchor_asset_type = "${currency.anchor_asset_type}"`); + lines.push(`anchor_asset = "${currency.anchor_asset}"`); + lines.push(`desc = ${quoteTomlString(currency.desc)}`); + lines.push(`display_decimals = ${currency.display_decimals}`); + lines.push(`name = ${quoteTomlString(currency.name)}`); + lines.push(``); + } + + return lines.join("\n"); +} diff --git a/src/utils/fileValidation.js b/src/utils/fileValidation.js new file mode 100644 index 00000000..a596e740 --- /dev/null +++ b/src/utils/fileValidation.js @@ -0,0 +1,34 @@ +import { fileTypeFromBuffer } from "file-type"; +import logger from "../config/logger.js"; + +/** + * Validates the magic bytes of a file buffer against a list of allowed MIME types. + * @param {Buffer} buffer - The file buffer to check. + * @param {string[]} allowedMimeTypes - Array of allowed MIME types (e.g., ['image/jpeg', 'application/pdf']). + * @returns {Promise} True if the file type matches one of the allowed types. + */ +export const validateMagicBytes = async (buffer, allowedMimeTypes) => { + if (!buffer || buffer.length === 0) { + logger.warn("Empty buffer provided for magic byte validation"); + return false; + } + + try { + const type = await fileTypeFromBuffer(buffer); + + if (!type) { + logger.warn("Could not determine file type from buffer"); + return false; + } + + const isValid = allowedMimeTypes.includes(type.mime); + if (!isValid) { + logger.warn(`File type mismatch: expected one of [${allowedMimeTypes.join(", ")}], got ${type.mime}`); + } + + return isValid; + } catch (error) { + logger.error("Error validating magic bytes:", error); + return false; + } +}; diff --git a/test/app.test.js b/test/app.test.js index 41bc297b..acc40e2f 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -1,5 +1,6 @@ import request from "supertest"; import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; import app from "../app.js"; import { calculateFeeSplit, @@ -13,14 +14,26 @@ import { paymentsFailed, } from "../src/config/metrics.js"; -// app.js skips connectDB() under NODE_ENV=test; routes that touch the DB need -// a live connection, so this suite manages its own. +let mongoServer; + beforeAll(async () => { - await mongoose.connect(process.env.MONGO_URI); -}); + if (process.env.MONGO_URI && !process.env.MONGO_URI.includes("localhost")) { + try { + await mongoose.connect(process.env.MONGO_URI); + return; + } catch (_err) { + // Fallback to MongoMemoryServer + } + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); +}, 30000); afterAll(async () => { await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } }); // Read a labeled counter value via prom-client's public API (internals like diff --git a/test/auth.test.js b/test/auth.test.js index 740d381c..1cb5dbc3 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -366,4 +366,8 @@ describe("Authentication & Session Management", () => { expect(activeSessions.length).toBe(0); }); }); + + afterAll(() => { + jest.restoreAllMocks(); + }); }); diff --git a/test/authRoles.test.js b/test/authRoles.test.js new file mode 100644 index 00000000..6c2333b7 --- /dev/null +++ b/test/authRoles.test.js @@ -0,0 +1,286 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import Space from "../src/models/Space.js"; +import Course from "../src/models/Course.js"; +import "../src/jobs/handlers.js"; +import { protect, authorize, restrictTo } from "../src/middlewares/authMiddleware.js"; +import { registerUser } from "../src/controllers/authController.js"; +import { deleteBook } from "../src/controllers/books/bookController.js"; +import { deleteSpace, updateSpace } from "../src/controllers/spaceController.js"; +import { updateUser, deleteUser } from "../src/controllers/userController.js"; +import { updateCourse } from "../src/controllers/courses/courseController.js"; + +describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Book.deleteMany({}); + await Space.deleteMany({}); + await Course.deleteMany({}); + }); + + describe("User Model Role Enum Validation", () => { + it("accepts valid canonical roles: student, tutor, mentor, admin, arbiter", async () => { + const validRoles = ["student", "tutor", "mentor", "admin", "arbiter"]; + for (const role of validRoles) { + const u = await User.create({ + name: `User ${role}`, + email: `${role}@example.com`, + password: "password123", + role, + }); + expect(u.role).toBe(role); + } + }); + + it("rejects invalid role strings", async () => { + await expect( + User.create({ + name: "Hacker User", + email: "hacker@example.com", + password: "password123", + role: "superadmin_hacker", + }) + ).rejects.toThrow(); + }); + }); + + describe("Authorize / RestrictTo Middleware", () => { + it("allows access when user has one of the allowed roles", async () => { + const app = express(); + app.get( + "/admin-only", + (req, _res, next) => { + req.user = { role: "admin" }; + next(); + }, + authorize("admin"), + (_req, res) => res.status(200).json({ success: true, message: "Welcome Admin" }) + ); + + const res = await request(app).get("/admin-only"); + expect(res.status).toBe(200); + expect(res.body.message).toBe("Welcome Admin"); + }); + + it("blocks access with 403 Forbidden when user does not have required role", async () => { + const app = express(); + app.get( + "/mentor-only", + (req, _res, next) => { + req.user = { role: "student" }; + next(); + }, + restrictTo("mentor", "tutor", "admin"), + (_req, res) => res.status(200).json({ success: true }) + ); + + const res = await request(app).get("/mentor-only"); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.message).toContain("Forbidden"); + }); + }); + + describe("Registration Anti-Privilege Escalation", () => { + it("prevents self-assignment of admin or arbiter roles during registration", async () => { + const app = express(); + app.use(express.json()); + app.post("/register", registerUser); + + // Attempt to register as admin + const resAdmin = await request(app).post("/register").send({ + name: "Self Admin", + email: "self_admin@example.com", + password: "password123", + role: "admin", + }); + + expect(resAdmin.status).toBe(201); + const createdAdminUser = await User.findOne({ email: "self_admin@example.com" }); + expect(createdAdminUser.role).toBe("student"); + + // Attempt to register as arbiter + const resArbiter = await request(app).post("/register").send({ + name: "Self Arbiter", + email: "self_arbiter@example.com", + password: "password123", + role: "arbiter", + }); + + expect(resArbiter.status).toBe(201); + const createdArbiterUser = await User.findOne({ email: "self_arbiter@example.com" }); + expect(createdArbiterUser.role).toBe("student"); + }); + + it("allows tutor or mentor roles during registration", async () => { + const app = express(); + app.use(express.json()); + app.post("/register", registerUser); + + const resTutor = await request(app).post("/register").send({ + name: "Self Tutor", + email: "self_tutor@example.com", + password: "password123", + role: "tutor", + }); + + expect(resTutor.status).toBe(201); + const createdTutorUser = await User.findOne({ email: "self_tutor@example.com" }); + expect(createdTutorUser.role).toBe("tutor"); + }); + }); + + describe("Owner or Admin Content & Account Authorization Gating", () => { + let studentUser, authorUser, adminUser, testBook, testSpace; + + beforeEach(async () => { + studentUser = await User.create({ + name: "Student", + email: "student_auth@example.com", + password: "password123", + role: "student", + }); + + authorUser = await User.create({ + name: "Author", + email: "author_auth@example.com", + password: "password123", + role: "tutor", + }); + + adminUser = await User.create({ + name: "Admin", + email: "admin_auth@example.com", + password: "password123", + role: "admin", + }); + + testBook = await Book.create({ + title: "Test Book", + description: "Test Description", + category: "Tech", + price: 10, + author: authorUser._id, + thumbnail: "https://example.com/thumb.jpg", + image: "https://example.com/thumb.jpg", + fileUrl: "https://example.com/file.pdf", + }); + + testSpace = await Space.create({ + title: "Test Space", + description: "Test Description", + category: "Tech", + host: authorUser._id, + price: 0, + eventDate: new Date(), + eventTime: "10:00 AM", + duration: 60, + }); + }); + + it("rejects book deletion by a non-author non-admin with 403 Forbidden", async () => { + const app = express(); + app.use((req, _res, next) => { + req.user = studentUser; + next(); + }); + app.delete("/books/:id", deleteBook); + + const res = await request(app).delete(`/books/${testBook._id}`); + expect(res.status).toBe(403); + expect(res.body.message).toContain("Not authorized to delete this book"); + + // Verify book still exists + const bookExists = await Book.findById(testBook._id); + expect(bookExists).not.toBeNull(); + }); + + it("allows book deletion by author or admin", async () => { + const appAuthor = express(); + appAuthor.use((req, _res, next) => { + req.user = authorUser; + next(); + }); + appAuthor.delete("/books/:id", deleteBook); + + const resAuthor = await request(appAuthor).delete(`/books/${testBook._id}`); + expect(resAuthor.status).toBe(200); + expect(resAuthor.body.message).toBe("Book deleted"); + + // Recreate book and test admin deletion + const newBook = await Book.create({ + title: "New Book", + description: "New Description", + category: "Tech", + price: 10, + author: authorUser._id, + thumbnail: "https://example.com/thumb.jpg", + image: "https://example.com/thumb.jpg", + fileUrl: "https://example.com/file.pdf", + }); + + const appAdmin = express(); + appAdmin.use((req, _res, next) => { + req.user = adminUser; + next(); + }); + appAdmin.delete("/books/:id", deleteBook); + + const resAdmin = await request(appAdmin).delete(`/books/${newBook._id}`); + expect(resAdmin.status).toBe(200); + }); + + it("rejects space deletion and update by a non-host non-admin with 403 Forbidden", async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = studentUser; + next(); + }); + app.delete("/spaces/:id", deleteSpace); + app.put("/spaces/:id", updateSpace); + + const resDelete = await request(app).delete(`/spaces/${testSpace._id}`); + expect(resDelete.status).toBe(403); + + const resUpdate = await request(app).put(`/spaces/${testSpace._id}`).send({ title: "Updated Title" }); + expect(resUpdate.status).toBe(403); + }); + + it("rejects profile update and account deletion of another user with 403 Forbidden", async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = studentUser; + next(); + }); + app.put("/users/:id", updateUser); + app.delete("/users/:id", deleteUser); + + const resUpdate = await request(app).put(`/users/${authorUser._id}`).send({ name: "Hacked Name" }); + expect(resUpdate.status).toBe(403); + + const resDelete = await request(app).delete(`/users/${authorUser._id}`); + expect(resDelete.status).toBe(403); + }); + }); +}); diff --git a/test/bookUpload.test.js b/test/bookUpload.test.js new file mode 100644 index 00000000..c793f38a --- /dev/null +++ b/test/bookUpload.test.js @@ -0,0 +1,142 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import { PassThrough } from "stream"; + +import app from "../app.js"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import cloudinary from "../src/utils/cloudinary.js"; + +import * as fileValidation from "../src/utils/fileValidation.js"; + +// Need realistic magic bytes for file-type detection +const validPdfBytes = Buffer.from("%PDF-1.4\n%EOF\n"); +const validImageBytes = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01]); + +describe("Media Upload Hardening", () => { + jest.setTimeout(30000); + let token; + let testUser; + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + // Mock cloudinary upload stream + jest.spyOn(cloudinary.uploader, "upload_stream").mockImplementation((options, cb) => { + const pass = new PassThrough(); + pass.on('data', () => {}); // Consume data to prevent backpressure + pass.on('end', () => cb(null, { secure_url: "https://example.com/file", public_id: "mock_public_id" })); + return pass; + }); + + jest.spyOn(cloudinary.utils, "private_download_url").mockImplementation(() => { + return "https://example.com/signed-url"; + }); + + const authRes = await request(app).post("/api/auth/register").send({ name: "Uploader", email: "uploader@example.com", password: "password", role: "student" }); + token = authRes.body.accessToken; + testUser = authRes.body.user; + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + jest.restoreAllMocks(); + }); + + it("should reject oversized files (Multer limits)", async () => { + const largeBuffer = Buffer.alloc(55 * 1024 * 1024); // 55MB (limit is 50MB) + + const res = await request(app) + .post("/api/books") + .set("Authorization", `Bearer ${token}`) + .attach("thumbnail", largeBuffer, "large.jpg") + .attach("file", validPdfBytes, "book.pdf") + .field("title", "Test Book") + .field("category", "Test") + .field("price", 10) + .field("description", "Test Description"); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/File too large/i); + }); + + it("should reject mismatched magic bytes (server validation)", async () => { + const res = await request(app) + .post("/api/books") + .set("Authorization", `Bearer ${token}`) + .attach("thumbnail", validImageBytes, "thumb.jpg") + .attach("file", Buffer.from("this is a fake pdf text file"), "book.pdf") + .field("title", "Fake PDF") + .field("category", "Test") + .field("price", 10) + .field("description", "Test Description"); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/Invalid file content/i); + }); + + it("should upload a valid book and store filePublicId", async () => { + const res = await request(app) + .post("/api/books") + .set("Authorization", `Bearer ${token}`) + .attach("thumbnail", validImageBytes, "thumb.jpg") + .attach("file", validPdfBytes, "book.pdf") + .field("title", "Valid Book") + .field("category", "Test") + .field("price", 10) + .field("description", "Test Description"); + + if (res.status === 500) { + console.log("500 Body:", res.body); + } + expect(res.status).toBe(201); + expect(res.body.data).toHaveProperty("filePublicId"); + }); + + it("should deny unentitled user access to paid book", async () => { + const book = await Book.create({ + title: "Paid Book", + author: testUser.id || testUser._id, + category: "Test", + price: 10, + description: "Desc", + image: "url", + fileUrl: "url", + filePublicId: "paid_public_id" + }); + + const authRes = await request(app).post("/api/auth/register").send({ name: "Poor", email: "poor@example.com", password: "password", role: "student" }); + + const res = await request(app) + .get(`/api/books/${book._id}/preview`) + .set("Authorization", `Bearer ${authRes.body.accessToken}`); + + expect(res.status).toBe(403); + expect(res.body.message).toMatch(/do not have access/i); + }); + + it("should provide signed URL to entitled user", async () => { + const book = await Book.create({ + title: "My Paid Book", + author: testUser.id || testUser._id, // testUser is the author, so they are entitled + category: "Test", + price: 10, + description: "Desc", + image: "url", + fileUrl: "url", + filePublicId: "paid_public_id" + }); + + const res = await request(app) + .get(`/api/books/${book._id}/preview`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe("https://example.com/signed-url"); + }); +}); diff --git a/test/jest.setup.js b/test/jest.setup.js new file mode 100644 index 00000000..964d1cf6 --- /dev/null +++ b/test/jest.setup.js @@ -0,0 +1,17 @@ +import dotenv from "dotenv"; +import path from "path"; +import fs from "fs"; + +// Load test environment variables from .env.test if it exists +// In CI environments, these should be provided via environment variables +const envPath = path.resolve(process.cwd(), ".env.test"); +if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }); +} else { + // If no .env.test is found, we assume environment variables are set (e.g. in CI) + // We'll also just call dotenv.config() as a fallback + dotenv.config(); +} + +// Force NODE_ENV to test to ensure we don't accidentally connect to production +process.env.NODE_ENV = "test"; diff --git a/test/jobHandlers.test.js b/test/jobHandlers.test.js new file mode 100644 index 00000000..2db1e399 --- /dev/null +++ b/test/jobHandlers.test.js @@ -0,0 +1,97 @@ +import { jest } from "@jest/globals"; + +const registered = new Map(); +const enqueue = jest.fn().mockResolvedValue({ queued: true }); +const findById = jest.fn(); +const verifyPaymentOperations = jest.fn(); + +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + registerJob: (name, handler) => registered.set(name, handler), + enqueue, +})); +jest.unstable_mockModule("../src/models/Transaction.js", () => ({ + default: { findById }, +})); +jest.unstable_mockModule("../src/models/User.js", () => ({ + default: { findById: jest.fn(), updateOne: jest.fn() }, +})); +jest.unstable_mockModule("../src/models/Course.js", () => ({ + default: { updateOne: jest.fn() }, +})); +jest.unstable_mockModule("../services/emails/sendMail.js", () => ({ + sendOtpEmail: jest.fn(), + sendReceiptEmail: jest.fn(), +})); +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + verifyPaymentOperations, + getExplorerUrl: (hash) => `https://explorer/${hash}`, +})); +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings: jest.fn(), +})); + +await import("../src/jobs/handlers.js"); + +const transaction = () => ({ + _id: { toString: () => "transaction-id" }, + type: "donation", + status: "retrying", + stellarTxHash: "stellar-hash", + creatorWallet: "destination", + amount: "10", + save: jest.fn().mockResolvedValue(), +}); + +describe("verifyPaymentOnChain job", () => { + const handler = registered.get("verifyPaymentOnChain"); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("keeps transient Horizon failures retryable", async () => { + const record = transaction(); + findById.mockResolvedValue(record); + verifyPaymentOperations.mockResolvedValue({ + verified: false, + transient: true, + reason: "Horizon timeout", + }); + + await expect(handler({ transactionId: "transaction-id" }, { attempt: 1, maxAttempts: 3 })) + .rejects.toThrow("Horizon timeout"); + + expect(record.status).toBe("retrying"); + expect(record.retryCount).toBe(1); + }); + + it("definitively fails after the final transient attempt", async () => { + const record = transaction(); + findById.mockResolvedValue(record); + verifyPaymentOperations.mockResolvedValue({ + verified: false, + transient: true, + reason: "Transaction not found on network", + }); + + await handler({ transactionId: "transaction-id" }, { attempt: 3, maxAttempts: 3 }); + + expect(record.status).toBe("failed"); + expect(record.failureReason).toContain("Transaction not found"); + }); + + it("confirms a verified transaction and enqueues one receipt", async () => { + const record = transaction(); + findById.mockResolvedValue(record); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + + await handler({ transactionId: "transaction-id" }, { attempt: 2, maxAttempts: 3 }); + + expect(record.status).toBe("confirmed"); + expect(enqueue).toHaveBeenCalledWith( + "generateReceipt", + { transactionId: "transaction-id" }, + expect.objectContaining({ idempotencyKey: "receipt:stellar-hash" }) + ); + }); +}); diff --git a/test/jobs.test.js b/test/jobs.test.js new file mode 100644 index 00000000..a4af8c4e --- /dev/null +++ b/test/jobs.test.js @@ -0,0 +1,66 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import app from "../app.js"; +import { + enqueue, + registerJob, + resetInlineQueueForTests, + waitForIdle, +} from "../src/jobs/queue.js"; + +describe("background job queue", () => { + beforeEach(() => { + process.env.QUEUE_DRIVER = "inline"; + resetInlineQueueForTests(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("deduplicates jobs with the same idempotency key", async () => { + const handler = jest.fn(); + registerJob("test-idempotency", handler); + + const first = await enqueue("test-idempotency", { recordId: "one" }, { idempotencyKey: "same" }); + const second = await enqueue("test-idempotency", { recordId: "one" }, { idempotencyKey: "same" }); + await waitForIdle(); + + expect(first.queued).toBe(true); + expect(second.duplicate).toBe(true); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("retries failed work with backoff", async () => { + jest.useFakeTimers(); + const handler = jest + .fn() + .mockRejectedValueOnce(new Error("temporary outage")) + .mockRejectedValueOnce(new Error("temporary outage")) + .mockResolvedValueOnce(); + registerJob("test-retry", handler); + + await enqueue( + "test-retry", + { recordId: "retry-me" }, + { attempts: 3, backoffMs: 100, idempotencyKey: "retry-once" } + ); + await jest.runAllTimersAsync(); + await waitForIdle(); + + expect(handler).toHaveBeenCalledTimes(3); + expect(handler.mock.calls.map((call) => call[1].attempt)).toEqual([1, 2, 3]); + }); + + it("protects the jobs dashboard with a bearer token", async () => { + process.env.JOBS_DASHBOARD_TOKEN = "dashboard-secret"; + + const missing = await request(app).get("/admin/jobs"); + const wrong = await request(app) + .get("/admin/jobs") + .set("Authorization", "Bearer wrong"); + + expect(missing.statusCode).toBe(401); + expect(wrong.statusCode).toBe(401); + }); +}); diff --git a/test/notification.test.js b/test/notification.test.js new file mode 100644 index 00000000..405ac7a5 --- /dev/null +++ b/test/notification.test.js @@ -0,0 +1,282 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import Notification from "../src/models/Notification.js"; +import User from "../src/models/User.js"; +import Course from "../src/models/Course.js"; +import Book from "../src/models/Book.js"; +import { + sseNotifications, + getUserNotifications, + createFollowNotification, + createUnfollowNotification, + createNewCourseNotification, + createNewBookNotification, +} from "../src/controllers/notificationController.js"; + +describe("Notification System & Event Wiring", () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await Notification.deleteMany({}); + await User.deleteMany({}); + await Course.deleteMany({}); + await Book.deleteMany({}); + }); + + describe("Mongoose Schema Compound Indexes", () => { + it("has expected compound indexes declared on Notification schema", () => { + const indexes = Notification.schema.indexes(); + const indexFields = indexes.map(([spec]) => Object.keys(spec).join(",")); + + expect(indexFields).toContain("recipient,createdAt"); + expect(indexFields).toContain("recipient,isRead"); + expect(indexFields).toContain("recipient,isDeleted"); + }); + }); + + describe("Event Producer Notifications", () => { + it("creates a follow notification for the followed user", async () => { + const follower = await User.create({ + name: "Follower User", + email: "follower@example.com", + password: "password123", + }); + + const followed = await User.create({ + name: "Followed User", + email: "followed@example.com", + password: "password123", + }); + + await createFollowNotification(follower._id, followed._id); + + const notifs = await Notification.find({ recipient: followed._id }); + expect(notifs).toHaveLength(1); + expect(notifs[0].type).toBe("follow"); + expect(notifs[0].sender.toString()).toBe(follower._id.toString()); + expect(notifs[0].message).toContain("Follower User started following you"); + }); + + it("creates an unfollow notification for the unfollowed user", async () => { + const unfollower = await User.create({ + name: "Unfollower User", + email: "unfollower@example.com", + password: "password123", + }); + + const unfollowed = await User.create({ + name: "Unfollowed User", + email: "unfollowed@example.com", + password: "password123", + }); + + await createUnfollowNotification(unfollower._id, unfollowed._id); + + const notifs = await Notification.find({ recipient: unfollowed._id }); + expect(notifs).toHaveLength(1); + expect(notifs[0].type).toBe("unfollow"); + expect(notifs[0].sender.toString()).toBe(unfollower._id.toString()); + expect(notifs[0].message).toContain("Unfollower User unfollowed you"); + }); + + it("creates batched new_course notifications for all followers", async () => { + const follower1 = await User.create({ + name: "Follower 1", + email: "f1@example.com", + password: "password123", + }); + const follower2 = await User.create({ + name: "Follower 2", + email: "f2@example.com", + password: "password123", + }); + + const creator = await User.create({ + name: "Course Creator", + email: "creator@example.com", + password: "password123", + followers: [follower1._id, follower2._id], + }); + + const course = await Course.create({ + title: "Mastering Node.js", + description: "Comprehensive Node.js course", + category: "Programming", + createdBy: creator._id, + }); + + const result = await createNewCourseNotification( + course._id, + creator._id, + course.title + ); + + expect(result).toHaveLength(2); + + const notifs = await Notification.find({ sender: creator._id }); + expect(notifs).toHaveLength(2); + const recipientIds = notifs.map((n) => n.recipient.toString()); + expect(recipientIds).toContain(follower1._id.toString()); + expect(recipientIds).toContain(follower2._id.toString()); + expect(notifs[0].type).toBe("new_course"); + expect(notifs[0].message).toContain("Course Creator created a new course: Mastering Node.js"); + }); + + it("creates batched new_book notifications for all followers", async () => { + const follower1 = await User.create({ + name: "Book Reader 1", + email: "r1@example.com", + password: "password123", + }); + const follower2 = await User.create({ + name: "Book Reader 2", + email: "r2@example.com", + password: "password123", + }); + + const author = await User.create({ + name: "Book Author", + email: "author@example.com", + password: "password123", + followers: [follower1._id, follower2._id], + }); + + const book = await Book.create({ + title: "Clean Architecture in JS", + description: "Guide to scalable code architecture", + category: "Tech", + price: 15, + author: author._id, + thumbnail: "https://example.com/thumb.jpg", + image: "https://example.com/thumb.jpg", + fileUrl: "https://example.com/book.pdf", + }); + + const result = await createNewBookNotification( + book._id, + author._id, + book.title + ); + + expect(result).toHaveLength(2); + + const notifs = await Notification.find({ sender: author._id }); + expect(notifs).toHaveLength(2); + const recipientIds = notifs.map((n) => n.recipient.toString()); + expect(recipientIds).toContain(follower1._id.toString()); + expect(recipientIds).toContain(follower2._id.toString()); + expect(notifs[0].type).toBe("new_book"); + expect(notifs[0].message).toContain("Book Author published a new book: Clean Architecture in JS"); + }); + }); + + describe("getUserNotifications Pagination & Bounding", () => { + it("caps limit to 100 and validates invalid page/limit params", async () => { + const recipient = await User.create({ + name: "Recipient User", + email: "recipient@example.com", + password: "password123", + }); + + const sender = await User.create({ + name: "Sender User", + email: "sender@example.com", + password: "password123", + }); + + // Insert 120 notifications for recipient + const docs = Array.from({ length: 120 }, (_, i) => ({ + recipient: recipient._id, + sender: sender._id, + type: "system", + title: `Notification ${i + 1}`, + message: `Message ${i + 1}`, + })); + + await Notification.insertMany(docs); + + // Request with limit=500 -> should be capped at 100 + const reqLarge = { + user: { _id: recipient._id }, + query: { limit: "500", page: "1" }, + }; + + const resLarge = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + await getUserNotifications(reqLarge, resLarge); + + expect(resLarge.status).toHaveBeenCalledWith(200); + const dataLarge = resLarge.json.mock.calls[0][0]; + expect(dataLarge.notifications).toHaveLength(100); + expect(dataLarge.pagination.currentPage).toBe(1); + + // Request with negative page and non-numeric limit -> should default to page=1, limit=20 + const reqInvalid = { + user: { _id: recipient._id }, + query: { limit: "invalid", page: "-5" }, + }; + + const resInvalid = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + await getUserNotifications(reqInvalid, resInvalid); + + const dataInvalid = resInvalid.json.mock.calls[0][0]; + expect(dataInvalid.notifications).toHaveLength(20); + expect(dataInvalid.pagination.currentPage).toBe(1); + }); + }); + + describe("SSE Connection & Delivery", () => { + it("establishes SSE connection stream and handles payload delivery", async () => { + const user = await User.create({ + name: "SSE User", + email: "sse@example.com", + password: "password123", + }); + + const writeHeadMock = jest.fn(); + const writeMock = jest.fn(); + const reqMock = { + user: { _id: user._id }, + on: jest.fn(), + }; + const resMock = { + writeHead: writeHeadMock, + write: writeMock, + }; + + await sseNotifications(reqMock, resMock); + + expect(writeHeadMock).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "Content-Type": "text/event-stream", + }) + ); + expect(writeMock).toHaveBeenCalledWith( + expect.stringContaining("Connected to notifications") + ); + }); + }); +}); diff --git a/test/pathPayments.test.js b/test/pathPayments.test.js new file mode 100644 index 00000000..701e7df8 --- /dev/null +++ b/test/pathPayments.test.js @@ -0,0 +1,172 @@ +import * as StellarSdk from "@stellar/stellar-sdk"; +import { + applySlippage, + calculateFeeSplit, + toStroops, + fromStroops, +} from "../src/services/stellar/stellarService.js"; + +const PLATFORM_WALLET = + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +describe("applySlippage", () => { + it("adds 100 bps (1%) to an amount", () => { + expect(applySlippage("100", 100)).toBe("101"); + }); + + it("adds 50 bps (0.5%) to an amount", () => { + expect(applySlippage("200", 50)).toBe("201"); + }); + + it("handles fractional amounts with 7-decimal precision", () => { + expect(applySlippage("105.5", 100)).toBe("106.555"); + }); + + it("handles 500 bps (5%)", () => { + expect(applySlippage("100", 500)).toBe("105"); + }); + + it("handles 10 bps (0.1%)", () => { + expect(applySlippage("1000", 10)).toBe("1001"); + }); + + it("handles zero bps (no slippage)", () => { + expect(applySlippage("50", 0)).toBe("50"); + }); + + it("produces stroop-exact results", () => { + const result = applySlippage("0.0000001", 100); + const stroops = toStroops(result); + expect(stroops).toBe( + toStroops("0.0000001") + toStroops("0.0000001") * 100n / 10000n + ); + }); + + it("creates a valid sendMax for the quote use case", () => { + const sourceAmount = "525.5"; + const sendMax = applySlippage(sourceAmount, 100); + const sourceStroops = toStroops(sourceAmount); + const sendMaxStroops = toStroops(sendMax); + const diff = sendMaxStroops - sourceStroops; + expect(diff).toBe(sourceStroops * 100n / 10000n); + }); +}); + +describe("pathPaymentStrictReceive XDR shape (built directly via SDK)", () => { + it("builds a transaction with a path_payment_strict_receive operation", () => { + const source = StellarSdk.Keypair.random(); + const account = new StellarSdk.Account(source.publicKey(), "1234"); + + const usdc = new StellarSdk.Asset( + "USDC", + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + ); + + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset: StellarSdk.Asset.native(), + sendMax: "106.555", + destination: PLATFORM_WALLET, + destAsset: usdc, + destAmount: "100", + path: [], + }) + ) + .addMemo(StellarSdk.Memo.text("DNB-TEST")) + .setTimeout(300) + .build(); + + const parsed = StellarSdk.TransactionBuilder.fromXDR( + tx.toXDR(), + StellarSdk.Networks.TESTNET + ); + + expect(parsed.operations).toHaveLength(1); + const op = parsed.operations[0]; + expect(op.type).toBe("pathPaymentStrictReceive"); + expect(toStroops(op.destAmount.toString())).toBe(toStroops("100")); + expect(toStroops(op.sendMax.toString())).toBe(toStroops("106.555")); + expect(op.destination).toBe(PLATFORM_WALLET); + }); + + it("builds two path_payment_strict_receive operations with fee split", () => { + const source = StellarSdk.Keypair.random(); + const account = new StellarSdk.Account(source.publicKey(), "1234"); + const usdc = new StellarSdk.Asset( + "USDC", + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + ); + + const feeSplit = calculateFeeSplit("100", 5, PLATFORM_WALLET); + const totalSendMaxStroops = toStroops("106.555"); + const totalDestStroops = toStroops("100"); + const creatorDestStroops = toStroops(feeSplit.creatorAmount); + const creatorSendMaxStroops = + (totalSendMaxStroops * creatorDestStroops) / totalDestStroops; + const platformSendMaxStroops = totalSendMaxStroops - creatorSendMaxStroops; + + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset: StellarSdk.Asset.native(), + sendMax: fromStroops(creatorSendMaxStroops), + destination: PLATFORM_WALLET, + destAsset: usdc, + destAmount: feeSplit.creatorAmount, + path: [], + }) + ) + .addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset: StellarSdk.Asset.native(), + sendMax: fromStroops(platformSendMaxStroops), + destination: PLATFORM_WALLET, + destAsset: usdc, + destAmount: feeSplit.platformAmount, + path: [], + }) + ) + .addMemo(StellarSdk.Memo.text("DNB-TEST")) + .setTimeout(300) + .build(); + + const parsed = StellarSdk.TransactionBuilder.fromXDR( + tx.toXDR(), + StellarSdk.Networks.TESTNET + ); + + expect(parsed.operations).toHaveLength(2); + parsed.operations.forEach((op) => { + expect(op.type).toBe("pathPaymentStrictReceive"); + }); + + const op1Dest = toStroops(parsed.operations[0].destAmount); + const op2Dest = toStroops(parsed.operations[1].destAmount); + expect(op1Dest + op2Dest).toBe(toStroops("100")); + + const op1SendMax = toStroops(parsed.operations[0].sendMax); + const op2SendMax = toStroops(parsed.operations[1].sendMax); + expect(op1SendMax + op2SendMax).toBe(totalSendMaxStroops); + }); +}); + +describe("toStroops / fromStroops consistency", () => { + it("round-trips correctly", () => { + const amounts = ["0", "1", "100.5", "0.0000001", "9999.9999999"]; + for (const a of amounts) { + expect(fromStroops(toStroops(a))).toBe(a); + } + }); + + it("fromStroops / toStroops on large numbers", () => { + const large = "1000000000.0000001"; + expect(fromStroops(toStroops(large))).toBe(large); + }); +}); diff --git a/test/payout.test.js b/test/payout.test.js index c251808d..1bba6dfb 100644 --- a/test/payout.test.js +++ b/test/payout.test.js @@ -496,4 +496,8 @@ describe("Payout Service & Earnings Ledger", () => { expect(isPayoutAdmin({ _id: "admin_user_1" })).toBe(false); }); }); + + afterAll(() => { + jest.restoreAllMocks(); + }); }); diff --git a/test/preflightPayment.test.js b/test/preflightPayment.test.js new file mode 100644 index 00000000..b21f94d4 --- /dev/null +++ b/test/preflightPayment.test.js @@ -0,0 +1,272 @@ +import { jest } from "@jest/globals"; +import { + preflightPayment, + isMemoRequired, + MEMO_REQUIRED_DATA_KEY, + PREFLIGHT_REASON_CODES, + USDC_ISSUER, + server, +} from "../src/services/stellar/stellarService.js"; + +const SOURCE = "GASOURCE000000000000000000000000000000000000000000000000"; +const DESTINATION = "GADEST0000000000000000000000000000000000000000000000000"; + +const memoRequiredDataAttr = () => ({ + [MEMO_REQUIRED_DATA_KEY]: Buffer.from("1").toString("base64"), +}); + +const fundedAccount = ({ + xlm = "10", + usdc = "100", + hasTrustline = true, + subentryCount = 1, + dataAttr, +} = {}) => ({ + balances: [ + { asset_type: "native", balance: xlm }, + ...(hasTrustline + ? [{ asset_code: "USDC", asset_issuer: USDC_ISSUER, balance: usdc }] + : []), + ], + subentry_count: subentryCount, + ...(dataAttr && { data_attr: dataAttr }), +}); + +const notFoundError = () => { + const error = new Error("Not Found"); + error.response = { status: 404 }; + return error; +}; + +const mockLoadAccount = (impl) => { + jest.spyOn(server, "loadAccount").mockImplementation(impl); +}; + +describe("preflightPayment", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("passes with no reasons when source and destination are both healthy", async () => { + mockLoadAccount(async (key) => + key === SOURCE ? fundedAccount() : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(true); + expect(result.reasons).toEqual([]); + }); + + it("flags destination_account_missing when destination is unfunded (404)", async () => { + mockLoadAccount(async (key) => { + if (key === DESTINATION) throw notFoundError(); + return fundedAccount(); + }); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.DESTINATION_ACCOUNT_MISSING + ); + }); + + it("flags source_account_missing when source is unfunded (404)", async () => { + mockLoadAccount(async (key) => { + if (key === SOURCE) throw notFoundError(); + return fundedAccount(); + }); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.SOURCE_ACCOUNT_MISSING + ); + }); + + it("flags destination_no_trustline when the destination has no USDC trustline", async () => { + mockLoadAccount(async (key) => + key === DESTINATION + ? fundedAccount({ hasTrustline: false }) + : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.DESTINATION_NO_TRUSTLINE + ); + }); + + it("flags source_insufficient_balance when the source USDC balance is too low", async () => { + mockLoadAccount(async (key) => + key === SOURCE ? fundedAccount({ usdc: "10" }) : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_BALANCE + ); + }); + + it("flags source_insufficient_reserve when XLM balance can't cover the minimum reserve", async () => { + mockLoadAccount(async (key) => + key === SOURCE + ? fundedAccount({ xlm: "0.5", subentryCount: 1 }) + : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_RESERVE + ); + }); + + it("accounts for extra operations (fee split) when computing the reserve/fee floor", async () => { + // (2 + 1 subentry) * 0.5 XLM = 1.5 XLM reserve, plus 2 ops * BASE_FEE. + // 1.500011 XLM covers 1 op but not 2. + mockLoadAccount(async (key) => + key === SOURCE + ? fundedAccount({ xlm: "1.500011", subentryCount: 1 }) + : fundedAccount() + ); + + const singleOp = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + operationCount: 1, + }); + expect(singleOp.ok).toBe(true); + + const twoOps = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + operationCount: 2, + }); + expect(twoOps.ok).toBe(false); + expect(twoOps.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_RESERVE + ); + }); + + it("SEP-29: flags destination_memo_required when the destination requires a memo and none is given", async () => { + mockLoadAccount(async (key) => + key === DESTINATION + ? fundedAccount({ dataAttr: memoRequiredDataAttr() }) + : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.DESTINATION_MEMO_REQUIRED + ); + }); + + it("SEP-29: passes when the destination requires a memo and a compliant memo is supplied", async () => { + mockLoadAccount(async (key) => + key === DESTINATION + ? fundedAccount({ dataAttr: memoRequiredDataAttr() }) + : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(true); + expect(result.reasons).toEqual([]); + }); + + it("collects multiple reasons at once rather than short-circuiting", async () => { + mockLoadAccount(async (key) => { + if (key === DESTINATION) throw notFoundError(); + return fundedAccount({ usdc: "0", xlm: "0.1" }); + }); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + const codes = result.reasons.map((r) => r.code); + expect(codes).toContain(PREFLIGHT_REASON_CODES.DESTINATION_ACCOUNT_MISSING); + expect(codes).toContain(PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_BALANCE); + expect(codes).toContain(PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_RESERVE); + }); +}); + +describe("isMemoRequired", () => { + it("returns false when there is no config.memo_required data entry", () => { + expect(isMemoRequired(fundedAccount())).toBe(false); + }); + + it("returns true when config.memo_required is base64-encoded '1'", () => { + expect( + isMemoRequired(fundedAccount({ dataAttr: memoRequiredDataAttr() })) + ).toBe(true); + }); + + it("returns false for a falsy encoded value", () => { + const dataAttr = { [MEMO_REQUIRED_DATA_KEY]: Buffer.from("0").toString("base64") }; + expect(isMemoRequired(fundedAccount({ dataAttr }))).toBe(false); + }); + + it("returns false when given no account", () => { + expect(isMemoRequired(null)).toBe(false); + expect(isMemoRequired(undefined)).toBe(false); + }); +}); diff --git a/test/refund.test.js b/test/refund.test.js new file mode 100644 index 00000000..bb817e30 --- /dev/null +++ b/test/refund.test.js @@ -0,0 +1,375 @@ +// test/refund.test.js +import "dotenv/config"; +import dns from "node:dns"; +dns.setServers(["8.8.8.8", "8.8.4.4"]); + +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import jwt from "jsonwebtoken"; +import mongoose from "mongoose"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import Course from "../src/models/Course.js"; +import Transaction from "../src/models/Transaction.js"; +import Refund from "../src/models/Refund.js"; +import paymentRoutes from "../src/routes/stellar/paymentRoutes.js"; +import { server } from "../src/services/stellar/stellarService.js"; + +jest.setTimeout(60000); + +const app = express(); +app.use(express.json()); +app.use("/api/stellar/payment", paymentRoutes); + +const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; + +const generateToken = (userId, role = "student") => { + return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" }); +}; + +describe("Non-Custodial Refund & Dispute Flow (#62)", () => { + let buyer, educator, otherUser, adminUser; + let buyerToken, educatorToken, otherToken, adminToken; + let confirmedTx; + let course; + + beforeAll(async () => { + const uri = process.env.MONGO_URI || "mongodb://127.0.0.1:27017/dnb-backend-test"; + + if (mongoose.connection.readyState === 0) { + await mongoose.connect(uri); + } + + // Mock Horizon Server + jest.spyOn(server, "loadAccount").mockImplementation(async (publicKey) => { + const acc = new StellarSdk.Account(publicKey, "1000"); + acc.balances = [{ asset_type: "native", balance: "100" }]; + return acc; + }); + + jest.spyOn(server, "submitTransaction").mockImplementation(async () => ({ + hash: "mock_reverse_tx_hash_12345", + ledger: 998877, + successful: true, + })); + + jest.spyOn(server, "transactions").mockImplementation(() => ({ + transaction: () => ({ + call: async () => ({ + successful: true, + ledger: 998877, + created_at: new Date().toISOString(), + }), + }), + })); + + jest.spyOn(server, "operations").mockImplementation(() => ({ + forTransaction: () => ({ + call: async () => ({ + records: [], + }), + }), + })); + }); + + afterAll(async () => { + jest.restoreAllMocks(); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Course.deleteMany({}); + await Book.deleteMany({}); + await Transaction.deleteMany({}); + await Refund.deleteMany({}); + + const buyerWallet = StellarSdk.Keypair.random().publicKey(); + const educatorWallet = StellarSdk.Keypair.random().publicKey(); + const otherWallet = StellarSdk.Keypair.random().publicKey(); + + // Create test users + buyer = await User.create({ + name: "Buyer Student", + email: "buyer@example.com", + password: "password123", + role: "student", + stellarWallet: { publicKey: buyerWallet }, + purchasedCourses: [], + }); + + educator = await User.create({ + name: "Educator Tutor", + email: "tutor@example.com", + password: "password123", + role: "tutor", + stellarWallet: { publicKey: educatorWallet }, + }); + + otherUser = await User.create({ + name: "Other User", + email: "other@example.com", + password: "password123", + role: "student", + stellarWallet: { publicKey: otherWallet }, + }); + + adminUser = await User.create({ + name: "Admin Arbiter", + email: "admin@example.com", + password: "password123", + role: "admin", + }); + + buyerToken = generateToken(buyer._id, "student"); + educatorToken = generateToken(educator._id, "tutor"); + otherToken = generateToken(otherUser._id, "student"); + adminToken = generateToken(adminUser._id, "admin"); + + // Create a purchased course and enroll buyer + course = await Course.create({ + title: "Advanced Fiqh Course", + description: "Comprehensive Fiqh study", + category: "Fiqh", + price: 50, + createdBy: educator._id, + enrolledUsers: [buyer._id], + }); + + buyer.purchasedCourses = [course._id]; + await buyer.save(); + + // Create confirmed purchase transaction + confirmedTx = await Transaction.create({ + stellarTxHash: "mock_original_tx_hash_99999", + buyer: buyer._id, + buyerWallet: buyer.stellarWallet.publicKey, + creator: educator._id, + creatorWallet: educator.stellarWallet.publicKey, + itemType: "course", + itemId: course._id, + itemTypeModel: "Course", + itemTitle: course.title, + amount: "50", + currency: "USDC", + network: "testnet", + status: "confirmed", + confirmedAt: new Date(), + }); + }); + + describe("1. Refund Request (POST /transactions/:id/refund-request)", () => { + it("should allow original buyer to request refund within window", async () => { + const res = await request(app) + .post(`/api/stellar/payment/transactions/${confirmedTx._id}/refund-request`) + .set("Authorization", `Bearer ${buyerToken}`) + .send({ reason: "Accidental purchase" }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.refund.status).toBe("requested"); + expect(res.body.refund.reason).toBe("Accidental purchase"); + }); + + it("should reject refund request from non-buyer (403)", async () => { + const res = await request(app) + .post(`/api/stellar/payment/transactions/${confirmedTx._id}/refund-request`) + .set("Authorization", `Bearer ${otherToken}`) + .send({ reason: "Unwanted item" }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + }); + + it("should enforce idempotency (prevent duplicate active refund requests)", async () => { + // First request + await request(app) + .post(`/api/stellar/payment/transactions/${confirmedTx._id}/refund-request`) + .set("Authorization", `Bearer ${buyerToken}`) + .send({ reason: "First request" }); + + // Second request + const res = await request(app) + .post(`/api/stellar/payment/transactions/${confirmedTx._id}/refund-request`) + .set("Authorization", `Bearer ${buyerToken}`) + .send({ reason: "Second request" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/already exists/i); + }); + + it("should reject refund request for non-confirmed transaction", async () => { + const pendingTx = await Transaction.create({ + stellarTxHash: "mock_pending_hash", + buyer: buyer._id, + buyerWallet: buyer.stellarWallet.publicKey, + creator: educator._id, + creatorWallet: educator.stellarWallet.publicKey, + itemType: "course", + itemId: course._id, + itemTypeModel: "Course", + itemTitle: course.title, + amount: "50", + network: "testnet", + status: "pending", + }); + + const res = await request(app) + .post(`/api/stellar/payment/transactions/${pendingTx._id}/refund-request`) + .set("Authorization", `Bearer ${buyerToken}`) + .send({ reason: "Cancel pending" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe("2. Reverse Payment XDR Build & Submission", () => { + let refund; + + beforeEach(async () => { + refund = await Refund.create({ + originalTransaction: confirmedTx._id, + buyer: buyer._id, + educator: educator._id, + itemType: "course", + itemId: course._id, + amount: "50", + currency: "USDC", + reason: "Course not relevant", + status: "requested", + }); + }); + + it("should allow educator to build unsigned reverse payment XDR", async () => { + const res = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/build`) + .set("Authorization", `Bearer ${educatorToken}`) + .send(); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.unsignedXdr).toBeDefined(); + expect(res.body.refund.status).toBe("approved"); + }); + + it("should prevent non-educator from building reverse payment XDR (403)", async () => { + const res = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/build`) + .set("Authorization", `Bearer ${buyerToken}`) + .send(); + + expect(res.status).toBe(403); + }); + + it("should submit signed XDR, verify Horizon, and revoke item access atomically", async () => { + // Step 1: Educator builds + const buildRes = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/build`) + .set("Authorization", `Bearer ${educatorToken}`) + .send(); + + const unsignedXdr = buildRes.body.unsignedXdr; + + // Step 2: Educator submits signed XDR + const res = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/submit`) + .set("Authorization", `Bearer ${educatorToken}`) + .send({ signedXdr: unsignedXdr }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.refund.status).toBe("confirmed"); + + // Verify access revocation on User and Course + const updatedBuyer = await User.findById(buyer._id); + const updatedCourse = await Course.findById(course._id); + const updatedTx = await Transaction.findById(confirmedTx._id); + + expect(updatedBuyer.purchasedCourses.map((c) => c.toString())).not.toContain(course._id.toString()); + expect(updatedCourse.enrolledUsers.map((u) => u.toString())).not.toContain(buyer._id.toString()); + expect(updatedCourse.enrolledUsers.length).toBe(0); + expect(updatedTx.status).toBe("refunded"); + }); + + it("should block submit if refund is not in approved state", async () => { + // Try submitting directly on 'requested' refund without building + const res = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/submit`) + .set("Authorization", `Bearer ${educatorToken}`) + .send({ signedXdr: "AAAA...XDR..." }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/Must be 'approved' first/i); + }); + }); + + describe("3. Dispute Escalation & Admin Arbitration", () => { + let refund; + + beforeEach(async () => { + refund = await Refund.create({ + originalTransaction: confirmedTx._id, + buyer: buyer._id, + educator: educator._id, + itemType: "course", + itemId: course._id, + amount: "50", + currency: "USDC", + reason: "Educator uncooperative", + status: "rejected", + }); + }); + + it("should allow buyer to escalate rejected refund to disputed", async () => { + const res = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/dispute`) + .set("Authorization", `Bearer ${buyerToken}`) + .send(); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.refund.status).toBe("disputed"); + + const updatedTx = await Transaction.findById(confirmedTx._id); + expect(updatedTx.status).toBe("disputed"); + }); + + it("should allow admin/arbiter to record arbitration resolution", async () => { + // Escalate to disputed first + refund.status = "disputed"; + await refund.save(); + + const res = await request(app) + .patch(`/api/stellar/payment/refunds/${refund._id}/arbitrate`) + .set("Authorization", `Bearer ${adminToken}`) + .send({ + decision: "off_chain_resolved", + notes: "Mediated off-chain resolution with educator", + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.refund.status).toBe("resolved"); + expect(res.body.refund.resolution.decision).toBe("off_chain_resolved"); + expect(res.body.disclaimer).toMatch(/Non-custodial Limitation/i); + }); + + it("should reject arbitration attempt from non-admin user (403)", async () => { + refund.status = "disputed"; + await refund.save(); + + const res = await request(app) + .patch(`/api/stellar/payment/refunds/${refund._id}/arbitrate`) + .set("Authorization", `Bearer ${buyerToken}`) + .send({ decision: "approved" }); + + expect(res.status).toBe(403); + }); + }); +}); diff --git a/test/search.test.js b/test/search.test.js new file mode 100644 index 00000000..4da519b7 --- /dev/null +++ b/test/search.test.js @@ -0,0 +1,100 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import Course from "../src/models/Course.js"; +import Book from "../src/models/Book.js"; +import User from "../src/models/User.js"; +import Space from "../src/models/Space.js"; +import Reel from "../src/models/Reel.js"; + +let mongoServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + // Clean db + await Course.deleteMany({}); + await Book.deleteMany({}); + await User.deleteMany({}); + await Space.deleteMany({}); + await Reel.deleteMany({}); + + const userId1 = new mongoose.Types.ObjectId(); + const userId2 = new mongoose.Types.ObjectId(); + + await Course.create([ + { title: "React Fundamentals", description: "Learn React from scratch.", category: "Programming", price: 100, createdBy: userId1 }, + { title: "Advanced Node.js", description: "Deep dive into Node and V8.", category: "Programming", price: 150, createdBy: userId1 }, + { title: "Cooking 101", description: "Learn how to cook basic meals.", category: "Cooking", price: 0, createdBy: userId2 } + ]); + + await Book.create([ + { title: "React Design Patterns", description: "Advanced patterns in React.", category: "Programming", price: 50, author: userId1, image: "url", fileUrl: "url" }, + { title: "Node.js Design Patterns", description: "Node best practices.", category: "Programming", price: 60, author: userId1, image: "url", fileUrl: "url" } + ]); + + await User.create([ + { _id: userId1, name: "John Doe", email: "john@example.com", password: "password", role: "tutor", bio: "React expert and tutor.", interests: ["React", "JavaScript"] }, + { _id: userId2, name: "Jane Smith", email: "jane@example.com", password: "password", role: "tutor", bio: "Cooking master.", interests: ["Cooking"] }, + { name: "Student Bob", email: "bob@example.com", password: "password", role: "student" } + ]); + + // Wait for indexes to build + await Course.syncIndexes(); + await Book.syncIndexes(); + await User.syncIndexes(); + await Space.syncIndexes(); + await Reel.syncIndexes(); +}); + +afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } +}); + +describe("Full-text search API", () => { + it("should return relevance ordered results for 'React'", async () => { + const res = await request(app).get("/api/search?q=React"); + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.results.courses).toBeDefined(); + expect(res.body.results.books).toBeDefined(); + // Check if courses returned match the query + expect(res.body.results.courses.length).toBeGreaterThan(0); + expect(res.body.results.courses[0].title).toContain("React"); + }); + + it("should support pagination per type", async () => { + const res = await request(app).get("/api/search?q=Node&limit=1"); + expect(res.statusCode).toBe(200); + expect(res.body.results.courses.length).toBeLessThanOrEqual(1); + expect(res.body.pagination.courses.limit).toBe(1); + }); + + it("should filter by free", async () => { + const res = await request(app).get("/api/search?type=courses&free=true"); + expect(res.statusCode).toBe(200); + expect(res.body.results.courses.length).toBe(1); + expect(res.body.results.courses[0].title).toBe("Cooking 101"); + }); + + it("should filter by category", async () => { + const res = await request(app).get("/api/search?category=Cooking"); + expect(res.statusCode).toBe(200); + expect(res.body.results.courses.length).toBe(1); + expect(res.body.results.books.length).toBe(0); + }); + + it("should support educator search with public fields only", async () => { + const res = await request(app).get("/api/search/educators?interest=React"); + expect(res.statusCode).toBe(200); + expect(res.body.results.length).toBe(1); + expect(res.body.results[0].name).toBe("John Doe"); + expect(res.body.results[0].email).toBeUndefined(); + expect(res.body.results[0].password).toBeUndefined(); + }); +}); diff --git a/test/stellarPaymentController.test.js b/test/stellarPaymentController.test.js index fef923ba..339642c7 100644 --- a/test/stellarPaymentController.test.js +++ b/test/stellarPaymentController.test.js @@ -5,19 +5,50 @@ import mongoose from "mongoose"; const buildPaymentTransaction = jest.fn(); const buildSep7Uri = jest.fn(); +const calculateFeeSplit = jest.fn(); +const preflightPayment = jest.fn(); const submitTransaction = jest.fn(); const verifyPaymentOperations = jest.fn(); const getExplorerUrl = jest.fn((hash) => `https://stellar.expert/tx/${hash}`); const recordSaleEarnings = jest.fn(); +const enqueue = jest.fn(); +// Mirrors every named export of stellarService.js, not just the ones this +// suite exercises: other test files (preflightPayment.test.js, +// pathPayments.test.js) statically import the real module, and Jest's ESM +// module linker can resolve those against this mock's identity when both +// run in the same worker - an incomplete mock then fails with "does not +// provide an export named X" for exports this file never even references. jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ - buildPaymentTransaction, + STROOPS_PER_UNIT: 10000000n, + toStroops: jest.fn(), + fromStroops: jest.fn(), + applySlippage: jest.fn(), + findPaymentPaths: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + calculateFeeSplit, buildSep7Uri, + isValidPublicKey: jest.fn(), + getAccountBalance: jest.fn(), + MEMO_REQUIRED_DATA_KEY: "config.memo_required", + isMemoRequired: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + preflightPayment, + buildPaymentTransaction, + buildReversePaymentTransaction: jest.fn(), submitTransaction, - verifyPaymentOperations, verifyTransaction: jest.fn(), - NETWORK: "testnet", + verifyPaymentOperations, + hasUsdcTrustline: jest.fn(), getExplorerUrl, + getAccountExplorerUrl: jest.fn(), + server: {}, + USDC: "USDC", + USDC_ISSUER: "", + NETWORK: "testnet", + networkPassphrase: "Test SDF Network ; September 2015", + DONATION_WALLET_PUBLIC_KEY: "", + PLATFORM_FEE_PERCENT: 0, PLATFORM_WALLET_PUBLIC_KEY: "", })); @@ -25,6 +56,13 @@ jest.unstable_mockModule("../src/services/payoutService.js", () => ({ recordSaleEarnings, })); +// paymentController.js enqueues background jobs (receipt generation, retry +// verification) via the real job queue - mock it so those calls don't hit +// the actual queue, which has no handlers registered in this suite. +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + enqueue, +})); + const { initializePayment, submitPayment } = await import( "../src/controllers/stellar/paymentController.js" ); @@ -78,10 +116,13 @@ describe("Stellar payment controller", () => { jest.restoreAllMocks(); buildPaymentTransaction.mockReset(); buildSep7Uri.mockReset(); + calculateFeeSplit.mockReset().mockReturnValue(null); + preflightPayment.mockReset().mockResolvedValue({ ok: true }); submitTransaction.mockReset(); verifyPaymentOperations.mockReset(); getExplorerUrl.mockClear(); recordSaleEarnings.mockReset(); + enqueue.mockReset().mockResolvedValue(undefined); buyerId = new mongoose.Types.ObjectId(); creatorId = new mongoose.Types.ObjectId(); diff --git a/test/stellarToml.test.js b/test/stellarToml.test.js new file mode 100644 index 00000000..d3afeb67 --- /dev/null +++ b/test/stellarToml.test.js @@ -0,0 +1,155 @@ +import request from "supertest"; +import TOML from "@iarna/toml"; +import app from "../app.js"; +import { + networkPassphrase, + USDC_ISSUER, +} from "../src/services/stellar/stellarService.js"; + +/** + * Helper to safely override process.env variables and guarantee exact restoration in a finally block. + */ +const withEnv = async (overrides, fn) => { + const saved = {}; + for (const key of Object.keys(overrides)) { + saved[key] = process.env[key]; + if (overrides[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = overrides[key]; + } + } + try { + await fn(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +}; + +describe("GET /.well-known/stellar.toml", () => { + it("returns 200 with Content-Type text/toml", async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/toml/); + }); + + it("includes Access-Control-Allow-Origin: *", async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + }); + + it("body parses as valid TOML", async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + const doc = TOML.parse(res.text); + expect(doc).toBeDefined(); + expect(typeof doc.VERSION).toBe("string"); + }); + + it("NETWORK_PASSPHRASE matches the active network configuration", async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + const doc = TOML.parse(res.text); + expect(doc.NETWORK_PASSPHRASE).toBe(networkPassphrase); + }); + + it("contains USDC [[CURRENCIES]] block with correct issuer for configured network", async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + const doc = TOML.parse(res.text); + expect(Array.isArray(doc.CURRENCIES)).toBe(true); + const usdc = doc.CURRENCIES.find((c) => c.code === "USDC"); + expect(usdc).toBeDefined(); + expect(usdc.issuer).toBe(USDC_ISSUER); + expect(usdc.is_asset_anchored).toBe(true); + }); + + it("includes [DOCUMENTATION] when ORG_NAME is set", async () => { + await withEnv({ ORG_NAME: "DeenBridge" }, async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + const doc = TOML.parse(res.text); + expect(doc.DOCUMENTATION).toBeDefined(); + expect(doc.DOCUMENTATION.ORG_NAME).toBe("DeenBridge"); + }); + }); + + it("escapes quotes, backslashes, and newlines safely in [DOCUMENTATION] values", async () => { + const complexDesc = 'DeenBridge "Platform"\nLine 2 \\ test'; + await withEnv({ ORG_DESCRIPTION: complexDesc }, async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + const doc = TOML.parse(res.text); + expect(doc.DOCUMENTATION).toBeDefined(); + expect(doc.DOCUMENTATION.ORG_DESCRIPTION).toBe(complexDesc); + }); + }); + + it("omits [DOCUMENTATION] when no ORG_* env vars are set", async () => { + const overrides = { + ORG_NAME: undefined, + ORG_URL: undefined, + ORG_DESCRIPTION: undefined, + ORG_LOGO: undefined, + ORG_TWITTER: undefined, + ORG_GITHUB: undefined, + }; + await withEnv(overrides, async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + const doc = TOML.parse(res.text); + expect(doc.DOCUMENTATION).toBeUndefined(); + }); + }); + + it("includes ACCOUNTS when STELLAR_PLATFORM_PUBLIC_KEY is set", async () => { + const key = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + await withEnv({ STELLAR_PLATFORM_PUBLIC_KEY: key }, async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + const doc = TOML.parse(res.text); + expect(doc.ACCOUNTS).toContain(key); + }); + }); + + it("omits ACCOUNTS gracefully when no STELLAR_PLATFORM_PUBLIC_KEY is set", async () => { + await withEnv({ STELLAR_PLATFORM_PUBLIC_KEY: undefined }, async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + expect(res.statusCode).toBe(200); + const doc = TOML.parse(res.text); + expect(doc.ACCOUNTS).toBeUndefined(); + }); + }); + + it("emits SIGNING_KEY only when a valid public Stellar key starting with G is supplied", async () => { + const validKey = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + const invalidSeed = "SD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + await withEnv({ SIGNING_KEY: validKey }, async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + const doc = TOML.parse(res.text); + expect(doc.SIGNING_KEY).toBe(validKey); + }); + + await withEnv({ SIGNING_KEY: invalidSeed }, async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + const doc = TOML.parse(res.text); + expect(doc.SIGNING_KEY).toBeUndefined(); + expect(res.text).toContain('# SIGNING_KEY = "G..."'); + }); + }); + + it("does not require authentication", async () => { + const res = await request(app).get("/.well-known/stellar.toml"); + expect(res.statusCode).not.toBe(401); + expect(res.statusCode).not.toBe(403); + }); + + it("is not affected by /api rate limiter", async () => { + const results = await Promise.all( + Array.from({ length: 10 }, () => + request(app).get("/.well-known/stellar.toml") + ) + ); + results.forEach((res) => expect(res.statusCode).toBe(200)); + }); +}); diff --git a/test/upload.test.js b/test/upload.test.js new file mode 100644 index 00000000..d3823d38 --- /dev/null +++ b/test/upload.test.js @@ -0,0 +1,90 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; + +import app from "../app.js"; +import cloudinary from "../src/utils/cloudinary.js"; + +describe("Upload Routes", () => { + jest.setTimeout(30000); + let mongoServer; + let token; + let testUserId; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + const authRes = await request(app) + .post("/api/auth/register") + .send({ name: "Uploader", email: "uploader@example.com", password: "password", role: "student" }); + token = authRes.body.accessToken; + testUserId = authRes.body.user._id || authRes.body.user.id; + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + describe("POST /api/uploads/signature", () => { + it("should reject unauthenticated requests", async () => { + const res = await request(app).post("/api/uploads/signature"); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should generate a signature for authenticated requests", async () => { + const res = await request(app) + .post("/api/uploads/signature") + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe("Signature generated successfully"); + expect(res.body.data).toHaveProperty("signature"); + expect(res.body.data).toHaveProperty("timestamp"); + const config = cloudinary.config(); + expect(res.body.data).toHaveProperty("cloudName"); + expect(res.body.data).toHaveProperty("apiKey"); + + // Ensure we don't leak the API secret + const resStr = JSON.stringify(res.body); + const apiSecret = config.api_secret; + expect(apiSecret).toBeDefined(); + expect(resStr).not.toContain(apiSecret); + }); + }); + + describe("PUT /api/users/update/:id", () => { + it("should allow the owner to update their profile", async () => { + const res = await request(app) + .put(`/api/users/update/${testUserId}`) + .set("Authorization", `Bearer ${token}`) + .send({ bio: "Updated bio" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("should return 403 if user tries to update another user's profile", async () => { + const otherAuthRes = await request(app) + .post("/api/auth/register") + .send({ name: "Other", email: "other@example.com", password: "password", role: "student" }); + + const otherUserId = otherAuthRes.body.user._id || otherAuthRes.body.user.id; + + const res = await request(app) + .put(`/api/users/update/${otherUserId}`) + .set("Authorization", `Bearer ${token}`) + .send({ bio: "Malicious update" }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe("Not authorized to update this profile"); + expect(res.body.data).toBeNull(); + }); + }); +});