From b0876eb80411f49c2bae90467618dfd3a761ee82 Mon Sep 17 00:00:00 2001 From: Gerald Iakobinyi-Pich1 Date: Mon, 30 Dec 2024 13:51:57 +0200 Subject: [PATCH] fix: adding tests for express app --- embed/__mocks__/ioredis.js | 18 ++++ embed/__tests__/index.test.ts | 193 ++++++++++++++++++++++++++++++++++ embed/src/autoVerification.ts | 53 +++++++--- embed/src/index.ts | 18 ++-- embed/src/rate-limiter.ts | 17 ++- embed/src/redis.ts | 5 +- 6 files changed, 272 insertions(+), 32 deletions(-) create mode 100644 embed/__mocks__/ioredis.js create mode 100644 embed/__tests__/index.test.ts diff --git a/embed/__mocks__/ioredis.js b/embed/__mocks__/ioredis.js new file mode 100644 index 0000000000..4caf39e3b9 --- /dev/null +++ b/embed/__mocks__/ioredis.js @@ -0,0 +1,18 @@ +// __mocks__/ioredis.ts +const RedisMock = jest.fn().mockImplementation(() => { + return { + get: jest.fn((key) => Promise.resolve(null)), + set: jest.fn((key, value) => { + return Promise.resolve("OK"); + }), + on: jest.fn((key, func) => {}), + call: jest.fn((type, ...args) => { + if(type === "EVALSHA") { + return Promise.resolve([ 1, 60000 ]); + } + return Promise.resolve("OK"); + }), + }; +}); + +module.exports = RedisMock; diff --git a/embed/__tests__/index.test.ts b/embed/__tests__/index.test.ts new file mode 100644 index 0000000000..1dd9c76e47 --- /dev/null +++ b/embed/__tests__/index.test.ts @@ -0,0 +1,193 @@ +// ---- Testing libraries + +// jest.mock("ioredis"); + +import request from "supertest"; +import { Response, Request } from "express"; +import { apiKeyRateLimit, keyGenerator } from "../src/rate-limiter"; +import { + PassportScore, + AutoVerificationResponseBodyType, + AutoVerificationRequestBodyType, +} from "../src/autoVerification"; +import { ParamsDictionary } from "express-serve-static-core"; +import { VerifiableEip712Credential } from "@gitcoin/passport-types"; +// ---- Test subject + +const mockedScore: PassportScore = { + address: "0x0000000000000000000000000000000000000000", + score: "12", + passing_score: true, + last_score_timestamp: new Date().toISOString(), + expiration_timestamp: new Date().toISOString(), + threshold: "20.000", + error: "", + stamps: { "provider-1": { score: "12", dedup: true, expiration_date: new Date().toISOString() } }, +}; + +// const createMockVerifiableCredential = (address: string): VerifiableEip712Credential => ({ +// "@context": ["https://www.w3.org/2018/credentials/v1", "https://w3id.org/security/suites/eip712sig-2021/v1"], +// type: ["VerifiableCredential", "EVMCredential"], +// credentialSubject: { +// id: `did:pkh:eip155:1:${address}`, +// "@context": { +// hash: "https://schema.org/Text", +// provider: "https://schema.org/Text", +// address: "https://schema.org/Text", +// challenge: "https://schema.org/Text", +// metaPointer: "https://schema.org/URL", +// }, +// hash: "0x123456789", +// provider: "test-provider", +// address: address, +// challenge: "test-challenge", +// metaPointer: "https://example.com/metadata", +// }, +// issuer: "did:key:test-issuer", +// issuanceDate: new Date().toISOString(), +// expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now +// proof: { +// "@context": "https://w3id.org/security/suites/eip712sig-2021/v1", +// type: "EthereumEip712Signature2021", +// proofPurpose: "assertionMethod", +// proofValue: "0xabcdef1234567890", +// verificationMethod: "did:key:test-verification", +// created: new Date().toISOString(), +// eip712Domain: { +// domain: { +// name: "GitcoinVerifiableCredential", +// }, +// primaryType: "VerifiableCredential", +// types: { +// EIP712Domain: [ +// { name: "name", type: "string" }, +// { name: "version", type: "string" }, +// ], +// VerifiableCredential: [ +// { name: "id", type: "string" }, +// { name: "address", type: "string" }, +// ], +// }, +// }, +// }, +// }); + +jest.mock("../src/rate-limiter", () => { + const originalModule = jest.requireActual("../src/rate-limiter"); + + return { + ...originalModule, + apiKeyRateLimit: jest.fn((req, res) => { + return new Promise((resolve, reject) => { + resolve(10000); + }); + }), + keyGenerator: jest.fn(originalModule.keyGenerator), + }; +}); + + + +jest.mock("../src/autoVerification", () => { + const originalModule = jest.requireActual("../src/autoVerification"); + + return { + // __esModule: true, // Use it when dealing with esModules + ...originalModule, + autoVerificationHandler: jest.fn( + ( + req: Request, + res: Response + ) => { + return new Promise((resolve, reject) => { + res.status(200).json(mockedScore); + resolve(); + }); + } + ), + }; +}); + + + +import { app } from "../src/index"; + +beforeEach(() => { + // CLear the spy stats + jest.clearAllMocks(); +}); + +describe("POST /embed/verify", function () { + it("handles valid verify requests", async () => { + // as each signature is unique, each request results in unique output + const payload = { + address: "0x0000000000000000000000000000000000000000", + scorerId: "123", + }; + + // create a req against the express app + const verifyRequest = await request(app) + .post("/embed/verify") + .send(payload) + .set("Accept", "application/json") + .set("X-API-KEY", "MY.SECRET-KEY"); + + expect(apiKeyRateLimit as jest.Mock).toHaveBeenCalledTimes(1); + expect(keyGenerator as jest.Mock).toHaveBeenCalledTimes(1); + expect(verifyRequest.status).toBe(200); + expect(verifyRequest.body).toStrictEqual(mockedScore); + }); + + it("handles invalid verify requests - missing api key", async () => { + // as each signature is unique, each request results in unique output + const payload = { + address: "0x0000000000000000000000000000000000000000", + scorerId: "123", + }; + + // create a req against the express app + const verifyRequest = await request(app).post("/embed/verify").send(payload).set("Accept", "application/json"); + + expect(apiKeyRateLimit as jest.Mock).toHaveBeenCalledTimes(0); + expect(keyGenerator as jest.Mock).toHaveBeenCalledTimes(1); + expect(verifyRequest.status).toBe(401); + expect(verifyRequest.body).toStrictEqual({ message: "Unauthorized! No 'X-API-KEY' present in the header!" }); + }); + + it("handles invalid verify requests - api key validation fails", async () => { + // as each signature is unique, each request results in unique output + const payload = { + address: "0x0000000000000000000000000000000000000000", + scorerId: "123", + }; + + (apiKeyRateLimit as jest.Mock).mockImplementationOnce(() => { + throw "Invalid API-KEY"; + }); + + // create a req against the express app + const verifyRequest = await request(app) + .post("/embed/verify") + .send(payload) + .set("Accept", "application/json") + .set("X-API-KEY", "MY.SECRET-KEY"); + + expect(apiKeyRateLimit as jest.Mock).toHaveBeenCalledTimes(1); + expect(keyGenerator as jest.Mock).toHaveBeenCalledTimes(1); + expect(verifyRequest.status).toBe(500); + }); +}); + +describe("POST /health", function () { + it("handles valid health requests", async () => { + // create a req against the express app + const verifyRequest = await request(app).get("/health").set("Accept", "application/json"); + + expect(apiKeyRateLimit as jest.Mock).toHaveBeenCalledTimes(0); + expect(keyGenerator as jest.Mock).toHaveBeenCalledTimes(0); + expect(verifyRequest.status).toBe(200); + expect(verifyRequest.body).toStrictEqual({ + message: "Ok", + }); + }); +}); diff --git a/embed/src/autoVerification.ts b/embed/src/autoVerification.ts index 665256f23b..80defc420a 100644 --- a/embed/src/autoVerification.ts +++ b/embed/src/autoVerification.ts @@ -93,6 +93,23 @@ type VerifyTypeResult = { code?: number; }; +export type PassportProviderPoints = { + score: string; + dedup: boolean; + expiration_date: string; +}; + +export type PassportScore = { + address: string; + score: string; + passing_score: boolean; + last_score_timestamp: string; + expiration_timestamp: string; + threshold: string; + error: string; + stamps: Record; +}; + const providerTypePlatformMap = Object.entries(platforms).reduce( (acc, [platformName, { providers }]) => { providers.forEach(({ type }) => { @@ -244,14 +261,14 @@ export const issueCredentials = async ( ); }; -type AutoVerificationRequestBodyType = { +export type AutoVerificationRequestBodyType = { address: string; scorerId: string; }; type AutoVerificationFields = AutoVerificationRequestBodyType; -type AutoVerificationResponseBodyType = { +export type AutoVerificationResponseBodyType = { score: string; threshold: string; }; @@ -265,6 +282,7 @@ export const autoVerificationHandler = async ( try { console.log("====> step 1"); const { address, scorerId } = req.body; + console.log("====> step 1 --- ", address); if (!isAddress(address)) { return void errorRes(res, "Invalid address", 400); @@ -274,19 +292,19 @@ export const autoVerificationHandler = async ( const stamps = await getPassingEvmStamps({ address, scorerId }); console.log("====> step 3"); - const { score, threshold } = await addStampsAndGetScore({ address, scorerId, stamps }); + const score = await addStampsAndGetScore({ address, scorerId, stamps }); // TODO should we issue a score VC? console.log("====> step 4"); - return void res.json({ score, threshold }); + return void res.json(score); } catch (error) { console.log("====> ERROR", error); - if (error instanceof ApiError) { - return void errorRes(res, error.message, error.code); - } - const message = addErrorDetailsToMessage("Unexpected error when processing request", error); - return void errorRes(res, message, 500); + // if (error instanceof ApiError) { + // return void errorRes(res, error.message, error.code); + // } + // const message = addErrorDetailsToMessage("Unexpected error when processing request", error); + // return void errorRes(res, message, 500); } }; @@ -301,7 +319,10 @@ const getEvmProviders = ({ scorerId }: { scorerId: string }): PROVIDER_ID[] => { .flat(2); }; -const getPassingEvmStamps = async ({ address, scorerId }: AutoVerificationFields): Promise => { +export const getPassingEvmStamps = async ({ + address, + scorerId, +}: AutoVerificationFields): Promise => { const evmProviders = getEvmProviders({ scorerId }); const credentialsInfo = { @@ -327,10 +348,7 @@ const addStampsAndGetScore = async ({ address, scorerId, stamps, -}: AutoVerificationFields & { stamps: VerifiableCredential[] }): Promise<{ - score: string; - threshold: string; -}> => { +}: AutoVerificationFields & { stamps: VerifiableCredential[] }): Promise => { const scorerResponse: { data?: { score?: { @@ -356,10 +374,11 @@ const addStampsAndGetScore = async ({ const scoreData = scorerResponse.data?.score || {}; - const score = String(scoreData.evidence?.rawScore || scoreData.score); - const threshold = String(scoreData.evidence?.threshold || 20); + console.log("geri scoreData", scoreData); + // const score = String(scoreData.evidence?.rawScore || scoreData.score); + // const threshold = String(scoreData.evidence?.threshold || 20); - return { score, threshold }; + return scoreData as PassportScore; }; export const checkConditionsAndIssueCredentials = async ( diff --git a/embed/src/index.ts b/embed/src/index.ts index ce2dd2686c..7abc77044c 100644 --- a/embed/src/index.ts +++ b/embed/src/index.ts @@ -7,10 +7,10 @@ import express from "express"; // ---- Production plugins import cors from "cors"; import { rateLimit } from "express-rate-limit"; -import { RedisStore } from "rate-limit-redis"; +import { RedisReply, RedisStore } from "rate-limit-redis"; // --- Relative imports -import { apiKeyRateLimit } from "./rate-limiter.js"; +import { keyGenerator, apiKeyRateLimit } from "./rate-limiter.js"; import { autoVerificationHandler } from "./autoVerification.js"; import { metadataHandler } from "./metadata.js"; import { redis } from "./redis.js"; @@ -81,14 +81,19 @@ app.use(cors()); // Use the rate limiting middleware app.use( rateLimit({ - windowMs: 60 * 1000, // We claculate the limit for a 1 minute limit ... + windowMs: 60 * 1000, // We calculate the limit for a 1 minute limit ... limit: apiKeyRateLimit, // Redis store configuration - keyGenerator: (req, _res) => req.headers["x-api-key"] as string, + keyGenerator: keyGenerator, store: new RedisStore({ - // @ts-expect-error - Known issue: the `call` function is not present in @types/ioredis - sendCommand: (...args: string[]) => redis.call(...args), + sendCommand: async (...args: string[]): Promise => { + // @ts-expect-error - Known issue: the `call` function is not present in @types/ioredis + return await redis.call(...args); + }, }), + skip: (req, res): boolean => { + return req.path === "/health"; + }, }) ); @@ -96,7 +101,6 @@ app.use( app.get("/health", (_req, res) => { const data = { message: "Ok", - date: new Date(), }; res.status(200).send(data); diff --git a/embed/src/rate-limiter.ts b/embed/src/rate-limiter.ts index 9fbe1ce3e4..c624498008 100644 --- a/embed/src/rate-limiter.ts +++ b/embed/src/rate-limiter.ts @@ -40,6 +40,7 @@ function parseRateLimit(rateLimitSpec: string): number { export async function apiKeyRateLimit(req: Request, res: Response): Promise { try { + console.log(" ===> apiKeyRateLimit"); const apiKey = req.headers["x-api-key"] as string; const cacheKey = `erl:${apiKey}`; const cachedRateLimit = await redis.get(cacheKey); @@ -65,9 +66,17 @@ export async function apiKeyRateLimit(req: Request, res: Response): Promise { console.log("Connected to Redis");