diff --git a/apps/api/src/services/reportValidation.service.ts b/apps/api/src/services/reportValidation.service.ts index 56f2e908..5183787d 100644 --- a/apps/api/src/services/reportValidation.service.ts +++ b/apps/api/src/services/reportValidation.service.ts @@ -160,13 +160,13 @@ export async function validateReport( // 6. Geographic diversity: same IP reporting for many different districts if (ipAddress) { - const { count: distinctCount, error: geoError } = await supabase + const { data: geoRows, error: geoError } = await supabase .from("counterfeit_reports") - .select("district", { count: "exact", head: true }) + .select("district") .eq("ip_address", ipAddress) .gte("created_at", burstDeadline); - - if (!geoError && distinctCount && distinctCount >= 3) { + const distinctCount = geoRows ? new Set(geoRows.map((r) => r.district)).size : 0; + if (!geoError && distinctCount >= 3) { reasons.push( `Suspicious geographic spread: IP reported in ${distinctCount} different districts` ); @@ -177,26 +177,29 @@ export async function validateReport( // 7. Sybil detection: many distinct IPs reporting for same district or medicine // Catches slow coordinated attacks across multiple accounts/IPs if (ipAddress) { - const { count: distinctIpsForDistrict, error: sybilDistError } = await supabase + const { data: districtRows, error: sybilDistError } = await supabase .from("counterfeit_reports") - .select("ip_address", { count: "exact", head: true }) + .select("ip_address") .eq("district", payload.district) .gte("created_at", burstDeadline); - - if (!sybilDistError && distinctIpsForDistrict && distinctIpsForDistrict >= 8) { + const distinctIpsForDistrict = districtRows + ? new Set(districtRows.map((r) => r.ip_address)).size + : 0; + if (!sybilDistError && distinctIpsForDistrict >= 8) { reasons.push( `Sybil pattern: ${distinctIpsForDistrict} different reporters for district "${payload.district}" in last hour` ); riskScore += 0.2; } - - const { count: distinctIpsForMedicine, error: sybilMedError } = await supabase + const { data: medicineRows, error: sybilMedError } = await supabase .from("counterfeit_reports") - .select("ip_address", { count: "exact", head: true }) + .select("ip_address") .eq("reported_brand_name", payload.medicineName) .gte("created_at", burstDeadline); - - if (!sybilMedError && distinctIpsForMedicine && distinctIpsForMedicine >= 5) { + const distinctIpsForMedicine = medicineRows + ? new Set(medicineRows.map((r) => r.ip_address)).size + : 0; + if (!sybilMedError && distinctIpsForMedicine >= 5) { reasons.push( `Sybil pattern: ${distinctIpsForMedicine} different reporters for "${payload.medicineName}" in last hour` ); diff --git a/apps/api/tests/reportValidation.service.test.ts b/apps/api/tests/reportValidation.service.test.ts new file mode 100644 index 00000000..3f3ca99d --- /dev/null +++ b/apps/api/tests/reportValidation.service.test.ts @@ -0,0 +1,95 @@ +process.env.SUPABASE_URL = process.env.SUPABASE_URL || "http://localhost:54321"; +process.env.SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || "test-anon-key"; +(global as any).WebSocket = (global as any).WebSocket || class {}; + +import { validateReport, ReportPayload } from "../src/services/reportValidation.service"; +import { supabase } from "../src/db/client"; + +jest.mock("../src/db/client", () => ({ + supabase: { + from: jest.fn(), + }, +})); + +function mockQueryResult(data: any[], error: any = null) { + const builder: any = { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + ilike: jest.fn().mockReturnThis(), + gte: jest.fn().mockReturnThis(), + order: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockResolvedValue({ data: data[0] ?? null, error }), + single: jest.fn().mockResolvedValue({ data: data[0] ?? null, error }), + }; + // Make the builder itself awaitable for chains that don't terminate in + // .maybeSingle()/.single() (e.g. chains ending in .limit() or .gte()) + builder.then = (resolve: any) => Promise.resolve({ data, error }).then(resolve); + return builder; +} + +const basePayload: ReportPayload = { + medicineName: "Paracetamol", + manufacturer: "ABC Pharma", + description: "Suspicious packaging", + pharmacyName: "Apollo Pharmacy", + address: "123 Main St", + city: "Pune", + state: "Maharashtra", + pincode: "411001", + district: "Pune", +}; + +describe("reportValidation.service - distinct count checks", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should NOT flag geographic spread when 5 duplicate reports come from the same IP and same district", async () => { + (supabase.from as jest.Mock).mockImplementation(() => + mockQueryResult(Array(5).fill({ district: "Pune" })) + ); + + const result = await validateReport(basePayload, "1.2.3.4", null); + + const geoReason = result.reasons.find((r) => r.includes("geographic spread")); + expect(geoReason).toBeUndefined(); + }); + + it("should flag geographic spread when an IP reports for 3+ distinct districts", async () => { + (supabase.from as jest.Mock).mockImplementation(() => + mockQueryResult([{ district: "Pune" }, { district: "Mumbai" }, { district: "Nashik" }]) + ); + + const result = await validateReport(basePayload, "1.2.3.4", null); + + const geoReason = result.reasons.find((r) => r.includes("geographic spread")); + expect(geoReason).toContain("3 different districts"); + }); + + it("should NOT flag Sybil pattern when 8 duplicate reports come from the same IP for one district", async () => { + (supabase.from as jest.Mock).mockImplementation(() => + mockQueryResult(Array(8).fill({ ip_address: "1.2.3.4" })) + ); + + const result = await validateReport(basePayload, "1.2.3.4", null); + + const sybilReason = result.reasons.find( + (r) => r.includes("Sybil pattern") && r.includes("district") + ); + expect(sybilReason).toBeUndefined(); + }); + + it("should flag Sybil pattern when 8+ distinct IPs report for the same district", async () => { + (supabase.from as jest.Mock).mockImplementation(() => + mockQueryResult(Array.from({ length: 8 }, (_, i) => ({ ip_address: `1.2.3.${i}` }))) + ); + + const result = await validateReport(basePayload, "1.2.3.4", null); + + const sybilReason = result.reasons.find( + (r) => r.includes("Sybil pattern") && r.includes("district") + ); + expect(sybilReason).toContain("8 different reporters"); + }); +});