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
88 changes: 88 additions & 0 deletions stellar-payment-platform/federation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'use strict';

const request = require('supertest');

jest.mock('./prismaClient', () => ({
prisma: {
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
},
$disconnect: jest.fn().mockResolvedValue(undefined),
},
}));

jest.mock('@stellar/stellar-sdk', () => ({
Horizon: { Server: jest.fn() },
StrKey: { isValidEd25519PublicKey: jest.fn(() => true) },
}));

jest.mock('pdfkit', () => jest.fn());
jest.mock('./src/cleanup-cron', () => ({ scheduleCleanupJob: jest.fn() }));

const { app, prisma } = require('./server');

describe('GET /federation', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('returns 400 if q parameter is missing', async () => {
const response = await request(app).get('/federation');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe("Missing 'q' parameter");
});

test('successfully looks up username and formats Stellar TOML response', async () => {
prisma.user.findUnique.mockResolvedValue({
address: 'GDQ4X7B2QWYRDB6S2Y5R6G6U4E6U6C7G6U6C7G6U6C7G6U6C7G6U6C7G',
});

const response = await request(app).get('/federation?q=alice*localhost');

expect(response.status).toBe(200);
expect(response.body).toEqual({
stellar_address: 'GDQ4X7B2QWYRDB6S2Y5R6G6U4E6U6C7G6U6C7G6U6C7G6U6C7G6U6C7G',
account_id: 'GDQ4X7B2QWYRDB6S2Y5R6G6U4E6U6C7G6U6C7G6U6C7G6U6C7G6U6C7G',
memo_type: 'text',
memo: 'PlatformPayment',
});
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { username: 'alice*localhost' },
select: { address: true },
});
});

test('returns 404 for missing username lookup', async () => {
prisma.user.findUnique.mockResolvedValue(null);

const response = await request(app).get('/federation?q=unknown*localhost');

expect(response.status).toBe(404);
expect(response.body.error).toBe('Name tag not found');
});

test('successfully looks up address when type=id', async () => {
prisma.user.findFirst.mockResolvedValue({
username: 'bob',
address: 'GDQ4X7B2QWYRDB6S2Y5R6G6U4E6U6C7G6U6C7G6U6C7G6U6C7G6U6C7G',
});

// process.env.DOMAIN can be empty, defaulting to localhost
const response = await request(app).get('/federation?type=id&q=GDQ4X7B2QWYRDB6S2Y5R6G6U4E6U6C7G6U6C7G6U6C7G6U6C7G6U6C7G');

expect(response.status).toBe(200);
expect(response.body.stellar_address).toMatch(/^bob\*/);
expect(response.body.account_id).toBe('GDQ4X7B2QWYRDB6S2Y5R6G6U4E6U6C7G6U6C7G6U6C7G6U6C7G6U6C7G');
});

test('returns 404 for missing address lookup when type=id', async () => {
prisma.user.findFirst.mockResolvedValue(null);

const response = await request(app).get('/federation?type=id&q=UNKNOWNADDRESS');

expect(response.status).toBe(404);
expect(response.body.error).toBe('Address not found');
});
});
15 changes: 15 additions & 0 deletions stellar-payment-platform/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,16 @@ scheduleCleanupJob(prisma);
scheduleSoftDeletePurgeJob(prisma);
const poolMonitor = schedulePoolMonitoring(prisma);

const RESERVED_USERNAMES = [
'admin',
'root',
'stellar',
'system',
'superuser',
'administrator',
'support',
];

// ---------------------------------------------------------------------------
// #51 — ETag Caching Middleware for Federation Endpoint
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -517,6 +527,11 @@ app.post('/register', idempotencyMiddleware(redisClient), requireJson, validateS
}

const normalizedUsername = username.toLowerCase();

const normalizedLocalPart = normalizedUsername.includes('*') ? normalizedUsername.split('*')[0] : normalizedUsername;
if (RESERVED_USERNAMES.includes(normalizedLocalPart)) {
return res.status(403).json({ error: "Username is reserved." });
}

const RESERVED_NAMES = ['admin', 'root', 'support', 'system', 'stellar', 'api', 'help'];
if (RESERVED_NAMES.includes(normalizedUsername)) {
Expand Down
130 changes: 5 additions & 125 deletions stellar-payment-platform/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -617,33 +617,8 @@ jest.mock('./src/soft-delete-purge-cron', () => ({ scheduleSoftDeletePurgeJob: j
address: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ'
});
});
});

describe('POST /register — memo validation', () => {
let request;
let app;
let prisma;

const VALID_ADDRESS = 'GBCDEFGHIJKLMNOPQRSTUVWXYZ';

beforeEach(() => {
jest.resetModules();
({ app } = require('./server'));
({ prisma } = require('./prismaClient'));
request = require('supertest');

prisma.user.findUnique.mockReset();
prisma.user.create.mockReset();
prisma.user.findUnique.mockResolvedValue(null);
prisma.user.create.mockResolvedValue({
username: 'alice*localhost',
address: VALID_ADDRESS,
memoType: null,
memo: null,
});
});

test('registers without memo fields', async () => {
test('rejects reserved usernames', async () => {
const res = await request(app)
.post('/register')
.send({ username: 'alice', address: VALID_ADDRESS });
Expand Down Expand Up @@ -820,106 +795,11 @@ jest.mock('./src/soft-delete-purge-cron', () => ({ scheduleSoftDeletePurgeJob: j
}),
};

jest.mock('generic-pool', () => ({
createPool: jest.fn(() => ({
acquire: jest.fn().mockResolvedValue(mockConn),
release: jest.fn(),
drain: jest.fn().mockResolvedValue(undefined),
clear: jest.fn().mockResolvedValue(undefined),
})),
}));

({ app } = require('./server'));
({ prisma } = require('./prismaClient'));
request = require('supertest');

prisma.user.count.mockReset();
prisma.user.findMany.mockReset();
prisma.$transaction.mockReset();
prisma.user.count.mockResolvedValue(2);
prisma.user.findMany.mockResolvedValue([
{ username: 'alice*localhost', address: 'GABC', createdAt: new Date('2024-01-01T00:00:00.000Z') },
]);
prisma.$transaction.mockResolvedValue([2, [
{ username: 'alice*localhost', address: 'GABC', createdAt: new Date('2024-01-01T00:00:00.000Z') },
]]);
});

afterEach(() => {
jest.restoreAllMocks();
});

test('GET /api/v1/lookup returns 400 without params', async () => {
const res = await request(app).get('/api/v1/lookup');
expect(res.status).toBe(400);
});

test('GET /api/v1/users returns paginated data', async () => {
const res = await request(app).get('/api/v1/users');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.data)).toBe(true);
});

test('GET /api/v1/federation returns 400 without q param', async () => {
const res = await request(app).get('/api/v1/federation');
expect(res.status).toBe(400);
});
});

describe('Idempotency Middleware', () => {
let app;
let request;
let prisma;

beforeEach(() => {
jest.resetModules();
({ app } = require('./server'));
({ prisma } = require('./prismaClient'));
request = require('supertest');

prisma.user.findUnique.mockResolvedValue(null);
prisma.user.create.mockResolvedValue({
id: 1,
username: 'idempotent-user',
address: 'GABC123',
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: "Username is reserved."
});
});

afterEach(() => {
jest.restoreAllMocks();
});

test('POST /register with new idempotency key succeeds and caches', async () => {
const payload = {
username: 'idempotentuser',
address: 'GDUMMYACCOUNTIDIIIIIIIIIIIIIIIIIIIIIIIIIIIIII',
signature: 'GDUMMYACCOUNTIDIIIIIIIIIIIIIIIIIIIIIIIIIIIIII'
};

// First request
const res1 = await request(app)
.post('/register')
.set('X-Idempotency-Key', 'test-key-123')
.set('Content-Type', 'application/json')
.send(payload);

expect([200, 201, 400, 401, 404, 409]).toContain(res1.status);
expect(res1.header['x-idempotent-replay']).toBeUndefined();

// Second request with SAME key
const res2 = await request(app)
.post('/register')
.set('X-Idempotency-Key', 'test-key-123')
.set('Content-Type', 'application/json')
.send(payload);

expect(res2.status).toBe(201);
expect(res2.header['x-idempotent-replay']).toBe('true');
expect(res2.body).toEqual(res1.body);

// Ensure prisma.user.create was only called once
expect(prisma.user.create).toHaveBeenCalledTimes(1);
});
});

describe('Database disconnection — 503 handling', () => {
Expand Down Expand Up @@ -1058,4 +938,4 @@ describe('Database disconnection — 503 handling', () => {
const res = await request(app).get('/federation?q=nonexistent*localhost&type=name');
expect(res.status).toBe(404);
});
});
});
Loading