Alexis Bruneteau d111eccf9a fix: Fix all identified bugs and add comprehensive tests
This commit fixes 5 critical bugs found during code review:

Bug #1 (CRITICAL): Missing API endpoints for election filtering
- Added GET /api/elections/upcoming endpoint
- Added GET /api/elections/completed endpoint
- Both properly filter elections by date

Bug #2 (HIGH): Auth context has_voted state inconsistency
- Backend schemas now include has_voted in LoginResponse and RegisterResponse
- Auth routes return actual has_voted value from database
- Frontend context uses server response instead of hardcoding false
- Frontend API client properly typed with has_voted field

Bug #3 (HIGH): Transaction safety in vote submission
- Simplified error handling in vote submission endpoints
- Now only calls mark_as_voted() once at the end
- Vote response includes voter_marked_voted flag to indicate success
- Ensures consistency even if blockchain submission fails

Bug #4 (MEDIUM): Vote status endpoint
- Verified endpoint already exists at GET /api/votes/status
- Tests confirm proper functionality

Bug #5 (MEDIUM): Response format inconsistency
- Previously fixed in commit e10a882
- Frontend now handles both array and wrapped object formats

Added comprehensive test coverage:
- 20+ backend API tests (tests/test_api_fixes.py)
- 6+ auth context tests (frontend/__tests__/auth-context.test.tsx)
- 8+ elections API tests (frontend/__tests__/elections-api.test.ts)
- 10+ vote submission tests (frontend/__tests__/vote-submission.test.ts)

All fixes ensure frontend and backend communicate consistently.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 18:07:57 +01:00

261 lines
7.8 KiB
Python

"""
Routes pour les élections et les candidats.
Elections are stored immutably in blockchain with cryptographic security:
- SHA-256 hash chain prevents tampering
- RSA-PSS signatures authenticate election data
- Merkle tree verification for candidates
- Complete audit trail on blockchain
"""
from fastapi import APIRouter, HTTPException, status, Depends
from sqlalchemy.orm import Session
from datetime import datetime, timezone
from .. import schemas, services
from ..dependencies import get_db, get_current_voter
from ..models import Voter
from ..blockchain_elections import (
record_election_to_blockchain,
verify_election_in_blockchain,
get_elections_blockchain_data,
)
router = APIRouter(prefix="/api/elections", tags=["elections"])
@router.get("/debug/all")
def debug_all_elections(db: Session = Depends(get_db)):
"""DEBUG: Return all elections with dates for troubleshooting"""
from .. import models
now = datetime.now(timezone.utc)
all_elections = db.query(models.Election).all()
return {
"current_time": now.isoformat(),
"elections": [
{
"id": e.id,
"name": e.name,
"is_active": e.is_active,
"start_date": e.start_date.isoformat() if e.start_date else None,
"end_date": e.end_date.isoformat() if e.end_date else None,
"should_be_active": (
e.start_date <= now <= e.end_date and e.is_active
if e.start_date and e.end_date
else False
),
}
for e in all_elections
],
}
@router.get("/active", response_model=list[schemas.ElectionResponse])
def get_active_elections(db: Session = Depends(get_db)):
"""Récupérer toutes les élections actives en cours"""
from datetime import timedelta
from .. import models
now = datetime.now(timezone.utc)
# Allow 1 hour buffer for timezone issues
start_buffer = now - timedelta(hours=1)
end_buffer = now + timedelta(hours=1)
active = db.query(models.Election).filter(
(models.Election.start_date <= end_buffer) &
(models.Election.end_date >= start_buffer) &
(models.Election.is_active == True)
).order_by(models.Election.id.asc()).all()
return active
@router.get("/upcoming", response_model=list[schemas.ElectionResponse])
def get_upcoming_elections(db: Session = Depends(get_db)):
"""Récupérer toutes les élections à venir"""
from datetime import timedelta
from .. import models
now = datetime.now(timezone.utc)
# Allow 1 hour buffer for timezone issues
end_buffer = now + timedelta(hours=1)
upcoming = db.query(models.Election).filter(
(models.Election.start_date > end_buffer) &
(models.Election.is_active == True)
).order_by(models.Election.start_date.asc()).all()
return upcoming
@router.get("/completed", response_model=list[schemas.ElectionResponse])
def get_completed_elections(db: Session = Depends(get_db)):
"""Récupérer toutes les élections terminées"""
from datetime import timedelta
from .. import models
now = datetime.now(timezone.utc)
# Allow 1 hour buffer for timezone issues
start_buffer = now - timedelta(hours=1)
completed = db.query(models.Election).filter(
(models.Election.end_date < start_buffer) &
(models.Election.is_active == True)
).order_by(models.Election.end_date.desc()).all()
return completed
@router.get("/blockchain")
def get_elections_blockchain():
"""
Retrieve the complete elections blockchain.
Returns all election records stored immutably with cryptographic verification.
Useful for auditing election creation and verifying no tampering occurred.
"""
return get_elections_blockchain_data()
@router.get("/{election_id}/blockchain-verify")
def verify_election_blockchain(election_id: int, db: Session = Depends(get_db)):
"""
Verify an election's blockchain integrity.
Returns verification report:
- hash_valid: Block hash matches computed hash
- chain_valid: Entire chain integrity is valid
- signature_valid: Block is properly signed
- verified: All checks passed
"""
# First verify it exists in database
election = services.ElectionService.get_election(db, election_id)
if not election:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Election not found in database"
)
# Then verify it's in blockchain
verification = verify_election_in_blockchain(election_id)
if not verification.get("verified"):
# Still return data but mark as unverified
return {
**verification,
"warning": "Election blockchain verification failed - possible tampering"
}
return verification
# Routes with path parameters must come AFTER specific routes
@router.get("/active/results")
def get_active_election_results(db: Session = Depends(get_db)):
"""Récupérer les résultats de l'élection active"""
election = services.ElectionService.get_active_election(db)
if not election:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active election"
)
results = services.VoteService.get_election_results(db, election.id)
return results
@router.get("/{election_id}/candidates")
def get_election_candidates(election_id: int, db: Session = Depends(get_db)):
"""Récupérer les candidats d'une élection"""
election = services.ElectionService.get_election(db, election_id)
if not election:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Election not found"
)
return election.candidates
@router.get("/{election_id}/results", response_model=schemas.ElectionResultResponse)
def get_election_results(
election_id: int,
db: Session = Depends(get_db)
):
"""
Récupérer les résultats d'une élection.
Disponible après la fermeture du scrutin.
"""
election = services.ElectionService.get_election(db, election_id)
if not election:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Election not found"
)
if not election.results_published:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Results not yet published"
)
results = services.VoteService.get_election_results(db, election_id)
return schemas.ElectionResultResponse(
election_id=election.id,
election_name=election.name,
total_votes=sum(r.vote_count for r in results),
results=results
)
@router.post("/{election_id}/publish-results")
def publish_results(
election_id: int,
db: Session = Depends(get_db)
):
"""
Publier les résultats d'une élection (admin only).
À utiliser après la fermeture du scrutin.
"""
election = services.ElectionService.get_election(db, election_id)
if not election:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Election not found"
)
# Marquer les résultats comme publiés
election.results_published = True
db.commit()
return {
"message": "Results published successfully",
"election_id": election.id,
"election_name": election.name
}
@router.get("/{election_id}", response_model=schemas.ElectionResponse)
def get_election(election_id: int, db: Session = Depends(get_db)):
"""Récupérer une élection par son ID"""
election = services.ElectionService.get_election(db, election_id)
if not election:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Election not found"
)
return election