File size: 7,976 Bytes
ec8acdf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | // U3 (#3199) — per-account rate-limit module. Pipeline + UTC math tested in
// isolation with an injected mock pipeline + injected Date (no live Redis).
// Per the plan, Upstash's sliding-window math is NOT re-tested here — only our
// meter/ceiling logic and fail-open posture.
//
// Constants mirrored from the module so a prod drift fails by name rather than
// silently (matches tests/mcp-quota-concurrent.test.mjs discipline).
const STARTER_ALLOWANCE = 1000;
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
apiKeyDailyKey,
reserveDailyMeter,
rateLimitHeaders,
checkBurst,
ENTERPRISE_API_RATE_LIMIT,
} from '../server/_shared/api-key-rate-limit.ts';
// A mock pipeline that simulates an INCR/DECR counter, recording every command.
function makePipeline(initial = 0) {
let count = initial;
const commands: Array<Array<string | number>>[] = [];
const pipeline = async (cmds: Array<Array<string | number>>) => {
commands.push(cmds);
return cmds.map((cmd) => {
const verb = cmd[0];
if (verb === 'INCR') return { result: (count += 1) };
if (verb === 'DECR') return { result: (count -= 1) };
return { result: 1 }; // EXPIRE etc.
});
};
return {
pipeline,
commands,
current: () => count,
};
}
const D = (y: number, mo: number, d: number, h = 12) =>
new Date(Date.UTC(y, mo, d, h, 0, 0));
describe('#3199 U3 — apiKeyDailyKey (UTC calendar day)', () => {
it('formats a plain (un-prefixed) UTC-day key', () => {
assert.equal(apiKeyDailyKey('user_1', D(2026, 5, 30)), 'rl:apikey:day:user_1:2026-06-30');
});
it('rolls over at UTC midnight, not before', () => {
assert.equal(apiKeyDailyKey('u', new Date(Date.UTC(2026, 5, 30, 23, 59, 59))).endsWith('2026-06-30'), true);
assert.equal(apiKeyDailyKey('u', new Date(Date.UTC(2026, 6, 1, 0, 0, 0))).endsWith('2026-07-01'), true);
});
it('returns empty for a missing userId', () => {
assert.equal(apiKeyDailyKey(''), '');
});
});
describe('#3199 U3 — reserveDailyMeter', () => {
it('under the allowance: meters, does not flag, no rollback', async () => {
const mock = makePipeline(500); // next INCR -> 501, under the 1000 allowance
const r = await reserveDailyMeter({ userId: 'u', allowance: STARTER_ALLOWANCE, pipeline: mock.pipeline });
assert.equal(r.count, 501);
assert.equal(r.overLimit, false);
assert.equal(r.metered, true);
// INCR+EXPIRE issued, no DECR.
assert.equal(mock.commands.length, 1);
});
it('over the sold allowance: flags, and rollback() floors the counter', async () => {
const mock = makePipeline(STARTER_ALLOWANCE); // 1000 -> INCR 1001
const r = await reserveDailyMeter({ userId: 'u', allowance: STARTER_ALLOWANCE, pipeline: mock.pipeline });
assert.equal(r.count, 1001);
assert.equal(r.overLimit, true);
await r.rollback();
assert.equal(mock.current(), 1000, 'rollback DECRs the over-limit increment');
// rollback is idempotent
await r.rollback();
assert.equal(mock.current(), 1000);
});
it('exactly at the allowance is allowed (only strictly-over rejects)', async () => {
const mock = makePipeline(STARTER_ALLOWANCE - 1); // -> 1000
const r = await reserveDailyMeter({ userId: 'u', allowance: STARTER_ALLOWANCE, pipeline: mock.pipeline });
assert.equal(r.count, 1000);
assert.equal(r.overLimit, false);
});
it('unlimited allowance (-1): never touches Redis, never over-limits', async () => {
const mock = makePipeline(999999);
const r = await reserveDailyMeter({ userId: 'ent', allowance: -1, pipeline: mock.pipeline });
assert.equal(r.metered, false);
assert.equal(r.overLimit, false);
assert.equal(mock.commands.length, 0, 'no pipeline call for unlimited');
});
it('allowance 0 (misconfig): fails open, never meters or ceilings (no brick)', async () => {
const mock = makePipeline(0);
const r = await reserveDailyMeter({ userId: 'u', allowance: 0, pipeline: mock.pipeline });
assert.equal(r.metered, false);
assert.equal(r.overLimit, false);
assert.equal(mock.commands.length, 0, 'allowance 0 must not ceiling-429 request #1');
});
it('fail-open when Redis returns empty (outage): metered:false, served', async () => {
const downPipeline = async () => [];
const r = await reserveDailyMeter({ userId: 'u', allowance: STARTER_ALLOWANCE, pipeline: downPipeline });
assert.equal(r.metered, false);
assert.equal(r.overLimit, false);
});
it('fail-open when the pipeline throws', async () => {
const throwingPipeline = async () => {
throw new Error('redis exploded');
};
const r = await reserveDailyMeter({ userId: 'u', allowance: STARTER_ALLOWANCE, pipeline: throwingPipeline });
assert.equal(r.metered, false);
assert.equal(r.overLimit, false);
});
it('per-account: the metered key is the userId-scoped daily key', async () => {
const mock = makePipeline(0);
const date = D(2026, 5, 30);
await reserveDailyMeter({ userId: 'acct_42', allowance: STARTER_ALLOWANCE, pipeline: mock.pipeline, date });
const incrCmd = mock.commands[0][0];
assert.deepEqual(incrCmd, ['INCR', apiKeyDailyKey('acct_42', date)]);
});
it('retryAfterSec points at the next UTC midnight', async () => {
const date = new Date(Date.UTC(2026, 5, 30, 23, 0, 0)); // 1h before midnight
const r = await reserveDailyMeter({ userId: 'u', allowance: STARTER_ALLOWANCE, pipeline: makePipeline(0).pipeline, date });
assert.equal(r.retryAfterSec, 3600);
});
});
describe('#3199 U3 — burst limiter fail-open + headers', () => {
it('checkBurst fails open (ok:true) when Upstash is not configured', async () => {
const prevUrl = process.env.UPSTASH_REDIS_REST_URL;
const prevToken = process.env.UPSTASH_REDIS_REST_TOKEN;
delete process.env.UPSTASH_REDIS_REST_URL;
delete process.env.UPSTASH_REDIS_REST_TOKEN;
try {
const r = await checkBurst(60, 'acct_1');
assert.deepEqual(r, { ok: true });
} finally {
if (prevUrl !== undefined) process.env.UPSTASH_REDIS_REST_URL = prevUrl;
if (prevToken !== undefined) process.env.UPSTASH_REDIS_REST_TOKEN = prevToken;
}
});
it('rateLimitHeaders emits the standard X-RateLimit-* + Retry-After set', () => {
const h = rateLimitHeaders({ limit: 60, remaining: 0, resetMs: 1_900_000_000_000, retryAfterSec: 42 });
assert.equal(h['X-RateLimit-Limit'], '60');
assert.equal(h['X-RateLimit-Remaining'], '0');
assert.equal(h['X-RateLimit-Reset'], '1900000000000');
assert.equal(h['Retry-After'], '42');
});
it('rateLimitHeaders emits IETF RateLimit fields with a delta-seconds reset', () => {
const now = Date.now();
const h = rateLimitHeaders({ limit: 60, remaining: 7, resetMs: now + 30_000, retryAfterSec: 30, windowSec: 60 });
// RateLimit-Policy advertises the quota + window (structured-field syntax).
assert.equal(h['RateLimit-Policy'], '"default";q=60;w=60');
assert.equal(h['RateLimit-Limit'], '60');
assert.equal(h['RateLimit-Remaining'], '7');
// IETF reset is DELTA-seconds (~30), not the epoch-ms carried by X-RateLimit-Reset.
const resetSec = Number(h['RateLimit-Reset']);
assert.ok(resetSec >= 29 && resetSec <= 31, `RateLimit-Reset should be ~30s, got ${resetSec}`);
assert.equal(h.RateLimit, `"default";r=7;t=${resetSec}`);
});
it('rateLimitHeaders defaults the advertised window to 60s', () => {
const h = rateLimitHeaders({ limit: 600, remaining: 0, resetMs: Date.now() + 1000, retryAfterSec: 1 });
assert.equal(h['RateLimit-Policy'], '"default";q=600;w=60');
});
it('Retry-After floors at 1 second', () => {
assert.equal(rateLimitHeaders({ limit: 60, remaining: 0, resetMs: 0, retryAfterSec: 0 })['Retry-After'], '1');
});
it('enterprise per-minute constant matches the catalog (1000)', () => {
assert.equal(ENTERPRISE_API_RATE_LIMIT, 1000);
});
});
|