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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
488 changes: 488 additions & 0 deletions src/__tests__/donation.service.test.ts

Large diffs are not rendered by default.

221 changes: 221 additions & 0 deletions src/__tests__/donation.validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import {
createDonationSchema,
listDonationsQuerySchema,
donationParamsSchema,
} from '../components/v1/Donation/donation.validation';

const validAddress = '0x1234567890abcdef1234567890abcdef12345678';
const validTokenAddress = '0xabcdef1234567890abcdef1234567890abcdef12';

test('createDonationSchema validates required fields', () => {
assert.throws(() => createDonationSchema.parse({}), /Required/);
});

test('createDonationSchema rejects invalid Ethereum addresses', () => {
assert.throws(
() =>
createDonationSchema.parse({
campaignId: 'camp_1',
donorAddress: 'invalid',
tokenAddress: validTokenAddress,
tokenSymbol: 'USDC',
tokenDecimals: 6,
amount: '100',
}),
/donorAddress/
);

assert.throws(
() =>
createDonationSchema.parse({
campaignId: 'camp_1',
donorAddress: validAddress,
tokenAddress: 'invalid',
tokenSymbol: 'USDC',
tokenDecimals: 6,
amount: '100',
}),
/tokenAddress/
);
});

test('createDonationSchema rejects invalid decimal strings', () => {
assert.throws(
() =>
createDonationSchema.parse({
campaignId: 'camp_1',
donorAddress: validAddress,
tokenAddress: validTokenAddress,
tokenSymbol: 'USDC',
tokenDecimals: 6,
amount: 'abc',
}),
/amount/
);
});

test('createDonationSchema rejects invalid token decimals', () => {
assert.throws(
() =>
createDonationSchema.parse({
campaignId: 'camp_1',
donorAddress: validAddress,
tokenAddress: validTokenAddress,
tokenSymbol: 'USDC',
tokenDecimals: -1,
amount: '100',
}),
/tokenDecimals/
);

assert.throws(
() =>
createDonationSchema.parse({
campaignId: 'camp_1',
donorAddress: validAddress,
tokenAddress: validTokenAddress,
tokenSymbol: 'USDC',
tokenDecimals: 31,
amount: '100',
}),
/tokenDecimals/
);
});

test('createDonationSchema accepts valid input with optional fields', () => {
const result = createDonationSchema.parse({
campaignId: 'camp_1',
campaignRef: 'REF123',
donorId: 'user_1',
donorAddress: validAddress,
donorName: 'John Doe',
tokenAddress: validTokenAddress,
tokenSymbol: 'USDC',
tokenDecimals: 6,
amount: '100.50',
usdAmount: '100.50',
gasFee: '0.001',
transactionHash:
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
network: 'mainnet',
isAnonymous: false,
message: 'Great campaign!',
campaignTitle: 'Help the kids',
});

assert.equal(result.campaignId, 'camp_1');
assert.equal(result.donorAddress, validAddress);
assert.equal(result.amount, '100.50');
assert.equal(result.isAnonymous, false);
assert.equal(result.message, 'Great campaign!');
assert.equal(result.campaignTitle, 'Help the kids');
});

test('createDonationSchema accepts minimal valid input', () => {
const result = createDonationSchema.parse({
campaignId: 'camp_1',
donorAddress: validAddress,
tokenAddress: validTokenAddress,
tokenSymbol: 'USDC',
tokenDecimals: 6,
amount: '50',
});

assert.equal(result.campaignId, 'camp_1');
assert.equal(result.amount, '50');
});

test('listDonationsQuerySchema provides defaults for page and limit', () => {
const result = listDonationsQuerySchema.parse({});

assert.equal(result.page, 1);
assert.equal(result.limit, 20);
assert.equal(result.sort_by, 'created_at');
assert.equal(result.sort_order, 'desc');
});

test('listDonationsQuerySchema clamps limit to max 100', () => {
const result = listDonationsQuerySchema.parse({
limit: '999',
});

assert.equal(result.limit, 100);
});

test('listDonationsQuerySchema converts string page/limit to numbers', () => {
const result = listDonationsQuerySchema.parse({
page: '3',
limit: '10',
});

assert.equal(result.page, 3);
assert.equal(result.limit, 10);
});

test('listDonationsQuerySchema accepts all filter fields', () => {
const result = listDonationsQuerySchema.parse({
page: '1',
limit: '20',
sort_by: 'amount',
sort_order: 'asc',
from_date: '2024-01-01T00:00:00.000Z',
to_date: '2024-12-31T23:59:59.000Z',
min_amount: '10',
max_amount: '1000',
campaign_id: 'camp_1',
donor_id: 'user_1',
donation_token: validTokenAddress,
status: 'confirmed',
confirmed: 'true',
search: 'john',
});

assert.equal(result.sort_by, 'amount');
assert.equal(result.sort_order, 'asc');
assert.equal(result.status, 'confirmed');
assert.equal(result.confirmed, true);
assert.equal(result.search, 'john');
});

test('listDonationsQuerySchema parses sort_by campaign_ref, campaign_title and donor_address', () => {
const byCampaign = listDonationsQuerySchema.parse({
sort_by: 'campaign_ref',
});
assert.equal(byCampaign.sort_by, 'campaign_ref');

const byTitle = listDonationsQuerySchema.parse({
sort_by: 'campaign_title',
});
assert.equal(byTitle.sort_by, 'campaign_title');

const byDonor = listDonationsQuerySchema.parse({
sort_by: 'donor_address',
});
assert.equal(byDonor.sort_by, 'donor_address');
});

test('listDonationsQuerySchema parses confirmed param', () => {
const confirmed = listDonationsQuerySchema.parse({ confirmed: 'true' });
assert.equal(confirmed.confirmed, true);

const notConfirmed = listDonationsQuerySchema.parse({ confirmed: 'false' });
assert.equal(notConfirmed.confirmed, false);

const unset = listDonationsQuerySchema.parse({});
assert.equal(unset.confirmed, undefined);
});

test('donationParamsSchema validates UUID format', () => {
assert.throws(
() => donationParamsSchema.parse({ id: 'not-a-uuid' }),
/uuid/
);
assert.doesNotThrow(() =>
donationParamsSchema.parse({
id: '550e8400-e29b-41d4-a716-446655440000',
})
);
});
128 changes: 81 additions & 47 deletions src/appMiddlewares/jwtAuth.api.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,89 @@
import type { NextFunction, Response } from "express"
import jwt from "jsonwebtoken"
import type { NextFunction, Response } from 'express';
import jwt from 'jsonwebtoken';

import type { IRequest } from "../types/global"
import appConfigs from "../config"
import { sendError } from "../utils/apiResponse"
import type { IRequest } from '../types/global';
import appConfigs from '../config';
import { sendError } from '../utils/apiResponse';

type JwtClaims = jwt.JwtPayload & {
sub?: string
userId?: string
id?: string
walletAddress?: string
address?: string
email?: string
}

export const requireJwtAuthApi = (req: IRequest, res: Response, next: NextFunction) => {
const header = req.headers.authorization
const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : ""

if (!token) {
return sendError(res, 401, {
code: "AUTH_MISSING_TOKEN",
message: "Missing authentication token",
})
}

try {
const decoded = jwt.verify(token, appConfigs.authConfig.jwtSecret) as JwtClaims

const userId = decoded.sub ?? decoded.userId ?? decoded.id
if (!userId) {
return sendError(res, 401, {
code: "AUTH_INVALID_TOKEN",
message: "Invalid authentication token",
})
sub?: string;
userId?: string;
id?: string;
walletAddress?: string;
address?: string;
email?: string;
role?: string;
userType?: string;
};

export const requireJwtAuthApi = (
req: IRequest,
res: Response,
next: NextFunction
) => {
const header = req.headers.authorization;
const token = header?.startsWith('Bearer ')
? header.slice('Bearer '.length).trim()
: '';

if (!token) {
return sendError(res, 401, {
code: 'AUTH_MISSING_TOKEN',
message: 'Missing authentication token',
});
}

try {
const decoded = jwt.verify(
token,
appConfigs.authConfig.jwtSecret
) as JwtClaims;

const userId = decoded.sub ?? decoded.userId ?? decoded.id;
if (!userId) {
return sendError(res, 401, {
code: 'AUTH_INVALID_TOKEN',
message: 'Invalid authentication token',
});
}

req.auth = {
userId: String(userId),
walletAddress: decoded.walletAddress ?? decoded.address,
email: decoded.email,
claims: decoded,
};

return next();
} catch (error) {
return sendError(res, 401, {
code: 'AUTH_INVALID_TOKEN',
message: 'Invalid authentication token',
details: error instanceof Error ? { name: error.name } : {},
});
}
};

const ADMIN_ROLES = ['super-admin', 'admin'];

req.auth = {
userId: String(userId),
walletAddress: decoded.walletAddress ?? decoded.address,
email: decoded.email,
claims: decoded,
export const requireAdminApi = (
req: IRequest,
res: Response,
next: NextFunction
) => {
const role = req.auth?.claims?.role as string | undefined;
const userType = req.auth?.claims?.userType as string | undefined;

if (role && ADMIN_ROLES.includes(role)) {
return next();
}

return next()
} catch (error) {
return sendError(res, 401, {
code: "AUTH_INVALID_TOKEN",
message: "Invalid authentication token",
details: error instanceof Error ? { name: error.name } : {},
})
}
}
if (userType && ADMIN_ROLES.includes(userType)) {
return next();
}

return sendError(res, 403, {
code: 'FORBIDDEN',
message: 'Admin access required',
});
};
Loading