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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,12 @@ coverage/
*.lcov
coverage-final.json
clover.xml

# Local environment files. .env is loaded by dotenv at boot and holds live
# secrets (ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY,
# DEPLOYER_SECRET_KEY). The committed example file is the only environment
# file that belongs in git.
.env
.env.local
.env.*.local
!.env.example
2 changes: 2 additions & 0 deletions src/__tests__/contribution-scheduler.worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { Job } from 'bullmq';

jest.mock('@prisma/client', () => {
const mockPrisma = {
// lib/prisma registers the encryption middleware on construction.
$use: jest.fn(),
savingsGroup: {
update: jest.fn(),
findUnique: jest.fn()
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/key-rotation.worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,6 @@ describe('key-rotation.worker', () => {

// Decrypt with v2
const decrypted = decrypt(newWallet.encryptedSecret, newWallet.iv, newWallet.authTag, 2);
expect(decrypted).toBe('secret-data-0');
expect(decrypted.toString('utf8')).toBe('secret-data-0');
});
});
68 changes: 68 additions & 0 deletions src/__tests__/message-processor.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,74 @@ describe('MessageProcessor', () => {
});
});

describe('unreadable wallet blob (issue #254)', () => {
// processCommand() and each handler both load the user, so the mock
// must answer twice per command.
const queueUserWith = (stellarWallet?: string) => {
const user = {
id: 'u1',
phoneNumber: '12345',
username: 'john',
language: 'en',
stellarWallet,
createdAt: new Date(),
};
mockGetOrCreateUser.mockResolvedValueOnce(user).mockResolvedValueOnce(user);
};

const malformedBlobs: Array<[string, string]> = [
['truncated JSON', '{"publicKey":"G_PUB","encryptedSecret":"ENC_SEC"'],
['a pre-encryption bare public key', 'G_PUB'],
['a JSON array', '["G_PUB"]'],
['JSON without a public key', JSON.stringify({ encryptedSecret: 'ENC_SEC', iv: 'IV', authTag: 'TAG' })],
];

it.each(malformedBlobs)('treats %s as unreadable instead of throwing', async (_label, blob) => {
queueUserWith(blob);

await processor.processCommand('12345', 'BALANCE');

expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('wallet.unreadable'));
expect(mockSendMessage).not.toHaveBeenCalledWith('12345', expect.stringContaining('error.generic'));
expect(mockCheckBalance).not.toHaveBeenCalled();
});

it.each(['BALANCE', 'HISTORY', 'PROFILE', 'SEND 10 @jane', 'CONTRIBUTE 10'])(
'answers %s with the unreadable-wallet message',
async (command) => {
queueUserWith('{"publicKey":"G_PUB",');

await processor.processCommand('12345', command);

expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('wallet.unreadable'));
expect(mockSendMessage).not.toHaveBeenCalledWith('12345', expect.stringContaining('error.generic'));
},
);

it('reports an unreadable recipient wallet as a missing recipient', async () => {
mockResolveUser.mockResolvedValueOnce({
id: 'u2',
phoneNumber: '67890',
username: 'jane',
stellarWallet: '{"publicKey":"G_PUB2"',
});

await processor.processCommand('12345', 'SEND 10 @jane');

expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('send.no_recipient'));
expect(mockSendPayment).not.toHaveBeenCalled();
});

it('keeps the no-wallet message when the column is empty', async () => {
queueUserWith(undefined);

await processor.processCommand('12345', 'BALANCE');

expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('balance.no_wallet'));
expect(mockSendMessage).not.toHaveBeenCalledWith('12345', expect.stringContaining('wallet.unreadable'));
});
});

describe('payment requests (issue #65)', () => {
it('REQUEST creates a tracked request and sends the recipient the requestId', async () => {
mockCreatePaymentRequest.mockResolvedValue({ id: 'REQ-1', expiresAt: new Date(Date.now() + 86400000) });
Expand Down
11 changes: 10 additions & 1 deletion src/__tests__/soroban.service.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ import { config } from '../config/env';

jest.setTimeout(120000);

describe('SorobanService Integration', () => {
// This suite uploads and deploys a real contract to Stellar Testnet, so it
// needs both network access and a compiled artifact at
// contracts/savings_group.wasm. The repository ships an 8-byte placeholder,
// which the Soroban host rejects with "contract missing metadata section".
// Run it explicitly once a real build is present:
// RUN_SOROBAN_INTEGRATION=1 npm test
const describeIntegration =
process.env.RUN_SOROBAN_INTEGRATION === '1' ? describe : describe.skip;

describeIntegration('SorobanService Integration', () => {
let sorobanService: SorobanService;
let deployerKeypair: StellarSdk.Keypair;

Expand Down
33 changes: 18 additions & 15 deletions src/__tests__/user.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const mockRedisSet = redisClient.set as jest.Mock;

jest.mock('@prisma/client', () => {
const mPrismaClient = {
// lib/prisma registers the encryption middleware on construction.
$use: jest.fn(),
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
Expand Down Expand Up @@ -49,7 +51,7 @@ jest.mock('../services/whatsapp.service', () => {
});

jest.mock('../utils/encryption.util', () => ({
decrypt: jest.fn().mockReturnValue('S_MOCK_SECRET'),
decrypt: jest.fn().mockReturnValue(Buffer.from('S_MOCK_SECRET')),
}));

jest.mock('../services/locale.service', () => ({
Expand Down Expand Up @@ -81,19 +83,19 @@ describe('UserService', () => {
describe('getOrCreateUser', () => {
it('should return existing user if found', async () => {
const mockUser = { id: '1', phoneNumber: '1234567890' };
prismaClientMock.user.findUnique.mockResolvedValueOnce(mockUser);
prismaClientMock.user.findFirst.mockResolvedValueOnce(mockUser);

const result = await userService.getOrCreateUser('1234567890');

expect(prismaClientMock.user.findUnique).toHaveBeenCalledWith({
expect(prismaClientMock.user.findFirst).toHaveBeenCalledWith({
where: { phoneNumber: '1234567890' }
});
expect(prismaClientMock.user.create).not.toHaveBeenCalled();
expect(result).toEqual(mockUser);
});

it('should create new user with generated stellar wallet if not found', async () => {
prismaClientMock.user.findUnique.mockResolvedValueOnce(null);
prismaClientMock.user.findFirst.mockResolvedValueOnce(null);
const expectedWallet = JSON.stringify({
publicKey: 'G_MOCK_PUBLIC_KEY',
encryptedSecret: 'ENC_SECRET',
Expand All @@ -118,7 +120,7 @@ describe('UserService', () => {
});

it('should handle friendbot funding failure gracefully', async () => {
prismaClientMock.user.findUnique.mockResolvedValueOnce(null);
prismaClientMock.user.findFirst.mockResolvedValueOnce(null);
stellarServiceMock.fundTestnetAccount.mockRejectedValueOnce(new Error('Network error'));
const expectedWallet = JSON.stringify({
publicKey: 'G_MOCK_PUBLIC_KEY',
Expand All @@ -138,7 +140,7 @@ describe('UserService', () => {

it('should skip trustline creation when USDC_ISSUER_PUBLIC_KEY is not configured', async () => {
config.USDC_ISSUER_PUBLIC_KEY = '';
prismaClientMock.user.findUnique.mockResolvedValueOnce(null);
prismaClientMock.user.findFirst.mockResolvedValueOnce(null);
prismaClientMock.user.create.mockResolvedValueOnce({ id: '4', phoneNumber: '2222222222' });

await userService.getOrCreateUser('2222222222');
Expand All @@ -148,19 +150,19 @@ describe('UserService', () => {

it('should establish a USDC trustline when USDC_ISSUER_PUBLIC_KEY is configured', async () => {
config.USDC_ISSUER_PUBLIC_KEY = 'G_USDC_ISSUER';
prismaClientMock.user.findUnique.mockResolvedValueOnce(null);
prismaClientMock.user.findFirst.mockResolvedValueOnce(null);
prismaClientMock.user.create.mockResolvedValueOnce({ id: '5', phoneNumber: '3333333333' });

await userService.getOrCreateUser('3333333333');

expect(stellarServiceMock.createTrustline).toHaveBeenCalledWith('S_MOCK_SECRET', 'USDC', 'G_USDC_ISSUER');
expect(stellarServiceMock.createTrustline).toHaveBeenCalledWith(expect.any(Buffer), 'USDC', 'G_USDC_ISSUER');
expect(whatsappServiceMock.sendMessage).not.toHaveBeenCalled();
});

it('should notify the user via WhatsApp when the trustline reserve is insufficient', async () => {
config.USDC_ISSUER_PUBLIC_KEY = 'G_USDC_ISSUER';
stellarServiceMock.createTrustline.mockRejectedValueOnce(new InsufficientReserveError());
prismaClientMock.user.findUnique.mockResolvedValueOnce(null);
prismaClientMock.user.findFirst.mockResolvedValueOnce(null);
prismaClientMock.user.create.mockResolvedValueOnce({ id: '6', phoneNumber: '4444444444' });

await userService.getOrCreateUser('4444444444');
Expand All @@ -174,7 +176,7 @@ describe('UserService', () => {
it('should log and continue when trustline creation fails for an unrelated reason', async () => {
config.USDC_ISSUER_PUBLIC_KEY = 'G_USDC_ISSUER';
stellarServiceMock.createTrustline.mockRejectedValueOnce(new Error('Horizon is down'));
prismaClientMock.user.findUnique.mockResolvedValueOnce(null);
prismaClientMock.user.findFirst.mockResolvedValueOnce(null);
const createdUser = { id: '7', phoneNumber: '5555555555' };
prismaClientMock.user.create.mockResolvedValueOnce(createdUser);
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
Expand All @@ -189,7 +191,7 @@ describe('UserService', () => {
});

it('should use supported locale if provided', async () => {
prismaClientMock.user.findUnique.mockResolvedValueOnce(null);
prismaClientMock.user.findFirst.mockResolvedValueOnce(null);
prismaClientMock.user.create.mockResolvedValueOnce({ id: '5', phoneNumber: '3333333333', language: 'fr' });

await userService.getOrCreateUser('3333333333', 'fr');
Expand All @@ -204,7 +206,7 @@ describe('UserService', () => {
});

it('should fallback to en if unsupported locale is provided', async () => {
prismaClientMock.user.findUnique.mockResolvedValueOnce(null);
prismaClientMock.user.findFirst.mockResolvedValueOnce(null);
prismaClientMock.user.create.mockResolvedValueOnce({ id: '6', phoneNumber: '4444444444', language: 'en' });

await userService.getOrCreateUser('4444444444', 'xyz');
Expand All @@ -221,12 +223,13 @@ describe('UserService', () => {

describe('updateUserLanguage', () => {
it('should update user language', async () => {
prismaClientMock.user.findFirst.mockResolvedValueOnce({ id: '1', phoneNumber: '1234567890' });
prismaClientMock.user.update.mockResolvedValueOnce({ id: '1', phoneNumber: '1234567890', language: 'fr' });

await userService.updateUserLanguage('1234567890', 'fr');

expect(prismaClientMock.user.update).toHaveBeenCalledWith({
where: { phoneNumber: '1234567890' },
where: { id: '1' },
data: { language: 'fr' }
});
});
Expand All @@ -242,9 +245,9 @@ describe('UserService', () => {
});

it('should resolve by phone number if target does not start with @', async () => {
prismaClientMock.user.findUnique.mockResolvedValueOnce({ phoneNumber: '123' });
prismaClientMock.user.findFirst.mockResolvedValueOnce({ phoneNumber: '123' });
await userService.resolveUser('123');
expect(prismaClientMock.user.findUnique).toHaveBeenCalledWith({
expect(prismaClientMock.user.findFirst).toHaveBeenCalledWith({
where: { phoneNumber: '123' }
});
});
Expand Down
52 changes: 52 additions & 0 deletions src/__tests__/wallet.util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { parseStellarWallet } from '../utils/wallet.util';

const validBlob = {
publicKey: 'G_PUB',
encryptedSecret: 'ENC_SEC',
iv: 'IV',
authTag: 'TAG',
keyVersion: 1,
};

describe('parseStellarWallet', () => {
it('reports an absent wallet for a null, undefined or blank column', () => {
expect(parseStellarWallet(null)).toEqual({ status: 'absent' });
expect(parseStellarWallet(undefined)).toEqual({ status: 'absent' });
expect(parseStellarWallet('')).toEqual({ status: 'absent' });
expect(parseStellarWallet(' ')).toEqual({ status: 'absent' });
});

it('returns the wallet blob for a canonical row', () => {
expect(parseStellarWallet(JSON.stringify(validBlob))).toEqual({
status: 'ok',
wallet: validBlob,
});
});

it('drops a non-numeric keyVersion instead of rejecting the blob', () => {
const { keyVersion: _keyVersion, ...rest } = validBlob;
expect(parseStellarWallet(JSON.stringify({ ...rest, keyVersion: '1' }))).toEqual({
status: 'ok',
wallet: rest,
});
});

const unreadableCases: Array<[string, string]> = [
['truncated JSON', '{"publicKey":"G_PUB",'],
['a pre-encryption bare public key', 'G_PUB'],
['a JSON string', '"G_PUB"'],
['a JSON array', '["G_PUB"]'],
['JSON null', 'null'],
['an object without publicKey', JSON.stringify({ ...validBlob, publicKey: undefined })],
['an object with a blank publicKey', JSON.stringify({ ...validBlob, publicKey: ' ' })],
['an object without the encrypted secret', JSON.stringify({ ...validBlob, encryptedSecret: undefined })],
];

it.each(unreadableCases)('reports unreadable for %s', (_label, raw) => {
expect(parseStellarWallet(raw)).toEqual({ status: 'unreadable' });
});

it('never throws on arbitrary non-JSON input', () => {
expect(() => parseStellarWallet('not json at all \u0000')).not.toThrow();
});
});
1 change: 1 addition & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
"exceeded": "⚠️ You're sending too many requests. Please wait {{remainingSeconds}} seconds."
},
"wallet": {
"unreadable": "⚠️ We couldn't read your saved wallet. Please set up a new wallet or contact support.",
"usdc_trustline_low_reserve": "You need at least 2 XLM in your wallet to enable USDC."
},
"payment_request": {
Expand Down
1 change: 1 addition & 0 deletions src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"exceeded": "⚠️ Vous envoyez trop de demandes. Veuillez patienter {{remainingSeconds}} secondes."
},
"wallet": {
"unreadable": "⚠️ Nous n'avons pas pu lire votre portefeuille enregistré. Veuillez configurer un nouveau portefeuille ou contacter le support.",
"usdc_trustline_low_reserve": "Vous avez besoin d'au moins 2 XLM dans votre portefeuille pour activer l'USDC."
}
}
1 change: 1 addition & 0 deletions src/locales/ha.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"success": "✅ An canza harshe cikin nasara!"
},
"wallet": {
"unreadable": "⚠️ Ba mu iya karanta walat ɗinka da aka adana ba. Don Allah saita sabon walat ko tuntuɓi tallafi.",
"usdc_trustline_low_reserve": "Kuna buƙatar aƙalla XLM 2 a cikin walat ɗinku don kunna USDC."
}
}
1 change: 1 addition & 0 deletions src/locales/ig.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"success": "✅ Agbanwela asụsụ ahụ nke ọma!"
},
"wallet": {
"unreadable": "⚠️ Anyị enweghị ike ịgụ obere akpa gị echekwara. Biko hazigharịa obere akpa ọhụrụ ma ọ bụ kpọtụrụ nkwado.",
"usdc_trustline_low_reserve": "Ị chọrọ opekata mpe 2 XLM na obere akpa gị iji mee ka USDC rụọ ọrụ."
}
}
1 change: 1 addition & 0 deletions src/locales/pcm.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"success": "✅ You don change language successfully!"
},
"wallet": {
"unreadable": "⚠️ We no fit read the wallet wey you save. Abeg set up a new wallet or contact support.",
"usdc_trustline_low_reserve": "You need at least 2 XLM for your wallet make you fit use USDC."
}
}
1 change: 1 addition & 0 deletions src/locales/yo.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"exceeded": "⚠️ O n kọlu awọn ibeere pupọ ju. Jọwọ duro fun iṣẹju-aaya {{remainingSeconds}}."
},
"wallet": {
"unreadable": "⚠️ A kò le kà ápamọ́wọ́ rẹ tí a fipamọ́. Jọ̀wọ́ ṣètò ápamọ́wọ́ tuntun tàbí kàn sí àtìlẹ́yìn.",
"usdc_trustline_low_reserve": "O nílò ó kéré tán 2 XLM nínú àpamọ́wọ́ rẹ láti mú USDC ṣiṣẹ́."
}
}
Loading