diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..654ccda --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +node_modules +dist +.git +.github +coverage +tests +docs +scripts +*.md +.env +.env.* +!.env.example +.editorconfig +.prettierrc.json +.prettierignore +.gitignore +eslint.config.js +vitest.config.ts diff --git a/.env.example b/.env.example index 7d6a248..21a8b7e 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ REDIS_URL=redis://localhost:6379 # Prefix used for Redis keys so multiple environments can share one Redis cluster SERVICE_NAME=arce +# Optional API key for authenticating protected endpoints. +# When set, all /check-limit, /consume, and /api/dashboard-data requests +# must include a matching x-api-key header. When empty, auth is disabled. +API_KEY= + # Baseline limit applied to normal traffic DEFAULT_LIMIT_PER_MINUTE=100 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba42d72..7eed1a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,17 @@ jobs: ci: runs-on: ubuntu-latest + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: - uses: actions/checkout@v4 @@ -23,4 +34,4 @@ jobs: - run: npm ci - run: npm run build - run: npm run lint - - run: npm test \ No newline at end of file + - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8a675dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.1.0] — 2026-04-29 + +### Security + +- **API key authentication** — protected endpoints (`/check-limit`, `/consume`, `/api/dashboard-data`) now require a valid `x-api-key` header when `API_KEY` is configured. Authentication is disabled when the variable is empty, preserving the local development workflow. +- **Dashboard XSS fix** — replaced all `innerHTML` usage in the dashboard with safe DOM construction (`textContent` and `createElement`). User-controlled data can no longer execute scripts in the operator's browser. +- **Input length limits** — `userId` (max 256), `ip` (max 64), and `identifier` (max 256) fields now enforce maximum length to prevent memory exhaustion via oversized Redis keys. +- **Error handler hardened** — `ZodError` returns 400 with safe validation details. All other errors return a generic 500 without leaking internal messages or stack traces. +- **Security headers** — all responses now include `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, and `Referrer-Policy`. + +### Changed + +- **Parallel I/O on hot path** — `recordOutcome` and `recordDecision` now execute via `Promise.all()` instead of sequential awaits, reducing latency by one Redis round-trip. +- **Graceful shutdown timeout** — the server now force-exits after 10 seconds if `server.close()` hangs on connection draining. +- **Redis reconnect strategy** — the Redis client now retries with exponential backoff (up to 10 attempts) instead of silently logging errors. + +### Added + +- **Comprehensive test suite** — added 50+ tests across 7 new test files: + - Abuse detector edge cases and threshold boundaries + - Schema validation for all `superRefine` rules and field constraints + - Identity utility tests (all `resolveSubject` branches, `buildFingerprint` paths) + - Hashing determinism and format tests + - Time bucketing tests + - Express middleware tests (allow, deny, error forwarding) +- **`.dockerignore`** — excludes dev-only files from the build context. +- **Docker hardening** — non-root user (`USER node`), `HEALTHCHECK` directive, `npm ci` for reproducible installs, only copies necessary runtime assets. +- **CI improvements** — added `format:check`, `typecheck`, `npm audit`, Redis service container, and Node 20 + 22 matrix testing. + +## [1.0.0] — 2026-04-29 + +Initial release. Distributed adaptive traffic control with Redis-backed rate limiting, abuse detection, and operator dashboard. diff --git a/Dockerfile b/Dockerfile index 34d5459..8f6835d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM node:24-alpine AS build WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm ci COPY tsconfig.json ./ COPY configs ./configs COPY apps ./apps @@ -11,8 +11,16 @@ RUN npm run build FROM node:24-alpine WORKDIR /app COPY package*.json ./ -RUN npm install --omit=dev +RUN npm ci --omit=dev + COPY --from=build /app/dist ./dist -COPY --from=build /app/apps ./apps +COPY --from=build /app/apps/dashboard/public ./apps/dashboard/public + EXPOSE 4000 + +USER node + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1 + CMD ["node", "dist/apps/server/main.js"] diff --git a/README.md b/README.md index 1bbe555..fa555ef 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ Additional design notes: - [docs/architecture.md](./docs/architecture.md) - [docs/design-decisions.md](./docs/design-decisions.md) +- [docs/system-diagram.md](./docs/system-diagram.md) ## Design Decisions @@ -208,6 +209,21 @@ http://localhost:4000/dashboard If you need non-default settings, use the variables documented in [.env.example](./.env.example). +## Authentication + +When `API_KEY` is set in the environment, ARCE requires all protected endpoints (`/check-limit`, `/consume`, `/api/dashboard-data`) to include a matching `x-api-key` header. + +When `API_KEY` is not set, authentication is disabled so local development remains frictionless. + +```bash +curl -X POST http://localhost:4000/consume \ + -H "Content-Type: application/json" \ + -H "x-api-key: your-secret-key" \ + -d '{"algorithm":"token_bucket","route":"/api/orders","method":"GET","ip":"203.0.113.8","scope":"ip"}' +``` + +Public endpoints (`/`, `/health`, `/dashboard`, `/static/*`) do not require authentication. + ## API Example Check a limit without consuming: @@ -251,7 +267,8 @@ Example request shape: import { ArceClient } from "./src/sdk/client"; const client = new ArceClient({ - baseUrl: "http://localhost:4000" + baseUrl: "http://localhost:4000", + headers: { "x-api-key": process.env.ARCE_API_KEY ?? "" } }); const decision = await client.consume({ @@ -297,14 +314,15 @@ app.use( ## Configuration -| Variable | Default | Purpose | -| ----------------------------- | ------------------------ | ---------------------------------------------------- | -| `PORT` | `4000` | HTTP port for the ARCE server | -| `REDIS_URL` | `redis://localhost:6379` | Redis connection string | -| `SERVICE_NAME` | `arce` | Prefix for Redis keys | -| `DEFAULT_LIMIT_PER_MINUTE` | `100` | Baseline limit for normal traffic | -| `SUSPICIOUS_LIMIT_PER_MINUTE` | `20` | Lower bound for suspicious traffic | -| `BLOCK_DURATION_SECONDS` | `300` | Temporary block duration for critical abuse patterns | +| Variable | Default | Purpose | +| ----------------------------- | ------------------------ | ---------------------------------------------------------------- | +| `PORT` | `4000` | HTTP port for the ARCE server | +| `REDIS_URL` | `redis://localhost:6379` | Redis connection string | +| `SERVICE_NAME` | `arce` | Prefix for Redis keys | +| `API_KEY` | _(empty, auth disabled)_ | API key for protecting `/check-limit`, `/consume`, and dashboard | +| `DEFAULT_LIMIT_PER_MINUTE` | `100` | Baseline limit for normal traffic | +| `SUSPICIOUS_LIMIT_PER_MINUTE` | `20` | Lower bound for suspicious traffic | +| `BLOCK_DURATION_SECONDS` | `300` | Temporary block duration for critical abuse patterns | ## Development @@ -312,11 +330,13 @@ app.use( npm run build npm run typecheck npm run lint +npm run format:check npm run test npm run smoke ``` See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution and PR expectations. +See [CHANGELOG.md](./CHANGELOG.md) for a history of changes. ## Roadmap diff --git a/apps/dashboard/public/dashboard.js b/apps/dashboard/public/dashboard.js index cee1ca6..47fcdb0 100644 --- a/apps/dashboard/public/dashboard.js +++ b/apps/dashboard/public/dashboard.js @@ -9,6 +9,26 @@ function formatTime(isoString) { }); } +function el(tag, className, children) { + const element = document.createElement(tag); + + if (className) { + element.className = className; + } + + if (typeof children === "string") { + element.textContent = children; + } else if (Array.isArray(children)) { + children.forEach(function (child) { + element.appendChild(child); + }); + } else if (children instanceof Node) { + element.appendChild(children); + } + + return element; +} + function renderTotals(data) { document.getElementById("requests-total").textContent = formatNumber( data.totals.requests @@ -26,97 +46,130 @@ function renderTotals(data) { data.totals.anomalies ); document.getElementById("last-updated").textContent = - `Updated ${new Date().toLocaleTimeString()}`; + "Updated " + new Date().toLocaleTimeString(); } function renderChart(series) { - const container = document.getElementById("traffic-chart"); - const maxRequests = Math.max(...series.map((point) => point.requests), 1); - - container.innerHTML = series - .map((point) => { - const requestHeight = Math.max( - 4, - Math.round((point.requests / maxRequests) * 170) - ); - const blockedHeight = - point.blocked > 0 - ? Math.max(3, Math.round((point.blocked / maxRequests) * 36)) - : 0; - - return ` -
-
-
-
${formatTime(point.timestamp)}
-
- `; - }) - .join(""); + var container = document.getElementById("traffic-chart"); + var maxRequests = Math.max.apply( + null, + series + .map(function (point) { + return point.requests; + }) + .concat([1]) + ); + + container.innerHTML = ""; + + series.forEach(function (point) { + var requestHeight = Math.max( + 4, + Math.round((point.requests / maxRequests) * 170) + ); + var blockedHeight = + point.blocked > 0 + ? Math.max(3, Math.round((point.blocked / maxRequests) * 36)) + : 0; + + var barBlocked = el("div", "bar-blocked"); + barBlocked.style.height = blockedHeight + "px"; + + var barFill = el("div", "bar-fill"); + barFill.style.height = requestHeight + "px"; + + var barLabel = el("div", "bar-label", formatTime(point.timestamp)); + + var bar = el("div", "bar", [barBlocked, barFill, barLabel]); + bar.title = + formatTime(point.timestamp) + + " | requests=" + + point.requests + + ", blocked=" + + point.blocked + + ", anomalies=" + + point.anomalies; + + container.appendChild(bar); + }); } function renderBlocks(blocks) { - const container = document.getElementById("active-blocks"); + var container = document.getElementById("active-blocks"); + container.innerHTML = ""; if (!blocks.length) { - container.innerHTML = - '
No active blocks right now.
'; + container.appendChild(el("div", "empty", "No active blocks right now.")); return; } - container.innerHTML = blocks - .map( - (block) => ` -
- ${block.subject} -

${block.reason}

-
- TTL ${(block.ttlMs / 1000).toFixed(0)}s - Expires ${formatTime(block.expiresAt)} -
-
- ` - ) - .join(""); + blocks.forEach(function (block) { + var subjectEl = el("strong", null, block.subject); + var reasonEl = el("p", null, block.reason); + var ttlBadge = el( + "span", + "badge", + "TTL " + (block.ttlMs / 1000).toFixed(0) + "s" + ); + var expiresBadge = el( + "span", + "badge", + "Expires " + formatTime(block.expiresAt) + ); + var badgeRow = el("div", "badge-row", [ttlBadge, expiresBadge]); + var article = el("article", "list-item", [subjectEl, reasonEl, badgeRow]); + + container.appendChild(article); + }); } function renderAnomalies(events) { - const container = document.getElementById("recent-anomalies"); + var container = document.getElementById("recent-anomalies"); + container.innerHTML = ""; if (!events.length) { - container.innerHTML = '
No flagged anomalies yet.
'; + container.appendChild(el("div", "empty", "No flagged anomalies yet.")); return; } - container.innerHTML = events - .map((event) => { - const badges = event.anomalies - .map((anomaly) => `${anomaly.code}`) - .join(""); - - return ` -
- ${event.subject} -

${event.reasons.join(" ")}

-
- Tier ${event.tier} - Risk ${event.riskScore} - ${formatTime(event.timestamp)} - ${badges} -
-
- `; - }) - .join(""); + events.forEach(function (event) { + var subjectEl = el("strong", null, event.subject); + var reasonEl = el("p", null, event.reasons.join(" ")); + var tierBadge = el("span", "badge", "Tier " + event.tier); + var riskBadge = el("span", "badge", "Risk " + event.riskScore); + var timeBadge = el("span", "badge", formatTime(event.timestamp)); + + var badges = [tierBadge, riskBadge, timeBadge]; + + event.anomalies.forEach(function (anomaly) { + badges.push(el("span", "badge", anomaly.code)); + }); + + var badgeRow = el("div", "badge-row", badges); + var article = el("article", "list-item", [subjectEl, reasonEl, badgeRow]); + + container.appendChild(article); + }); } async function refresh() { - const response = await fetch("/api/dashboard-data"); - const data = await response.json(); - renderTotals(data); - renderChart(data.recentSeries); - renderBlocks(data.activeBlocks); - renderAnomalies(data.recentAnomalies); + try { + var response = await fetch("/api/dashboard-data"); + + if (!response.ok) { + document.getElementById("last-updated").textContent = + "Error: " + response.status; + return; + } + + var data = await response.json(); + renderTotals(data); + renderChart(data.recentSeries); + renderBlocks(data.activeBlocks); + renderAnomalies(data.recentAnomalies); + } catch { + document.getElementById("last-updated").textContent = "Connection error"; + } } refresh(); diff --git a/apps/server/main.ts b/apps/server/main.ts index bd93e37..6f74403 100644 --- a/apps/server/main.ts +++ b/apps/server/main.ts @@ -8,6 +8,8 @@ import { MetricsStore } from "../../src/store/metrics-store"; import { createRedisConnection } from "../../src/store/redis"; import { RedisRateLimiterStore } from "../../src/store/rate-limiter-store"; +const SHUTDOWN_TIMEOUT_MS = 10_000; + async function bootstrap(): Promise { const redis = await createRedisConnection(); const prefix = runtimeConfig.serviceName; @@ -36,8 +38,18 @@ async function bootstrap(): Promise { }); const shutdown = (): void => { + console.log("Shutting down gracefully..."); + + const forceExit = setTimeout(() => { + console.error("Graceful shutdown timed out. Forcing exit."); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + + forceExit.unref(); + server.close(async () => { await redis.quit(); + clearTimeout(forceExit); process.exit(0); }); }; diff --git a/configs/runtime.ts b/configs/runtime.ts index 1fa4d3d..790c607 100644 --- a/configs/runtime.ts +++ b/configs/runtime.ts @@ -17,6 +17,7 @@ export const runtimeConfig = { port: readNumber("PORT", 4000), redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379", serviceName: process.env.SERVICE_NAME ?? "arce", + apiKey: process.env.API_KEY ?? "", defaultLimitPerMinute: readNumber("DEFAULT_LIMIT_PER_MINUTE", 100), suspiciousLimitPerMinute: readNumber("SUSPICIOUS_LIMIT_PER_MINUTE", 20), blockDurationSeconds: readNumber("BLOCK_DURATION_SECONDS", 300) diff --git a/docs/system-diagram.md b/docs/system-diagram.md new file mode 100644 index 0000000..f922973 --- /dev/null +++ b/docs/system-diagram.md @@ -0,0 +1,233 @@ +# ARCE System Diagram + +This document translates the current codebase into diagrams that are useful for design reviews, demos, and onboarding. + +It is based on the implementation in: + +- `apps/server/main.ts` +- `src/api/*` +- `src/core/*` +- `src/store/*` +- `src/sdk/*` +- `apps/dashboard/public/*` +- `configs/*` +- `.github/workflows/ci.yml` + +## 1. End-to-End System Overview + +```mermaid +flowchart TB + subgraph Actors["External Actors"] + ApiClients["API clients"] + HostApps["Node/Express apps using ARCE"] + Operators["Operators using the dashboard"] + Developers["Developers and CI"] + end + + subgraph Integration["Integration Surface"] + SDK["ArceClient
src/sdk/client.ts"] + Middleware["createArceMiddleware
src/sdk/express-middleware.ts"] + end + + subgraph Runtime["ARCE Server Runtime"] + Bootstrap["Bootstrap
apps/server/main.ts"] + App["Express app
src/api/app.ts"] + Root["GET /"] + Health["GET /health"] + Check["POST /check-limit"] + Consume["POST /consume"] + Dashboard["GET /dashboard"] + DashboardData["GET /api/dashboard-data"] + StaticAssets["Dashboard static files
apps/dashboard/public/*"] + Schema["Zod request validation
src/api/schemas.ts"] + Service["LimiterService"] + Detector["AbuseDetector
analyzeBehavior()"] + Policy["AdaptivePolicy
buildEffectivePolicy()"] + BehaviorStore["BehaviorStore"] + LimiterStore["RedisRateLimiterStore"] + MetricsStore["MetricsStore"] + Snapshot["getDashboardSnapshot()"] + end + + subgraph RedisLayer["Redis Shared State"] + RedisServer["Redis 7 server"] + LimitState["Limiter state
arce:limit:*"] + BehaviorCounters["Behavior counters
req10s / fingerprint / route / missing-ua"] + DenialHistory["Denial history
arce:behavior:denials:*"] + BlockState["Block TTLs and indexes
arce:block:* / arce:blocks*"] + MetricState["Metric totals and minute buckets
arce:metrics:*"] + RecentAnomalyState["Recent anomaly feed"] + end + + subgraph Tooling["Configuration, Delivery, and Validation"] + Env[".env + configs/runtime.ts"] + Compose["docker-compose.yml"] + Docker["Dockerfile"] + Tests["Vitest / Supertest / smoke tests"] + CI["GitHub Actions CI"] + Dist["Compiled dist/* output"] + end + + ApiClients -->|direct HTTP| App + ApiClients -->|Node integration| SDK + HostApps --> Middleware + Middleware -->|build payload + consume| SDK + SDK -->|POST JSON| App + + Operators -->|open page| Dashboard + Operators -->|poll data| DashboardData + + Developers -->|npm run dev / start| Bootstrap + Developers -->|npm test / smoke| Tests + CI -->|npm ci, build, lint, test| Tests + Docker --> Dist + Dist --> Bootstrap + Compose --> RedisServer + Env --> Bootstrap + + Bootstrap --> App + Bootstrap --> BehaviorStore + Bootstrap --> LimiterStore + Bootstrap --> MetricsStore + Bootstrap --> RedisServer + + App --> Root + App --> Health + App --> Check + App --> Consume + App --> Dashboard + App --> DashboardData + Dashboard --> StaticAssets + + Check --> Schema + Consume --> Schema + Schema --> Service + + Service --> BehaviorStore + BehaviorStore --> RedisServer + RedisServer --> BehaviorCounters + RedisServer --> DenialHistory + RedisServer --> BlockState + + Service --> Detector + Detector --> Policy + Policy -->|tier and limit| Service + Policy -->|blocked tier| BehaviorStore + + Service --> LimiterStore + LimiterStore -->|Lua enforcement| RedisServer + RedisServer --> LimitState + + Service --> MetricsStore + MetricsStore --> RedisServer + RedisServer --> MetricState + RedisServer --> RecentAnomalyState + + DashboardData --> Snapshot + Snapshot --> MetricsStore + Snapshot --> BehaviorStore +``` + +## 2. Request Lifecycle + +This sequence shows the hot path for `POST /consume`. + +```mermaid +sequenceDiagram + participant Caller as Caller + participant Client as ArceClient or direct HTTP client + participant API as limit handler + participant Schema as Zod schema + participant Service as LimiterService + participant Behavior as BehaviorStore + participant Redis as Redis + participant Detector as AbuseDetector + participant Policy as AdaptivePolicy + participant Limiter as RedisRateLimiterStore + participant Metrics as MetricsStore + + Caller->>Client: send subject + route + algorithm + Client->>API: POST /consume + API->>Schema: validate request body + Schema-->>API: normalized payload + API->>Service: evaluate(payload, true) + + Service->>Behavior: observe(subject, event) + Behavior->>Redis: update counters and read recent state + Redis-->>Behavior: behavior snapshot + Behavior-->>Service: recent behavior + + Service->>Detector: analyzeBehavior(snapshot) + Detector-->>Service: risk score + anomalies + Service->>Policy: buildEffectivePolicy(...) + Policy-->>Service: tier + effective limit + + alt active block already exists + Service->>Behavior: recordOutcome(denied) + else blocked tier recommended + Service->>Behavior: registerBlock(subject, reason, ttl) + Service->>Behavior: recordOutcome(denied) + else enforce adaptive limit + Service->>Limiter: evaluate(subject, algorithm, policy, cost, consume) + Limiter->>Redis: execute Lua script atomically + Redis-->>Limiter: allowed, remaining, retry, reset + Limiter-->>Service: limiter decision + Service->>Behavior: recordOutcome(allowed or denied) + end + + Service->>Metrics: recordDecision(result) + Metrics->>Redis: update totals, minute buckets, anomaly feed + Service-->>API: EnforcementResult + API-->>Caller: 200 or 429 JSON +``` + +## 3. Redis Responsibility Map + +This diagram shows how Redis is partitioned by responsibility instead of treating every key as one blob of rate-limiter state. + +```mermaid +flowchart LR + BehaviorStore["BehaviorStore"] --> Req10s["arce:behavior:req10s:[subject]
10-second volume buckets"] + BehaviorStore --> Fingerprints["arce:behavior:fingerprint:[subject]
per-minute duplicate fingerprints"] + BehaviorStore --> Routes["arce:behavior:route:[subject]
per-minute route fan-out"] + BehaviorStore --> Denials["arce:behavior:denials:[subject]
recent denials"] + BehaviorStore --> MissingUa["arce:behavior:missing-ua:[subject]
missing user-agent counts"] + BehaviorStore --> LiveBlock["arce:block:[subject]
temporary block TTL"] + BehaviorStore --> BlockIndex["arce:blocks and arce:blocks:meta
active block index and metadata"] + + LimiterStore["RedisRateLimiterStore"] --> Token["token bucket state
tokens + updatedAt"] + LimiterStore --> Leaky["leaky bucket state
level + updatedAt"] + LimiterStore --> Sliding["sliding window state
sorted set of request timestamps"] + + MetricsStore["MetricsStore"] --> Totals["arce:metrics:totals
global counters"] + MetricsStore --> Series["arce:metrics:req / blocked / rate-limited / anomalies
minute series"] + MetricsStore --> Recent["arce:metrics:recent-anomalies
dashboard event feed"] +``` + +## 4. Build, Run, and Verify Path + +```mermaid +flowchart LR + Source["TypeScript source
apps / src / configs"] --> Build["npm run build
tsc"] + Build --> Dist["dist/* compiled JavaScript"] + Dist --> Start["node dist/apps/server/main.js"] + Start --> Runtime["ARCE server process"] + Redis["redis:7 via docker-compose"] --> Runtime + Env[".env settings"] --> Runtime + + Dev["Developer"] -->|npm run dev| Runtime + Dev -->|npm test| TestSuite["Vitest + Supertest"] + Dev -->|npm run smoke| Smoke["scripts/smoke-test.mjs"] + + CI[".github/workflows/ci.yml"] --> Install["npm ci"] + Install --> CIBuild["npm run build"] + CIBuild --> Lint["npm run lint"] + Lint --> CITest["npm test"] +``` + +## Reading Guide + +- Use diagram 1 when you need the full system picture. +- Use diagram 2 when you need to explain exactly how one rate-limit decision is produced. +- Use diagram 3 when you need to reason about Redis data ownership and lifecycle. +- Use diagram 4 when you need to explain developer workflow, packaging, and CI. diff --git a/package.json b/package.json index 18045d6..c9f55af 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "traffic-control", "express" ], - "author": "", + "author": "ARCE contributors", "license": "MIT", "engines": { "node": ">=20" diff --git a/src/api/app.ts b/src/api/app.ts index bcbd244..5c05797 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -6,6 +6,8 @@ import express, { } from "express"; import path from "node:path"; +import { ZodError } from "zod"; + import { createDashboardDataHandler, createDashboardPageHandler @@ -13,6 +15,7 @@ import { import { createHealthHandler } from "./handlers/health"; import { createLimitHandler } from "./handlers/limit"; import { createRootHandler } from "./handlers/root"; +import { createAuthMiddleware } from "./middleware/auth"; import { LimiterService } from "../core/limiter-service"; import type { RedisClient } from "../store/redis"; @@ -25,20 +28,40 @@ interface CreateServerAppArgs { export function createServerApp(args: CreateServerAppArgs): Express { const app = express(); const staticDir = path.resolve(args.dashboardPublicDir); + const auth = createAuthMiddleware(); app.disable("x-powered-by"); + + // Security headers + app.use((_request, response, next) => { + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "DENY"); + response.setHeader("X-XSS-Protection", "1; mode=block"); + response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + next(); + }); + app.use(express.json({ limit: "1mb" })); app.use("/static", express.static(staticDir)); + // Public endpoints app.get("/", createRootHandler()); app.get("/health", createHealthHandler(args.redis)); - app.post("/check-limit", createLimitHandler(args.limiterService, false)); - app.post("/consume", createLimitHandler(args.limiterService, true)); + app.get("/dashboard", createDashboardPageHandler(staticDir)); + + // Protected endpoints + app.post( + "/check-limit", + auth, + createLimitHandler(args.limiterService, false) + ); + app.post("/consume", auth, createLimitHandler(args.limiterService, true)); app.get( "/api/dashboard-data", + auth, createDashboardDataHandler(args.limiterService) ); - app.get("/dashboard", createDashboardPageHandler(staticDir)); + app.use((_request, response) => { response.status(404).json({ error: "Route not found" @@ -52,13 +75,21 @@ export function createServerApp(args: CreateServerAppArgs): Express { response: Response, _next: NextFunction ) => { - if (error instanceof Error) { + if (error instanceof ZodError) { response.status(400).json({ - error: error.message + error: "Validation failed", + issues: error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message + })) }); return; } + if (error instanceof Error) { + console.error("Unhandled error:", error); + } + response.status(500).json({ error: "Unexpected server error" }); diff --git a/src/api/middleware/auth.ts b/src/api/middleware/auth.ts new file mode 100644 index 0000000..86acb3e --- /dev/null +++ b/src/api/middleware/auth.ts @@ -0,0 +1,32 @@ +import type { RequestHandler } from "express"; + +import { runtimeConfig } from "../../../configs/runtime"; + +/** + * API key authentication middleware. + * + * When `API_KEY` is set in the environment, all protected endpoints require + * a matching `x-api-key` header. When `API_KEY` is not set, authentication + * is disabled so local development remains frictionless. + */ +export function createAuthMiddleware(): RequestHandler { + return (request, response, next) => { + const configuredKey = runtimeConfig.apiKey; + + if (!configuredKey) { + next(); + return; + } + + const provided = request.header("x-api-key"); + + if (!provided || provided !== configuredKey) { + response.status(401).json({ + error: "Unauthorized. Provide a valid x-api-key header." + }); + return; + } + + next(); + }; +} diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 8e671fd..0f8ec20 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -5,9 +5,9 @@ export const limitRequestSchema = z algorithm: z.enum(["token_bucket", "sliding_window", "leaky_bucket"]), route: z.string().trim().min(1).default("/"), method: z.string().trim().min(1).default("GET"), - userId: z.string().trim().min(1).optional(), - ip: z.string().trim().min(1).optional(), - identifier: z.string().trim().min(1).optional(), + userId: z.string().trim().min(1).max(256).optional(), + ip: z.string().trim().min(1).max(64).optional(), + identifier: z.string().trim().min(1).max(256).optional(), scope: z.enum(["user", "ip", "hybrid", "custom"]).optional(), fingerprint: z.string().trim().min(1).max(256).optional(), cost: z.number().int().min(1).max(10).default(1), diff --git a/src/core/limiter-service.ts b/src/core/limiter-service.ts index 2020992..afe375d 100644 --- a/src/core/limiter-service.ts +++ b/src/core/limiter-service.ts @@ -124,12 +124,14 @@ export class LimiterService { }; } - await this.behaviorStore.recordOutcome(subject, { - allowed: result.allowed, - blocked: result.blocked, - timestampMs: nowMs - }); - await this.metricsStore.recordDecision(result); + await Promise.all([ + this.behaviorStore.recordOutcome(subject, { + allowed: result.allowed, + blocked: result.blocked, + timestampMs: nowMs + }), + this.metricsStore.recordDecision(result) + ]); return result; } diff --git a/src/store/redis.ts b/src/store/redis.ts index 77c4457..5fa1308 100644 --- a/src/store/redis.ts +++ b/src/store/redis.ts @@ -6,7 +6,23 @@ export type RedisClient = ReturnType; export async function createRedisConnection(): Promise { const client = createClient({ - url: runtimeConfig.redisUrl + url: runtimeConfig.redisUrl, + socket: { + reconnectStrategy(retries: number) { + if (retries > 10) { + console.error( + `Redis reconnection failed after ${retries} attempts. Giving up.` + ); + return new Error("Redis reconnection limit reached."); + } + + const delay = Math.min(retries * 200, 5_000); + console.warn( + `Redis connection lost. Retrying in ${delay}ms (attempt ${retries})...` + ); + return delay; + } + } }); client.on("error", (error) => { diff --git a/tests/unit/abuse-detector.test.ts b/tests/unit/abuse-detector.test.ts new file mode 100644 index 0000000..99888c5 --- /dev/null +++ b/tests/unit/abuse-detector.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeBehavior } from "../../src/core/abuse-detector"; +import type { BehaviorSnapshot } from "../../src/types"; + +function baseline(): BehaviorSnapshot { + return { + recentTenSecondCount: 0, + trailingMinuteCount: 0, + averageTenSecondCount: 0, + duplicateFingerprintCount: 0, + duplicateFingerprintRatio: 0, + uniqueRouteCount: 0, + deniedLastFiveMinutes: 0, + missingUserAgentCount: 0, + activeBlock: null + }; +} + +describe("analyzeBehavior", () => { + describe("burst spike detection", () => { + it("does not flag when below thresholds", () => { + const snapshot = { + ...baseline(), + recentTenSecondCount: 14, + averageTenSecondCount: 5 + }; + const result = analyzeBehavior(snapshot); + expect( + result.anomalies.find((a) => a.code === "burst_spike") + ).toBeUndefined(); + }); + + it("does not flag high count with low ratio", () => { + const snapshot = { + ...baseline(), + recentTenSecondCount: 15, + averageTenSecondCount: 10 + }; + const result = analyzeBehavior(snapshot); + expect( + result.anomalies.find((a) => a.code === "burst_spike") + ).toBeUndefined(); + }); + + it("flags when count >= 15 and ratio >= 3", () => { + const snapshot = { + ...baseline(), + recentTenSecondCount: 15, + averageTenSecondCount: 5 + }; + const result = analyzeBehavior(snapshot); + const anomaly = result.anomalies.find((a) => a.code === "burst_spike"); + expect(anomaly).toBeDefined(); + expect(anomaly!.severity).toBe("high"); + expect(result.riskScore).toBe(35); + }); + + it("uses raw count as ratio when average is zero", () => { + const snapshot = { + ...baseline(), + recentTenSecondCount: 15, + averageTenSecondCount: 0 + }; + const result = analyzeBehavior(snapshot); + expect( + result.anomalies.find((a) => a.code === "burst_spike") + ).toBeDefined(); + }); + }); + + describe("repeated identical requests", () => { + it("does not flag when below thresholds", () => { + const snapshot = { + ...baseline(), + duplicateFingerprintCount: 7, + duplicateFingerprintRatio: 0.8 + }; + const result = analyzeBehavior(snapshot); + expect( + result.anomalies.find((a) => a.code === "repeated_identical_requests") + ).toBeUndefined(); + }); + + it("does not flag high count with low ratio", () => { + const snapshot = { + ...baseline(), + duplicateFingerprintCount: 10, + duplicateFingerprintRatio: 0.6 + }; + const result = analyzeBehavior(snapshot); + expect( + result.anomalies.find((a) => a.code === "repeated_identical_requests") + ).toBeUndefined(); + }); + + it("flags when count >= 8 and ratio >= 0.7", () => { + const snapshot = { + ...baseline(), + duplicateFingerprintCount: 8, + duplicateFingerprintRatio: 0.7 + }; + const result = analyzeBehavior(snapshot); + const anomaly = result.anomalies.find( + (a) => a.code === "repeated_identical_requests" + ); + expect(anomaly).toBeDefined(); + expect(anomaly!.severity).toBe("high"); + expect(result.riskScore).toBe(30); + }); + }); + + describe("wide route scan", () => { + it("does not flag few routes", () => { + const snapshot = { + ...baseline(), + uniqueRouteCount: 11, + trailingMinuteCount: 40 + }; + const result = analyzeBehavior(snapshot); + expect( + result.anomalies.find((a) => a.code === "wide_route_scan") + ).toBeUndefined(); + }); + + it("does not flag many routes with low traffic", () => { + const snapshot = { + ...baseline(), + uniqueRouteCount: 15, + trailingMinuteCount: 20 + }; + const result = analyzeBehavior(snapshot); + expect( + result.anomalies.find((a) => a.code === "wide_route_scan") + ).toBeUndefined(); + }); + + it("flags when routes >= 12 and trailing count >= 30", () => { + const snapshot = { + ...baseline(), + uniqueRouteCount: 12, + trailingMinuteCount: 30 + }; + const result = analyzeBehavior(snapshot); + const anomaly = result.anomalies.find( + (a) => a.code === "wide_route_scan" + ); + expect(anomaly).toBeDefined(); + expect(anomaly!.severity).toBe("medium"); + expect(result.riskScore).toBe(25); + }); + }); + + describe("repeated denials", () => { + it("does not flag below threshold", () => { + const snapshot = { ...baseline(), deniedLastFiveMinutes: 4 }; + const result = analyzeBehavior(snapshot); + expect( + result.anomalies.find((a) => a.code === "repeated_denials") + ).toBeUndefined(); + }); + + it("flags when denied >= 5", () => { + const snapshot = { ...baseline(), deniedLastFiveMinutes: 5 }; + const result = analyzeBehavior(snapshot); + const anomaly = result.anomalies.find( + (a) => a.code === "repeated_denials" + ); + expect(anomaly).toBeDefined(); + expect(anomaly!.severity).toBe("medium"); + expect(result.riskScore).toBe(15); + }); + }); + + describe("missing user agent", () => { + it("does not flag low volume", () => { + const snapshot = { + ...baseline(), + missingUserAgentCount: 15, + recentTenSecondCount: 9 + }; + const result = analyzeBehavior(snapshot); + expect( + result.anomalies.find((a) => a.code === "missing_user_agent") + ).toBeUndefined(); + }); + + it("flags when count >= 10 and recent traffic >= 10", () => { + const snapshot = { + ...baseline(), + missingUserAgentCount: 10, + recentTenSecondCount: 10 + }; + const result = analyzeBehavior(snapshot); + const anomaly = result.anomalies.find( + (a) => a.code === "missing_user_agent" + ); + expect(anomaly).toBeDefined(); + expect(anomaly!.severity).toBe("low"); + expect(result.riskScore).toBe(10); + }); + }); + + describe("active block override", () => { + it("overrides risk score to at least 90 when an active block exists", () => { + const snapshot = { + ...baseline(), + activeBlock: { + reason: "test block", + ttlMs: 60_000, + expiresAtMs: Date.now() + 60_000 + } + }; + const result = analyzeBehavior(snapshot); + expect(result.riskScore).toBeGreaterThanOrEqual(90); + expect(result.reasons).toContain( + "An active temporary block is already in effect." + ); + }); + }); + + describe("recommended block", () => { + it("does not recommend block below 75", () => { + const snapshot = { + ...baseline(), + recentTenSecondCount: 15, + averageTenSecondCount: 5, + duplicateFingerprintCount: 8, + duplicateFingerprintRatio: 0.7 + }; + const result = analyzeBehavior(snapshot); + // 35 + 30 = 65 + expect(result.riskScore).toBe(65); + expect(result.recommendedBlock).toBe(false); + }); + + it("recommends block at 75 or above", () => { + const snapshot = { + ...baseline(), + recentTenSecondCount: 15, + averageTenSecondCount: 5, + duplicateFingerprintCount: 8, + duplicateFingerprintRatio: 0.7, + uniqueRouteCount: 12, + trailingMinuteCount: 30 + }; + const result = analyzeBehavior(snapshot); + // 35 + 30 + 25 = 90 + expect(result.riskScore).toBe(90); + expect(result.recommendedBlock).toBe(true); + }); + }); + + describe("cumulative scoring", () => { + it("accumulates risk from multiple signals", () => { + const snapshot = { + ...baseline(), + recentTenSecondCount: 20, + averageTenSecondCount: 5, + duplicateFingerprintCount: 10, + duplicateFingerprintRatio: 0.8, + uniqueRouteCount: 14, + trailingMinuteCount: 40, + deniedLastFiveMinutes: 8, + missingUserAgentCount: 12 + }; + const result = analyzeBehavior(snapshot); + // 35 + 30 + 25 + 15 + 10 = 115 + expect(result.riskScore).toBe(115); + expect(result.anomalies).toHaveLength(5); + expect(result.recommendedBlock).toBe(true); + }); + }); +}); diff --git a/tests/unit/express-middleware.test.ts b/tests/unit/express-middleware.test.ts new file mode 100644 index 0000000..c0c3a5f --- /dev/null +++ b/tests/unit/express-middleware.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createArceMiddleware } from "../../src/sdk/express-middleware"; +import type { ArceClient } from "../../src/sdk/client"; +import type { EnforcementResult } from "../../src/types"; +import type { Request, Response } from "express"; + +function mockResult(allowed: boolean): EnforcementResult { + return { + mode: "consume", + allowed, + blocked: !allowed, + subject: "ip:10.0.0.1", + fingerprint: "GET:/test", + algorithm: "token_bucket", + cost: 1, + anomalies: [], + behavior: { + recentTenSecondCount: 1, + trailingMinuteCount: 1, + averageTenSecondCount: 0, + duplicateFingerprintCount: 0, + duplicateFingerprintRatio: 0, + uniqueRouteCount: 1, + deniedLastFiveMinutes: 0, + missingUserAgentCount: 0, + activeBlock: null + }, + effectivePolicy: { + algorithm: "token_bucket", + tier: "normal", + baseLimitPerMinute: 100, + effectiveLimitPerMinute: 100, + capacity: 100, + windowMs: 60_000, + refillTokensPerMs: 100 / 60_000, + leakRatePerMs: 100 / 60_000, + riskScore: 0, + reasons: [], + blockDurationSeconds: 0 + }, + decision: { allowed, remaining: 99, retryAfterMs: 0, resetAfterMs: 60_000 }, + evaluatedAt: new Date().toISOString() + }; +} + +function mockRequest(overrides: Partial = {}): Request { + return { + originalUrl: "/test", + method: "GET", + ip: "10.0.0.1", + headers: { "user-agent": "test-agent" }, + header: vi.fn().mockReturnValue(undefined), + socket: { remoteAddress: "10.0.0.1" }, + ...overrides + } as unknown as Request; +} + +function mockResponse(): Response & { + _status: number; + _json: unknown; + _headers: Record; +} { + const res = { + _status: 200, + _json: null, + _headers: {} as Record, + status(code: number) { + res._status = code; + return res; + }, + json(data: unknown) { + res._json = data; + return res; + }, + setHeader(key: string, value: string) { + res._headers[key] = value; + return res; + } + }; + return res as never; +} + +describe("createArceMiddleware", () => { + it("calls next() on allowed request", async () => { + const consume = vi.fn().mockResolvedValue(mockResult(true)); + const client = { consume } as unknown as ArceClient; + const middleware = createArceMiddleware({ client }); + const req = mockRequest(); + const res = mockResponse(); + const next = vi.fn(); + + await (middleware as (...args: unknown[]) => Promise)(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res._headers["x-rate-limit-remaining"]).toBe("99"); + }); + + it("returns 429 on denied request", async () => { + const consume = vi.fn().mockResolvedValue(mockResult(false)); + const client = { consume } as unknown as ArceClient; + const middleware = createArceMiddleware({ client }); + const req = mockRequest(); + const res = mockResponse(); + const next = vi.fn(); + + await (middleware as (...args: unknown[]) => Promise)(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(429); + }); + + it("forwards errors to next()", async () => { + const error = new Error("connection failed"); + const consume = vi.fn().mockRejectedValue(error); + const client = { consume } as unknown as ArceClient; + const middleware = createArceMiddleware({ client }); + const req = mockRequest(); + const res = mockResponse(); + const next = vi.fn(); + + await (middleware as (...args: unknown[]) => Promise)(req, res, next); + expect(next).toHaveBeenCalledWith(error); + }); +}); diff --git a/tests/unit/hashing.test.ts b/tests/unit/hashing.test.ts new file mode 100644 index 0000000..7916e96 --- /dev/null +++ b/tests/unit/hashing.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { hashValue } from "../../src/utils/hashing"; + +describe("hashValue", () => { + it("returns a 16-character hex string", () => { + const result = hashValue("test-input"); + expect(result).toHaveLength(16); + expect(result).toMatch(/^[0-9a-f]{16}$/); + }); + + it("returns deterministic output for the same input", () => { + const first = hashValue("hello"); + const second = hashValue("hello"); + expect(first).toBe(second); + }); + + it("returns different output for different inputs", () => { + const a = hashValue("input-a"); + const b = hashValue("input-b"); + expect(a).not.toBe(b); + }); + + it("handles empty string", () => { + const result = hashValue(""); + expect(result).toHaveLength(16); + expect(result).toMatch(/^[0-9a-f]{16}$/); + }); +}); diff --git a/tests/unit/identity.test.ts b/tests/unit/identity.test.ts new file mode 100644 index 0000000..1068a97 --- /dev/null +++ b/tests/unit/identity.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { resolveSubject, buildFingerprint } from "../../src/utils/identity"; +import type { LimitRequestPayload } from "../../src/types"; + +function basePayload( + overrides: Partial = {} +): LimitRequestPayload { + return { + algorithm: "token_bucket", + route: "/test", + method: "GET", + ...overrides + }; +} + +describe("resolveSubject", () => { + it("returns hybrid key when scope is hybrid", () => { + const result = resolveSubject( + basePayload({ scope: "hybrid", userId: "u1", ip: "1.2.3.4" }) + ); + expect(result).toBe("hybrid:user:u1:ip:1.2.3.4"); + }); + + it("returns user key when scope is user", () => { + const result = resolveSubject(basePayload({ scope: "user", userId: "u1" })); + expect(result).toBe("user:u1"); + }); + + it("returns ip key when scope is ip", () => { + const result = resolveSubject(basePayload({ scope: "ip", ip: "10.0.0.1" })); + expect(result).toBe("ip:10.0.0.1"); + }); + + it("returns custom key when identifier is provided", () => { + const result = resolveSubject(basePayload({ identifier: "device-abc" })); + expect(result).toBe("custom:device-abc"); + }); + + it("falls back to userId when no scope is set", () => { + const result = resolveSubject(basePayload({ userId: "u1" })); + expect(result).toBe("user:u1"); + }); + + it("falls back to ip when no scope and no userId", () => { + const result = resolveSubject(basePayload({ ip: "10.0.0.1" })); + expect(result).toBe("ip:10.0.0.1"); + }); + + it("throws when no identifier can be derived", () => { + expect(() => resolveSubject(basePayload())).toThrow( + "Unable to derive a subject key" + ); + }); +}); + +describe("buildFingerprint", () => { + it("uses the provided fingerprint when available", () => { + const result = buildFingerprint( + basePayload({ fingerprint: "GET:/api?q=1" }) + ); + expect(result).toBe("GET:/api?q=1"); + }); + + it("trims whitespace from provided fingerprint", () => { + const result = buildFingerprint( + basePayload({ fingerprint: " GET:/api " }) + ); + expect(result).toBe("GET:/api"); + }); + + it("builds a default fingerprint from method and route", () => { + const result = buildFingerprint( + basePayload({ method: "POST", route: "/orders" }) + ); + expect(result).toBe("POST:/orders"); + }); + + it("uppercases the method in default fingerprint", () => { + const result = buildFingerprint( + basePayload({ method: "post", route: "/orders" }) + ); + expect(result).toBe("POST:/orders"); + }); +}); diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts new file mode 100644 index 0000000..16ad6d5 --- /dev/null +++ b/tests/unit/schemas.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; + +import { limitRequestSchema } from "../../src/api/schemas"; + +describe("limitRequestSchema", () => { + it("accepts minimal valid payload", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + ip: "10.0.0.1", + scope: "ip" + }); + expect(r.success).toBe(true); + }); + + it("rejects missing algorithm", () => { + expect(limitRequestSchema.safeParse({ ip: "10.0.0.1" }).success).toBe( + false + ); + }); + + it("rejects invalid algorithm", () => { + expect( + limitRequestSchema.safeParse({ algorithm: "invalid", ip: "1.2.3.4" }) + .success + ).toBe(false); + }); + + it("rejects when no identifier provided", () => { + const r = limitRequestSchema.safeParse({ algorithm: "token_bucket" }); + expect(r.success).toBe(false); + }); + + it("rejects hybrid without userId", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + ip: "1.2.3.4", + scope: "hybrid" + }); + expect(r.success).toBe(false); + }); + + it("rejects hybrid without ip", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + userId: "u1", + scope: "hybrid" + }); + expect(r.success).toBe(false); + }); + + it("accepts hybrid with both", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + userId: "u1", + ip: "1.2.3.4", + scope: "hybrid" + }); + expect(r.success).toBe(true); + }); + + it("rejects user scope without userId", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + ip: "1.2.3.4", + scope: "user" + }); + expect(r.success).toBe(false); + }); + + it("rejects ip scope without ip", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + userId: "u1", + scope: "ip" + }); + expect(r.success).toBe(false); + }); + + it("rejects custom scope without identifier", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + ip: "1.2.3.4", + scope: "custom" + }); + expect(r.success).toBe(false); + }); + + it("accepts custom scope with identifier", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + identifier: "d1", + scope: "custom" + }); + expect(r.success).toBe(true); + }); + + it("enforces max length on userId", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + userId: "x".repeat(257), + scope: "user" + }); + expect(r.success).toBe(false); + }); + + it("enforces max length on ip", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + ip: "x".repeat(65), + scope: "ip" + }); + expect(r.success).toBe(false); + }); + + it("enforces max length on identifier", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + identifier: "x".repeat(257), + scope: "custom" + }); + expect(r.success).toBe(false); + }); + + it("enforces cost range", () => { + expect( + limitRequestSchema.safeParse({ + algorithm: "token_bucket", + ip: "1.2.3.4", + cost: 0 + }).success + ).toBe(false); + expect( + limitRequestSchema.safeParse({ + algorithm: "token_bucket", + ip: "1.2.3.4", + cost: 11 + }).success + ).toBe(false); + }); + + it("defaults cost, route, and method", () => { + const r = limitRequestSchema.safeParse({ + algorithm: "token_bucket", + ip: "1.2.3.4" + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.cost).toBe(1); + expect(r.data.route).toBe("/"); + expect(r.data.method).toBe("GET"); + } + }); +}); diff --git a/tests/unit/time.test.ts b/tests/unit/time.test.ts new file mode 100644 index 0000000..683e82e --- /dev/null +++ b/tests/unit/time.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { bucketStart } from "../../src/utils/time"; + +describe("bucketStart", () => { + it("floors to the nearest minute by default", () => { + // 2026-01-01T00:01:30.500Z → 2026-01-01T00:01:00.000Z + const ts = new Date("2026-01-01T00:01:30.500Z").getTime(); + const result = bucketStart(ts); + expect(result).toBe(new Date("2026-01-01T00:01:00.000Z").getTime()); + }); + + it("returns the same value when already on a boundary", () => { + const ts = new Date("2026-01-01T00:02:00.000Z").getTime(); + expect(bucketStart(ts)).toBe(ts); + }); + + it("supports custom window sizes", () => { + const ts = new Date("2026-01-01T00:00:15.000Z").getTime(); + const result = bucketStart(ts, 10_000); + expect(result).toBe(new Date("2026-01-01T00:00:10.000Z").getTime()); + }); + + it("handles zero timestamp", () => { + expect(bucketStart(0)).toBe(0); + }); + + it("handles 10-second windows correctly", () => { + const ts = new Date("2026-01-01T00:00:37.000Z").getTime(); + const result = bucketStart(ts, 10_000); + expect(result).toBe(new Date("2026-01-01T00:00:30.000Z").getTime()); + }); +});