Skip to content

Commit

Permalink
refactor: add tests and CI
Browse files Browse the repository at this point in the history
This PR moves logic out of the request handler into their own testable functions, adds a bunch of tests, and uses GitHub Actions to run the tests.

add: tests using jest
refactor: move checks out of request handler into own files
add: CI using github actions
add: some console.debug() with reason for each rejection
refactor: use getKeypairFromEnvironment()
refactor: use a character range to clean out IP address separators in a single step
add: README updates
  • Loading branch information
mikemaccana committed Jan 2, 2024
1 parent 44cc9c0 commit 49f7aee
Show file tree
Hide file tree
Showing 14 changed files with 13,704 additions and 7,122 deletions.
25 changes: 25 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# From https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs#using-the-nodejs-starter-workflow
name: Node.js and Solana CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [20.x]

steps:
- uses: actions/checkout@v3

# Install everything
- run: npm ci

# Run tests
- run: npm test
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
Solana Devnet Faucet with rate limiting
# Solana Devnet Faucet with rate limiting

This is the code for the [Solana Devnet Faucet](https://faucet.solana.com/)
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
File renamed without changes.
55 changes: 55 additions & 0 deletions lib/db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "@jest/globals";
import { Row, checkLimits } from "./db";
import { Pool } from "pg";
import { MINUTES } from "./constants";
const log = console.log;

let mockRows: Array<Row> = [];

// Make a mock for the pg Pool constructor
// https://jestjs.io/docs/mock-functions#mocking-modules
jest.mock("pg", () => {
return {
Pool: jest.fn(() => ({
query: jest.fn(() => {
return {
rows: mockRows,
};
}),
})),
};
});

describe("checkLimits", () => {
// TODO: ideally I'd like to use mockValueOnce() instead of the
// mockRows variable, but I couldn't get it to work.
test("is fine when there's no previous usage", async () => {
mockRows = [];
await checkLimits("1.1.1.1");
});

test("allows reasonable usage", async () => {
mockRows = [
{
timestamps: [Date.now() - 10 * MINUTES],
},
];
await checkLimits("1.1.1.1");
});

test("blocks unreasonable usage", async () => {
mockRows = [
{
timestamps: [
Date.now() - 10 * MINUTES,
Date.now() - 10 * MINUTES,
Date.now() - 10 * MINUTES,
Date.now() - 10 * MINUTES,
],
},
];
await expect(checkLimits("1.1.1.1")).rejects.toThrow(
"You have exceeded the 2 airdrops limit in the past 1 hour(s)"
);
});
});
62 changes: 62 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Pool } from "pg";
import { HOURS } from "./constants";
const log = console.log;

export interface Row {
timestamps: Array<number>;
}

const pgClient = new Pool({
connectionString: process.env.POSTGRES_STRING as string,
});

// Eg if AIRDROPS_LIMIT_TOTAL is 2, and AIRDROPS_LIMIT_HOURS is 1,
// then a user can only get 2 airdrops per 1 hour.
const AIRDROPS_LIMIT_TOTAL = 2;
const AIRDROPS_LIMIT_HOURS = 1;

// Formerly called 'getOrCreateAndVerifyDatabaseEntry'
export const checkLimits = async (
ipAddressWithoutDotsOrWalletAddress: string
): Promise<void> => {
// Remove the . (IPV4) and : (IPV6) from the IP address
let databaseKey = ipAddressWithoutDotsOrWalletAddress.replace(/[\.,:]/g, "");

const entryQuery = "SELECT * FROM rate_limits WHERE key = $1;";
const insertQuery =
"INSERT INTO rate_limits (key, timestamps) VALUES ($1, $2);";
const updateQuery = "UPDATE rate_limits SET timestamps = $2 WHERE key = $1;";

const timeAgo = Date.now() - AIRDROPS_LIMIT_HOURS * HOURS;

const queryResult = await pgClient.query(entryQuery, [databaseKey]);

const rows = queryResult.rows as Array<Row>;
const entry = rows[0];

if (entry) {
const timestamps = entry.timestamps;

const isExcessiveUsage =
timestamps.filter((timestamp: number) => timestamp > timeAgo).length >=
AIRDROPS_LIMIT_TOTAL;

if (isExcessiveUsage) {
throw new Error(
`You have exceeded the ${AIRDROPS_LIMIT_TOTAL} airdrops limit in the past ${AIRDROPS_LIMIT_HOURS} hour(s)`
);
}

timestamps.push(Date.now());

await pgClient.query(updateQuery, [
ipAddressWithoutDotsOrWalletAddress,
timestamps,
]);
} else {
await pgClient.query(insertQuery, [
ipAddressWithoutDotsOrWalletAddress,
[Date.now()],
]);
}
};
35 changes: 35 additions & 0 deletions lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextApiRequest } from "next";
import { getHeaderValues } from "./utils";

describe("getHeaderValues", () => {
it("returns an array of strings when x-forwarded-for is a string", () => {
const req = {
headers: {
"x-forwarded-for": "10.0.0.0",
},
} as unknown as NextApiRequest;
const headerName = "x-forwarded-for";
const result = getHeaderValues(req, headerName);
expect(result).toEqual(["10.0.0.0"]);
});

it("returns an array of strings when x-forwarded-for is an array", () => {
const req = {
headers: {
"x-forwarded-for": ["10.0.0.0"],
},
} as unknown as NextApiRequest;
const headerName = "x-forwarded-for";
const result = getHeaderValues(req, headerName);
expect(result).toEqual(["10.0.0.0"]);
});

it("returns an array of strings when x-forwarded-for does not exist", () => {
const req = {
headers: {},
} as unknown as NextApiRequest;
const headerName = "x-forwarded-for";
const result = getHeaderValues(req, headerName);
expect(result).toEqual([]);
});
});
11 changes: 11 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { type ClassValue, clsx } from "clsx";
import { NextApiRequest } from "next";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

export const getHeaderValues = (req: NextApiRequest, headerName: string) => {
// Annoyingly, req.headers["x-forwarded-for"] can be a string or an array of strings
// Let's just make it an array of strings
let valueOrValues = req.headers[headerName] || [];
if (Array.isArray(valueOrValues)) {
return valueOrValues;
}
return [valueOrValues];
};
38 changes: 38 additions & 0 deletions lib/validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { validate } from "./validate";

// Write some tests for the validate function
describe("validate", () => {
test("allows reasonable usage", () => {
validate("DXJfhtWicZwBpHGiBepWwwnJK7jJYNYguGDUgNYbMCCi", 1);
});

test("throws when wallet address is a PDA", () => {
expect(() => {
validate("4MD31b2GFAWVDYQT8KG7E5GcZiFyy4MpDUt4BcyEdJRP", 1);
}).toThrow("Please enter valid wallet address.");
});

test("throws when wallet address is empty string", () => {
expect(() => {
validate("", 1);
}).toThrow("Missing wallet address.");
});

test("throws when amount is 0", () => {
expect(() => {
validate("abcdef", 0);
}).toThrow("Missing SOL amount.");
});

test("throws when amount is too large", () => {
expect(() => {
validate("abcdef", 6);
}).toThrow("Requested SOL amount too large.");
});

test("throws when wallet address is invalid", () => {
expect(() => {
validate("invalidWalletAddress", 1);
}).toThrow("Please enter valid wallet address.");
});
});
27 changes: 27 additions & 0 deletions lib/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { PublicKey } from "@solana/web3.js";

const MAX_SOL_AMOUNT = 5;

export const validate = (walletAddress: string, amount: number): void => {
if (!walletAddress) {
throw new Error("Missing wallet address.");
}

if (!amount) {
throw new Error("Missing SOL amount.");
}

if (amount > MAX_SOL_AMOUNT) {
throw new Error("Requested SOL amount too large.");
}

try {
let pubkey = new PublicKey(walletAddress);
let isOnCurve = PublicKey.isOnCurve(pubkey.toBuffer());
if (!isOnCurve) {
throw new Error("Address can't be a PDA.");
}
} catch (error) {
throw new Error("Please enter valid wallet address.");
}
};
Loading

0 comments on commit 49f7aee

Please sign in to comment.