196 lines
7.6 KiB
TypeScript
196 lines
7.6 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const {
|
|
trackDevice,
|
|
getDeviceCount,
|
|
getDeviceDetails,
|
|
getAllDeviceCounts,
|
|
expireDevices,
|
|
extractIpFromHeaders,
|
|
maskIp,
|
|
clearDeviceTracker,
|
|
} = await import("../../open-sse/services/deviceTracker.ts");
|
|
|
|
test.beforeEach(() => {
|
|
delete process.env.DEVICE_TRACKER_TTL_MS;
|
|
delete process.env.DEVICE_TRACKER_MAX_DEVICES_PER_KEY;
|
|
delete process.env.DEVICE_TRACKER_MAX_TOTAL_DEVICES;
|
|
clearDeviceTracker();
|
|
});
|
|
|
|
// ─── Fingerprint dedup ──────────────────────────────────────────────────────
|
|
|
|
test("trackDevice: same IP + UA for a key counts as one device", async () => {
|
|
await trackDevice("key-1", "203.0.113.5", "Mozilla/5.0 test-agent");
|
|
await trackDevice("key-1", "203.0.113.5", "Mozilla/5.0 test-agent");
|
|
await trackDevice("key-1", "203.0.113.5", "Mozilla/5.0 test-agent");
|
|
|
|
assert.equal(getDeviceCount("key-1"), 1);
|
|
});
|
|
|
|
test("trackDevice: different User-Agent for same key/IP counts as a new device", async () => {
|
|
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
|
await trackDevice("key-1", "203.0.113.5", "python-requests/2.31");
|
|
|
|
assert.equal(getDeviceCount("key-1"), 2);
|
|
});
|
|
|
|
test("trackDevice: different IP for same key/UA counts as a new device", async () => {
|
|
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
|
await trackDevice("key-1", "198.51.100.9", "curl/8.0");
|
|
|
|
assert.equal(getDeviceCount("key-1"), 2);
|
|
});
|
|
|
|
test("trackDevice: repeated tracking refreshes lastSeen instead of duplicating", async () => {
|
|
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
|
const [before] = getDeviceDetails("key-1");
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
|
const [after] = getDeviceDetails("key-1");
|
|
|
|
assert.equal(getDeviceCount("key-1"), 1);
|
|
assert.ok(after.lastSeen >= before.lastSeen);
|
|
});
|
|
|
|
test("trackDevice: no-ops and returns null when apiKeyId is missing", async () => {
|
|
const fingerprint = await trackDevice(null, "203.0.113.5", "curl/8.0");
|
|
assert.equal(fingerprint, null);
|
|
assert.equal(getDeviceCount(null), 0);
|
|
});
|
|
|
|
// ─── Per-key isolation ──────────────────────────────────────────────────────
|
|
|
|
test("trackDevice: devices are scoped per API key, not global", async () => {
|
|
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
|
await trackDevice("key-2", "203.0.113.5", "curl/8.0"); // same fingerprint, different key
|
|
|
|
assert.equal(getDeviceCount("key-1"), 1);
|
|
assert.equal(getDeviceCount("key-2"), 1);
|
|
|
|
const allCounts = getAllDeviceCounts();
|
|
assert.equal(allCounts["key-1"], 1);
|
|
assert.equal(allCounts["key-2"], 1);
|
|
});
|
|
|
|
// ─── TTL expiry ─────────────────────────────────────────────────────────────
|
|
|
|
test("expireDevices: evicts devices whose lastSeen exceeds the TTL window", async () => {
|
|
process.env.DEVICE_TRACKER_TTL_MS = "1000";
|
|
clearDeviceTracker();
|
|
|
|
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
|
assert.equal(getDeviceCount("key-1"), 1);
|
|
|
|
const farFuture = Date.now() + 5000;
|
|
const expiredCount = expireDevices(farFuture);
|
|
|
|
assert.equal(expiredCount, 1);
|
|
assert.equal(getDeviceCount("key-1"), 0);
|
|
});
|
|
|
|
test("expireDevices: keeps devices seen within the TTL window", async () => {
|
|
process.env.DEVICE_TRACKER_TTL_MS = "60000";
|
|
clearDeviceTracker();
|
|
|
|
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
|
const soon = Date.now() + 1000;
|
|
const expiredCount = expireDevices(soon);
|
|
|
|
assert.equal(expiredCount, 0);
|
|
assert.equal(getDeviceCount("key-1"), 1);
|
|
});
|
|
|
|
// ─── Eviction under caps ────────────────────────────────────────────────────
|
|
|
|
test("trackDevice: enforces maxDevicesPerApiKey by evicting the oldest device", async () => {
|
|
process.env.DEVICE_TRACKER_MAX_DEVICES_PER_KEY = "2";
|
|
clearDeviceTracker();
|
|
|
|
await trackDevice("key-1", "203.0.113.1", "ua-1");
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
await trackDevice("key-1", "203.0.113.2", "ua-2");
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
// Third distinct device should evict the oldest (203.0.113.1 / ua-1).
|
|
await trackDevice("key-1", "203.0.113.3", "ua-3");
|
|
|
|
assert.equal(getDeviceCount("key-1"), 2);
|
|
const uaSet = new Set(getDeviceDetails("key-1").map((d) => d.userAgent));
|
|
assert.ok(!uaSet.has("ua-1"), "oldest device should have been evicted");
|
|
assert.ok(uaSet.has("ua-2"));
|
|
assert.ok(uaSet.has("ua-3"));
|
|
});
|
|
|
|
test("trackDevice: enforces maxTotalDevices globally across all keys", async () => {
|
|
process.env.DEVICE_TRACKER_MAX_TOTAL_DEVICES = "2";
|
|
clearDeviceTracker();
|
|
|
|
await trackDevice("key-1", "203.0.113.1", "ua-1");
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
await trackDevice("key-2", "203.0.113.2", "ua-2");
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
await trackDevice("key-3", "203.0.113.3", "ua-3");
|
|
|
|
const total = Object.values(getAllDeviceCounts()).reduce((a, b) => a + b, 0);
|
|
assert.equal(total, 2);
|
|
// key-1's device was the oldest globally and should be gone.
|
|
assert.equal(getDeviceCount("key-1"), 0);
|
|
});
|
|
|
|
// ─── IP masking ─────────────────────────────────────────────────────────────
|
|
|
|
test("maskIp: masks the last two octets of an IPv4 address", () => {
|
|
assert.equal(maskIp("203.0.113.42"), "203.0.x.x");
|
|
});
|
|
|
|
test("maskIp: masks an IPv6 address down to its first three groups", () => {
|
|
assert.equal(maskIp("2001:db8:85a3:0:0:8a2e:370:7334"), "2001:db8:85a3:...");
|
|
});
|
|
|
|
test("maskIp: returns 'unknown' for missing/unknown input", () => {
|
|
assert.equal(maskIp(null), "unknown");
|
|
assert.equal(maskIp(undefined), "unknown");
|
|
assert.equal(maskIp("unknown"), "unknown");
|
|
});
|
|
|
|
test("trackDevice: never stores the raw IP — only the masked form", async () => {
|
|
await trackDevice("key-1", "203.0.113.42", "curl/8.0");
|
|
const [detail] = getDeviceDetails("key-1");
|
|
|
|
assert.equal(detail.ip, "203.0.x.x");
|
|
assert.notEqual(detail.ip, "203.0.113.42");
|
|
});
|
|
|
|
test("getDeviceDetails: truncates the fingerprint instead of exposing the full SHA-256 hash", async () => {
|
|
await trackDevice("key-1", "203.0.113.42", "curl/8.0");
|
|
const [detail] = getDeviceDetails("key-1");
|
|
|
|
assert.equal(detail.fingerprint.length, 12);
|
|
});
|
|
|
|
// ─── IP extraction from headers ─────────────────────────────────────────────
|
|
|
|
test("extractIpFromHeaders: prefers cf-connecting-ip over x-forwarded-for", () => {
|
|
const ip = extractIpFromHeaders({
|
|
"cf-connecting-ip": "203.0.113.5",
|
|
"x-forwarded-for": "198.51.100.9, 10.0.0.1",
|
|
});
|
|
assert.equal(ip, "203.0.113.5");
|
|
});
|
|
|
|
test("extractIpFromHeaders: falls back to the first hop of x-forwarded-for", () => {
|
|
const ip = extractIpFromHeaders({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" });
|
|
assert.equal(ip, "198.51.100.9");
|
|
});
|
|
|
|
test("extractIpFromHeaders: works with a real Headers instance", () => {
|
|
const headers = new Headers({ "x-real-ip": "192.0.2.7" });
|
|
assert.equal(extractIpFromHeaders(headers), "192.0.2.7");
|
|
});
|
|
|
|
test("extractIpFromHeaders: returns 'unknown' when no IP header is present", () => {
|
|
assert.equal(extractIpFromHeaders({}), "unknown");
|
|
assert.equal(extractIpFromHeaders(null), "unknown");
|
|
});
|