randomayzer/tests/idempotency-hardening.test.ts

144 lines
3.9 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { MemoryIdempotencyStore } from '../src/lib/idempotency';
import { IdempotencyKeyReusedError, ValidationError } from '../src/core/errors/http-errors';
describe('Idempotency Hardening & Request Fingerprinting', () => {
let store: MemoryIdempotencyStore;
beforeEach(() => {
store = new MemoryIdempotencyStore({ defaultTtlMs: 1000, maxKeyLength: 128, maxEntries: 100 });
});
it('returns cached response for same key and identical request payload', () => {
const key = 'test-key-1';
const payload = { sourceUrl: 'https://vk.com/wall-1_1', winnersCount: 1 };
const responseBody = { success: true, giveawayId: 'gw-1' };
store.set({
key,
operation: 'create-giveaway',
giveawayId: 'gw-1',
requestPayload: payload,
statusCode: 201,
body: responseBody,
});
const cached = store.get({
key,
operation: 'create-giveaway',
giveawayId: 'gw-1',
requestPayload: payload,
});
expect(cached).not.toBeNull();
expect(cached?.statusCode).toBe(201);
expect(cached?.body).toEqual(responseBody);
});
it('throws IdempotencyKeyReusedError (409) when same key is used with different payload', () => {
const key = 'test-key-reused';
const originalPayload = { sourceUrl: 'https://vk.com/wall-1_1', winnersCount: 1 };
const modifiedPayload = { sourceUrl: 'https://vk.com/wall-1_1', winnersCount: 5 }; // Changed!
store.set({
key,
operation: 'create-giveaway',
giveawayId: 'gw-1',
requestPayload: originalPayload,
statusCode: 201,
body: { success: true },
});
expect(() =>
store.get({
key,
operation: 'create-giveaway',
giveawayId: 'gw-1',
requestPayload: modifiedPayload,
})
).toThrow(IdempotencyKeyReusedError);
});
it('prevents collision across different operations or giveaways with identical key', () => {
const key = 'shared-key-id';
const payload = { test: 123 };
store.set({
key,
operation: 'operation-A',
giveawayId: 'gw-1',
requestPayload: payload,
statusCode: 200,
body: { op: 'A' },
});
// Lookup under operation-B with same key must return null
const resB = store.get({
key,
operation: 'operation-B',
giveawayId: 'gw-1',
requestPayload: payload,
});
expect(resB).toBeNull();
// Lookup under different giveaway must return null
const resGw2 = store.get({
key,
operation: 'operation-A',
giveawayId: 'gw-2',
requestPayload: payload,
});
expect(resGw2).toBeNull();
});
it('rejects keys exceeding maximum allowed length', () => {
const oversizedKey = 'a'.repeat(129);
expect(() =>
store.get({
key: oversizedKey,
operation: 'op',
requestPayload: {},
})
).toThrow(ValidationError);
});
it('cleans up expired entries proactively', async () => {
const shortTtlStore = new MemoryIdempotencyStore({ defaultTtlMs: 20 });
shortTtlStore.set({
key: 'expiring-key',
operation: 'op',
requestPayload: { a: 1 },
statusCode: 200,
body: { ok: true },
});
expect(shortTtlStore.size()).toBe(1);
await new Promise(r => setTimeout(r, 40));
const res = shortTtlStore.get({
key: 'expiring-key',
operation: 'op',
requestPayload: { a: 1 },
});
expect(res).toBeNull();
});
it('handles bounded capacity with synthetic keys without memory leak', () => {
const boundedStore = new MemoryIdempotencyStore({ maxEntries: 50, defaultTtlMs: 10000 });
for (let i = 0; i < 200; i++) {
boundedStore.set({
key: `synth-key-${i}`,
operation: 'op',
requestPayload: { index: i },
statusCode: 200,
body: { i },
});
}
// Capacity must not exceed maxEntries
expect(boundedStore.size()).toBeLessThanOrEqual(50);
});
});