diff --git a/app.js b/app.js index 52651c5d..a65b59b8 100644 --- a/app.js +++ b/app.js @@ -47,6 +47,7 @@ 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"; +import auditRoutes from "./src/routes/admin/auditRoutes.js"; handleUncaughtException(); validateEnv(); @@ -188,6 +189,7 @@ app.use("/api/stellar/donation", stellarDonationRoutes); app.use("/api/payouts", payoutRoutes); app.use("/api/uploads", uploadRoutes); app.use("/admin/jobs", jobsRoutes); +app.use("/api/admin/audit", auditRoutes); // ====================== // ERROR HANDLING diff --git a/package.json b/package.json index 65e451f6..81e72d7a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev": "nodemon server.js", "seed": "node src/scripts/seedDatabase.js", "test-redis": "node test-redis.js", - "payouts:audit": "node src/scripts/auditPayouts.js" + "payouts:audit": "node src/scripts/auditPayouts.js", + "worker:ingest": "node src/workers/runIngestionWorker.js" }, "keywords": [], "author": "zeemscript", diff --git a/server.js b/server.js index 7a848019..4921eca2 100644 --- a/server.js +++ b/server.js @@ -22,6 +22,19 @@ const server = app.listen(PORT, () => { startJobs().catch((err) => logger.error(err, "Background job startup failed")); +// Start payment ingestion worker if enabled +let stopIngestionWorker; +if (process.env.INGESTION_WORKER_ENABLED === "true") { + import("./src/workers/paymentIngestionWorker.js").then( + ({ startIngestionWorker, stopIngestionWorker: stopFn }) => { + stopIngestionWorker = stopFn; + startIngestionWorker().catch((err) => + logger.error(err, "Ingestion worker startup failed") + ); + } + ); +} + // Graceful shutdown const gracefulShutdown = async (signal) => { logger.info(`${signal} received. Starting graceful shutdown...`); @@ -31,6 +44,10 @@ const gracefulShutdown = async (signal) => { await stopJobs(); + if (stopIngestionWorker) { + await stopIngestionWorker(); + } + // Close Redis connection await closeRedis(); diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index fd18be10..9b0f36bd 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -47,6 +47,8 @@ const optionalEnvVars = [ "ORG_TWITTER", "ORG_GITHUB", "SIGNING_KEY", + "INGESTION_WORKER_ENABLED", + "INGESTION_POLL_INTERVAL_MS", ]; export const validateEnv = () => { diff --git a/src/controllers/authController.js b/src/controllers/authController.js index 662ecbb1..f9ed8dc3 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -8,6 +8,8 @@ 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 { recordAudit } from "../services/audit/auditService.js"; +import { AUDIT_ACTIONS } from "../models/AuditLog.js"; import { generateOtp, hashOtp, verifyOtp } from "../utils/otp.js"; import { catchAsync, APIError } from "../middlewares/errorHandler.js"; @@ -111,6 +113,15 @@ export const registerUser = catchAsync(async (req, res, next) => { const existing = await User.findOne({ email }); if (existing) { logger.warn(`❌ Registration failed - Email already exists: ${email}`); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_REGISTER_FAILURE, + actor: null, + req, + targetType: "User", + targetId: email, + status: "failure", + metadata: { email, reason: "email_already_exists" }, + }); return next(new APIError("Email already exists", 400)); } @@ -137,6 +148,16 @@ export const registerUser = catchAsync(async (req, res, next) => { logger.info(`✅ User registered successfully: ${email} (ID: ${user._id})`); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_REGISTER_SUCCESS, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email, assignedRole, name }, + }); + // Generate session and tokens const { accessToken, refreshToken } = await createSessionAndTokens(user, req, res); @@ -169,6 +190,15 @@ export const loginUser = catchAsync(async (req, res, next) => { const user = await User.findOne({ email }).select("+password"); if (!user) { logger.warn(`❌ Login failed - User not found: ${email}`); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_LOGIN_FAILURE, + actor: null, + req, + targetType: "User", + targetId: email, + status: "failure", + metadata: { email, reason: "user_not_found" }, + }); return next(new APIError("Invalid credentials", 401)); } @@ -176,6 +206,15 @@ export const loginUser = catchAsync(async (req, res, next) => { const isPasswordCorrect = await bcrypt.compare(password, user.password); if (!isPasswordCorrect) { logger.warn(`❌ Login failed - Incorrect password: ${email}`); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_LOGIN_FAILURE, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email, reason: "invalid_password" }, + }); return next(new APIError("Invalid credentials", 401)); } @@ -188,6 +227,16 @@ export const loginUser = catchAsync(async (req, res, next) => { logger.info(`✅ Login successful: ${email} (ID: ${user._id})`); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_LOGIN_SUCCESS, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email, role: user.role }, + }); + res.status(200).json({ success: true, message: "Login successful", @@ -251,6 +300,16 @@ export const requestPasswordReset = async (req, res) => { await user.save(); } + recordAudit({ + action: AUDIT_ACTIONS.AUTH_PASSWORD_RESET_REQUEST, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email }, + }); + res.status(200).json({ success: true, message: @@ -303,6 +362,17 @@ export const resetPassword = async (req, res) => { await user.save(); logger.info("✅ Password reset successful for:", email); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_PASSWORD_RESET_COMPLETE, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email }, + }); + res.status(200).json({ success: true, message: @@ -484,6 +554,16 @@ export const logoutUser = catchAsync(async (req, res, next) => { ); } + recordAudit({ + action: AUDIT_ACTIONS.AUTH_LOGOUT, + actor: req.user?._id ?? null, + req, + targetType: "User", + targetId: req.user?._id?.toString() ?? null, + status: "success", + metadata: null, + }); + const isProd = process.env.NODE_ENV === "production"; res.clearCookie("refreshToken", { httpOnly: true, diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index 5cc0bfc9..b6911842 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -23,6 +23,7 @@ import { import { getAssetConfig, isAssetSupported, getSupportedCodes } from "../../config/assets.js"; import * as StellarSdk from "@stellar/stellar-sdk"; import { recordSaleEarnings } from "../../services/payoutService.js"; +import { grantItemAccess } from "../../services/stellar/reconciliationService.js"; import { enqueue } from "../../jobs/queue.js"; import logger from "../../config/logger.js"; import { @@ -31,6 +32,8 @@ import { paymentsConfirmed, paymentsFailed, } from "../../config/metrics.js"; +import { recordAudit } from "../../services/audit/auditService.js"; +import { AUDIT_ACTIONS } from "../../models/AuditLog.js"; /** * Resolve the item, its creator, and the settlement destination wallet for a @@ -519,6 +522,23 @@ export const initializePayment = async (req, res) => { `Payment initialized: ${transaction._id} for ${itemType} ${itemId}` ); + recordAudit({ + action: AUDIT_ACTIONS.PAYMENT_INITIALIZE, + actor: buyerId, + req, + targetType: "Transaction", + targetId: transaction._id.toString(), + status: "success", + metadata: { + transactionId: transaction._id.toString(), + itemType, + itemId, + itemTitle: item.title, + amount: item.price.toString(), + settlementMode, + }, + }); + res.status(200).json({ success: true, transactionId: transaction._id, @@ -683,6 +703,20 @@ export const submitPayment = async (req, res) => { `Transaction ${transactionId} verification failed: ${verification.reason}` ); + recordAudit({ + action: AUDIT_ACTIONS.PAYMENT_SUBMIT_FAILED, + actor: buyerId, + req, + targetType: "Transaction", + targetId: transactionId, + status: "failure", + metadata: { + transactionId, + stellarTxHash: result.hash, + failureReason: `On-chain verification failed: ${verification.reason}`, + }, + }); + return res.status(400).json({ success: false, message: "Payment could not be verified on the Stellar network", @@ -699,33 +733,13 @@ export const submitPayment = async (req, res) => { await recordSaleEarnings(transaction, { session }); - const buyer = await User.findById(buyerId).session(session); - - if (transaction.itemType === "book") { - buyer.purchasedBooks.push({ - bookId: transaction.itemId, - purchaseDate: new Date(), - }); - if (buyer.stat) { - buyer.stat.booksRead = (buyer.stat.booksRead || 0) + 1; - } - } else { - buyer.purchasedCourses.push({ - courseId: transaction.itemId, - purchaseDate: new Date(), - }); - if (buyer.stat) { - buyer.stat.coursesEnrolled = (buyer.stat.coursesEnrolled || 0) + 1; - } - - await Course.findByIdAndUpdate( - transaction.itemId, - { $addToSet: { enrolledUsers: buyerId } }, - { session } - ); - } - - await buyer.save({ session }); + // Grant access to the purchased item (shared with ingestion worker) + await grantItemAccess({ + buyerId, + itemType: transaction.itemType, + itemId: transaction.itemId, + session, + }); try { await enqueue( "generateReceipt", @@ -752,6 +766,24 @@ export const submitPayment = async (req, res) => { `Payment successful: ${transactionId}, Stellar TX: ${result.hash}` ); + recordAudit({ + action: AUDIT_ACTIONS.PAYMENT_SUBMIT_CONFIRMED, + actor: buyerId, + req, + targetType: "Transaction", + targetId: transactionId, + status: "success", + metadata: { + transactionId, + stellarTxHash: result.hash, + stellarLedger: result.ledger, + amount: transaction.amount, + itemType: transaction.itemType, + itemId: transaction.itemId.toString(), + settlementMode: transaction.settlement, + }, + }); + res.status(200).json({ success: true, message: "Payment successful!", @@ -912,6 +944,22 @@ export const cancelTransaction = async (req, res) => { logger.info(`Transaction ${transactionId} cancelled by user ${userId}`); + recordAudit({ + action: AUDIT_ACTIONS.PAYMENT_CANCEL, + actor: userId, + req, + targetType: "Transaction", + targetId: transactionId, + status: "success", + metadata: { + transactionId, + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + amount: transaction.amount, + failureReason: "Cancelled by user", + }, + }); + res.status(200).json({ success: true, message: "Transaction cancelled", diff --git a/src/controllers/stellar/reconciliationController.js b/src/controllers/stellar/reconciliationController.js new file mode 100644 index 00000000..4bc2c264 --- /dev/null +++ b/src/controllers/stellar/reconciliationController.js @@ -0,0 +1,18 @@ +import { getReconciliationStatus } from "../../services/stellar/reconciliationService.js"; +import logger from "../../config/logger.js"; + +export const reconciliationStatus = async (req, res) => { + try { + const status = await getReconciliationStatus(); + res.status(200).json({ + success: true, + ...status, + }); + } catch (error) { + logger.error("Get reconciliation status error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch reconciliation status", + }); + } +}; diff --git a/src/controllers/stellar/walletController.js b/src/controllers/stellar/walletController.js index 3e75b421..7d47c0f2 100644 --- a/src/controllers/stellar/walletController.js +++ b/src/controllers/stellar/walletController.js @@ -6,6 +6,8 @@ import { NETWORK, } from "../../services/stellar/stellarService.js"; import logger from "../../config/logger.js"; +import { recordAudit } from "../../services/audit/auditService.js"; +import { AUDIT_ACTIONS } from "../../models/AuditLog.js"; /** * Connect Stellar wallet to user profile @@ -17,6 +19,15 @@ export const connectWallet = async (req, res) => { const { publicKey } = req.body; if (!publicKey || !isValidPublicKey(publicKey)) { + recordAudit({ + action: AUDIT_ACTIONS.WALLET_CONNECT_FAILURE, + actor: userId, + req, + targetType: "Wallet", + targetId: publicKey ?? null, + status: "failure", + metadata: { reason: "invalid_public_key" }, + }); return res.status(400).json({ success: false, message: "Invalid Stellar public key", @@ -29,6 +40,15 @@ export const connectWallet = async (req, res) => { }); if (existingUser) { + recordAudit({ + action: AUDIT_ACTIONS.WALLET_REASSIGN_ATTEMPT, + actor: userId, + req, + targetType: "Wallet", + targetId: publicKey, + status: "failure", + metadata: { publicKey, reason: "wallet_already_claimed", conflictUserId: existingUser._id.toString() }, + }); return res.status(400).json({ success: false, message: "This wallet is already connected to another account", @@ -54,6 +74,16 @@ export const connectWallet = async (req, res) => { logger.info(`Wallet connected for user ${userId}: ${publicKey}`); + recordAudit({ + action: AUDIT_ACTIONS.WALLET_CONNECT_SUCCESS, + actor: userId, + req, + targetType: "Wallet", + targetId: publicKey, + status: "success", + metadata: { publicKey, network: NETWORK }, + }); + res.status(200).json({ success: true, message: "Wallet connected successfully", @@ -82,12 +112,26 @@ export const disconnectWallet = async (req, res) => { try { const userId = req.user._id; + // Capture the wallet key before unsetting it (for the audit row) + const currentUser = await User.findById(userId).select("stellarWallet"); + const previousPublicKey = currentUser?.stellarWallet?.publicKey ?? null; + await User.findByIdAndUpdate(userId, { $unset: { stellarWallet: 1 }, }); logger.info(`Wallet disconnected for user ${userId}`); + recordAudit({ + action: AUDIT_ACTIONS.WALLET_DISCONNECT, + actor: userId, + req, + targetType: "Wallet", + targetId: previousPublicKey, + status: "success", + metadata: { previousPublicKey }, + }); + res.status(200).json({ success: true, message: "Wallet disconnected successfully", diff --git a/src/controllers/userController.js b/src/controllers/userController.js index ba704174..f462e1aa 100644 --- a/src/controllers/userController.js +++ b/src/controllers/userController.js @@ -7,6 +7,8 @@ import { validateMagicBytes } from "../utils/fileValidation.js"; import CourseProgress from "../models/CourseProgress.js"; import { createFollowNotification, createUnfollowNotification } from "./notificationController.js"; +const PUBLIC_FIELDS = "name avatar bio role interests gender age country language"; + // Update user profile (including avatar upload to Cloudinary) export const updateUser = async (req, res) => { try { @@ -95,6 +97,14 @@ export const updateUser = async (req, res) => { }); } catch (error) { logger.error("Profile update error:", error); + + if (error.code === 11000 || error.message?.includes("E11000")) { + return res.status(409).json({ + success: false, + message: "A user with this email already exists", + }); + } + res.status(500).json({ success: false, message: "Failed to update profile. Please try again.", @@ -106,7 +116,14 @@ export const updateUser = async (req, res) => { // Get user by ID export const getUser = async (req, res) => { try { - const user = await User.findById(req.params.id); + const isSelf = req.user._id.toString() === req.params.id; + const query = User.findById(req.params.id); + + if (!isSelf) { + query.select(PUBLIC_FIELDS); + } + + const user = await query; if (!user) { return res.status(404).json({ success: false, diff --git a/src/models/AuditLog.js b/src/models/AuditLog.js new file mode 100644 index 00000000..f4dad609 --- /dev/null +++ b/src/models/AuditLog.js @@ -0,0 +1,168 @@ +// models/AuditLog.js +// +// Append-only audit log for security- and financial-sensitive actions. +// +// RETENTION POLICY +// ───────────────── +// Financial rows (action prefix "payment.*", "payout.*") are retained +// indefinitely by default. No TTL index is set. Auth/wallet rows follow +// the same policy for now. A future ops decision may archive rows older +// than N years to cold storage; update this comment and add an index at +// that time — do NOT add auto-expiry to financial rows without an explicit +// compliance sign-off. +import mongoose from "mongoose"; + +// ── Action enum ──────────────────────────────────────────────────────────── +// New categories should be added here (and mirrored in auditService.js) +// before instrumenting new controllers. +export const AUDIT_ACTIONS = Object.freeze({ + // Auth + AUTH_REGISTER_SUCCESS: "auth.register.success", + AUTH_REGISTER_FAILURE: "auth.register.failure", + AUTH_LOGIN_SUCCESS: "auth.login.success", + AUTH_LOGIN_FAILURE: "auth.login.failure", + AUTH_LOGOUT: "auth.logout", + AUTH_PASSWORD_RESET_REQUEST: "auth.password_reset.request", + AUTH_PASSWORD_RESET_COMPLETE: "auth.password_reset.complete", + + // Wallet + WALLET_CONNECT_SUCCESS: "wallet.connect.success", + WALLET_CONNECT_FAILURE: "wallet.connect.failure", + WALLET_DISCONNECT: "wallet.disconnect", + WALLET_REASSIGN_ATTEMPT: "wallet.reassign.attempt", + + // Payments + PAYMENT_INITIALIZE: "payment.initialize", + PAYMENT_SUBMIT_CONFIRMED: "payment.submit.confirmed", + PAYMENT_SUBMIT_FAILED: "payment.submit.failed", + PAYMENT_CANCEL: "payment.cancel", + + // Entitlements (access grants) + ENTITLEMENT_GRANT: "entitlement.grant", + + // Extension points — to be instrumented with issue #20 / #28 + PAYOUT_BATCH_INITIATED: "payout.batch.initiated", + PAYOUT_BATCH_CONFIRMED: "payout.batch.confirmed", + ROLE_CHANGE: "role.change", +}); + +const ACTION_VALUES = Object.values(AUDIT_ACTIONS); + +// ── Schema ───────────────────────────────────────────────────────────────── +const auditLogSchema = new mongoose.Schema( + { + /** The security/financial action that occurred. */ + action: { + type: String, + enum: ACTION_VALUES, + required: [true, "action is required"], + }, + + /** The authenticated user who performed the action. + * Null for pre-authentication failures (e.g. login with unknown email). */ + actor: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null, + }, + + /** Client IP address at the time of the action. */ + actorIp: { + type: String, + default: null, + }, + + /** Raw User-Agent string. */ + actorUserAgent: { + type: String, + default: null, + }, + + /** The kind of resource affected (e.g. "User", "Transaction", "Wallet"). */ + targetType: { + type: String, + default: null, + }, + + /** + * The identifier of the affected resource (Mongo ObjectId as string, + * email address, public key, etc.). + */ + targetId: { + type: String, + default: null, + }, + + /** Whether the action succeeded or failed. */ + status: { + type: String, + enum: ["success", "failure"], + required: [true, "status is required"], + }, + + /** + * Structured context for the event. Only an explicit allowlist of keys + * is persisted (enforced in auditService.js) — no secrets, passwords, + * tokens, or full wallet balances reach this field. + */ + metadata: { + type: mongoose.Schema.Types.Mixed, + default: null, + }, + + /** Correlates to the X-Request-Id header / req.id set in app.js. */ + requestId: { + type: String, + default: null, + }, + }, + { + // Only createdAt is meaningful; updatedAt would imply mutability. + timestamps: { createdAt: true, updatedAt: false }, + versionKey: false, + // Collection name is explicit so it never clashes with any future model. + collection: "auditlogs", + } +); + +// ── Indexes ──────────────────────────────────────────────────────────────── +auditLogSchema.index({ actor: 1, createdAt: -1 }); +auditLogSchema.index({ action: 1, createdAt: -1 }); +auditLogSchema.index({ targetType: 1, targetId: 1 }); + +// ── Append-only enforcement ───────────────────────────────────────────────── +// These hooks ensure no document can be mutated or deleted at the model +// layer regardless of how the model is imported. + +const MUTATION_ERROR = + "AuditLog is append-only: update and delete operations are forbidden."; + +// Block save() on existing documents +auditLogSchema.pre("save", function (next) { + if (!this.isNew) { + return next(new Error(MUTATION_ERROR)); + } + next(); +}); + +// Block query-based updates +for (const hook of [ + "updateOne", + "updateMany", + "findOneAndUpdate", + "findByIdAndUpdate", + "replaceOne", +]) { + auditLogSchema.pre(hook, function (next) { + next(new Error(MUTATION_ERROR)); + }); +} + +// Block query-based deletes +for (const hook of ["deleteOne", "deleteMany", "findOneAndDelete", "findByIdAndDelete"]) { + auditLogSchema.pre(hook, function (next) { + next(new Error(MUTATION_ERROR)); + }); +} + +export default mongoose.model("AuditLog", auditLogSchema); diff --git a/src/models/Course.js b/src/models/Course.js index 69aa4d57..bbbec532 100644 --- a/src/models/Course.js +++ b/src/models/Course.js @@ -50,6 +50,20 @@ const courseSchema = new mongoose.Schema( required: true, }, enrolledUsers: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }], + sections: [ + { + title: String, + order: Number, + lessons: [ + { + title: String, + order: Number, + videoUrl: String, + durationSeconds: Number, + }, + ], + }, + ], }, { timestamps: true } diff --git a/src/models/IngestionCursor.js b/src/models/IngestionCursor.js new file mode 100644 index 00000000..2f6af9ed --- /dev/null +++ b/src/models/IngestionCursor.js @@ -0,0 +1,18 @@ +import mongoose from "mongoose"; + +const ingestionCursorSchema = new mongoose.Schema({ + account: { + type: String, + required: true, + unique: true, + }, + cursor: { + type: String, + default: "", + }, + lastSyncAt: { + type: Date, + }, +}, { timestamps: true }); + +export default mongoose.model("IngestionCursor", ingestionCursorSchema); diff --git a/src/models/UnreconciledPayment.js b/src/models/UnreconciledPayment.js new file mode 100644 index 00000000..72c3e989 --- /dev/null +++ b/src/models/UnreconciledPayment.js @@ -0,0 +1,33 @@ +import mongoose from "mongoose"; + +const unreconciledPaymentSchema = new mongoose.Schema({ + stellarTxHash: { + type: String, + required: true, + unique: true, + }, + from: { + type: String, + required: true, + }, + to: { + type: String, + required: true, + }, + amount: { + type: String, + required: true, + }, + memo: { + type: String, + }, + sourceAccount: { + type: String, + }, + reason: { + type: String, + required: true, + }, +}, { timestamps: true }); + +export default mongoose.model("UnreconciledPayment", unreconciledPaymentSchema); diff --git a/src/routes/admin/auditRoutes.js b/src/routes/admin/auditRoutes.js new file mode 100644 index 00000000..3d3b759d --- /dev/null +++ b/src/routes/admin/auditRoutes.js @@ -0,0 +1,126 @@ +// routes/admin/auditRoutes.js +// +// Read-only admin API for querying audit logs. +// Mounted at: /api/admin/audit +// +// Gate: protect (JWT) → authorizeRoles("admin") +// No create / update / delete endpoints are exposed — ever. +import express from "express"; +import mongoose from "mongoose"; +import AuditLog from "../../models/AuditLog.js"; +import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; +import { catchAsync, APIError } from "../../middlewares/errorHandler.js"; + +const router = express.Router(); + +// Apply auth gate to every route in this file +router.use(protect, authorizeRoles("admin")); + +/** + * GET /api/admin/audit + * + * Query params (all optional): + * actor — MongoDB ObjectId string + * action — exact action string (e.g. "auth.login.failure") + * targetType — e.g. "User", "Transaction", "Wallet" + * targetId — arbitrary string + * status — "success" | "failure" + * from — ISO date string (inclusive lower bound on createdAt) + * to — ISO date string (inclusive upper bound on createdAt) + * page — positive integer (default 1) + * limit — positive integer (default 20, max 100) + */ +router.get( + "/", + catchAsync(async (req, res) => { + const { + actor, + action, + targetType, + targetId, + status, + from, + to, + page = "1", + limit = "20", + } = req.query; + + // ── Build filter ────────────────────────────────────────────────────── + const filter = {}; + + if (actor) { + if (!mongoose.Types.ObjectId.isValid(actor)) { + throw new APIError("Invalid actor ObjectId", 400); + } + filter.actor = new mongoose.Types.ObjectId(actor); + } + + if (action) { + filter.action = action; + } + + if (targetType) { + filter.targetType = targetType; + } + + if (targetId) { + filter.targetId = targetId; + } + + if (status) { + if (!["success", "failure"].includes(status)) { + throw new APIError("status must be 'success' or 'failure'", 400); + } + filter.status = status; + } + + if (from || to) { + filter.createdAt = {}; + if (from) { + const fromDate = new Date(from); + if (isNaN(fromDate.getTime())) throw new APIError("Invalid 'from' date", 400); + filter.createdAt.$gte = fromDate; + } + if (to) { + const toDate = new Date(to); + if (isNaN(toDate.getTime())) throw new APIError("Invalid 'to' date", 400); + filter.createdAt.$lte = toDate; + } + } + + // ── Pagination ──────────────────────────────────────────────────────── + const pageNum = Math.max(1, parseInt(page, 10) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 20)); + const skip = (pageNum - 1) * limitNum; + + // ── Query ───────────────────────────────────────────────────────────── + const [logs, total] = await Promise.all([ + AuditLog.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limitNum) + .populate("actor", "name email role") + .lean(), + AuditLog.countDocuments(filter), + ]); + + res.status(200).json({ + success: true, + logs, + pagination: { + page: pageNum, + limit: limitNum, + total, + pages: Math.ceil(total / limitNum), + }, + }); + }) +); + +// Catch-all: any non-GET method on any path under this router returns 405. +// Belt-and-suspenders on top of the model-layer append-only pre-hooks. +router.use((req, res) => + res.status(405).json({ success: false, message: "Method not allowed on audit log" }) +); + +export default router; diff --git a/src/routes/stellar/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js index 4ac207bc..c68fc793 100644 --- a/src/routes/stellar/paymentRoutes.js +++ b/src/routes/stellar/paymentRoutes.js @@ -18,6 +18,7 @@ import { escalateDispute, arbitrateDispute, } from "../../controllers/stellar/refundController.js"; +import { reconciliationStatus } from "../../controllers/stellar/reconciliationController.js"; const router = express.Router(); @@ -47,4 +48,11 @@ router.patch( arbitrateDispute ); +// Admin reconciliation status +router.get( + "/reconciliation/status", + authorizeRoles("admin", "arbiter"), + reconciliationStatus +); + export default router; diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 20a630bf..08797257 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -48,7 +48,7 @@ router.put( "/update/:id", protect, (req, res, next) => { - if (req.user._id.toString() !== req.params.id) { + if (req.user.role !== "admin" && req.user._id.toString() !== req.params.id) { return res.status(403).json({ success: false, message: "Not authorized to update this profile", data: null }); } next(); @@ -70,6 +70,12 @@ router.get( router.delete( "/:id", protect, + (req, res, next) => { + if (req.user.role !== "admin" && req.user._id.toString() !== req.params.id) { + return res.status(403).json({ success: false, message: "Not authorized to delete this user", data: null }); + } + next(); + }, invalidateCacheMiddleware([`${CACHE_KEYS.USER}*`]), deleteUser ); diff --git a/src/services/audit/auditService.js b/src/services/audit/auditService.js new file mode 100644 index 00000000..4d5dca0c --- /dev/null +++ b/src/services/audit/auditService.js @@ -0,0 +1,140 @@ +// services/audit/auditService.js +// +// Non-blocking, redaction-safe helper for writing audit rows. +// +// Usage (fire-and-forget — do NOT await at call site): +// +// recordAudit({ +// action: AUDIT_ACTIONS.AUTH_LOGIN_SUCCESS, +// actor: user._id, // null for anonymous/pre-auth +// req, // pass the Express request for IP/UA/reqId +// targetType: "User", +// targetId: user._id.toString(), +// status: "success", +// metadata: { email: user.email }, // only allowlisted keys are stored +// }); +// +// The function schedules the write via Promise microtask and catches all +// errors internally, so a DB write failure NEVER propagates to the caller. +import mongoose from "mongoose"; +import AuditLog from "../../models/AuditLog.js"; +import logger from "../../config/logger.js"; + +// ── Metadata redaction allowlist ─────────────────────────────────────────── +// ONLY keys listed here will be persisted in metadata. +// Add keys here when instrumenting new actions — never use a denylist approach. +const METADATA_ALLOWLIST = new Set([ + // Identity / context + "email", + "role", + "assignedRole", + "name", + + // Wallet + "publicKey", + "network", + "previousPublicKey", + + // Payment + "transactionId", + "itemType", + "itemId", + "itemTitle", + "amount", + "stellarTxHash", + "stellarLedger", + "settlementMode", + "failureReason", + + // Entitlement + "accessGranted", + + // Payout / ledger (extension — #28) + "batchId", + "payoutAmount", + "educatorId", + + // Role change (extension — #20) + "previousRole", + "newRole", + "changedBy", + + // Generic error context + "reason", + "conflictUserId", +]); + +/** + * Strip any metadata key not in the allowlist. + * Returns null if metadata is null/undefined/empty. + * + * @param {object|null} metadata + * @returns {object|null} + */ +export function redactMetadata(metadata) { + if (!metadata || typeof metadata !== "object") return null; + const safe = {}; + for (const key of Object.keys(metadata)) { + if (METADATA_ALLOWLIST.has(key)) { + safe[key] = metadata[key]; + } + } + return Object.keys(safe).length > 0 ? safe : null; +} + +/** + * Record a security/financial audit event. Always fire-and-forget relative + * to the caller — the write is scheduled in a microtask and errors are + * swallowed (logged only). + * + * @param {object} opts + * @param {string} opts.action - One of AUDIT_ACTIONS values + * @param {string|ObjectId|null} opts.actor - User._id or null + * @param {import("express").Request} [opts.req] - Express request (for IP/UA/reqId) + * @param {string|null} [opts.targetType] + * @param {string|null} [opts.targetId] + * @param {"success"|"failure"} opts.status + * @param {object|null} [opts.metadata] - Will be allowlist-filtered + */ +export function recordAudit({ + action, + actor = null, + req = null, + targetType = null, + targetId = null, + status, + metadata = null, +}) { + // Schedule asynchronously — do not block caller + Promise.resolve() + .then(async () => { + // If DB is not connected and AuditLog.create is not mocked (e.g. unit tests without DB), + // skip write to prevent 10s Mongoose buffer timeouts. + if (mongoose.connection.readyState !== 1 && !AuditLog.create.mock) { + return; + } + + const actorIp = req?.ip ?? null; + const actorUserAgent = req?.headers?.["user-agent"] ?? null; + const requestId = req?.id ?? null; + + await AuditLog.create({ + action, + actor: actor || null, + actorIp, + actorUserAgent, + targetType, + targetId: targetId ? String(targetId) : null, + status, + metadata: redactMetadata(metadata), + requestId, + }); + }) + .catch((err) => { + // Never let an audit failure surface to the user. + logger.error( + { err, action, actor, targetType, targetId }, + "audit: failed to write audit log row" + ); + }); +} diff --git a/src/services/stellar/reconciliationService.js b/src/services/stellar/reconciliationService.js new file mode 100644 index 00000000..41d7d1df --- /dev/null +++ b/src/services/stellar/reconciliationService.js @@ -0,0 +1,268 @@ +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 UnreconciledPayment from "../../models/UnreconciledPayment.js"; +import { recordSaleEarnings } from "../payoutService.js"; +import { toStroops, USDC_ISSUER, verifyPaymentOperations, DONATION_WALLET_PUBLIC_KEY } from "./stellarService.js"; +import logger from "../../config/logger.js"; + +const DONATION_MEMO = "DNB-SADAQAH"; + +const PURCHASE_MEMO_RE = /^DNB-(BOOK|COURSE)-([A-Fa-f0-9]+)$/; + +export const grantItemAccess = async ({ buyerId, itemType, itemId, session }) => { + const buyer = await User.findById(buyerId).session(session || null); + + if (itemType === "book") { + buyer.purchasedBooks.push({ + bookId: itemId, + purchaseDate: new Date(), + }); + if (buyer.stat) { + buyer.stat.booksRead = (buyer.stat.booksRead || 0) + 1; + } + } else { + buyer.purchasedCourses.push({ + courseId: itemId, + purchaseDate: new Date(), + }); + if (buyer.stat) { + buyer.stat.coursesEnrolled = (buyer.stat.coursesEnrolled || 0) + 1; + } + await Course.findByIdAndUpdate( + itemId, + { $addToSet: { enrolledUsers: buyerId } }, + { session: session || null } + ); + } + + await buyer.save({ session: session || null }); +}; + +const buildExpectedPayments = (transaction) => + 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 promoteTransaction = async (transaction, paymentRecord) => { + transaction.stellarTxHash = paymentRecord.transaction_hash; + transaction.stellarLedger = paymentRecord.ledger || undefined; + transaction.status = "confirmed"; + transaction.confirmedAt = new Date(); + await transaction.save(); + + await recordSaleEarnings(transaction); + await grantItemAccess({ + buyerId: transaction.buyer, + itemType: transaction.itemType, + itemId: transaction.itemId, + }); + + logger.info( + { txHash: paymentRecord.transaction_hash, txId: transaction._id }, + "Reconciled and confirmed pending transaction from on-chain data" + ); +}; + +const createConfirmedDonation = async ({ sourceAccount, amount, hash, memo }) => { + const user = await User.findOne({ "stellarWallet.publicKey": sourceAccount }); + if (!user) { + return null; + } + + const donation = new Transaction({ + type: "donation", + buyer: user._id, + buyerWallet: sourceAccount, + creatorWallet: DONATION_WALLET_PUBLIC_KEY, + amount: amount.toString(), + network: process.env.STELLAR_NETWORK || "testnet", + status: "confirmed", + stellarTxHash: hash, + confirmedAt: new Date(), + }); + + await donation.save(); + logger.info( + { txHash: hash, donationId: donation._id }, + "Created confirmed donation from on-chain payment" + ); + return donation; +}; + +const createConfirmedPurchase = async ({ sourceAccount, amount, hash, memo, itemType, itemId }) => { + const user = await User.findOne({ "stellarWallet.publicKey": sourceAccount }); + if (!user) { + return null; + } + + const Model = itemType === "book" ? Book : Course; + const item = await Model.findById(itemId); + if (!item) { + return null; + } + + const itemAmount = toStroops(amount); + const expectedAmount = toStroops(item.price.toString()); + if (itemAmount < expectedAmount) { + return null; + } + + const creator = itemType === "book" ? item.author : item.createdBy; + + const purchase = new Transaction({ + buyer: user._id, + buyerWallet: sourceAccount, + creator: creator?._id, + creatorWallet: creator?.stellarWallet?.publicKey || "", + itemType, + itemId, + itemTypeModel: itemType === "book" ? "Book" : "Course", + itemTitle: item.title, + amount: item.price.toString(), + network: process.env.STELLAR_NETWORK || "testnet", + status: "confirmed", + stellarTxHash: hash, + confirmedAt: new Date(), + }); + + await purchase.save(); + await grantItemAccess({ + buyerId: user._id, + itemType, + itemId, + }); + + logger.info( + { txHash: hash, purchaseId: purchase._id }, + "Created confirmed purchase from on-chain payment" + ); + return purchase; +}; + +export const matchByTxHash = async (txHash) => { + return Transaction.findOne({ + stellarTxHash: txHash, + status: { $in: ["pending", "submitted", "retrying"] }, + }); +}; + +export const matchByMemo = async (memo, sourceAccount) => { + if (!memo) return null; + + if (memo === DONATION_MEMO) { + return { type: "donation", sourceAccount }; + } + + const match = memo.match(PURCHASE_MEMO_RE); + if (match) { + const itemType = match[1].toLowerCase(); + const itemIdSuffix = match[2]; + + const transactions = await Transaction.find({ + itemType, + status: { $in: ["pending", "submitted", "retrying"] }, + }).sort({ createdAt: -1 }); + + for (const tx of transactions) { + if (tx.itemId.toString().slice(-8).toLowerCase() === itemIdSuffix.toLowerCase()) { + return tx; + } + } + + const Model = itemType === "book" ? Book : Course; + const items = await Model.find({}).select("_id title price").lean(); + const matchedItem = items.find( + (it) => it._id.toString().slice(-8).toLowerCase() === itemIdSuffix.toLowerCase() + ); + if (matchedItem) { + return { type: "purchase", itemType, itemId: matchedItem._id, itemPrice: matchedItem.price, sourceAccount }; + } + } + + return null; +}; + +export const reconcilePayment = async (paymentRecord, txRecord) => { + const assetCode = paymentRecord.asset_code || (paymentRecord.type === "path_payment_strict_receive" ? paymentRecord.destination_asset_code : null); + const assetIssuer = paymentRecord.asset_issuer || (paymentRecord.type === "path_payment_strict_receive" ? paymentRecord.destination_asset_issuer : null); + const amount = paymentRecord.amount || paymentRecord.destination_amount; + + if (assetCode !== "USDC" || assetIssuer !== USDC_ISSUER) { + return; + } + + const txHash = paymentRecord.transaction_hash; + const memo = txRecord?.memo; + const sourceAccount = txRecord?.source_account; + + const existingTx = await matchByTxHash(txHash); + if (existingTx) { + const expectedPayments = buildExpectedPayments(existingTx); + const verification = await verifyPaymentOperations(txHash, expectedPayments); + if (verification.verified) { + await promoteTransaction(existingTx, paymentRecord); + return; + } + } + + const memoMatch = await matchByMemo(memo, sourceAccount); + if (memoMatch) { + if (memoMatch.type === "donation") { + const created = await createConfirmedDonation({ + sourceAccount, + amount, + hash: txHash, + memo, + }); + if (created) return; + } else if (memoMatch.type === "purchase") { + const created = await createConfirmedPurchase({ + sourceAccount, + amount, + hash: txHash, + memo, + itemType: memoMatch.itemType, + itemId: memoMatch.itemId, + }); + if (created) return; + } + } + + await UnreconciledPayment.findOneAndUpdate( + { stellarTxHash: txHash }, + { + $setOnInsert: { + from: paymentRecord.from, + to: paymentRecord.to, + amount, + memo: memo || null, + sourceAccount: sourceAccount || null, + reason: "No matching pending transaction or known user found", + }, + }, + { upsert: true } + ); + + logger.warn( + { txHash, memo: memo || "(none)" }, + "Unreconciled USDC payment — no matching pending transaction or user" + ); +}; + +export const getReconciliationStatus = async () => { + const CursorModel = (await import("../../models/IngestionCursor.js")).default; + const cursors = await CursorModel.find({}).lean(); + const unreconciledCount = await UnreconciledPayment.countDocuments(); + const confirmedByWorker = await Transaction.countDocuments({ + status: "confirmed", + confirmedAt: { $exists: true }, + }); + return { cursors, unreconciledCount, confirmedByWorker }; +}; diff --git a/src/workers/paymentIngestionWorker.js b/src/workers/paymentIngestionWorker.js new file mode 100644 index 00000000..1207f226 --- /dev/null +++ b/src/workers/paymentIngestionWorker.js @@ -0,0 +1,136 @@ +import IngestionCursor from "../models/IngestionCursor.js"; +import { client } from "../services/stellar/horizonClient.js"; +import { USDC_ISSUER } from "../services/stellar/stellarService.js"; +import { reconcilePayment } from "../services/stellar/reconciliationService.js"; +import logger from "../config/logger.js"; + +const POLL_INTERVAL_MS = parseInt(process.env.INGESTION_POLL_INTERVAL_MS || "30000", 10); +const PAGE_LIMIT = 200; + +let running = false; +let pollTimer = null; + +const WATCHED_ACCOUNTS = () => { + const accounts = []; + const platformWallet = process.env.PLATFORM_WALLET_PUBLIC_KEY; + const donationWallet = process.env.DONATION_WALLET_PUBLIC_KEY; + if (platformWallet) accounts.push(platformWallet); + if (donationWallet) accounts.push(donationWallet); + return accounts; +}; + +const fetchParentTransaction = async (server, txHash) => { + try { + return await server.transactions().transaction(txHash).call(); + } catch { + return null; + } +}; + +const isUsdcPayment = (record) => { + if (record.type === "payment") { + return record.asset_code === "USDC" && record.asset_issuer === USDC_ISSUER; + } + if (record.type === "path_payment_strict_receive") { + return record.destination_asset_code === "USDC" && record.destination_asset_issuer === USDC_ISSUER; + } + return false; +}; + +const processAccount = async (account, server) => { + let cursorDoc = await IngestionCursor.findOne({ account }); + if (!cursorDoc) { + cursorDoc = await IngestionCursor.create({ account, cursor: "" }); + } + + const savedCursor = cursorDoc.cursor; + let records; + try { + const paymentsCall = server + .payments() + .forAccount(account) + .order("asc") + .limit(PAGE_LIMIT); + + if (savedCursor) { + paymentsCall.cursor(savedCursor); + } + + records = await paymentsCall.call(); + } catch (error) { + logger.error({ account, error: error.message }, "Failed to fetch payments for account"); + return; + } + + if (!records?.records?.length) { + await IngestionCursor.updateOne( + { account }, + { $set: { lastSyncAt: new Date() } } + ); + return; + } + + const usdcPayments = records.records.filter(isUsdcPayment); + + for (const payment of usdcPayments) { + try { + const txRecord = await fetchParentTransaction(server, payment.transaction_hash); + await reconcilePayment(payment, txRecord); + } catch (error) { + logger.error( + { txHash: payment.transaction_hash, error: error.message }, + "Error processing payment record" + ); + } + } + + const lastRecord = records.records[records.records.length - 1]; + const newCursor = lastRecord.paging_token; + + await IngestionCursor.updateOne( + { account }, + { $set: { cursor: newCursor, lastSyncAt: new Date() } } + ); + + logger.info( + { account, cursor: newCursor, usdcProcessed: usdcPayments.length, totalRecords: records.records.length }, + "Account ingestion page complete" + ); +}; + +const ingestionLoop = async (server) => { + if (!running) return; + + const accounts = WATCHED_ACCOUNTS(); + if (accounts.length === 0) { + logger.warn("No watched accounts configured (PLATFORM_WALLET_PUBLIC_KEY / DONATION_WALLET_PUBLIC_KEY)"); + return; + } + + for (const account of accounts) { + await processAccount(account, server); + } + + pollTimer = setTimeout(() => ingestionLoop(server), POLL_INTERVAL_MS); + if (pollTimer && typeof pollTimer.unref === "function") { + pollTimer.unref(); + } +}; + +export const startIngestionWorker = async () => { + if (running) return; + running = true; + + const server = client.endpoints[0].server; + logger.info({ intervalMs: POLL_INTERVAL_MS }, "Payment ingestion worker started"); + ingestionLoop(server); +}; + +export const stopIngestionWorker = async () => { + running = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + logger.info("Payment ingestion worker stopped"); +}; diff --git a/src/workers/runIngestionWorker.js b/src/workers/runIngestionWorker.js new file mode 100644 index 00000000..94bc3ece --- /dev/null +++ b/src/workers/runIngestionWorker.js @@ -0,0 +1,19 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import connectDB from "../config/db.js"; +import validateEnv from "../config/validateEnv.js"; +import logger from "../config/logger.js"; +import { startIngestionWorker } from "./paymentIngestionWorker.js"; + +validateEnv(); + +connectDB() + .then(() => startIngestionWorker()) + .catch((error) => { + logger.error(error, "Failed to start ingestion worker"); + process.exit(1); + }); + +process.on("SIGTERM", () => process.exit(0)); +process.on("SIGINT", () => process.exit(0)); diff --git a/test/auditLog.test.js b/test/auditLog.test.js new file mode 100644 index 00000000..9a22835a --- /dev/null +++ b/test/auditLog.test.js @@ -0,0 +1,529 @@ +// test/auditLog.test.js +// +// Jest + supertest tests for the tamper-evident audit log (issue #66). +// +// Uses the same in-memory mock-store pattern as auth.test.js — no +// mongodb-memory-server or real network calls required. +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import app from "../app.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; +import User from "../src/models/User.js"; +import Session from "../src/models/Session.js"; +import { redactMetadata } from "../src/services/audit/auditService.js"; +import jwt from "jsonwebtoken"; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared in-memory stores (reset per test) +// ───────────────────────────────────────────────────────────────────────────── +let usersStore = []; +let sessionsStore = []; +let auditStore = []; + +const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; + +// Helper: mint a JWT for a user in usersStore +const mintToken = (user) => + jwt.sign({ userId: user._id, role: user.role, sessionId: "sess-1" }, JWT_SECRET, { + expiresIn: "15m", + }); + +// Helper: make a minimal user object +const makeUser = (overrides = {}) => { + const _id = new mongoose.Types.ObjectId().toString(); + return { + _id, + name: "Test User", + email: `user_${_id}@example.com`, + role: "student", + save: async function () { return this; }, + ...overrides, + }; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Global mock setup +// ───────────────────────────────────────────────────────────────────────────── +beforeAll(() => { + // ── AuditLog mocks ────────────────────────────────────────────────────── + jest.spyOn(AuditLog, "create").mockImplementation(async (data) => { + const doc = { + _id: new mongoose.Types.ObjectId().toString(), + createdAt: new Date(), + ...data, + }; + auditStore.push(doc); + return doc; + }); + + jest.spyOn(AuditLog, "find").mockImplementation((filter = {}) => { + const filtered = auditStore.filter((d) => { + if (filter.action && d.action !== filter.action) return false; + if (filter.status && d.status !== filter.status) return false; + if (filter.targetType && d.targetType !== filter.targetType) return false; + if (filter.actor && d.actor?.toString() !== filter.actor?.toString()) return false; + if (filter.createdAt) { + if (filter.createdAt.$gte && new Date(d.createdAt) < filter.createdAt.$gte) return false; + if (filter.createdAt.$lte && new Date(d.createdAt) > filter.createdAt.$lte) return false; + } + return true; + }); + // Chainable query builder + const chain = { + _docs: [...filtered], + sort: function () { return this; }, + skip: function (n) { this._docs = this._docs.slice(n); return this; }, + limit: function (n) { this._docs = this._docs.slice(0, n); return this; }, + populate: function () { return this; }, + lean: async function () { return this._docs; }, + then: (resolve) => resolve(filtered), + }; + return chain; + }); + + jest.spyOn(AuditLog, "countDocuments").mockImplementation(async (filter = {}) => { + return auditStore.filter((d) => { + if (filter.action && d.action !== filter.action) return false; + if (filter.status && d.status !== filter.status) return false; + return true; + }).length; + }); + + // Block mutations — mirror the real pre-hooks + const MUTATION_ERROR = "AuditLog is append-only: update and delete operations are forbidden."; + jest.spyOn(AuditLog, "updateOne").mockImplementation(async () => { + throw new Error(MUTATION_ERROR); + }); + jest.spyOn(AuditLog, "updateMany").mockImplementation(async () => { + throw new Error(MUTATION_ERROR); + }); + jest.spyOn(AuditLog, "deleteOne").mockImplementation(async () => { + throw new Error(MUTATION_ERROR); + }); + jest.spyOn(AuditLog, "deleteMany").mockImplementation(async () => { + throw new Error(MUTATION_ERROR); + }); + + // ── User mocks ────────────────────────────────────────────────────────── + jest.spyOn(User, "findOne").mockImplementation((query) => { + const email = query?.email; + const found = usersStore.find((u) => u.email === email); + return { select: () => found || null, then: (resolve) => resolve(found || null) }; + }); + + jest.spyOn(User, "findById").mockImplementation((id) => { + const found = usersStore.find((u) => u._id.toString() === id.toString()); + return { select: () => found || null, then: (resolve) => resolve(found || null) }; + }); + + jest.spyOn(User, "create").mockImplementation(async (data) => { + const user = makeUser(data); + usersStore.push(user); + return user; + }); + + jest.spyOn(User, "deleteMany").mockImplementation(async () => { + usersStore = []; + return { acknowledged: true }; + }); + + // ── Session mocks ─────────────────────────────────────────────────────── + jest.spyOn(Session, "create").mockImplementation(async (data) => { + const sess = { + _id: new mongoose.Types.ObjectId().toString(), + revokedAt: null, + replacedBy: null, + lastUsedAt: new Date(), + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + ...data, + save: async function () { return this; }, + }; + sessionsStore.push(sess); + return sess; + }); + + jest.spyOn(Session, "findOne").mockImplementation((query) => { + let found = null; + if (query?.refreshTokenHash) { + found = sessionsStore.find((s) => s.refreshTokenHash === query.refreshTokenHash); + } else if (query?._id) { + found = sessionsStore.find( + (s) => s._id.toString() === query._id.toString() && + (!query.user || s.user.toString() === query.user.toString()) + ); + } + return { + populate: () => { + if (!found) return null; + const userObj = usersStore.find((u) => u._id.toString() === (found.user?._id || found.user)?.toString()); + if (userObj) found.user = userObj; + return found; + }, + then: (resolve) => resolve(found || null), + }; + }); + + jest.spyOn(Session, "find").mockImplementation((query) => { + let results = sessionsStore; + if (query?.user) results = results.filter((s) => (s.user?._id || s.user)?.toString() === query.user.toString()); + if (query?.revokedAt === null) results = results.filter((s) => s.revokedAt === null); + if (query?.expiresAt?.$gt) results = results.filter((s) => new Date(s.expiresAt) > query.expiresAt.$gt); + return results; + }); + + jest.spyOn(Session, "updateOne").mockImplementation(async (query, update) => { + const found = sessionsStore.find((s) => + (query.refreshTokenHash && s.refreshTokenHash === query.refreshTokenHash) || + (query._id && s._id.toString() === query._id.toString()) + ); + if (found && update.$set) Object.assign(found, update.$set); + return { acknowledged: true }; + }); + + jest.spyOn(Session, "updateMany").mockImplementation(async (query, update) => { + let matches = sessionsStore; + if (query?.family) matches = matches.filter((s) => s.family === query.family); + if (query?.user) matches = matches.filter((s) => s.user?.toString() === query.user.toString()); + if (query?._id?.$ne) matches = matches.filter((s) => s._id.toString() !== query._id.$ne.toString()); + if (query?.revokedAt === null) matches = matches.filter((s) => s.revokedAt === null); + if (update.$set) matches.forEach((s) => Object.assign(s, update.$set)); + return { acknowledged: true }; + }); + + jest.spyOn(Session, "deleteMany").mockImplementation(async () => { + sessionsStore = []; + return { acknowledged: true }; + }); +}); + +beforeEach(() => { + usersStore = []; + sessionsStore = []; + auditStore = []; +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 1. REDACTION UNIT TESTS +// ───────────────────────────────────────────────────────────────────────────── +describe("redactMetadata()", () => { + it("strips secrets and PII not in the allowlist", () => { + const raw = { + email: "user@example.com", // allowlisted + password: "hunter2", // NOT allowed + token: "abc.def.ghi", // NOT allowed + signedXdr: "AAAA...", // NOT allowed + otp: "123456", // NOT allowed + newPassword: "s3cr3t", // NOT allowed + transactionId: "tx-123", // allowlisted + }; + const result = redactMetadata(raw); + expect(result).toHaveProperty("email"); + expect(result).toHaveProperty("transactionId"); + expect(result).not.toHaveProperty("password"); + expect(result).not.toHaveProperty("token"); + expect(result).not.toHaveProperty("signedXdr"); + expect(result).not.toHaveProperty("otp"); + expect(result).not.toHaveProperty("newPassword"); + }); + + it("returns null for null input", () => { + expect(redactMetadata(null)).toBeNull(); + }); + + it("returns null when all keys are stripped", () => { + expect(redactMetadata({ password: "x", token: "y" })).toBeNull(); + }); + + it("returns null for non-object input", () => { + expect(redactMetadata("string")).toBeNull(); + expect(redactMetadata(42)).toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. AUDIT ROW WRITTEN ON AUDITED ACTION +// ───────────────────────────────────────────────────────────────────────────── +describe("Audit row written on register", () => { + it("writes a success row with correct action, status, and safe metadata", async () => { + const res = await request(app).post("/api/auth/register").send({ + name: "Alice", + email: "alice@example.com", + password: "password123", + role: "student", + }); + + expect(res.statusCode).toBe(201); + + // Give the fire-and-forget microtask a tick to complete + await new Promise((resolve) => setImmediate(resolve)); + + const row = auditStore.find((r) => r.action === AUDIT_ACTIONS.AUTH_REGISTER_SUCCESS); + expect(row).toBeDefined(); + expect(row.status).toBe("success"); + expect(row.targetType).toBe("User"); + + // Sensitive field must NOT appear in stored metadata + expect(row.metadata).not.toHaveProperty("password"); + expect(row.metadata?.email).toBe("alice@example.com"); + }); + + it("writes a failure row when email already exists", async () => { + // First register + await request(app).post("/api/auth/register").send({ + name: "Bob", + email: "bob@example.com", + password: "password123", + }); + await new Promise((resolve) => setImmediate(resolve)); + + // Second attempt with same email + const res = await request(app).post("/api/auth/register").send({ + name: "Bob Again", + email: "bob@example.com", + password: "password456", + }); + + expect(res.statusCode).toBe(400); + await new Promise((resolve) => setImmediate(resolve)); + + const row = auditStore.find((r) => r.action === AUDIT_ACTIONS.AUTH_REGISTER_FAILURE); + expect(row).toBeDefined(); + expect(row.status).toBe("failure"); + expect(row.actor).toBeNull(); + expect(row.metadata?.reason).toBe("email_already_exists"); + expect(row.metadata).not.toHaveProperty("password"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. ADMIN ROUTE — AUTHENTICATION & AUTHORIZATION +// ───────────────────────────────────────────────────────────────────────────── +describe("GET /api/admin/audit — access control", () => { + it("returns 401 when no Authorization header is provided", async () => { + const res = await request(app).get("/api/admin/audit"); + expect(res.statusCode).toBe(401); + }); + + it("returns 403 when authenticated as a non-admin user (student)", async () => { + const student = makeUser({ role: "student" }); + usersStore.push(student); + const token = mintToken(student); + + jest.spyOn(User, "findById").mockImplementationOnce((id) => { + const found = usersStore.find((u) => u._id.toString() === id.toString()); + return { select: () => found || null, then: (resolve) => resolve(found || null) }; + }); + + const res = await request(app) + .get("/api/admin/audit") + .set("Authorization", `Bearer ${token}`); + + expect(res.statusCode).toBe(403); + }); + + it("returns 200 with logs array and pagination when authenticated as admin", async () => { + const admin = makeUser({ role: "admin" }); + usersStore.push(admin); + const token = mintToken(admin); + + // Seed a row + auditStore.push({ + _id: new mongoose.Types.ObjectId().toString(), + action: AUDIT_ACTIONS.AUTH_LOGIN_SUCCESS, + actor: admin._id, + actorIp: "127.0.0.1", + status: "success", + targetType: "User", + targetId: admin._id, + metadata: { email: admin.email }, + createdAt: new Date(), + }); + + const res = await request(app) + .get("/api/admin/audit") + .set("Authorization", `Bearer ${token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.logs)).toBe(true); + expect(res.body.pagination).toHaveProperty("total"); + expect(res.body.pagination).toHaveProperty("page"); + expect(res.body.pagination).toHaveProperty("limit"); + expect(res.body.pagination).toHaveProperty("pages"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. ADMIN ROUTE — FILTERING +// ───────────────────────────────────────────────────────────────────────────── +describe("GET /api/admin/audit — filtering", () => { + let adminToken; + + beforeEach(() => { + const admin = makeUser({ role: "admin" }); + usersStore.push(admin); + adminToken = mintToken(admin); + + // Seed several rows + const now = new Date(); + auditStore.push( + { + _id: new mongoose.Types.ObjectId().toString(), + action: AUDIT_ACTIONS.AUTH_LOGIN_SUCCESS, + status: "success", + targetType: "User", + createdAt: now, + }, + { + _id: new mongoose.Types.ObjectId().toString(), + action: AUDIT_ACTIONS.AUTH_LOGIN_FAILURE, + status: "failure", + targetType: "User", + createdAt: now, + }, + { + _id: new mongoose.Types.ObjectId().toString(), + action: AUDIT_ACTIONS.PAYMENT_INITIALIZE, + status: "success", + targetType: "Transaction", + createdAt: now, + } + ); + }); + + it("filters by action", async () => { + const res = await request(app) + .get(`/api/admin/audit?action=${AUDIT_ACTIONS.AUTH_LOGIN_SUCCESS}`) + .set("Authorization", `Bearer ${adminToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.logs.every((l) => l.action === AUDIT_ACTIONS.AUTH_LOGIN_SUCCESS)).toBe(true); + }); + + it("filters by status=failure", async () => { + const res = await request(app) + .get("/api/admin/audit?status=failure") + .set("Authorization", `Bearer ${adminToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.logs.every((l) => l.status === "failure")).toBe(true); + }); + + it("returns 400 for invalid status value", async () => { + const res = await request(app) + .get("/api/admin/audit?status=invalid") + .set("Authorization", `Bearer ${adminToken}`); + + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid actor ObjectId", async () => { + const res = await request(app) + .get("/api/admin/audit?actor=notanid") + .set("Authorization", `Bearer ${adminToken}`); + + expect(res.statusCode).toBe(400); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. ADMIN ROUTE — PAGINATION +// ───────────────────────────────────────────────────────────────────────────── +describe("GET /api/admin/audit — pagination", () => { + let adminToken; + + beforeEach(() => { + const admin = makeUser({ role: "admin" }); + usersStore.push(admin); + adminToken = mintToken(admin); + + // Seed 7 rows + for (let i = 0; i < 7; i++) { + auditStore.push({ + _id: new mongoose.Types.ObjectId().toString(), + action: AUDIT_ACTIONS.AUTH_LOGIN_SUCCESS, + status: "success", + createdAt: new Date(), + }); + } + }); + + it("respects page and limit", async () => { + const res = await request(app) + .get("/api/admin/audit?page=1&limit=3") + .set("Authorization", `Bearer ${adminToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.logs.length).toBeLessThanOrEqual(3); + expect(res.body.pagination.limit).toBe(3); + expect(res.body.pagination.page).toBe(1); + expect(res.body.pagination.pages).toBe(Math.ceil(7 / 3)); + }); + + it("caps limit at 100", async () => { + const res = await request(app) + .get("/api/admin/audit?limit=999") + .set("Authorization", `Bearer ${adminToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.pagination.limit).toBe(100); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 6. APPEND-ONLY ENFORCEMENT +// ───────────────────────────────────────────────────────────────────────────── +describe("AuditLog append-only enforcement", () => { + it("throws when updateOne is called on AuditLog", async () => { + await expect(AuditLog.updateOne({ _id: "x" }, { action: "tampered" })) + .rejects.toThrow("append-only"); + }); + + it("throws when updateMany is called on AuditLog", async () => { + await expect(AuditLog.updateMany({}, { action: "tampered" })) + .rejects.toThrow("append-only"); + }); + + it("throws when deleteOne is called on AuditLog", async () => { + await expect(AuditLog.deleteOne({ _id: "x" })) + .rejects.toThrow("append-only"); + }); + + it("throws when deleteMany is called on AuditLog", async () => { + await expect(AuditLog.deleteMany({})) + .rejects.toThrow("append-only"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 7. NO MUTATING VERBS ON ADMIN ROUTE +// ───────────────────────────────────────────────────────────────────────────── +describe("Admin audit route — no mutating endpoints", () => { + let adminToken; + + beforeEach(() => { + const admin = makeUser({ role: "admin" }); + usersStore.push(admin); + adminToken = mintToken(admin); + }); + + it("POST /api/admin/audit returns 405", async () => { + const res = await request(app) + .post("/api/admin/audit") + .set("Authorization", `Bearer ${adminToken}`) + .send({ action: "tampered" }); + expect(res.statusCode).toBe(405); + }); + + it("DELETE /api/admin/audit returns 405", async () => { + const res = await request(app) + .delete("/api/admin/audit/someid") + .set("Authorization", `Bearer ${adminToken}`); + expect(res.statusCode).toBe(405); + }); +}); diff --git a/test/authRoles.test.js b/test/authRoles.test.js index 6c2333b7..29f1e836 100644 --- a/test/authRoles.test.js +++ b/test/authRoles.test.js @@ -12,7 +12,7 @@ import { protect, authorize, restrictTo } from "../src/middlewares/authMiddlewar 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 { updateUser, deleteUser, getUser } from "../src/controllers/userController.js"; import { updateCourse } from "../src/controllers/courses/courseController.js"; describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { @@ -282,5 +282,99 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { const resDelete = await request(app).delete(`/users/${authorUser._id}`); expect(resDelete.status).toBe(403); }); + + it("allows self-update and self-delete", 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/${studentUser._id}`).send({ name: "Updated Self" }); + expect(resUpdate.status).toBe(200); + expect(resUpdate.body.user.name).toBe("Updated Self"); + + const resDelete = await request(app).delete(`/users/${studentUser._id}`); + expect(resDelete.status).toBe(200); + + const deletedUser = await User.findById(studentUser._id); + expect(deletedUser).toBeNull(); + }); + + it("allows admin to update or delete any user", async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = adminUser; + next(); + }); + app.put("/users/:id", updateUser); + app.delete("/users/:id", deleteUser); + + const resUpdate = await request(app).put(`/users/${studentUser._id}`).send({ name: "Admin Updated" }); + expect(resUpdate.status).toBe(200); + + const resDelete = await request(app).delete(`/users/${authorUser._id}`); + expect(resDelete.status).toBe(200); + }); + + it("rejects duplicate email update with 409", async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = studentUser; + next(); + }); + app.put("/users/:id", updateUser); + + const res = await request(app) + .put(`/users/${studentUser._id}`) + .send({ email: authorUser.email }); + expect(res.status).toBe(409); + expect(res.body.message).toBe("A user with this email already exists"); + }); + + it("returns only public fields for non-self getUser", async () => { + const app = express(); + app.use((req, _res, next) => { + req.user = studentUser; + next(); + }); + app.get("/users/:id", getUser); + + const res = await request(app).get(`/users/${authorUser._id}`); + expect(res.status).toBe(200); + + const body = res.body.user; + expect(body.name).toBeDefined(); + expect(body.role).toBeDefined(); + expect(body.email).toBeUndefined(); + expect(body.stellarWallet).toBeUndefined(); + expect(body.following).toBeUndefined(); + expect(body.followers).toBeUndefined(); + expect(body.purchasedBooks).toBeUndefined(); + expect(body.purchasedCourses).toBeUndefined(); + }); + + it("returns full user document for self getUser", async () => { + const app = express(); + app.use((req, _res, next) => { + req.user = studentUser; + next(); + }); + app.get("/users/:id", getUser); + + const res = await request(app).get(`/users/${studentUser._id}`); + expect(res.status).toBe(200); + + const body = res.body.user; + expect(body.name).toBeDefined(); + expect(body.email).toBeDefined(); + expect(body.following).toBeDefined(); + expect(body.followers).toBeDefined(); + }); }); }); diff --git a/test/reconciliation.test.js b/test/reconciliation.test.js new file mode 100644 index 00000000..0fa73d1a --- /dev/null +++ b/test/reconciliation.test.js @@ -0,0 +1,334 @@ +import { jest } from "@jest/globals"; +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 Transaction from "../src/models/Transaction.js"; +import UnreconciledPayment from "../src/models/UnreconciledPayment.js"; +import IngestionCursor from "../src/models/IngestionCursor.js"; + +const mockVerifyPaymentOperations = jest.fn(); +const mockRecordSaleEarnings = jest.fn(); + +const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings: mockRecordSaleEarnings, +})); + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + toStroops: (amount) => { + const [whole, frac = ""] = amount.toString().split("."); + return ( + BigInt(whole || "0") * 10000000n + + BigInt((frac + "0000000").slice(0, 7)) + ); + }, + USDC_ISSUER, + DONATION_WALLET_PUBLIC_KEY: "GDONATIONWALLET123456789", + verifyPaymentOperations: mockVerifyPaymentOperations, +})); + +const { + reconcilePayment, + matchByTxHash, + matchByMemo, + getReconciliationStatus, + grantItemAccess, +} = await import("../src/services/stellar/reconciliationService.js"); + +describe("Payment Reconciliation Service", () => { + let mongoServer; + let buyer, author, admin, book, course; + + 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 Promise.all([ + User.deleteMany({}), + Book.deleteMany({}), + Transaction.deleteMany({}), + UnreconciledPayment.deleteMany({}), + IngestionCursor.deleteMany({}), + ]); + + const makeKey = (prefix) => { + const p = prefix.padEnd(55, "0").slice(0, 55).toUpperCase(); + return "G" + p; + }; + + buyer = await User.create({ + name: "Buyer", + email: "buyer@test.com", + password: "password123", + stellarWallet: { publicKey: makeKey("BUYER") }, + }); + + author = await User.create({ + name: "Author", + email: "author@test.com", + password: "password123", + stellarWallet: { publicKey: makeKey("AUTHOR") }, + }); + + book = await Book.create({ + title: "Test Book", + description: "A test book", + category: "Tech", + price: 25, + author: author._id, + thumbnail: "https://example.com/thumb.jpg", + image: "https://example.com/image.jpg", + fileUrl: "https://example.com/file.pdf", + }); + + mockVerifyPaymentOperations.mockReset(); + mockRecordSaleEarnings.mockReset(); + mockRecordSaleEarnings.mockResolvedValue({ success: true }); + }); + + describe("matchByTxHash", () => { + it("finds a pending transaction by hash", async () => { + const tx = await Transaction.create({ + stellarTxHash: "hash123", + buyer: buyer._id, + buyerWallet: buyer.stellarWallet.publicKey, + creator: author._id, + creatorWallet: author.stellarWallet.publicKey, + itemType: "book", + itemId: book._id, + itemTypeModel: "Book", + itemTitle: book.title, + amount: "25", + network: "testnet", + status: "pending", + }); + + const found = await matchByTxHash("hash123"); + expect(found).not.toBeNull(); + expect(found._id.toString()).toBe(tx._id.toString()); + }); + + it("returns null for confirmed transactions", async () => { + await Transaction.create({ + stellarTxHash: "hash456", + buyer: buyer._id, + buyerWallet: buyer.stellarWallet.publicKey, + creator: author._id, + creatorWallet: author.stellarWallet.publicKey, + itemType: "book", + itemId: book._id, + itemTypeModel: "Book", + itemTitle: book.title, + amount: "25", + network: "testnet", + status: "confirmed", + }); + + const found = await matchByTxHash("hash456"); + expect(found).toBeNull(); + }); + + it("returns null for unknown hash", async () => { + const found = await matchByTxHash("nonexistent"); + expect(found).toBeNull(); + }); + }); + + describe("matchByMemo", () => { + it("matches donation memo", async () => { + const result = await matchByMemo("DNB-SADAQAH", "GBUYERWALLET"); + expect(result).toEqual({ type: "donation", sourceAccount: "GBUYERWALLET" }); + }); + + it("matches purchase memo to pending transaction", async () => { + const itemIdStr = book._id.toString(); + const memo = `DNB-BOOK-${itemIdStr.slice(-8)}`; + + await Transaction.create({ + stellarTxHash: "pending-hash", + buyer: buyer._id, + buyerWallet: buyer.stellarWallet.publicKey, + creator: author._id, + creatorWallet: author.stellarWallet.publicKey, + itemType: "book", + itemId: book._id, + itemTypeModel: "Book", + itemTitle: book.title, + amount: "25", + network: "testnet", + status: "pending", + }); + + const result = await matchByMemo(memo, buyer.stellarWallet.publicKey); + expect(result).not.toBeNull(); + expect(result._id).toBeDefined(); + expect(result.status).toBe("pending"); + }); + + it("falls back to item lookup when no pending transaction exists", async () => { + const itemIdStr = book._id.toString(); + const memo = `DNB-BOOK-${itemIdStr.slice(-8)}`; + + const result = await matchByMemo(memo, buyer.stellarWallet.publicKey); + expect(result).not.toBeNull(); + expect(result.type).toBe("purchase"); + expect(result.itemType).toBe("book"); + expect(result.itemId.toString()).toBe(book._id.toString()); + }); + + it("returns null for unknown memo", async () => { + const result = await matchByMemo("UNKNOWN-MEMO", "GBUYERWALLET"); + expect(result).toBeNull(); + }); + + it("returns null for empty memo", async () => { + const result = await matchByMemo(null, "GBUYERWALLET"); + expect(result).toBeNull(); + }); + }); + + describe("reconcilePayment", () => { + it("promotes pending transaction via hash match", async () => { + const tx = await Transaction.create({ + stellarTxHash: "existing-hash", + buyer: buyer._id, + buyerWallet: buyer.stellarWallet.publicKey, + creator: author._id, + creatorWallet: author.stellarWallet.publicKey, + itemType: "book", + itemId: book._id, + itemTypeModel: "Book", + itemTitle: book.title, + amount: "25", + network: "testnet", + status: "pending", + }); + + mockVerifyPaymentOperations.mockResolvedValue({ verified: true }); + mockRecordSaleEarnings.mockResolvedValue({ success: true }); + + const paymentRecord = { + transaction_hash: "existing-hash", + from: buyer.stellarWallet.publicKey, + to: author.stellarWallet.publicKey, + amount: "25.0000000", + asset_code: "USDC", + asset_issuer: USDC_ISSUER, + type: "payment", + }; + + const txRecord = { + memo: `DNB-BOOK-${book._id.toString().slice(-8)}`, + source_account: buyer.stellarWallet.publicKey, + }; + + await reconcilePayment(paymentRecord, txRecord); + + const updated = await Transaction.findById(tx._id); + expect(updated.status).toBe("confirmed"); + expect(updated.confirmedAt).toBeDefined(); + expect(mockRecordSaleEarnings).toHaveBeenCalled(); + }); + + it("skips non-USDC payments", async () => { + const paymentRecord = { + transaction_hash: "non-usdc-hash", + from: "GABC", + to: "GDEF", + amount: "100.0000000", + asset_code: "XLM", + type: "payment", + }; + + await reconcilePayment(paymentRecord, null); + + const unreconciled = await UnreconciledPayment.countDocuments(); + expect(unreconciled).toBe(0); + }); + + it("writes UnreconciledPayment when no match is found", async () => { + mockVerifyPaymentOperations.mockResolvedValue({ verified: false }); + + const paymentRecord = { + transaction_hash: "unmatched-hash", + from: "GUNKNOWN123", + to: "GDEST456", + amount: "50.0000000", + asset_code: "USDC", + asset_issuer: USDC_ISSUER, + type: "payment", + }; + + const txRecord = { + memo: "SOME-RANDOM-MEMO", + source_account: "GUNKNOWN123", + }; + + await reconcilePayment(paymentRecord, txRecord); + + const unreconciled = await UnreconciledPayment.findOne({ stellarTxHash: "unmatched-hash" }); + expect(unreconciled).not.toBeNull(); + expect(unreconciled.reason).toBeDefined(); + expect(unreconciled.from).toBe("GUNKNOWN123"); + }); + + it("creates confirmed donation from chain data when memo is DNB-SADAQAH", async () => { + const paymentRecord = { + transaction_hash: "donation-hash-1", + from: buyer.stellarWallet.publicKey, + to: "GDONATIONWALLET123456789", + amount: "10.0000000", + asset_code: "USDC", + asset_issuer: USDC_ISSUER, + type: "payment", + }; + + const txRecord = { + memo: "DNB-SADAQAH", + source_account: buyer.stellarWallet.publicKey, + }; + + await reconcilePayment(paymentRecord, txRecord); + + const donation = await Transaction.findOne({ stellarTxHash: "donation-hash-1" }); + expect(donation).not.toBeNull(); + expect(donation.type).toBe("donation"); + expect(donation.status).toBe("confirmed"); + expect(donation.buyer.toString()).toBe(buyer._id.toString()); + }); + }); + + describe("getReconciliationStatus", () => { + it("returns cursor positions and counts", async () => { + await IngestionCursor.create({ + account: "GPLATFORM123", + cursor: "12345-1", + lastSyncAt: new Date(), + }); + + await UnreconciledPayment.create({ + stellarTxHash: "bad-hash", + from: "GABC", + to: "GDEF", + amount: "100", + reason: "No match found", + }); + + const status = await getReconciliationStatus(); + expect(Array.isArray(status.cursors)).toBe(true); + expect(status.cursors).toHaveLength(1); + expect(status.cursors[0].account).toBe("GPLATFORM123"); + expect(status.cursors[0].cursor).toBe("12345-1"); + expect(status.unreconciledCount).toBe(1); + }); + }); +});