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
29 changes: 16 additions & 13 deletions apps/api/src/services/reportValidation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
);
Expand All @@ -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`
);
Expand Down
95 changes: 95 additions & 0 deletions apps/api/tests/reportValidation.service.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading