diff --git a/src/__tests__/apiKeyScopes.test.ts b/src/__tests__/apiKeyScopes.test.ts index 14fa737..dd2f6f5 100644 --- a/src/__tests__/apiKeyScopes.test.ts +++ b/src/__tests__/apiKeyScopes.test.ts @@ -164,7 +164,7 @@ describe("API-key scopes", () => { expect(res.status).toBe(400); expectCanonicalError( res.body, - res.headers["x-request-id"], + res.headers["x-request-id"] ?? "", "invalid_request", ); expect((res.body.message as string).toLowerCase()).toMatch( @@ -179,7 +179,7 @@ describe("API-key scopes", () => { expect(res.status).toBe(400); expectCanonicalError( res.body, - res.headers["x-request-id"], + res.headers["x-request-id"] ?? "", "invalid_request", ); }); @@ -189,7 +189,7 @@ describe("API-key scopes", () => { expect(res.status).toBe(400); expectCanonicalError( res.body, - res.headers["x-request-id"], + res.headers["x-request-id"] ?? "", "invalid_request", ); }); @@ -202,7 +202,7 @@ describe("API-key scopes", () => { expect(res.status).toBe(400); expectCanonicalError( res.body, - res.headers["x-request-id"], + res.headers["x-request-id"] ?? "", "invalid_request", ); }); diff --git a/src/__tests__/apiKeys.test.ts b/src/__tests__/apiKeys.test.ts index 067f7f2..f136226 100644 --- a/src/__tests__/apiKeys.test.ts +++ b/src/__tests__/apiKeys.test.ts @@ -67,7 +67,7 @@ describe("requireAdmin middleware — constant-time admin token check", () => { process.env.ADMIN_TOKEN = "admin-secret-token-123"; const res = await request(app).get("/api/v1/admin/status"); expect(res.status).toBe(401); - expectCanonicalError(res.body, res.headers["x-request-id"], "unauthorized"); + expectCanonicalError(res.body, res.headers["x-request-id"] ?? "", "unauthorized"); }); it("rejects requests with wrong admin token", async () => { @@ -76,7 +76,7 @@ describe("requireAdmin middleware — constant-time admin token check", () => { .get("/api/v1/admin/status") .set("Authorization", "Bearer wrong-admin-token"); expect(res.status).toBe(401); - expectCanonicalError(res.body, res.headers["x-request-id"], "unauthorized"); + expectCanonicalError(res.body, res.headers["x-request-id"] ?? "", "unauthorized"); }); it("accepts requests with correct admin token", async () => { @@ -93,7 +93,7 @@ describe("requireAdmin middleware — constant-time admin token check", () => { .get("/api/v1/admin/status") .set("Authorization", "Bearer shortextra"); expect(res.status).toBe(401); - expectCanonicalError(res.body, res.headers["x-request-id"], "unauthorized"); + expectCanonicalError(res.body, res.headers["x-request-id"] ?? "", "unauthorized"); }); it("rejects empty bearer token", async () => { @@ -176,7 +176,7 @@ describe("api-keys lifecycle", () => { expect(res.status).toBe(400); expectCanonicalError( res.body, - res.headers["x-request-id"], + res.headers["x-request-id"] ?? "", "invalid_request", ); }); @@ -188,7 +188,7 @@ describe("api-keys lifecycle", () => { expect(res.status).toBe(400); expectCanonicalError( res.body, - res.headers["x-request-id"], + res.headers["x-request-id"] ?? "", "invalid_request", ); }); @@ -257,7 +257,7 @@ describe("api-keys lifecycle", () => { it("returns 404 not_found for an unknown prefix", async () => { const res = await request(app).delete("/api/v1/api-keys/deadbeef"); expect(res.status).toBe(404); - expectCanonicalError(res.body, res.headers["x-request-id"], "not_found"); + expectCanonicalError(res.body, res.headers["x-request-id"] ?? "", "not_found"); }); }); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 336dec2..75af923 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -66,7 +66,9 @@ describe("config GET/PATCH", () => { // ──────────────────────────────────────────────────────────────── it("PATCH bulkMaxItems persists on the next GET", async () => { - const next = original.bulkMaxItems + 7; + const bulkMaxItems = original.bulkMaxItems; + if (bulkMaxItems === undefined) throw new Error("bulkMaxItems missing from original config"); + const next = bulkMaxItems + 7; const patch = await request(app) .patch("/api/v1/config") .send({ bulkMaxItems: next }); @@ -78,7 +80,9 @@ describe("config GET/PATCH", () => { }); it("PATCH rateLimitPerWindow persists on the next GET", async () => { - const next = original.rateLimitPerWindow + 13; + const rateLimitPerWindow = original.rateLimitPerWindow; + if (rateLimitPerWindow === undefined) throw new Error("rateLimitPerWindow missing from original config"); + const next = rateLimitPerWindow + 13; const patch = await request(app) .patch("/api/v1/config") .send({ rateLimitPerWindow: next }); @@ -90,7 +94,7 @@ describe("config GET/PATCH", () => { }); it("PATCH rateLimitWindowMs persists on the next GET", async () => { - const next = original.rateLimitWindowMs + 5_000; + const next = (original.rateLimitWindowMs ?? 0) + 5_000; const patch = await request(app) .patch("/api/v1/config") .send({ rateLimitWindowMs: next }); @@ -147,10 +151,10 @@ describe("config GET/PATCH", () => { expect(patch.status).toBe(200); expect(patch.body.config).toEqual( expect.objectContaining({ - rateLimitPerWindow: original.rateLimitPerWindow, - rateLimitWindowMs: original.rateLimitWindowMs, - bulkMaxItems: original.bulkMaxItems, - eventLogCap: original.eventLogCap, + rateLimitPerWindow: original.rateLimitPerWindow ?? 0, + rateLimitWindowMs: original.rateLimitWindowMs ?? 0, + bulkMaxItems: original.bulkMaxItems ?? 0, + eventLogCap: original.eventLogCap ?? 0, }), ); }); @@ -385,8 +389,8 @@ describe("config GET/PATCH", () => { expect(res.status).toBe(200); expect(eventLog.length).toBe(5); // oldest-first eviction: remaining entries are the 5 newest - expect(eventLog[0].payload).toEqual({ i: 15 }); - expect(eventLog[4].payload).toEqual({ i: 19 }); + expect(eventLog.at(0)?.payload).toEqual({ i: 15 }); + expect(eventLog.at(4)?.payload).toEqual({ i: 19 }); }); it("GET /api/v1/events limit clamp respects configured eventLogCap", async () => { @@ -421,7 +425,9 @@ describe("config GET/PATCH", () => { expect(res.status).toBe(200); expect(eventLog.length).toBe(1); // Only the newest entry survives - expect(eventLog[0].payload).toEqual({ i: 4 }); + const firstEntry = eventLog.at(0); + expect(firstEntry).toBeDefined(); + expect(firstEntry?.payload).toEqual({ i: 4 }); }); it("PATCH eventLogCap does not trim when buffer is already within new cap", async () => { @@ -453,7 +459,7 @@ describe("config GET/PATCH", () => { } expect(eventLog.length).toBe(5); // No trim needed; buffer is at cap - expect(eventLog[0].payload).toEqual({ i: 0 }); + expect(eventLog.at(0)?.payload).toEqual({ i: 0 }); }); it("PATCH eventLogCap accepts EVENT_LOG_CAP_MAX (boundary)", async () => { diff --git a/src/__tests__/etag.test.ts b/src/__tests__/etag.test.ts index 069abbc..59ef937 100644 --- a/src/__tests__/etag.test.ts +++ b/src/__tests__/etag.test.ts @@ -28,7 +28,7 @@ describe("ETag / conditional GET on /api/v1/pairs", () => { const second = await request(app) .get("/api/v1/pairs") - .set("If-None-Match", etag); + .set("If-None-Match", etag ?? ""); expect(second.status).toBe(304); expect(second.text).toBe(""); }); @@ -36,6 +36,7 @@ describe("ETag / conditional GET on /api/v1/pairs", () => { it("mutating the set changes the ETag so a stale If-None-Match yields 200", async () => { const before = await request(app).get("/api/v1/pairs"); const staleEtag = before.headers.etag; + const staleEtagStr = typeof staleEtag === "string" ? staleEtag : ""; // Mutate the registry with a fresh, unique pair. const reg = await request(app) @@ -45,7 +46,7 @@ describe("ETag / conditional GET on /api/v1/pairs", () => { const after = await request(app) .get("/api/v1/pairs") - .set("If-None-Match", staleEtag); + .set("If-None-Match", staleEtagStr); expect(after.status).toBe(200); expect(after.headers.etag).toBeDefined(); expect(after.headers.etag).not.toBe(staleEtag); @@ -107,7 +108,7 @@ describe("HEAD /api/v1/pairs — ETag without body", () => { const res = await request(app) .head("/api/v1/pairs") - .set("If-None-Match", etag); + .set("If-None-Match", etag ?? ""); expect(res.status).toBe(304); // HEAD responses carry no body — supertest returns undefined for res.text expect(res.body).toEqual({}); diff --git a/src/__tests__/events.test.ts b/src/__tests__/events.test.ts index 0badb8a..46092b8 100644 --- a/src/__tests__/events.test.ts +++ b/src/__tests__/events.test.ts @@ -211,14 +211,14 @@ describe("GET /api/v1/events — filtering, limit, and capacity", () => { }); } expect(eventLog.length).toBe(EVENT_LOG_CAP); - expect(eventLog[0].id).toBe(sentinel); + expect(eventLog.at(0)?.id).toBe(sentinel); // Push one more via recordEvent — should evict the sentinel recordEvent("pair.unregistered", { source: "X", destination: "Y" }); expect(eventLog.length).toBe(EVENT_LOG_CAP); - expect(eventLog[0].id).not.toBe(sentinel); - expect(eventLog[eventLog.length - 1].type).toBe("pair.unregistered"); + expect(eventLog.at(0)?.id).not.toBe(sentinel); + expect(eventLog.at(eventLog.length - 1)?.type).toBe("pair.unregistered"); }); it("does not evict entries when log is below EVENT_LOG_CAP", () => { @@ -227,8 +227,8 @@ describe("GET /api/v1/events — filtering, limit, and capacity", () => { recordEvent("pair.registered", { source: "C", destination: "D" }); expect(eventLog.length).toBe(2); // Both entries still present - expect(eventLog[0].payload.source).toBe("A"); - expect(eventLog[1].payload.source).toBe("C"); + expect(eventLog.at(0)?.payload.source).toBe("A"); + expect(eventLog.at(1)?.payload.source).toBe("C"); }); // ─── security: no sensitive payload fields ─────────────────────────────── diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 8ebad33..c8802c1 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -920,7 +920,7 @@ describe("StableRoute Backend", () => { expect(Number(match1![1])).toBe(config.rateLimitPerWindow); // Change the config and verify the gauge updates - const previous = config.rateLimitPerWindow; + const previous = config.rateLimitPerWindow ?? 60; config.rateLimitPerWindow = 120; const res2 = await request(app).get("/api/v1/metrics"); @@ -2063,10 +2063,11 @@ describe("StableRoute Backend", () => { const first = await request(app).get("/api/v1/pairs"); const etag = first.headers["etag"]; expect(etag).toBeTruthy(); + const etagStr = typeof etag === "string" ? etag : ""; const second = await request(app) .get("/api/v1/pairs") - .set("If-None-Match", etag); + .set("If-None-Match", etagStr); expect(second.status).toBe(304); }); }); @@ -2618,7 +2619,7 @@ describe("StableRoute Backend", () => { const res304 = await request(app) .get("/api/v1/pairs") .query({ limit: 1 }) - .set("If-None-Match", etag); + .set("If-None-Match", etag ?? ""); expect(res304.status).toBe(304); }); }); diff --git a/src/__tests__/pairsBulk.test.ts b/src/__tests__/pairsBulk.test.ts index 155ad5c..a05308d 100644 --- a/src/__tests__/pairsBulk.test.ts +++ b/src/__tests__/pairsBulk.test.ts @@ -425,8 +425,8 @@ describe("POST /api/v1/pairs/bulk", () => { const newEvents = eventLog.slice(eventsBefore); expect(newEvents).toHaveLength(1); - expect(newEvents[0].type).toBe("pair.refreshed"); - expect(newEvents[0].payload).toMatchObject({ + expect(newEvents.at(0)?.type).toBe("pair.refreshed"); + expect(newEvents.at(0)?.payload).toMatchObject({ source: "USDC", destination: "EURC", }); @@ -449,13 +449,13 @@ describe("POST /api/v1/pairs/bulk", () => { const newEvents = eventLog.slice(eventsBefore); expect(newEvents).toHaveLength(2); - expect(newEvents[0].type).toBe("pair.registered"); - expect(newEvents[0].payload).toMatchObject({ + expect(newEvents.at(0)?.type).toBe("pair.registered"); + expect(newEvents.at(0)?.payload).toMatchObject({ source: "USDC", destination: "EURC", }); - expect(newEvents[1].type).toBe("pair.registered"); - expect(newEvents[1].payload).toMatchObject({ + expect(newEvents.at(1)?.type).toBe("pair.registered"); + expect(newEvents.at(1)?.payload).toMatchObject({ source: "XLM", destination: "USDC", }); diff --git a/src/__tests__/pairsEtag.test.ts b/src/__tests__/pairsEtag.test.ts index 5a71d63..4635c53 100644 --- a/src/__tests__/pairsEtag.test.ts +++ b/src/__tests__/pairsEtag.test.ts @@ -59,7 +59,7 @@ describe("GET /api/v1/pairs — ETag and 304 conditional request coverage", () = const second = await request(app) .get("/api/v1/pairs") - .set("If-None-Match", etag); + .set("If-None-Match", etag ?? ""); expect(second.status).toBe(304); }); @@ -69,7 +69,7 @@ describe("GET /api/v1/pairs — ETag and 304 conditional request coverage", () = const second = await request(app) .get("/api/v1/pairs") - .set("If-None-Match", etag); + .set("If-None-Match", etag ?? ""); expect(second.status).toBe(304); expect(second.text).toBe(""); }); @@ -80,7 +80,7 @@ describe("GET /api/v1/pairs — ETag and 304 conditional request coverage", () = const second = await request(app) .get("/api/v1/pairs") - .set("If-None-Match", etag); + .set("If-None-Match", etag ?? ""); expect(second.status).toBe(304); expect(Object.keys(second.body).length).toBe(0); }); @@ -121,7 +121,7 @@ describe("GET /api/v1/pairs — ETag and 304 conditional request coverage", () = for (let i = 0; i < 3; i++) { const cached = await request(app) .get("/api/v1/pairs") - .set("If-None-Match", cachedEtag); + .set("If-None-Match", cachedEtag ?? ""); expect(cached.status).toBe(304); } }); @@ -155,7 +155,7 @@ describe("GET /api/v1/pairs — ETag and 304 conditional request coverage", () = const after = await request(app) .get("/api/v1/pairs") - .set("If-None-Match", staleEtag); + .set("If-None-Match", staleEtag ?? ""); expect(after.status).toBe(200); expect(after.headers.etag).not.toBe(staleEtag); @@ -180,7 +180,7 @@ describe("GET /api/v1/pairs — ETag and 304 conditional request coverage", () = const res = await request(app) .get("/api/v1/pairs") - .set("If-None-Match", oldEtag); + .set("If-None-Match", oldEtag ?? ""); // The status is either 304 (if ETag coincidentally matches) or 200 with a new ETag. if (res.status === 200) { diff --git a/src/__tests__/persistence.test.ts b/src/__tests__/persistence.test.ts index 6d04d46..5ec0ecb 100644 --- a/src/__tests__/persistence.test.ts +++ b/src/__tests__/persistence.test.ts @@ -504,14 +504,16 @@ describe("Persistence Layer", () => { const adapter = new JsonFileStoreAdapter(TEST_SNAP_PATH); const loaded = adapter.load(); expect(loaded).not.toBeNull(); + const pm0 = loaded?.pairMeta.at(0); + expect(pm0).toBeDefined(); // Version should be bumped - expect(loaded!.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); + expect(loaded?.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); // Backfilled fields - expect(loaded!.pairMeta[0][1].enabled).toBe(true); - expect(loaded!.pairMeta[0][1].rate).toBe("1.0"); + expect(pm0?.[1].enabled).toBe(true); + expect(pm0?.[1].rate).toBe("1.0"); // Original data preserved - expect(loaded!.pairMeta[0][1].feeBps).toBe(10); - expect(loaded!.pairRegistry).toContain("USDC::EURC"); + expect(pm0?.[1].feeBps).toBe(10); + expect(loaded?.pairRegistry).toContain("USDC::EURC"); }); it("v0 migration preserves existing enabled and rate when present", () => { @@ -539,8 +541,10 @@ describe("Persistence Layer", () => { const adapter = new JsonFileStoreAdapter(TEST_SNAP_PATH); const loaded = adapter.load(); expect(loaded).not.toBeNull(); - expect(loaded!.pairMeta[0][1].enabled).toBe(false); - expect(loaded!.pairMeta[0][1].rate).toBe("0.5"); + const pm0 = loaded?.pairMeta.at(0); + expect(pm0).toBeDefined(); + expect(pm0?.[1].enabled).toBe(false); + expect(pm0?.[1].rate).toBe("0.5"); }); it("v0 migration with partial pairMeta fields backfills only missing", () => { @@ -578,13 +582,17 @@ describe("Persistence Layer", () => { const loaded = adapter.load(); expect(loaded).not.toBeNull(); - const meta0 = loaded!.pairMeta[0][1]; - expect(meta0.enabled).toBe(false); - expect(meta0.rate).toBe("1.0"); // backfilled - - const meta1 = loaded!.pairMeta[1][1]; - expect(meta1.enabled).toBe(true); // backfilled - expect(meta1.rate).toBe("60000"); // preserved + const metaEntry0 = loaded?.pairMeta.at(0); + expect(metaEntry0).toBeDefined(); + const meta0 = metaEntry0?.[1]; + expect(meta0?.enabled).toBe(false); + expect(meta0?.rate).toBe("1.0"); // backfilled + + const metaEntry1 = loaded?.pairMeta.at(1); + expect(metaEntry1).toBeDefined(); + const meta1 = metaEntry1?.[1]; + expect(meta1?.enabled).toBe(true); // backfilled + expect(meta1?.rate).toBe("60000"); // preserved }); it("v0 snapshot with no pairs migrates cleanly", () => { diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 154111c..161fd7c 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -173,7 +173,7 @@ describe("handleShutdown — clean drain", () => { handleShutdown(server, "SIGINT", deps); - expect(timers[0].ms).toBe(3_000); + expect(timers.at(0)?.ms).toBe(3_000); }); }); @@ -206,7 +206,9 @@ describe("handleShutdown — forced drain timeout", () => { // Timer armed but server.close never resolved — simulate timeout firing. expect(timers).toHaveLength(1); - timers[0].fn(); + const firstTimer = timers.at(0); + expect(firstTimer).toBeDefined(); + firstTimer?.fn(); expect(exitCodes).toEqual([1]); }); @@ -249,7 +251,7 @@ describe("handleShutdown — adapter flush", () => { // timers[0] is the drain safety timer; timers[1] is the flush timeout. expect(timers.length).toBeGreaterThanOrEqual(2); - timers[1].fn(); + timers.at(1)?.fn(); await new Promise((r) => setImmediate(r)); diff --git a/src/__tests__/store.adapter.test.ts b/src/__tests__/store.adapter.test.ts index 7e34861..24ac690 100644 --- a/src/__tests__/store.adapter.test.ts +++ b/src/__tests__/store.adapter.test.ts @@ -277,7 +277,7 @@ describe("JsonFileAdapter", () => { expect(b.keysGet("srk_abc")).toEqual(sampleKey()); expect(b.webhooksGet("wh_1")).toEqual(sampleWebhook()); expect(b.eventsGet()).toHaveLength(1); - expect(b.eventsGet()[0].type).toBe("pair.registered"); + expect(b.eventsGet().at(0)?.type).toBe("pair.registered"); }); it("starts with empty state when file does not exist", () => { diff --git a/src/__tests__/stores.test.ts b/src/__tests__/stores.test.ts index 99f8247..2667ace 100644 --- a/src/__tests__/stores.test.ts +++ b/src/__tests__/stores.test.ts @@ -7,6 +7,7 @@ import { rateBuckets, config, paused, + readOnly, pairKey, defaultMeta, recordEvent, @@ -15,7 +16,25 @@ import { effectiveEventLogCap, EVENT_LOG_CAP, EVENT_LOG_CAP_MAX, + RATE_BUCKETS_MAX_IPS, + HEALTH_PROBE_KEY, + KNOWN_EVENT_TYPES, + apiKeyPrefix, + generateApiKeySalt, + hashApiKeySecret, + verifyApiKeySecret, + isPaused, + isReadOnly, + setPaused, + setReadOnly, + isHydrating, + setHydrating, + getSnapshot, + hydrateFromSnapshot, + triggerSnapshot, type EventType, + type ApiKeyRecord, + type PairMeta, } from "../stores"; describe("stores module", () => { @@ -54,11 +73,11 @@ describe("stores module", () => { it("appends an event with id, ts, type, and payload", () => { recordEvent("pair.registered", { foo: "bar" }); expect(eventLog.length).toBe(1); - const evt = eventLog[0]; - expect(evt.type).toBe("pair.registered"); - expect(evt.payload).toEqual({ foo: "bar" }); - expect(typeof evt.id).toBe("string"); - expect(typeof evt.ts).toBe("number"); + const evt = eventLog.at(0); + expect(evt?.type).toBe("pair.registered"); + expect(evt?.payload).toEqual({ foo: "bar" }); + expect(typeof evt?.id).toBe("string"); + expect(typeof evt?.ts).toBe("number"); }); it("evicts oldest entry beyond EVENT_LOG_CAP", () => { @@ -73,8 +92,8 @@ describe("stores module", () => { } recordEvent("pair.unregistered", { n: 1 }); expect(eventLog.length).toBe(EVENT_LOG_CAP); - expect(eventLog[0].type).toBe("pair.refreshed"); // oldest of original fill - expect(eventLog[eventLog.length - 1].type).toBe("pair.unregistered"); + expect(eventLog.at(0)?.type).toBe("pair.refreshed"); // oldest of original fill + expect(eventLog.at(eventLog.length - 1)?.type).toBe("pair.unregistered"); }); it("evicts based on config.eventLogCap when configured to a lower value", () => { @@ -85,7 +104,7 @@ describe("stores module", () => { expect(eventLog.length).toBe(5); recordEvent("pair.unregistered" as EventType, { n: 1 }); expect(eventLog.length).toBe(5); - expect(eventLog[eventLog.length - 1].type).toBe("pair.unregistered"); + expect(eventLog.at(eventLog.length - 1)?.type).toBe("pair.unregistered"); }); it("evicts with a cap of 1 (edge case)", () => { @@ -94,7 +113,9 @@ describe("stores module", () => { expect(eventLog.length).toBe(1); recordEvent("pair.unregistered" as EventType, {}); expect(eventLog.length).toBe(1); - expect(eventLog[0].type).toBe("pair.unregistered"); + const firstEntry = eventLog.at(0); + expect(firstEntry).toBeDefined(); + expect(firstEntry?.type).toBe("pair.unregistered"); }); it("falls back to EVENT_LOG_CAP if config.eventLogCap is zero or invalid", () => { @@ -139,8 +160,8 @@ describe("stores module", () => { trimEventLog(5); expect(eventLog.length).toBe(5); // oldest removed; remaining are the 5 newest - expect(eventLog[0].payload).toEqual({ i: 5 }); - expect(eventLog[4].payload).toEqual({ i: 9 }); + expect(eventLog.at(0)?.payload).toEqual({ i: 5 }); + expect(eventLog.at(4)?.payload).toEqual({ i: 9 }); }); it("is a no-op when log is already within the cap", () => { @@ -240,4 +261,396 @@ describe("stores module", () => { expect("injected" in config).toBe(false); }); }); + + describe("config indexed access (noUncheckedIndexedAccess)", () => { + it("reads known config keys safely", () => { + const maxItems = config.bulkMaxItems; + expect(maxItems).toBe(100); + }); + + it("returns undefined for unset config keys", () => { + const val = (config as Record)["__nonexistent__"]; + expect(val).toBeUndefined(); + }); + }); + + describe("pairMeta.get — undefined-safe access", () => { + it("returns undefined for an unregistered pair", () => { + const meta = pairMeta.get("NONEXISTENT::PAIR"); + expect(meta).toBeUndefined(); + }); + + it("supports optional chaining on registered pair", () => { + pairMeta.set("A::B", defaultMeta()); + const feeBps = pairMeta.get("A::B")?.feeBps; + expect(feeBps).toBe(0); + }); + + it("returns undefined via optional chaining for missing pair", () => { + const feeBps = pairMeta.get("MISSING::PAIR")?.feeBps; + expect(feeBps).toBeUndefined(); + }); + }); + + describe("apiKeyStore.get — undefined-safe access", () => { + it("returns undefined for a missing key", () => { + const rec = apiKeyStore.get("srk_nonexistent"); + expect(rec).toBeUndefined(); + }); + + it("returns full record after insertion", () => { + const record: ApiKeyRecord = { + label: "test", + createdAt: 100, + salt: "abc", + hash: "def", + }; + apiKeyStore.set("srk_test", record); + const retrieved = apiKeyStore.get("srk_test"); + expect(retrieved?.label).toBe("test"); + expect(retrieved?.salt).toBe("abc"); + expect(retrieved?.hash).toBe("def"); + }); + + it("accesses optional fields via optional chaining", () => { + const record: ApiKeyRecord = { + label: "test", + createdAt: 100, + salt: "abc", + hash: "def", + scopes: ["admin"], + expiresAt: 999, + }; + apiKeyStore.set("srk_scoped", record); + const retrieved = apiKeyStore.get("srk_scoped"); + expect(retrieved?.scopes).toEqual(["admin"]); + expect(retrieved?.expiresAt).toBe(999); + expect(retrieved?.lastUsedAt).toBeUndefined(); + }); + }); + + describe("rateBuckets.get — undefined-safe access", () => { + it("returns undefined for never-seen IP", () => { + const bucket = rateBuckets.get("10.0.0.1"); + expect(bucket).toBeUndefined(); + }); + + it("returns timestamp array for tracked IP", () => { + rateBuckets.set("10.0.0.2", [100, 200]); + expect(rateBuckets.get("10.0.0.2")).toEqual([100, 200]); + }); + }); + + describe("eventLog indexed access", () => { + it("at() returns undefined for out-of-range index", () => { + const entry = eventLog.at(9999); + expect(entry).toBeUndefined(); + }); + + it("at() returns the entry at a valid index", () => { + recordEvent("pair.registered", { idx: 1 }); + const entry = eventLog.at(0); + expect(entry?.type).toBe("pair.registered"); + expect(entry?.payload).toEqual({ idx: 1 }); + }); + }); + + describe("pairRegistry iteration with split", () => { + it("handles pair keys with exactly two parts", () => { + pairRegistry.add("USDC::EURC"); + pairRegistry.add("XLM::USDT"); + const assets = new Set(); + for (const k of pairRegistry) { + const parts = k.split("::"); + const source = parts[0]; + const destination = parts[1]; + if (source !== undefined && destination !== undefined) { + assets.add(source); + assets.add(destination); + } + } + expect(assets.has("USDC")).toBe(true); + expect(assets.has("EURC")).toBe(true); + expect(assets.has("XLM")).toBe(true); + expect(assets.has("USDT")).toBe(true); + }); + + it("skips malformed pair keys gracefully", () => { + pairRegistry.add("NO_SEPARATOR"); + const assets = new Set(); + for (const k of pairRegistry) { + const parts = k.split("::"); + const source = parts[0]; + const destination = parts[1]; + if (source !== undefined && destination !== undefined) { + assets.add(source); + assets.add(destination); + } + } + expect(assets.size).toBe(0); + }); + }); + + describe("ApiKeyRecord optional property handling (exactOptionalPropertyTypes)", () => { + it("omits optional scopes by default", () => { + const record: ApiKeyRecord = { + label: "no-scopes", + createdAt: 1, + salt: "s", + hash: "h", + }; + expect(record.scopes).toBeUndefined(); + }); + + it("sets scopes when provided", () => { + const record: ApiKeyRecord = { + label: "with-scopes", + createdAt: 2, + scopes: ["read", "write"], + salt: "s", + hash: "h", + }; + expect(record.scopes).toEqual(["read", "write"]); + }); + + it("marks rotatedAt as undefined before rotation", () => { + const record: ApiKeyRecord = { + label: "never-rotated", + createdAt: 3, + salt: "s", + hash: "h", + }; + expect(record.rotatedAt).toBeUndefined(); + expect(record.graceExpiresAt).toBeUndefined(); + }); + + it("omits expiresAt when key never expires", () => { + const record: ApiKeyRecord = { + label: "no-expiry", + createdAt: 4, + salt: "s", + hash: "h", + }; + expect(record.expiresAt).toBeUndefined(); + }); + + it("omits lastUsedAt before first use", () => { + const record: ApiKeyRecord = { + label: "fresh-key", + createdAt: 5, + salt: "s", + hash: "h", + }; + expect(record.lastUsedAt).toBeUndefined(); + }); + }); + + describe("apiKeyPrefix", () => { + it("returns first 8 characters of the raw key", () => { + expect(apiKeyPrefix("abcdefghijk")).toBe("abcdefgh"); + }); + + it("returns full key when shorter than prefix length", () => { + expect(apiKeyPrefix("short")).toBe("short"); + }); + }); + + describe("generateApiKeySalt", () => { + it("returns a 32-character hex string", () => { + const salt = generateApiKeySalt(); + expect(salt).toMatch(/^[0-9a-f]{32}$/); + }); + + it("produces unique values across calls", () => { + const a = generateApiKeySalt(); + const b = generateApiKeySalt(); + expect(a).not.toBe(b); + }); + }); + + describe("hashApiKeySecret", () => { + it("returns deterministic hex output for same inputs", () => { + const a = hashApiKeySecret("test-key", "salt1234"); + const b = hashApiKeySecret("test-key", "salt1234"); + expect(a).toBe(b); + }); + + it("returns different output for different keys", () => { + const a = hashApiKeySecret("key-a", "salt1234"); + const b = hashApiKeySecret("key-b", "salt1234"); + expect(a).not.toBe(b); + }); + }); + + describe("verifyApiKeySecret", () => { + it("returns true for matching key and record", () => { + const salt = generateApiKeySalt(); + const rawKey = "my-secret-api-key"; + const hash = hashApiKeySecret(rawKey, salt); + expect(verifyApiKeySecret(rawKey, { salt, hash })).toBe(true); + }); + + it("returns false for wrong key", () => { + const salt = generateApiKeySalt(); + const hash = hashApiKeySecret("real-key", salt); + expect(verifyApiKeySecret("wrong-key", { salt, hash })).toBe(false); + }); + }); + + describe("paused / readOnly accessors", () => { + it("isPaused returns current paused state", () => { + expect(isPaused()).toBe(false); + setPaused(true); + expect(isPaused()).toBe(true); + setPaused(false); + expect(isPaused()).toBe(false); + }); + + it("isReadOnly returns current readOnly state", () => { + expect(isReadOnly()).toBe(false); + setReadOnly(true); + expect(isReadOnly()).toBe(true); + setReadOnly(false); + expect(isReadOnly()).toBe(false); + }); + + it("readOnly resets to false after resetStores", () => { + setReadOnly(true); + resetStores(); + expect(isReadOnly()).toBe(false); + }); + }); + + describe("isHydrating / setHydrating", () => { + it("starts as false", () => { + expect(isHydrating).toBe(false); + }); + + it("setHydrating updates the flag", () => { + setHydrating(true); + expect(isHydrating).toBe(true); + setHydrating(false); + expect(isHydrating).toBe(false); + }); + }); + + describe("getSnapshot", () => { + it("returns all stores in a serializable shape", () => { + pairRegistry.add("X::Y"); + pairMeta.set("X::Y", defaultMeta()); + const snap = getSnapshot(); + expect(snap).toHaveProperty("schemaVersion"); + expect(snap.pairRegistry).toContain("X::Y"); + expect(snap.pairMeta).toEqual([["X::Y", defaultMeta()]]); + }); + + it("clones the event log", () => { + recordEvent("pair.registered", {}); + const snap = getSnapshot(); + expect(snap.eventLog).toHaveLength(1); + snap.eventLog.length = 0; + expect(eventLog.length).toBe(1); + }); + }); + + describe("hydrateFromSnapshot", () => { + it("restores pairRegistry and pairMeta from snapshot", () => { + const snap = { + schemaVersion: 1, + pairRegistry: ["A::B", "C::D"], + pairMeta: [["A::B", defaultMeta()] as const], + apiKeyStore: [] as [], + webhookStore: [] as [], + eventLog: [] as [], + }; + hydrateFromSnapshot(snap); + expect(pairRegistry.has("A::B")).toBe(true); + expect(pairRegistry.has("C::D")).toBe(true); + expect(pairMeta.get("A::B")).toEqual(defaultMeta()); + }); + + it("skips invalid snapshot entries gracefully", () => { + hydrateFromSnapshot({ + pairRegistry: "not-an-array", + pairMeta: null, + }); + expect(pairRegistry.size).toBe(0); + expect(pairMeta.size).toBe(0); + }); + + it("discards API key records missing salt or hash", () => { + apiKeyStore.set("srk_legacy", { + label: "legacy", + createdAt: 1, + salt: "", + hash: "", + }); + const snap = getSnapshot(); + snap.apiKeyStore.push(["srk_bad", { label: "bad", createdAt: 2 } as unknown as ApiKeyRecord]); + hydrateFromSnapshot(snap); + expect(apiKeyStore.has("srk_legacy")).toBe(true); + expect(apiKeyStore.has("srk_bad")).toBe(false); + }); + + it("hydrates apiKeyStore and webhookStore", () => { + const apiRecord: ApiKeyRecord = { + label: "hydrated-key", + createdAt: 10, + salt: "s", + hash: "h", + scopes: ["read"], + }; + const snap = { + schemaVersion: 1, + pairRegistry: [], + pairMeta: [], + apiKeyStore: [["srk_hydrated", apiRecord] as const], + webhookStore: [["wh_hydrated", { url: "https://hook.example.com", events: ["pair.registered"], createdAt: 20 }] as const], + eventLog: [], + }; + hydrateFromSnapshot(snap); + expect(apiKeyStore.get("srk_hydrated")?.label).toBe("hydrated-key"); + expect(apiKeyStore.get("srk_hydrated")?.scopes).toEqual(["read"]); + expect(webhookStore.get("wh_hydrated")?.url).toBe("https://hook.example.com"); + }); + + it("hydrates eventLog", () => { + const snap = { + schemaVersion: 1, + pairRegistry: [], + pairMeta: [], + apiKeyStore: [], + webhookStore: [], + eventLog: [{ id: "evt1", ts: 1, type: "pair.registered" as EventType, payload: {} }], + }; + hydrateFromSnapshot(snap); + expect(eventLog).toHaveLength(1); + expect(eventLog.at(0)?.id).toBe("evt1"); + }); + }); + + describe("KNOWN_EVENT_TYPES", () => { + it("is a frozen tuple of strings", () => { + expect(Array.isArray(KNOWN_EVENT_TYPES)).toBe(true); + expect(KNOWN_EVENT_TYPES.length).toBeGreaterThan(0); + }); + }); + + describe("constants", () => { + it("EVENT_LOG_CAP is 10_000", () => { + expect(EVENT_LOG_CAP).toBe(10_000); + }); + + it("EVENT_LOG_CAP_MAX is 1_000_000", () => { + expect(EVENT_LOG_CAP_MAX).toBe(1_000_000); + }); + + it("RATE_BUCKETS_MAX_IPS is 10_000", () => { + expect(RATE_BUCKETS_MAX_IPS).toBe(10_000); + }); + + it("HEALTH_PROBE_KEY starts with NUL", () => { + expect(HEALTH_PROBE_KEY).toMatch(/^\x00/); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index d248d19..0fcac19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,9 @@ const WEBHOOK_RESERVED_PREFIXES = ["internal.", "system.", "admin."]; /** Absolute ceiling for bulk item counts — operators cannot raise beyond this. */ const BULK_ABSOLUTE_MAX = 10_000; +/** Default cap for bulk endpoints when the config value is not set. */ +const DEFAULT_BULK_MAX_ITEMS = 100; + const app = express(); // --- Persistence Hydration on startup --- @@ -746,7 +749,7 @@ app.use((req: Request, res: Response, next: NextFunction) => { // Split on comma to get individual media-range tokens; strip quality params. const types = accept .split(",") - .map((t) => t.split(";")[0].trim().toLowerCase()); + .map((t) => (t.split(";").at(0) ?? "").trim().toLowerCase()); const acceptable = types.some( (t) => t === "*/*" || t === "application/json" || t === "application/*", @@ -1108,7 +1111,7 @@ export function requireAdmin( } const auth = req.header("authorization") ?? ""; const match = /^Bearer\s+(\S+)$/i.exec(auth); - const supplied = match ? match[1] : ""; + const supplied = match ? match.at(1) ?? "" : ""; if (!timingSafeCompare(supplied, adminToken)) { sendError(res, req, 401, "unauthorized", "valid admin token required"); return; @@ -1227,7 +1230,7 @@ const mintApiKey = (): { }; app.delete("/api/v1/api-keys/:prefix", (req: Request, res: Response) => { - const { prefix } = req.params; + const prefix = req.params.prefix ?? ""; if (!apiKeyStore.has(prefix)) { sendError(res, req, 404, "not_found", `no key with prefix ${prefix}`); return; @@ -1371,7 +1374,7 @@ const ROTATION_GRACE_MS = 60 * 60 * 1000; * @route POST /api/v1/api-keys/:prefix/rotate */ app.post("/api/v1/api-keys/:prefix/rotate", (req: Request, res: Response) => { - const { prefix } = req.params; + const prefix = req.params.prefix ?? ""; const predecessor = apiKeyStore.get(prefix); if (!predecessor) { sendError(res, req, 404, "not_found", `no key with prefix ${prefix}`); @@ -1403,7 +1406,7 @@ app.post("/api/v1/api-keys/:prefix/rotate", (req: Request, res: Response) => { }); app.delete("/api/v1/webhooks/:id", (req: Request, res: Response) => { - const { id } = req.params; + const id = req.params.id ?? ""; if (!webhookStore.has(id)) { sendError(res, req, 404, "not_found", `webhook ${id} not found`); return; @@ -1566,7 +1569,7 @@ app.post( * @route GET /api/v1/webhooks/:id */ app.get("/api/v1/webhooks/:id", (req: Request, res: Response) => { - const { id } = req.params; + const id = req.params.id ?? ""; const record = webhookStore.get(id); if (!record) { sendError(res, req, 404, "not_found", `webhook ${id} not found`); @@ -1589,7 +1592,7 @@ app.get("/api/v1/webhooks/:id", (req: Request, res: Response) => { */ app.patch("/api/v1/webhooks/:id", (req: Request, res: Response) => { if (rejectUnknownKeys(req, res, ["events"])) return; - const { id } = req.params; + const id = req.params.id ?? ""; const record = webhookStore.get(id); if (!record) { sendError(res, req, 404, "not_found", `webhook ${id} not found`); @@ -1679,8 +1682,8 @@ const normalizePairParams = ( req: Request, res: Response, ): { source: string; destination: string } | null => { - const source = normalizeAsset(req.params.source); - const destination = normalizeAsset(req.params.destination); + const source = normalizeAsset(req.params.source ?? ""); + const destination = normalizeAsset(req.params.destination ?? ""); if (source === null || destination === null) { sendError( res, @@ -1974,7 +1977,8 @@ app.patch( app.post( "/api/v1/pairs/:source/:destination/reset", (req: Request, res: Response) => { - const { source, destination } = req.params; + const source = req.params.source ?? ""; + const destination = req.params.destination ?? ""; const k = pairKey(source, destination); if (!pairRegistry.has(k)) { sendError(res, req, 404, "not_found", "pair not registered"); @@ -1991,7 +1995,8 @@ app.post( app.delete( "/api/v1/pairs/:source/:destination", (req: Request, res: Response) => { - const { source, destination } = req.params; + const source = req.params.source ?? ""; + const destination = req.params.destination ?? ""; const k = pairKey(source, destination); if (!pairRegistry.has(k)) { sendError( @@ -2011,7 +2016,8 @@ app.delete( /** Read a single registered pair. */ app.get("/api/v1/pairs/:source/:destination", (req: Request, res: Response) => { - const { source, destination } = req.params; + const source = req.params.source ?? ""; + const destination = req.params.destination ?? ""; if (!pairRegistry.has(pairKey(source, destination))) { sendError( res, @@ -2184,9 +2190,13 @@ const aggregatePairStats = (): { let pairsWithFee = 0; const assets = new Set(); for (const k of pairRegistry) { - const [source, destination] = k.split("::"); - assets.add(source); - assets.add(destination); + const parts = k.split("::"); + const source = parts[0]; + const destination = parts[1]; + if (source !== undefined && destination !== undefined) { + assets.add(source); + assets.add(destination); + } if ((pairMeta.get(k)?.feeBps ?? 0) > 0) pairsWithFee += 1; } return { pairsWithFee, distinctAssets: assets.size }; @@ -2357,7 +2367,7 @@ app.post("/api/v1/pairs", idempotencyGuard, (req: Request, res: Response) => { */ app.post("/api/v1/pairs/bulk", (req: Request, res: Response) => { const { pairs } = req.body ?? {}; - const maxItems = config.bulkMaxItems; + const maxItems = config.bulkMaxItems ?? DEFAULT_BULK_MAX_ITEMS; if (!Array.isArray(pairs) || pairs.length === 0 || pairs.length > maxItems) { sendError( res, @@ -2443,7 +2453,7 @@ const parseSlippageBps = (v: unknown): number | null => { app.post("/api/v1/quote/bulk", (req: Request, res: Response) => { const { items } = req.body ?? {}; - const maxItems = config.bulkMaxItems; // driven by config.bulkMaxItems + const maxItems = config.bulkMaxItems ?? DEFAULT_BULK_MAX_ITEMS; if (!Array.isArray(items) || items.length === 0 || items.length > maxItems) { sendError( res, diff --git a/src/stores.ts b/src/stores.ts index 2306ee4..340cce5 100644 --- a/src/stores.ts +++ b/src/stores.ts @@ -88,22 +88,22 @@ export type ApiKeyRecord = { label: string; createdAt: number; /** Granted authorization scopes; empty array means read-only. Defaults to [] if omitted. */ - scopes?: string[]; + scopes?: string[] | undefined; /** * Epoch-ms timestamp at which this key was rotated and replaced by a * successor. Absent on keys that have not been rotated. */ - rotatedAt?: number; + rotatedAt?: number | undefined; /** * Absolute epoch-ms deadline after which a rotated (predecessor) key is * considered invalid. Both predecessor and successor remain valid until * this deadline, giving callers an overlap window. Absent until rotation. */ - graceExpiresAt?: number; + graceExpiresAt?: number | undefined; /** Epoch-ms when key expires; absent = never expires. */ - expiresAt?: number; + expiresAt?: number | undefined; /** Epoch-ms of last successful authentication; absent until first use. */ - lastUsedAt?: number; + lastUsedAt?: number | undefined; /** Per-key random salt (hex-encoded) used to derive {@link hash}. */ salt: string; /** Keyed hash of the raw key, derived via {@link hashApiKeySecret}. */ diff --git a/src/utils/clientIp.ts b/src/utils/clientIp.ts index 7cd3d36..b35d050 100644 --- a/src/utils/clientIp.ts +++ b/src/utils/clientIp.ts @@ -8,9 +8,14 @@ export const resolveClientIp = ( remoteAddress: string | undefined, ): string => { if (forwardedFor) { - const raw = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; - const first = raw.split(",")[0].trim(); - if (first) return first; + const raw = Array.isArray(forwardedFor) + ? forwardedFor.at(0) + : forwardedFor; + if (raw !== undefined) { + const parts = raw.split(","); + const first = parts.at(0)?.trim(); + if (first) return first; + } } return remoteAddress ?? "unknown"; }; diff --git a/tsconfig.json b/tsconfig.json index 856416b..3180171 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,10 +6,14 @@ "outDir": "./dist", "rootDir": "./src", "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node", "jest"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]