Skip to content
Merged
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
15 changes: 10 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
node_modules
contributoNote.md
node_modules/
**/node_modules/
dist/
**/dist/

# Rust build artifacts
contract/target/
contract/Cargo.lock
contracts/target/
# contracts/Cargo.lock <-- Keep this committed for reproducibility

.DS_Store
.DS_Store
*.log
.env
.env.local
24 changes: 24 additions & 0 deletions backend/src/progress/controllers/progress.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
Expand All @@ -17,11 +19,14 @@ import { paginationQueryDto } from '../../common/pagination/paginationQueryDto';
import { GetProgressHistoryProvider } from '../providers/get-progress-history.provider';
import { GetCategoryStatsProvider } from '../providers/get-category-stats.provider';
import { GetOverallStatsProvider } from '../providers/get-overall-stats.provider';
import { ProgressService } from '../progress.service';
import { SubmitAnswerDto } from '../dtos/submit-answer.dto';
import { PaginatedProgressDto } from '../dtos/paginated-progress.dto';
import { CategoryStatsDto } from '../dtos/category-stats.dto';
import { OverallStatsDto } from '../dtos/overall-stats.dto';
import { ActiveUser } from '../../auth/decorators/activeUser.decorator';
import { ActiveUserData } from '../../auth/interfaces/activeInterface';
import { Throttle } from '@nestjs/throttler';

@Controller('progress')
@ApiTags('Progress')
Expand All @@ -32,8 +37,27 @@ export class ProgressController {
private readonly getProgressHistoryProvider: GetProgressHistoryProvider,
private readonly getCategoryStatsProvider: GetCategoryStatsProvider,
private readonly getOverallStatsProvider: GetOverallStatsProvider,
private readonly progressService: ProgressService,
) {}

@Post('submit')
@Throttle({ default: { limit: 5, ttl: 60000 } })
@ApiOperation({ summary: 'Submit a puzzle answer' })
@ApiResponse({ status: 200, description: 'Answer processed successfully' })
async submitAnswer(
@ActiveUser() user: ActiveUserData,
@Body() submitAnswerDto: SubmitAnswerDto,
) {
if (!user || !user.sub) {
throw new BadRequestException('User not found');
}

// Ensure the userId in DTO matches the authenticated user
submitAnswerDto.userId = user.sub;

return this.progressService.submitAnswer(submitAnswerDto);
}

@Get()
@ApiOperation({
summary: 'Get paginated progress history',
Expand Down
130 changes: 130 additions & 0 deletions backend/src/progress/progress-security.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ProgressCalculationProvider } from './providers/progress-calculation.provider';
import { Puzzle } from '../puzzles/entities/puzzle.entity';
import { UserProgress } from './entities/progress.entity';
import { XpLevelService } from '../users/providers/xp-level.service';
import { User } from '../users/user.entity';
import { DailyQuest } from '../quests/entities/daily-quest.entity';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm';

import { PuzzleDifficulty } from '../puzzles/enums/puzzle-difficulty.enum';

describe('ProgressCalculationProvider Security', () => {
let provider: ProgressCalculationProvider;
let puzzleRepository: Repository<Puzzle>;
let userProgressRepository: Repository<UserProgress>;
let userRepository: Repository<User>;

const mockPuzzle: Partial<Puzzle> = {
id: 'puzzle-1',
correctAnswer: 'correct',
difficulty: PuzzleDifficulty.BEGINNER,
timeLimit: 60,
};

const mockUser: Partial<User> = {
id: 'user-1',
xp: 0,
level: 1,
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ProgressCalculationProvider,
{
provide: getRepositoryToken(Puzzle),
useValue: {
findOne: jest.fn().mockResolvedValue(mockPuzzle),
},
},
{
provide: getRepositoryToken(UserProgress),
useValue: {
findOne: jest.fn(),
create: jest.fn().mockReturnValue({}),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn().mockResolvedValue(mockUser),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(DailyQuest),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
},
},
{
provide: XpLevelService,
useValue: {
addXp: jest.fn(),
},
},
],
}).compile();

provider = module.get<ProgressCalculationProvider>(ProgressCalculationProvider);
puzzleRepository = module.get(getRepositoryToken(Puzzle));
userProgressRepository = module.get(getRepositoryToken(UserProgress));
userRepository = module.get(getRepositoryToken(User));
});

it('should prevent double-rewarding for the same puzzle', async () => {
// Mock that a correct attempt already exists
jest.spyOn(userProgressRepository, 'findOne').mockResolvedValueOnce({
isCorrect: true,
pointsEarned: 50,
} as any);

const result = await provider.processAnswerSubmission({
userId: 'user-1',
puzzleId: 'puzzle-1',
categoryId: 'cat-1',
userAnswer: 'correct',
timeSpent: 10,
});

expect(result.validation.pointsEarned).toBe(0);
expect(result.validation.isCorrect).toBe(true);
});

it('should prevent rapid duplicate submissions (10s window)', async () => {
// Mock that no correct attempt exists, but a recent attempt does
jest.spyOn(userProgressRepository, 'findOne')
.mockResolvedValueOnce(null) // existingCorrectAttempt
.mockResolvedValueOnce({ attemptedAt: new Date() } as any); // recentAttempt

await expect(provider.processAnswerSubmission({
userId: 'user-1',
puzzleId: 'puzzle-1',
categoryId: 'cat-1',
userAnswer: 'any',
timeSpent: 10,
})).rejects.toThrow(BadRequestException);
});

it('should award points for first correct submission', async () => {
jest.spyOn(userProgressRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(userProgressRepository, 'create').mockReturnValue({ id: 1 } as any);
jest.spyOn(userProgressRepository, 'save').mockResolvedValue({ id: 1 } as any);

const result = await provider.processAnswerSubmission({
userId: 'user-1',
puzzleId: 'puzzle-1',
categoryId: 'cat-1',
userAnswer: 'correct',
timeSpent: 10,
});

expect(result.validation.isCorrect).toBe(true);
expect(result.validation.pointsEarned).toBeGreaterThan(0);
});
});
17 changes: 0 additions & 17 deletions backend/src/progress/progress.controller.ts

This file was deleted.

36 changes: 29 additions & 7 deletions backend/src/progress/providers/progress-calculation.provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, MoreThan, Repository } from 'typeorm';
import { Puzzle } from '../../puzzles/entities/puzzle.entity';
Expand Down Expand Up @@ -109,25 +109,47 @@ export class ProgressCalculationProvider {
where: { id: submitAnswerDto.puzzleId },
});

// In processAnswerSubmission, check for recent duplicate:
// 1. Check for any existing correct attempt by this user for THIS puzzle
// (Prevents double-rewarding/XP exploitation)
const existingCorrectAttempt = await this.userProgressRepository.findOne({
where: {
userId: submitAnswerDto.userId,
puzzleId: submitAnswerDto.puzzleId,
isCorrect: true,
},
});

if (existingCorrectAttempt) {
// If already solved, return success but with 0 additional points
return {
userProgress: existingCorrectAttempt,
validation: {
isCorrect: true,
pointsEarned: 0,
normalizedAnswer: submitAnswerDto.userAnswer.trim().toLowerCase(),
},
};
}

// 2. Check for very recent attempts (idempotency / spam prevention)
const recentAttempt = await this.userProgressRepository.findOne({
where: {
userId: submitAnswerDto.userId,
puzzleId: submitAnswerDto.puzzleId,
attemptedAt: MoreThan(new Date(Date.now() - 5000)), // 5 second window
attemptedAt: MoreThan(new Date(Date.now() - 10000)), // 10 second window
},
});

if (recentAttempt) {
throw new BadRequestException('Duplicate submission detected. Please wait 10 seconds.');
}

if (!puzzle) {
throw new NotFoundException(
`Puzzle with ID ${submitAnswerDto.puzzleId} not found`,
);
}

if (recentAttempt) {
throw new Error('Duplicate submission detected');
}

// Validate answer
const validation = this.validateAnswer(
submitAnswerDto.userAnswer,
Expand Down
20 changes: 10 additions & 10 deletions backend/src/puzzles/controllers/puzzles.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Controller, Post, Body, Get, Query, Param } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { PuzzlesService } from '../providers/puzzles.service';
import { CreatePuzzleDto } from '../dtos/create-puzzle.dto';
import { Puzzle } from '../entities/puzzle.entity';
import { PuzzleResponseDto } from '../dtos/puzzle-response.dto';
import { PuzzleQueryDto } from '../dtos/puzzle-query.dto';

@Controller('puzzles')
Expand All @@ -15,7 +15,7 @@ export class PuzzlesController {
@ApiResponse({
status: 201,
description: 'Puzzle created successfully',
type: Puzzle,
type: PuzzleResponseDto,
})
@ApiResponse({
status: 400,
Expand All @@ -25,25 +25,25 @@ export class PuzzlesController {
status: 500,
description: 'Internal server error',
})
async create(@Body() createPuzzleDto: CreatePuzzleDto): Promise<Puzzle> {
async create(@Body() createPuzzleDto: CreatePuzzleDto): Promise<PuzzleResponseDto> {
return this.puzzlesService.create(createPuzzleDto);
}

@ApiOperation({ summary: 'Get daily quest puzzles' })
@ApiResponse({
status: 200,
description: 'Daily quest puzzles retrieved successfully',
type: Puzzle,
type: PuzzleResponseDto,
isArray: true,
})
@Get('daily-quest')
getDailyQuest() {
getDailyQuest(): Promise<PuzzleResponseDto[]> {
return this.puzzlesService.getDailyQuestPuzzles();
}
@ApiOperation({ summary: 'Get all puzzles' })
@ApiResponse({
status: 201,
description: 'Puzzle retrieved successfully',
type: Puzzle,
status: 200,
description: 'Puzzles retrieved successfully',
})
@Get()
findAll(@Query() query: PuzzleQueryDto) {
Expand All @@ -54,10 +54,10 @@ export class PuzzlesController {
@ApiResponse({
status: 200,
description: 'Puzzle retrieved successfully',
type: Puzzle,
type: PuzzleResponseDto,
})
@Get(':id')
getById(@Param('id') id: string) {
getById(@Param('id') id: string): Promise<PuzzleResponseDto> {
return this.puzzlesService.getPuzzleById(id);
}
}
34 changes: 34 additions & 0 deletions backend/src/puzzles/dtos/puzzle-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { PuzzleDifficulty } from '../enums/puzzle-difficulty.enum';

export class PuzzleResponseDto {
@ApiProperty({ description: 'The unique identifier of the puzzle' })
id: string;

@ApiProperty({ description: 'The puzzle question' })
question: string;

@ApiProperty({ description: 'The options for the puzzle', type: [String] })
options: string[];

@ApiProperty({ description: 'The difficulty level of the puzzle', enum: PuzzleDifficulty })
difficulty: PuzzleDifficulty;

@ApiProperty({ description: 'The ID of the category this puzzle belongs to' })
categoryId: string;

@ApiProperty({ description: 'The points awarded for solving the puzzle' })
points: number;

@ApiProperty({ description: 'The time limit for the puzzle in seconds' })
timeLimit: number;

@ApiProperty({ description: 'The explanation for the puzzle answer', required: false })
explanation?: string;

@ApiProperty({ description: 'The date and time the puzzle was created' })
createdAt: Date;

@ApiProperty({ description: 'The date and time the puzzle was last updated' })
updatedAt: Date;
}
Loading
Loading