- Add maxmind library for GeoIP database lookups when not behind Cloudflare - Extract client IP from multiple header sources (CF, X-Real-IP, X-Forwarded-For, etc.) - Change device fingerprints from UUID to base58 with device_ prefix - Add isValidDeviceFingerprint() that accepts both new and legacy formats - Colocate unit tests with source files, remove __tests__/unit directory - Add test coverage reporting to test script Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
223 lines
5.7 KiB
TypeScript
223 lines
5.7 KiB
TypeScript
import { beforeEach, describe, expect, test } from "bun:test";
|
|
import {
|
|
_resetForTesting,
|
|
_setReaderForTesting,
|
|
extractClientIP,
|
|
getGeoInfo,
|
|
getUserAgent,
|
|
lookupGeoFromIP,
|
|
} from "./geo.js";
|
|
|
|
const createHeaders = (entries: Record<string, string>): Headers => {
|
|
return new Headers(entries);
|
|
};
|
|
|
|
describe("extractClientIP", () => {
|
|
test("extracts from CF-Connecting-IP", () => {
|
|
const headers = createHeaders({ "CF-Connecting-IP": "1.2.3.4" });
|
|
expect(extractClientIP(headers)).toBe("1.2.3.4");
|
|
});
|
|
|
|
test("extracts from True-Client-IP", () => {
|
|
const headers = createHeaders({ "True-Client-IP": "5.6.7.8" });
|
|
expect(extractClientIP(headers)).toBe("5.6.7.8");
|
|
});
|
|
|
|
test("extracts from X-Real-IP", () => {
|
|
const headers = createHeaders({ "X-Real-IP": "10.20.30.40" });
|
|
expect(extractClientIP(headers)).toBe("10.20.30.40");
|
|
});
|
|
|
|
test("extracts first IP from X-Forwarded-For", () => {
|
|
const headers = createHeaders({
|
|
"X-Forwarded-For": "100.1.2.3, 200.4.5.6, 10.0.0.1",
|
|
});
|
|
expect(extractClientIP(headers)).toBe("100.1.2.3");
|
|
});
|
|
|
|
test("extracts from X-Client-IP", () => {
|
|
const headers = createHeaders({ "X-Client-IP": "192.168.1.1" });
|
|
expect(extractClientIP(headers)).toBe("192.168.1.1");
|
|
});
|
|
|
|
test("prioritizes CF-Connecting-IP over others", () => {
|
|
const headers = createHeaders({
|
|
"CF-Connecting-IP": "1.1.1.1",
|
|
"True-Client-IP": "2.2.2.2",
|
|
"X-Real-IP": "3.3.3.3",
|
|
"X-Forwarded-For": "4.4.4.4",
|
|
"X-Client-IP": "5.5.5.5",
|
|
});
|
|
expect(extractClientIP(headers)).toBe("1.1.1.1");
|
|
});
|
|
|
|
test("returns null when no IP headers present", () => {
|
|
expect(extractClientIP(createHeaders({}))).toBeNull();
|
|
});
|
|
|
|
test("trims whitespace", () => {
|
|
const headers = createHeaders({ "CF-Connecting-IP": " 1.2.3.4 " });
|
|
expect(extractClientIP(headers)).toBe("1.2.3.4");
|
|
});
|
|
|
|
test("handles IPv6", () => {
|
|
const headers = createHeaders({
|
|
"CF-Connecting-IP": "2001:0db8:85a3::8a2e:0370:7334",
|
|
});
|
|
expect(extractClientIP(headers)).toBe("2001:0db8:85a3::8a2e:0370:7334");
|
|
});
|
|
});
|
|
|
|
describe("lookupGeoFromIP", () => {
|
|
beforeEach(() => {
|
|
_resetForTesting();
|
|
});
|
|
|
|
test("returns nulls when reader not initialized", () => {
|
|
expect(lookupGeoFromIP("8.8.8.8")).toEqual({
|
|
city: null,
|
|
region: null,
|
|
country: null,
|
|
});
|
|
});
|
|
|
|
test("returns geo data from reader", () => {
|
|
_setReaderForTesting({
|
|
get: (ip: string) =>
|
|
ip === "8.8.8.8"
|
|
? {
|
|
city: { names: { en: "Mountain View" } },
|
|
subdivisions: [{ names: { en: "California" } }],
|
|
country: { iso_code: "US" },
|
|
}
|
|
: null,
|
|
} as never);
|
|
|
|
expect(lookupGeoFromIP("8.8.8.8")).toEqual({
|
|
city: "Mountain View",
|
|
region: "California",
|
|
country: "US",
|
|
});
|
|
});
|
|
|
|
test("returns nulls for unknown IP", () => {
|
|
_setReaderForTesting({ get: () => null } as never);
|
|
expect(lookupGeoFromIP("0.0.0.0")).toEqual({
|
|
city: null,
|
|
region: null,
|
|
country: null,
|
|
});
|
|
});
|
|
|
|
test("handles partial geo data", () => {
|
|
_setReaderForTesting({
|
|
get: () => ({ country: { iso_code: "DE" } }),
|
|
} as never);
|
|
|
|
expect(lookupGeoFromIP("1.2.3.4")).toEqual({
|
|
city: null,
|
|
region: null,
|
|
country: "DE",
|
|
});
|
|
});
|
|
|
|
test("handles reader errors gracefully", () => {
|
|
_setReaderForTesting({
|
|
get: () => {
|
|
throw new Error("Lookup failed");
|
|
},
|
|
} as never);
|
|
|
|
expect(lookupGeoFromIP("invalid")).toEqual({
|
|
city: null,
|
|
region: null,
|
|
country: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getGeoInfo", () => {
|
|
beforeEach(() => {
|
|
_resetForTesting();
|
|
});
|
|
|
|
test("uses Cloudflare geo headers when present", () => {
|
|
const headers = createHeaders({
|
|
"CF-Connecting-IP": "1.2.3.4",
|
|
"CF-IPCountry": "US",
|
|
"CF-IPCity": "San Francisco",
|
|
"CF-Region": "California",
|
|
});
|
|
|
|
expect(getGeoInfo(headers)).toEqual({
|
|
ip: "1.2.3.4",
|
|
city: "San Francisco",
|
|
region: "California",
|
|
country: "US",
|
|
});
|
|
});
|
|
|
|
test("prefers CF headers over GeoIP lookup", () => {
|
|
_setReaderForTesting({
|
|
get: () => ({
|
|
city: { names: { en: "Wrong" } },
|
|
country: { iso_code: "XX" },
|
|
}),
|
|
} as never);
|
|
|
|
const headers = createHeaders({
|
|
"CF-Connecting-IP": "1.2.3.4",
|
|
"CF-IPCountry": "US",
|
|
});
|
|
|
|
expect(getGeoInfo(headers).country).toBe("US");
|
|
});
|
|
|
|
test("falls back to GeoIP lookup when no CF geo headers", () => {
|
|
_setReaderForTesting({
|
|
get: () => ({
|
|
city: { names: { en: "Berlin" } },
|
|
subdivisions: [{ names: { en: "Berlin" } }],
|
|
country: { iso_code: "DE" },
|
|
}),
|
|
} as never);
|
|
|
|
const headers = createHeaders({ "X-Real-IP": "1.2.3.4" });
|
|
|
|
expect(getGeoInfo(headers)).toEqual({
|
|
ip: "1.2.3.4",
|
|
city: "Berlin",
|
|
region: "Berlin",
|
|
country: "DE",
|
|
});
|
|
});
|
|
|
|
test("returns IP even without geo data", () => {
|
|
const headers = createHeaders({ "X-Forwarded-For": "203.0.113.50" });
|
|
const result = getGeoInfo(headers);
|
|
|
|
expect(result.ip).toBe("203.0.113.50");
|
|
expect(result.city).toBeNull();
|
|
});
|
|
|
|
test("returns all nulls when no headers", () => {
|
|
expect(getGeoInfo(createHeaders({}))).toEqual({
|
|
ip: null,
|
|
city: null,
|
|
region: null,
|
|
country: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getUserAgent", () => {
|
|
test("returns User-Agent header", () => {
|
|
const headers = createHeaders({ "User-Agent": "Mozilla/5.0 Chrome/120" });
|
|
expect(getUserAgent(headers)).toBe("Mozilla/5.0 Chrome/120");
|
|
});
|
|
|
|
test('returns "Unknown" when missing', () => {
|
|
expect(getUserAgent(createHeaders({}))).toBe("Unknown");
|
|
});
|
|
});
|