diff --git a/app/__init__.py b/app/__init__.py index 85ec504..50b5bd6 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,22 +2,23 @@ import os -from dotenv import load_dotenv from flask import Flask +from app.config import Config -def create_app(): + +def create_app(test_config=None): """Application factory pattern.""" - # Load the env variables to use here - load_dotenv() app = Flask(__name__) - # Use app.config to set config connection details - app.config["MONGO_URI"] = os.getenv("MONGO_CONNECTION") - app.config["DB_NAME"] = os.getenv("PROJECT_DATABASE") - app.config["COLLECTION_NAME"] = os.getenv("PROJECT_COLLECTION") - # Import routes — routes can import app safely because it exists + # 1. Load the default configuration from the Config object. + app.config.from_object(Config) + + if test_config: # Override with test specifics + app.config.from_mapping(test_config) + + # Import routes from app.routes import \ register_routes # pylint: disable=import-outside-toplevel diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..72da9b0 --- /dev/null +++ b/app/config.py @@ -0,0 +1,34 @@ +# pylint: disable=too-few-public-methods + +""" +Application configuration module for Flask. + +Loads environment variables from a .env file and defines the Config class +used to configure Flask and database connection settings. +""" + +import os + +from dotenv import load_dotenv + +# Find the absolute path of the root directory of the project +basedir = os.path.abspath(os.path.dirname(__file__)) +# Build the path to the .env file located in the parent directory of the project root +dotenv_path = os.path.join(os.path.dirname(basedir), ".env") +# Load environment variables to be used +load_dotenv(dotenv_path=dotenv_path) + + +class Config: + """Set Flask configuration variables from environment variables""" + + # General config + SECRET_KEY = os.environ.get("SECRET_KEY") + FLASK_APP = os.environ.get("FLASK_APP") + FLASK_ENV = os.environ.get("FLASK") # values will be 'development' or 'production' + API_KEY = os.environ.get("API_KEY") + + # Database config + MONGO_URI = os.environ.get("MONGO_CONNECTION") + DB_NAME = os.environ.get("PROJECT_DATABASE") + COLLECTION_NAME = os.environ.get("PROJECT_COLLECTION") diff --git a/app/routes.py b/app/routes.py index 8580a86..112bfa0 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,15 +4,15 @@ import uuid from flask import jsonify, request -from werkzeug.exceptions import NotFound +from werkzeug.exceptions import HTTPException, NotFound from app.datastore.mongo_db import get_book_collection from app.datastore.mongo_helper import insert_book_to_mongo +from app.utils.api_security import require_api_key from app.utils.helper import append_hostname from data import books -# ----------- POST section ------------------ def register_routes(app): # pylint: disable=too-many-statements """ Register all Flask routes with the given app instance. @@ -21,7 +21,9 @@ def register_routes(app): # pylint: disable=too-many-statements app (Flask): The Flask application instance to register routes on. """ + # ----------- POST section ------------------ @app.route("/books", methods=["POST"]) + @require_api_key def add_book(): """Function to add a new book to the collection.""" # check if request is json @@ -149,6 +151,7 @@ def get_book(book_id): # ----------- DELETE section ------------------ @app.route("/books/", methods=["DELETE"]) + @require_api_key def delete_book(book_id): """ Soft delete a book by setting its state to 'deleted' or return error if not found. @@ -165,6 +168,7 @@ def delete_book(book_id): # ----------- PUT section ------------------ @app.route("/books/", methods=["PUT"]) + @require_api_key def update_book(book_id): """ Update a book by its unique ID using JSON from the request body. @@ -215,11 +219,30 @@ def update_book(book_id): return jsonify({"error": "Book not found"}), 404 + + # ----------- CUSTOM ERROR HANDLERS ------------------ + @app.errorhandler(NotFound) def handle_not_found(e): """Return a custom JSON response for 404 Not Found errors.""" return jsonify({"error": str(e)}), 404 + @app.errorhandler(HTTPException) + def handle_http_exception(e): + """Return JSON for any HTTPException (401, 404, 403, etc.) + preserving its original status code & description. + """ + # e.code is the HTTP status code (e.g. 401) + # e.description is the text you passed to abort() + response = { + "error": { + "code": e.code, + "name": e.name, + "message": e.description + } + } + return jsonify(response), e.code + @app.errorhandler(Exception) def handle_exception(e): """Return a custom JSON response for any exception.""" diff --git a/app/utils/api_security.py b/app/utils/api_security.py new file mode 100644 index 0000000..a488a0e --- /dev/null +++ b/app/utils/api_security.py @@ -0,0 +1,47 @@ +"""API security decorators.""" + +import hmac +from functools import wraps + +from flask import abort, current_app, request + + +def log_unauthorized_access(): + """ + Helper function to log a warning for unauthorized access attempts with IP and path info. + """ + current_app.logger.warning( + f"Unauthorized access attempt: IP={request.remote_addr}, path={request.path}" + ) + + +def require_api_key(f): + """A decorator to protect routes with a required API key""" + + @wraps(f) + def decorated_function(*args, **kwargs): + + expected_key = current_app.config.get("API_KEY") + + if not expected_key: + # Secure logging — don't leak keys + log_unauthorized_access() + + abort(500, description="API key not configured on the server.") + + provided_key = request.headers.get("X-API-KEY") + if not provided_key: + # Secure logging — don't leak keys + log_unauthorized_access() + abort(401, description="API key is missing.") + + # Securely compare the provided key with the expected key + if not hmac.compare_digest(provided_key, expected_key): + # Secure logging — don't leak keys + log_unauthorized_access() + abort(401, description="Invalid API key.") + + # If key is valid, proceed with the original function + return f(*args, **kwargs) + + return decorated_function diff --git a/openapi.yml b/openapi.yml index 8b00636..7be90bf 100644 --- a/openapi.yml +++ b/openapi.yml @@ -6,12 +6,12 @@ info: title: Book Collection API version: v1.0.0 description: A simple API to manage a collection of books. - termsOfService: 'https://github.com/methods/NandS_BookAPIV.2' + termsOfService: 'https://github.com/methods/S_BookAPIV.2' contact: email: booksAPI@example.com license: name: MIT License - url: 'https://github.com/methods/NandS_BookAPIV.2/blob/main/LICENSE.md' + url: 'https://github.com/methods/S_BookAPIV.2/blob/main/LICENSE.md' # -------------------------------------------- # Server @@ -31,6 +31,12 @@ tags: # -------------------------------------------- # Components components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-KEY + schemas: # Schema for the data client POSTs to create a book BookInput: @@ -105,14 +111,63 @@ components: $ref: '#/components/schemas/BookOutput' # Generic Error schema - Error: + StandardError: type: object properties: error: - type: string - description: A message describing the error. + type: object + properties: + code: + type: integer + description: The HTTP status code. + example: 400 + name: + type: string + description: The HTTP status name. + example: "Bad Request" + message: + type: string + description: A detailed error message. + example: "API key is missing." + required: + - code + - name + - message required: - error + + # API Error: Reusable responses for common errors + responses: + BadRequest: + description: The server could not process the request due to a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing). + content: + application/json: + schema: + $ref: '#/components/schemas/StandardError' + Unauthorized: + description: API key is missing or invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/StandardError' + NotFound: + description: The requested resource could not be found. + content: + application/json: + schema: + $ref: '#/components/schemas/StandardError' + UnsupportedMediaType: + description: The request payload is not in the expected format (e.g., not JSON). + content: + application/json: + schema: + $ref: '#/components/schemas/StandardError' + InternalServerError: + description: An unexpected error occurred on the server. + content: + application/json: + schema: + $ref: '#/components/schemas/StandardError' # -------------------------------------------- # Paths paths: @@ -123,8 +178,10 @@ paths: tags: - Books summary: Add a new book + security: + - ApiKeyAuth: [] description: Adds a new book to the collection. The server will generate a unique ID and HATEOAS links for the new book. - operationId: addBook + operationId: addBook requestBody: description: Book object that needs to be added to the store. required: true @@ -140,40 +197,13 @@ paths: schema: $ref: '#/components/schemas/BookOutput' '400': - description: Invalid input provided or missing fields. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - examples: - missingFields: - summary: Example of missing fields error - value: - error: "Missing required fields: title, synopsis" - notADictionary: - summary: Example of payload not being a dictionary - value: - error: "JSON payload must be a dictionary" - incorrectFieldTypeInternal: - summary: Example of internal field type validation error - value: - error: "Field title is not of type " + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '415': - description: Request payload is not JSON. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: "Request must be JSON" + $ref: '#/components/responses/UnsupportedMediaType' '500': - description: Internal Server Error. An unexpected error occurred on the server. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: "An unexpected error occurred." + $ref: '#/components/responses/InternalServerError' # -------------------------------------------- get: tags: @@ -198,21 +228,9 @@ paths: items: $ref: '#/components/schemas/BookOutput' '404': - description: No books found in the database. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: "No books found" + $ref: '#/components/responses/NotFound' '500': - description: Internal Server Error. An unexpected error occurred. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: "An unexpected error occurred." + $ref: '#/components/responses/InternalServerError' # -------------------------------------------- /books/{book_id}: # -------------------------------------------- @@ -239,30 +257,17 @@ paths: schema: $ref: '#/components/schemas/BookOutput' '404': - description: Book not found. The requested book ID does not exist. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - bookNotFound: - summary: Example of a book not found error - value: - error: "Book not found" + $ref: '#/components/responses/NotFound' '500': - description: Internal Server Error. An unexpected error occurred on the server. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: "An unexpected error occurred." + $ref: '#/components/responses/InternalServerError' # -------------------------------------------- put: tags: - Books summary: Update a book + security: + - ApiKeyAuth: [] description: Updates an existing book by its unique ID. The entire book object must be provided in the request body. operationId: updateBook parameters: @@ -289,50 +294,24 @@ paths: schema: $ref: '#/components/schemas/BookOutput' '400': - description: Invalid input, or missing required fields. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - examples: - missingFields: - summary: Example of missing fields error - value: - error: "Missing required fields: title" - notADictionary: - summary: Example of payload not being a dictionary - value: - error: "JSON payload must be a dictionary" + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '404': - description: Book not found. The requested book ID does not exist. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: "Book not found" + $ref: '#/components/responses/NotFound' '415': - description: Request payload is not JSON. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: "Request must be JSON" + $ref: '#/components/responses/UnsupportedMediaType' '500': - description: Internal Server Error. An unexpected error occurred on the server. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: "Book collection not initialized" + $ref: '#/components/responses/InternalServerError' + # -------------------------------------------- delete: tags: - Books summary: Delete a book by ID + security: + - ApiKeyAuth: [] description: Soft deletes a book by setting its state to 'deleted'. operationId: deleteBookById parameters: @@ -347,19 +326,10 @@ paths: responses: '204': description: Book deleted successfully. + content: {} + '401': + $ref: '#/components/responses/Unauthorized' '404': - description: Book not found. The requested book ID does not exist. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: "Book not found" + $ref: '#/components/responses/NotFound' '500': - description: Internal Server Error. An unexpected error occurred on the server. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: "An unexpected error occurred." + $ref: '#/components/responses/InternalServerError' diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..03f586d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . \ No newline at end of file diff --git a/scripts/delete_books.py b/scripts/delete_books.py index 66546a7..7939eff 100644 --- a/scripts/delete_books.py +++ b/scripts/delete_books.py @@ -7,7 +7,9 @@ """ import sys + from pymongo.errors import ConnectionFailure + from app import create_app from app.datastore.mongo_db import get_book_collection @@ -37,7 +39,7 @@ def main(): """ Starts the Flask app context, connects to MongoDB, and deletes all documents in the books collection. - + Returns: int: 0 on success, 1 on failure. """ @@ -62,7 +64,9 @@ def main(): # This is a critical failure. The script could not do its job. # Printing to sys.stderr is best practice for error messages. print("❌ ERROR: Could not connect to MongoDB.", file=sys.stderr) - print("Please ensure the database is running and accessible.", file=sys.stderr) + print( + "Please ensure the database is running and accessible.", file=sys.stderr + ) print(f"Details: {e}", file=sys.stderr) # Return the failure code. diff --git a/tests/conftest.py b/tests/conftest.py index dea84cf..9d80751 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,17 +4,29 @@ This file contains shared fixtures and helpers that are automatically discovered by pytest and made available to all tests. """ +from unittest.mock import patch import mongomock import pytest +from app import create_app + + +@pytest.fixture(name="_insert_book_to_db") +def stub_insert_book(): + """Fixture that mocks insert_book_to_mongo() to prevent real DB writes during tests. Returns a mock with a fixed inserted_id.""" + + with patch("app.routes.insert_book_to_mongo") as mock_insert_book: + mock_insert_book.return_value.inserted_id = "12345" + yield mock_insert_book + @pytest.fixture(name="mock_books_collection") def mock_books_collection_fixture(): """Provides an in-memory, empty 'books' collection for each test.""" # mongomock.MongoClient() creates a fake client. - client = mongomock.MongoClient() - db = client["test_database"] + mongo_client = mongomock.MongoClient() + db = mongo_client["test_database"] return db["test_books_collection"] @@ -47,3 +59,28 @@ def sample_book_data(): "state": "active", }, ] + + +@pytest.fixture() +def test_app(): + """ + Creates the Flask app instance configured for testing. + This is the single source of truth for the test app. + """ + app = create_app( + { + "TESTING": True, + "TRAP_HTTP_EXCEPTIONS": True, + "API_KEY": "test-key-123", + "MONGO_URI": "mongodb://localhost:27017/", + "DB_NAME": "test_database", + "COLLECTION_NAME": "test_books", + } + ) + yield app + + +@pytest.fixture(name="client") +def client(test_app): # pylint: disable=redefined-outer-name + """A test client for the app.""" + return test_app.test_client() diff --git a/tests/test_api_security.py b/tests/test_api_security.py new file mode 100644 index 0000000..694eeb9 --- /dev/null +++ b/tests/test_api_security.py @@ -0,0 +1,196 @@ +# pylint: disable=missing-docstring +""" +Tests for API security features, such as API key authentication. +""" +import logging + +import pytest + +# A dictionary for headers to keep things clean +HEADERS = { + "VALID": {"X-API-KEY": "test-key-123"}, + "INVALID": {"X-API-KEY": "This-is-the-wrong-key-12345"}, + "MISSING": {}, +} + +# A sample payload for POST/PUT requests +DUMMY_PAYLOAD = { + "title": "A Test Book", + "synopsis": "A test synopsis.", + "author": "Tester McTestFace", +} + +# -------------- LOGGING -------------------------- + + +@pytest.mark.parametrize( + "method, path", + [ + ("post", "/books"), + ("put", "/books/some-id"), + ("delete", "/books/some-id"), + ], +) +def test_invalid_api_key_logs_attempt_for_post_route(client, caplog, method, path): + caplog.set_level(logging.WARNING) + + invalid_header = {"X-API-KEY": "This-is-the-wrong-key-12345"} + + response = getattr(client, method)(path, headers=invalid_header) + + assert response.status_code == 401 + assert "Unauthorized access attempt" in caplog.text + assert "/books" in caplog.text + + +# -------------- POST -------------------------- + + +def test_add_book_fails_with_missing_key(client, monkeypatch): + + # Mock external dependencies using monkeypatch + monkeypatch.setattr("app.routes.get_book_collection", lambda: None) + monkeypatch.setattr( + "app.routes.insert_book_to_mongo", lambda book, collection: None + ) + monkeypatch.setattr("app.routes.append_hostname", lambda book, host: book) + + + # Hit the endpoint without Authorization header + response = client.post("/books", json=DUMMY_PAYLOAD) + + # 4. Assert that you got a 401 back + assert response.status_code == 401 + assert "API key is missing." in response.json["error"]["message"] + + +def test_add_book_succeeds_with_valid_key(client, monkeypatch): + # Patch the database + monkeypatch.setattr("app.routes.get_book_collection", lambda: None) + monkeypatch.setattr( + "app.routes.insert_book_to_mongo", lambda book, collection: None + ) + monkeypatch.setattr("app.routes.append_hostname", lambda book, host: book) + + response = client.post("/books", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"]) + + assert response.status_code == 201 + + +def test_add_book_fails_with_invalid_key(client): + # ACT + response = client.post("/books", json=DUMMY_PAYLOAD, headers=HEADERS["INVALID"]) + + # ASSERT: Verify the server rejected the request as expected. + assert response.status_code == 401 + assert "Invalid API key." in response.json["error"]["message"] + + +def test_add_book_fails_if_api_key_not_configured_on_the_server(client, test_app): + # ARRANGE: Remove API_KEY from the test_app config + test_app.config.pop("API_KEY", None) + + response = client.post("/books", json=DUMMY_PAYLOAD) + + assert response.status_code == 500 + assert "API key not configured on the server." in response.json["error"]["message"] + + +# -------------- PUT -------------------------- +def test_update_book_succeeds_with_valid_api_key(monkeypatch, client): + """Test successful book update with valid API key.""" + + # 1. Patch the books list + test_books = [ + { + "id": "abc123", + "title": "Old Title", + "synopsis": "Old Synopsis", + "author": "Old Author", + } + ] + monkeypatch.setattr("app.routes.books", test_books) + + # 2. Patch append_hostname to just return the book + monkeypatch.setattr("app.routes.append_hostname", lambda book, host: book) + + + # 3. Call the endpoint with request details + response = client.put("/books/abc123", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"]) + + # 4. Assert response + assert response.status_code == 200 + assert response.json["title"] == "A Test Book" + + +def test_update_book_fails_with_missing_api_key(monkeypatch, client): + """Should return 401 if no API key is provided.""" + + monkeypatch.setattr("app.routes.books", []) + + response = client.put("/books/abc123", json=DUMMY_PAYLOAD) + + assert response.status_code == 401 + assert "API key is missing." in response.json["error"]["message"] + + +def test_update_book_fails_with_invalid_api_key(client, monkeypatch): + monkeypatch.setattr("app.routes.books", []) + + response = client.put("/books/abc123", json=DUMMY_PAYLOAD, headers=HEADERS["INVALID"]) + # ASSERT: Verify the server rejected the request as expected. + assert response.status_code == 401 + assert "Invalid API key." in response.json["error"]["message"] + + +def test_update_book_fails_if_api_key_not_configured_on_the_server( + client, test_app, monkeypatch +): + # ARRANGE: Remove API_KEY from the test_app config + monkeypatch.setattr("app.routes.books", []) + test_app.config.pop("API_KEY", None) + + response = client.put("/books/abc123", json=DUMMY_PAYLOAD) + + assert response.status_code == 500 + assert "API key not configured on the server." in response.json["error"]["message"] + + +# -------------- DELETE -------------------------- +def test_delete_book_fails_with_invalid_api_key(client): + """ + WHEN a DELETE request is made without an API key + THEN the response should be 401 Unauthorized + """ + response = client.delete("/books/some-id") + assert response.status_code == 401 + assert "API key is missing." in response.json["error"]["message"] + + +def test_delete_book_succeeds_with_valid_api_key(client, monkeypatch): + """ + WHEN a DELETE request is made with a valid API key + THEN it should return 200 OK (or appropriate response) + """ + monkeypatch.setattr("app.routes.books", [{"id": "some-id"}]) + + response = client.delete("/books/some-id", headers=HEADERS["VALID"]) + assert response.status_code == 204 + + +def test_delete_book_fails_with_invalid_key(client): + + response = client.delete("/books/any-book-id", headers=HEADERS["INVALID"]) + + # ASSERT: Verify the server rejected the request as expected. + assert response.status_code == 401 + assert "Invalid API key." in response.json["error"]["message"] + + +def test_delete_book_fails_if_api_key_not_configured_on_the_server(client, test_app): + test_app.config.pop("API_KEY", None) + + response = client.delete("/books/any-book-id") + + assert response.status_code == 500 + assert "API key not configured on the server." in response.json["error"]["message"] diff --git a/tests/test_app.py b/tests/test_app.py index e4494f4..e9c1d07 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -8,26 +8,7 @@ from app import create_app from app.datastore.mongo_db import get_book_collection - -# Option 1: Rename the fixture to something unique (which I've used) -# Option 2: Use a linter plugin that understands pytest -@pytest.fixture(name="client") -def client_fixture(): - app = create_app() - app.config["TESTING"] = True - return app.test_client() - - -# Create a stub to mock the insert_book_to_mongo function to avoid inserting to real DB -@pytest.fixture(name="_insert_book_to_db") -def stub_insert_book(): - with patch("app.routes.insert_book_to_mongo") as mock_insert_book: - mock_insert_book.return_value.inserted_id = "12345" - yield mock_insert_book - - # Mock book database object - books_database = [ { "id": "1", @@ -78,7 +59,10 @@ def test_add_book_creates_new_book(client, _insert_book_to_db): "synopsis": "Test Synopsis", } - response = client.post("/books", json=test_book) + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} + + response = client.post("/books", json=test_book, headers=valid_headers) assert response.status_code == 201 assert response.headers["content-type"] == "application/json" @@ -95,7 +79,10 @@ def test_add_book_sent_with_missing_required_fields(client): "author": "AN Other" # missing 'title' and 'synopsis' } - response = client.post("/books", json=test_book) + + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} + response = client.post("/books", json=test_book, headers=valid_headers) assert response.status_code == 400 response_data = response.get_json() @@ -106,7 +93,9 @@ def test_add_book_sent_with_missing_required_fields(client): def test_add_book_sent_with_wrong_types(client): test_book = {"title": 1234567, "author": "AN Other", "synopsis": "Test Synopsis"} - response = client.post("/books", json=test_book) + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} + response = client.post("/books", json=test_book, headers=valid_headers) assert response.status_code == 400 response_data = response.get_json() @@ -116,19 +105,26 @@ def test_add_book_sent_with_wrong_types(client): def test_add_book_with_invalid_json_content(client): + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} + # This should trigger a TypeError - response = client.post("/books", json="This is not a JSON object") + response = client.post( + "/books", json="This is not a JSON object", headers=valid_headers + ) assert response.status_code == 400 assert "JSON payload must be a dictionary" in response.get_json()["error"] def test_add_book_check_request_header_is_json(client): + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} response = client.post( "/books", data="This is not a JSON object", - headers={"content-type": "text/plain"}, + headers={"content-type": "text/plain", **valid_headers}, ) assert response.status_code == 415 @@ -141,10 +137,12 @@ def test_500_response_is_json(client): "author": "AN Other", "synopsis": "Test Synopsis", } + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} # Use patch to mock uuid module failing and throwing an exception with patch("uuid.uuid4", side_effect=Exception("An unexpected error occurred")): - response = client.post("/books", json=test_book) + response = client.post("/books", json=test_book, headers=valid_headers) # Check the response code is 500 assert response.status_code == 500 @@ -311,9 +309,10 @@ def test_get_book_returns_404_if_state_equals_deleted(client): def test_book_is_soft_deleted_on_delete_request(client): with patch("app.routes.books", books_database): - # Send DELETE request + # Send DELETE request with valid API header book_id = "1" - response = client.delete(f"/books/{book_id}") + headers = {"X-API-KEY": "test-key-123"} + response = client.delete(f"/books/{book_id}", headers=headers) assert response.status_code == 204 assert response.data == b"" @@ -330,7 +329,8 @@ def test_delete_empty_book_id(client): def test_delete_invalid_book_id(client): - response = client.delete("/books/12341234") + headers = {"X-API-KEY": "test-key-123"} + response = client.delete("/books/12341234", headers=headers) assert response.status_code == 404 assert response.content_type == "application/json" assert "Book not found" in response.get_json()["error"] @@ -338,7 +338,8 @@ def test_delete_invalid_book_id(client): def test_book_database_is_initialized_for_delete_book_route(client): with patch("app.routes.books", None): - response = client.delete("/books/1") + headers = {"X-API-KEY": "test-key-123"} + response = client.delete("/books/1", headers=headers) assert response.status_code == 500 assert "Book collection not initialized" in response.get_json()["error"] @@ -354,9 +355,11 @@ def test_update_book_request_returns_correct_status_and_content_type(client): "author": "AN Other", "synopsis": "Test Synopsis", } + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} # send PUT request - response = client.put("/books/1", json=test_book) + response = client.put("/books/1", json=test_book, headers=valid_headers) # Check response status code and content type assert response.status_code == 200 @@ -370,9 +373,11 @@ def test_update_book_request_returns_required_fields(client): "author": "AN Other", "synopsis": "Test Synopsis", } + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} # Send PUT request - response = client.put("/books/1", json=test_book) + response = client.put("/books/1", json=test_book, headers=valid_headers) response_data = response.get_json() # Check that required fields are in the response data @@ -400,8 +405,9 @@ def test_update_book_replaces_whole_object(client): "author": "Updated Author", "synopsis": "Updated Synopsis", } - - response = client.put("/books/1", json=updated_data) + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} + response = client.put("/books/1", json=updated_data, headers=valid_headers) assert response.status_code == 200 data = response.get_json() @@ -423,7 +429,9 @@ def test_update_book_sent_with_invalid_book_id(client): "author": "Some author", "synopsis": "Some synopsis", } - response = client.put("/books/999", json=test_book) + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} + response = client.put("/books/999", json=test_book, headers=valid_headers) assert response.status_code == 404 assert "Book not found" in response.get_json()["error"] @@ -435,9 +443,10 @@ def test_book_database_is_initialized_for_update_book_route(client): "author": "AN Other", "synopsis": "Test Synopsis", } - + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} # Send PUT request - response = client.put("/books/1", json=test_book) + response = client.put("/books/1", json=test_book, headers=valid_headers) assert response.status_code == 500 assert "Book collection not initialized" in response.get_json()["error"] @@ -447,7 +456,7 @@ def test_update_book_check_request_header_is_json(client): response = client.put( "/books/1", data="This is not a JSON object", - headers={"content-type": "text/plain"}, + headers={"content-type": "text/plain", "X-API-KEY": "test-key-123"}, ) assert response.status_code == 415 @@ -455,9 +464,13 @@ def test_update_book_check_request_header_is_json(client): def test_update_book_with_invalid_json_content(client): + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} # This should trigger a TypeError - response = client.put("/books/1", json="This is not a JSON object") + response = client.put( + "/books/1", json="This is not a JSON object", headers=valid_headers + ) assert response.status_code == 400 assert "JSON payload must be a dictionary" in response.get_json()["error"] @@ -468,7 +481,10 @@ def test_update_book_sent_with_missing_required_fields(client): "author": "AN Other" # missing 'title' and 'synopsis' } - response = client.put("/books/1", json=test_book) + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} + + response = client.put("/books/1", json=test_book, headers=valid_headers) assert response.status_code == 400 response_data = response.get_json() @@ -486,8 +502,10 @@ def test_append_host_to_links_in_post(client, _insert_book_to_db): "author": "AN Other II", "synopsis": "Test Synopsis", } + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} - response = client.post("/books", json=test_book) + response = client.post("/books", json=test_book, headers=valid_headers) assert response.status_code == 201 assert response.headers["content-type"] == "application/json" @@ -573,7 +591,10 @@ def test_append_host_to_links_in_put(client): "author": "AN Other", "synopsis": "Test Synopsis", } - response = client.put("/books/1", json=test_book) + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} + + response = client.put("/books/1", json=test_book, headers=valid_headers) assert response.status_code == 200 assert response.headers["content-type"] == "application/json" diff --git a/tests/test_delete_books.py b/tests/test_delete_books.py index b3b23be..b52bcc3 100644 --- a/tests/test_delete_books.py +++ b/tests/test_delete_books.py @@ -1,6 +1,8 @@ # pylint: disable=missing-docstring,line-too-long from unittest.mock import MagicMock, patch + from pymongo.errors import ConnectionFailure + from scripts.delete_books import delete_all_books, main # ----------------- TEST SUITE --------------------------------- @@ -98,8 +100,10 @@ def test_delete_all_books_clears_collection_and_returns_count( assert mock_books_collection.count_documents({}) == 0 -@patch('scripts.delete_books.get_book_collection', -side_effect=ConnectionFailure("Mock DB connection error")) +@patch( + "scripts.delete_books.get_book_collection", + side_effect=ConnectionFailure("Mock DB connection error"), +) def test_main_handles_connection_failure_gracefully(mock_get_book_collection, capsys): # ACT exit_code = main() diff --git a/tests/test_integration.py b/tests/test_integration.py index 272a132..f2ecdef 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,19 +2,6 @@ import pytest from pymongo import MongoClient -from app import create_app - - -@pytest.fixture(name="client") -def client_fixture(): - """Provides a test client for making requests to our Flask app.""" - app = create_app() - app.config["TESTING"] = True - app.config["MONGO_URI"] = "mongodb://localhost:27017/" - app.config["DB_NAME"] = "test_database" - app.config["COLLECTION_NAME"] = "test_books" - return app.test_client() - @pytest.fixture(name="mongo_client") def mongo_client_fixture(): @@ -38,9 +25,11 @@ def test_post_route_inserts_to_mongodb(mongo_client, client): "synopsis": "A novel about all the choices that go into a life well lived.", "author": "Matt Haig", } + # Define the valid headers, including the API key that matches conftest.py + valid_headers = {"X-API-KEY": "test-key-123"} # Act- send the POST request: - response = client.post("/books", json=new_book_payload) + response = client.post("/books", json=new_book_payload, headers=valid_headers) # Assert: assert response.status_code == 201