mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
110 lines
3.4 KiB
TypeScript
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();
|
|
});
|