diff --git a/README.md b/README.md index 7ad6ef9..ff77b71 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ # Challenge API + +HexaCTF의 Container Control 프로젝트의 일부입니다. +클라이언트로부터 커스텀 리소스인 Challenge를 제어하기 위해 사용되는 Control API입니다. + +> [!NOTE] +> Challenge에 대한 정보는 [Challenge Operator](https://github.com/HexaCTF/challenge-operator)를 참고해주세요. + +## 아키텍쳐 + +[Challenge Operator](https://github.com/HexaCTF/challenge-operator)에서 상태가 변경되면 큐에 상태 메시지를 전송합니다. Challenge API는 큐에 있는 상태 메세지를 얻어 데이터베이스에 값을 저장합니다. + +![시스템 구성도](./imgs/image.png) + +> [!TIP] +> Challenge API 제작에 관한 내용은 [블로그](https://s0okju.github.io/p/hexactf-10/)에 자세히 나와 있습니다. + +## API + +| Endpoint | Method | Description | +| :---------------------------------------------------------: | :----: | :-----------------: | +| [/v1/user-challenges](./md/user-challenge.md) | POST | Challenge 생성 | +| [/v1/user-challenges/delete](./md/user-challenge-delete.md) | POST | Challenge 삭제 | +| [/v1/user-challenges/status](./md/user-challenge-status.md) | GET | Challenge 상태 조회 | diff --git a/app.py b/app.py index 87f9391..07e495b 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,4 @@ - - -from app.factory import create_app +from hexactf.factory import create_app app = create_app() diff --git a/app/__pycache__/__init__.cpython-310.pyc b/app/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 6b7d380..0000000 Binary files a/app/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/app/__pycache__/__init__.cpython-39.pyc b/app/__pycache__/__init__.cpython-39.pyc deleted file mode 100644 index 2a3b394..0000000 Binary files a/app/__pycache__/__init__.cpython-39.pyc and /dev/null differ diff --git a/app/__pycache__/factory.cpython-310.pyc b/app/__pycache__/factory.cpython-310.pyc deleted file mode 100644 index a19f2d6..0000000 Binary files a/app/__pycache__/factory.cpython-310.pyc and /dev/null differ diff --git a/app/__pycache__/factory.cpython-39.pyc b/app/__pycache__/factory.cpython-39.pyc deleted file mode 100644 index 6a79da7..0000000 Binary files a/app/__pycache__/factory.cpython-39.pyc and /dev/null differ diff --git a/app/extensions/db/repository_test.py b/app/extensions/db/repository_test.py deleted file mode 100644 index 9a47d30..0000000 --- a/app/extensions/db/repository_test.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest -from unittest.mock import MagicMock -from app.repositories.user_challenges_repository import UserChallengesRepository -from app.extensions.db.models import UserChallenges - -@pytest.fixture -def mock_session(): - return MagicMock() - -@pytest.fixture -def user_challenges_repo(mock_session): - return UserChallengesRepository(session=mock_session) - -@pytest.fixture -def mock_challenge(): - return UserChallenges(C_idx=1, username="test_user", status="Running", port=8080) - -def test_get_status_running(user_challenges_repo, mock_session, mock_challenge): - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_challenge - result = user_challenges_repo.get_status(challenge_id=1, username="test_user") - assert result == {'status': 'Running', 'port': 8080} - -def test_get_status_not_running(user_challenges_repo, mock_session): - mock_challenge = UserChallenges(C_idx=1, username="test_user", status="Stopped", port=8080) - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_challenge - result = user_challenges_repo.get_status(challenge_id=1, username="test_user") - assert result == {'status': 'Stopped'} - -def test_get_status_not_found(user_challenges_repo, mock_session): - mock_session.query.return_value.filter_by.return_value.first.return_value = None - result = user_challenges_repo.get_status(challenge_id=1, username="nonexistent_user") - assert result is None diff --git a/app/extensions/kafka/__init__.py b/app/extensions/kafka/__init__.py deleted file mode 100644 index dccd3af..0000000 --- a/app/extensions/kafka/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -__version__ = '1.0.0' - -__all__ = [ - 'KafkaEventConsumer', - 'KafkaConfig', -] - -from app.extensions.kafka.config import KafkaConfig -from app.extensions.kafka.consumer import KafkaEventConsumer diff --git a/app/monitoring/__pycache__/__init__.cpython-310.pyc b/app/monitoring/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 2598f91..0000000 Binary files a/app/monitoring/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/app/monitoring/__pycache__/__init__.cpython-39.pyc b/app/monitoring/__pycache__/__init__.cpython-39.pyc deleted file mode 100644 index 8f77c97..0000000 Binary files a/app/monitoring/__pycache__/__init__.cpython-39.pyc and /dev/null differ diff --git a/app/monitoring/__pycache__/async_handler.cpython-39.pyc b/app/monitoring/__pycache__/async_handler.cpython-39.pyc deleted file mode 100644 index 60e9b76..0000000 Binary files a/app/monitoring/__pycache__/async_handler.cpython-39.pyc and /dev/null differ diff --git a/app/monitoring/__pycache__/ctf_metrics_collector.cpython-39.pyc b/app/monitoring/__pycache__/ctf_metrics_collector.cpython-39.pyc deleted file mode 100644 index 8f92e9f..0000000 Binary files a/app/monitoring/__pycache__/ctf_metrics_collector.cpython-39.pyc and /dev/null differ diff --git a/app/monitoring/__pycache__/loki_logger.cpython-39.pyc b/app/monitoring/__pycache__/loki_logger.cpython-39.pyc deleted file mode 100644 index bd26880..0000000 Binary files a/app/monitoring/__pycache__/loki_logger.cpython-39.pyc and /dev/null differ diff --git a/app/__init__.py b/hexactf/__init__.py similarity index 100% rename from app/__init__.py rename to hexactf/__init__.py diff --git a/hexactf/__pycache__/__init__.cpython-310.pyc b/hexactf/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..b6c39ee Binary files /dev/null and b/hexactf/__pycache__/__init__.cpython-310.pyc differ diff --git a/hexactf/__pycache__/config.cpython-310.pyc b/hexactf/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..8e154e6 Binary files /dev/null and b/hexactf/__pycache__/config.cpython-310.pyc differ diff --git a/hexactf/__pycache__/extensions_manager.cpython-310.pyc b/hexactf/__pycache__/extensions_manager.cpython-310.pyc new file mode 100644 index 0000000..5da603f Binary files /dev/null and b/hexactf/__pycache__/extensions_manager.cpython-310.pyc differ diff --git a/hexactf/__pycache__/factory.cpython-310.pyc b/hexactf/__pycache__/factory.cpython-310.pyc new file mode 100644 index 0000000..f493417 Binary files /dev/null and b/hexactf/__pycache__/factory.cpython-310.pyc differ diff --git a/app/api/__init__.py b/hexactf/api/__init__.py similarity index 100% rename from app/api/__init__.py rename to hexactf/api/__init__.py diff --git a/hexactf/api/__pycache__/__init__.cpython-310.pyc b/hexactf/api/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..7f4f927 Binary files /dev/null and b/hexactf/api/__pycache__/__init__.cpython-310.pyc differ diff --git a/hexactf/api/__pycache__/challenge_api.cpython-310.pyc b/hexactf/api/__pycache__/challenge_api.cpython-310.pyc new file mode 100644 index 0000000..47b0b8d Binary files /dev/null and b/hexactf/api/__pycache__/challenge_api.cpython-310.pyc differ diff --git a/app/api/challenge.py b/hexactf/api/challenge_api.py similarity index 87% rename from app/api/challenge.py rename to hexactf/api/challenge_api.py index 0139a4b..efca40c 100644 --- a/app/api/challenge.py +++ b/hexactf/api/challenge_api.py @@ -1,14 +1,12 @@ from json import JSONDecodeError from logging import log -import sys -from app.monitoring.ctf_metrics_collector import ChallengeMetricsCollector from flask import Blueprint, jsonify, request +import yaml -from app.exceptions.api import InvalidRequest -from app.exceptions.userchallenge import UserChallengeCreationError, UserChallengeDeletionError, UserChallengeNotFoundError -from app.extensions.db.repository import UserChallengesRepository -from app.extensions.k8s.client import K8sClient -from app.monitoring.ctf_metrics_collector import challenge_metrics_collector +from hexactf.exceptions.api_exceptions import InvalidRequest +from hexactf.exceptions.userchallenge_exceptions import UserChallengeCreationError, UserChallengeDeletionError, UserChallengeNotFoundError +from hexactf.extensions.db.repository import UserChallengesRepository +from hexactf.extensions.k8s.client import K8sClient challenge_bp = Blueprint('challenge', __name__) @@ -35,6 +33,7 @@ def create_challenge(): return jsonify({'data' : {'port': endpoint}}), 200 + @challenge_bp.route('/delete', methods=['POST']) def delete_userchallenges(): try: @@ -69,7 +68,6 @@ def get_userchallenge_status(): try: # Challenge 관련 정보 가져오기 res = request.get_json() - # print(f"/status : {res}",file=sys.stderr) if not res: raise InvalidRequest(error_msg="Request body is empty or not valid JSON") diff --git a/app/config.py b/hexactf/config.py similarity index 100% rename from app/config.py rename to hexactf/config.py diff --git a/app/exceptions/__init__.py b/hexactf/exceptions/__init__.py similarity index 100% rename from app/exceptions/__init__.py rename to hexactf/exceptions/__init__.py diff --git a/hexactf/exceptions/__pycache__/__init__.cpython-310.pyc b/hexactf/exceptions/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..a2667fa Binary files /dev/null and b/hexactf/exceptions/__pycache__/__init__.cpython-310.pyc differ diff --git a/hexactf/exceptions/__pycache__/api_exceptions.cpython-310.pyc b/hexactf/exceptions/__pycache__/api_exceptions.cpython-310.pyc new file mode 100644 index 0000000..5d33390 Binary files /dev/null and b/hexactf/exceptions/__pycache__/api_exceptions.cpython-310.pyc differ diff --git a/hexactf/exceptions/__pycache__/base_exceptions.cpython-310.pyc b/hexactf/exceptions/__pycache__/base_exceptions.cpython-310.pyc new file mode 100644 index 0000000..3ce3945 Binary files /dev/null and b/hexactf/exceptions/__pycache__/base_exceptions.cpython-310.pyc differ diff --git a/hexactf/exceptions/__pycache__/challenge_exceptions.cpython-310.pyc b/hexactf/exceptions/__pycache__/challenge_exceptions.cpython-310.pyc new file mode 100644 index 0000000..286fe0b Binary files /dev/null and b/hexactf/exceptions/__pycache__/challenge_exceptions.cpython-310.pyc differ diff --git a/hexactf/exceptions/__pycache__/error_types.cpython-310.pyc b/hexactf/exceptions/__pycache__/error_types.cpython-310.pyc new file mode 100644 index 0000000..2cd78f3 Binary files /dev/null and b/hexactf/exceptions/__pycache__/error_types.cpython-310.pyc differ diff --git a/hexactf/exceptions/__pycache__/handlers.cpython-310.pyc b/hexactf/exceptions/__pycache__/handlers.cpython-310.pyc new file mode 100644 index 0000000..286e090 Binary files /dev/null and b/hexactf/exceptions/__pycache__/handlers.cpython-310.pyc differ diff --git a/hexactf/exceptions/__pycache__/kafka_exceptions.cpython-310.pyc b/hexactf/exceptions/__pycache__/kafka_exceptions.cpython-310.pyc new file mode 100644 index 0000000..f986c6f Binary files /dev/null and b/hexactf/exceptions/__pycache__/kafka_exceptions.cpython-310.pyc differ diff --git a/hexactf/exceptions/__pycache__/userchallenge_exceptions.cpython-310.pyc b/hexactf/exceptions/__pycache__/userchallenge_exceptions.cpython-310.pyc new file mode 100644 index 0000000..97d7ab7 Binary files /dev/null and b/hexactf/exceptions/__pycache__/userchallenge_exceptions.cpython-310.pyc differ diff --git a/app/exceptions/api.py b/hexactf/exceptions/api_exceptions.py similarity index 88% rename from app/exceptions/api.py rename to hexactf/exceptions/api_exceptions.py index 48b49c3..8fb667d 100644 --- a/app/exceptions/api.py +++ b/hexactf/exceptions/api_exceptions.py @@ -1,6 +1,6 @@ -from app.exceptions.base import CustomBaseException -from app.exceptions.error_types import ApiErrorTypes +from hexactf.exceptions.base_exceptions import CustomBaseException +from hexactf.exceptions.error_types import ApiErrorTypes class APIException(CustomBaseException): diff --git a/app/exceptions/base.py b/hexactf/exceptions/base_exceptions.py similarity index 94% rename from app/exceptions/base.py rename to hexactf/exceptions/base_exceptions.py index a8c20db..c0d2229 100644 --- a/app/exceptions/base.py +++ b/hexactf/exceptions/base_exceptions.py @@ -1,6 +1,6 @@ # app/utils/exceptions.py -from app.exceptions.error_types import ApiErrorTypes +from hexactf.exceptions.error_types import ApiErrorTypes class CustomBaseException(Exception): """ diff --git a/app/exceptions/challenge.py b/hexactf/exceptions/challenge_exceptions.py similarity index 83% rename from app/exceptions/challenge.py rename to hexactf/exceptions/challenge_exceptions.py index cd6cd45..427982e 100644 --- a/app/exceptions/challenge.py +++ b/hexactf/exceptions/challenge_exceptions.py @@ -1,5 +1,5 @@ -from app.exceptions.base import CustomBaseException -from app.exceptions.error_types import ApiErrorTypes +from hexactf.exceptions.base_exceptions import CustomBaseException +from hexactf.exceptions.error_types import ApiErrorTypes class ChallengeException(CustomBaseException): diff --git a/app/exceptions/error_types.py b/hexactf/exceptions/error_types.py similarity index 100% rename from app/exceptions/error_types.py rename to hexactf/exceptions/error_types.py diff --git a/app/exceptions/handlers.py b/hexactf/exceptions/handlers.py similarity index 86% rename from app/exceptions/handlers.py rename to hexactf/exceptions/handlers.py index 59944cb..d42670b 100644 --- a/app/exceptions/handlers.py +++ b/hexactf/exceptions/handlers.py @@ -1,6 +1,6 @@ from flask import jsonify -from app.exceptions.base import CustomBaseException +from hexactf.exceptions.base_exceptions import CustomBaseException def register_error_handler(app): """CustomBaseException을 처리하는 에러 핸들러""" diff --git a/app/exceptions/kafka.py b/hexactf/exceptions/kafka_exceptions.py similarity index 93% rename from app/exceptions/kafka.py rename to hexactf/exceptions/kafka_exceptions.py index 868907a..fb0bc7b 100644 --- a/app/exceptions/kafka.py +++ b/hexactf/exceptions/kafka_exceptions.py @@ -1,5 +1,5 @@ -from app.exceptions.base import CustomBaseException -from app.exceptions.error_types import ApiErrorTypes +from hexactf.exceptions.base_exceptions import CustomBaseException +from hexactf.exceptions.error_types import ApiErrorTypes class QueueException(CustomBaseException): """Queue(Kafka) 관련 기본 예외""" diff --git a/app/exceptions/userchallenge.py b/hexactf/exceptions/userchallenge_exceptions.py similarity index 93% rename from app/exceptions/userchallenge.py rename to hexactf/exceptions/userchallenge_exceptions.py index fad8544..83da20e 100644 --- a/app/exceptions/userchallenge.py +++ b/hexactf/exceptions/userchallenge_exceptions.py @@ -1,6 +1,6 @@ -from app.exceptions.base import CustomBaseException -from app.exceptions.error_types import ApiErrorTypes +from hexactf.exceptions.base_exceptions import CustomBaseException +from hexactf.exceptions.error_types import ApiErrorTypes class UserChallengeException(CustomBaseException): """UserChallenge 관련 기본 예외""" diff --git a/app/extensions/__init__.py b/hexactf/extensions/__init__.py similarity index 100% rename from app/extensions/__init__.py rename to hexactf/extensions/__init__.py diff --git a/hexactf/extensions/__pycache__/__init__.cpython-310.pyc b/hexactf/extensions/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..7046e17 Binary files /dev/null and b/hexactf/extensions/__pycache__/__init__.cpython-310.pyc differ diff --git a/app/extensions/db/__init__.py b/hexactf/extensions/db/__init__.py similarity index 62% rename from app/extensions/db/__init__.py rename to hexactf/extensions/db/__init__.py index 2457388..db783c9 100644 --- a/app/extensions/db/__init__.py +++ b/hexactf/extensions/db/__init__.py @@ -3,4 +3,4 @@ __all__ = ['MariaDBConfig'] from flask_sqlalchemy import SQLAlchemy -from app.extensions.db.config import MariaDBConfig +from hexactf.extensions.db.config import MariaDBConfig diff --git a/hexactf/extensions/db/__pycache__/__init__.cpython-310.pyc b/hexactf/extensions/db/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..674d459 Binary files /dev/null and b/hexactf/extensions/db/__pycache__/__init__.cpython-310.pyc differ diff --git a/hexactf/extensions/db/__pycache__/config.cpython-310.pyc b/hexactf/extensions/db/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..69fdfb7 Binary files /dev/null and b/hexactf/extensions/db/__pycache__/config.cpython-310.pyc differ diff --git a/hexactf/extensions/db/__pycache__/models.cpython-310.pyc b/hexactf/extensions/db/__pycache__/models.cpython-310.pyc new file mode 100644 index 0000000..07f2619 Binary files /dev/null and b/hexactf/extensions/db/__pycache__/models.cpython-310.pyc differ diff --git a/hexactf/extensions/db/__pycache__/repository.cpython-310.pyc b/hexactf/extensions/db/__pycache__/repository.cpython-310.pyc new file mode 100644 index 0000000..fdc8e49 Binary files /dev/null and b/hexactf/extensions/db/__pycache__/repository.cpython-310.pyc differ diff --git a/app/extensions/db/config.py b/hexactf/extensions/db/config.py similarity index 100% rename from app/extensions/db/config.py rename to hexactf/extensions/db/config.py diff --git a/app/extensions/db/models.py b/hexactf/extensions/db/models.py similarity index 99% rename from app/extensions/db/models.py rename to hexactf/extensions/db/models.py index f29b18a..235e482 100644 --- a/app/extensions/db/models.py +++ b/hexactf/extensions/db/models.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from app.extensions_manager import db +from hexactf.extensions_manager import db from sqlalchemy import ForeignKey from sqlalchemy.orm import relationship diff --git a/app/extensions/db/repository.py b/hexactf/extensions/db/repository.py similarity index 82% rename from app/extensions/db/repository.py rename to hexactf/extensions/db/repository.py index fc48a0a..f2ada3f 100644 --- a/app/extensions/db/repository.py +++ b/hexactf/extensions/db/repository.py @@ -1,13 +1,11 @@ import logging -from sqlite3 import OperationalError -from typing import List, Optional +import os +from typing import Optional from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session, load_only from sqlalchemy.sql import text -from contextlib import contextmanager -from app.exceptions.api import InternalServerError -from app.extensions_manager import db -from app.extensions.db.models import Challenges, UserChallenges +from hexactf.exceptions.api_exceptions import InternalServerError +from hexactf.extensions_manager import db +from hexactf.extensions.db.models import Challenges, UserChallenges class UserChallengesRepository: def __init__(self, session=None): @@ -72,17 +70,14 @@ def update_status(self, challenge: UserChallenges, new_status: str) -> bool: bool: 업데이트 성공 여부 """ try: - db.session.execute(text("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED")) + if os.getenv("TEST_MODE") != "true": + db.session.execute(text("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED")) fresh_challenge = self.session.merge(challenge) self.session.refresh(fresh_challenge) fresh_challenge.status = new_status - # self.session.add(challenge) # Add this line to track the object - # self.session.flush() self.session.commit() return True except SQLAlchemyError as e: - # logger.error(f"Error updating challenge status: {e}") - self.session.rollback() raise InternalServerError(error_msg=f"Error updating challenge status: {e}") from e @@ -139,16 +134,6 @@ def get_status(self, challenge_id, username) -> Optional[dict]: return {'status': challenge.status, 'port': int(challenge.port)} return {'status': challenge.status} -# class ChallengeRepository: -# def __init__(self): -# self.db_session = db.session - -# def get_challenge_name(self, challenge_id: int) -> Optional[str]: -# """챌린지 ID로 챌린지 조회""" -# with self.get_session() as session: -# challenge = session.query(Challenges).get(challenge_id) -# return challenge.title if challenge else None - class ChallengeRepository: @staticmethod diff --git a/app/extensions/k8s/__init__.py b/hexactf/extensions/k8s/__init__.py similarity index 100% rename from app/extensions/k8s/__init__.py rename to hexactf/extensions/k8s/__init__.py diff --git a/hexactf/extensions/k8s/__pycache__/__init__.cpython-310.pyc b/hexactf/extensions/k8s/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..804a337 Binary files /dev/null and b/hexactf/extensions/k8s/__pycache__/__init__.cpython-310.pyc differ diff --git a/hexactf/extensions/k8s/__pycache__/client.cpython-310.pyc b/hexactf/extensions/k8s/__pycache__/client.cpython-310.pyc new file mode 100644 index 0000000..4b80942 Binary files /dev/null and b/hexactf/extensions/k8s/__pycache__/client.cpython-310.pyc differ diff --git a/app/extensions/k8s/client.py b/hexactf/extensions/k8s/client.py similarity index 93% rename from app/extensions/k8s/client.py rename to hexactf/extensions/k8s/client.py index 0eeed02..59e189c 100644 --- a/app/extensions/k8s/client.py +++ b/hexactf/extensions/k8s/client.py @@ -4,10 +4,9 @@ from kubernetes import client, config -from app.exceptions.challenge import ChallengeNotFound -from app.exceptions.userchallenge import UserChallengeCreationError, UserChallengeDeletionError -from app.extensions.db.repository import ChallengeRepository, UserChallengesRepository -from app.monitoring.loki_logger import FlaskLokiLogger +from hexactf.exceptions.challenge_exceptions import ChallengeNotFound +from hexactf.exceptions.userchallenge_exceptions import UserChallengeCreationError, UserChallengeDeletionError +from hexactf.extensions.db.repository import ChallengeRepository, UserChallengesRepository MAX_RETRIES = 3 @@ -148,19 +147,12 @@ def _normalize_k8s_name(self, name: str) -> str: if not name or len(name) > 253: raise ValueError("이름이 비어있거나 길이가 253자를 초과함") - # 1. 소문자로 변환 name = name.lower() - - # 2. 공백 및 비허용 문자 (`[^a-z0-9-]`)를 `-`로 변환 name = re.sub(r'[^a-z0-9-]+', '-', name) - - # 3. 하이픈(-)이 연속적으로 나오면 하나로 줄이기 name = re.sub(r'-+', '-', name) - - # 4. 앞뒤의 하이픈 제거 name = name.strip('-') - # 5. 최종 길이 검사 (1~253자) + # 최종 길이 검사 (1~253자) if not name or len(name) > 253: raise ValueError(f"변환 후에도 유효하지 않은 Kubernetes 리소스 이름: {name}") diff --git a/hexactf/extensions/kafka/__init__.py b/hexactf/extensions/kafka/__init__.py new file mode 100644 index 0000000..04c5eca --- /dev/null +++ b/hexactf/extensions/kafka/__init__.py @@ -0,0 +1,9 @@ +__version__ = '1.0.0' + +__all__ = [ + 'KafkaEventConsumer', + 'KafkaConfig', +] + +from hexactf.extensions.kafka.config import KafkaConfig +from hexactf.extensions.kafka.consumer import KafkaEventConsumer diff --git a/hexactf/extensions/kafka/__pycache__/__init__.cpython-310.pyc b/hexactf/extensions/kafka/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..7f77c3e Binary files /dev/null and b/hexactf/extensions/kafka/__pycache__/__init__.cpython-310.pyc differ diff --git a/hexactf/extensions/kafka/__pycache__/config.cpython-310.pyc b/hexactf/extensions/kafka/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..ae7b145 Binary files /dev/null and b/hexactf/extensions/kafka/__pycache__/config.cpython-310.pyc differ diff --git a/hexactf/extensions/kafka/__pycache__/consumer.cpython-310.pyc b/hexactf/extensions/kafka/__pycache__/consumer.cpython-310.pyc new file mode 100644 index 0000000..1353c0a Binary files /dev/null and b/hexactf/extensions/kafka/__pycache__/consumer.cpython-310.pyc differ diff --git a/hexactf/extensions/kafka/__pycache__/handler.cpython-310.pyc b/hexactf/extensions/kafka/__pycache__/handler.cpython-310.pyc new file mode 100644 index 0000000..ace2d47 Binary files /dev/null and b/hexactf/extensions/kafka/__pycache__/handler.cpython-310.pyc differ diff --git a/app/extensions/kafka/config.py b/hexactf/extensions/kafka/config.py similarity index 100% rename from app/extensions/kafka/config.py rename to hexactf/extensions/kafka/config.py diff --git a/app/extensions/kafka/consumer.py b/hexactf/extensions/kafka/consumer.py similarity index 98% rename from app/extensions/kafka/consumer.py rename to hexactf/extensions/kafka/consumer.py index 2aa288d..bbb8171 100644 --- a/app/extensions/kafka/consumer.py +++ b/hexactf/extensions/kafka/consumer.py @@ -3,7 +3,7 @@ from typing import Any, Dict from kafka import KafkaConsumer import json -from app.exceptions.kafka import QueueProcessingError +from hexactf.exceptions.kafka_exceptions import QueueProcessingError class StatusMessage: diff --git a/app/extensions/kafka/handler.py b/hexactf/extensions/kafka/handler.py similarity index 95% rename from app/extensions/kafka/handler.py rename to hexactf/extensions/kafka/handler.py index bb5fcd0..5d045b5 100644 --- a/app/extensions/kafka/handler.py +++ b/hexactf/extensions/kafka/handler.py @@ -1,7 +1,7 @@ import logging from typing import Any, Dict -from app.exceptions.kafka import QueueProcessingError -from app.extensions.db.repository import UserChallengesRepository +from hexactf.exceptions.kafka_exceptions import QueueProcessingError +from hexactf.extensions.db.repository import UserChallengesRepository logger = logging.getLogger(__name__) diff --git a/app/extensions_manager.py b/hexactf/extensions_manager.py similarity index 98% rename from app/extensions_manager.py rename to hexactf/extensions_manager.py index a07c0d1..07eee54 100644 --- a/app/extensions_manager.py +++ b/hexactf/extensions_manager.py @@ -4,7 +4,7 @@ from typing import Optional, Callable from flask import Flask from flask_sqlalchemy import SQLAlchemy -from app.extensions.kafka import KafkaConfig, KafkaEventConsumer +from hexactf.extensions.kafka import KafkaConfig, KafkaEventConsumer class FlaskKafkaConsumer: """Flask 애플리케이션에서 Kafka 메시지 소비를 관리하는 클래스""" diff --git a/app/factory.py b/hexactf/factory.py similarity index 85% rename from app/factory.py rename to hexactf/factory.py index 884a579..7e8b7c8 100644 --- a/app/factory.py +++ b/hexactf/factory.py @@ -1,21 +1,16 @@ +import os import sys -from requests import Response -from app.monitoring.ctf_metrics_collector import ChallengeMetricsCollector -from app.monitoring.loki_logger import FlaskLokiLogger -from app.monitoring.system_metrics_collector import SystemMetricsCollector +# from hexactf.monitoring.system_metrics_collector import SystemMetricsCollector from flask import Flask, g, request -import threading from datetime import datetime from typing import Any, Dict, Type -from prometheus_client import REGISTRY, generate_latest, CONTENT_TYPE_LATEST -from app.api.challenge import challenge_bp -from app.config import Config -from app.exceptions.base import CustomBaseException -from app.exceptions.handlers import register_error_handler -from app.extensions.kafka.handler import MessageHandler -from app.extensions_manager import kafka_consumer, db +from hexactf.api.challenge_api import challenge_bp +from hexactf.config import Config +from hexactf.exceptions.base_exceptions import CustomBaseException +from hexactf.extensions.kafka.handler import MessageHandler +from hexactf.extensions_manager import kafka_consumer, db def start_kafka_consumer(app): """Start Kafka consumer in a separate thread""" @@ -26,15 +21,22 @@ class FlaskApp: def __init__(self, config_class: Type[Config] = Config): self.app = Flask(__name__) self.app.config.from_object(config_class) + self.logger = FlaskLokiLogger(app_name="challenge-api", loki_url=self.app.config['LOKI_URL']).logger + + if os.getenv("TEST_MODE") != "true": + from hexactf.monitoring.loki_logger import FlaskLokiLogger + self.logger = FlaskLokiLogger(app_name="challenge-api", loki_url=self.app.config['LOKI_URL']).logger + else: + self.logger = self.app.logger # Use Flask default logger # 초기 설정 self._init_extensions() self._setup_middleware() self._register_error_handlers() self._setup_blueprints() - self._init_metrics_collector() - + # self._init_metrics_collector() + def _init_extensions(self): """Extensions 초기화""" # Kafka 초기화 @@ -46,11 +48,12 @@ def _init_extensions(self): with self.app.app_context(): db.create_all() - def _init_metrics_collector(self): + # @NOTE : 추후 제거 예정 + # def _init_metrics_collector(self): - # System 메트릭 수집기 초기화 - system_collector = SystemMetricsCollector(self.app) - system_collector.start_collecting() + # # System 메트릭 수집기 초기화 + # system_collector = SystemMetricsCollector(self.app) + # system_collector.start_collecting() def _setup_middleware(self): diff --git a/app/monitoring/__init__.py b/hexactf/monitoring/__init__.py similarity index 100% rename from app/monitoring/__init__.py rename to hexactf/monitoring/__init__.py diff --git a/hexactf/monitoring/__pycache__/__init__.cpython-310.pyc b/hexactf/monitoring/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..b714999 Binary files /dev/null and b/hexactf/monitoring/__pycache__/__init__.cpython-310.pyc differ diff --git a/hexactf/monitoring/__pycache__/async_handler.cpython-310.pyc b/hexactf/monitoring/__pycache__/async_handler.cpython-310.pyc new file mode 100644 index 0000000..dbe3f71 Binary files /dev/null and b/hexactf/monitoring/__pycache__/async_handler.cpython-310.pyc differ diff --git a/app/monitoring/__pycache__/ctf_metrics_collector.cpython-310.pyc b/hexactf/monitoring/__pycache__/ctf_metrics_collector.cpython-310.pyc similarity index 90% rename from app/monitoring/__pycache__/ctf_metrics_collector.cpython-310.pyc rename to hexactf/monitoring/__pycache__/ctf_metrics_collector.cpython-310.pyc index 7c078cd..b1c37d5 100644 Binary files a/app/monitoring/__pycache__/ctf_metrics_collector.cpython-310.pyc and b/hexactf/monitoring/__pycache__/ctf_metrics_collector.cpython-310.pyc differ diff --git a/hexactf/monitoring/__pycache__/loki_logger.cpython-310.pyc b/hexactf/monitoring/__pycache__/loki_logger.cpython-310.pyc new file mode 100644 index 0000000..f039ded Binary files /dev/null and b/hexactf/monitoring/__pycache__/loki_logger.cpython-310.pyc differ diff --git a/hexactf/monitoring/__pycache__/system_metrics_collector.cpython-310.pyc b/hexactf/monitoring/__pycache__/system_metrics_collector.cpython-310.pyc new file mode 100644 index 0000000..999cdf4 Binary files /dev/null and b/hexactf/monitoring/__pycache__/system_metrics_collector.cpython-310.pyc differ diff --git a/app/monitoring/async_handler.py b/hexactf/monitoring/async_handler.py similarity index 100% rename from app/monitoring/async_handler.py rename to hexactf/monitoring/async_handler.py diff --git a/app/monitoring/ctf_metrics_collector.py b/hexactf/monitoring/ctf_metrics_collector.py similarity index 100% rename from app/monitoring/ctf_metrics_collector.py rename to hexactf/monitoring/ctf_metrics_collector.py diff --git a/app/monitoring/loki_logger.py b/hexactf/monitoring/loki_logger.py similarity index 98% rename from app/monitoring/loki_logger.py rename to hexactf/monitoring/loki_logger.py index 55b77c0..43a7b81 100644 --- a/app/monitoring/loki_logger.py +++ b/hexactf/monitoring/loki_logger.py @@ -1,14 +1,11 @@ import time import json import logging -import sys import traceback -from app.monitoring.async_handler import AsyncHandler +from hexactf.monitoring.async_handler import AsyncHandler from logging_loki import LokiHandler - - class FlaskLokiLogger: def __init__(self, app_name,loki_url: str): self.app_name = app_name diff --git a/app/monitoring/system_metrics_collector.py b/hexactf/monitoring/system_metrics_collector.py similarity index 100% rename from app/monitoring/system_metrics_collector.py rename to hexactf/monitoring/system_metrics_collector.py diff --git a/imgs/image.png b/imgs/image.png new file mode 100644 index 0000000..ceacd04 Binary files /dev/null and b/imgs/image.png differ diff --git a/md/user-challenge-delete.md b/md/user-challenge-delete.md new file mode 100644 index 0000000..11cd32b --- /dev/null +++ b/md/user-challenge-delete.md @@ -0,0 +1,82 @@ +## 기본 정보 + +- **API 경로**: /v1/user-challenges/delete +- **Method**: POST +- **Description**: 사용자의 특정 챌린지 컨테이너를 삭제합니다. + +## Request + +### Path Parameters + +| 파라미터명 | 타입 | 필수 여부 | 설명 | +| ------------ | ------ | --------- | ------------------------------ | +| challenge_id | int | Yes | 삭제할 Challenge의 고유 식별자 | +| username | string | Yes | 사용자 닉네임 | + +### Request Header + +| 헤더명 | 필수 여부 | 설명 | +| ------------ | --------- | ---------------- | +| Content-Type | Yes | application/json | + +## Response + +### Response Body + +| 필드 | 타입 | 필수 여부 | 설명 | +| ------- | ------- | --------- | -------------- | +| success | boolean | Yes | 삭제 성공 여부 | +| message | string | Yes | 결과 메시지 | + +### 성공 응답 예시 (200 OK) + +```json +{ + "message": "챌린지가 성공적으로 삭제되었습니다." +} +``` + +### 실패 응답 예시 + +### 403 Forbidden + +```json +{ + "error": { + "code": "FORBIDDEN", + "message": "해당 챌린지를 삭제할 권한이 없습니다." + } +} +``` + +### 404 Not Found + +```json +{ + "error": { + "code": "CHALLENGE_NOT_FOUND", + "message": "요청한 챌린지를 찾을 수 없습니다." + } +} +``` + +### 500 Internal Server Error + +```json +{ + "error": { + "code": "INTERNAL_SERVER_ERROR", + "message": "챌린지 삭제 중 오류가 발생했습니다." + } +} +``` + +## 비고 + +- 삭제 요청 시 실행 중인 컨테이너도 함께 종료됩니다. +- 삭제된 챌린지는 복구할 수 없습니다. +- 사용자는 자신이 생성한 챌린지만 삭제할 수 있습니다. + +## 제한사항 + +- 삭제 요청은 챌린지 소유자만 가능 diff --git a/md/user-challenge-status.md b/md/user-challenge-status.md new file mode 100644 index 0000000..8195e80 --- /dev/null +++ b/md/user-challenge-status.md @@ -0,0 +1,83 @@ +## 기본 정보 + +- **API 경로**: /v1/user-challenges +- **Method**: POST +- **Description**: 사용자별 챌린지 컨테이너 정보를 조회합니다. + +## Request + +### Path Parameters + +| 파라미터명 | 타입 | 필수 여부 | 설명 | +| ------------ | ------ | --------- | ------------------------------ | +| challenge_id | int | Yes | 생성할 Challenge의 고유 식별자 | +| username | string | Yes | 사용자 아이디 | + +### Request Body + +```json +{ + "challenge_id": 1, + "username": "test" +} +``` + +### Request Header + +| 헤더명 | 필수 여부 | 설명 | +| ------------ | --------- | ---------------- | +| Content-Type | Yes | application/json | + +## Response + +### Response Body + +| 필드 | 타입 | 필수 여부 | 설명 | +| ----------- | ------ | --------- | ------------------------------------------------ | +| data.status | string | Yes | 생성된 컨테이너의 상태 정보 | +| data.port | int | No | 컨테이너 포트 번호, Running 상태일 경우에만 반환 | + +컨테이너 상태값 + +| 상태 | 설명 | +| ------- | ------ | +| None | 생성중 | +| Running | 실행중 | +| Deleted | 삭제됨 | +| Error | 에러 | + +### 성공 응답 예시 (200 OK) + +```json +{ + "data": { + "status": "Running" + "port" : 12345 + } +} + +``` + +### 실패 응답 예시 + +### 404 Not Found + +```json +{ + "error": { + "type": "CHALLENGE_NOT_FOUND", + "message": "Challenge not found" + } +} +``` + +### 500 Internal Server Error + +```json +{ + "error": { + "type": "INTERNAL_SERVER_ERROR", + "message": "컨테이너 생성 중 오류가 발생했습니다." + } +} +``` diff --git a/md/user-challenge.md b/md/user-challenge.md new file mode 100644 index 0000000..8195e80 --- /dev/null +++ b/md/user-challenge.md @@ -0,0 +1,83 @@ +## 기본 정보 + +- **API 경로**: /v1/user-challenges +- **Method**: POST +- **Description**: 사용자별 챌린지 컨테이너 정보를 조회합니다. + +## Request + +### Path Parameters + +| 파라미터명 | 타입 | 필수 여부 | 설명 | +| ------------ | ------ | --------- | ------------------------------ | +| challenge_id | int | Yes | 생성할 Challenge의 고유 식별자 | +| username | string | Yes | 사용자 아이디 | + +### Request Body + +```json +{ + "challenge_id": 1, + "username": "test" +} +``` + +### Request Header + +| 헤더명 | 필수 여부 | 설명 | +| ------------ | --------- | ---------------- | +| Content-Type | Yes | application/json | + +## Response + +### Response Body + +| 필드 | 타입 | 필수 여부 | 설명 | +| ----------- | ------ | --------- | ------------------------------------------------ | +| data.status | string | Yes | 생성된 컨테이너의 상태 정보 | +| data.port | int | No | 컨테이너 포트 번호, Running 상태일 경우에만 반환 | + +컨테이너 상태값 + +| 상태 | 설명 | +| ------- | ------ | +| None | 생성중 | +| Running | 실행중 | +| Deleted | 삭제됨 | +| Error | 에러 | + +### 성공 응답 예시 (200 OK) + +```json +{ + "data": { + "status": "Running" + "port" : 12345 + } +} + +``` + +### 실패 응답 예시 + +### 404 Not Found + +```json +{ + "error": { + "type": "CHALLENGE_NOT_FOUND", + "message": "Challenge not found" + } +} +``` + +### 500 Internal Server Error + +```json +{ + "error": { + "type": "INTERNAL_SERVER_ERROR", + "message": "컨테이너 생성 중 오류가 발생했습니다." + } +} +``` diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/requirements.txt b/requirements.txt index f9370c2..dc45637 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,9 @@ SQLAlchemy==2.0.20 pymysql==1.0.3 kubernetes==26.1.0 Flask-SQLAlchemy==3.0.2 -mariadb>=1.0.11 +# mariadb>=1.0.11 prometheus-client==0.19.0 python-logging-loki==0.3.1 flask-prometheus-metrics==1.0.0 -psutil==5.9.8 \ No newline at end of file +psutil +pytest-mock==3.14.0 \ No newline at end of file diff --git a/tests/units/.gitignore b/tests/units/.gitignore new file mode 100644 index 0000000..d9cf955 --- /dev/null +++ b/tests/units/.gitignore @@ -0,0 +1,2 @@ +.pytest_cache +__pycache__/ diff --git a/tests/units/__init__.py b/tests/units/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/units/db/__init__.py b/tests/units/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/units/db/conftest.py b/tests/units/db/conftest.py new file mode 100644 index 0000000..ca4a675 --- /dev/null +++ b/tests/units/db/conftest.py @@ -0,0 +1,48 @@ +import os +from flask import Flask +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, scoped_session + +from hexactf.extensions.db.models import UserChallenges +from hexactf.extensions_manager import db + +class TestDB: + """Test database setup for integration testing""" + def __init__(self): + self.app = Flask(__name__) + self.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + self.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + self.engine = create_engine(self.app.config['SQLALCHEMY_DATABASE_URI']) + self.session_factory = sessionmaker(bind=self.engine) + self.Session = scoped_session(self.session_factory) + + with self.app.app_context(): + db.init_app(self.app) + db.create_all() + db.session.commit() + + def get_session(self): + with self.app.app_context(): + return db.session + + def close(self): + with self.app.app_context(): + db.session.remove() + db.drop_all() + +@pytest.fixture() +def test_db(): + """Provides an instance of TestDB for database tests inside a Flask app context""" + test_db_instance = TestDB() + with test_db_instance.app.app_context(): + yield test_db_instance + test_db_instance.close() + +# autouse 명시적으로 call 하지 않아도 사용할 수 있는 옵션 +@pytest.fixture(scope="session", autouse=True) +def set_test_env(): + """Automatically set TEST_MODE=true for all tests""" + os.environ["TEST_MODE"] = "true" + \ No newline at end of file diff --git a/tests/units/db/test_userchallenges_repo.py b/tests/units/db/test_userchallenges_repo.py new file mode 100644 index 0000000..a571b90 --- /dev/null +++ b/tests/units/db/test_userchallenges_repo.py @@ -0,0 +1,173 @@ +import pytest +from hexactf.exceptions.api_exceptions import InternalServerError +from hexactf.extensions.db.models import UserChallenges +from hexactf.extensions.db.repository import UserChallengesRepository + +# ============================ +# create_challenge +# ============================ + +def test_create_challenge_success(test_db): + """Test successful challenge creation""" + session = test_db.get_session() + user_challenges_repo = UserChallengesRepository(session=session) + + challenge = user_challenges_repo.create( + username="test_user", + C_idx=1, + userChallengeName="test_challenge", + port=30000 + ) + + assert challenge is not None + assert challenge.username == "test_user" + assert challenge.C_idx == 1 + assert challenge.userChallengeName == "test_challenge" + assert challenge.port == 30000 + +def test_create_challenge_None_value_failed(test_db): + """Test challenge creation failure due to database error""" + + session = test_db.get_session() + user_challenges_repo = UserChallengesRepository(session=session) + + with pytest.raises(InternalServerError): + user_challenges_repo.create( + username=None, + C_idx=1, + userChallengeName="test_challenge", + port=30000 + ) + +# ============================ +# get_by_user_challenge_name +# ============================ + +def test_get_by_user_challenge_name_success(test_db): + """Test retrieving a challenge by name""" + session = test_db.get_session() + user_challenges_repo = UserChallengesRepository(session=session) + + challenge = UserChallenges( + username="test_user", + C_idx=1, + userChallengeName="test_challenge", + port=30000 + ) + session.add(challenge) + session.commit() + + result = user_challenges_repo.get_by_user_challenge_name("test_challenge") + assert result == challenge, "Challenge should be retrieved by name" + +def test_get_by_user_challenge_name_Invalid_name_failed(test_db): + """Test retrieving a challenge by name""" + session = test_db.get_session() + user_challenges_repo = UserChallengesRepository(session=session) + + challenge = UserChallenges( + username="test_user", + C_idx=1, + userChallengeName="test_challenge", + port=30000 + ) + session.add(challenge) + session.commit() + + result = user_challenges_repo.get_by_user_challenge_name("invalid_challenge") + assert result is None, "Invalid challenge name should return None" + + +# ============================ +# get_by_user_challenge_name +# ============================ + +def test_update_status_success(test_db): + """Test updating challenge status""" + session = test_db.get_session() + user_challenges_repo = UserChallengesRepository(session=session) + + challenge = UserChallenges( + username="test_user", + C_idx=1, + userChallengeName="test_challenge", + port=30000, + status="Running" + ) + session.add(challenge) + session.commit() + + success = user_challenges_repo.update_status(challenge, "Deleted") + assert success + assert challenge.status == "Deleted" + +# ============================ +# is_running +# ============================ + +def test_is_running_success(test_db): + """Test checking if challenge is running""" + session = test_db.get_session() + user_challenges_repo = UserChallengesRepository(session=session) + + challenge = UserChallenges( + username="test_user", + C_idx=1, + userChallengeName="test_challenge", + port=30000, + status="Running" + ) + + assert user_challenges_repo.is_running(challenge) + challenge.status = "Stopped" + assert not user_challenges_repo.is_running(challenge) + +# ============================ +# get_status +# ============================ + +def test_get_status_success(test_db): + """Test retrieving challenge status""" + session = test_db.get_session() + user_challenges_repo = UserChallengesRepository(session=session) + + challenge = UserChallenges( + username="test_user", + C_idx=1, + userChallengeName="test_challenge", + port=30000, + status="Running" + ) + session.add(challenge) + session.commit() + + result = user_challenges_repo.get_status(1, "test_user") + assert result == {"status": "Running", "port": 30000} + + challenge.status = "Deleted" + session.commit() + result = user_challenges_repo.get_status(1, "test_user") + assert result == {"status": "Deleted"} + +def test_get_status_invalid_challenge_failed(test_db): + """Test retrieving challenge status""" + session = test_db.get_session() + user_challenges_repo = UserChallengesRepository(session=session) + + challenge = UserChallenges( + username="test_user", + C_idx=1, + userChallengeName="test_challenge", + port=30000, + status="Running" + ) + session.add(challenge) + session.commit() + + # 잘못된 challenge id + result = user_challenges_repo.get_status(2, "test_user") + assert result is None + + # 잘못된 username + result2 = user_challenges_repo.get_status(1, "wrong_user") + assert result2 is None \ No newline at end of file diff --git a/tests/units/exceptions/__init__.py b/tests/units/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/units/exceptions/conftest.py b/tests/units/exceptions/conftest.py new file mode 100644 index 0000000..c4ce273 --- /dev/null +++ b/tests/units/exceptions/conftest.py @@ -0,0 +1,26 @@ +import os +from flask import Flask +import pytest + +from hexactf.exceptions.api_exceptions import InternalServerError, InvalidRequest +from hexactf.exceptions.handlers import register_error_handler +from hexactf.factory import create_app + +def create_app(): + app = Flask(__name__) + register_error_handler(app) + + @app.route('/invalid') + def trigger_invalid_request(): + raise InvalidRequest("Missing required field") + + @app.route('/internal') + def trigger_internal_error(): + raise InternalServerError("Unexpected DB failure") + + return app + +@pytest.fixture +def client(): + app = create_app() + return app.test_client() diff --git a/tests/units/exceptions/test_handlers.py b/tests/units/exceptions/test_handlers.py new file mode 100644 index 0000000..d8c0574 --- /dev/null +++ b/tests/units/exceptions/test_handlers.py @@ -0,0 +1,24 @@ +from hexactf.exceptions.error_types import ApiErrorTypes + + +def test_invalid_request_handler(client): + response = client.get('/invalid') + assert response.status_code == 400 + json_data = response.get_json() + assert json_data == { + 'error': { + 'code': ApiErrorTypes.INVALID_REQUEST, + 'message': "Invalid request format", + } + } + +def test_internal_server_error_handler(client): + response = client.get('/internal') + assert response.status_code == 500 + json_data = response.get_json() + assert json_data == { + 'error': { + 'code': ApiErrorTypes.INTERNAL_SERVER_ERROR, + 'message': "An unexpected error occurred", + } + } \ No newline at end of file diff --git a/tests/units/kafka/__init__.py b/tests/units/kafka/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/units/kafka/conftest.py b/tests/units/kafka/conftest.py new file mode 100644 index 0000000..4a9c423 --- /dev/null +++ b/tests/units/kafka/conftest.py @@ -0,0 +1,38 @@ +import pytest +import json +from unittest.mock import MagicMock, patch +from hexactf.exceptions.kafka_exceptions import QueueProcessingError +from kafka import KafkaConsumer +from hexactf.extensions.kafka.consumer import KafkaEventConsumer # Ensure it's patched correctly + +@pytest.fixture +def sample_json(): + """Fixture for a sample status message JSON""" + return { + "user": "test_user", + "problemId": "1234", + "newStatus": "solved", + "timestamp": "2025-03-10T12:00:00" + } + +@pytest.fixture +def kafka_mock(): + """Mock KafkaConsumer globally to prevent real connection attempts""" + with patch("hexactf.extensions.kafka.consumer.KafkaConsumer") as mock_kafka_consumer: + mock_kafka_consumer.return_value.__iter__.return_value = [] # No messages by default + yield mock_kafka_consumer + +@pytest.fixture +def kafka_event_consumer(kafka_mock): + """Fixture for KafkaEventConsumer with mocked KafkaConsumer""" + mock_config = MagicMock() + mock_config.topic = "test_topic" + mock_config.consumer_config = {"bootstrap_servers": "localhost:9092"} + return KafkaEventConsumer(mock_config) + + +@pytest.fixture +def repo_mock(): + """Fixture for mocking the UserChallengesRepository""" + with patch("hexactf.extensions.db.UserChallengesRepository") as mock_repo: + yield mock_repo.return_value diff --git a/tests/units/kafka/test_consumer.py b/tests/units/kafka/test_consumer.py new file mode 100644 index 0000000..deb9b14 --- /dev/null +++ b/tests/units/kafka/test_consumer.py @@ -0,0 +1,42 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from hexactf.exceptions.kafka_exceptions import QueueProcessingError +from hexactf.extensions.kafka.consumer import KafkaEventConsumer + + +def test_consumer_initialization(kafka_event_consumer, kafka_mock): + """Test Kafka consumer initialization""" + assert kafka_event_consumer.consumer is not None + kafka_mock.assert_called_once() + +def test_consume_valid_message(kafka_event_consumer, kafka_mock, sample_json): + """Test consuming valid Kafka messages""" + mock_callback = MagicMock() + + mock_message = MagicMock() + mock_message.value = sample_json + + kafka_mock.return_value.__iter__.return_value = [mock_message] + kafka_event_consumer.consume_events(mock_callback) + mock_callback.assert_called_once() + + status_msg = mock_callback.call_args[0][0] + assert status_msg.user == "test_user" + assert status_msg.problemId == "1234" + + +def test_consumer_creation_failure(): + """Test handling when Kafka consumer fails to initialize""" + with patch("hexactf.extensions.kafka.consumer.KafkaConsumer", side_effect=Exception("Failed to process request")): + with pytest.raises(QueueProcessingError, match="Failed to process request"): + consumer = KafkaEventConsumer(MagicMock()) + _ = consumer.consumer + +def test_close_consumer(kafka_event_consumer, kafka_mock): + """Test consumer closing""" + _ = kafka_event_consumer.consumer # Ensure the consumer is initialized + kafka_event_consumer.close() + kafka_mock.return_value.close.assert_called_once() # Check that close was called + assert kafka_event_consumer._consumer is None