Skip to content

Commit 86aa96b

Browse files
committed
Major fixes
1 parent 02501b0 commit 86aa96b

26 files changed

Lines changed: 1704 additions & 375 deletions

.dockerignore

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.git
2+
.github
3+
node_modules
4+
**/node_modules
5+
apps/web/.next
6+
apps/web/tsconfig.tsbuildinfo
7+
apps/web/next-env.d.ts
8+
apps/web/pnpm-lock.yaml
9+
apps/web/pnpm-workspace.yaml
10+
apps/api/venv
11+
apps/api/.pytest_cache
12+
apps/api/.ruff_cache
13+
apps/api/.coverage
14+
apps/api/data
15+
data
16+
Documents
17+
*.log

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
- name: Install pnpm
5252
uses: pnpm/action-setup@v3
5353
with:
54-
version: 9
54+
version: 10.28.2
5555

5656
- name: Setup Node.js
5757
uses: actions/setup-node@v4
@@ -66,7 +66,7 @@ jobs:
6666
run: pnpm --filter web lint
6767

6868
- name: Type Check
69-
run: pnpm --filter web tsc --noEmit
69+
run: pnpm --filter web type-check
7070

7171
- name: Run Tests
7272
run: pnpm --filter web test

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,4 @@ htmlcov/
8383
*.tmp
8484
.cache/
8585
Documents/
86-
repos/
86+
/repos/

apps/api/src/api/routes/learning.py

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from fastapi import APIRouter, Depends, HTTPException
44
from pydantic import BaseModel
5+
from sqlalchemy.orm import Session
56

6-
from src.dependencies import get_gamification_service, get_learning_service
7+
from src.dependencies import get_db, get_gamification_service, get_learning_service
78
from src.models.codetour_schemas import CodeTour
89
from src.models.learning import LessonContent, Persona, Syllabus
910
from src.services.gamification import GamificationService, UserStats
1011
from src.services.learning_service import LearningService
12+
from src.services.challenges import ChallengeService
1113

1214
router = APIRouter(tags=["learning"])
1315

@@ -262,15 +264,12 @@ async def generate_challenge(
262264
repo_id: str,
263265
lesson_id: str,
264266
request: GenerateChallengeRequest,
265-
learning_service: LearningService = Depends(get_learning_service)
267+
learning_service: LearningService = Depends(get_learning_service),
268+
db: Session = Depends(get_db),
266269
):
267270
"""Generate an interactive challenge for a lesson."""
268-
from src.dependencies import get_db
269-
from src.services.challenges import ChallengeService
270-
271271
try:
272272
# Create challenge service with LLM from learning service
273-
db = next(get_db())
274273
challenge_service = ChallengeService(db, learning_service._llm)
275274

276275
challenge = await challenge_service.generate_challenge(
@@ -295,21 +294,20 @@ class ValidateBugHuntRequest(BaseModel):
295294
async def validate_bug_hunt(
296295
repo_id: str,
297296
request: ValidateBugHuntRequest,
298-
gamification: GamificationService = Depends(get_gamification_service)
297+
gamification: GamificationService = Depends(get_gamification_service),
298+
db: Session = Depends(get_db),
299299
):
300300
"""Validate a bug hunt challenge answer."""
301-
from src.dependencies import get_db
302-
from src.services.challenges import ChallengeService
303-
304301
try:
305-
db = next(get_db())
306302
challenge_service = ChallengeService(db)
307303
result = challenge_service.validate_bug_hunt(request.challenge, request.selected_line)
308304

309305
# Award XP if correct
310306
if result["correct"]:
311307
xp_gain = gamification.record_challenge_complete(repo_id, request.used_hint)
312308
result["xp_gained"] = xp_gain.model_dump()
309+
result["xp_earned"] = xp_gain.amount + xp_gain.bonus
310+
result["stats"] = gamification.get_user_stats(repo_id).model_dump()
313311

314312
return result
315313
except Exception as e:
@@ -326,21 +324,20 @@ class ValidateCodeTraceRequest(BaseModel):
326324
async def validate_code_trace(
327325
repo_id: str,
328326
request: ValidateCodeTraceRequest,
329-
gamification: GamificationService = Depends(get_gamification_service)
327+
gamification: GamificationService = Depends(get_gamification_service),
328+
db: Session = Depends(get_db),
330329
):
331330
"""Validate a code trace challenge answer."""
332-
from src.dependencies import get_db
333-
from src.services.challenges import ChallengeService
334-
335331
try:
336-
db = next(get_db())
337332
challenge_service = ChallengeService(db)
338333
result = challenge_service.validate_code_trace(request.challenge, request.selected_index)
339334

340335
# Award XP if correct
341336
if result["correct"]:
342337
xp_gain = gamification.record_challenge_complete(repo_id, request.used_hint)
343338
result["xp_gained"] = xp_gain.model_dump()
339+
result["xp_earned"] = xp_gain.amount + xp_gain.bonus
340+
result["stats"] = gamification.get_user_stats(repo_id).model_dump()
344341

345342
return result
346343
except Exception as e:
@@ -357,21 +354,20 @@ class ValidateFillBlankRequest(BaseModel):
357354
async def validate_fill_blank(
358355
repo_id: str,
359356
request: ValidateFillBlankRequest,
360-
gamification: GamificationService = Depends(get_gamification_service)
357+
gamification: GamificationService = Depends(get_gamification_service),
358+
db: Session = Depends(get_db),
361359
):
362360
"""Validate a fill in the blank challenge answer."""
363-
from src.dependencies import get_db
364-
from src.services.challenges import ChallengeService
365-
366361
try:
367-
db = next(get_db())
368362
challenge_service = ChallengeService(db)
369363
result = challenge_service.validate_fill_blank(request.challenge, request.answers)
370364

371365
# Award XP if correct
372366
if result["correct"]:
373367
xp_gain = gamification.record_challenge_complete(repo_id, request.used_hint)
374368
result["xp_gained"] = xp_gain.model_dump()
369+
result["xp_earned"] = xp_gain.amount + xp_gain.bonus
370+
result["stats"] = gamification.get_user_stats(repo_id).model_dump()
375371

376372
return result
377373
except Exception as e:

apps/api/src/core/github/repo_manager.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,18 +152,24 @@ async def cleanup_local_repo(self, local_path: str):
152152

153153
async def get_file_content(self, owner: str, name: str, file_path: str) -> str:
154154
"""Read content of a specific file in the repository."""
155+
requested_path = Path(file_path)
156+
if requested_path.is_absolute():
157+
raise ValueError(f"Invalid file path: {file_path}")
158+
155159
repo_root = self.get_local_path(owner, name).resolve()
156-
target_path = (repo_root / file_path).resolve()
160+
target_path = (repo_root / requested_path).resolve()
157161

158162
# Security check: Ensure target is within repo root
159-
if not str(target_path).startswith(str(repo_root)):
163+
try:
164+
target_path.relative_to(repo_root)
165+
except ValueError:
160166
raise ValueError(f"Invalid file path: {file_path}")
161167

162168
if not target_path.exists():
163169
raise FileNotFoundError(f"File not found: {file_path}")
164170

165171
if not target_path.is_file():
166-
raise ValueError(f"Path is not a file: {file_path}")
172+
raise ValueError(f"Path is not a file: {file_path}")
167173

168174
# Read file with utf-8, ignoring errors
169175
with open(target_path, "r", encoding="utf-8", errors="ignore") as f:

apps/api/src/services/challenges.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ async def generate_challenge(
9797
prompt = self._build_challenge_prompt(challenge_type, context, code_references)
9898

9999
try:
100-
response = await self._llm.generate(prompt)
100+
messages = [
101+
{"role": "system", "content": "You are a challenge generator. Output valid JSON only."},
102+
{"role": "user", "content": prompt},
103+
]
104+
response = await self._llm.generate(messages)
101105
challenge_data = self._parse_challenge_response(response, challenge_type)
102106

103107
return {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from unittest.mock import MagicMock
2+
3+
from src.dependencies import get_db, get_gamification_service
4+
from src.services.gamification import LevelInfo, StreakInfo, UserStats, XPGain
5+
6+
7+
class DummyGamificationService:
8+
def __init__(self):
9+
self.record_calls = []
10+
11+
def record_challenge_complete(self, repo_id: str, used_hint: bool) -> XPGain:
12+
self.record_calls.append((repo_id, used_hint))
13+
if used_hint:
14+
return XPGain(amount=75, reason="challenge_complete")
15+
return XPGain(amount=150, reason="challenge_perfect")
16+
17+
def get_user_stats(self, repo_id: str) -> UserStats:
18+
return UserStats(
19+
total_xp=225,
20+
level=LevelInfo(
21+
level=2,
22+
title="Explorer",
23+
icon="🔍",
24+
current_xp=225,
25+
xp_for_next_level=500,
26+
xp_progress=0.1,
27+
),
28+
streak=StreakInfo(current=2, longest=3, active_today=True),
29+
lessons_completed=1,
30+
quizzes_passed=1,
31+
challenges_completed=2,
32+
perfect_quizzes=0,
33+
)
34+
35+
36+
def test_validate_bug_hunt_returns_backend_scoring_payload(client):
37+
dummy_service = DummyGamificationService()
38+
mock_db = MagicMock()
39+
40+
app = client.app
41+
app.dependency_overrides[get_db] = lambda: mock_db
42+
app.dependency_overrides[get_gamification_service] = lambda: dummy_service
43+
44+
payload = {
45+
"challenge": {
46+
"data": {
47+
"bug_line": 4,
48+
"bug_description": "Null check missing",
49+
}
50+
},
51+
"selected_line": 4,
52+
"used_hint": True,
53+
}
54+
55+
response = client.post("/api/learning/repo-1/challenges/validate/bug_hunt", json=payload)
56+
57+
assert response.status_code == 200
58+
data = response.json()
59+
assert data["correct"] is True
60+
assert data["xp_earned"] == 75
61+
assert data["xp_gained"]["amount"] == 75
62+
assert data["stats"]["total_xp"] == 225
63+
assert dummy_service.record_calls == [("repo-1", True)]
64+
65+
app.dependency_overrides.clear()
66+
67+
68+
def test_validate_code_trace_incorrect_does_not_award_xp(client):
69+
dummy_service = DummyGamificationService()
70+
mock_db = MagicMock()
71+
72+
app = client.app
73+
app.dependency_overrides[get_db] = lambda: mock_db
74+
app.dependency_overrides[get_gamification_service] = lambda: dummy_service
75+
76+
payload = {
77+
"challenge": {
78+
"data": {
79+
"correct_index": 1,
80+
"options": ["A", "B", "C", "D"],
81+
"explanation": "B is correct",
82+
}
83+
},
84+
"selected_index": 0,
85+
"used_hint": False,
86+
}
87+
88+
response = client.post("/api/learning/repo-2/challenges/validate/code_trace", json=payload)
89+
90+
assert response.status_code == 200
91+
data = response.json()
92+
assert data["correct"] is False
93+
assert data["xp_earned"] == 0
94+
assert "xp_gained" not in data
95+
assert "stats" not in data
96+
assert dummy_service.record_calls == []
97+
98+
app.dependency_overrides.clear()
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from src.config import settings
6+
from src.core.github.repo_manager import RepoManager
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_get_file_content_reads_valid_repo_file(tmp_path, monkeypatch):
11+
repos_root = tmp_path / "repos"
12+
monkeypatch.setattr(settings, "repos_dir", str(repos_root))
13+
14+
owner = "octocat"
15+
repo = "hello-world"
16+
repo_root = repos_root / owner / repo
17+
target = repo_root / "src" / "main.py"
18+
target.parent.mkdir(parents=True, exist_ok=True)
19+
target.write_text("print('hello')", encoding="utf-8")
20+
21+
manager = RepoManager()
22+
content = await manager.get_file_content(owner, repo, "src/main.py")
23+
24+
assert content == "print('hello')"
25+
26+
27+
@pytest.mark.asyncio
28+
async def test_get_file_content_rejects_parent_traversal(tmp_path, monkeypatch):
29+
repos_root = tmp_path / "repos"
30+
monkeypatch.setattr(settings, "repos_dir", str(repos_root))
31+
32+
owner = "octocat"
33+
repo = "hello-world"
34+
repo_root = repos_root / owner / repo
35+
repo_root.mkdir(parents=True, exist_ok=True)
36+
outside = repos_root / owner / "secret.txt"
37+
outside.write_text("do not read", encoding="utf-8")
38+
39+
manager = RepoManager()
40+
with pytest.raises(ValueError, match="Invalid file path"):
41+
await manager.get_file_content(owner, repo, "../secret.txt")
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_get_file_content_rejects_prefix_sibling_escape(tmp_path, monkeypatch):
46+
repos_root = tmp_path / "repos"
47+
monkeypatch.setattr(settings, "repos_dir", str(repos_root))
48+
49+
owner = "octocat"
50+
repo = "hello-world"
51+
repo_root = repos_root / owner / repo
52+
repo_root.mkdir(parents=True, exist_ok=True)
53+
54+
sibling_repo = repos_root / owner / "hello-world-evil"
55+
sibling_target = sibling_repo / "secret.txt"
56+
sibling_target.parent.mkdir(parents=True, exist_ok=True)
57+
sibling_target.write_text("sensitive", encoding="utf-8")
58+
59+
manager = RepoManager()
60+
with pytest.raises(ValueError, match="Invalid file path"):
61+
await manager.get_file_content(owner, repo, "../hello-world-evil/secret.txt")

apps/web/next.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { NextConfig } from "next";
22

33
const nextConfig: NextConfig = {
4-
/* config options here */
4+
output: "standalone",
55
};
66

77
export default nextConfig;

apps/web/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"build": "next build",
88
"start": "next start",
99
"lint": "eslint",
10-
"test": "vitest"
10+
"test": "vitest",
11+
"type-check": "tsc --noEmit"
1112
},
1213
"dependencies": {
1314
"@types/react-syntax-highlighter": "^15.5.13",
@@ -45,4 +46,4 @@
4546
"typescript": "^5",
4647
"vitest": "^4.0.18"
4748
}
48-
}
49+
}

0 commit comments

Comments
 (0)