Skip to content

Commit 15730d4

Browse files
authored
Merge pull request #234 from petersdt/test/contract-helpers
Test/contract helpers
2 parents f83c91b + d0caf30 commit 15730d4

File tree

20 files changed

+18336
-9840
lines changed

20 files changed

+18336
-9840
lines changed

.eslintrc.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "next/core-web-vitals"
3+
}

.github/workflows/e2e.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ jobs:
3434
- name: Install dependencies
3535
run: npm ci
3636

37+
- name: Run Unit Tests
38+
run: npm run test
39+
3740
- name: Install Playwright Browsers
3841
run: npx playwright install --with-deps chromium
3942

app/api/auth/nonce/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { setNonce } from "@/lib/auth-cache";
2+
import { storeNonce } from "@/lib/auth/nonce-store";
33
import { randomBytes } from "crypto";
44

55
export async function POST(request: NextRequest) {
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
1717
const nonce = nonceBuffer.toString("hex");
1818

1919
// Store nonce in cache for later verification
20-
setNonce(publicKey, nonce);
20+
storeNonce(publicKey, nonce);
2121

2222
return NextResponse.json({ nonce });
2323
}

app/api/remittance/build/route.ts

Lines changed: 5 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,9 @@ async function simulateTransaction(xdr: string): Promise<{
167167
const server = getServer();
168168

169169
try {
170-
const { Transaction } = await import('@stellar/stellar-sdk');
171-
const transaction = Transaction.fromXDR(xdr, getNetworkPassphrase());
172-
170+
const { TransactionBuilder } = await import('@stellar/stellar-sdk');
171+
const transaction = TransactionBuilder.fromXDR(xdr, getNetworkPassphrase());
172+
173173
const simulation = await server.simulateTransaction(transaction);
174174

175175
// Check for simulation errors
@@ -243,12 +243,12 @@ export async function POST(request: NextRequest) {
243243
// Check for common errors
244244
if (!simulation.success) {
245245
const errorMsg = simulation.error || 'Unknown simulation error';
246-
246+
247247
// Check for insufficient balance
248248
if (errorMsg.toLowerCase().includes('insufficient') || errorMsg.toLowerCase().includes('balance')) {
249249
return jsonError('VALIDATION_ERROR', 'Insufficient balance to complete this transaction');
250250
}
251-
251+
252252
// Check for invalid destination
253253
if (errorMsg.toLowerCase().includes('destination') || errorMsg.toLowerCase().includes('account')) {
254254
return jsonError('VALIDATION_ERROR', 'Invalid or non-existent recipient address');
@@ -268,52 +268,4 @@ export async function POST(request: NextRequest) {
268268
};
269269

270270
return jsonSuccess(response);
271-
import { NextRequest, NextResponse } from 'next/server';
272-
import { withIdempotency } from '@/lib/idempotency';
273-
274-
/**
275-
* POST /api/remittance/build
276-
* Build a remittance transaction
277-
*
278-
* Supports idempotency via Idempotency-Key header
279-
*/
280-
export async function POST(request: NextRequest) {
281-
return withIdempotency(request, async (body) => {
282-
// TODO: Add authentication
283-
// const session = await getSession(request);
284-
// if (!session) {
285-
// return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
286-
// }
287-
288-
// Validate request body
289-
const { amount, recipient, currency } = body;
290-
291-
if (!amount || !recipient || !currency) {
292-
return NextResponse.json(
293-
{ error: 'Missing required fields: amount, recipient, currency' },
294-
{ status: 400 }
295-
);
296-
}
297-
298-
if (amount <= 0) {
299-
return NextResponse.json(
300-
{ error: 'Amount must be greater than 0' },
301-
{ status: 400 }
302-
);
303-
}
304-
305-
// TODO: Implement actual remittance building logic
306-
// For now, return a mock response
307-
const transactionId = `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
308-
309-
return NextResponse.json({
310-
transactionId,
311-
amount,
312-
recipient,
313-
currency,
314-
status: 'pending',
315-
createdAt: new Date().toISOString(),
316-
message: 'Remittance transaction built successfully',
317-
});
318-
});
319271
}

app/transactions/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export default function TransactionsPage() {
221221
))
222222
) : (
223223
<div className="text-center py-12 text-gray-500">
224-
No transactions found matching "{searchQuery}"
224+
No transactions found matching &quot;{searchQuery}&quot;
225225
</div>
226226
)}
227227
</div>

components/Dashboard/SavingsByGoalWidget.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ export default function SavingsByGoalWidget({
2727
<PiggyBank className="w-6 h-6 text-red-500" />
2828
<h2 className="text-xl font-bold text-white">Savings by Goal</h2>
2929
</div>
30-
30+
3131
{/* Subtitle */}
32-
<p className="text-sm text-gray-400 mb-6">Where you're saving</p>
32+
<p className="text-sm text-gray-400 mb-6">Where you&apos;re saving</p>
3333

3434
{/* Goals List */}
3535
<div className="space-y-6">
@@ -43,7 +43,7 @@ export default function SavingsByGoalWidget({
4343
<span className="text-xs text-gray-400">{goal.percentage}%</span>
4444
</div>
4545
</div>
46-
46+
4747
{/* Progress Bar */}
4848
<div className="w-full bg-gray-800 rounded-full h-2.5 overflow-hidden">
4949
<div

components/FAQSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export default function FAQSection() {
5656
Frequently Asked <span className="text-[#DC2626]">Questions</span>
5757
</h2>
5858
<p className="text-white/60 max-w-xl mx-auto text-base font-regular">
59-
Got questions? We've got answers. Find everything you need to know
59+
Got questions? We&apos;ve got answers. Find everything you need to know
6060
about using our platform, plans, and features.
6161
</p>
6262
</div>

lib/auth.ts

Lines changed: 6 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function unauthorizedResponse() {
3636
});
3737
}
3838

39-
import { NextResponse } from "next/server";
39+
import { NextResponse } from "next/server";
4040
import { getSession } from "@/lib/session";
4141

4242
type NextHandler = (req: NextRequest, address: string) => Promise<NextResponse>;
@@ -45,85 +45,13 @@ export function withAuth(handler: NextHandler) {
4545
return async (req: NextRequest) => {
4646

4747
const session = await getSession();
48-
if (!session?.address) {
49-
return NextResponse.json(
50-
{ error: 'Unauthorized', message: 'Not authenticated' },
51-
{ status: 401 }
52-
);
53-
}
54-
55-
return handler(req, session.address);
56-
/**
57-
* Higher-order function that wraps a route handler with auth validation.
58-
* Extracts the Bearer token and passes it to the handler as `session`.
59-
* Returns 401 if no valid token is present.
60-
*
61-
* Usage:
62-
* async function handler(req: NextRequest, session: string) { ... }
63-
* export const GET = withAuth(handler);
64-
*/
65-
export function withAuth(
66-
handler: (request: NextRequest, session: string) => Promise<NextResponse>
67-
) {
68-
return async (request: NextRequest): Promise<NextResponse> => {
69-
const authHeader = request.headers.get("authorization") ?? "";
70-
const token = authHeader.startsWith("Bearer ")
71-
? authHeader.slice(7).trim()
72-
: null;
73-
74-
if (!token) {
75-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
76-
}
77-
78-
if (token !== process.env.AUTH_SECRET) {
79-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
80-
}
81-
82-
return handler(request, token);
83-
};
84-
}
85-
* Wrapper for protecting route handlers with session-based authentication.
86-
*
87-
* Extracts session from encrypted cookie and passes to handler.
88-
* Returns 401 Unauthorized if session is missing or invalid.
89-
*
90-
* @param handler - Route handler that receives (request, session)
91-
* @returns Route handler compatible with Next.js App Router
92-
*
93-
* @example
94-
* async function handler(request: NextRequest, session: string) {
95-
* return NextResponse.json({ address: session });
96-
* }
97-
* export const GET = withAuth(handler);
98-
*/
99-
export function withAuth(
100-
handler: (request: NextRequest, session: string) => Promise<Response>,
101-
) {
102-
return async (request: NextRequest): Promise<Response> => {
103-
try {
104-
const sessionData = await getSession();
105-
if (!sessionData?.address) {
106-
return NextResponse.json(
107-
{ error: "Unauthorized", message: "Not authenticated" },
108-
{ status: 401 },
109-
);
110-
}
111-
112-
return await handler(request, sessionData.address);
113-
} catch (error) {
114-
if (error instanceof ApiError) {
115-
return NextResponse.json(
116-
{ error: error.message },
117-
{ status: error.status },
118-
);
119-
}
120-
121-
// Log unexpected errors but don't expose details
122-
console.error("Route handler error:", error);
48+
if (!session?.address) {
12349
return NextResponse.json(
124-
{ error: "Internal server error" },
125-
{ status: 500 },
50+
{ error: 'Unauthorized', message: 'Not authenticated' },
51+
{ status: 401 }
12652
);
12753
}
54+
55+
return handler(req, session.address);
12856
};
12957
}

lib/auth/nonce-store.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ interface NonceRecord {
1212
expiresAt: number;
1313
}
1414

15-
// In-memory store (replace with Redis/DB in production)
16-
const nonceStore = new Map<string, NonceRecord>();
15+
const globalForNonce = globalThis as unknown as { nonceStore: Map<string, NonceRecord> };
16+
const nonceStore = globalForNonce.nonceStore || new Map<string, NonceRecord>();
17+
if (process.env.NODE_ENV !== "production") globalForNonce.nonceStore = nonceStore;
1718

1819
// Default TTL: 5 minutes
1920
const DEFAULT_NONCE_TTL_MS = 5 * 60 * 1000;
@@ -41,6 +42,7 @@ export function storeNonce(
4142
nonce: string,
4243
ttlMs: number = DEFAULT_NONCE_TTL_MS
4344
): void {
45+
console.log(`[NONCE-STORE] Storing nonce for ${address}: ${nonce}`);
4446
const now = Date.now();
4547
const record: NonceRecord = {
4648
nonce,
@@ -58,6 +60,7 @@ export function storeNonce(
5860
* Returns the nonce if valid, null if expired or not found
5961
*/
6062
export function getNonce(address: string): string | null {
63+
console.log(`[NONCE-STORE] Getting nonce for ${address}. Current store size: ${nonceStore.size}, store keys:`, Array.from(nonceStore.keys()));
6164
const record = nonceStore.get(address);
6265

6366
if (!record) {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import {
3+
buildCreateBillTx,
4+
buildPayBillTx,
5+
buildCancelBillTx,
6+
} from './bill-payments'
7+
import * as StellarSdk from '@stellar/stellar-sdk'
8+
9+
vi.spyOn(StellarSdk.Horizon.Server.prototype, 'loadAccount').mockImplementation(async (accountId: string) => {
10+
if (accountId.startsWith('G')) {
11+
return { sequence: '123' } as any
12+
}
13+
throw new Error('invalid-account')
14+
})
15+
16+
describe('bill-payments helper', () => {
17+
let validPublicKey: string
18+
19+
beforeEach(() => {
20+
vi.clearAllMocks()
21+
validPublicKey = StellarSdk.Keypair.random().publicKey()
22+
})
23+
24+
describe('buildCreateBillTx', () => {
25+
it('returns a valid XDR for a one-time bill', async () => {
26+
const owner = validPublicKey
27+
const name = 'Electric Bill'
28+
const amount = 50
29+
const dueDate = new Date(Date.now() + 86400000).toISOString() // tomorrow
30+
const recurring = false
31+
32+
const xdr = await buildCreateBillTx(owner, name, amount, dueDate, recurring)
33+
expect(typeof xdr).toBe('string')
34+
expect(xdr.length).toBeGreaterThan(0)
35+
36+
const tx = new StellarSdk.Transaction(xdr, StellarSdk.Networks.TESTNET)
37+
expect(tx.operations).toHaveLength(4)
38+
})
39+
40+
it('returns a valid XDR for a recurring bill', async () => {
41+
const owner = validPublicKey
42+
const name = 'Internet Bill'
43+
const amount = 80
44+
const dueDate = new Date(Date.now() + 86400000).toISOString()
45+
const recurring = true
46+
const frequencyDays = 30
47+
48+
const xdr = await buildCreateBillTx(owner, name, amount, dueDate, recurring, frequencyDays)
49+
expect(typeof xdr).toBe('string')
50+
51+
const tx = new StellarSdk.Transaction(xdr, StellarSdk.Networks.TESTNET)
52+
expect(tx.operations).toHaveLength(5)
53+
})
54+
55+
it('throws error for invalid owner public key', async () => {
56+
await expect(
57+
buildCreateBillTx('invalid-key', 'Bill', 50, new Date().toISOString(), false)
58+
).rejects.toThrow('invalid-owner')
59+
})
60+
61+
it('throws error for invalid amount', async () => {
62+
await expect(
63+
buildCreateBillTx(validPublicKey, 'Bill', -10, new Date().toISOString(), false)
64+
).rejects.toThrow('invalid-amount')
65+
})
66+
67+
it('throws error for invalid frequency', async () => {
68+
await expect(
69+
buildCreateBillTx(validPublicKey, 'Bill', 50, new Date().toISOString(), true, -5)
70+
).rejects.toThrow('invalid-frequency')
71+
})
72+
73+
it('throws error for invalid due date', async () => {
74+
await expect(
75+
buildCreateBillTx(validPublicKey, 'Bill', 50, 'not-a-date', false)
76+
).rejects.toThrow('invalid-dueDate')
77+
})
78+
})
79+
80+
describe('buildPayBillTx', () => {
81+
it('returns a valid XDR for paying a bill', async () => {
82+
const xdr = await buildPayBillTx(validPublicKey, 'bill-123')
83+
expect(typeof xdr).toBe('string')
84+
const tx = new StellarSdk.Transaction(xdr, StellarSdk.Networks.TESTNET)
85+
expect(tx.operations).toHaveLength(1)
86+
})
87+
88+
it('throws error for invalid caller', async () => {
89+
await expect(buildPayBillTx('invalid', 'bill-123')).rejects.toThrow('invalid-caller')
90+
})
91+
92+
it('throws error for missing billId', async () => {
93+
await expect(buildPayBillTx(validPublicKey, '')).rejects.toThrow('invalid-billId')
94+
})
95+
})
96+
97+
describe('buildCancelBillTx', () => {
98+
it('returns a valid XDR for canceling a bill', async () => {
99+
const xdr = await buildCancelBillTx(validPublicKey, 'bill-456')
100+
expect(typeof xdr).toBe('string')
101+
const tx = new StellarSdk.Transaction(xdr, StellarSdk.Networks.TESTNET)
102+
expect(tx.operations).toHaveLength(1)
103+
})
104+
105+
it('throws error for invalid caller', async () => {
106+
await expect(buildCancelBillTx('invalid', 'bill-123')).rejects.toThrow('invalid-caller')
107+
})
108+
109+
it('throws error for missing billId', async () => {
110+
await expect(buildCancelBillTx(validPublicKey, '')).rejects.toThrow('invalid-billId')
111+
})
112+
})
113+
})

0 commit comments

Comments
 (0)