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
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ REDIS_HOST=localhost
REDIS_PORT=6379
USE_REDIS_CACHE=true

# ─── Token Cleanup ──────────────────────────────────────────────────────────────
# Cron expression for the scheduled cleanup job (default: 3 AM daily).
REFRESH_TOKEN_CLEANUP_CRON="0 3 * * *"

# ─── CORS / Frontend ────────────────────────────────────────────────────────────
# Origin allowed by CORS (frontend URL). Must be a valid URI.
ALLOWED_ORIGIN=http://localhost:5173
Expand Down
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:14
FROM node:20-slim

# Create app directory
WORKDIR /usr/src/app
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cookie-parser": "^1.4.7",
"cron": "^4.3.3",
Comment thread
GitAddRemote marked this conversation as resolved.
"dotenv": "^16.4.5",
"figlet": "^1.8.0",
"helmet": "^8.0.0",
Expand Down
60 changes: 60 additions & 0 deletions backend/src/migrations/1765038000000-AddTokenCleanupIndexes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Adds indexes to support efficient token cleanup queries.
*
* The cleanup job deletes rows matching:
* refresh_tokens: revoked = true OR "expiresAt" < now
* password_resets: used = true OR "expiresAt" < now
*
* Partial indexes on the boolean flags keep index size small by only
* covering the rows that will actually be deleted.
*/
export class AddTokenCleanupIndexes1765038000000 implements MigrationInterface {
name = 'AddTokenCleanupIndexes1765038000000';

public async up(queryRunner: QueryRunner): Promise<void> {
// Partial index on revoked refresh tokens — covers the `revoked = true`
// predicate of the cleanup DELETE's OR condition, keeping the index small
// by only including rows that will eventually be deleted.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_refresh_tokens_revoked"
ON "refresh_tokens" ("revoked")
WHERE "revoked" = TRUE
`);

// Range index for expiry-based cleanup of refresh tokens
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_refresh_tokens_expiresAt"
ON "refresh_tokens" ("expiresAt")
`);

// Partial index on used password resets — covers the `used = true`
// predicate of the cleanup DELETE's OR condition, keeping the index small
// by only including rows that will eventually be deleted.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_password_resets_used"
ON "password_resets" ("used")
WHERE "used" = TRUE
`);

// Range index for expiry-based cleanup of password resets
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_password_resets_expiresAt"
ON "password_resets" ("expiresAt")
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_password_resets_expiresAt"`,
);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_password_resets_used"`);
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_refresh_tokens_expiresAt"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_refresh_tokens_revoked"`,
);
}
}
3 changes: 2 additions & 1 deletion backend/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { TokenCleanupService } from './token-cleanup.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
Expand All @@ -26,7 +27,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
providers: [AuthService, TokenCleanupService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
289 changes: 289 additions & 0 deletions backend/src/modules/auth/token-cleanup.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TokenCleanupService } from './token-cleanup.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { RefreshToken } from './refresh-token.entity';
import { PasswordReset } from './password-reset.entity';
import { SchedulerRegistry } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';

// Helper that mirrors the safe JEST_WORKER_ID restore pattern used throughout
// this file: deletes the variable when the original value was undefined rather
// than assigning the string 'undefined' (Node coerces undefined to 'undefined'
// in process.env, which would leave the guard permanently set).
function restoreWorker(original: string | undefined): void {
if (original === undefined) {
delete process.env['JEST_WORKER_ID'];
} else {
process.env['JEST_WORKER_ID'] = original;
}
}

describe('TokenCleanupService', () => {
let service: TokenCleanupService;

const mockQueryBuilder = {
delete: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn(),
};

const mockRefreshTokenRepository = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
};

const mockPasswordResetRepository = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
};

const mockSchedulerRegistry = {
addCronJob: jest.fn(),
};

const mockConfigService = {
get: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TokenCleanupService,
{
provide: getRepositoryToken(RefreshToken),
useValue: mockRefreshTokenRepository,
},
{
provide: getRepositoryToken(PasswordReset),
useValue: mockPasswordResetRepository,
},
{
provide: SchedulerRegistry,
useValue: mockSchedulerRegistry,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();

service = module.get<TokenCleanupService>(TokenCleanupService);
jest.clearAllMocks();
});

describe('onApplicationBootstrap', () => {
it('should not register cron when NODE_ENV is test', () => {
mockConfigService.get.mockImplementation((key: string) =>
key === 'NODE_ENV' ? 'test' : undefined,
);

service.onApplicationBootstrap();

expect(mockSchedulerRegistry.addCronJob).not.toHaveBeenCalled();
});

it('should not register cron when JEST_WORKER_ID is set', () => {
const originalWorker = process.env['JEST_WORKER_ID'];
process.env['JEST_WORKER_ID'] = '1';
mockConfigService.get.mockReturnValue('development');

service.onApplicationBootstrap();

expect(mockSchedulerRegistry.addCronJob).not.toHaveBeenCalled();
restoreWorker(originalWorker);
});
Comment thread
GitAddRemote marked this conversation as resolved.

it('should not register cron when schedulerRegistry is absent', () => {
// Simulate the @Optional() case: schedulerRegistry is undefined
const serviceWithoutRegistry = new TokenCleanupService(
mockRefreshTokenRepository as any,
mockPasswordResetRepository as any,
mockConfigService as any,
undefined,
);

const originalWorker = process.env['JEST_WORKER_ID'];
delete process.env['JEST_WORKER_ID'];
mockConfigService.get.mockImplementation((key: string) =>
key === 'NODE_ENV' ? 'production' : undefined,
);

serviceWithoutRegistry.onApplicationBootstrap();

expect(mockSchedulerRegistry.addCronJob).not.toHaveBeenCalled();
restoreWorker(originalWorker);
});

it('should register cron with default expression in non-test env', () => {
const originalWorker = process.env['JEST_WORKER_ID'];
delete process.env['JEST_WORKER_ID'];
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'NODE_ENV') return 'production';
return undefined; // REFRESH_TOKEN_CLEANUP_CRON not set → fallback
});

service.onApplicationBootstrap();

expect(mockSchedulerRegistry.addCronJob).toHaveBeenCalledWith(
'tokenCleanup',
expect.any(Object),
);
restoreWorker(originalWorker);
});

it('should register cron with custom expression from config', () => {
const originalWorker = process.env['JEST_WORKER_ID'];
delete process.env['JEST_WORKER_ID'];
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'NODE_ENV') return 'production';
if (key === 'REFRESH_TOKEN_CLEANUP_CRON') return '0 2 * * *';
return undefined;
});

service.onApplicationBootstrap();

expect(mockSchedulerRegistry.addCronJob).toHaveBeenCalledWith(
'tokenCleanup',
expect.any(Object),
);
restoreWorker(originalWorker);
});

it('should fall back to default cron expression when config value is invalid', () => {
const originalWorker = process.env['JEST_WORKER_ID'];
delete process.env['JEST_WORKER_ID'];
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'NODE_ENV') return 'production';
if (key === 'REFRESH_TOKEN_CLEANUP_CRON') return 'not-a-valid-cron';
return undefined;
});

// Should not throw, and should still register the job with the fallback
expect(() => service.onApplicationBootstrap()).not.toThrow();
expect(mockSchedulerRegistry.addCronJob).toHaveBeenCalledWith(
'tokenCleanup',
expect.any(Object),
);
restoreWorker(originalWorker);
});

it('should treat blank REFRESH_TOKEN_CLEANUP_CRON as unset and use default', () => {
const originalWorker = process.env['JEST_WORKER_ID'];
delete process.env['JEST_WORKER_ID'];
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'NODE_ENV') return 'production';
if (key === 'REFRESH_TOKEN_CLEANUP_CRON') return ' '; // blank/whitespace
return undefined;
});

service.onApplicationBootstrap();

expect(mockSchedulerRegistry.addCronJob).toHaveBeenCalledWith(
'tokenCleanup',
expect.any(Object),
);
restoreWorker(originalWorker);
});
});

describe('cleanupExpiredTokens', () => {
it('should return early in test environment without touching the database', async () => {
// Explicitly set NODE_ENV to 'test' for a deterministic guard check
mockConfigService.get.mockImplementation((key: string) =>
key === 'NODE_ENV' ? 'test' : undefined,
);

await service.cleanupExpiredTokens();

expect(
mockRefreshTokenRepository.createQueryBuilder,
).not.toHaveBeenCalled();
expect(
mockPasswordResetRepository.createQueryBuilder,
).not.toHaveBeenCalled();
});

it('should return early when JEST_WORKER_ID is set without touching the database', async () => {
const originalWorker = process.env['JEST_WORKER_ID'];
process.env['JEST_WORKER_ID'] = '1';
mockConfigService.get.mockImplementation((key: string) =>
key === 'NODE_ENV' ? 'development' : undefined,
);

await service.cleanupExpiredTokens();

expect(
mockRefreshTokenRepository.createQueryBuilder,
).not.toHaveBeenCalled();
expect(
mockPasswordResetRepository.createQueryBuilder,
).not.toHaveBeenCalled();
restoreWorker(originalWorker);
});

describe('in a non-test environment', () => {
let originalWorker: string | undefined;

beforeEach(() => {
originalWorker = process.env['JEST_WORKER_ID'];
delete process.env['JEST_WORKER_ID'];
mockConfigService.get.mockImplementation((key: string) =>
key === 'NODE_ENV' ? 'development' : undefined,
);
mockQueryBuilder.execute
.mockResolvedValueOnce({ affected: 3 })
.mockResolvedValueOnce({ affected: 1 });
});

afterEach(() => {
restoreWorker(originalWorker);
});
Comment thread
GitAddRemote marked this conversation as resolved.

it('should delete revoked and expired refresh tokens', async () => {
await service.cleanupExpiredTokens();

expect(
mockRefreshTokenRepository.createQueryBuilder,
).toHaveBeenCalled();
const [whereClause, params] = mockQueryBuilder.where.mock.calls[0];
expect(whereClause).toContain('revoked');
expect(whereClause).toContain('"expiresAt"');
expect(params).toMatchObject({ revoked: true, now: expect.any(Date) });
});

it('should delete used and expired password resets', async () => {
await service.cleanupExpiredTokens();

expect(
mockPasswordResetRepository.createQueryBuilder,
).toHaveBeenCalled();
const [whereClause, params] = mockQueryBuilder.where.mock.calls[1];
expect(whereClause).toContain('used');
expect(whereClause).toContain('"expiresAt"');
expect(params).toMatchObject({ used: true, now: expect.any(Date) });
});

it('should not throw when a query fails', async () => {
mockQueryBuilder.execute.mockReset();
mockQueryBuilder.execute.mockRejectedValueOnce(new Error('DB failure'));

await expect(service.cleanupExpiredTokens()).resolves.toBeUndefined();
});

it('should still clean up password resets when refresh token cleanup fails', async () => {
mockQueryBuilder.execute.mockReset();
mockQueryBuilder.execute
.mockRejectedValueOnce(new Error('refresh token DB failure'))
.mockResolvedValueOnce({ affected: 2 });

await expect(service.cleanupExpiredTokens()).resolves.toBeUndefined();
// Both query builders should have been invoked despite the first failure
expect(
mockRefreshTokenRepository.createQueryBuilder,
).toHaveBeenCalled();
expect(
mockPasswordResetRepository.createQueryBuilder,
).toHaveBeenCalled();
});
});
});
});
Loading
Loading