Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/components/ui/config/redis.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as dotenv from 'dotenv';

// Ensure environment variables are loaded
dotenv.config();

export const redisConfig = {
// Redis Server Connection Details
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0', 10),

/**
* Configurable duplicate tracking window Time-To-Live (TTL).
* Dictates how long an eventId will be preserved in the Redis set
* to catch flaky client network re-tries.
* Default: 86400 seconds (24 hours)
*/
eventIdTtl: parseInt(process.env.EVENT_ID_TTL_SECONDS || '86400', 10),
};
39 changes: 39 additions & 0 deletions src/components/ui/services/idempotency.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { Redis } from 'ioredis';
import { redisConfig } from '../config/redis.config';

@Injectable()
export class IdempotencyService {
private redisClient: Redis;

constructor() {
this.redisClient = new Redis({
host: redisConfig.host,
port: redisConfig.port,
});
}

/**
* Checks if an eventId has already been seen for a specific device.
* If unseen, tracks it with a configurable TTL and returns false.
* If seen, returns true (signaling a duplicate replay event).
*/
async isDuplicateEvent(deviceId: string, eventId: string): Promise<boolean> {
const redisKey = `device:${deviceId}:event_ids`;

// Check if the eventId is already part of the device's Redis Set
const isMember = await this.redisClient.sismember(redisKey, eventId);

if (isMember === 1) {
return true; // Duplicate caught
}

// Add eventId to the set and refresh/set the configurable TTL window expiration
await this.redisClient.multi()
.sadd(redisKey, eventId)
.expire(redisKey, redisConfig.eventIdTtl)
.exec();

return false; // Distinct legitimate event
}
}
50 changes: 50 additions & 0 deletions src/controllers/event.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Controller, Post, Body, HttpCode, HttpStatus, Logger, BadRequestException } from '@nestjs/common';
import { IdempotencyService } from '../services/idempotency.service';

interface InboundEventDto {
eventId: string;
deviceId: string;
payload: any;
}

@Controller('events')
export class EventController {
private readonly logger = new Logger(EventController.name);

constructor(private readonly idempotencyService: IdempotencyService) {}

@Post()
@HttpCode(HttpStatus.OK)
async ingestEvent(@Body() eventDto: InboundEventDto) {
const { eventId, deviceId, payload } = eventDto;

// Fast-fail if required validation elements are completely missing from the request body
if (!eventId || !deviceId) {
throw new BadRequestException('Missing structural requirements: eventId and deviceId are mandatory.');
}

// Intercept event handling stream with our check-and-set Redis guardrail
const isDuplicate = await this.idempotencyService.isDuplicateEvent(deviceId, eventId);

if (isDuplicate) {
this.logger.warn(`Replay protection triggered: Dropped duplicate eventId "${eventId}" from device "${deviceId}"`);

// Return an 'ignored' status acknowledgment back to the client.
// Using an HTTP 200 OK prevents flaky client networks from continuously retrying an already cached event.
return {
status: 'ignored',
reason: 'duplicate',
eventId
};
}

this.logger.log(`Processing legitimate distinct event: ${eventId} for device: ${deviceId}`);

// ... Place your downstream event processing, persistence, or message delivery hooks here ...

return {
status: 'processed',
eventId
};
}
}
44 changes: 44 additions & 0 deletions src/services/__tests__/idempotency.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { IdempotencyService } from '../idempotency.service';

// Mock ioredis execution flows entirely
vi.mock('ioredis', () => {
return {
Redis: vi.fn().mockImplementation(() => {
const db = new Set<string>();
return {
sismember: vi.fn().mockImplementation(async (key, val) => db.has(`${key}:${val}`) ? 1 : 0),
multi: vi.fn().mockReturnValue({
sadd: vi.fn().mockReturnThis(),
expire: vi.fn().mockReturnThis(),
exec: vi.fn().mockImplementation(async function(this: any) {
db.add(`device:mock-device:event_ids:mock-event-123`);
return [];
}),
}),
};
}),
};
});

describe('Idempotency Replay Protection Spec', () => {
let service: IdempotencyService;

beforeEach(() => {
service = new IdempotencyService();
});

it('should accept distinct legitimate events and return false', async () => {
const isDuplicate = await service.isDuplicateEvent('mock-device', 'mock-event-123');
expect(isDuplicate).toBe(false);
});

it('should identify and reject re-sent eventId within the configured window', async () => {
// First tracking execution passes
await service.isDuplicateEvent('mock-device', 'mock-event-123');

// Immediate subsequent duplicate replay check yields true
const isDuplicateAgain = await service.isDuplicateEvent('mock-device', 'mock-event-123');
expect(isDuplicateAgain).toBe(true);
});
});
Loading