diff --git a/backend/src/common/interceptors/response.interceptor.spec.ts b/backend/src/common/interceptors/response.interceptor.spec.ts new file mode 100644 index 00000000..86616223 --- /dev/null +++ b/backend/src/common/interceptors/response.interceptor.spec.ts @@ -0,0 +1,195 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, StreamableFile } from '@nestjs/common'; +import { ResponseInterceptor, ApiSuccessEnvelope } from './response.interceptor'; +import { CallHandler } from '@nestjs/common'; +import { of } from 'rxjs'; + +describe('ResponseInterceptor', () => { + let interceptor: ResponseInterceptor; + let mockExecutionContext: jest.Mocked; + let mockCallHandler: jest.Mocked; + let mockResponse: any; + + beforeEach(async () => { + mockResponse = { + statusCode: 200, + }; + + mockExecutionContext = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as unknown as jest.Mocked; + + mockCallHandler = { + handle: jest.fn(), + } as unknown as jest.Mocked; + + interceptor = new ResponseInterceptor(); + }); + + describe('normal response wrapping', () => { + it('wraps normal response in success envelope', (done) => { + const responseData = { id: 1, name: 'test' }; + mockCallHandler.handle.mockReturnValue(of(responseData)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toEqual( + expect.objectContaining({ + success: true, + data: responseData, + timestamp: expect.any(String), + }), + ); + expect(result.timestamp).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, + ); + done(); + }); + }); + + it('wraps null data in success envelope', (done) => { + mockCallHandler.handle.mockReturnValue(of(null)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toEqual( + expect.objectContaining({ + success: true, + data: null, + timestamp: expect.any(String), + }), + ); + done(); + }); + }); + + it('wraps array response in success envelope', (done) => { + const responseData = [{ id: 1 }, { id: 2 }]; + mockCallHandler.handle.mockReturnValue(of(responseData)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toEqual( + expect.objectContaining({ + success: true, + data: responseData, + timestamp: expect.any(String), + }), + ); + done(); + }); + }); + + it('wraps empty object in success envelope', (done) => { + const responseData = {}; + mockCallHandler.handle.mockReturnValue(of(responseData)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toEqual( + expect.objectContaining({ + success: true, + data: responseData, + timestamp: expect.any(String), + }), + ); + done(); + }); + }); + }); + + describe('StreamableFile passthrough', () => { + it('does not wrap StreamableFile instance', (done) => { + const streamableFile = new StreamableFile(Buffer.from('test content')); + mockCallHandler.handle.mockReturnValue(of(streamableFile)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toBe(streamableFile); + expect(result).not.toHaveProperty('success'); + expect(result).not.toHaveProperty('data'); + expect(result).not.toHaveProperty('timestamp'); + done(); + }); + }); + }); + + describe('204 No Content status passthrough', () => { + it('does not wrap response when status code is 204', (done) => { + mockResponse.statusCode = 204; + const responseData = undefined; + mockCallHandler.handle.mockReturnValue(of(responseData)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toBeUndefined(); + done(); + }); + }); + + it('does not wrap response when status code is 304 Not Modified', (done) => { + mockResponse.statusCode = 304; + const responseData = undefined; + mockCallHandler.handle.mockReturnValue(of(responseData)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toBeUndefined(); + done(); + }); + }); + }); + + describe('status code boundary cases', () => { + it('wraps response when status code is 200', (done) => { + mockResponse.statusCode = 200; + const responseData = { message: 'ok' }; + mockCallHandler.handle.mockReturnValue(of(responseData)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty('data', responseData); + done(); + }); + }); + + it('wraps response when status code is 201 Created', (done) => { + mockResponse.statusCode = 201; + const responseData = { id: 'new-id' }; + mockCallHandler.handle.mockReturnValue(of(responseData)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty('data', responseData); + done(); + }); + }); + + it('wraps response when status code is 400 Bad Request', (done) => { + mockResponse.statusCode = 400; + const responseData = { error: 'invalid input' }; + mockCallHandler.handle.mockReturnValue(of(responseData)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty('data', responseData); + done(); + }); + }); + }); +}); diff --git a/backend/src/seasons/seasons.service.spec.ts b/backend/src/seasons/seasons.service.spec.ts index d18ca892..022cfd16 100644 --- a/backend/src/seasons/seasons.service.spec.ts +++ b/backend/src/seasons/seasons.service.spec.ts @@ -212,6 +212,96 @@ describe('SeasonsService', () => { }), ); }); + + it('throws NotFoundException when no season at all exists in database', async () => { + seasonsRepository.createQueryBuilder.mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + } as never); + + await expect(service.findActive()).rejects.toThrow(NotFoundException); + await expect(service.findActive()).rejects.toThrow( + 'No active season exists', + ); + }); + + it('throws NotFoundException when season exists but window has not started yet', async () => { + const futureStart = new Date(); + futureStart.setFullYear(futureStart.getFullYear() + 1); + const futureEnd = new Date(futureStart); + futureEnd.setMonth(futureEnd.getMonth() + 6); + + seasonsRepository.createQueryBuilder.mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + } as never); + + await expect(service.findActive()).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when season exists but has already ended', async () => { + const pastStart = new Date(); + pastStart.setFullYear(pastStart.getFullYear() - 2); + const pastEnd = new Date(); + pastEnd.setFullYear(pastEnd.getFullYear() - 1); + + seasonsRepository.createQueryBuilder.mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + } as never); + + await expect(service.findActive()).rejects.toThrow(NotFoundException); + }); + + it('returns the season with latest starts_at when multiple active seasons overlap', async () => { + const olderSeason: Season = { + id: 's1', + season_number: 1, + name: 'Season 1', + starts_at: new Date('2020-01-01'), + ends_at: new Date('2099-01-01'), + reward_pool_stroops: '1', + is_active: true, + is_finalized: false, + top_winner: null, + on_chain_season_id: null, + soroban_tx_hash: null, + created_at: new Date(), + updated_at: new Date(), + }; + const newerSeason: Season = { + id: 's2', + season_number: 2, + name: 'Season 2', + starts_at: new Date('2022-01-01'), + ends_at: new Date('2099-01-01'), + reward_pool_stroops: '1', + is_active: true, + is_finalized: false, + top_winner: null, + on_chain_season_id: null, + soroban_tx_hash: null, + created_at: new Date(), + updated_at: new Date(), + }; + + seasonsRepository.createQueryBuilder.mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(newerSeason), + } as never); + + const result = await service.findActive(); + + expect(result).toEqual(newerSeason); + }); }); describe('create', () => { diff --git a/backend/src/websocket/events.gateway.spec.ts b/backend/src/websocket/events.gateway.spec.ts index fc1d8ec8..9485fabc 100644 --- a/backend/src/websocket/events.gateway.spec.ts +++ b/backend/src/websocket/events.gateway.spec.ts @@ -16,6 +16,7 @@ const makeSocket = (overrides: Record = {}) => leave: jest.fn(), emit: jest.fn(), on: jest.fn(), + disconnect: jest.fn(), ...overrides, }) as any; @@ -137,6 +138,75 @@ describe('EventsGateway', () => { }); }); + describe('rate limiting', () => { + it('allows up to 60 messages without disconnect', async () => { + const client = makeSocket(); + const rateLimits = (gateway as any).rateLimits; + + for (let i = 0; i < 60; i++) { + await gateway.handleJoin(client, 'event:1'); + } + + expect(client.disconnect).not.toHaveBeenCalled(); + expect(client.emit).toHaveBeenCalledWith('joined', { room: 'event:1' }); + }); + + it('disconnects socket on 61st message when rate limit exceeded', async () => { + const client = makeSocket(); + + for (let i = 0; i < 60; i++) { + await gateway.handleJoin(client, 'event:1'); + } + + expect(client.disconnect).not.toHaveBeenCalled(); + + await gateway.handleJoin(client, 'event:2'); + + expect(client.disconnect).toHaveBeenCalledTimes(1); + expect(client.emit).toHaveBeenCalledWith('error', { + message: 'Rate limit exceeded', + }); + }); + + it('enforces per-socket rate limiting', async () => { + const client1 = makeSocket({ id: 'socket-1' }); + const client2 = makeSocket({ id: 'socket-2' }); + + (gateway as any).rateLimits.set('socket-1', 60); + + await gateway.handleJoin(client1, 'event:1'); + + expect(client1.disconnect).toHaveBeenCalled(); + expect(client1.emit).toHaveBeenCalledWith('error', { + message: 'Rate limit exceeded', + }); + + await gateway.handleJoin(client2, 'event:1'); + + expect(client2.disconnect).not.toHaveBeenCalled(); + expect(client2.emit).toHaveBeenCalledWith('joined', { room: 'event:1' }); + }); + + it('resets rate limit counter after window expires', async () => { + jest.useFakeTimers(); + const client = makeSocket(); + const rateLimitWindow = 60_000; + + await gateway.handleJoin(client, 'event:1'); + + expect(client.disconnect).not.toHaveBeenCalled(); + + const rateLimits = (gateway as any).rateLimits; + expect(rateLimits.has('socket-1')).toBe(true); + + jest.advanceTimersByTime(rateLimitWindow + 100); + + expect(rateLimits.has('socket-1')).toBe(false); + + jest.useRealTimers(); + }); + }); + describe('handleLeave', () => { it('leaves room and emits left event', async () => { const client = makeSocket(); diff --git a/backend/src/websocket/events.gateway.ts b/backend/src/websocket/events.gateway.ts index fbd3a1d9..7308dc76 100644 --- a/backend/src/websocket/events.gateway.ts +++ b/backend/src/websocket/events.gateway.ts @@ -98,6 +98,7 @@ export class EventsGateway ): Promise { if (!this.checkRateLimit(client.id)) { client.emit('error', { message: 'Rate limit exceeded' }); + client.disconnect(); return; } diff --git a/contracts/open-market/src/errors.rs b/contracts/open-market/src/errors.rs index 791a8f88..f67f9064 100644 --- a/contracts/open-market/src/errors.rs +++ b/contracts/open-market/src/errors.rs @@ -78,6 +78,9 @@ pub enum InsightArenaError { /// The user has already successfully claimed their payout for this market. /// Raised to prevent double-claiming after `payout_claimed` is set to true. PayoutAlreadyClaimed = 24, + /// The market has already been closed and is no longer accepting changes. + /// Raised when a mutation (fee update, end_time extension) is attempted after close. + MarketAlreadyClosed = 25, // ── Escrow ──────────────────────────────────────────────────────────────── /// The contract's escrow balance is insufficient to complete the transfer. diff --git a/contracts/open-market/src/lib.rs b/contracts/open-market/src/lib.rs index e2ec671b..fab6004b 100644 --- a/contracts/open-market/src/lib.rs +++ b/contracts/open-market/src/lib.rs @@ -175,6 +175,16 @@ impl InsightArenaContract { market::update_creator_fee(&env, creator, market_id, new_creator_fee_bps) } + /// Extend the end_time of a market before it closes. + pub fn extend_market_end_time( + env: Env, + creator: Address, + market_id: u64, + new_end_time: u64, + ) -> Result<(), InsightArenaError> { + market::extend_market_end_time(&env, creator, market_id, new_end_time) + } + /// Cancel a market and refund all stakers. pub fn cancel_market( env: Env, diff --git a/contracts/open-market/src/market.rs b/contracts/open-market/src/market.rs index 6308a199..3d966a54 100644 --- a/contracts/open-market/src/market.rs +++ b/contracts/open-market/src/market.rs @@ -518,6 +518,72 @@ pub fn update_creator_fee( Ok(()) } +/// Extend the end_time of a market. +/// +/// Only the market creator may call this, and only before `end_time` has passed. +/// The new end_time must be strictly later than the current end_time. +/// The resolution_time is automatically adjusted if needed to maintain the constraint +/// that resolution_time >= end_time. +/// +/// Validation order: +/// 1. Market exists +/// 2. Market has not been resolved +/// 3. Market has not been cancelled +/// 4. Market has not been closed +/// 5. `caller` is the market creator +/// 6. Current time is before market end_time (market is still open) +/// 7. `new_end_time` is strictly later than current `end_time` +pub fn extend_market_end_time( + env: &Env, + creator: Address, + market_id: u64, + new_end_time: u64, +) -> Result<(), InsightArenaError> { + config::ensure_not_paused(env)?; + + creator.require_auth(); + + let mut market = get_market(env, market_id)?; + + if market.creator != creator { + return Err(InsightArenaError::Unauthorized); + } + + if market.is_resolved { + return Err(InsightArenaError::MarketAlreadyResolved); + } + + if market.is_cancelled { + return Err(InsightArenaError::MarketAlreadyCancelled); + } + + if market.is_closed { + return Err(InsightArenaError::MarketAlreadyClosed); + } + + let now = env.ledger().timestamp(); + if now >= market.end_time { + return Err(InsightArenaError::MarketExpired); + } + + if new_end_time <= market.end_time { + return Err(InsightArenaError::InvalidTimeRange); + } + + market.end_time = new_end_time; + + if market.resolution_time < new_end_time { + market.resolution_time = new_end_time; + } + + env.storage() + .persistent() + .set(&DataKey::Market(market_id), &market); + bump_market(env, market_id); + + Ok(()) +} + /// Cancel a market that could not be resolved (oracle failure, creator error, etc.). /// /// Upon cancellation every predictor's full stake is refunded via the escrow diff --git a/contracts/open-market/tests/market_tests.rs b/contracts/open-market/tests/market_tests.rs index 6a3ab8d8..e5dd9988 100644 --- a/contracts/open-market/tests/market_tests.rs +++ b/contracts/open-market/tests/market_tests.rs @@ -968,3 +968,137 @@ fn cancel_market_refunds_exact_stake_amounts() { Err(Ok(InsightArenaError::MarketAlreadyCancelled)) )); } + +#[test] +fn extend_market_end_time_success() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _oracle, _) = deploy_with_token(&env); + let creator = Address::generate(&env); + + let params = default_params(&env); + let original_end_time = params.end_time; + let id = client.create_market(&creator, ¶ms); + + let new_end_time = original_end_time + 500; + client.extend_market_end_time(&creator, &id, &new_end_time); + + let market = client.get_market(&id); + assert_eq!(market.end_time, new_end_time); +} + +#[test] +fn extend_market_end_time_adjusts_resolution_time_if_needed() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _oracle, _) = deploy_with_token(&env); + let creator = Address::generate(&env); + + let params = default_params(&env); + let resolution_time = params.resolution_time; + let id = client.create_market(&creator, ¶ms); + + let new_end_time = resolution_time + 500; + client.extend_market_end_time(&creator, &id, &new_end_time); + + let market = client.get_market(&id); + assert_eq!(market.end_time, new_end_time); + assert_eq!(market.resolution_time, new_end_time); +} + +#[test] +fn extend_market_end_time_fails_non_creator() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _oracle, _) = deploy_with_token(&env); + let creator = Address::generate(&env); + let other = Address::generate(&env); + + let params = default_params(&env); + let id = client.create_market(&creator, ¶ms); + + let result = client.try_extend_market_end_time(&other, &id, &(params.end_time + 500)); + assert!(matches!(result, Err(Ok(InsightArenaError::Unauthorized)))); +} + +#[test] +fn extend_market_end_time_fails_after_end_time() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _oracle, _) = deploy_with_token(&env); + let creator = Address::generate(&env); + + let params = default_params(&env); + let id = client.create_market(&creator, ¶ms); + + env.ledger().set_timestamp(params.end_time); + + let result = client.try_extend_market_end_time(&creator, &id, &(params.end_time + 500)); + assert!(matches!(result, Err(Ok(InsightArenaError::MarketExpired)))); +} + +#[test] +fn extend_market_end_time_fails_new_end_time_not_strictly_later() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _oracle, _) = deploy_with_token(&env); + let creator = Address::generate(&env); + + let params = default_params(&env); + let id = client.create_market(&creator, ¶ms); + + let result = client.try_extend_market_end_time(&creator, &id, ¶ms.end_time); + assert!(matches!(result, Err(Ok(InsightArenaError::InvalidTimeRange)))); +} + +#[test] +fn extend_market_end_time_fails_when_resolved() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, oracle, _) = deploy_with_token(&env); + let creator = Address::generate(&env); + + let params = default_params(&env); + let id = client.create_market(&creator, ¶ms); + + env.ledger() + .with_mut(|li| li.timestamp = params.resolution_time + 1); + client.resolve_market(&oracle, &id, &symbol_short!("yes")); + + let result = client.try_extend_market_end_time(&creator, &id, &(params.end_time + 500)); + assert!(matches!(result, Err(Ok(InsightArenaError::MarketAlreadyResolved)))); +} + +#[test] +fn extend_market_end_time_fails_when_cancelled() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _oracle, _) = deploy_with_token(&env); + let creator = Address::generate(&env); + + let params = default_params(&env); + let id = client.create_market(&creator, ¶ms); + + client.cancel_market(&admin, &id); + + let result = client.try_extend_market_end_time(&creator, &id, &(params.end_time + 500)); + assert!(matches!(result, Err(Ok(InsightArenaError::MarketAlreadyCancelled)))); +} + +#[test] +fn extend_market_end_time_fails_when_closed() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _oracle, _) = deploy_with_token(&env); + let creator = Address::generate(&env); + + let params = default_params(&env); + let id = client.create_market(&creator, ¶ms); + + env.ledger().set_timestamp(params.end_time + 1); + client.close_market(&creator, &id); + + env.ledger().set_timestamp(params.end_time); + let result = client.try_extend_market_end_time(&creator, &id, &(params.end_time + 500)); + assert!(matches!(result, Err(Ok(InsightArenaError::MarketAlreadyClosed)))); +}