Skip to content

Commit f7739e0

Browse files
authored
Merge pull request #19 from DevanshuNEU/fix/issue-6-websocket-auth
Fix/issue 6 websocket auth
2 parents aa403e7 + dc566e6 commit f7739e0

5 files changed

Lines changed: 198 additions & 64 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ dist/
1111
# Environment
1212
.env
1313
.env.local
14+
.env.dev
15+
.env.prod
1416
backend/.env
1517
backend/.env.local
18+
frontend/.env.local
1619
mcp-server/.env
1720
mcp-server/.env.local
1821

@@ -30,3 +33,4 @@ backend/repos/
3033

3134
# MCP Server
3235
mcp-server/__pycache__/
36+
*.code-workspace

Makefile

Lines changed: 59 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,76 +5,93 @@ help:
55
@echo "CodeIntel - Development Commands"
66
@echo ""
77
@echo "Local Development:"
8-
@echo " make dev - Start all services with hot reload"
9-
@echo " make prod - Start production-like environment"
10-
@echo " make build - Build Docker images"
8+
@echo " make dev - Start local dev (uses .env.dev)"
9+
@echo " make dev-prod - Test prod config locally (uses .env.prod)"
1110
@echo " make stop - Stop all services"
1211
@echo " make clean - Stop and remove all containers/volumes"
1312
@echo " make logs - View all logs"
13+
@echo " make health - Check service health"
1414
@echo ""
1515
@echo "Testing:"
1616
@echo " make test - Run backend tests"
17-
@echo " make test-watch - Run tests in watch mode"
17+
@echo " make test-ws - Run WebSocket auth tests only"
1818
@echo " make coverage - Run tests with coverage report"
1919
@echo ""
2020
@echo "Deployment:"
2121
@echo " make deploy-backend - Deploy backend to Railway"
2222
@echo " make deploy-frontend - Deploy frontend to Vercel"
23-
@echo " make deploy-all - Deploy both backend and frontend"
2423

25-
# Development with hot reload
24+
# ============================================
25+
# LOCAL DEVELOPMENT
26+
# ============================================
27+
28+
# Development with .env.dev
2629
dev:
27-
docker compose -f docker-compose.dev.yml up -d
30+
@echo "🚀 Starting LOCAL DEV environment..."
31+
@cp .env.dev .env
32+
docker compose up -d --build
2833
@echo ""
2934
@echo "✅ Development environment started!"
3035
@echo " Backend: http://localhost:8000"
31-
@echo " Docs: http://localhost:8000/docs"
36+
@echo " API Docs: http://localhost:8000/docs"
3237
@echo " Frontend: http://localhost:3000"
3338
@echo " Redis: localhost:6379"
3439
@echo ""
3540
@echo "View logs: make logs"
3641

37-
# Production-like environment
38-
prod:
39-
docker compose up -d
42+
# Test production config locally (uses .env.prod)
43+
dev-prod:
44+
@echo "🚀 Starting LOCAL environment with PROD config..."
45+
@cp .env.prod .env
46+
docker compose up -d --build
4047
@echo ""
41-
@echo "Production environment started!"
48+
@echo "Prod-config environment started!"
4249
@echo " Backend: http://localhost:8000"
4350
@echo " Frontend: http://localhost:3000"
4451

45-
# Build images
46-
build:
47-
docker compose build
48-
4952
# Stop services
5053
stop:
5154
docker compose down
52-
docker compose -f docker-compose.dev.yml down
55+
@echo "✅ Services stopped"
5356

5457
# Clean everything (including volumes)
5558
clean:
56-
docker compose down -v
57-
docker compose -f docker-compose.dev.yml down -v
59+
docker compose down -v --remove-orphans
5860
@echo "✅ Cleaned all containers and volumes"
5961

6062
# View logs
6163
logs:
6264
docker compose logs -f
6365

64-
# Run backend tests
66+
# Logs for specific service
67+
logs-backend:
68+
docker compose logs -f backend
69+
70+
logs-frontend:
71+
docker compose logs -f frontend
72+
73+
# ============================================
74+
# TESTING
75+
# ============================================
76+
77+
# Run all backend tests
6578
test:
66-
cd backend && python -m pytest tests/ -v
79+
cd backend && python3 -m pytest tests/ -v --no-cov
6780

68-
# Run tests in watch mode
69-
test-watch:
70-
cd backend && python -m pytest tests/ -v --looponfail
81+
# Run WebSocket auth tests only
82+
test-ws:
83+
cd backend && python3 -m pytest tests/test_websocket_auth.py -v --no-cov
7184

7285
# Run tests with coverage
7386
coverage:
74-
cd backend && python -m pytest tests/ --cov=. --cov-report=html --cov-report=term
87+
cd backend && python3 -m pytest tests/ --cov=. --cov-report=html --cov-report=term
7588
@echo ""
7689
@echo "Coverage report: backend/htmlcov/index.html"
7790

91+
# ============================================
92+
# DEPLOYMENT
93+
# ============================================
94+
7895
# Deploy backend to Railway
7996
deploy-backend:
8097
@echo "🚀 Deploying backend to Railway..."
@@ -91,15 +108,16 @@ deploy-frontend:
91108
deploy-all: deploy-backend deploy-frontend
92109
@echo "✅ All services deployed!"
93110

94-
# Quick restart of backend (for dev)
95-
restart-backend:
96-
docker compose restart backend
97-
@echo "✅ Backend restarted"
111+
# ============================================
112+
# UTILITIES
113+
# ============================================
98114

99-
# Quick restart of frontend (for dev)
100-
restart-frontend:
101-
docker compose restart frontend
102-
@echo "✅ Frontend restarted"
115+
# Check service health
116+
health:
117+
@echo "Checking services..."
118+
@curl -s http://localhost:8000/health | python3 -m json.tool 2>/dev/null || echo "❌ Backend not responding"
119+
@curl -s -o /dev/null -w "" http://localhost:3000 && echo "✅ Frontend is up" || echo "❌ Frontend not responding"
120+
@docker compose exec -T redis redis-cli ping 2>/dev/null | grep -q PONG && echo "✅ Redis is up" || echo "❌ Redis not responding"
103121

104122
# Shell into backend container
105123
shell-backend:
@@ -109,27 +127,12 @@ shell-backend:
109127
shell-redis:
110128
docker compose exec redis redis-cli
111129

112-
# View Redis stats
113-
redis-stats:
114-
docker compose exec redis redis-cli INFO
115-
116-
# Check service health
117-
health:
118-
@echo "Checking services..."
119-
@curl -s http://localhost:8000/health | python -m json.tool || echo "❌ Backend not responding"
120-
@curl -s http://localhost:3000 > /dev/null && echo "✅ Frontend is up" || echo "❌ Frontend not responding"
121-
@docker compose exec redis redis-cli ping > /dev/null && echo "✅ Redis is up" || echo "❌ Redis not responding"
122-
123-
# Install dependencies (local dev without Docker)
124-
install-backend:
125-
cd backend && pip install -r requirements.txt
126-
127-
install-frontend:
128-
cd frontend && npm install
129-
130-
# Run locally without Docker
131-
run-backend-local:
132-
cd backend && uvicorn main:app --reload --port 8000
130+
# Quick rebuild backend only
131+
rebuild-backend:
132+
docker compose up -d --build backend
133+
@echo "✅ Backend rebuilt and restarted"
133134

134-
run-frontend-local:
135-
cd frontend && npm run dev
135+
# Quick rebuild frontend only
136+
rebuild-frontend:
137+
docker compose up -d --build frontend
138+
@echo "✅ Frontend rebuilt and restarted"

backend/main.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -180,19 +180,56 @@ async def add_repository(
180180
raise HTTPException(status_code=400, detail=str(e))
181181

182182

183+
async def authenticate_websocket(websocket: WebSocket) -> Optional[dict]:
184+
"""
185+
Authenticate WebSocket connection via query parameter token.
186+
187+
WebSockets can't use Authorization headers during handshake,
188+
so we pass the JWT token as a query parameter instead.
189+
190+
Returns:
191+
User dict if authenticated, None otherwise (connection closed with error)
192+
"""
193+
token = websocket.query_params.get("token")
194+
if not token:
195+
await websocket.close(code=4001, reason="Missing authentication token")
196+
return None
197+
198+
try:
199+
from services.auth import get_auth_service
200+
auth_service = get_auth_service()
201+
return auth_service.verify_jwt(token)
202+
except Exception:
203+
await websocket.close(code=4001, reason="Invalid or expired token")
204+
return None
205+
206+
183207
@app.websocket("/ws/index/{repo_id}")
184208
async def websocket_index(websocket: WebSocket, repo_id: str):
185-
"""Real-time indexing with progress updates"""
209+
"""
210+
Real-time repository indexing with progress updates.
211+
212+
Requires JWT token passed as query parameter: ?token=<jwt>
213+
Sends progress updates via JSON messages during indexing.
214+
"""
215+
# Authenticate before accepting connection
216+
user = await authenticate_websocket(websocket)
217+
if not user:
218+
return
219+
220+
# TODO: Add repo ownership validation once user_id column exists in repos table
221+
# For now, any authenticated user can index any repo they know the ID of
222+
223+
# Validate repo exists before accepting connection
224+
repo = repo_manager.get_repo(repo_id)
225+
if not repo:
226+
await websocket.close(code=4004, reason="Repository not found")
227+
return
228+
229+
# Connection authenticated and repo valid - accept
186230
await websocket.accept()
187231

188232
try:
189-
# Get repo info
190-
repo = repo_manager.get_repo(repo_id)
191-
if not repo:
192-
await websocket.send_json({"error": "Repository not found"})
193-
await websocket.close()
194-
return
195-
196233
repo_manager.update_status(repo_id, "indexing")
197234

198235
# Index with progress callback

backend/pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ testpaths = tests
33
python_files = test_*.py
44
python_classes = Test*
55
python_functions = test_*
6+
asyncio_mode = auto
67
addopts =
78
-v
89
--tb=short
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
WebSocket Authentication Tests
3+
Tests for issue #6: Secure WebSocket endpoints
4+
"""
5+
import pytest
6+
from unittest.mock import MagicMock, AsyncMock, patch
7+
8+
9+
class TestWebSocketAuthentication:
10+
"""Integration tests for WebSocket authentication via query parameter token"""
11+
12+
def test_websocket_rejects_missing_token(self, client):
13+
"""WebSocket should reject connections without token (4001)"""
14+
with pytest.raises(Exception):
15+
with client.websocket_connect("/ws/index/test-repo-id"):
16+
pass
17+
18+
def test_websocket_rejects_invalid_token(self, client):
19+
"""WebSocket should reject connections with invalid token (4001)"""
20+
with pytest.raises(Exception):
21+
with client.websocket_connect("/ws/index/test-repo-id?token=invalid-token"):
22+
pass
23+
24+
def test_websocket_rejects_nonexistent_repo(self, client):
25+
"""WebSocket should reject if repo doesn't exist (4004)"""
26+
with patch('main.authenticate_websocket') as mock_auth:
27+
mock_auth.return_value = {"user_id": "test-user", "email": "test@example.com"}
28+
29+
with pytest.raises(Exception):
30+
with client.websocket_connect("/ws/index/nonexistent-repo?token=valid"):
31+
pass
32+
33+
34+
class TestAuthenticateWebsocketFunction:
35+
"""Unit tests for the authenticate_websocket helper"""
36+
37+
@pytest.mark.asyncio
38+
async def test_returns_none_without_token(self):
39+
"""Should return None and close connection if no token provided"""
40+
from main import authenticate_websocket
41+
42+
mock_ws = MagicMock()
43+
mock_ws.query_params = {}
44+
mock_ws.close = AsyncMock()
45+
46+
result = await authenticate_websocket(mock_ws)
47+
48+
assert result is None
49+
mock_ws.close.assert_called_once_with(code=4001, reason="Missing authentication token")
50+
51+
@pytest.mark.asyncio
52+
async def test_returns_none_with_invalid_token(self):
53+
"""Should return None and close connection if token is invalid"""
54+
from main import authenticate_websocket
55+
56+
mock_ws = MagicMock()
57+
mock_ws.query_params = {"token": "invalid-token"}
58+
mock_ws.close = AsyncMock()
59+
60+
with patch('services.auth.get_auth_service') as mock_get_service:
61+
mock_service = MagicMock()
62+
mock_service.verify_jwt.side_effect = Exception("Invalid token")
63+
mock_get_service.return_value = mock_service
64+
65+
result = await authenticate_websocket(mock_ws)
66+
67+
assert result is None
68+
mock_ws.close.assert_called_once_with(code=4001, reason="Invalid or expired token")
69+
70+
@pytest.mark.asyncio
71+
async def test_returns_user_with_valid_token(self):
72+
"""Should return user dict if token is valid"""
73+
from main import authenticate_websocket
74+
75+
mock_ws = MagicMock()
76+
mock_ws.query_params = {"token": "valid-jwt-token"}
77+
mock_ws.close = AsyncMock()
78+
79+
expected_user = {"user_id": "user-123", "email": "test@example.com"}
80+
81+
with patch('services.auth.get_auth_service') as mock_get_service:
82+
mock_service = MagicMock()
83+
mock_service.verify_jwt.return_value = expected_user
84+
mock_get_service.return_value = mock_service
85+
86+
result = await authenticate_websocket(mock_ws)
87+
88+
assert result == expected_user
89+
mock_ws.close.assert_not_called()

0 commit comments

Comments
 (0)