Skip to content
Merged
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
6 changes: 5 additions & 1 deletion micopay/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
"test:kyc-didit": "node --import tsx src/tests/kyc-didit.test.ts",
"test:security": "node --import tsx src/tests/security.test.ts",
"test:sign-requests": "node --import tsx src/tests/signRequests.test.ts",
"issue-device-key": "tsx src/scripts/issue-device-key.ts"
"issue-device-key": "tsx src/scripts/issue-device-key.ts",
"test:trade-auth": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/tradeAuth.test.ts",
"test:refund": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/refund.test.ts",
"test:challenge": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/challenge.service.test.ts",
"test:discovery": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/merchant.discovery.test.ts"
},
"dependencies": {
"@fastify/cors": "^8.5.0",
Expand Down
8 changes: 8 additions & 0 deletions micopay/backend/src/routes/merchants.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import type { FastifyInstance } from 'fastify';
import { authMiddleware } from '../middleware/auth.middleware.js';
import { createRateLimiter } from '../middleware/rateLimit.middleware.js';
import {
getOrCreateMerchantConfig,
updateMerchantConfig,
getAvailableMerchants,
} from '../services/merchant.service.js';
import db from '../db/schema.js';

// G1: /merchants/available is public and unauthenticated — without a rate
// limit it lets anyone scrape the full census of merchant locations by
// sweeping lat/lng. 30 req/min per IP is generous for legitimate use (the
// app makes one request per search).
const discoveryRateLimit = createRateLimiter({ windowMs: 60_000, max: 30 });

export async function merchantRoutes(app: FastifyInstance) {
/**
* GET /merchants/available
Expand All @@ -20,6 +27,7 @@ export async function merchantRoutes(app: FastifyInstance) {
* flow – 'cashout' | 'deposit' (optional, reserved)
*/
app.get('/merchants/available', {
preHandler: [discoveryRateLimit],
schema: {
querystring: {
type: 'object',
Expand Down
9 changes: 7 additions & 2 deletions micopay/backend/src/services/merchant.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,13 @@ export async function getAvailableMerchants(
min_trade_mxn: r.min_trade_mxn,
max_trade_mxn: r.max_trade_mxn,
daily_cap_mxn: r.daily_cap_mxn,
latitude: parseFloat(r.latitude as unknown as string),
longitude: parseFloat(r.longitude as unknown as string),
// G1 privacy: coarsen public discovery coordinates to ~110m (3 decimals).
// Exact coordinates are only revealed to a counterparty inside an
// accepted trade, never at discovery time. distance_km above is
// already computed in SQL from the exact, unrounded columns, so it
// stays accurate — only these two output fields are rounded.
latitude: Math.round(parseFloat(r.latitude as unknown as string) * 1000) / 1000,
longitude: Math.round(parseFloat(r.longitude as unknown as string) * 1000) / 1000,
address_text: r.address_text,
distance_km: Math.round(distanceKm * 1000) / 1000,
payout_mxn: payoutMxn,
Expand Down
163 changes: 163 additions & 0 deletions micopay/backend/src/tests/merchant.discovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* G1 — /merchants/available is public, unauthenticated and (before this fix)
* had no rate limit and returned exact lat/lng, letting anyone scrape the
* full census of merchant locations.
*
* This test covers the two mitigations from docs/PLAN_MAPA_REAL_2026-07.md
* WP3:
* (a) getAvailableMerchants() rounds the *returned* latitude/longitude to
* 3 decimals (~110m) while distance_km keeps its existing precision.
* (b) the discoveryRateLimit limiter (createRateLimiter({ windowMs: 60_000,
* max: 30 })) throws a RateLimitError (429, Retry-After) once a single
* IP exceeds `max` requests inside the window.
*
* Runs against the in-memory DB (ALLOW_IN_MEMORY_DB=true, no PostgreSQL
* needed), following the pattern of tradeAuth.test.ts / refund.test.ts.
*
* NOTE on (a): the in-memory SQL shim in src/db/schema.ts is a small regex
* based mock. It does not evaluate computed SQL columns (the HAVERSINE_SQL
* expression aliased as distance_km, or the seller_id/username/trades_*
* subqueries), only special-cases LEFT JOIN (not the plain INNER JOIN this
* query uses against `users`), and — critically — its WHERE-clause regex
* (`/\bWHERE\b.../`) matches the *first* literal "WHERE" in the raw SQL
* text, which here is the one inside the nested trades_completed/
* trades_terminal subqueries, not the query's real WHERE. Seeding rows into
* merchant_configs and calling getAvailableMerchants() end-to-end therefore
* can't reliably exercise this query against the mock — it's a limitation of
* the mock, not of getAvailableMerchants() itself (against real PostgreSQL
* the query runs as written).
*
* So instead this test stubs `db.getMany` for the duration of the call,
* returning exactly the shape PostgreSQL would for one seeded merchant, and
* asserts on what getAvailableMerchants() does with that row — i.e. it
* targets the actual code under test (the rounding in the .map() in
* src/services/merchant.service.ts), independent of the mock SQL engine.
*/

import { strictEqual, ok, notStrictEqual } from "assert";
import db from "../db/schema.js";
import { getAvailableMerchants } from "../services/merchant.service.js";
import { InMemoryStore, createRateLimiter } from "../middleware/rateLimit.middleware.js";
import { RateLimitError } from "../utils/errors.js";

// ── (a) coordinate rounding ─────────────────────────────────────────────────

async function testAvailableMerchantsRoundsCoordinates() {
const preciseLat = 19.432608123; // exact GPS reading, many decimals
const preciseLng = -99.133209456;
const preciseDistanceKm = 12.34567; // exact haversine result, as Postgres would compute it

const originalGetMany = db.getMany;
db.getMany = (async (_text: string, _params?: any[]) => [
{
seller_id: "user-discovery-1",
username: "merchant_discovery_1",
rate_percent: "1.5",
min_trade_mxn: 100,
max_trade_mxn: 50000,
daily_cap_mxn: 250000,
latitude: String(preciseLat),
longitude: String(preciseLng),
address_text: "CDMX",
distance_km: String(preciseDistanceKm),
trades_completed: "3",
trades_terminal: "3",
},
]) as typeof db.getMany;

let results: Awaited<ReturnType<typeof getAvailableMerchants>>;
try {
results = await getAvailableMerchants({
lat: preciseLat,
lng: preciseLng,
radius_km: 5,
amount_mxn: 500,
});
} finally {
db.getMany = originalGetMany;
}

ok(results.length >= 1, "expected the seeded merchant to be returned");
const merchant = results.find((m) => m.address_text === "CDMX");
ok(merchant, "expected to find the seeded merchant by address_text");

const expectedLat = Math.round(preciseLat * 1000) / 1000;
const expectedLng = Math.round(preciseLng * 1000) / 1000;

strictEqual(merchant!.latitude, expectedLat, "latitude must be rounded to 3 decimals");
strictEqual(merchant!.longitude, expectedLng, "longitude must be rounded to 3 decimals");
notStrictEqual(merchant!.latitude, preciseLat, "rounded latitude must differ from the precise input");
notStrictEqual(merchant!.longitude, preciseLng, "rounded longitude must differ from the precise input");

// decimal-place check: no more than 3 digits after the decimal point
const decimalsOf = (n: number) => (String(n).split(".")[1] ?? "").length;
ok(decimalsOf(merchant!.latitude) <= 3, "latitude must have at most 3 decimal places");
ok(decimalsOf(merchant!.longitude) <= 3, "longitude must have at most 3 decimal places");

// distance_km keeps its own (already existing) 3-decimal rounding and is
// NOT derived from the coarsened lat/lng — it stays independently accurate.
strictEqual(
merchant!.distance_km,
Math.round(preciseDistanceKm * 1000) / 1000,
"distance_km must reflect the precise coordinates, unaffected by public lat/lng rounding",
);

console.log(" ✓ getAvailableMerchants() rounds public latitude/longitude to 3 decimals, distance_km unaffected");
}

// ── (b) discovery rate limiter ─────────────────────────────────────────────

async function testDiscoveryRateLimiterBlocksAfterMax() {
const store = new InMemoryStore();
const windowMs = 1000;
const max = 30;

// Same construction as the discoveryRateLimit wired into
// src/routes/merchants.ts (createRateLimiter({ windowMs: 60_000, max: 30 })),
// using a shorter window here so the test doesn't need to wait a full minute.
const discoveryRateLimit = createRateLimiter({
windowMs,
max,
store,
keyGenerator: (req) => req.ip,
});

const mockReq = { ip: "203.0.113.7" };
const mockReply = {
header: (_name: string, _value: any) => {},
};

for (let i = 0; i < max; i++) {
await (discoveryRateLimit as any)(mockReq, mockReply);
}
console.log(` ✓ ${max} requests from the same IP within the window are allowed`);

let threw = false;
try {
await (discoveryRateLimit as any)(mockReq, mockReply);
} catch (err) {
threw = true;
ok(err instanceof RateLimitError, `expected RateLimitError, got ${(err as Error)?.constructor?.name}`);
strictEqual((err as RateLimitError).statusCode, 429, "rate-limited response must be 429");
ok((err as RateLimitError).retryAfter !== undefined, "rate-limited response must carry retryAfter");
}
ok(threw, `request ${max + 1} should have thrown RateLimitError`);
console.log(" ✓ request past max is rejected with 429 and Retry-After");

// A different IP is unaffected by the first IP's exhausted budget.
const otherReq = { ip: "203.0.113.99" };
await (discoveryRateLimit as any)(otherReq, mockReply);
console.log(" ✓ a different IP is not affected by another IP's rate limit");
}

async function main() {
console.log("\nMerchant discovery privacy & rate-limit tests\n");
await testAvailableMerchantsRoundsCoordinates();
await testDiscoveryRateLimiterBlocksAfterMax();
console.log("\nAll merchant.discovery tests passed.\n");
}

main().catch((err) => {
console.error("❌ merchant.discovery tests failed:", err);
process.exit(1);
});
Loading