-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
44cc9c0
commit 49f7aee
Showing
14 changed files
with
13,704 additions
and
7,122 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)" | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()], | ||
]); | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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([]); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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."); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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."); | ||
} | ||
}; |
Oops, something went wrong.