diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml new file mode 100644 index 0000000..d47ea1c --- /dev/null +++ b/.github/workflows/documentation.yaml @@ -0,0 +1,59 @@ +name: Documentation - LogBuster + +on: + push: + branches: + - main + +# Permissions (lecture et écriture sur la GitHub Page) +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # Etape 1: Cloner le dépôt + - name: Positionnement sur le dépôt + uses: actions/checkout@v4 + + # Étape 2 : Installer Python + - name: Mis en place de Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + # Étape 3 : Installer les dépendances + - name: Installation des dépendances + run: | + python -m pip install --upgrade pip + pip install sphinx + pip install sphinx_rtd_theme --break-system-packages + pip install colorama + + # Étape 4 : Générer la documentation + - name: Construction de la documentation (avec Sphinx) + run: | + sphinx-build -b html docs/source docs/build/html + + # Étape 5 : Sauvegarder l'artefact (pour la GitHub Page) + - name: Sauvegarder la documentation + uses: actions/upload-pages-artifact@v3 + with: + path: docs/build/html + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + # Déploiement de la documentation + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.github/workflows/qualite.yaml b/.github/workflows/qualite.yaml new file mode 100644 index 0000000..6e6ad7b --- /dev/null +++ b/.github/workflows/qualite.yaml @@ -0,0 +1,48 @@ +name: Qualité code - LogBuster + +on: + pull_request: + branches: + - main + - develop + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + # Étape 1 : Cloner le dépôt + - name: Checkout du code + uses: actions/checkout@v4 + + # Étape 2 : Installer Python + - name: Configuration de Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + # Étape 3 : Installer les dépendances + - name: Installation des dépendances + run: | + python -m pip install --upgrade pip + pip install pylint + pip install colorama + + # Étape 4 : Lancement de l'analyse + - name: Analyse avec Pylint (note >= 9.0 requise) + run: | + pylint app > tests-resultats-qualite.txt || true + SCORE=$(grep "Your code has been rated at" tests-resultats-qualite.txt | awk '{print $7}' | cut -d"/" -f1) + echo "Le score du code dans le dossier app est de $SCORE" + SCORE_VALIDE=$(echo "$SCORE >= 9.0" | bc) + if [ "$SCORE_VALIDE" -ne 1 ]; then + echo "Erreur: La note du code est inférieur à 9." + exit 1 + fi + + # Sauvegarder l'artefact + - name: Upload du rapport Pylint + uses: actions/upload-artifact@v4 + with: + name: rapport-qualite-code + path: tests-resultats-qualite.txt \ No newline at end of file diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..13b33c9 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,53 @@ +name: Tests unitaires - LogBuster + +on: + pull_request: + branches: + - main + - develop + +# Permissions (lecture uniquement) +permissions: + contents: read + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] # Liste des versions de Python à tester + + steps: + # Étape 1 : Cloner le dépôt + - name: Cloner le dépôt + uses: actions/checkout@v4 + + # Étape 2 : Installer Python + - name: Installer Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + # Étape 3 : Installer les dépendances + - name: Installer les dépendances + run: | + python -m pip install --upgrade pip + pip install colorama + pip install pytest + pip install pytest-cov + pip install pytest-mock + + # Étape 4 : Lancer les tests unitaires + - name: Lancer les tests unitaires + run: | + cd tests + pytest --basetemp=resultats_pytest --verbose --cov=../app --cov-report=term-missing --cov-report=xml:resultats_pytest/tests-couverture.xml --junitxml=resultats_pytest/tests-rapport.xml + + # Étape 5 : Sauvegarder les artefacts + - name: Sauvegarder les résultats de test + if: always() # Sauvegarde même si les tests échouent + uses: actions/upload-artifact@v4 + with: + if-no-files-found: error + name: rapport-tests-unitaires-python-${{ matrix.python-version }} # Nom de l'artefact + path: tests/resultats_pytest # Eléments à sauvegarder diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9dda353 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Environnement virtuel +.venv/ + +# Fichiers propres à Python +*.pyc +__pycache__/ + +# Fichiers propres à VisualStudioCode +.vscode/ +.idea/ + +# Fichiers propres à Pytest +.pytest_cache/ + +# Fichiers propres à Coverage +.coverage +htmlcov/ + +# Fichiers de la documentation sphinx +docs/build/ + +# Fichier de tests durant le développement +analyse-log-apache.json \ No newline at end of file diff --git a/README.md b/README.md index 8d28618..e02d302 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,117 @@ -# code_source -C - Analysateur de fichiers logs +# LogBuster + +``` + .-. .-') .-') .-') _ ('-. _ .-') ,---. + \ ( OO ) ( OO ). ( OO) ) _( OO)( \( -O ) | | + ,--. .-'),-----. ,----. ;-----.\ ,--. ,--. (_)---\_)/ '._(,------.,------. | | + | |.-') ( OO' .-. ' ' .-./-') | .-. | | | | | / _ | |'--...__)| .---'| /`. '| | + | | OO )/ | | | | | |_( O- )| '-' /_) | | | .-') \ :` `. '--. .--'| | | / | || | + | |`-' |\_) | |\| | | | .--, \| .-. `. | |_|( OO ) '..`''.) | | (| '--. | |_.' || .' +(| '---.' \ | | | |(| | '. (_/| | \ | | | | `-' /.-._) \ | | | .--' | . '.'`--' + | | `' '-' ' | '--' | | '--' /(' '-'(_.-' \ / | | | `---.| |\ \ .--. + `------' `-----' `------' `------' `-----' `-----' `--' `------'`--' '--''--' +``` + +Bienvenue dans le monde de LogBuster, l'outil ultime pour analyser, décortiquer et sauver vos logs Apache des griffes du chaos. Vous avez des logs qui traînent, qui sont indéchiffrables ou tout simplement encombrants ? Pas de panique, LogBuster est là pour les attraper, les analyser et vous offrir des statistiques claires et précises, comme jamais auparavant ! + +## 📋 Table des matières + +- [👻 Fonctionnalités](#-fonctionnalités) +- [📦 Installation](#-installation) +- [🛠️ Utilisation de base](#️-utilisation-de-base) +- [⚠️ Précautions](#️-précautions) +- [📖 Documentation](#-documentation) +- [🧪 Lancer les tests](#-lancer-les-tests) +- [📜 Licence](#-licence) + +## 👻 Fonctionnalités + +- 📄 Parsing avancé de logs Apache. +- 📉 Extraire des statistiques clés. +- 🗂️ Ranger les données par catégorie. +- 🧹 Indiquer les erreurs de format avec précision. +- 🚚 Exporter les données en JSON. + +## 📦 Installation + +### Bash (linux/macOS) +```bash +git clone https://github.com/AnthonyGuillauma/code_source +cd code_source +python -m venv .venv +source .venv/bin/activate # Activation de l'environnement virtuel sous Bash +pip install -r requirements.txt +``` + +### Windows (cmd) +```bash +git clone https://github.com/AnthonyGuillauma/code_source +cd code_source +python -m venv .venv +.venv\Scripts\activate # Activation de l'environnement virtuel sous Windows +pip install -r requirements.txt +``` + +## 🛠️ Utilisation de base + +``` +python app/main.py chemin_log [-s SORTIE] +``` +- `chemin_log` : Le chemin vers le fichier de log Apache à analyser. +- `-s SORTIE` (optionnel) : Le chemin où sauvegarder les résultats de l'analyse. Si non spécifié, les résultats seront sauvegardés dans un fichier `analyse-log-apache.json`. + +## ⚠️ Précautions + +Le projet LogBuster utilise des caractères Unicode, tels que des symboles spéciaux, dans le terminal pour rendre l'affichage plus plaisant. Assurez-vous que votre terminal est configuré pour prendre en charge l'affichage de caractères Unicode afin de profiter pleinement de l'expérience utilisateur. + +Si vous rencontrez des problèmes d'affichage (comme des symboles manquants ou mal rendus), vous pouvez essayer les solutions suivantes : + +- Utiliser un terminal compatible avec Unicode (par exemple, Terminal sous macOS, Windows Terminal sous Windows, ou des terminaux comme GNOME Terminal ou Konsole sous Linux). +- Vérifier que votre terminal utilise une police qui prend en charge les caractères Unicode (par exemple, DejaVu Sans Mono ou Consolas). + +## 📖 Documentation + +La documentation complète du code du projet se situe [ici](https://anthonyguillauma.github.io/code_source/ +). + +Si vous souhaitez la générer vous même, suivez ces étapes : + +Tout d'abord, placez-vous dans le dossier `docs` qui contient les fichiers sources de la documentation : + +```bash +cd docs +``` + +Puis, générez la documentation via la commande suivante : + +```bash +./make html +``` + +Enfin, ouvrez le fichier html `build/html/index.html` généré dans un navigateur. + +## 🧪 Lancer les tests + +Les tests unitaires du projet peuvent être exécutés avec pytest. Pour lancer les tests, assurez-vous d'avoir activé l'environnement virtuel et installé les dépendances. + +Premièrement, placez-vous dans le dossier `tests` qui contient les fichiers de configuration pour les tests unitaires : + +```bash +cd tests +``` + +Ensuite, exécutez les tests avec la commande suivante : + +```bash +pytest +``` + +Enfin, si vous souhaitez également afficher la couverture des tests unitaires, utilisez la commande suivante : + +```bash +pytest --cov=../app --cov-report=term-missing +``` + +# 📜 Licence + +Ce projet est sous licence MIT. \ No newline at end of file diff --git a/app/analyse/analyseur_log_apache.py b/app/analyse/analyseur_log_apache.py new file mode 100644 index 0000000..f00552f --- /dev/null +++ b/app/analyse/analyseur_log_apache.py @@ -0,0 +1,159 @@ +""" +Module pour l'analyse statistique d'un fichier log Apache. +""" + +from collections import Counter +from parse.fichier_log_apache import FichierLogApache + + +class AnalyseurLogApache: + """ + Représente un analysateur pour faire une analyse statistique d'un fichier + log Apache et créer des statistiques à partir de ce dernier. + + Attributes: + fichier (FichierLogApache): Le fichier de log Apache à analyser. + nombre_par_top (int): Le nombre maximal d'éléments à inclure dans + les statistiques des classements (tops). + """ + + def __init__(self, fichier_log_apache: FichierLogApache, nombre_par_top: int = 3): + """ + Initialise un nouveau analysateur de fichier log Apache. + + Args: + fichier_log_apache (FichierLogApache): Le fichier à analyser. + nombre_par_top (int): Le nombre maximal d'éléments à inclure dans + les statistiques des classements (tops). Par défaut, sa valeur est égale à ``3``. + + Raises: + TypeError: Si l'argument ``fichier_log_apache`` n'est pas une instance + de :class:`FichierLogApache` + ou si l'argument ``nombre_par_top`` n'est pas un entier. + ValueError: Si l'argument ``nombre_par_top`` est inférieur à ``0``. + """ + # Vérification du type des paramètres + if not isinstance(fichier_log_apache, FichierLogApache): + raise TypeError("La représentation du fichier doit être de type FichierLogApache.") + if not isinstance(nombre_par_top, int) or isinstance(nombre_par_top, bool): + raise TypeError("Le nombre par top doit être un entier.") + # Vérification de la valeur du paramètre + if nombre_par_top < 0: + raise ValueError("Le nombre par top doit être supérieur ou égale à 0.") + + # Ajout des données + self.fichier = fichier_log_apache + self.nombre_par_top = nombre_par_top + + def _get_repartition_elements(self, + liste_elements: list, + nom_elements: str, + mode_top_classement: bool = False) -> list: + """ + Retourne le top 'n' des éléments qui apparaissent le plus dans la liste. + + Args: + liste_elements (list): La liste des éléments. + nom_elements (str): Le nom des éléments. + mode_top_classement (bool): Indique si la méthode doit retourner ou non le top + 'n' des éléments les plus présents, où 'n' est égale à l'attribut + :attr:`nombre_par_top`. Par défaut, ce mode est désactivé (valeur à ``False``). + + Returns: + list: Une liste de dictionnaires contenant, pour chaque élément : + - Sa valeur. + - Son nombre total d'apparitions. + - Son taux d'apparition dans la liste. + La liste est triée dans l'ordre décroissant selon le nombre d'apparitions. + Si ``mode_top_classement`` est défini à ``True``, elle contient au maximum + :attr:`nombre_par_top` éléments, mais peut en contenir moins s'il y a moins + de :attr:`nombre_par_top` éléments distincts. + """ + # Vérification du type des paramètres + if not isinstance(liste_elements, list): + raise TypeError("La liste des éléments doit être une instance du type list.") + if not isinstance(nom_elements, str): + raise TypeError("Le nom des éléments doit être une chaîne de caractères") + if not isinstance(mode_top_classement, bool): + raise TypeError("L'indication de l'activation du mode 'top_classement' " + "doit être un booléen.") + + # Analyse de la liste + total_elements = len(liste_elements) + compteur_elements = Counter(liste_elements) + top_elements = compteur_elements.most_common(self.nombre_par_top + if mode_top_classement else None) + return [ + {nom_elements: element, "total": total, "taux": total / total_elements * 100} + for element, total in top_elements + ] + + def get_analyse_complete(self) -> dict: + """ + Retourne l'analyse complète du fichier de log Apache. + + L'analyse suit la structure suivante : + - chemin: chemin du fichier + - statistiques: + - requetes: + - top_urls: voir :meth:`get_top_urls` + - repartition_code_statut_http: + voir :meth:`get_total_par_code_statut_http` + + Returns: + dict: L'analyse sous forme d'un dictionnaire. + """ + return { + "chemin": self.fichier.chemin, + "statistiques": { + "total_entrees": self.get_total_entrees(), + "requetes": { + "top_urls": self.get_top_urls(), + "repartition_code_statut_http": self.get_total_par_code_statut_http() + } + } + } + + def get_total_entrees(self) -> int: + """ + Retourne le nombre total d'entrées dans le fichier. + + Returns: + int: Le nombre total d'entrées. + """ + return len(self.fichier.entrees) + + def get_top_urls(self) -> list: + """ + Retourne le top :attr:`nombre_par_top` des urls les plus demandées. + + Returns: + list: Une liste de dictionnaires où chaque clé contient : + - url: L'URL demandée. + - total: Le nombre total de fois où cette URL a été demandée. + - taux: Le pourcentage de demandes correspondant à cette URL. + + La liste est triée dans l'ordre décroissant du nombre total d'apparitions. + """ + return self._get_repartition_elements( + [entree.requete.url for entree in self.fichier.entrees], + "url", + True + ) + + def get_total_par_code_statut_http(self) -> list: + """ + Retourne la répartition des réponses par code de statut htpp retourné. + + Returns: + list: Une liste de dictionnaires où chaque clé contient : + - code: Le code de statut http. + - total: Le nombre total de fois où ce code a été demandée. + - taux: Le pourcentage de demandes correspondant à ce code. + + La liste est triée dans l'ordre décroissant du nombre total d'apparitions. + """ + return self._get_repartition_elements( + [entree.reponse.code_statut_http for entree in self.fichier.entrees], + "code" + ) diff --git a/app/cli/afficheur_cli.py b/app/cli/afficheur_cli.py new file mode 100644 index 0000000..e16f9d6 --- /dev/null +++ b/app/cli/afficheur_cli.py @@ -0,0 +1,221 @@ +""" +Module pour les intéractions avec la ligne de commande. +""" + +import sys +from pathlib import Path +from json import load +from time import sleep +from random import choice +import threading +import colorama + + +class AfficheurCLI: + """ + Représente une classe pour afficher des informations dans la ligne de commande. + + Attributes: + _thread_chargement (Union[None,Thread]): Le thread de l'animation de chargement. + _thread_chargement_termine (Event): L'évènement pour demander au thread de + l'animation de chargement de s'arrêter lorsque le chargement est terminé. + _thread_chargement_erreur (Event): L'évènement pour demander au thread de + l'animation de chargement de s'arrêter lorsque une erreur s'est produite. + _animations_actuelles (dict): Les éléments visuels pour l'animation de chargement. + + Class-level variables: + :cvar COULEUR_MESSAGE_NORMAL (str): La couleur pour les messages normaux en CLI. + :cvar COULEUR_MESSAGE_ERREUR (str): La couleur pour les messages d'erreur en CLI. + """ + COULEUR_MESSAGE_NORMAL = colorama.Fore.WHITE + COULEUR_MESSAGE_ERREUR = colorama.Fore.RED + + def __init__(self): + """ + Initialise un objet pour afficher des informations dans la ligne de commande. + """ + # Normalise les codes couleurs pour fonctionner partout + colorama.init() + # Initialise les variables pour le chargement + self._thread_chargement = None + self._thread_chargement_termine = threading.Event() + self._thread_chargement_erreur = threading.Event() + # Récupère les animations + chemin_racine = Path(__file__).parent.parent.parent.resolve() + chemin_animations = chemin_racine / "assets" / "animations.json" + with open(chemin_animations, "r", encoding="utf-8") as animations: + elements_animations = load(animations) + # Choisis une animation au hasard parmi chaque catégorie d'animation + self._animations_actuelles = { + "chasseur": choice(elements_animations["chasseurs"]), + "fantome": choice(elements_animations["fantomes"]), + "rayon_laser": elements_animations["rayons_laser"] + } + + def reecrire_ligne(self, message: str) -> None: + """ + Permet d'écrire des caractères par dessus la dernière ligne dans la + ligne de commande. + + Args: + message (str): Les caractères à afficher. + + Returns: + None + + Raises: + TypeError: Le paramètre ``message`` n'est pas une chaîne de caractères. + """ + # Validation du paramètre + if not isinstance(message, str): + raise TypeError("Le message pour la réécriture doit être une chaîne de caractères.") + + # Ecriture du message + sys.stdout.write("\r" + self.COULEUR_MESSAGE_NORMAL + message) + sys.stdout.flush() + + def affiche_message(self, message: str) -> None: + """ + Permet d'écrire un message commun dans la ligne de commande avec la bonne + couleur. + + Args: + message (str): Le message à afficher. + + Returns: + None + + Raises: + TypeError: Le paramètre ``message`` n'est pas une chaîne de caractères. + """ + # Validation du paramètre + if not isinstance(message, str): + raise TypeError("Le message doit être une chaîne de caractères.") + + # Ecriture du message + print(self.COULEUR_MESSAGE_NORMAL + message, flush=True) + + def affiche_erreur(self, message: str, exception: Exception) -> None: + """ + Permet d'écrire un message d'erreur dans la ligne de commande avec la bonne + couleur. + + Args: + message (str): Le message à afficher. + exception (Exception): L'exception à afficher. + + Returns: + None + + Raises: + TypeError: Le paramètre ``message`` n'est pas une chaîne de caractères ou + le paramètre ``exception`` n'est pas une instance de la classe :class:`Exception`. + """ + # Validation des paramètres + if not isinstance(message, str): + raise TypeError("Le message d'erreur doit être une chaîne de caractères.") + if not isinstance(exception, Exception): + raise TypeError("L'exception à afficher doit être une instance de Exception.") + + # Ecriture du message + print(self.COULEUR_MESSAGE_ERREUR + f"{message}\n{exception}", flush=True) + + def lance_animation_chargement(self) -> None: + """ + Lance une animation de chargement dans la ligne de commande via un thread non bloquant. + Si l'animation de chargement est déjà en cours, cette méthode ne fait rien. + + Returns: + None + """ + # Si le thread est déjà lancé, annulation de l'animation + if self._thread_chargement is None: + # On réinitialise les demandes d'arrêt + self._thread_chargement_termine.clear() + self._thread_chargement_erreur.clear() + # Initialisation du thread pour le chargement + self._thread_chargement = threading.Thread(target=self._animation_chargement, + daemon=True) + # Lancement du thread pour le chargement + self._thread_chargement.start() + + def _animation_chargement(self) -> None: + """ + Lance l'animation de chargement en boucle jusqu'à la demande d'arrêt via + l'attribut :attr:`_thread_chargement_demande_arret`. + + Returns: + None + """ + # Eléments de l'animation de chargement + chasseur_chargement = self._animations_actuelles["chasseur"][0] + fantome_chargement = self._animations_actuelles["fantome"][0] + signes_rayon_laser = self._animations_actuelles["rayon_laser"] + couleurs = ["\033[91m", "\033[93m", "\033[94m", "\033[95m"] # Rouge, Jaune, Bleu, Magenta + + # Eléments de l'animation de fin de chargement en cas de succès + chasseur_gagne = self._animations_actuelles["chasseur"][2] + fantome_perd = self._animations_actuelles["fantome"][1] + + # Eléments de l'animation de fin de chargement en cas d'erreur + chasseur_perd = self._animations_actuelles["chasseur"][1] + fantome_gagne = self._animations_actuelles["fantome"][2] + + # Variables pour l'animation de chargement + index_boucle = 0 + rayon_laser = "" + + # Début de l'animation (jusqu'à la demande d'arrêt) + while not (self._thread_chargement_termine.is_set() + or self._thread_chargement_erreur.is_set()): + # Arrête d'ajouter des caractères lorsque la chaîne est trop longue + if index_boucle < 40: + # Récupération de la prochaine couleur + couleur_courante = couleurs[(index_boucle % len(couleurs))] + # Récupération du prochain signe du rayon + signe_courant = signes_rayon_laser[(index_boucle % len(signes_rayon_laser))] + # Ajout du dernier signe avec la nouvelle couleur au rayon + rayon_laser += couleur_courante + signe_courant + # Réactualisation de l'animation de chargement + self.reecrire_ligne( + f"{chasseur_chargement}{rayon_laser}\033[0m{fantome_chargement}" + ) + index_boucle += 1 + sleep(0.05) + + # Suppression de la ligne de chargement + self.reecrire_ligne("\033[K") + espace_rayon_laser = " " * index_boucle + if self._thread_chargement_termine.is_set(): + # Message d'animation terminée + self.reecrire_ligne(f"{chasseur_gagne}{espace_rayon_laser}\033[0m{fantome_perd}\n") + self.affiche_message("Analyse terminée! We came, we saw, we logged it.") + else: + # Message d'animation erreur + self.reecrire_ligne(f"{chasseur_perd}{espace_rayon_laser}\033[0m{fantome_gagne}\n") + + def stop_animation_chargement(self, erreur: bool = False) -> None: + """ + Lance une demande d'arrêt au thread qui gère l'animation de chargement + en cours. Si aucune animation n'est en cours, cette méthode ne fait rien. + + Args: + erreur (bool): Indique si la demande d'arrêt est dûe à une erreur ou non. + + Returns: + None + """ + # Vérification du type du paramètre + if not isinstance(erreur, bool): + raise TypeError("L'indication d'une erreur doit être un booléan.") + + # Si le thread de chargement existe et est lancé + if self._thread_chargement and self._thread_chargement.is_alive(): + # Lancement de la demade d'arrêt + if not erreur: + self._thread_chargement_termine.set() + else: + self._thread_chargement_erreur.set() + # Attente de l'arrêt depuis le thread principal + self._thread_chargement.join() + self._thread_chargement = None diff --git a/app/cli/parseur_arguments_cli.py b/app/cli/parseur_arguments_cli.py new file mode 100644 index 0000000..795e0f2 --- /dev/null +++ b/app/cli/parseur_arguments_cli.py @@ -0,0 +1,112 @@ +""" +Module pour analyser les arguments passés en ligne de commande. +""" + +from argparse import ArgumentParser, Namespace +from re import match +from typing import Optional + + +class ParseurArgumentsCLI(ArgumentParser): + """ + Représente un parseur pour analyser les arguments passés en ligne + de commande pour l'application. + """ + + def __init__(self): + """ + Initialise uparseur pour analyser les arguments passés en ligne de commande. + """ + super().__init__( + description="LogBuster, l'analyseur de log Apache.", allow_abbrev=False, + ) + self.__set_arguments() + + def __set_arguments(self) -> None: + """ + Définit les arguments attendus par l'application. + + Returns: + None + """ + # -- Argument obligatoire -- + self.add_argument( + "chemin_log", + type=str, + help="Chemin du fichier log Apache à analyser." + ) + # -- Argument optionnel -- + self.add_argument( + "-s", + "--sortie", + type=str, + default="./analyse-log-apache.json", + help="Fichier JSON où sera écrit l'analyse. Par défaut, un fichier avec le " + "nom 'analyse-log-apache.json' dans le repertoire courant sera crée.", + ) + + def parse_args(self, + args: Optional[list] = None, + namespace: Optional[Namespace] = None) -> Namespace: + """ + Récupère les arguments passés en ligne de commande puis vérifie + que leur format est conforme à ceux attendus. + + Args: + args (Optional[list]): Liste des arguments passés en paramètre. + Si ``None``, les arguments de la ligne de commande sont utilisés. + namespace (Optional[Namespace]): Un espace de noms (namespace) + pour stocker les résultats. Si ``None``, un nouvel espace de noms est créé. + + Returns: + Namespace: L'objet contenant les arguments analysés et leurs valeurs. + + Raises: + ArgumentCLIException: Si une erreur se produit lors du parsing des arguments + (par exemple, si un argument inconnu est fourni ou si son format est invalide). + """ + # Vérification du type des paramètres + if args is not None and not isinstance(args, list): + raise TypeError("Les arguments doivent soit être None, soit être dans une liste.") + if namespace is not None and not isinstance(args, Namespace): + raise TypeError("L'espace de noms doit soit être None, soit être un objet Namespace.") + + # Analyse des arguments + try: + arguments_parses = super().parse_args(args, namespace) + except SystemExit as ex: #Arguments inconnus + raise ArgumentCLIException() from ex + + # Vérification syntaxique des arguments + regex_chemin = r"^[a-zA-Z0-9:_\\\-.\/]+$" + + if not match(regex_chemin, arguments_parses.chemin_log): + raise ArgumentCLIException( + "Le chemin du fichier log doit uniquement contenir les caractères autorisés. " + "Les caractères autorisés sont les minuscules, majuscules, chiffres ou les " + "caractères spéciaux suivants: _, \\, -, /." + ) + + if not match(regex_chemin, arguments_parses.sortie): + raise ArgumentCLIException( + "Le chemin du fichier de sortie doit uniquement contenir les caractères " + "autorisés. Les caractères autorisés sont les minuscules, majuscules, " + "chiffres ou les caractères spéciaux suivants: _, \\, -, /." + ) + + if not arguments_parses.sortie.endswith(".json"): + raise ArgumentCLIException( + "Le fichier de sortie doit obligatoirement être un fichier au format json." + ) + + return arguments_parses + + +class ArgumentCLIException(Exception): + """ + Représente une erreur lorsque un argument passé en ligne de commande + est inconnu ou que son format est invalide. + """ + + def __init__(self, *args): + super().__init__(*args) diff --git a/app/donnees/client_informations.py b/app/donnees/client_informations.py new file mode 100644 index 0000000..4834de4 --- /dev/null +++ b/app/donnees/client_informations.py @@ -0,0 +1,49 @@ +""" +Module relatif aux informations d'un client dans un fichier de log Apache. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ClientInformations: + """ + Représente les informations d'un client à partir d'une entrée d'un log Apache. + + Cette classe regroupe les données extraites d'une entrée de log, + qui concernent le client ayant effectué la requête au serveur Apache. + + Attributes: + adresse_ip (str): L'adresse IP du client. + identifiant_rfc (Optional[str]): L'identifiant RFC du client. + Peut être None si non fournie. + nom_utilisateur (Optional[str]): Le nom de l'utilisateur authentifié. + Peut être None si non fournie. + agent_utilisateur (Optional[str]): L'agent utilisateur (User-Agent). + Peut être None si non fournie. + """ + adresse_ip: str + identifiant_rfc: Optional[str] + nom_utilisateur: Optional[str] + agent_utilisateur: Optional[str] + + def __post_init__(self): + """ + Vérifie le bon type des données de cette classe lors de l'initialisation de l'instance. + + Raises: + TypeError: Une donnée n'est pas du bon type. + """ + # Validation de l'adresse IP + if not isinstance(self.adresse_ip, str): + raise TypeError("L'adresse IP est obligatoire et doit être une chaîne de caractères.") + # Validation de l'identifiant RFC + if self.identifiant_rfc is not None and not isinstance(self.identifiant_rfc, str): + raise TypeError("L'identifiant RFC doit être une chaîne de caractères ou None.") + # Validation du nom d'utilisateur + if self.nom_utilisateur is not None and not isinstance(self.nom_utilisateur, str): + raise TypeError("Le nom d'utilisateur doit être une chaîne de caractères ou None.") + # Validation de l'agent utilisateur + if self.agent_utilisateur is not None and not isinstance(self.agent_utilisateur, str): + raise TypeError("L'agent utilisateur doit être une chaîne de caractères ou None.") diff --git a/app/donnees/reponse_informations.py b/app/donnees/reponse_informations.py new file mode 100644 index 0000000..b1c6fff --- /dev/null +++ b/app/donnees/reponse_informations.py @@ -0,0 +1,42 @@ +""" +Module relatif aux informations de la réponse dans un fichier de log Apache. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ReponseInformations: + """ + Représente les informations de la réponse HTTP à partir d'une entrée d'un log Apache. + + Cette classe regroupe les données extraites d'une entrée de log, + qui concernent les informations techniques sur la réponse émise par + le serveur Apache au client. + + Attributes: + code_statut_http (int): Le code de statut HTTP. + taille_octets (Optional[int]): La taille de la réponse en octets. + Peut être None si non fournie. + """ + + code_statut_http: int + taille_octets: Optional[int] + + def __post_init__(self): + """ + Vérifie le bon type des données de cette classe lors de l'initialisation de l'instance. + + Raises: + TypeError: Une donnée n'est pas du bon type. + """ + # Vérification du code de statut HTTP + if (not isinstance(self.code_statut_http, int) + or isinstance(self.code_statut_http, bool)): + raise TypeError("Le code de statut HTTP doit être un entier.") + # Vérification de la taille de la réponse (en octets) + if (self.taille_octets is not None + and not isinstance(self.taille_octets, int) + or isinstance(self.taille_octets, bool)): + raise TypeError("La taille en octets doit être un entier ou None.") diff --git a/app/donnees/requete_informations.py b/app/donnees/requete_informations.py new file mode 100644 index 0000000..2206227 --- /dev/null +++ b/app/donnees/requete_informations.py @@ -0,0 +1,56 @@ +""" +Module relatif aux informations de la requete dans un fichier de log Apache. +""" + +from dataclasses import dataclass +from typing import Optional +from datetime import datetime + +@dataclass +class RequeteInformations: + """ + Représente les informations de la requête HTTP à partir d'une entrée d'un log Apache. + + Cette classe regroupe les données extraites d'une entrée de log, + qui concernent les informations techniques sur la requête émise au + serveur Apache. + + Attributes: + horodatage (datetime): L'horodatage de la requête. + methode_http (Optional[str]): La méthode HTTP utilisée. + Peut être None si non fournie. + url (Optional[str]): L'URL cible de la requête. + Peut être None si non fournie. + protocole_http (Optional[str]): Le protocole HTTP utilisé. + Peut être None si non fournie. + ancienne_url (Optional[str]): L'URL de provenance (referrer). + Peut être None si non fournie. + """ + horodatage: datetime + methode_http: Optional[str] + url: Optional[str] + protocole_http: Optional[str] + ancienne_url: Optional[str] + + def __post_init__(self): + """ + Vérifie le bon type des données de cette classe lors de l'initialisation de l'instance. + + Raises: + TypeError: Une donnée n'est pas du bon type. + """ + # Vérification de l'horodatage + if not isinstance(self.horodatage, datetime): + raise TypeError("L'horodatage doit être de type datetime.") + # Vérification de la méthode HTTP + if self.methode_http is not None and not isinstance(self.methode_http, str): + raise TypeError("La méthode HTTP doit être une chaine de caractère ou None.") + # Vérification de la ressource demandée + if self.url is not None and not isinstance(self.url, str): + raise TypeError("L'URL doit être une chaine de caractère ou None.") + # Vérification du protocole HTTP + if self.protocole_http is not None and not isinstance(self.protocole_http, str): + raise TypeError("Le protocole HTTP doit être une chaine de caractère ou None.") + # Vérification de l'ancienne URL + if self.ancienne_url is not None and not isinstance(self.ancienne_url, str): + raise TypeError("L'ancienne URL doit être une chaine de caractère ou None.") diff --git a/app/export/exporteur.py b/app/export/exporteur.py new file mode 100644 index 0000000..bbcc3c7 --- /dev/null +++ b/app/export/exporteur.py @@ -0,0 +1,99 @@ +""" +Module pour l'exportation des données. +""" + +from os.path import abspath, dirname, isdir +from json import dump + + +class Exporteur: + """ + Représente un exporteur de données pour exporter des données + vers un fichier de sortie. + + Attributes: + _chemin_sortie (str): Le chemin du fichier vers lequel + les données seront exportées. + """ + + def __init__(self, chemin_sortie: str): + """ + Initialise un exporteur de données. + + Args: + chemin_sortie (str): Le chemin du fichier vers lequel + les données seront exportées. + + Raises: + TypeError: Le chemin de sortie n'est pas une chaîne de caractère. + ExportationDossierParentException: Exportation impossible à cause de + l'inexistance du dossier parent du fichier d'exportation. + """ + # Vérification du type du paramètre + if not isinstance(chemin_sortie, str): + raise TypeError("Le chemin de sortie doit être une chaîne de caractère.") + # Vérification du chemin d'exportation + self.verification_exportation_possible(chemin_sortie) + # Ajout du chemin d'exportation + self._chemin_sortie = chemin_sortie + + def verification_exportation_possible(self, chemin_sortie: str) -> None: + """ + Vérifie qu'une exportation est possible vers le chemin du fichier indiqué. Renvoie une + exception expliquant le problème si elle n'est pas possible. + + Args: + chemin_sortie (str): Le chemin du fichier d'exportation. + + Returns: + None + + Raises: + ExportationDossierParentException: Le dossier parent du fichier n'existe pas. + """ + # Vérification du type du paramètre + if not isinstance(chemin_sortie, str): + raise TypeError("Le chemin de sortie doit être une chaîne de caractères.") + # Vérification du chemin + chemin_sortie_absolue = abspath(chemin_sortie) + dossier_parent = dirname(chemin_sortie_absolue) + if not isdir(dossier_parent): + raise ExportationDossierParentException(f"Impossible d'exporter vers le " + f"fichier {chemin_sortie}, son dossier parent " + f"{dossier_parent} n'existe pas.") + + def export_vers_json(self, donnees: dict) -> None: + """ + Export le dictionnaire fourni vers le :attr:`chemin de sortie`. + + Args: + donnees (dict): Le dictionnaire qui contient les données. + + Returns: + None + + Raises: + TypeError: Le paramètre ``donnees`` n'est pas un dictionnaire. + ExportationException: Une erreur lors de l'écriture dans le fichier JSON. + """ + # Vérification du type du paramètre + if not isinstance(donnees, dict): + raise TypeError("Les données à exporter doivent être sous une forme " + "de dictionnaire.") + # Exportation + try: + with open(self._chemin_sortie, 'w', encoding="utf-8") as fichier: + dump(donnees, fichier, indent=4) + except Exception as ex: + raise ExportationException(str(ex)) from ex + +class ExportationException(Exception): + """ + Représente une erreur lors de l'exportation de données. + """ + +class ExportationDossierParentException(ExportationException): + """ + Représente une erreur lorsque une exportation est impossible + lorsque le dossier parent du fichier d'exportation n'existe pas. + """ diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..55f5911 --- /dev/null +++ b/app/main.py @@ -0,0 +1,64 @@ +""" +Point d'entrée de l'application LogBuster ! +""" +from cli.afficheur_cli import AfficheurCLI +from cli.parseur_arguments_cli import ParseurArgumentsCLI, ArgumentCLIException +from parse.parseur_log_apache import ParseurLogApache, ParsageLogApacheException +from analyse.analyseur_log_apache import AnalyseurLogApache +from export.exporteur import Exporteur, ExportationException + + +def main() -> None: + """ + Point d'entrée de l'application. + + Returns: + None + """ + afficheur_cli = AfficheurCLI() + afficheur_cli.affiche_message("Who ya gonna call? LogBuster!") + try: + # Récupération des arguments + parseur_cli = ParseurArgumentsCLI() + arguments_cli = parseur_cli.parse_args() + # Lance l'animation de chargement + afficheur_cli.lance_animation_chargement() + # Analyse syntaxique du fichier log + parseur_log = ParseurLogApache(arguments_cli.chemin_log) + fichier_log = parseur_log.parse_fichier() + # Analyse statistique du fichier log + analyseur_log = AnalyseurLogApache(fichier_log) + analyse = analyseur_log.get_analyse_complete() + # Exportation de l'analyse + exporteur = Exporteur(arguments_cli.sortie) + exporteur.export_vers_json(analyse) + # Termine l'animation de chargement + afficheur_cli.stop_animation_chargement() + except ArgumentCLIException as ex: + gestion_exception(afficheur_cli, "Erreur dans les arguments fournis !", ex) + except ParsageLogApacheException as ex: + gestion_exception(afficheur_cli, "Erreur dans l'analyse du log Apache !", ex) + except ExportationException as ex: + gestion_exception(afficheur_cli, "Erreur dans l'exportation de l'analyse !", ex) + except (ValueError, TypeError) as ex: + gestion_exception(afficheur_cli, "Erreur interne !", ex) + +def gestion_exception(afficheur_cli: AfficheurCLI, message: str, exception: Exception) -> None: + """ + Gère les erreurs qui demandent une fin du programme. + Affiche un message d'erreur personnalisé ainsi que les détails de l'exception. + + Args: + afficheur_cli (AfficheurCLI): L'objet permettant d'intéragir avec la ligne + de commande. + message (str): Message principal à afficher. + exception (Exception): L'exception qui s'est produite. + + Returns: + None + """ + afficheur_cli.stop_animation_chargement(True) + afficheur_cli.affiche_erreur(message, exception) + +if __name__ == "__main__": + main() diff --git a/app/parse/entree_log_apache.py b/app/parse/entree_log_apache.py new file mode 100644 index 0000000..3a68075 --- /dev/null +++ b/app/parse/entree_log_apache.py @@ -0,0 +1,47 @@ +""" +Module qui contient la classe pour représenter une entrée log Apache. +""" + +from dataclasses import dataclass +from donnees.client_informations import ClientInformations +from donnees.requete_informations import RequeteInformations +from donnees.reponse_informations import ReponseInformations + + +@dataclass +class EntreeLogApache: + """ + Représente une entrée dans un fichier de log Apache. + + Attributes: + client (ClientInformations): Les informations du client. + requete (RequeteInformations): Les informations de la requête. + reponse (ReponseInformations): Les informations de la réponse. + """ + client: ClientInformations + requete: RequeteInformations + reponse: ReponseInformations + + def __post_init__(self): + """ + Vérifie le bon type des données de cette classe lors de l'initialisation de l'instance. + + Raises: + TypeError: Une donnée n'est pas du bon type. + """ + # Validation des informations + if not isinstance(self.client, ClientInformations): + raise TypeError( + "Les informations du client dans une entrée doivent être" + "regroupées au sein d'un objet ClientInformations." + ) + if not isinstance(self.requete, RequeteInformations): + raise TypeError( + "Les informations de la requête dans une entrée doivent être" + "regroupées au sein d'un objet RequeteInformations." + ) + if not isinstance(self.reponse, ReponseInformations): + raise TypeError( + "Les informations de la réponse dans une entrée doivent être" + "regroupées au sein d'un objet ReponseInformations." + ) diff --git a/app/parse/fichier_log_apache.py b/app/parse/fichier_log_apache.py new file mode 100644 index 0000000..0adc2e7 --- /dev/null +++ b/app/parse/fichier_log_apache.py @@ -0,0 +1,53 @@ +""" +Module qui contient la classe pour représenter un fichier log Apache. +""" + +from dataclasses import dataclass, field +from parse.entree_log_apache import EntreeLogApache + + +@dataclass +class FichierLogApache: + """ + Représente un fichier de log Apache. + + Attributes: + chemin (str): Le chemin du fichier. + entrees (list): La liste des entrées du fichier. + """ + chemin: str + entrees: list = field(default_factory=list) + + def __post_init__(self): + """ + Vérifie le bon type des données de cette classe lors de l'initialisation de l'instance. + + Raises: + TypeError: Une donnée n'est pas du bon type. + """ + # Validation du chemin + if not isinstance (self.chemin, str): + raise TypeError("Le chemin du fichier doit être une chaîne de caractère.") + if not isinstance (self.entrees, list): + raise TypeError("La liste des entrées doit être une liste.") + + def ajoute_entree(self, entree: EntreeLogApache) -> None: + """ + Ajoute une entrée à la liste des entrées du fichier. + + Args: + entree (EntreeLogApache): L'entrée à ajouter. + + Returns: + None + + Raises: + TypeError: L'entrée ``entree`` n'est pas un objet :class:`EntreeLogApache`. + """ + # Vérification du type du paramètre + if not isinstance (entree, EntreeLogApache): + raise TypeError("Les informations de l'entrée doivent être dans un objet" + "EntreeLogApache.") + + # Récupération de l'entrée + self.entrees.append(entree) diff --git a/app/parse/parseur_log_apache.py b/app/parse/parseur_log_apache.py new file mode 100644 index 0000000..f1fa9e8 --- /dev/null +++ b/app/parse/parseur_log_apache.py @@ -0,0 +1,266 @@ +""" +Module pour parser un fichier log Apache. +""" + +import os +from re import match +from datetime import datetime +from typing import Optional +from parse.fichier_log_apache import FichierLogApache +from parse.entree_log_apache import EntreeLogApache +from donnees.client_informations import ClientInformations +from donnees.requete_informations import RequeteInformations +from donnees.reponse_informations import ReponseInformations + + +class ParseurLogApache(): + """ + Représente un parseur pour faire une analyse synthaxique d'un fichier + log Apache. + + Class-level variables: + :cvar PATTERN_ENTREE_LOG_APACHE (str): Le pattern regex d'une entrée dans un log Apache. + """ + + PATTERN_ENTREE_LOG_APACHE: str = ( + r'(?P\S+) (?P\S+) (?P\S+)' + r' (\[(?P\d{2}\/\w{3}\/\d{4}:\d{1,2}:\d{1,2}:\d{1,2} \+\d{4})\]|-)' + r' "((?P\S+) (?P\S+) (?P\S+)|-)"' + r' (?P\d+) (?P\d+|-)' + r'( "(?P.*?)")?( "(?P.*?)")?' + ) + + def __init__(self, chemin_log): + """ + Initialise un nouveau parseur de fichier log Apache et vérifie que + le fichier passé en paramètre existe. + + Args: + chemin_log (str): Le chemin du fichier à analyser. + + Raises: + TypeError: Le chemin ``chemin_log`` n'est pas de type ``str``. + FichierLogApacheIntrouvableException: Si le fichier à analyser est introuvable. + """ + # Vérification du type du paramètre + if not isinstance(chemin_log, str): + raise TypeError("Le chemin du log doit être une chaîne de caractères.") + # Vérification du chemin + if not os.path.isfile(chemin_log): + raise FichierLogApacheIntrouvableException(f"Le fichier {chemin_log} est introuvable.") + # Ajout du chemin + self.chemin_log = chemin_log + + def parse_fichier(self) -> FichierLogApache: + """ + Effectue une analyse syntaxique du fichier de log Apache puis retourne + une représentation du fichier avec les informations trouvées. + + Returns: + log_analyse (FichierLogApache): Représentation du fichier. + + Raises: + FormatLogApacheInvalideException: Format du fichier log invalide. + """ + # Initialisation de la représentation du fichier + log_analyse = FichierLogApache(self.chemin_log) + # Ouverture du log + with open(self.chemin_log, "r", encoding="utf-8") as log: + # Parcours des entrées du log + for numero_ligne, ligne in enumerate(log, start=1): + try: + # Parsage de l'entrée + entree = self.parse_entree(ligne) + log_analyse.ajoute_entree(entree) + except FormatLogApacheInvalideException as ex: + raise FormatLogApacheInvalideException( + f"Le format de l'entrée à la ligne {numero_ligne} " + f"('{ligne.strip()}') est invalide." + ) from ex + + return log_analyse + + def parse_entree(self, entree: str) -> EntreeLogApache: + """ + Effectue une analyse syntaxique d'une entrée dans un fichier de log + Apache puis retourne une représentation de l'entrée avec les + informations trouvées. + + Args: + entree (str): Entrée à analyser. + + Returns: + entree_analysee (EntreeLogApache): Représentation de l'entrée. + + Raises: + TypeError: L'entrée ``entree`` n'est pas de type :class:`EntreeLogApache` + FormatLogApacheInvalideException: Format de l'entrée du fichier log invalide. + """ + # Vérification du type du paramètre + if not isinstance(entree, str): + raise TypeError("L'entrée doit être représentée sous forme de chaîne de caractères.") + + # Analyse de l'entrée + analyse = match(self.PATTERN_ENTREE_LOG_APACHE, entree) + if not analyse: + raise FormatLogApacheInvalideException() + + # Extraction des résultats d'analyse + resultat_analyse = analyse.groupdict() + + # Récupération des informations liées au client + informations_client = self._extraire_informations_client(resultat_analyse) + + # Récupération des informations liées à la requête + informations_requete = self._extraire_informations_requete(resultat_analyse) + + # Récupération des informations liées à la réponse + informations_reponse = self._extraire_informations_reponse(resultat_analyse) + + # Retour des informations regroupées dans l'objet EntreeLogApache + return EntreeLogApache( + informations_client, informations_requete, informations_reponse + ) + + def _extraire_informations_client(self, analyse_regex: dict) -> ClientInformations: + """ + Extrait les informations liées au client depuis l'analyse regex d'une entrée. + + Args: + analyse_regex (dict): Analyse regex d'une entrée. + + Returns: + ClientInformations: Les informations du client. + + Raises: + TypeError: L'analyse ``analyse_regex`` n'est pas un ``dict``. + FormatLogApacheInvalideException: L'adresse IP n'est pas présente dans l'analyse. + """ + # Vérification du type du paramètre + if not isinstance(analyse_regex, dict): + raise TypeError("L'analyse du regex doit être un dictionnaire.") + + # Adresse IP + adresse_ip = self.get_information_entree(analyse_regex, "ip") + if adresse_ip is None: + raise FormatLogApacheInvalideException("L'adresse IP est obligatoire.") + # Identifiant RFC + identifiant_rfc = self.get_information_entree(analyse_regex, "rfc") + # Nom de l'utilisateur + utilisateur = self.get_information_entree(analyse_regex, "utilisateur") + # User-Agent + agent_utilisateur = self.get_information_entree(analyse_regex, "agent_utilisateur") + + return ClientInformations( + adresse_ip, identifiant_rfc, utilisateur, agent_utilisateur + ) + + def _extraire_informations_requete(self, analyse_regex: dict) -> RequeteInformations: + """ + Extrait les informations liées à la requête depuis l'analyse regex d'une entrée. + + Args: + analyse_regex (dict): Analyse regex d'une entrée. + + Returns: + RequeteInformations: Les informations de la requête. + + Raises: + TypeError: L'analyse ``analyse_regex`` n'est pas un ``dict``. + FormatLogApacheInvalideException: L'horodatage n'est pas présente dans l'analyse. + """ + # Vérification du type du paramètre + if not isinstance(analyse_regex, dict): + raise TypeError("L'analyse du regex doit être un dictionnaire.") + + # Horodatage + horodatage = self.get_information_entree(analyse_regex, "horodatage") + if horodatage: + horodatage = datetime.strptime(horodatage, "%d/%b/%Y:%H:%M:%S %z") + if horodatage is None: + raise FormatLogApacheInvalideException("L'horodatage est obligatoire.") + # Méthode HTTP + methode_http = self.get_information_entree(analyse_regex, "methode") + # URL de la ressource + url = self.get_information_entree(analyse_regex, "url") + # Protocole HTTP + protocole_http = self.get_information_entree(analyse_regex, "protocole") + # URL de la précédente ressource demandée + ancienne_url = self.get_information_entree(analyse_regex, "ancienne_url") + + return RequeteInformations( + horodatage, methode_http, url, protocole_http, ancienne_url + ) + + def _extraire_informations_reponse(self, analyse_regex: dict) -> ReponseInformations: + """ + Extrait les informations liées à la réponse depuis l'analyse regex d'une entrée. + + Args: + analyse_regex (dict): Analyse regex d'une entrée. + + Returns: + TypeError: L'analyse ``analyse_regex`` n'est pas un ``dict``. + ReponseInformations: Les informations de la réponse. + """ + # Vérification du type du paramètre + if not isinstance(analyse_regex, dict): + raise TypeError("L'analyse du regex doit être un dictionnaire.") + + # Code de statut + code_statut = self.get_information_entree(analyse_regex, "code_status") + code_statut = int(code_statut) + # Taille de la réponse + taille_octets = self.get_information_entree(analyse_regex, "taille_octets") + if taille_octets: + taille_octets = int(taille_octets) + + return ReponseInformations( + code_statut, taille_octets + ) + + def get_information_entree(self, analyse_regex: dict, nom_information: str) -> Optional[str]: + """ + Retourne la valeur de l'information dans l'analyse si elle possède une valeur + ou None si elle ne possède pas de valeur (égale à - ou vide). + + Args: + analyse_regex (Match[str]): Résultat du regex de l'analyse. + nom_information (str): Nom de l'information souhaitée. + + Returns: + Optional[str]: La valeur sous forme de chaîne de caractère ou None si + aucune valeur n'a été trouvée. + + Raises: + TypeError: Un paramètre n'a pas le bon type. + """ + # Vérification du type des paramètres + if not isinstance(analyse_regex, dict): + raise TypeError("L'analyse du regex doit être un dictionnaire.") + if not isinstance(nom_information, str): + raise TypeError("Le nom de l'information doit être une chaîne de caractères.") + + # Récupération de l'information + valeur = analyse_regex.get(nom_information) + return valeur if valeur not in ("", "-") else None + +class ParsageLogApacheException(Exception): + """ + Exception représentant une erreur lors du parsage du fichier + de log Apache. + """ + def __init__(self, *args): + super().__init__(*args) + +class FichierLogApacheIntrouvableException(ParsageLogApacheException): + """ + Exception représentant une erreur lorsque le fichier de log Apache + est introuvable. + """ + +class FormatLogApacheInvalideException(ParsageLogApacheException): + """ + Exception représentant une erreur dans le format du fichier + de log Apache fourni. + """ diff --git a/assets/animations.json b/assets/animations.json new file mode 100644 index 0000000..eb38a6f --- /dev/null +++ b/assets/animations.json @@ -0,0 +1,18 @@ +{ + "chasseurs": [ + ["(҂-_•)⊃═O", "(҂x_x)", "d(•᎑-҂)"], + ["(⌐■_■)⊃═O", "(⌐x_x)", "d(■᎑■⌐)"], + ["(∩•_•)⊃═O", "(∩x_x)", "d(•᎑•∩)"], + ["(ò_ó)⊃═O", "(x_x)", "d(ò᎑ó)"] + ], + + "fantomes": [ + ["ε=(( ꐑº-° )ꐑ", "(( x-x)", "ε=(( ꐑº᎑° )ꐑ"], + ["ε=(¬ ´ཀ` )¬", "( xཀx)", "ε=(¬ ´᎑` )¬"], + ["ε=༼ つ ╹ ╹ ༽つ", "༼ x x ༽", "ε=༼ つ ╹᎑╹ ༽つ"], + ["ε=( >_<)", "( x_x)", "ε=( >᎑<)"], + ["ε=(ง'̀-'́)ง", "('x-x)", "ε=(ง'̀᎑'́)ง"] + ], + + "rayons_laser": ["-", "=", "~", "*", "~", "=", "-"] +} \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..dc1312a --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/_static/documentation.css b/docs/source/_static/documentation.css new file mode 100644 index 0000000..81f0a2f --- /dev/null +++ b/docs/source/_static/documentation.css @@ -0,0 +1,4 @@ +/* Étendre le contenu principal sur toute la largeur */ +.wy-nav-content { + max-width: 100%; +} \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..507551a --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,39 @@ +import os +import sys + +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'LogBuster' +copyright = '2025, Anthony GUILLAUMA' +author = 'Anthony GUILLAUMA' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon'] + +templates_path = ['_templates'] +exclude_patterns = [] + +language = 'fr' + +# Ajout du chemin vers les modules +sys.path.insert(0, os.path.abspath('../../app')) + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] +html_css_files = ["documentation.css"] +html_theme_options = { + "collapse_navigation": False, # Pour éviter la fermeture automatique + "sticky_navigation": True, # Pour rendre le menu toujours visible + "titles_only": False # Pour éviter de masquer les sous-sections +} \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..c15e472 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,102 @@ +.. Documentation de LogBuster + +👻 Bienvenue dans la documentation de **LogBuster** +==================================================== + +**LogBuster** est un outil puissant pour analyser les fichiers de logs Apache. +Grâce à LogBuster, vous pouvez extraire des informations essentielles, gérer des erreurs de format et bien plus encore. + +.. code:: text + + .-. .-') .-') .-') _ ('-. _ .-') ,---. + \ ( OO ) ( OO ). ( OO) ) _( OO)( \( -O ) | | + ,--. .-'),-----. ,----. ;-----.\ ,--. ,--. (_)---\_)/ '._(,------.,------. | | + | |.-') ( OO' .-. ' ' .-./-') | .-. | | | | | / _ | |'--...__)| .---'| /`. '| | + | | OO )/ | | | | | |_( O- )| '-' /_) | | | .-') \ :` `. '--. .--'| | | / | || | + | |`-' |\_) | |\| | | | .--, \| .-. `. | |_|( OO ) '..`''.) | | (| '--. | |_.' || .' + (| '---.' \ | | | |(| | '. (_/| | \ | | | | `-' /.-._) \ | | | .--' | . '.'`--' + | | `' '-' ' | '--' | | '--' /(' '-'(_.-' \ / | | | `---.| |\ \ .--. + `------' `-----' `------' `------' `-----' `-----' `--' `------'`--' '--''--' + +**(҂-_•)⊃═O Fonctionnalités principales** +------------------------------------------- + +- **Extraire des statistiques clés** : Obtenez des données précieuses sur vos fichiers de logs. +- **Exporter les données en JSON** : Accédez à un format structuré pour vos analyses. +- **Gérer les erreurs de format avec précision** : Identifiez rapidement les anomalies et les erreurs de vos fichiers log. + +**d(■᎑■⌐) Utilisation** +--------------------------- + +``` +python app/main.py chemin_log [-s SORTIE] +``` + +- `chemin_log` : Le chemin vers le fichier de log Apache à analyser. +- `-s SORTIE` (optionnel) : Le chemin où sauvegarder les résultats de l'analyse. Si non spécifié, les résultats seront sauvegardés dans un fichier `analyse-log-apache.json`. + +**(ง'̀᎑'́)ง Format des fichier de log Apache** +------------------------------------------------ + +Le format de log Apache pris en charge est celui utilisé par le fichier `access.log`. +Ces logs Apache contiennent des informations détaillées sur les requêtes HTTP traitées par le serveur. +Chaque ligne d'un fichier représente une requête individuelle, et les informations sont généralement séparées par des espaces ou des caractères spécifiques. + +Format commun (Common Log Format) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Un fichier de log Apache standard suit généralement un format similaire au suivant : + +``127.0.0.1 - - [10/Oct/2025:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326`` + +1. **IP de l'utilisateur** (127.0.0.1) : L'adresse IP de l'utilisateur qui a effectué la requête. +2. **Identifiant de l'utilisateur** (vide ici) : L'identifiant de l'utilisateur, souvent vide ou un tiret (`-`). +3. **Identifiant de l'utilisateur authentifié** (vide ici) : Si l'utilisateur est authentifié, cet identifiant sera visible, sinon il sera également vide ou un tiret (`-`). +4. **Date et heure de la requête** ([10/Oct/2025:13:55:36 +0000]) : La date et l'heure précises de la requête, suivies du fuseau horaire. +5. **Requête HTTP** ("GET /index.html HTTP/1.1") : La méthode HTTP utilisée (ici `GET`), l'URL demandée (ici `/index.html`), et la version du protocole HTTP (ici `HTTP/1.1`). +6. **Code de statut HTTP** (200) : Le code de statut retourné par le serveur (ici, `200` indique que la requête a réussi). +7. **Taille de la réponse** (2326) : La taille en octets de la réponse envoyée au client. + +Dans LogBuster, l'**IP de l'utilisateur** et la **Date et heure de la requête** sont obligatoires, sinon un message d'erreur est retournée. + +Format combiné (Combined Log Format) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Le format étendu permet d'ajouter plus de détails sur chaque requête. +Voici un exemple de ligne de log avec ce format : + +``127.0.0.1 - - [10/Oct/2025:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326 "http://referrer.com" "Mozilla/5.0"`` + +Dans ce format, deux informations supplémentaires à la fin de l'entrée sont présentes : + +1. **Référent HTTP** ("http://referrer.com") : L'URL de la page depuis laquelle la requête a été faite. Cela peut être vide si la requête provient directement de l'utilisateur sans référence. +2. **Agent utilisateur** ("Mozilla/5.0") : L'agent utilisateur indique quel navigateur ou appareil a effectué la requête. + +Assurez-vous que votre fichier de log Apache suit un format cohérent, comme ceux mentionnés ci-dessus, afin d'obtenir des résultats précis et fiables lors de l'utilisation de LogBuster. +Pour plus d'informations, consultez la documentation Apache sur ce lien : https://httpd.apache.org/docs/2.4/fr/logs.html + +**(∩x_x) Précautions concernant l'affichage des caractères spéciaux** +---------------------------------------------------------------------- + +Le projet LogBuster utilise des caractères **Unicode**, tels que des symboles spéciaux, dans le terminal pour rendre l'affichage plus plaisant. Assurez-vous que votre terminal est configuré pour prendre en charge l'affichage de caractères Unicode afin de profiter pleinement de l'expérience utilisateur. + +Si vous rencontrez des problèmes d'affichage (comme des symboles manquants ou mal rendus), vous pouvez essayer les solutions suivantes : + +- Utiliser un terminal compatible avec Unicode (par exemple, Terminal sous macOS, Windows Terminal sous Windows, ou des terminaux comme GNOME Terminal ou Konsole sous Linux). +- Vérifier que votre terminal utilise une police qui prend en charge les caractères Unicode (par exemple, DejaVu Sans Mono ou Consolas). + +**ε=(( ꐑº-° )ꐑ Documentation du projet** +------------------------------------------- + +Consultez les différentes sections pour en savoir plus sur le projet **LogBuster** : + +.. toctree:: + :maxdepth: 4 + :caption: Contenu + :numbered: + + modules/index_modules.rst + +--- + +© 2025 - Projet LogBuster diff --git a/docs/source/modules/analyse/analyseur_log_apache.rst b/docs/source/modules/analyse/analyseur_log_apache.rst new file mode 100644 index 0000000..69138a6 --- /dev/null +++ b/docs/source/modules/analyse/analyseur_log_apache.rst @@ -0,0 +1,7 @@ +AnalyseurLogApache +====================== + +.. automodule:: analyse.analyseur_log_apache + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/modules/analyse/index_analyse.rst b/docs/source/modules/analyse/index_analyse.rst new file mode 100644 index 0000000..64ad521 --- /dev/null +++ b/docs/source/modules/analyse/index_analyse.rst @@ -0,0 +1,8 @@ +Analyse +=========== + +.. toctree:: + :maxdepth: 4 + + analyseur_log_apache.rst + \ No newline at end of file diff --git a/docs/source/modules/cli/afficheur_cli.rst b/docs/source/modules/cli/afficheur_cli.rst new file mode 100644 index 0000000..ff28b66 --- /dev/null +++ b/docs/source/modules/cli/afficheur_cli.rst @@ -0,0 +1,7 @@ +AfficheurCLI +====================== + +.. automodule:: cli.afficheur_cli + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/modules/cli/index_cli.rst b/docs/source/modules/cli/index_cli.rst new file mode 100644 index 0000000..3f644e3 --- /dev/null +++ b/docs/source/modules/cli/index_cli.rst @@ -0,0 +1,8 @@ +CLI +=========== + +.. toctree:: + :maxdepth: 4 + + parseur_arguments_cli.rst + afficheur_cli.rst diff --git a/docs/source/modules/cli/parseur_arguments_cli.rst b/docs/source/modules/cli/parseur_arguments_cli.rst new file mode 100644 index 0000000..2ffeeef --- /dev/null +++ b/docs/source/modules/cli/parseur_arguments_cli.rst @@ -0,0 +1,7 @@ +ParseurArgumentsCLI +====================== + +.. automodule:: cli.parseur_arguments_cli + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/modules/donnees/client_informations.rst b/docs/source/modules/donnees/client_informations.rst new file mode 100644 index 0000000..3671f3e --- /dev/null +++ b/docs/source/modules/donnees/client_informations.rst @@ -0,0 +1,8 @@ +ClientInformations +====================== + +.. automodule:: donnees.client_informations + :members: + :show-inheritance: + :undoc-members: + :exclude-members: adresse_ip, identifiant_rfc, nom_utilisateur, agent_utilisateur diff --git a/docs/source/modules/donnees/index_donnees.rst b/docs/source/modules/donnees/index_donnees.rst new file mode 100644 index 0000000..b863f8b --- /dev/null +++ b/docs/source/modules/donnees/index_donnees.rst @@ -0,0 +1,9 @@ +Données +=========== + +.. toctree:: + :maxdepth: 4 + + client_informations.rst + requete_informations.rst + reponse_informations.rst \ No newline at end of file diff --git a/docs/source/modules/donnees/reponse_informations.rst b/docs/source/modules/donnees/reponse_informations.rst new file mode 100644 index 0000000..e94a60f --- /dev/null +++ b/docs/source/modules/donnees/reponse_informations.rst @@ -0,0 +1,8 @@ +ReponseInformations +====================== + +.. automodule:: donnees.reponse_informations + :members: + :show-inheritance: + :undoc-members: + :exclude-members: code_statut_http, taille_octets diff --git a/docs/source/modules/donnees/requete_informations.rst b/docs/source/modules/donnees/requete_informations.rst new file mode 100644 index 0000000..d504b3e --- /dev/null +++ b/docs/source/modules/donnees/requete_informations.rst @@ -0,0 +1,8 @@ +RequeteInformations +====================== + +.. automodule:: donnees.requete_informations + :members: + :show-inheritance: + :undoc-members: + :exclude-members: horodatage, methode_http, url, protocole_http, ancienne_url diff --git a/docs/source/modules/export/exporteur.rst b/docs/source/modules/export/exporteur.rst new file mode 100644 index 0000000..0c09299 --- /dev/null +++ b/docs/source/modules/export/exporteur.rst @@ -0,0 +1,7 @@ +Exporteur +========== + +.. automodule:: export.exporteur + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/modules/export/index_export.rst b/docs/source/modules/export/index_export.rst new file mode 100644 index 0000000..27e733e --- /dev/null +++ b/docs/source/modules/export/index_export.rst @@ -0,0 +1,7 @@ +Export +=========== + +.. toctree:: + :maxdepth: 4 + + exporteur.rst diff --git a/docs/source/modules/index_modules.rst b/docs/source/modules/index_modules.rst new file mode 100644 index 0000000..0760dfa --- /dev/null +++ b/docs/source/modules/index_modules.rst @@ -0,0 +1,13 @@ +Modules +======================== + +.. toctree:: + :maxdepth: 4 + :caption: Modules: + + main.rst + cli/index_cli.rst + parse/index_parse.rst + donnees/index_donnees.rst + analyse/index_analyse.rst + export/index_export.rst \ No newline at end of file diff --git a/docs/source/modules/main.rst b/docs/source/modules/main.rst new file mode 100644 index 0000000..94b7718 --- /dev/null +++ b/docs/source/modules/main.rst @@ -0,0 +1,7 @@ +Main +====================== + +.. automodule:: main + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/modules/parse/entree_log_apache.rst b/docs/source/modules/parse/entree_log_apache.rst new file mode 100644 index 0000000..57699a2 --- /dev/null +++ b/docs/source/modules/parse/entree_log_apache.rst @@ -0,0 +1,8 @@ +EntreeLogApache +====================== + +.. automodule:: parse.entree_log_apache + :members: + :show-inheritance: + :undoc-members: + :exclude-members: client, requete, reponse diff --git a/docs/source/modules/parse/fichier_log_apache.rst b/docs/source/modules/parse/fichier_log_apache.rst new file mode 100644 index 0000000..285677a --- /dev/null +++ b/docs/source/modules/parse/fichier_log_apache.rst @@ -0,0 +1,8 @@ +FichierLogApache +====================== + +.. automodule:: parse.fichier_log_apache + :members: + :show-inheritance: + :undoc-members: + :exclude-members: chemin, entrees diff --git a/docs/source/modules/parse/index_parse.rst b/docs/source/modules/parse/index_parse.rst new file mode 100644 index 0000000..13c7002 --- /dev/null +++ b/docs/source/modules/parse/index_parse.rst @@ -0,0 +1,9 @@ +Parse +=========== + +.. toctree:: + :maxdepth: 4 + + parseur_log_apache.rst + fichier_log_apache.rst + entree_log_apache.rst diff --git a/docs/source/modules/parse/parseur_log_apache.rst b/docs/source/modules/parse/parseur_log_apache.rst new file mode 100644 index 0000000..173d73f --- /dev/null +++ b/docs/source/modules/parse/parseur_log_apache.rst @@ -0,0 +1,7 @@ +ParseurLogApache +====================== + +.. automodule:: parse.parseur_log_apache + :members: + :show-inheritance: + :undoc-members: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c8dc61 Binary files /dev/null and b/requirements.txt differ diff --git a/tests/.coveragerc b/tests/.coveragerc new file mode 100644 index 0000000..7d5cb5a --- /dev/null +++ b/tests/.coveragerc @@ -0,0 +1,3 @@ +[report] +exclude_lines = + if __name__ == "__main__": \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9dbaba8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,193 @@ +""" +Module de configuration des tests unitaires. +""" + +import pytest +from cli.afficheur_cli import AfficheurCLI +from cli.parseur_arguments_cli import ParseurArgumentsCLI +from parse.parseur_log_apache import ParseurLogApache +from analyse.analyseur_log_apache import AnalyseurLogApache +from export.exporteur import Exporteur + + +# ----------------- +# Données générales +# ----------------- + +# Lignes respectant la syntaxe d'un fichier de log Apache +lignes_valides = [ + '192.168.1.1 - - [12/Jan/2025:10:15:32 +0000] "GET /index.html HTTP/1.1" 200 532', + + '::1 - - [05/Mar/2025:16:59:43 +0100] "POST / HTTP/1.1" 500 20' + '"http://localhost/connexion.php" "Mozilla/5.0 (Windows NT 10.0;' + ' Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"', + + '::1 - - [05/Mar/2025:16:59:43 +0100] "DELETE /index.html HTTP/2.1" 500 20', + + '::1 - test [05/Mar/2025:16:59:59 +0100] "DELETE /index.html HTTP/2.1" 500 20', + + '111.89.7.3 - essaie [27/Feb/2025:10:0:0 +0110] "GET / HTTP/2.1" 500 20' +] + +# Lignes ne respectant pas la syntaxe d'un fichier de log Apache +lignes_invalides = [ + '', + + 'Une ligne avec un format invalide !', + + '::1 - - [05/Mar/2025:16:59:43] "DELETE / HTTP/2.1" 200 20', + + '- - - [12/Jan/2025:10:15:32 +0000] "GET /index.html HTTP/1.1" 500 532', + + '::1 - - - "GET /index.html HTTP/1.1" 500 532', + + '::1 - - [12/Jan/2025:10:15:32 +0000] "GET /index.html HTTP/1.1" - 120' +] + +# ------------------ +# Fixtures générales +# ------------------ + +@pytest.fixture +def afficheur_cli(): + return AfficheurCLI() + +@pytest.fixture +def parseur_arguments_cli(): + """ + Fixture pour initialiser le parseur d'arguments CLI. + + Returns: + ParseurArgumentsCLI: Une instance de la classe :class:`ParseurArgumentsCLI`. + """ + return ParseurArgumentsCLI() + +@pytest.fixture() +def log_apache(tmp_path): + """ + Fixture pour créer et récupérer un fichier de log Apache temporaire. + Elle permet de générer un fichier de log Apache temporaire contenant + soit des lignes valides, soit des lignes invalides selon le paramètre fourni. + + Args: + tmp_path (Path): Chemin temporaire fourni par pytest. + + Returns: + Callable[[bool], Path]: Une fonction qui crée et retourne le chemin + du fichier de log temporaire. + """ + def _creer_log(valide): + """ + Crée un fichier de log Apache temporaire. + + Args: + valide (bool): Si ``True``, le fichier contient des lignes de log valides. + Sinon, il contient des lignes invalides. + + Returns: + Path: Le chemin du fichier de log temporaire créé. + """ + contenu = ( + "\n".join(lignes_valides) + if valide == True + else "\n".join(lignes_invalides) + ) + fichier_temp = tmp_path / "access.log" + fichier_temp.write_text(contenu) + return fichier_temp + return _creer_log + +@pytest.fixture +def parseur_log_apache(log_apache, request): + """ + Fixture pour initialiser un parseur de fichier de log Apache. + + Args: + log_apache (Path): La fixture pour initialiser un fichier temporaire. + request (object): Paramètre de la fonction. Si il est égale à ``False``, cette fixture + retourne un parseur de log Apache qui analyse un fichier avec un format + invalide. Sinon, retourne un parseur de log Apache qui analyse un fichier + avec un format valide. + + Returns: + ParseurLogApache: Une instance de la classe :class:`ParseurLogApache`. + """ + if hasattr(request, "param") and request.param == False: + return ParseurLogApache(str(log_apache(False))) + return ParseurLogApache(str(log_apache(True))) + +@pytest.fixture() +def fichier_log_apache(parseur_log_apache): + """ + Fixture pour initialiser une représentation d'un fichier de log Apache. + Cette représentation comprend par défaut les entrées parsées de la liste + ``lignes_valides``. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + + Returns: + FichierLogApache: Une instance de la classe :class:`FichierLogApache`. + """ + return parseur_log_apache.parse_fichier() + +@pytest.fixture() +def entree_log_apache(fichier_log_apache): + """ + Fixture pour initialiser une représentation d'une entrée d'un fichier de log Apache. + Cette représentation comprend par défaut les informations de la première ligne de + ``lignes_valides``. + + Args: + fichier_log_apache (EntreeLogApache): Fixture pour l'instance + de la classe :class:`EntreeLogApache`. + + Returns: + EntreeLogApache: Une instance de la classe :class:`EntreeLogApache`. + """ + return fichier_log_apache.entrees[0] + +@pytest.fixture() +def analyseur_log_apache(fichier_log_apache): + """ + Fixture pour initialiser un analyseur statistique de fichier de log Apache. + Le fichier qu'analyse cet analyseur comprend par défaut les entrées parsées de la liste + ``lignes_valides``. + + Args: + fichier_log_apache (FichierLogApache): Fixture pour l'instance + de la classe :class:`FichierLogApache`. + + Returns: + AnalyseurLogApache: Une instance de la classe :class:`AnalyseurLogApache`. + """ + return AnalyseurLogApache(fichier_log_apache) + +@pytest.fixture +def fichier_json(tmp_path): + """ + Fixture pour retourner un chemin de fichier JSON temporaire. + + Args: + tmp_path (Path): Chemin temporaire fourni par pytest. + + Returns: + Path: Un chemin de fichier JSON temporaire. + """ + fichier_temp = tmp_path / "sortie.json" + return fichier_temp + +@pytest.fixture +def exporteur(fichier_json): + """ + Fixture pour initialiser un exportateur de données. + + Args: + fichier_json (Path): Fixture pour initialiser + un chemin de fichier json temporaire. + + Returns: + Exporteur: Une instance de la classe :class:`Exportateur`. + """ + return Exporteur(str(fichier_json)) \ No newline at end of file diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..520e5c5 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = ../app \ No newline at end of file diff --git a/tests/test_afficheur_cli.py b/tests/test_afficheur_cli.py new file mode 100644 index 0000000..e5026fb --- /dev/null +++ b/tests/test_afficheur_cli.py @@ -0,0 +1,228 @@ +""" +Modules des tests unitaires pour l'affichage d'informations dans la CLI. +""" + +import pytest +from time import sleep +from io import StringIO +from cli.afficheur_cli import AfficheurCLI + +# Données +messages = [ + "Un message normal", + "Test\r avec des \ncaractères\t spéciaux", + "Test d'un message très long" * 1000, + "\033[31mTest avec couleur\033[0m", + "🎉 Test avec des unicodes 🎉" +] + +# Tests unitaires + +@pytest.mark.parametrize("message", messages) +def test_afficheur_cli_affiche_message(mocker, afficheur_cli, message): + """ + Vérifie que la méthode affiche_message affiche correctement les messages avec la bonne + couleur dans le terminal. + + Scénarios testés: + - Passage d'un message à la méthode. + + Asserts: + - Le message est le même que celui fourni avec la bonne couleur. + + Args: + mocker (MockerFixture): Fixture qui permet de modifier le stdout. + afficheur_cli (AfficheurCLI): Fixture pour l'instance de la classe ``AfficheurCLI``. + message (str): Message à afficher. + """ + # Remplacement de la sortie standard par un StringIO (nommé sortie_cli) + with mocker.patch("sys.stdout", new_callable=StringIO) as sortie_cli: + afficheur_cli.affiche_message(message) + assert sortie_cli.getvalue() == afficheur_cli.COULEUR_MESSAGE_NORMAL + message + "\n" + +def test_afficheur_cli_exception_affiche_message_type(afficheur_cli): + """ + Vérifie que la méthode affiche_message lève une exception TypeError + lorsque l'argument fourni n'est pas une chaîne de caractères. + + Scénarios testés: + - Passage d'un entier à la méthode. + + Asserts: + - Vérifie que l'exception TypeError est levée. + + Args: + afficheur_cli (AfficheurCLI): Fixture pour l'instance de la classe ``AfficheurCLI``. + """ + with pytest.raises(TypeError): + afficheur_cli.affiche_message(123) + +@pytest.mark.parametrize("message, exception", [ + ("Un message d'erreur !", Exception("Erreur générale !")), + ("Erreur de typage !", TypeError("Une variable str était attendue !")), + ("Erreur dans la recherche !", FileNotFoundError("Fichier non trouvé !")) +]) +def test_afficheur_cli_affiche_erreur(mocker, afficheur_cli, message, exception): + """ + Vérifie que la méthode affiche_erreur affiche correctement les messages d'erreur + dans le terminal avec la couleur adéquate. + + Scénarios testés: + - Affichage d'erreurs avec différents types d'exceptions. + + Asserts: + - Vérifie que le message d'erreur et l'exception sont bien affichés. + + Args: + mocker (MockerFixture): Fixture pour modifier le stdout. + afficheur_cli (AfficheurCLI): Fixture pour l'instance de la classe ``AfficheurCLI``. + message (str): Message d'erreur à afficher. + exception (Exception): Exception associée à l'erreur. + """ + # Remplacement de la sortie standard par un StringIO (nommé sortie_cli) + with mocker.patch("sys.stdout", new_callable=StringIO) as sortie_cli: + afficheur_cli.affiche_erreur(message, exception) + assert sortie_cli.getvalue() == (afficheur_cli.COULEUR_MESSAGE_ERREUR + + message + "\n" + str(exception) + "\n") + +@pytest.mark.parametrize("message, exception", [ + (False, Exception("Erreur générale !")), + ("Erreur de typage !", False) +]) +def test_afficheur_cli_exception_affiche_erreur_type(afficheur_cli, message, exception): + """ + Vérifie que la méthode affiche_erreur lève une exception TypeError + lorsque les arguments ne sont pas du bon type. + + Scénarios testés: + - Passage d'un mauvais type pour le paramètre ``message``. + - Passage d'un mauvais type pour le paramètre ``exception``. + + Asserts: + - Vérifie que l'exception TypeError est levée. + + Args: + afficheur_cli (AfficheurCLI): Fixture pour l'instance de la classe ``AfficheurCLI``. + message (Any): Valeur incorrecte à tester. + exception (Any): Valeur incorrecte à tester. + """ + with pytest.raises(TypeError): + afficheur_cli.affiche_erreur(message, exception) + +@pytest.mark.parametrize("message", messages) +def test_afficheur_cli_reecrire_ligne(mocker, afficheur_cli, message): + """ + Vérifie que la méthode reecrire_ligne affiche correctement un message + sur la même ligne du terminal. + + Scénarios testés: + - Affichage de plusieurs messages. + + Asserts: + - Vérifie que la sortie standard contient le message formaté. + + Args: + mocker (MockerFixture): Fixture pour modifier le stdout. + afficheur_cli (AfficheurCLI): Fixture pour l'instance de la classe ``AfficheurCLI``. + message (str): Message à afficher. + """ + # Remplacement de la sortie standard par un StringIO (nommé sortie_cli) + with mocker.patch("sys.stdout", new_callable=StringIO) as sortie_cli: + afficheur_cli.reecrire_ligne(message) + assert sortie_cli.getvalue() == "\r" + afficheur_cli.COULEUR_MESSAGE_NORMAL + message + +def test_afficheur_cli_exception_reecrire_ligne_type(afficheur_cli): + """ + Vérifie que la méthode reecrire_ligne lève une exception TypeError + lorsqu'un argument de type incorrect est fourni. + + Scénarios testés: + - Passage d'un entier au lieu d'une chaîne de caractères. + + Asserts: + - Vérifie que l'exception TypeError est levée. + + Args: + afficheur_cli (AfficheurCLI): Fixture pour l'instance de la classe ``AfficheurCLI``. + """ + with pytest.raises(TypeError): + afficheur_cli.reecrire_ligne(123) + +def test_afficheur_cli_lance_animation_chargement(mocker, afficheur_cli): + """ + Vérifie que la méthode lance_animation_chargement démarre bien un thread + pour l'animation de chargement. + + Scénarios testés: + - Lancement de l'animation de chargement. + + Asserts: + - Vérifie que le thread est bien initialisé et actif. + + Args: + mocker (MockerFixture): Fixture pour modifier les méthodes internes. + afficheur_cli (AfficheurCLI): Fixture pour l'instance de la classe ``AfficheurCLI``. + """ + mocker.patch.object(afficheur_cli, "_animation_chargement", side_effect=lambda: sleep(10)) + assert afficheur_cli._thread_chargement is None + afficheur_cli.lance_animation_chargement() + assert afficheur_cli._thread_chargement is not None + assert afficheur_cli._thread_chargement.is_alive() + +def test_afficheur_cli_exception_stop_animation_chargment_type_invalide(afficheur_cli): + """ + Vérifie que la méthode stop_animation_chargement renvoie une erreur lorsque le type + de son paramètre est invalide. + + Scénarios testés: + - Paramètre ``erreur`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + afficheur_cli (AfficheurCLI): Fixture pour l'instance de la classe ``AfficheurCLI``. + """ + with pytest.raises(TypeError): + afficheur_cli.stop_animation_chargement("False") + +def test_afficheur_cli_stop_animation_chargement_terminee(afficheur_cli): + """ + Vérifie que la méthode stop_animation_chargement arrête correctement l'animation en + cas de demande d'arrêt suite à une fin de chargement normale. + + Scénarios testés: + - Arrêt normal de l'animation de chargement. + + Asserts: + - Vérifie que le thread est bien arrêté. + - Vérifie que le flag d'arrêt normale est activé. + + Args: + afficheur_cli (AfficheurCLI): Fixture pour l'instance de la classe ``AfficheurCLI``. + """ + afficheur_cli.lance_animation_chargement() + afficheur_cli.stop_animation_chargement() + assert afficheur_cli._thread_chargement is None + assert afficheur_cli._thread_chargement is None + assert afficheur_cli._thread_chargement_termine.is_set() + +def test_afficheur_cli_stop_animation_chargement_erreur(afficheur_cli): + """ + Vérifie que la méthode stop_animation_chargement arrête correctement l'animation en + cas de demande d'arrêt suite à une erreur. + + Scénarios testés: + - Arrêt normal de l'animation de chargement. + + Asserts: + - Vérifie que le thread est bien arrêté. + - Vérifie que le flag d'arrêt d'erreur est activé. + + Args: + afficheur_cli (AfficheurCLI): Fixture pour l'instance de la classe ``AfficheurCLI``. + """ + afficheur_cli.lance_animation_chargement() + afficheur_cli.stop_animation_chargement(True) + assert afficheur_cli._thread_chargement is None + assert afficheur_cli._thread_chargement_erreur.is_set() \ No newline at end of file diff --git a/tests/test_analyseur_log_apache.py b/tests/test_analyseur_log_apache.py new file mode 100644 index 0000000..5defd19 --- /dev/null +++ b/tests/test_analyseur_log_apache.py @@ -0,0 +1,272 @@ +""" +Modules des tests unitaires pour l'analyse statistique d'un fichier de log Apache. +""" + +import pytest +from parse.fichier_log_apache import FichierLogApache +from analyse.analyseur_log_apache import AnalyseurLogApache + + +# Tests unitaires + +@pytest.mark.parametrize("fichier, nombre_par_top", [ + (False, 3), + (FichierLogApache("test.log"), False) +]) +def test_analyseur_log_exception_type_invalide(fichier, nombre_par_top): + """ + Vérifie que la classe AnalyseurLogApache lève une :class:`TypeError` si les types des + paramètres du constructeur sont invalides. + + Scénarios testés: + - Type incorrect pour le paramètre ``fichier``. + - Type incorrect pour le paramètre ``nombre_par_top``. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + fichier (any): Représentation du fichier log. + nombre_par_top (any): Nombre maximum d'éléments dans le top classement. + """ + with pytest.raises(TypeError): + analyseur = AnalyseurLogApache(fichier, nombre_par_top) + +def test_analyseur_log_exception_valeur_nombre_par_top_invalide(): + """ + Vérifie que la classe AnalyseurLogApache lève une exception si le + paramètre ``nombre_par_top`` du constructeur est un entier négatif. + + Scénarios testés: + - Nombre négatif pour ``nombre_par_top``. + + Asserts: + - Une exception :class:`ValueError` est levée. + """ + with pytest.raises(ValueError): + analyseur = AnalyseurLogApache(FichierLogApache("test.log"), -4) + +@pytest.mark.parametrize("liste_elements, nom_element, mode_top_classement", [ + (0, "test", True), + ([], 0, True), + ([], "test", 0) +]) +def test_analyseur_exception_repartition_elements_type_invalide(analyseur_log_apache, + liste_elements, + nom_element, + mode_top_classement): + """ + Vérifie que _get_repartition_elements lève une exception si les types sont invalides. + + Scénarios testés: + - Type incorrect pour le paramètre ``liste_elements``. + - Type incorrect pour le paramètre ``nom_element``. + - Type incorrect pour le paramètre ``mode_top_classement``. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + analyseur_log_apache (AnalyseurLogApache): Fixture pour l'instance + de la classe ``ParseurLogApache``. + liste_elements (any): Liste des éléments à répartir. + nom_element (any): Nom des éléments à analyser. + mode_top_classement (any): Mode top-classement activé ou non. + """ + with pytest.raises(TypeError): + analyseur_log_apache._get_repartition_elements(liste_elements, + nom_element, + mode_top_classement) + +@pytest.mark.parametrize("liste_elements, nom_element, resultat_attendu", [ + ([1] * 6 + [2] * 4, "chiffre", [ + {"chiffre": 1, "total": 6, "taux": 60.0}, + {"chiffre": 2, "total": 4, "taux": 40.0} + ]), + (["GET"] * 1 + ["POST"] * 7 + ["DELETE"] * 2, "methode", [ + {"methode": "POST", "total": 7, "taux": 70.0}, + {"methode": "DELETE", "total": 2, "taux": 20.0}, + {"methode": "GET", "total": 1, "taux": 10.0} + ]), +]) +def test_analyseur_repartition_elements_valide(analyseur_log_apache, + liste_elements, + nom_element, + resultat_attendu): + """ + Vérifie la répartition correcte des éléments dans ``_get_repartition_elements``. + + Scénarios testés: + - Répartition de chiffres avec deux valeurs fréquentes. + - Répartition de méthodes HTTP avec trois valeurs fréquentes. + + Asserts: + - La liste est triée dans l'ordre attendu. + - Le nombre d'éléments dans le résultat correspond à celui attendu. + - Les totaux et les taux sont correctement calculés. + + Args: + analyseur_log_apache (AnalyseurLogApache): Fixture pour l'instance + de la classe ``ParseurLogApache``. + liste_elements (list): Liste des éléments à analyser. + nom_element (str): Nom de l'élément à analyser. + resultat_attendu (list): Résultat attendu après répartition. + """ + repartitions = analyseur_log_apache._get_repartition_elements( + liste_elements, nom_element + ) + assert len(repartitions) == len(resultat_attendu) + for repartition, resultat in zip(repartitions, resultat_attendu): + assert repartition[nom_element] == resultat[nom_element] + assert repartition["total"] == resultat["total"] + assert repartition["taux"] == resultat["taux"] + +@pytest.mark.parametrize("liste_elements, nom_element, nombre_top, resultat_attendu", [ + ([1] * 6 + [2] * 4, "chiffre", 1, [ + {"chiffre": 1, "total": 6, "taux": 60.0} + ]), + (["GET"] * 1 + ["POST"] * 7 + ["DELETE"] * 2, "methode", 2, [ + {"methode": "POST", "total": 7, "taux": 70.0}, + {"methode": "DELETE", "total": 2, "taux": 20.0} + ]), +]) +def test_analyseur_repartition_mode_top_elements_valide(analyseur_log_apache, + liste_elements, + nom_element, + nombre_top, + resultat_attendu): + """ + Vérifie que ``_get_repartition_elements`` retourne les top éléments correctement. + + Scénarios testés: + - Classement des chiffres avec un seul élément dans le top. + - Classement des méthodes HTTP avec deux éléments dans le top. + + Asserts: + - Le nombre de top éléments retournés correspond à ``nombre_top``. + - Les totaux et les taux sont correctement calculés. + + Args: + analyseur_log_apache (AnalyseurLogApache): Fixture pour l'instance + de la classe ``ParseurLogApache``. + liste_elements (list): Liste des éléments à analyser. + nom_element (str): Nom de l'élément à analyser. + nombre_top (int): Nombre maximum d'éléments à retourner. + resultat_attendu (list): Résultat attendu. + """ + analyseur_log_apache.nombre_par_top = nombre_top + repartitions = analyseur_log_apache._get_repartition_elements( + liste_elements, nom_element, True + ) + assert len(repartitions) == len(resultat_attendu) + for repartition, resultat in zip(repartitions, resultat_attendu): + assert repartition[nom_element] == resultat[nom_element] + assert repartition["total"] == resultat["total"] + assert repartition["taux"] == resultat["taux"] + + +def test_analyseur_top_urls_valide(analyseur_log_apache): + """ + Vérifie que la méthode ``get_top_urls`` retourne correctement les URLs les plus fréquentées. + + Scénarios testés: + - Vérification du tri et du calcul du taux de fréquence. + + Asserts: + - La liste est triée dans l'ordre attendu. + - Le nombre d'éléments dans le résultat correspond à celui attendu. + + Args: + analyseur_log_apache (AnalyseurLogApache): Fixture pour l'instance + de la classe ParseurLogApache. + """ + top_urls = analyseur_log_apache.get_top_urls() + assert len(top_urls) == 2 + assert top_urls[0]["url"] == "/index.html" + assert top_urls[0]["total"] == 3 + assert top_urls[0]["taux"] == 60.0 + assert top_urls[1]["url"] == "/" + assert top_urls[1]["total"] == 2 + assert top_urls[1]["taux"] == 40.0 + +def test_analyseur_repartition_code_statut_htpp_valide(analyseur_log_apache): + """ + Vérifie que ``get_total_par_code_statut_http`` retourne la répartition correcte des codes HTTP. + + Scénarios testés: + - Vérification du tri et du calcul du taux de fréquence. + + Asserts: + - La liste est triée dans l'ordre attendu. + - Le nombre d'éléments dans le résultat correspond à celui attendu. + + Args: + analyseur_log_apache (AnalyseurLogApache): Fixture pour l'instance + de la classe ParseurLogApache. + """ + repartition = analyseur_log_apache.get_total_par_code_statut_http() + assert len(repartition) == 2 + assert repartition[0]["code"] == 500 + assert repartition[0]["total"] == 4 + assert repartition[0]["taux"] == 80.0 + assert repartition[1]["code"] == 200 + assert repartition[1]["total"] == 1 + assert repartition[1]["taux"] == 20.0 + +@pytest.mark.parametrize("nombre_entrees", [ + (0), (3), (100) +]) +def test_analyseur_get_total_entrees_valide(analyseur_log_apache, + fichier_log_apache, + entree_log_apache, + nombre_entrees): + """ + Vérifie que ``get_total_entrees`` retourne le nombre correcte d'entrées dans le fichier. + + Scénarios testés: + - Vérification avec des fichiers avec des nombre d'entrées différents. + + Asserts: + - La méthode renvoie le nombre d'éléments dans la liste. + + Args: + analyseur_log_apache (AnalyseurLogApache): Fixture pour l'instance + de la classe :class:`AnalyseurLogApache`. + fichier_log_apache (FichierLogApache): Fixture pour l'instance + de la classe :class:`FichierLogApache`. + entree_log_apache (EntreeLogApache): Fixture pour l'instance + de la classe :class:`EntreeLogApache`. + nombre_entrees (int): Le nombre total d'entrées dans le fichier. + """ + fichier_log_apache.entrees = [entree_log_apache] * nombre_entrees + assert analyseur_log_apache.get_total_entrees() == nombre_entrees + +def test_analyseur_get_analyse_complete_valide(analyseur_log_apache): + """ + Vérifie que ``get_analyse_complete`` retourne un rapport de l'analyse correct + qui se base sur le retour des autres méthodes. + + Scénarios testés: + - Vérification du rapport de l'analyse. + + Asserts: + - Les éléments du rapport se basent sur les mêmes valeurs que les autres méthodes. + + Args: + analyseur_log_apache (AnalyseurLogApache): Fixture pour l'instance + de la classe :class:`AnalyseurLogApache`. + fichier_log_apache (FichierLogApache): Fixture pour l'instance + de la classe :class:`FichierLogApache`. + entree_log_apache (EntreeLogApache): Fixture pour l'instance + de la classe :class:`EntreeLogApache`. + nombre_entrees (int): Le nombre total d'entrées dans le fichier. + """ + analyse = analyseur_log_apache.get_analyse_complete() + assert analyse["chemin"] == analyseur_log_apache.fichier.chemin + statistiques = analyse["statistiques"] + assert statistiques["total_entrees"] == analyseur_log_apache.get_total_entrees() + statistiques_requetes = statistiques["requetes"] + assert statistiques_requetes["top_urls"] == analyseur_log_apache.get_top_urls() + assert (statistiques_requetes["repartition_code_statut_http"] + == analyseur_log_apache.get_total_par_code_statut_http()) + \ No newline at end of file diff --git a/tests/test_donnees_log_apache.py b/tests/test_donnees_log_apache.py new file mode 100644 index 0000000..1aa2f53 --- /dev/null +++ b/tests/test_donnees_log_apache.py @@ -0,0 +1,219 @@ +""" +Module des tests unitaires pour les classes contenant les données des logs Apache. +""" + +import pytest +from datetime import datetime, timezone, timedelta +from donnees.client_informations import ClientInformations +from donnees.requete_informations import RequeteInformations +from donnees.reponse_informations import ReponseInformations + + +@pytest.mark.parametrize("adresse_ip, identifiant_rfc, utilisateur, agent_utilisateur", [ + ("192.168.0.1", "rfc", "utilisateur", "Mozilla/5.0") +]) +def test_donnees_client_informations_valide(adresse_ip, + identifiant_rfc, + utilisateur, + agent_utilisateur): + """ + Vérifie que les arguments passés dans le constructeur de la classe :class:`ClientInformations` + sont bien récupérés. + + Scénarios testés: + - Création d'une instance avec des arguments valides. + + Asserts: + - La valeur des arguments sont bien conservées au bon endroit avec la bonne valeur. + + Args: + adresse_ip (str): Une adresse IP. + identifiant_rfc (str): Un identifiant RFC. + utilisateur (str): Un nom d'utilisateur. + agent_utilisateur (str): Un User-Agent. + """ + client = ClientInformations( + adresse_ip, + identifiant_rfc, + utilisateur, + agent_utilisateur + ) + assert client.adresse_ip == adresse_ip + assert client.identifiant_rfc == identifiant_rfc + assert client.nom_utilisateur == utilisateur + assert client.agent_utilisateur == agent_utilisateur + +@pytest.mark.parametrize("adresse_ip, identifiant_rfc, utilisateur, agent_utilisateur", [ + (False, "rfc", "utilisateur", "Mozilla/5.0"), + ("192.168.0.1", False, "utilisateur", "Mozilla/5.0"), + ("192.168.0.1", "rfc", False, "Mozilla/5.0"), + ("192.168.0.1", "rfc", "utilisateur", False) +]) +def test_donnees_client_exception_type_invalide(adresse_ip, + identifiant_rfc, + utilisateur, + agent_utilisateur): + """ + Vérifie que la classe renvoie une erreur lorsque un argument de type invalide + est passé dans le constructeur. + + Scénarios testés: + - Type incorrect pour le paramètre ``adresse_ip``. + - Type incorrect pour le paramètre ``identifiant_rfc``. + - Type incorrect pour le paramètre ``utilisateur``. + - Type incorrect pour le paramètre ``agent_utilisateur``. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + adresse_ip (any): Une adresse IP. + identifiant_rfc (any): Un identifiant RFC. + utilisateur (any): Un nom d'utilisateur. + agent_utilisateur (any): Un User-Agent. + """ + with pytest.raises(TypeError): + client = ClientInformations( + adresse_ip, + identifiant_rfc, + utilisateur, + agent_utilisateur + ) + +@pytest.mark.parametrize("horodatage, methode_http, url, protocole_http, ancienne_url", [ + (datetime(2012, 12, 12, 10, 10, 10, tzinfo=timezone(timedelta(hours=10))), + "POST", "essaie.fr/contact", "HTTP/1.2", "essaie.fr/accueil") +]) +def test_donnes_requete_informations_valide(horodatage, + methode_http, + url, + protocole_http, + ancienne_url): + """ + Vérifie que les arguments passés dans le constructeur de la classe :class:`RequeteInformations` + sont bien récupérés. + + Scénarios testés: + - Création d'une instance avec des arguments valides. + + Asserts: + - La valeur des arguments sont bien conservées au bon endroit avec la bonne valeur. + + Args: + horodatage (datetime): La date de reception de la requête. + methode_http (str): La méthode HTTP utilisée. + url (str): La ressource demandée. + protocole_http (str): Le protocole HTTP utilisé. + ancienne_url (str): L'ancienne ressource demandée. + """ + requete = RequeteInformations( + horodatage, + methode_http, + url, + protocole_http, + ancienne_url + ) + assert requete.horodatage == horodatage + assert requete.methode_http == methode_http + assert requete.url == url + assert requete.protocole_http == protocole_http + assert requete.ancienne_url == ancienne_url + +@pytest.mark.parametrize("horodatage, methode_http, url, protocole_http, ancienne_url", [ + (False, "POST", "essaie.fr/contact", "HTTP/1.2", "essaie.fr/accueil"), + (datetime(2012, 12, 12, 10, 10, 10, tzinfo=timezone(timedelta(hours=10))), + False, "essaie.fr/contact", "HTTP/1.2", "essaie.fr/accueil"), + (datetime(2012, 12, 12, 10, 10, 10, tzinfo=timezone(timedelta(hours=10))), + "POST", False, "HTTP/1.2", "essaie.fr/accueil"), + (datetime(2012, 12, 12, 10, 10, 10, tzinfo=timezone(timedelta(hours=10))), + "POST", "essaie.fr/contact", False, "essaie.fr/accueil"), + (datetime(2012, 12, 12, 10, 10, 10, tzinfo=timezone(timedelta(hours=10))), + "POST", "essaie.fr/contact", "HTTP/1.2", False), +]) +def test_donnees_requete_exception_type_invalide(horodatage, + methode_http, + url, + protocole_http, + ancienne_url): + """ + Vérifie que la classe renvoie une erreur lorsque un argument de type invalide + est passé dans le constructeur. + + Scénarios testés: + - Type incorrect pour le paramètre ``horodatage``. + - Type incorrect pour le paramètre ``methode_http``. + - Type incorrect pour le paramètre ``url``. + - Type incorrect pour le paramètre ``protocole_http``. + - Type incorrect pour le paramètre ``ancienne_url``. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + horodatage (any): La date de reception de la requête. + methode_http (any): La méthode HTTP utilisée. + url (any): La ressource demandée. + protocole_http (any): Le protocole HTTP utilisé. + ancienne_url (any): L'ancienne ressource demandée. + """ + with pytest.raises(TypeError): + requete = RequeteInformations( + horodatage, + methode_http, + url, + protocole_http, + ancienne_url + ) + +@pytest.mark.parametrize("code_statut_http, taille_octets", [ + (404, 50) +]) +def test_donnes_reponse_informations_valide(code_statut_http, + taille_octets): + """ + Vérifie que les arguments passés dans le constructeur de la classe :class:`ReponseInformations` + sont bien récupérés. + + Scénarios testés: + - Création d'une instance avec des arguments valides. + + Asserts: + - La valeur des arguments sont bien conservées au bon endroit avec la bonne valeur. + + Args: + code_statut_http (int): La code de retour. + taille_octets (int): La taille de la réponse en octets. + """ + reponse = ReponseInformations( + code_statut_http, + taille_octets + ) + assert reponse.code_statut_http == code_statut_http + assert reponse.taille_octets == taille_octets + +@pytest.mark.parametrize("code_statut_http, taille_octets", [ + (False, 50), + (404, False) +]) +def test_reponse_exception_type_invalide(code_statut_http, + taille_octets): + """ + Vérifie que la classe renvoie une erreur lorsque un argument de type invalide + est passé dans le constructeur. + + Scénarios testés: + - Type incorrect pour le paramètre ``code_statut_http``. + - Type incorrect pour le paramètre ``taille_octets``. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + code_statut_http (int): La code de retour. + taille_octets (int): La taille de la réponse en octets. + """ + with pytest.raises(TypeError): + reponse = ReponseInformations( + code_statut_http, + taille_octets + ) \ No newline at end of file diff --git a/tests/test_exporteur.py b/tests/test_exporteur.py new file mode 100644 index 0000000..d5b6819 --- /dev/null +++ b/tests/test_exporteur.py @@ -0,0 +1,129 @@ +""" +Module des tests unitaires pour l'exporteur de données. +""" + +import pytest +from json import load +from export.exporteur import Exporteur, ExportationException + +@pytest.mark.parametrize("chemin_sortie", [ + (0), (None), ([]) +]) +def test_exporteur_type_chemin_invalide(chemin_sortie): + """ + Vérifie que la classe renvoie une erreur lorsque un argument de type invalide + est passé dans le constructeur. + + Scénarios testés: + - Type incorrect pour le paramètre ``chemin_sortie``. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + chemin_sortie (any): Le chemin de sortie utilisé dans le constructeur. + """ + with pytest.raises(TypeError): + exporteur = Exporteur(chemin_sortie) + +def test_exporteur_emplacement_inexistant(): + """ + Vérifie que la classe renvoie une erreur lorsque un chemin de fichier invalide + est passé dans le constructeur. + + Scénarios testés: + - Chemin invalide pour le paramètre ``chemin_sortie``. + + Asserts: + - Une exception :class:`ExportationException` est levée. + """ + with pytest.raises(ExportationException): + exporteur = Exporteur("dossier/inexistant/sortie.json") + +def test_exporteur_verification_exception_exportation_possible_type_invalide(exporteur): + """ + Vérifie que la méthode verification_exportation_possible renvoie une erreur lorsque le type + de son paramètre est invalide. + + Scénarios testés: + - Paramètre ``chemin_sortie`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + exporteur (Exporteur) : Fixture pour l'instance de la classe :class:`Exporteur`. + """ + with pytest.raises(TypeError): + exporteur.verification_exportation_possible(False) + +@pytest.mark.parametrize("donnees", [ + (0), (None), ([]) +]) +def test_exporteur_export_json_type_donnees_invalide(exporteur, donnees): + """ + Vérifie que la classe renvoie une erreur lorsque un argument de type invalide + est passé dans la méthode ``export_vers_json``. + + Scénarios testés: + - Type incorrect pour le paramètre ``données``. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + exporteur (Exporteur) : Fixture pour l'instance de la classe :class:`Exporteur`. + donnees (any): Les données à exporter. + """ + with pytest.raises(TypeError): + exporteur.export_vers_json("type invalide") + +@pytest.mark.parametrize("exception", [ + (PermissionError("Pas les droits")), + (FileNotFoundError("Fichier non trouvé.")), + (Exception("Toutes exceptions")) +]) +def test_exporteur_export_json_exception_exportation(exporteur, mocker, exception): + """ + Vérifie que la classe renvoie l'exception :class:`ExportationException` lorsque + une erreur apparait durant l'exportation des données. + + Scénarios testés: + - Une exception :class:`PermissionError` survient. + - Une exception :class:`FileNotFoundError` survient. + - Une exception :class:`Exception` survient. + + Asserts: + - Une exception :class:`ExportationException` est levée. + + Args: + exporteur (Exporteur) : Fixture pour l'instance de la classe :class:`Exporteur`. + mocker (any): Une fixture pour simuler des exceptions. + donnees (any): Les données à exporter. + """ + mocker.patch("builtins.open", side_effect=exception) + with pytest.raises(ExportationException): + exporteur.export_vers_json({}) + +def test_exporteur_exportation_json_valide(exporteur, fichier_json): + """ + Vérifie que la méthode ``export_vers_json`` exporte correctement les données + vers une fichier. + + Scénarios testés: + - Exportation d'un dictionnaire lambda. + + Asserts: + - Le fichier est bien crée. + - Les données dans le fichier sont conformes à celles fournies. + + Args: + exporteur (Exporteur) : Fixture pour l'instance de la classe :class:`Exporteur`. + fichier_json (Path): Le chemin du fichier. + """ + donnees = {"cle": {"valeur": [1, 2, 3]}} + exporteur.export_vers_json(donnees) + assert fichier_json.exists() + with open(fichier_json, "r") as exportation: + contenu_exportation = load(exportation) + assert contenu_exportation == donnees \ No newline at end of file diff --git a/tests/test_fichier_entree_log_apache.py b/tests/test_fichier_entree_log_apache.py new file mode 100644 index 0000000..5831ac6 --- /dev/null +++ b/tests/test_fichier_entree_log_apache.py @@ -0,0 +1,86 @@ +""" +Module des tests unitaires pour la classe de représentation d'un fichier de log +ou celle d'une entrée d'un log. +""" + +import pytest +from datetime import datetime +from parse.fichier_log_apache import FichierLogApache +from parse.entree_log_apache import EntreeLogApache +from donnees.client_informations import ClientInformations +from donnees.requete_informations import RequeteInformations +from donnees.reponse_informations import ReponseInformations + + +@pytest.mark.parametrize("chemin, entrees", [ + (False, []), + ("Chemin", False) +]) +def test_fichier_log_exception_type_invalide(chemin, entrees): + """ + Vérifie que l'initialisation d'un FichierLogApache renvoie une erreur lorsque le type + de son paramètre est invalide. + + Scénarios testés: + - Paramètre ``chemin`` avec un mauvais type. + - Paramètre ``entrees`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + chemin (any): Le chemin du fichier. + entrees (any): Les entrées du fichier. + """ + with pytest.raises(TypeError): + fichier = FichierLogApache(chemin, entrees) + +def test_fichier_log_exception_ajoute_entree_type_invalide(fichier_log_apache): + """ + Vérifie que la méthode ajoute_entree retourne une erreur lorsque son paramètre + n'est pas du type attendu. + + Scénarios testés: + - Paramètre ``entree`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + fichier_log_apache (FichierLogApache): Fixture pour l'instance + de la classe :class:`FichierLogApache`. + """ + with pytest.raises(TypeError): + fichier_log_apache.ajoute_entree(False) + +@pytest.mark.parametrize("client, requete, reponse", [ + (False, + RequeteInformations(datetime(1, 1, 1), "GET", "/", "HTTP/1.1", "/"), + ReponseInformations(0, 0)), + (ClientInformations("1.1.1.1", "RFC", "test", "google"), + False, + ReponseInformations(0, 0)), + (ClientInformations("1.1.1.1", "RFC", "test", "google"), + RequeteInformations(datetime(1, 1, 1), "GET", "/", "HTTP/1.1", "/"), + False) +]) +def test_entree_log_exception_type_invalide(client, requete, reponse): + """ + Vérifie que l'initialisation d'un EntreeLogApache renvoie une erreur lorsque le type + de son paramètre est invalide. + + Scénarios testés: + - Paramètre ``client`` avec un mauvais type. + - Paramètre ``requete`` avec un mauvais type. + - Paramètre ``reponse`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + client (any): Les informations du client sur cette entrée. + requete (any): Les informations de la requête sur cette entrée. + reponse (any): Les informations de la réponse sur cette entrée. + """ + with pytest.raises(TypeError): + entree = EntreeLogApache(client, requete, reponse) \ No newline at end of file diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..80d5f9c --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,69 @@ +""" +Module des tests unitaires pour le point d'entrée de l'application. +""" + +import pytest +from main import main +from cli.parseur_arguments_cli import ArgumentCLIException +from parse.parseur_log_apache import FormatLogApacheInvalideException +from export.exporteur import ExportationException + + +@pytest.mark.parametrize( + "exception", + [ + (ArgumentCLIException), + (FormatLogApacheInvalideException), + (ExportationException), + (TypeError), + (ValueError), + ], +) +def test_main_gestion_exception(mocker, exception): + """ + Vérifie que les exceptions attendues sont interceptées dans fichier principal. + + Scénarios testés: + - Vérification que les exceptions n'arrête pas le programme. + + Args: + mocker (MockerFixture): Une fixture pour simuler des exceptions. + exception (any): L'exception à simuler. + """ + mocker.patch("main.ParseurArgumentsCLI", side_effect=exception) + main() + + +def test_main_succes(mocker): + """ + Vérifie le fonctionnement du fichier principal sans exception. + + Scénarios testés: + - Vérification que le fichier principal s'execute sans exception lors + d'un déroulement normal. + + Args: + mocker (MockerFixture): Une fixture pour simuler des retours pour les classes + et méthodes dans main. + """ + # Mock des classes pour simuler un fonctionnement correct + mock_parseur_cli = mocker.patch("main.ParseurArgumentsCLI") + mock_parseur_cli.return_value.parse_args.return_value = mocker.MagicMock( + chemin_log="test.log" + ) + + mock_parseur_log = mocker.patch("main.ParseurLogApache") + mock_parseur_log.return_value.parse_fichier.return_value = mocker.MagicMock() + + mock_analyseur_log = mocker.patch("main.AnalyseurLogApache") + mock_analyseur_log.return_value.get_analyse_complete.return_value = { + "chemin": "test.log" + } + + mocker.patch("main.Exporteur") + + # Vérifie qu'aucune exception n'est levée + try: + main() + except Exception: + pytest.fail("Aucune exception ne doit être levée ici") diff --git a/tests/test_parseur_arguments_cli.py b/tests/test_parseur_arguments_cli.py new file mode 100644 index 0000000..0cf8fa9 --- /dev/null +++ b/tests/test_parseur_arguments_cli.py @@ -0,0 +1,198 @@ +""" +Module des tests unitaires pour le parseur des arguments passés depuis la CLI. +""" + +import pytest +from cli.parseur_arguments_cli import ArgumentCLIException + + +# Données utilisées pour les tests unitaires + +chemins_valides = [ + "fichier.log", + "f1chier.txt", + "./fichier.log", + "C:\\Users\\fest\\gros_fichier.log" +] + +chemins_invalides = [ + "", + "f$chier#2.log", + "fichier txt" +] + +sorties_valides = [ + "fichier.json", + "./fichier.json", + "C:/Users/fest/fichier.json" +] + +arguments_invalides = [ + ["fichier.txt", "-s"], + ["fichier.txt", "-f", "inutile"], + ["fichier.txt", "--faux", "inutile"], + ["fichier.txt", "--faux", "inutile", "-d"], +] + +# Tests unitaires + +@pytest.mark.parametrize("args, namespace", [ + (False, None), + (None, False) +]) +def test_parseur_cli_exception_parse_args_type_invalide(parseur_arguments_cli, args, namespace): + """ + Vérifie que la méthode parse_args renvoie une erreur lorsque le type + d'un de ses paramètres est invalide. + + Scénarios testés: + - Paramètre ``args`` avec un mauvais type. + - Paramètre ``namespace`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurArgumentsCLI`. + args (Optional[list]): Une liste avec des arguments. + namespace (Optional[Namespace]): L'espace de nom où stocker les arguments. + """ + with pytest.raises(TypeError): + parseur_arguments_cli.parse_args(args, namespace) + +@pytest.mark.parametrize("arguments", arguments_invalides) +def test_parseur_cli_exception_argument_inconnu(parseur_arguments_cli, arguments): + """ + Vérifie qu'une erreur est retournée lorsque un argument est passé en CLI et qu'il + n'est pas reconnu. + + Scénarios testés: + - Demande de parsage d'arguments avec des arguments invalides. + + Asserts: + - Une exception :class:`ArgumentCLIException` est levée lorsque un argument est invalide. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurArgumentsCLI`. + argument (list): Une liste avec des arguments. + """ + with pytest.raises(ArgumentCLIException): + arguments = parseur_arguments_cli.parse_args(args=arguments) + +@pytest.mark.parametrize("chemin_log", chemins_valides) +def test_parseur_cli_recuperation_chemin_log_valide(parseur_arguments_cli, chemin_log): + """ + Vérifie que le chemin du log Apache fourni depuis la ligne de commande est bien + récupéré par le parseur. + + Scénarios testés: + - Demande de parsage d'un chemin de log. + + Asserts: + - La valeur du chemin de log est bien récupérée et conforme à l'entrée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurArgumentsCLI`. + chemin_log (str): Un chemin de fichier valide. + """ + arguments = parseur_arguments_cli.parse_args(args=[chemin_log]) + assert arguments.chemin_log == chemin_log + +@pytest.mark.parametrize("chemin_log", chemins_invalides) +def test_parseur_cli_exception_chemin_log_invalide(parseur_arguments_cli, chemin_log): + """ + Vérifie qu'une erreur est retournée lorsque le chemin du log Apache fourni contient + au moins un caractère non autorisé. + + Scénarios testés: + - Demande de parsage d'un chemin de log avec un format invalide. + + Asserts: + - Une exception :class:`ArgumentCLIException` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurArgumentsCLI`. + chemin_log (str): Un chemin de fichier invalide. + """ + with pytest.raises(ArgumentCLIException): + parseur_arguments_cli.parse_args(args=[chemin_log]) + +@pytest.mark.parametrize("chemin_sortie", sorties_valides) +def test_parseur_cli_recuperation_chemin_sortie_valide(parseur_arguments_cli, chemin_sortie): + """ + Vérifie que le chemin du fichier de sortie JSON fourni depuis la ligne de commande est bien + récupéré par le parseur. + + Scénarios testés: + - Demande de parsage d'un chemin de fichier de sortie. + + Asserts: + - La valeur du chemin de sortie est bien récupérée et conforme à l'entrée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurArgumentsCLI`. + chemin_sortie (str): Un chemin de fichier de sortie valide. + """ + arguments = parseur_arguments_cli.parse_args(args=["fichier.txt", "-s", chemin_sortie]) + assert arguments.sortie == chemin_sortie + +@pytest.mark.parametrize("chemin_sortie", chemins_invalides) +def test_parseur_cli_exception_chemin_sortie_invalide(parseur_arguments_cli, chemin_sortie): + """ + Vérifie qu'une erreur est retournée lorsque le chemin du fichier de sortie fourni contient + au moins un caractère non autorisé. + + Scénarios testés: + - Demande de parsage d'un chemin de fichier de sortie invalide. + + Asserts: + - Une exception :class:`ArgumentCLIException` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurArgumentsCLI`. + chemin_sortie (str): Un chemin de fichier de fichier invalide. + """ + with pytest.raises(ArgumentCLIException): + parseur_arguments_cli.parse_args(args=["fichier.txt", "-s", chemin_sortie]) + +def test_parseur_cli_recuperation_chemin_sortie_defaut_valide(parseur_arguments_cli): + """ + Vérifie que le chemin du fichier de sortie JSON par défaut est bien appliqué lorsque + aucun chemin de sortie n'est donné. + + Scénarios testés: + - Demande de parsage avec aucun fichier de sortie indiqué. + + Asserts: + - La bonne valeur par défaut pour le chemin de sortie à été appliquée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurArgumentsCLI`. + """ + argument = parseur_arguments_cli.parse_args(args=["fichier.txt"]) + assert argument.sortie == "./analyse-log-apache.json" + +def test_parseur_cli_verification_extention_chemin_sortie(parseur_arguments_cli): + """ + Vérifie qu'une erreur est retournée lorsque le fichier de sortie fourni ne possède + pas l'extension '.json'. + + Scénarios testés: + - Demande de parsage d'un fichier de sortie qui n'est pas un fichier .json. + + Asserts: + - Une exception :class:`ArgumentCLIException` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurArgumentsCLI`. + """ + with pytest.raises(ArgumentCLIException): + parseur_arguments_cli.parse_args(args=["fichier.txt", "-s", "invalide.txt"]) diff --git a/tests/test_parseur_log_apache.py b/tests/test_parseur_log_apache.py new file mode 100644 index 0000000..eb0ca02 --- /dev/null +++ b/tests/test_parseur_log_apache.py @@ -0,0 +1,259 @@ +""" +Module des tests unitaires pour le parseur de fichier de log Apache. +""" + +import pytest +from re import match +from datetime import datetime, timezone, timedelta +from conftest import lignes_valides, lignes_invalides +from parse.parseur_log_apache import (ParseurLogApache, + FormatLogApacheInvalideException, + FichierLogApacheIntrouvableException) + + +# Tests unitaires + +def test_parseur_log_exception_type_invalide(): + """ + Vérifie que l'initialisation d'un ParseurLogApache renvoie une erreur lorsque le type + de son paramètre est invalide. + + Scénarios testés: + - Paramètre ``chemin_log`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + """ + with pytest.raises(TypeError): + ParseurLogApache(False) + +def test_parseur_log_exception_fichier_introuvable(): + """ + Vérifie qu'une exception est bien levée lorsque le chemin du fichier + passé dans le constructeur n'est pas trouvé. + + Scénarios testés: + - Création d'une instance avec un chemin invalide. + + Asserts: + - Une exception :class:`FileNotFoundError` est levée. + """ + with pytest.raises(FichierLogApacheIntrouvableException): + parseur = ParseurLogApache("fichier/existe/pas.txt") + +@pytest.mark.parametrize("parseur_log_apache", [False], indirect=["parseur_log_apache"]) +def test_parseur_log_exception_fichier_invalide(parseur_log_apache): + """ + Vérifie qu'une exception est bien levée lorsque le format d'un fichier n'est + pas valide (une entrée invalide). + + Scénarios testés: + - Parsage d'un fichier invalide. + + Asserts: + - Une exception :class:`FormatLogApacheInvalideException` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + """ + with pytest.raises(FormatLogApacheInvalideException): + fichier = parseur_log_apache.parse_fichier() + +def test_parseur_log_exception_parse_entree_type_invalide(parseur_log_apache): + """ + Vérifie que la méthode parse_entree renvoie une erreur lorsque le type + de son paramètre est invalide. + + Scénarios testés: + - Paramètre ``entree`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + """ + with pytest.raises(TypeError): + parseur_log_apache.parse_entree(False) + +@pytest.mark.parametrize("ligne_invalide", lignes_invalides) +def test_parseur_log_exception_entree_invalide(parseur_log_apache, ligne_invalide): + """ + Vérifie qu'une exception est bien levée lorsque le format d'une entrée + est invalide. + + Scénarios testés: + - Parsage d'une entrée invalide. + + Asserts: + - Une exception :class:`FormatLogApacheInvalideException` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + ligne_invalide (str): L'entrée à analyser. + """ + with pytest.raises(FormatLogApacheInvalideException): + parseur_log_apache.parse_entree(ligne_invalide) + +def test_parseur_log_nombre_entrees_valide(parseur_log_apache): + """ + Vérifie que le nombre d'entrées trouvé correspond au nombre de ligne dans le log. + + Scénarios testés: + - Parsage d'un fichier et récupération du nombre d'entrées dans ce dernier. + + Asserts: + - Le nombre d'entrées récupéré est égale au nombre attendu. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + """ + fichier_log = parseur_log_apache.parse_fichier() + assert len(fichier_log.entrees) == len(lignes_valides) + +@pytest.mark.parametrize("analyse_regex, nom_information", [ + (False, "Information"), + ({}, False) +]) +def test_parseur_log_apache_exception_get_information_entree_type_invalide( + parseur_log_apache, analyse_regex, nom_information): + """ + Vérifie que la méthode get_information_entree renvoie une erreur lorsque le type + d'un de ses paramètres est invalide. + + Scénarios testés: + - Paramètre ``analyse_regex`` avec un mauvais type. + - Paramètre ``nom_information`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + analyse_regex (any): L'analyse de l'entrée par un regex. + nom_informations (any): Nom de l'information à récupérer. + """ + with pytest.raises(TypeError): + parseur_log_apache.get_information_entree(analyse_regex, nom_information) + +@pytest.mark.parametrize("nom_information, retour_attendu", [ + ("ip", "192.168.1.1"), + ("rfc", None), + ("horodatage", "12/Jan/2025:10:15:32 +0000"), + ("Existe pas !", None) +]) +def test_parseur_log_regex_recuperation_information_entree(parseur_log_apache, + nom_information, + retour_attendu): + """ + Vérifie que la récupération des informations à partir d'un résultat de regex fonctionne + correctement et que toutes valeurs introuvables ou égales à - renvoient None. + + Scénarios testés: + - Récupération d'une information avec une valeur. + - Récupération d'une information sans valeur (égale à -). + - Récupération d'une information inexistante. + + Asserts: + - La valeur retournée est égale à celle attendue. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + nom_information (str): Nom de l'information à récupérer. + retour_attendu (Union[None, str]): La valeur attendue de l'information. + """ + ligne = '192.168.1.1 - - [12/Jan/2025:10:15:32 +0000] "GET /index.html HTTP/1.1" 200 532' + analyse = match(parseur_log_apache.PATTERN_ENTREE_LOG_APACHE, ligne) + resultat_analyse = analyse.groupdict() + assert parseur_log_apache.get_information_entree(resultat_analyse, nom_information) == retour_attendu + +def test_parsage_entree_valide(parseur_log_apache): + """ + Vérifie qu'une entrée est correctement analysée et que les informations partent + au bon endroit avec le bon typage. + + Scénarios testés: + - Parsage d'une entrée valide et récupération des valeurs trouvées. + + Asserts: + - Les valeurs sont égales à celles attendues. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + """ + ligne = '192.168.1.1 - - [12/Jan/2025:10:15:32 +0000] "GET /index.html HTTP/1.1" ' \ + '200 532 "/home" "Chrome/133.0.0.0"' + entree = parseur_log_apache.parse_entree(ligne) + assert entree.client.adresse_ip == "192.168.1.1" + assert entree.client.identifiant_rfc == None + assert entree.client.nom_utilisateur == None + assert entree.client.agent_utilisateur == "Chrome/133.0.0.0" + assert entree.requete.horodatage == datetime(2025, 1, 12, 10, 15, 32, + tzinfo=timezone(timedelta(hours=0))) + assert entree.requete.methode_http == "GET" + assert entree.requete.url == "/index.html" + assert entree.requete.protocole_http == "HTTP/1.1" + assert entree.requete.ancienne_url == "/home" + assert entree.reponse.code_statut_http == 200 + assert entree.reponse.taille_octets == 532 + +def test_parseur_log_exception_extraction_informations_client_type_invalide(parseur_log_apache): + """ + Vérifie que la méthode _extraire_informations_client renvoie une erreur lorsque le type + de son paramètre est invalide. + + Scénarios testés: + - Paramètre ``analyse_regex`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + """ + with pytest.raises(TypeError): + parseur_log_apache._extraire_informations_client(False) + +def test_parseur_log_exception_extraction_informations_requete_type_invalide(parseur_log_apache): + """ + Vérifie que la méthode _extraire_informations_requete renvoie une erreur lorsque le type + de son paramètre est invalide. + + Scénarios testés: + - Paramètre ``analyse_regex`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + """ + with pytest.raises(TypeError): + parseur_log_apache._extraire_informations_requete(False) + +def test_parseur_log_exception_extraction_informations_reponse_type_invalide(parseur_log_apache): + """ + Vérifie que la méthode _extraire_informations_reponse renvoie une erreur lorsque le type + de son paramètre est invalide. + + Scénarios testés: + - Paramètre ``analyse_regex`` avec un mauvais type. + + Asserts: + - Une exception :class:`TypeError` est levée. + + Args: + parseur_arguments_cli (ParseurArgumentsCLI): Fixture pour l'instance + de la classe :class:`ParseurLogApache`. + """ + with pytest.raises(TypeError): + parseur_log_apache._extraire_informations_reponse(False) \ No newline at end of file