wacrm/src/lib/rate-limit.test.ts
Arnas Donauskas db11f76226
test: foundational Vitest suite for security + critical pure logic
Adds the first test layer to the repo. Vitest is wired into the
existing CI workflow between typecheck and build so every PR gets the
same gating before merge. No source code under test was modified; this
PR only adds the test framework + tests.

Coverage focuses on the highest-impact pure modules — security-critical
crypto/HMAC, the phone-number pipeline every outbound message touches,
the rate-limiter that protects send/broadcast, automation activation
validation, and dashboard date math. 89 tests across 7 files; full run
is ~300ms.

Modules covered:
- lib/whatsapp/encryption — GCM round-trip, tamper detection (auth tag,
  ciphertext, IV length, auth-tag length), legacy CBC compat, format
  detection, malformed input
- lib/whatsapp/webhook-signature — valid/invalid HMAC, missing header,
  wrong prefix, length-mismatch guard, fail-closed when secret unset
- lib/whatsapp/phone-utils — sanitize, normalize, phonesMatch with
  trunk-prefix tail fallback, isValidE164 boundaries (7–15 digits,
  non-zero start), phoneVariants insert/remove with dedupe,
  isRecipientNotAllowedError detection
- lib/rate-limit — first-request math, exact-limit boundary, per-key
  isolation, window expiry with fake timers, 429 response shape,
  Retry-After clamp
- lib/automations/validate — every step type's required fields, wait
  amount/unit boundaries, webhook URL validation (missing / non-http /
  malformed), assign_conversation mode branching, recursive condition
  walking with stable dot-paths, unknown step types
- lib/broadcast-status — known status lookups, pulse flag, dark-theme
  class shape, fallback for unknown strings
- lib/dashboard/date-utils — startOfLocalDay, daysAgoStart, localDayKey
  zero-padding, lastNDayKeys (1, N, month crossing), Monday-first DOW

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:06:02 +03:00

110 lines
3.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetRateLimitForTests,
checkRateLimit,
rateLimitResponse,
} from "./rate-limit";
const OPTS = { limit: 3, windowMs: 60_000 };
describe("checkRateLimit", () => {
beforeEach(() => {
__resetRateLimitForTests();
});
it("permits the first request and decrements remaining", () => {
const result = checkRateLimit("user:1", OPTS);
expect(result).toMatchObject({
success: true,
remaining: 2,
limit: 3,
});
expect(result.reset).toBeGreaterThan(Date.now());
});
it("permits exactly `limit` requests then rejects the next", () => {
expect(checkRateLimit("user:1", OPTS).success).toBe(true);
expect(checkRateLimit("user:1", OPTS).success).toBe(true);
expect(checkRateLimit("user:1", OPTS).success).toBe(true);
const over = checkRateLimit("user:1", OPTS);
expect(over.success).toBe(false);
expect(over.remaining).toBe(0);
});
it("keeps separate counters per key", () => {
checkRateLimit("user:1", OPTS);
checkRateLimit("user:1", OPTS);
checkRateLimit("user:1", OPTS);
// user:1 is at the cap, user:2 should still be unaffected.
const other = checkRateLimit("user:2", OPTS);
expect(other.success).toBe(true);
expect(other.remaining).toBe(2);
});
it("opens a fresh window after `windowMs` elapses", () => {
vi.useFakeTimers();
try {
const t0 = new Date("2026-05-01T00:00:00Z").getTime();
vi.setSystemTime(t0);
__resetRateLimitForTests();
checkRateLimit("user:1", OPTS);
checkRateLimit("user:1", OPTS);
checkRateLimit("user:1", OPTS);
expect(checkRateLimit("user:1", OPTS).success).toBe(false);
// Jump just past the window.
vi.setSystemTime(t0 + OPTS.windowMs + 1);
const refreshed = checkRateLimit("user:1", OPTS);
expect(refreshed.success).toBe(true);
expect(refreshed.remaining).toBe(2);
} finally {
vi.useRealTimers();
}
});
});
describe("rateLimitResponse", () => {
it("returns a 429 with retry / X-RateLimit headers", async () => {
const reset = Date.now() + 30_000;
const res = rateLimitResponse({
success: false,
remaining: 0,
reset,
limit: 60,
});
expect(res.status).toBe(429);
expect(res.headers.get("X-RateLimit-Limit")).toBe("60");
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
expect(Number(res.headers.get("Retry-After"))).toBeGreaterThan(0);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/rate limit/i);
});
it("clamps Retry-After to a minimum of 1 second", () => {
// Reset already in the past — the ceiling math would otherwise give 0.
const res = rateLimitResponse({
success: false,
remaining: 0,
reset: Date.now() - 5_000,
limit: 10,
});
expect(Number(res.headers.get("Retry-After"))).toBeGreaterThanOrEqual(1);
});
});
describe("RATE_LIMITS presets", () => {
it("send and broadcast budgets are independent", async () => {
__resetRateLimitForTests();
// Importing here so the presets stay close to their assertions.
const { RATE_LIMITS } = await import("./rate-limit");
expect(RATE_LIMITS.send.limit).toBeGreaterThan(RATE_LIMITS.broadcast.limit);
expect(RATE_LIMITS.send.windowMs).toBe(60_000);
expect(RATE_LIMITS.broadcast.windowMs).toBe(60_000);
});
});
afterEach(() => {
__resetRateLimitForTests();
});