├── app ├── models │ ├── __init__.py │ ├── user.py │ └── todo.py ├── routes │ ├── __init__.py │ ├── errors.py │ ├── docs.py │ ├── notes.py │ ├── todos.py │ └── auth.py ├── services │ ├── __init__.py │ ├── note_service.py │ ├── auth_service.py │ └── todo_service.py ├── data │ └── notes │ │ └── welcome.txt ├── utils │ ├── auth.py │ └── config.py ├── config │ └── auth_config.py ├── middleware │ └── auth_middleware.py └── main.py ├── .gitignore ├── requirements.txt ├── docker-compose.yml ├── initial_users.json ├── auth_config.yml ├── docs ├── auth │ ├── api_key.json │ ├── session.json │ └── jwt.json └── routes │ ├── notes.json │ └── todos.json ├── initial_todos.json ├── Dockerfile ├── .github └── workflows │ └── docker-build-push.yml ├── README.md ├── LICENSE └── test ├── todo_api_collection.json └── auth_api_collection.json /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/routes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__/ 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.2.5 2 | PyJWT==2.8.0 3 | PyYAML==6.0.1 4 | flasgger==0.9.7.1 -------------------------------------------------------------------------------- /app/data/notes/welcome.txt: -------------------------------------------------------------------------------- 1 | Welcome to Your Notes! 📝 2 | 3 | This is a sample note that comes with the application. 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | flask-app: 3 | build: 4 | context: . 5 | ports: 6 | - "8000:8000" 7 | -------------------------------------------------------------------------------- /initial_users.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "username": "johnsmith", 5 | "password": "testpass123" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /auth_config.yml: -------------------------------------------------------------------------------- 1 | auth: 2 | # Choose one of: none, api_key, jwt, session 3 | method: none 4 | 5 | # For API key authentication: 6 | # api_key: your-secure-api-key 7 | 8 | # For JWT or session authentication: 9 | # secret: your-secret-key-here 10 | -------------------------------------------------------------------------------- /docs/auth/api_key.json: -------------------------------------------------------------------------------- 1 | { 2 | "method": "api_key", 3 | "description": "API Key authentication required for protected endpoints.", 4 | "how_to_authenticate": "Include your API key in the X-API-Key header for all requests.", 5 | "example": { 6 | "headers": { 7 | "X-API-Key": "your-api-key-here" 8 | } 9 | }, 10 | "protected_endpoints": ["/todos/*", "/notes/*"] 11 | } -------------------------------------------------------------------------------- /app/models/user.py: -------------------------------------------------------------------------------- 1 | from werkzeug.security import generate_password_hash, check_password_hash 2 | 3 | class User: 4 | def __init__(self, username, password): 5 | self.username = username 6 | self.password_hash = generate_password_hash(password) 7 | 8 | def check_password(self, password): 9 | return check_password_hash(self.password_hash, password) 10 | 11 | def __repr__(self): 12 | return f"User(username={self.username})" -------------------------------------------------------------------------------- /app/models/todo.py: -------------------------------------------------------------------------------- 1 | class Todo: 2 | """A class representing a single TODO item.""" 3 | def __init__(self, id, title, done=False, description=None): 4 | self.id = id 5 | self.title = title 6 | self.done = done 7 | self.description = description 8 | 9 | def to_dict(self): 10 | """Convert the TODO item to a dictionary.""" 11 | return { 12 | "id": self.id, 13 | "title": self.title, 14 | "done": self.done, 15 | "description": self.description, 16 | } 17 | -------------------------------------------------------------------------------- /app/routes/errors.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, request 2 | 3 | errors_bp = Blueprint("errors", __name__) 4 | 5 | @errors_bp.app_errorhandler(404) 6 | def handle_not_found_error(error): 7 | return jsonify({ 8 | "error": str(error), 9 | "endpoint": request.path 10 | }), 404 11 | 12 | @errors_bp.app_errorhandler(400) 13 | def handle_bad_request_error(error): 14 | return jsonify({ 15 | "error": str(error), 16 | "endpoint": request.path 17 | }), 400 18 | 19 | @errors_bp.app_errorhandler(500) 20 | def handle_internal_server_error(error): 21 | return jsonify({ 22 | "error": str(error), 23 | "endpoint": request.path 24 | }), 500 25 | -------------------------------------------------------------------------------- /initial_todos.json: -------------------------------------------------------------------------------- 1 | { 2 | "todos": [ 3 | { 4 | "id": 1, 5 | "title": "Buy groceries", 6 | "description": "Milk, eggs, bread, and coffee", 7 | "done": false 8 | }, 9 | { 10 | "id": 2, 11 | "title": "Call mom", 12 | "description": "Check in and catch up", 13 | "done": true 14 | }, 15 | { 16 | "id": 3, 17 | "title": "Finish project report", 18 | "description": "Summarize Q4 performance metrics", 19 | "done": false 20 | }, 21 | { 22 | "id": 4, 23 | "title": "Workout", 24 | "description": "30 minutes of cardio", 25 | "done": true 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Python 3.10.6 as the base image for the container 2 | FROM python:3.10.6 3 | 4 | # Copy the requirements.txt file to the container's root directory 5 | COPY requirements.txt . 6 | 7 | # Copy configuration and initial data files 8 | COPY auth_config.yml . 9 | COPY initial_todos.json . 10 | COPY initial_users.json . 11 | 12 | # Install Python dependencies listed in requirements.txt 13 | RUN pip install --no-cache-dir -r requirements.txt 14 | 15 | # Expose port 8000 to make the Flask app accessible 16 | # This is a documentation instruction and does not map the port to the host 17 | EXPOSE 8000 18 | 19 | # Copy the app folder containing the Flask application to /app in the container 20 | COPY app /app 21 | 22 | # Set the working directory to /app 23 | WORKDIR /app 24 | 25 | # Run the Flask app when the container starts 26 | CMD ["python", "main.py"] 27 | -------------------------------------------------------------------------------- /docs/routes/notes.json: -------------------------------------------------------------------------------- 1 | { 2 | "/notes": { 3 | "POST": { 4 | "description": "Upload a new note file.", 5 | "content_type": "multipart/form-data", 6 | "form_params": { 7 | "file": "The .txt file to upload (required, max size: 1MB)" 8 | }, 9 | "responses": { 10 | "201": "Note created successfully", 11 | "400": "Invalid request (empty file, wrong format, etc.)" 12 | } 13 | } 14 | }, 15 | "/notes/": { 16 | "GET": { 17 | "description": "Download a note by its name.", 18 | "responses": { 19 | "200": "Note file content", 20 | "404": "Note not found" 21 | } 22 | }, 23 | "DELETE": { 24 | "description": "Delete a note by its name.", 25 | "responses": { 26 | "204": "Note deleted successfully", 27 | "404": "Note not found" 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /docs/auth/session.json: -------------------------------------------------------------------------------- 1 | { 2 | "method": "session", 3 | "description": "Session-based authentication required for protected endpoints.", 4 | "how_to_authenticate": "Create account via /auth/signup, login via /auth/login to create a session, session cookie will be automatically managed by your client, use /auth/logout to end your session", 5 | "endpoints": { 6 | "/auth/signup": { 7 | "method": "POST", 8 | "body": {"username": "string", "password": "string"}, 9 | "response": {"message": "Signup successful. Please log in to continue."} 10 | }, 11 | "/auth/login": { 12 | "method": "POST", 13 | "body": {"username": "string", "password": "string"}, 14 | "response": {"message": "Login successful"} 15 | }, 16 | "/auth/logout": { 17 | "method": "POST", 18 | "response": {"message": "Logout successful"} 19 | } 20 | }, 21 | "protected_endpoints": ["/todos/*", "/notes/*"] 22 | } -------------------------------------------------------------------------------- /app/utils/auth.py: -------------------------------------------------------------------------------- 1 | from config.auth_config import AuthConfig 2 | 3 | def setup_auth_config(auth_method, secret=None): 4 | """Configure authentication based on the specified method.""" 5 | auth_config = AuthConfig() 6 | 7 | if auth_method == "none": 8 | auth_config.disable_auth() 9 | print("Running with no authentication - all endpoints are public") 10 | elif auth_method == "api_key": 11 | secret = secret or "your-secure-api-key" 12 | auth_config.configure_api_key(secret) 13 | print(f"Running with API Key authentication") 14 | elif auth_method == "jwt": 15 | secret = secret or "your-jwt-secret-key" 16 | auth_config.configure_jwt(secret) 17 | print(f"Running with JWT authentication") 18 | elif auth_method == "session": 19 | secret = secret or "your-session-secret-key" 20 | auth_config.configure_session(secret) 21 | print(f"Running with Session authentication") 22 | else: 23 | raise ValueError(f"Invalid authentication method: {auth_method}") 24 | 25 | return auth_config -------------------------------------------------------------------------------- /docs/auth/jwt.json: -------------------------------------------------------------------------------- 1 | { 2 | "method": "jwt", 3 | "description": "JWT (JSON Web Token) authentication required for protected endpoints.", 4 | "how_to_authenticate": "Create account via /auth/signup, get tokens via /auth/login, include the access token in the Authorization header, use /auth/refresh with refresh token to get new tokens, use /auth/logout with both tokens to end session", 5 | "endpoints": { 6 | "/auth/signup": { 7 | "method": "POST", 8 | "body": {"username": "string", "password": "string"}, 9 | "response": {"message": "Signup successful. Please log in to continue."} 10 | }, 11 | "/auth/login": { 12 | "method": "POST", 13 | "body": {"username": "string", "password": "string"}, 14 | "response": { 15 | "message": "Login successful", 16 | "access_token": "string", 17 | "refresh_token": "string" 18 | } 19 | }, 20 | "/auth/refresh": { 21 | "method": "POST", 22 | "body": {"refresh_token": "string"}, 23 | "response": { 24 | "access_token": "string", 25 | "refresh_token": "string" 26 | } 27 | }, 28 | "/auth/logout": { 29 | "method": "POST", 30 | "headers": {"Authorization": "Bearer "}, 31 | "body": {"refresh_token": "string"}, 32 | "response": {"message": "string"} 33 | } 34 | }, 35 | "example": { 36 | "headers": { 37 | "Authorization": "Bearer your-jwt-access-token-here" 38 | } 39 | }, 40 | "protected_endpoints": ["/todos/*", "/notes/*"] 41 | } -------------------------------------------------------------------------------- /.github/workflows/docker-build-push.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | run-name: Build and push todo-api image for ${{ github.ref_name }} by @${{ github.actor }} 3 | 4 | on: 5 | push: 6 | branches: 7 | - 'main' 8 | - 'develop' 9 | tags: 10 | - 'v*' 11 | pull_request: 12 | branches: 13 | - 'main' 14 | 15 | env: 16 | REGISTRY: ghcr.io 17 | IMAGE_NAME: ${{ github.repository }} 18 | 19 | permissions: 20 | contents: read 21 | packages: write 22 | 23 | jobs: 24 | build-and-push: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | 33 | - name: Log in to Container Registry 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Extract metadata 41 | id: meta 42 | uses: docker/metadata-action@v5 43 | with: 44 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 45 | tags: | 46 | # set latest tag for default branch 47 | type=ref,event=branch 48 | type=ref,event=pr 49 | type=semver,pattern={{version}} 50 | type=semver,pattern={{major}}.{{minor}} 51 | type=semver,pattern={{major}} 52 | type=raw,value=latest,enable={{is_default_branch}} 53 | 54 | - name: Build and push Docker image 55 | uses: docker/build-push-action@v5 56 | with: 57 | context: . 58 | platforms: linux/amd64,linux/arm64 59 | push: true 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: ${{ steps.meta.outputs.labels }} 62 | cache-from: type=gha 63 | cache-to: type=gha,mode=max 64 | -------------------------------------------------------------------------------- /app/routes/docs.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Blueprint, current_app, json, Response 3 | from config.auth_config import AuthMethod 4 | 5 | docs_bp = Blueprint("docs", __name__) 6 | 7 | def load_json_file(filename): 8 | """Load and parse a JSON file.""" 9 | try: 10 | with open(filename, 'r') as f: 11 | return json.load(f) 12 | except (FileNotFoundError, json.JSONDecodeError): 13 | return {} 14 | 15 | @docs_bp.route("", methods=["GET"]) 16 | def api_docs(): 17 | """Provide comprehensive API documentation.""" 18 | docs = {} 19 | 20 | # Load route documentation in specific order 21 | route_files = ['todos.json', 'notes.json'] # Define order explicitly 22 | docs_dir = os.path.join(current_app.root_path, '..', 'docs', 'routes') 23 | 24 | for filename in route_files: 25 | route_docs = load_json_file(os.path.join(docs_dir, filename)) 26 | docs.update(route_docs) 27 | 28 | # Load authentication documentation 29 | auth_docs = _get_auth_docs() 30 | if auth_docs: 31 | docs["authentication"] = auth_docs 32 | 33 | return Response( 34 | json.dumps(docs, sort_keys=False, indent=2) + "\n", 35 | mimetype='application/json' 36 | ), 200 37 | 38 | def _get_auth_docs(): 39 | """Return authentication documentation based on current auth method.""" 40 | auth_config = current_app.config.get('auth_config') 41 | if not auth_config or auth_config.auth_method == AuthMethod.NONE: 42 | return None 43 | 44 | auth_method_map = { 45 | AuthMethod.API_KEY: 'api_key', 46 | AuthMethod.JWT: 'jwt', 47 | AuthMethod.SESSION: 'session' 48 | } 49 | 50 | method_name = auth_method_map.get(auth_config.auth_method) 51 | if not method_name: 52 | return {"error": "Unknown authentication method"} 53 | 54 | auth_file = os.path.join( 55 | current_app.root_path, 56 | '..', 57 | 'docs', 58 | 'auth', 59 | f'{method_name}.json' 60 | ) 61 | return load_json_file(auth_file) 62 | -------------------------------------------------------------------------------- /app/routes/notes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request 2 | from services.note_service import NoteService 3 | 4 | notes_bp = Blueprint("notes", __name__) 5 | 6 | @notes_bp.route("", methods=["POST"]) 7 | def upload_note(): 8 | """Upload a new note in .txt format 9 | --- 10 | tags: 11 | - notes 12 | parameters: 13 | - name: file 14 | in: formData 15 | type: file 16 | required: true 17 | description: Text file to upload. Must be .txt format, max size 1MB 18 | responses: 19 | 201: 20 | description: Note created successfully 21 | schema: 22 | type: object 23 | properties: 24 | message: 25 | type: string 26 | description: Success message 27 | note_name: 28 | type: string 29 | description: Name of the uploaded note 30 | 400: 31 | description: Invalid request - empty file or wrong format 32 | """ 33 | return NoteService.upload_note(request) 34 | 35 | @notes_bp.route("/", methods=["GET"]) 36 | def download_note(note_name): 37 | """Download an existing note by its name 38 | --- 39 | tags: 40 | - notes 41 | parameters: 42 | - name: note_name 43 | in: path 44 | type: string 45 | required: true 46 | description: Name of the note to download 47 | responses: 48 | 200: 49 | description: Note file content 50 | content: 51 | text/plain: 52 | schema: 53 | type: string 54 | 404: 55 | description: Note not found 56 | """ 57 | return NoteService.download_note(note_name) 58 | 59 | @notes_bp.route("/", methods=["DELETE"]) 60 | def delete_note(note_name): 61 | """Delete an existing note by its name 62 | --- 63 | tags: 64 | - notes 65 | parameters: 66 | - name: note_name 67 | in: path 68 | type: string 69 | required: true 70 | description: Name of the note to delete 71 | responses: 72 | 204: 73 | description: Note deleted successfully 74 | 404: 75 | description: Note not found 76 | """ 77 | return NoteService.delete_note(note_name) -------------------------------------------------------------------------------- /docs/routes/todos.json: -------------------------------------------------------------------------------- 1 | { 2 | "/todos": { 3 | "GET": { 4 | "description": "Fetch all TODO items with optional filtering and pagination.", 5 | "query_params": { 6 | "done": "Filter by completion status (true/false).", 7 | "title": "Filter by TODO item title prefix.", 8 | "page": "Page number for pagination (optional, starts at 1).", 9 | "limit": "Number of items per page (optional)." 10 | }, 11 | "responses": { 12 | "200": "List of todo items" 13 | } 14 | }, 15 | "POST": { 16 | "description": "Add a new TODO item.", 17 | "body_params": { 18 | "title": "The TODO item title (required).", 19 | "done": "Completion status (optional, default: false).", 20 | "description": "Detailed TODO item description (optional)." 21 | }, 22 | "responses": { 23 | "201": "Created todo item", 24 | "400": "Invalid request (missing title)" 25 | } 26 | } 27 | }, 28 | "/todos/": { 29 | "GET": { 30 | "description": "Fetch a single TODO item by its ID.", 31 | "responses": { 32 | "200": "Todo item", 33 | "404": "Todo not found" 34 | } 35 | }, 36 | "PUT": { 37 | "description": "Replace an existing TODO item by its ID (all fields required).", 38 | "body_params": { 39 | "title": "The TODO item title (required).", 40 | "done": "Completion status (required).", 41 | "description": "Detailed TODO item description (required)." 42 | }, 43 | "responses": { 44 | "200": "Updated todo item", 45 | "400": "Invalid request (missing required fields)", 46 | "404": "Todo not found" 47 | } 48 | }, 49 | "PATCH": { 50 | "description": "Update part of a TODO item by its ID (any field can be provided).", 51 | "body_params": { 52 | "title": "The TODO item title (optional).", 53 | "done": "Completion status (optional).", 54 | "description": "Detailed TODO item description (optional)." 55 | }, 56 | "responses": { 57 | "200": "Updated todo item", 58 | "400": "Invalid request (empty body)", 59 | "404": "Todo not found" 60 | } 61 | }, 62 | "DELETE": { 63 | "description": "Delete a TODO item by its ID.", 64 | "responses": { 65 | "204": "Todo deleted successfully", 66 | "404": "Todo not found" 67 | } 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /app/utils/config.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import os 3 | import json 4 | from pathlib import Path 5 | from typing import List 6 | 7 | INITIAL_USERS_FILE = "initial_users.json" 8 | INITIAL_TODOS_FILE = "initial_todos.json" 9 | 10 | 11 | def load_config(): 12 | """Load configuration from auth_config.yml file.""" 13 | config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'auth_config.yml') 14 | 15 | if not os.path.exists(config_path): 16 | print(f"Warning: auth_config.yml not found at {config_path}, using default configuration (no auth)") 17 | return "none", None 18 | 19 | try: 20 | with open(config_path, 'r') as f: 21 | config = yaml.safe_load(f) 22 | 23 | auth_config = config.get('auth', {}) 24 | method = auth_config.get('method', 'none') 25 | 26 | if method == 'none': 27 | return method, None 28 | 29 | # Handle different authentication methods 30 | if method == 'api_key': 31 | secret = auth_config.get('api_key') 32 | if not secret: 33 | raise ValueError("API key must be provided when using api_key authentication") 34 | elif method in ['jwt', 'session']: 35 | secret = auth_config.get('secret') 36 | if not secret: 37 | raise ValueError(f"Secret key must be provided when using {method} authentication") 38 | else: 39 | raise ValueError(f"Invalid authentication method: {method}") 40 | 41 | return method, secret 42 | except Exception as e: 43 | print(f"Error loading auth_config.yml: {e}") 44 | print("Using default configuration (no auth)") 45 | return "none", None 46 | 47 | 48 | def load_initial_todos(): 49 | """Load initial todos from the configuration file.""" 50 | path = Path(__file__).resolve().parents[2] / INITIAL_TODOS_FILE 51 | return load_initial_data(path, "todos") 52 | 53 | def load_initial_users() -> List: 54 | path = Path(__file__).resolve().parents[2] / INITIAL_USERS_FILE 55 | return load_initial_data(path) 56 | 57 | def load_initial_data(path: Path, data_key: str = "data") -> List: 58 | filename = path.name 59 | 60 | if not path.exists(): 61 | print(f"Warning: {filename} not found at {path}, using empty list") 62 | return [] 63 | 64 | try: 65 | with open(path, 'r') as f: 66 | data = json.load(f) 67 | return data.get(data_key, []) 68 | except Exception as e: 69 | print(f"Error loading {filename}: {e}") 70 | return [] 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Todo API 2 | 3 | Todo API is a simple RESTful service for managing a to-do list, allowing users to create, read, update, and delete tasks. 4 | 5 | ## Prerequisites 6 | 7 | - [Docker](https://www.docker.com/get-started) 8 | - [Docker Compose](https://docs.docker.com/compose/) 9 | 10 | ## Quick Start 11 | 12 | 1. Clone the repository: 13 | 14 | ```bash 15 | git clone https://github.com/matheusgalvao1/todo-api.git 16 | cd todo-api 17 | ``` 18 | 19 | 2. Build and start the application with Docker Compose: 20 | 21 | ```bash 22 | docker-compose up --build -d 23 | ``` 24 | 25 | 3. The API will be available at `http://localhost:8000`. 26 | 27 | ## Authentication Methods 28 | 29 | The API's authentication is configured through `auth_config.yml` in the root directory: 30 | 31 | 1. No Authentication (`none`): 32 | ```yaml 33 | auth: 34 | method: none 35 | ``` 36 | All endpoints will be public. 37 | 38 | 2. API Key Authentication (`api_key`): 39 | ```yaml 40 | auth: 41 | method: api_key 42 | api_key: your-secure-api-key 43 | ``` 44 | Clients must include the API key in the `X-API-Key` header. 45 | 46 | 3. JWT Authentication (`jwt`): 47 | ```yaml 48 | auth: 49 | method: jwt 50 | secret: your-jwt-secret 51 | ``` 52 | Clients must obtain a JWT token via login/signup and include it in the `Authorization: Bearer ` header. 53 | 54 | 4. Session Authentication (`session`): 55 | ```yaml 56 | auth: 57 | method: session 58 | secret: your-session-secret 59 | ``` 60 | Uses browser sessions for authentication. 61 | 62 | ## Initial Data 63 | 64 | The project comes with initial data, seeded at startup: 65 | - Todos: `initial_todos.json` 66 | - Users: `initial_users.json` 67 | 68 | ## Running the API 69 | 70 | Simply run: 71 | ```bash 72 | python app/main.py 73 | ``` 74 | 75 | The API will read the configuration from `auth_config.yml`. If the file doesn't exist, it will default to no authentication. 76 | 77 | ## Sidecar Usage 78 | 79 | The application is containerized and exposes port `8000`, making it suitable for use as a sidecar in various deployment scenarios. 80 | 81 | The Docker container exposes port `8000`, allowing the API to be accessed from other containers or services in the same network. In order to enable it in the task, you need to add the following to the task configuration: 82 | 83 | - **For PR testing**: `ghcr.io/codesignal/learn_todo-api:` 84 | - Example: `ghcr.io/codesignal/learn_todo-api:pr-8` 85 | - **For main branch**: `ghcr.io/codesignal/learn_todo-api:latest` 86 | 87 | The sidecar can be used in base tasks and other containerized environments where you need a todo API service. 88 | -------------------------------------------------------------------------------- /app/services/note_service.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import send_file, jsonify, current_app 3 | from werkzeug.utils import secure_filename 4 | 5 | class NoteService: 6 | NOTES_DIR = 'data/notes' # Path relative to app directory 7 | MAX_NOTE_SIZE = 1024 * 1024 # 1MB max size for notes 8 | 9 | @classmethod 10 | def _get_notes_path(cls): 11 | """Get absolute path to notes directory""" 12 | return os.path.join(current_app.root_path, cls.NOTES_DIR) 13 | 14 | @classmethod 15 | def upload_note(cls, request): 16 | """Handle note upload""" 17 | if 'file' not in request.files: 18 | return jsonify({'error': 'No note file was provided'}), 400 19 | 20 | note_file = request.files['file'] 21 | if note_file.filename == '': 22 | return jsonify({'error': 'No note file was selected'}), 400 23 | 24 | if not note_file.filename.endswith('.txt'): 25 | return jsonify({'error': 'Notes must be in .txt format'}), 400 26 | 27 | # Check file size 28 | note_file.seek(0, os.SEEK_END) 29 | size = note_file.tell() 30 | note_file.seek(0) 31 | 32 | if size > cls.MAX_NOTE_SIZE: 33 | return jsonify({'error': 'Note is too large. Maximum size is 1MB'}), 400 34 | 35 | # Validate content is text 36 | try: 37 | content = note_file.read().decode('utf-8') 38 | if not content.strip(): 39 | return jsonify({'error': 'Note cannot be empty'}), 400 40 | except UnicodeDecodeError: 41 | return jsonify({'error': 'Note must contain valid text'}), 400 42 | finally: 43 | note_file.seek(0) 44 | 45 | note_name = secure_filename(note_file.filename) 46 | note_path = os.path.join(cls._get_notes_path(), note_name) 47 | note_file.save(note_path) 48 | 49 | return jsonify({ 50 | 'message': 'Note saved successfully', 51 | 'note_name': note_name 52 | }), 201 53 | 54 | @classmethod 55 | def download_note(cls, note_name): 56 | """Handle note download""" 57 | note_path = os.path.join(cls._get_notes_path(), secure_filename(note_name)) 58 | 59 | if not os.path.exists(note_path): 60 | return jsonify({'error': 'Note not found'}), 404 61 | 62 | try: 63 | return send_file( 64 | note_path, 65 | mimetype='text/plain', 66 | as_attachment=True, 67 | download_name=note_name 68 | ) 69 | except Exception as e: 70 | return jsonify({'error': 'Failed to retrieve note'}), 500 71 | 72 | @classmethod 73 | def delete_note(cls, note_name): 74 | """Delete a note""" 75 | note_path = os.path.join(cls._get_notes_path(), secure_filename(note_name)) 76 | 77 | if not os.path.exists(note_path): 78 | return jsonify({'error': 'Note not found'}), 404 79 | 80 | try: 81 | os.remove(note_path) 82 | return '', 204 83 | except Exception as e: 84 | return jsonify({'error': 'Failed to delete note'}), 500 -------------------------------------------------------------------------------- /app/config/auth_config.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class AuthMethod(Enum): 4 | API_KEY = "api_key" 5 | SESSION = "session" 6 | JWT = "jwt" 7 | NONE = "none" 8 | 9 | class AuthConfig: 10 | def __init__(self): 11 | self.auth_method = AuthMethod.NONE 12 | self.api_key = None 13 | self.jwt_secret = None 14 | self.session_secret = None 15 | 16 | def configure_api_key(self, api_key): 17 | self.auth_method = AuthMethod.API_KEY 18 | self.api_key = api_key 19 | 20 | def configure_jwt(self, secret_key): 21 | self.auth_method = AuthMethod.JWT 22 | self.jwt_secret = secret_key 23 | 24 | def configure_session(self, secret_key): 25 | self.auth_method = AuthMethod.SESSION 26 | self.session_secret = secret_key 27 | 28 | def disable_auth(self): 29 | self.auth_method = AuthMethod.NONE 30 | 31 | def update_from_dict(self, config_dict): 32 | """Update authentication configuration from a dictionary. 33 | 34 | Args: 35 | config_dict (dict): Configuration dictionary with 'auth' key containing: 36 | - method: one of 'none', 'api_key', 'jwt', 'session' 37 | - api_key: API key (required if method is 'api_key') 38 | - secret: Secret key (required if method is 'jwt' or 'session') 39 | 40 | Raises: 41 | ValueError: If configuration is invalid 42 | """ 43 | auth_config = config_dict.get('auth', {}) 44 | method = auth_config.get('method', 'none') 45 | 46 | # Validate method 47 | valid_methods = [e.value for e in AuthMethod] 48 | if method not in valid_methods: 49 | raise ValueError(f"Invalid authentication method: {method}. Must be one of: {valid_methods}") 50 | 51 | # Reset current configuration 52 | self.auth_method = AuthMethod.NONE 53 | self.api_key = None 54 | self.jwt_secret = None 55 | self.session_secret = None 56 | 57 | # Apply new configuration 58 | if method == 'none': 59 | self.disable_auth() 60 | elif method == 'api_key': 61 | api_key = auth_config.get('api_key') 62 | if not api_key: 63 | raise ValueError("API key must be provided when using api_key authentication") 64 | self.configure_api_key(api_key) 65 | elif method == 'jwt': 66 | secret = auth_config.get('secret') 67 | if not secret: 68 | raise ValueError("Secret key must be provided when using JWT authentication") 69 | self.configure_jwt(secret) 70 | elif method == 'session': 71 | secret = auth_config.get('secret') 72 | if not secret: 73 | raise ValueError("Secret key must be provided when using session authentication") 74 | self.configure_session(secret) 75 | 76 | def to_dict(self): 77 | """Convert current configuration to dictionary format. 78 | 79 | Returns: 80 | dict: Configuration dictionary in the same format as the YAML file 81 | """ 82 | config = { 83 | 'auth': { 84 | 'method': self.auth_method.value 85 | } 86 | } 87 | 88 | if self.auth_method == AuthMethod.API_KEY and self.api_key: 89 | config['auth']['api_key'] = self.api_key 90 | elif self.auth_method == AuthMethod.JWT and self.jwt_secret: 91 | config['auth']['secret'] = self.jwt_secret 92 | elif self.auth_method == AuthMethod.SESSION and self.session_secret: 93 | config['auth']['secret'] = self.session_secret 94 | 95 | return config 96 | -------------------------------------------------------------------------------- /app/middleware/auth_middleware.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from flask import request, jsonify, session, Blueprint 3 | import jwt 4 | from config.auth_config import AuthMethod, AuthConfig 5 | from services.auth_service import blacklisted_tokens, invalidated_sessions 6 | 7 | class AuthMiddleware: 8 | def __init__(self, config: AuthConfig): 9 | self.config = config 10 | 11 | def protect_blueprint(self, blueprint): 12 | """Add authentication middleware to all routes in a blueprint""" 13 | @blueprint.before_request 14 | @wraps(blueprint) 15 | def authenticate(): 16 | if self.config.auth_method == AuthMethod.NONE: 17 | return None 18 | 19 | if self.config.auth_method == AuthMethod.API_KEY: 20 | return self._validate_api_key() 21 | elif self.config.auth_method == AuthMethod.JWT: 22 | return self._validate_jwt() 23 | elif self.config.auth_method == AuthMethod.SESSION: 24 | return self._validate_session() 25 | 26 | def _validate_api_key(self): 27 | """Validate API key from request header""" 28 | api_key = request.headers.get('X-API-Key') 29 | if not api_key: 30 | return jsonify({"error": "API key is required"}), 401 31 | if api_key != self.config.api_key: 32 | return jsonify({"error": "Invalid API key"}), 401 33 | return None 34 | 35 | def _validate_jwt(self): 36 | """Validate JWT from Authorization header""" 37 | auth_header = request.headers.get('Authorization') 38 | if not auth_header or not auth_header.startswith('Bearer '): 39 | return jsonify({"error": "JWT token is required"}), 401 40 | 41 | token = auth_header.split(' ')[1] 42 | if token in blacklisted_tokens: 43 | return jsonify({"error": "You have been logged out. Please log in again."}), 401 44 | 45 | try: 46 | jwt.decode(token, self.config.jwt_secret, algorithms=["HS256"]) 47 | return None 48 | except jwt.ExpiredSignatureError: 49 | return jsonify({"error": "Token has expired"}), 401 50 | except jwt.InvalidTokenError as e: 51 | return jsonify({"error": f"Invalid JWT token: {str(e)}"}), 401 52 | 53 | def _validate_session(self): 54 | """Validate session authentication""" 55 | if not session.get("authenticated"): 56 | return jsonify({"error": "Valid session required"}), 401 57 | 58 | # Check if session has been invalidated 59 | current_session = request.cookies.get('session') 60 | if current_session and current_session in invalidated_sessions: 61 | session.clear() 62 | return jsonify({"error": "Session has been invalidated"}), 401 63 | 64 | return None 65 | 66 | # Global middleware instance for runtime updates 67 | _global_middleware_instance = None 68 | 69 | def reset_auth_middleware(new_config: AuthConfig): 70 | """Reset the global auth middleware instance with new configuration. 71 | 72 | This function updates the middleware configuration that's used 73 | by all protected blueprints. Due to Flask's blueprint registration 74 | mechanics, we update the global instance that blueprints reference. 75 | 76 | Args: 77 | new_config: New AuthConfig instance 78 | """ 79 | global _global_middleware_instance 80 | if _global_middleware_instance: 81 | _global_middleware_instance.config = new_config 82 | print(f"Auth middleware reset with new configuration: {new_config.auth_method.value}") 83 | 84 | def get_auth_middleware_instance(): 85 | """Get the global middleware instance.""" 86 | return _global_middleware_instance 87 | 88 | def set_auth_middleware_instance(instance): 89 | """Set the global middleware instance.""" 90 | global _global_middleware_instance 91 | _global_middleware_instance = instance 92 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from routes.todos import todos_bp 3 | from routes.errors import errors_bp 4 | from routes.docs import docs_bp 5 | from routes.notes import notes_bp 6 | from routes.auth import auth_bp, init_auth_routes 7 | from middleware.auth_middleware import AuthMiddleware, set_auth_middleware_instance 8 | from utils.config import load_config, load_initial_todos, load_initial_users 9 | from utils.auth import setup_auth_config 10 | from services.auth_service import init_auth_service, add_user 11 | from flasgger import Swagger 12 | import secrets 13 | 14 | def create_app(auth_config): 15 | """Create and configure the Flask application. 16 | 17 | This function: 18 | 1. Creates a new Flask instance 19 | 2. Configures app settings and secrets 20 | 3. Sets up authentication middleware 21 | 4. Registers blueprints with their URL prefixes 22 | 23 | Args: 24 | auth_config: Authentication configuration object 25 | 26 | Returns: 27 | Flask: Configured Flask application instance 28 | """ 29 | app = Flask(__name__) 30 | 31 | # Configure application settings 32 | app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True 33 | app.config['SECRET_KEY'] = secrets.token_hex(32) # Generate secure random secret key 34 | app.config['auth_config'] = auth_config 35 | app.config['initial_todos'] = load_initial_todos() # Load initial todos from config file 36 | 37 | # Configure Swagger 38 | template = { 39 | "swagger": "2.0", 40 | "info": { 41 | "title": "Todo API", 42 | "description": "A RESTful API for managing todos and notes", 43 | "version": "1.0.0" 44 | } 45 | } 46 | 47 | app.config['SWAGGER'] = { 48 | 'title': 'Todo API', 49 | 'uiversion': 3, 50 | 'specs_route': '/', 51 | 'url_prefix': '/swagger' 52 | } 53 | 54 | Swagger(app, template=template) 55 | 56 | # Set up authentication 57 | init_auth_routes(auth_config) 58 | auth_middleware = AuthMiddleware(auth_config) 59 | 60 | # Store the middleware instance globally for runtime updates 61 | set_auth_middleware_instance(auth_middleware) 62 | 63 | # Define routes that require authentication 64 | protected_blueprints = { 65 | todos_bp: "/todos", # Todo management endpoints 66 | notes_bp: "/notes" # Note management endpoints 67 | } 68 | 69 | # Register protected routes with authentication middleware 70 | for blueprint, url_prefix in protected_blueprints.items(): 71 | auth_middleware.protect_blueprint(blueprint) 72 | app.register_blueprint(blueprint, url_prefix=url_prefix) 73 | 74 | # Register public routes (no authentication required) 75 | app.register_blueprint(auth_bp, url_prefix="/auth") # Authentication endpoints 76 | app.register_blueprint(docs_bp, url_prefix="/docs") # API documentation 77 | app.register_blueprint(errors_bp) # Error handlers (no prefix needed) 78 | 79 | return app 80 | 81 | def seed_users(): 82 | for user_data in load_initial_users(): 83 | if not add_user(user_data["username"], user_data["password"]): 84 | print(f"Error: Failed to add user on init") 85 | 86 | if __name__ == "__main__": 87 | try: 88 | # Load authentication configuration from config file 89 | auth_method, secret = load_config() 90 | 91 | # Set up authentication based on configuration 92 | auth_config = setup_auth_config(auth_method, secret) 93 | seed_users() 94 | 95 | # Initialize auth service 96 | init_auth_service(auth_config) 97 | 98 | # Create and configure the application 99 | app = create_app(auth_config) 100 | 101 | # Start the server 102 | app.run(host="0.0.0.0", port=8000) # Listen on all interfaces, port 8000 103 | except ValueError as e: 104 | print(f"Error: {e}") 105 | exit(1) 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Elastic License 2.0 2 | 3 | URL: https://www.elastic.co/licensing/elastic-license 4 | 5 | ## Acceptance 6 | 7 | By using the software, you agree to all of the terms and conditions below. 8 | 9 | ## Copyright License 10 | 11 | The licensor grants you a non-exclusive, royalty-free, worldwide, 12 | non-sublicensable, non-transferable license to use, copy, distribute, make 13 | available, and prepare derivative works of the software, in each case subject to 14 | the limitations and conditions below. 15 | 16 | ## Limitations 17 | 18 | You may not provide the software to third parties as a hosted or managed 19 | service, where the service provides users with access to any substantial set of 20 | the features or functionality of the software. 21 | 22 | You may not move, change, disable, or circumvent the license key functionality 23 | in the software, and you may not remove or obscure any functionality in the 24 | software that is protected by the license key. 25 | 26 | You may not alter, remove, or obscure any licensing, copyright, or other notices 27 | of the licensor in the software. Any use of the licensor’s trademarks is subject 28 | to applicable law. 29 | 30 | ## Patents 31 | 32 | The licensor grants you a license, under any patent claims the licensor can 33 | license, or becomes able to license, to make, have made, use, sell, offer for 34 | sale, import and have imported the software, in each case subject to the 35 | limitations and conditions in this license. This license does not cover any 36 | patent claims that you cause to be infringed by modifications or additions to 37 | the software. If you or your company make any written claim that the software 38 | infringes or contributes to infringement of any patent, your patent license for 39 | the software granted under these terms ends immediately. If your company makes 40 | such a claim, your patent license ends immediately for work on behalf of your 41 | company. 42 | 43 | ## Notices 44 | 45 | You must ensure that anyone who gets a copy of any part of the software from you 46 | also gets a copy of these terms. 47 | 48 | If you modify the software, you must include in any modified copies of the 49 | software prominent notices stating that you have modified the software. 50 | 51 | ## No Other Rights 52 | 53 | These terms do not imply any licenses other than those expressly granted in 54 | these terms. 55 | 56 | ## Termination 57 | 58 | If you use the software in violation of these terms, such use is not licensed, 59 | and your licenses will automatically terminate. If the licensor provides you 60 | with a notice of your violation, and you cease all violation of this license no 61 | later than 30 days after you receive that notice, your licenses will be 62 | reinstated retroactively. However, if you violate these terms after such 63 | reinstatement, any additional violation of these terms will cause your licenses 64 | to terminate automatically and permanently. 65 | 66 | ## No Liability 67 | 68 | *As far as the law allows, the software comes as is, without any warranty or 69 | condition, and the licensor will not be liable to you for any damages arising 70 | out of these terms or the use or nature of the software, under any kind of 71 | legal claim.* 72 | 73 | ## Definitions 74 | 75 | The **licensor** is the entity offering these terms, and the **software** is the 76 | software the licensor makes available under these terms, including any portion 77 | of it. 78 | 79 | **you** refers to the individual or entity agreeing to these terms. 80 | 81 | **your company** is any legal entity, sole proprietorship, or other kind of 82 | organization that you work for, plus all organizations that have control over, 83 | are under the control of, or are under common control with that 84 | organization. **control** means ownership of substantially all the assets of an 85 | entity, or the power to direct its management and policies by vote, contract, or 86 | otherwise. Control can be direct or indirect. 87 | 88 | **your licenses** are all the licenses granted to you for the software under 89 | these terms. 90 | 91 | **use** means anything you do with the software requiring one of your licenses. 92 | 93 | **trademark** means trademarks, service marks, and similar rights. 94 | -------------------------------------------------------------------------------- /app/services/auth_service.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import json 3 | import datetime 4 | import secrets 5 | from config.auth_config import AuthConfig 6 | from models.user import User 7 | from flask import session, jsonify, request 8 | 9 | # --- Configuration --- 10 | auth_config = None # Global configuration object set during initialization 11 | 12 | def init_auth_service(config: AuthConfig): 13 | global auth_config 14 | auth_config = config 15 | 16 | # --- Storage --- 17 | users = [] # In-memory storage for user objects 18 | refresh_tokens = {} # Maps refresh tokens to usernames 19 | blacklisted_tokens = set() # Set of invalidated access tokens 20 | invalidated_sessions = set() # Set of invalidated session IDs 21 | 22 | # --- User Management --- 23 | def is_username_taken(username): 24 | """Check if a username is already registered""" 25 | return any(user.username == username for user in users) 26 | 27 | def add_user(username, password): 28 | """Add a new user if username is not taken""" 29 | if not is_username_taken(username): 30 | users.append(User(username, password)) 31 | return True 32 | return False 33 | 34 | def validate_credentials(username, password): 35 | """Verify username/password combination and return user if valid""" 36 | user = next((user for user in users if user.username == username), None) 37 | if not user or not user.check_password(password): 38 | return None 39 | return user 40 | 41 | # --- Token Management --- 42 | def generate_refresh_token(username): 43 | """Create and store a new refresh token for a user""" 44 | refresh_token = secrets.token_hex(32) 45 | refresh_tokens[refresh_token] = username 46 | return refresh_token 47 | 48 | def validate_refresh_token(refresh_token): 49 | """Check if refresh token is valid and return associated username""" 50 | return refresh_tokens.get(refresh_token) 51 | 52 | def blacklist_token(token): 53 | """Invalidate an access token""" 54 | blacklisted_tokens.add(token) 55 | 56 | def generate_jwt_token(username): 57 | """Generate a new JWT access token and refresh token pair""" 58 | access_token = jwt.encode( 59 | { 60 | "sub": username, 61 | "iat": datetime.datetime.utcnow(), 62 | "exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=15) 63 | }, 64 | auth_config.jwt_secret, 65 | algorithm="HS256" 66 | ) 67 | refresh_token = generate_refresh_token(username) 68 | return access_token, refresh_token 69 | 70 | # --- Authentication Operations --- 71 | def signup_user(data): 72 | """Register a new user with username and password""" 73 | if not data or "username" not in data or "password" not in data: 74 | return jsonify({"error": "Username and password are required"}), 400 75 | 76 | if is_username_taken(data["username"]): 77 | return jsonify({"error": "Username already exists"}), 400 78 | 79 | add_user(data["username"], data["password"]) 80 | return jsonify({"message": "Signup successful. Please log in to continue."}), 201 81 | 82 | def login_jwt(username, password): 83 | """Authenticate user and return JWT tokens if valid""" 84 | user = validate_credentials(username, password) 85 | if not user: 86 | return jsonify({"error": "Invalid username or password"}), 401 87 | 88 | access_token, refresh_token = generate_jwt_token(username) 89 | return jsonify({ 90 | "message": "Login successful", 91 | "access_token": access_token, 92 | "refresh_token": refresh_token 93 | }) 94 | 95 | def login_session(username, password): 96 | """Authenticate user and create session if valid""" 97 | user = validate_credentials(username, password) 98 | if not user: 99 | return jsonify({"error": "Invalid username or password"}), 401 100 | 101 | session["authenticated"] = True 102 | session["username"] = username 103 | return jsonify({"message": "Login successful"}) 104 | 105 | def logout_jwt(access_token, refresh_token): 106 | """Invalidate JWT access and refresh tokens""" 107 | if not access_token or not refresh_token: 108 | return jsonify({"error": "Both access token and refresh token are required"}), 400 109 | 110 | blacklist_token(access_token) 111 | if refresh_token in refresh_tokens: 112 | del refresh_tokens[refresh_token] 113 | 114 | return jsonify({"message": "Logout successful"}) 115 | 116 | def logout_session(): 117 | """Clear user session if authenticated and invalidate the session cookie""" 118 | if not session.get("authenticated"): 119 | return jsonify({"error": "Not authenticated"}), 401 120 | 121 | response = jsonify({"message": "Logout successful"}) 122 | 123 | # Add current session ID to invalidated sessions set 124 | if request.cookies.get('session'): 125 | invalidated_sessions.add(request.cookies.get('session')) 126 | 127 | session.clear() 128 | # Set the session cookie to expire immediately 129 | response.set_cookie('session', '', expires=0) 130 | return response 131 | 132 | def reset_auth_service(new_config: AuthConfig): 133 | """Reset the auth service with new configuration. 134 | 135 | This function: 136 | 1. Updates the global auth_config 137 | 2. Clears all existing tokens and sessions for security 138 | 3. Re-initializes the auth service 139 | 140 | Args: 141 | new_config: New AuthConfig instance 142 | """ 143 | global auth_config 144 | auth_config = new_config 145 | 146 | # Clear all existing authentication tokens and sessions for security 147 | # This ensures that after a config change, users need to re-authenticate 148 | refresh_tokens.clear() 149 | blacklisted_tokens.clear() 150 | invalidated_sessions.clear() 151 | 152 | print(f"Auth service reset with new configuration: {auth_config.auth_method.value}") 153 | 154 | def reset_users(file_content): 155 | """Reset users with new data from uploaded JSON file. 156 | 157 | This method: 158 | 1. Parses the JSON file content 159 | 2. Validates the file format and user data 160 | 3. Clears all existing users and their tokens/sessions 161 | 4. Loads new users from the file data 162 | 163 | Args: 164 | file_content (str): JSON file content as string 165 | 166 | Returns: 167 | tuple: JSON response and status code 168 | """ 169 | 170 | global users 171 | 172 | # Parse JSON content 173 | try: 174 | data = json.loads(file_content) 175 | except json.JSONDecodeError as e: 176 | return jsonify({"error": f"Invalid JSON format: {str(e)}"}), 400 177 | 178 | # Validate file structure - expect {"data": [...]} format (same as initial_users.json) 179 | if not isinstance(data, dict) or 'data' not in data: 180 | return jsonify({"error": "Invalid file format. Expected JSON with 'data' array field."}), 400 181 | 182 | new_users_data = data['data'] 183 | 184 | # Validate the users data 185 | if not isinstance(new_users_data, list): 186 | return jsonify({"error": "Invalid users format. Expected an array of user objects."}), 400 187 | 188 | # Validate each user item 189 | for i, user_data in enumerate(new_users_data): 190 | if not isinstance(user_data, dict): 191 | return jsonify({"error": f"Invalid user at index {i}. Expected an object."}), 400 192 | 193 | required_fields = ['username', 'password'] 194 | for field in required_fields: 195 | if field not in user_data: 196 | return jsonify({"error": f"Missing required field '{field}' in user at index {i}."}), 400 197 | 198 | # Validate field types 199 | if not isinstance(user_data['username'], str): 200 | return jsonify({"error": f"Invalid 'username' type in user at index {i}. Expected string."}), 400 201 | if not isinstance(user_data['password'], str): 202 | return jsonify({"error": f"Invalid 'password' type in user at index {i}. Expected string."}), 400 203 | 204 | # Validate username is not empty 205 | if not user_data['username'].strip(): 206 | return jsonify({"error": f"Empty username in user at index {i}."}), 400 207 | if not user_data['password'].strip(): 208 | return jsonify({"error": f"Empty password in user at index {i}."}), 400 209 | 210 | # Check for duplicate usernames 211 | usernames = [user['username'] for user in new_users_data] 212 | if len(usernames) != len(set(usernames)): 213 | return jsonify({"error": "Duplicate usernames found in the data."}), 400 214 | 215 | # Clear existing users and all authentication data for security 216 | users.clear() 217 | refresh_tokens.clear() 218 | blacklisted_tokens.clear() 219 | invalidated_sessions.clear() 220 | 221 | # Load new users 222 | for user_data in new_users_data: 223 | users.append(User(user_data['username'], user_data['password'])) 224 | 225 | return jsonify({ 226 | "message": f"Users reset successfully. Loaded {len(new_users_data)} users.", 227 | "users_count": len(new_users_data), 228 | "usernames": [user.username for user in users] 229 | }), 200 230 | -------------------------------------------------------------------------------- /app/services/todo_service.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, request, current_app 2 | from models.todo import Todo 3 | import json 4 | 5 | class TodoService: 6 | _instance = None 7 | _initialized = False 8 | 9 | def __new__(cls): 10 | if cls._instance is None: 11 | cls._instance = super(TodoService, cls).__new__(cls) 12 | return cls._instance 13 | 14 | def __init__(self): 15 | if not TodoService._initialized: 16 | self.todos = {} 17 | self.next_id = 1 18 | self._initialize_todos() 19 | TodoService._initialized = True 20 | 21 | def _initialize_todos(self): 22 | """Initialize todos from the app config.""" 23 | initial_todos = current_app.config.get('initial_todos', []) 24 | for todo_data in initial_todos: 25 | todo_id = todo_data['id'] 26 | self.todos[todo_id] = Todo( 27 | todo_id, 28 | todo_data['title'], 29 | todo_data['done'], 30 | todo_data['description'] 31 | ) 32 | # Update next_id to be greater than the highest existing id 33 | self.next_id = max(self.next_id, todo_id + 1) 34 | 35 | @classmethod 36 | def get_instance(cls): 37 | """Get the singleton instance of TodoService.""" 38 | if cls._instance is None: 39 | cls._instance = TodoService() 40 | return cls._instance 41 | 42 | @staticmethod 43 | def get_all_todos(request): 44 | """Get all todos with optional filtering and pagination. 45 | 46 | Query Parameters: 47 | done (str, optional): Filter by completion status ('true' or 'false') 48 | title (str, optional): Filter todos by title prefix (case-insensitive) 49 | page (int, optional): Page number for pagination (starts at 1) 50 | limit (int, optional): Number of items per page 51 | 52 | Returns: 53 | tuple: JSON response containing list of todos and HTTP status code 54 | 55 | Examples: 56 | GET /todos - Returns all todos 57 | GET /todos?done=true - Returns all completed todos 58 | GET /todos?title=buy - Returns todos with titles starting with 'buy' 59 | GET /todos?page=1&limit=10 - Returns first 10 todos 60 | """ 61 | service = TodoService.get_instance() 62 | done = request.args.get("done", type=str) 63 | title_prefix = request.args.get("title", type=str) 64 | page = request.args.get("page", type=int) 65 | limit = request.args.get("limit", type=int) 66 | 67 | if done is not None: 68 | done = done.lower() == 'true' 69 | 70 | results = list(service.todos.values()) 71 | 72 | if done is not None: 73 | results = [todo for todo in results if todo.done == done] 74 | 75 | if title_prefix: 76 | results = [todo for todo in results if todo.title.lower().startswith(title_prefix.lower())] 77 | 78 | # Apply pagination only if both page and limit parameters are provided 79 | if page is not None and limit is not None: 80 | start_idx = (page - 1) * limit 81 | end_idx = start_idx + limit 82 | results = results[start_idx:end_idx] 83 | 84 | return jsonify([todo.to_dict() for todo in results]), 200 85 | 86 | @staticmethod 87 | def get_todo(todo_id): 88 | service = TodoService.get_instance() 89 | todo = service.todos.get(todo_id) 90 | if todo is None: 91 | return jsonify({"error": "Todo not found"}), 404 92 | return jsonify(todo.to_dict()), 200 93 | 94 | @staticmethod 95 | def add_todo(request): 96 | service = TodoService.get_instance() 97 | data = request.get_json() 98 | if not data or "title" not in data: 99 | return jsonify({"error": "Invalid request. 'title' is required."}), 400 100 | 101 | todo = Todo(service.next_id, data["title"], data.get("done", False), data.get("description")) 102 | service.todos[service.next_id] = todo 103 | service.next_id += 1 104 | return jsonify(todo.to_dict()), 201 105 | 106 | @staticmethod 107 | def edit_todo(todo_id, request): 108 | service = TodoService.get_instance() 109 | data = request.get_json() 110 | todo = service.todos.get(todo_id) 111 | if todo is None: 112 | return jsonify({"error": "Todo not found"}), 404 113 | if not data or "title" not in data or "done" not in data or "description" not in data: 114 | return jsonify({"error": "Invalid request. 'title', 'done', and 'description' fields are required."}), 400 115 | 116 | todo.title = data["title"] 117 | todo.done = data["done"] 118 | todo.description = data["description"] 119 | return jsonify(todo.to_dict()), 200 120 | 121 | @staticmethod 122 | def patch_todo(todo_id, request): 123 | service = TodoService.get_instance() 124 | data = request.get_json() 125 | todo = service.todos.get(todo_id) 126 | if todo is None: 127 | return jsonify({"error": "Todo not found"}), 404 128 | if not data: 129 | return jsonify({"error": "Invalid request."}), 400 130 | 131 | todo.title = data.get("title", todo.title) 132 | todo.done = data.get("done", todo.done) 133 | todo.description = data.get("description", todo.description) 134 | return jsonify(todo.to_dict()), 200 135 | 136 | @staticmethod 137 | def delete_todo(todo_id): 138 | service = TodoService.get_instance() 139 | if todo_id not in service.todos: 140 | return jsonify({"error": "Todo not found"}), 404 141 | del service.todos[todo_id] 142 | return '', 204 143 | 144 | @staticmethod 145 | def reset_todos(file_content): 146 | """Reset todos with new data from uploaded JSON file. 147 | 148 | This method: 149 | 1. Parses the JSON file content 150 | 2. Validates the file format and todo data 151 | 3. Clears all existing todos 152 | 4. Loads new todos from the file data 153 | 5. Updates the next_id counter appropriately 154 | 155 | Args: 156 | file_content (str): JSON file content as string 157 | 158 | Returns: 159 | tuple: JSON response and status code 160 | """ 161 | 162 | service = TodoService.get_instance() 163 | 164 | # Parse JSON content 165 | try: 166 | data = json.loads(file_content) 167 | except json.JSONDecodeError as e: 168 | return jsonify({"error": f"Invalid JSON format: {str(e)}"}), 400 169 | 170 | # Validate file structure - expect {"todos": [...]} format 171 | if not isinstance(data, dict) or 'todos' not in data: 172 | return jsonify({"error": "Invalid file format. Expected JSON with 'todos' array field."}), 400 173 | 174 | new_todos_data = data['todos'] 175 | 176 | # Validate the todos data 177 | if not isinstance(new_todos_data, list): 178 | return jsonify({"error": "Invalid todos format. Expected an array of todo objects."}), 400 179 | 180 | # Validate each todo item 181 | for i, todo_data in enumerate(new_todos_data): 182 | if not isinstance(todo_data, dict): 183 | return jsonify({"error": f"Invalid todo at index {i}. Expected an object."}), 400 184 | 185 | required_fields = ['id', 'title', 'done'] 186 | for field in required_fields: 187 | if field not in todo_data: 188 | return jsonify({"error": f"Missing required field '{field}' in todo at index {i}."}), 400 189 | 190 | # Validate field types 191 | if not isinstance(todo_data['id'], int): 192 | return jsonify({"error": f"Invalid 'id' type in todo at index {i}. Expected integer."}), 400 193 | if not isinstance(todo_data['title'], str): 194 | return jsonify({"error": f"Invalid 'title' type in todo at index {i}. Expected string."}), 400 195 | if not isinstance(todo_data['done'], bool): 196 | return jsonify({"error": f"Invalid 'done' type in todo at index {i}. Expected boolean."}), 400 197 | if 'description' in todo_data and not isinstance(todo_data['description'], str): 198 | return jsonify({"error": f"Invalid 'description' type in todo at index {i}. Expected string."}), 400 199 | 200 | # Check for duplicate IDs 201 | ids = [todo['id'] for todo in new_todos_data] 202 | if len(ids) != len(set(ids)): 203 | return jsonify({"error": "Duplicate todo IDs found in the data."}), 400 204 | 205 | # Clear existing todos 206 | service.todos.clear() 207 | service.next_id = 1 208 | 209 | # Load new todos 210 | for todo_data in new_todos_data: 211 | todo_id = todo_data['id'] 212 | service.todos[todo_id] = Todo( 213 | todo_id, 214 | todo_data['title'], 215 | todo_data['done'], 216 | todo_data.get('description', '') 217 | ) 218 | # Update next_id to be greater than the highest existing id 219 | service.next_id = max(service.next_id, todo_id + 1) 220 | 221 | return jsonify({ 222 | "message": f"Todos reset successfully. Loaded {len(new_todos_data)} todos.", 223 | "todos_count": len(new_todos_data), 224 | "next_id": service.next_id 225 | }), 200 226 | -------------------------------------------------------------------------------- /app/routes/todos.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify 2 | from services.todo_service import TodoService 3 | 4 | todos_bp = Blueprint("todos", __name__) 5 | 6 | @todos_bp.route("", methods=["GET"]) 7 | def get_all_todos(): 8 | """Get all todos with optional filtering and pagination 9 | --- 10 | tags: 11 | - todos 12 | parameters: 13 | - name: done 14 | in: query 15 | type: boolean 16 | required: false 17 | description: Filter by completion status 18 | - name: title 19 | in: query 20 | type: string 21 | required: false 22 | description: Filter by TODO item title prefix 23 | - name: page 24 | in: query 25 | type: integer 26 | required: false 27 | description: Page number for pagination (starts at 1) 28 | - name: limit 29 | in: query 30 | type: integer 31 | required: false 32 | description: Number of items per page 33 | responses: 34 | 200: 35 | description: List of todo items 36 | schema: 37 | type: array 38 | items: 39 | type: object 40 | properties: 41 | id: 42 | type: integer 43 | description: The todo ID 44 | title: 45 | type: string 46 | description: The todo title 47 | done: 48 | type: boolean 49 | description: Whether the todo is completed 50 | description: 51 | type: string 52 | description: Detailed todo description 53 | """ 54 | return TodoService.get_all_todos(request) 55 | 56 | @todos_bp.route("", methods=["GET"]) 57 | def get_todo(todo_id): 58 | """Get a specific todo by ID 59 | --- 60 | tags: 61 | - todos 62 | parameters: 63 | - name: todo_id 64 | in: path 65 | type: integer 66 | required: true 67 | description: ID of the todo to retrieve 68 | responses: 69 | 200: 70 | description: Todo details 71 | schema: 72 | type: object 73 | properties: 74 | id: 75 | type: integer 76 | description: The todo ID 77 | title: 78 | type: string 79 | description: The todo title 80 | done: 81 | type: boolean 82 | description: Whether the todo is completed 83 | description: 84 | type: string 85 | description: Detailed todo description 86 | 404: 87 | description: Todo not found 88 | """ 89 | return TodoService.get_todo(todo_id) 90 | 91 | @todos_bp.route("", methods=["POST"]) 92 | def add_todo(): 93 | """Add a new TODO item 94 | --- 95 | tags: 96 | - todos 97 | parameters: 98 | - name: body 99 | in: body 100 | required: true 101 | schema: 102 | type: object 103 | required: 104 | - title 105 | properties: 106 | title: 107 | type: string 108 | description: The TODO item title 109 | done: 110 | type: boolean 111 | description: Completion status. Defaults to false if not provided 112 | description: 113 | type: string 114 | description: Detailed TODO item description 115 | responses: 116 | 201: 117 | description: Created todo item 118 | schema: 119 | type: object 120 | properties: 121 | id: 122 | type: integer 123 | description: The todo ID 124 | title: 125 | type: string 126 | description: The todo title 127 | done: 128 | type: boolean 129 | description: Whether the todo is completed 130 | description: 131 | type: string 132 | description: Detailed todo description 133 | 400: 134 | description: Invalid request - missing title 135 | """ 136 | return TodoService.add_todo(request) 137 | 138 | @todos_bp.route("", methods=["PUT"]) 139 | def edit_todo(todo_id): 140 | """Replace an existing TODO item (all fields required) 141 | --- 142 | tags: 143 | - todos 144 | parameters: 145 | - name: todo_id 146 | in: path 147 | type: integer 148 | required: true 149 | description: ID of the todo to update 150 | - name: body 151 | in: body 152 | required: true 153 | schema: 154 | type: object 155 | required: 156 | - title 157 | - done 158 | - description 159 | properties: 160 | title: 161 | type: string 162 | description: The TODO item title 163 | done: 164 | type: boolean 165 | description: Completion status 166 | description: 167 | type: string 168 | description: Detailed TODO item description 169 | responses: 170 | 200: 171 | description: Updated todo item 172 | schema: 173 | type: object 174 | properties: 175 | id: 176 | type: integer 177 | title: 178 | type: string 179 | done: 180 | type: boolean 181 | description: 182 | type: string 183 | 400: 184 | description: Invalid request (missing required fields) 185 | 404: 186 | description: Todo not found 187 | """ 188 | return TodoService.edit_todo(todo_id, request) 189 | 190 | @todos_bp.route("", methods=["PATCH"]) 191 | def patch_todo(todo_id): 192 | """Update part of a TODO item (any field optional) 193 | --- 194 | tags: 195 | - todos 196 | parameters: 197 | - name: todo_id 198 | in: path 199 | type: integer 200 | required: true 201 | description: ID of the todo to update 202 | - name: body 203 | in: body 204 | required: true 205 | schema: 206 | type: object 207 | properties: 208 | title: 209 | type: string 210 | description: The TODO item title 211 | done: 212 | type: boolean 213 | description: Completion status 214 | description: 215 | type: string 216 | description: Detailed TODO item description 217 | responses: 218 | 200: 219 | description: Updated todo item 220 | schema: 221 | type: object 222 | properties: 223 | id: 224 | type: integer 225 | title: 226 | type: string 227 | done: 228 | type: boolean 229 | description: 230 | type: string 231 | 400: 232 | description: Invalid request (empty body) 233 | 404: 234 | description: Todo not found 235 | """ 236 | return TodoService.patch_todo(todo_id, request) 237 | 238 | @todos_bp.route("", methods=["DELETE"]) 239 | def delete_todo(todo_id): 240 | """Delete a TODO item 241 | --- 242 | tags: 243 | - todos 244 | parameters: 245 | - name: todo_id 246 | in: path 247 | type: integer 248 | required: true 249 | description: ID of the todo to delete 250 | responses: 251 | 204: 252 | description: Todo deleted successfully 253 | 404: 254 | description: Todo not found 255 | """ 256 | return TodoService.delete_todo(todo_id) 257 | 258 | @todos_bp.route("/reset", methods=["POST"]) 259 | def reset_todos(): 260 | """Reset todos with data from uploaded JSON file 261 | --- 262 | tags: 263 | - todos 264 | summary: Reset all todos with data from JSON file 265 | description: | 266 | Replaces all existing todos with data from an uploaded JSON file. 267 | The original initial_todos.json file remains unchanged. 268 | This is useful for testing different todo datasets or resetting to a clean state. 269 | 270 | The uploaded file should be in the same format as initial_todos.json: 271 | { 272 | "todos": [ 273 | { 274 | "id": 1, 275 | "title": "Todo title", 276 | "done": false, 277 | "description": "Optional description" 278 | } 279 | ] 280 | } 281 | consumes: 282 | - multipart/form-data 283 | parameters: 284 | - in: formData 285 | name: file 286 | type: file 287 | required: true 288 | description: JSON file containing todos data 289 | responses: 290 | 200: 291 | description: Todos reset successfully 292 | schema: 293 | type: object 294 | properties: 295 | message: 296 | type: string 297 | example: "Todos reset successfully. Loaded 4 todos." 298 | todos_count: 299 | type: integer 300 | example: 4 301 | next_id: 302 | type: integer 303 | example: 5 304 | filename: 305 | type: string 306 | example: "my_todos.json" 307 | 400: 308 | description: Invalid file format or data 309 | schema: 310 | type: object 311 | properties: 312 | error: 313 | type: string 314 | example: "Invalid JSON format: Expecting ',' delimiter" 315 | 415: 316 | description: No file provided 317 | schema: 318 | type: object 319 | properties: 320 | error: 321 | type: string 322 | example: "No file provided" 323 | """ 324 | 325 | # Check if file was uploaded 326 | if 'file' not in request.files: 327 | return jsonify({"error": "No file provided. Please upload a JSON file."}), 400 328 | 329 | file = request.files['file'] 330 | 331 | # Check if file was actually selected 332 | if file.filename == '': 333 | return jsonify({"error": "No file selected. Please select a JSON file."}), 400 334 | 335 | # Check file extension 336 | if not file.filename.lower().endswith('.json'): 337 | return jsonify({"error": "Invalid file type. Please upload a JSON file."}), 400 338 | 339 | try: 340 | # Read file content 341 | file_content = file.read().decode('utf-8') 342 | 343 | # Reset todos with file content 344 | response, status_code = TodoService.reset_todos(file_content) 345 | 346 | # Add filename to successful response 347 | if status_code == 200: 348 | response_data = response.get_json() 349 | response_data['filename'] = file.filename 350 | return jsonify(response_data), status_code 351 | 352 | return response, status_code 353 | 354 | except UnicodeDecodeError: 355 | return jsonify({"error": "Invalid file encoding. Please ensure the file is UTF-8 encoded."}), 400 356 | except Exception as e: 357 | return jsonify({"error": f"Failed to process file: {str(e)}"}), 500 358 | -------------------------------------------------------------------------------- /app/routes/auth.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify, current_app 2 | from config.auth_config import AuthMethod, AuthConfig 3 | from middleware.auth_middleware import reset_auth_middleware 4 | from services.auth_service import ( 5 | signup_user, 6 | validate_refresh_token, 7 | generate_jwt_token, 8 | refresh_tokens, 9 | login_jwt, 10 | login_session, 11 | logout_jwt, 12 | logout_session, 13 | reset_users, 14 | reset_auth_service, 15 | ) 16 | 17 | # --- Configuration --- 18 | auth_bp = Blueprint("auth", __name__) # Flask blueprint for auth routes 19 | auth_config = None # Global configuration object set during initialization 20 | 21 | def init_auth_routes(config: AuthConfig): 22 | global auth_config 23 | auth_config = config 24 | 25 | # --- Authentication Routes --- 26 | @auth_bp.route("/signup", methods=["POST"]) 27 | def signup(): 28 | """ 29 | Register a new user 30 | Expects JSON: {"username": "user", "password": "pass"} 31 | """ 32 | if auth_config.auth_method == AuthMethod.API_KEY: 33 | return jsonify({"error": "Signup not available with API key authentication"}), 400 34 | 35 | data = request.get_json() 36 | return signup_user(data) 37 | 38 | @auth_bp.route("/login", methods=["POST"]) 39 | def login(): 40 | """ 41 | Authenticate user and return tokens (JWT) or create session 42 | Expects JSON: {"username": "user", "password": "pass"} 43 | """ 44 | if auth_config.auth_method == AuthMethod.API_KEY: 45 | return jsonify({"error": "Login not available with API key authentication"}), 400 46 | 47 | data = request.get_json() 48 | if not data or "username" not in data or "password" not in data: 49 | return jsonify({"error": "Username and password are required"}), 400 50 | 51 | username = data["username"] 52 | password = data["password"] 53 | 54 | if auth_config.auth_method == AuthMethod.JWT: 55 | return login_jwt(username, password) 56 | 57 | return login_session(username, password) 58 | 59 | @auth_bp.route("/logout", methods=["POST"]) 60 | def logout(): 61 | """ 62 | End user session or invalidate JWT tokens 63 | For JWT: Requires Authorization header with Bearer token and refresh_token in JSON body 64 | For Session: No additional requirements 65 | """ 66 | if auth_config.auth_method == AuthMethod.API_KEY: 67 | return jsonify({"error": "Logout not available with API key authentication"}), 400 68 | 69 | if auth_config.auth_method == AuthMethod.JWT: 70 | auth_header = request.headers.get('Authorization') 71 | if not auth_header or not auth_header.startswith('Bearer '): 72 | return jsonify({"error": "Access token is required in Authorization header"}), 401 73 | access_token = auth_header.split(' ')[1] 74 | 75 | if not request.is_json: 76 | return jsonify({"error": "Request must be JSON"}), 415 77 | 78 | data = request.get_json() 79 | refresh_token = data.get("refresh_token") 80 | if not refresh_token: 81 | return jsonify({"error": "Refresh token is required in request body"}), 400 82 | 83 | return logout_jwt(access_token, refresh_token) 84 | 85 | return logout_session() 86 | 87 | @auth_bp.route("/refresh", methods=["POST"]) 88 | def refresh_token(): 89 | """ 90 | Get new access token using refresh token 91 | Expects JSON: {"refresh_token": "token"} 92 | Returns: New access token and refresh token pair 93 | """ 94 | data = request.get_json() 95 | refresh_token = data.get("refresh_token") 96 | username = validate_refresh_token(refresh_token) 97 | if not username: 98 | return jsonify({"error": "Invalid refresh token"}), 401 99 | 100 | access_token, new_refresh_token = generate_jwt_token(username) 101 | 102 | if refresh_token in refresh_tokens: 103 | del refresh_tokens[refresh_token] 104 | 105 | return jsonify({ 106 | "access_token": access_token, 107 | "refresh_token": new_refresh_token 108 | }), 200 109 | 110 | @auth_bp.route("/reset", methods=["POST"]) 111 | def reset_config(): 112 | """ 113 | Reset authentication configuration at runtime 114 | --- 115 | tags: 116 | - Authentication 117 | summary: Update authentication configuration at runtime 118 | description: | 119 | Updates the authentication configuration without restarting the server or 120 | modifying the original auth_config.yml file. All existing tokens and sessions 121 | are invalidated for security reasons. 122 | parameters: 123 | - in: body 124 | name: config 125 | description: Authentication configuration 126 | required: true 127 | schema: 128 | type: object 129 | required: 130 | - auth 131 | properties: 132 | auth: 133 | type: object 134 | required: 135 | - method 136 | properties: 137 | method: 138 | type: string 139 | enum: [none, api_key, jwt, session] 140 | description: Authentication method to use 141 | api_key: 142 | type: string 143 | description: API key (required if method is api_key) 144 | secret: 145 | type: string 146 | description: Secret key (required if method is jwt or session) 147 | example: 148 | method: "api_key" 149 | api_key: "my-secure-api-key" 150 | responses: 151 | 200: 152 | description: Configuration updated successfully 153 | schema: 154 | type: object 155 | properties: 156 | message: 157 | type: string 158 | example: "Authentication configuration updated successfully" 159 | new_config: 160 | type: object 161 | properties: 162 | auth: 163 | type: object 164 | properties: 165 | method: 166 | type: string 167 | api_key: 168 | type: string 169 | secret: 170 | type: string 171 | 400: 172 | description: Invalid configuration or request format 173 | schema: 174 | type: object 175 | properties: 176 | error: 177 | type: string 178 | example: "Invalid authentication method: invalid_method" 179 | 415: 180 | description: Request must be JSON 181 | schema: 182 | type: object 183 | properties: 184 | error: 185 | type: string 186 | example: "Request must be JSON" 187 | 500: 188 | description: Internal server error 189 | schema: 190 | type: object 191 | properties: 192 | error: 193 | type: string 194 | example: "Failed to update configuration: Internal error" 195 | """ 196 | 197 | if not request.is_json: 198 | return jsonify({"error": "Request must be JSON"}), 415 199 | 200 | try: 201 | new_config = request.get_json() 202 | 203 | # Validate configuration format 204 | if not isinstance(new_config, dict) or 'auth' not in new_config: 205 | return jsonify({ 206 | "error": "Invalid configuration format. Expected: {'auth': {'method': '...', ...}}" 207 | }), 400 208 | 209 | # Update the global auth configuration 210 | auth_config.update_from_dict(new_config) 211 | 212 | # Update the Flask app configuration 213 | current_app.config['auth_config'] = auth_config 214 | 215 | # Reset auth service with new configuration 216 | reset_auth_service(auth_config) 217 | 218 | # Reset auth middleware with new configuration 219 | reset_auth_middleware(auth_config) 220 | 221 | return jsonify({ 222 | "message": "Authentication configuration updated successfully", 223 | "new_config": auth_config.to_dict() 224 | }), 200 225 | 226 | except ValueError as e: 227 | return jsonify({"error": str(e)}), 400 228 | except Exception as e: 229 | return jsonify({"error": f"Failed to update configuration: {str(e)}"}), 500 230 | 231 | @auth_bp.route("/reset-users", methods=["POST"]) 232 | def reset_users_endpoint(): 233 | """Reset users with data from uploaded JSON file 234 | --- 235 | tags: 236 | - Authentication 237 | summary: Reset all users with data from JSON file 238 | description: | 239 | Replaces all existing users with data from an uploaded JSON file. 240 | The original initial_users.json file remains unchanged. 241 | All existing authentication tokens and sessions are cleared for security. 242 | 243 | The uploaded file should be in the same format as initial_users.json: 244 | { 245 | "data": [ 246 | { 247 | "username": "user1", 248 | "password": "password123" 249 | } 250 | ] 251 | } 252 | consumes: 253 | - multipart/form-data 254 | parameters: 255 | - in: formData 256 | name: file 257 | type: file 258 | required: true 259 | description: JSON file containing users data 260 | responses: 261 | 200: 262 | description: Users reset successfully 263 | schema: 264 | type: object 265 | properties: 266 | message: 267 | type: string 268 | example: "Users reset successfully. Loaded 2 users." 269 | users_count: 270 | type: integer 271 | example: 2 272 | usernames: 273 | type: array 274 | items: 275 | type: string 276 | example: ["user1", "user2"] 277 | filename: 278 | type: string 279 | example: "my_users.json" 280 | 400: 281 | description: Invalid file format or data 282 | schema: 283 | type: object 284 | properties: 285 | error: 286 | type: string 287 | example: "Invalid JSON format: Expecting ',' delimiter" 288 | 415: 289 | description: No file provided 290 | schema: 291 | type: object 292 | properties: 293 | error: 294 | type: string 295 | example: "No file provided" 296 | """ 297 | 298 | # Check if file was uploaded 299 | if 'file' not in request.files: 300 | return jsonify({"error": "No file provided. Please upload a JSON file."}), 400 301 | 302 | file = request.files['file'] 303 | 304 | # Check if file was actually selected 305 | if file.filename == '': 306 | return jsonify({"error": "No file selected. Please select a JSON file."}), 400 307 | 308 | # Check file extension 309 | if not file.filename.lower().endswith('.json'): 310 | return jsonify({"error": "Invalid file type. Please upload a JSON file."}), 400 311 | 312 | try: 313 | # Read file content 314 | file_content = file.read().decode('utf-8') 315 | 316 | # Reset users with file content 317 | response, status_code = reset_users(file_content) 318 | 319 | # Add filename to successful response 320 | if status_code == 200: 321 | response_data = response.get_json() 322 | response_data['filename'] = file.filename 323 | return jsonify(response_data), status_code 324 | 325 | return response, status_code 326 | 327 | except UnicodeDecodeError: 328 | return jsonify({"error": "Invalid file encoding. Please ensure the file is UTF-8 encoded."}), 400 329 | except Exception as e: 330 | return jsonify({"error": f"Failed to process file: {str(e)}"}), 500 331 | -------------------------------------------------------------------------------- /test/todo_api_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "Todo API Collection", 4 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 5 | }, 6 | "item": [ 7 | { 8 | "name": "Get All Todos", 9 | "event": [ 10 | { 11 | "listen": "test", 12 | "script": { 13 | "exec": [ 14 | "pm.test(\"Status code is 200\", function () {", 15 | " pm.response.to.have.status(200);", 16 | "});", 17 | "", 18 | "pm.test(\"Response is an array\", function () {", 19 | " const responseData = pm.response.json();", 20 | " pm.expect(Array.isArray(responseData)).to.be.true;", 21 | "});", 22 | "", 23 | "pm.test(\"Todo items have correct structure\", function () {", 24 | " const responseData = pm.response.json();", 25 | " if (responseData.length > 0) {", 26 | " pm.expect(responseData[0]).to.have.property('id');", 27 | " pm.expect(responseData[0]).to.have.property('title');", 28 | " pm.expect(responseData[0]).to.have.property('description');", 29 | " pm.expect(responseData[0]).to.have.property('done');", 30 | " }", 31 | "});" 32 | ] 33 | } 34 | } 35 | ], 36 | "request": { 37 | "method": "GET", 38 | "url": { 39 | "raw": "http://localhost:8000/todos?done=true&title=call", 40 | "host": ["http://localhost:8000"], 41 | "path": ["todos"], 42 | "query": [ 43 | { 44 | "key": "done", 45 | "value": "true", 46 | "description": "Filter by done status" 47 | }, 48 | { 49 | "key": "title", 50 | "value": "call", 51 | "description": "Filter by title prefix" 52 | } 53 | ] 54 | } 55 | } 56 | }, 57 | { 58 | "name": "Get All Todos - With Pagination", 59 | "event": [ 60 | { 61 | "listen": "test", 62 | "script": { 63 | "exec": [ 64 | "pm.test(\"Status code is 200\", function () {", 65 | " pm.response.to.have.status(200);", 66 | "});", 67 | "", 68 | "pm.test(\"Response is an array\", function () {", 69 | " const responseData = pm.response.json();", 70 | " pm.expect(Array.isArray(responseData)).to.be.true;", 71 | "});", 72 | "", 73 | "pm.test(\"Todo items have correct structure\", function () {", 74 | " const responseData = pm.response.json();", 75 | " if (responseData.length > 0) {", 76 | " pm.expect(responseData[0]).to.have.property('id');", 77 | " pm.expect(responseData[0]).to.have.property('title');", 78 | " pm.expect(responseData[0]).to.have.property('description');", 79 | " pm.expect(responseData[0]).to.have.property('done');", 80 | " }", 81 | "});", 82 | "", 83 | "// Test pagination", 84 | "const url = pm.request.url;", 85 | "const page = url.query.find(param => param.key === 'page');", 86 | "const limit = url.query.find(param => param.key === 'limit');", 87 | "", 88 | "pm.test(\"Pagination parameters are present\", function () {", 89 | " pm.expect(page).to.exist;", 90 | " pm.expect(limit).to.exist;", 91 | "});", 92 | "", 93 | "pm.test(\"Pagination respects limit\", function () {", 94 | " const responseData = pm.response.json();", 95 | " pm.expect(responseData.length).to.be.at.most(Number(limit.value));", 96 | "});", 97 | "", 98 | "if (page.value === '2') {", 99 | " pm.test(\"Second page items have higher IDs\", function () {", 100 | " const responseData = pm.response.json();", 101 | " if (responseData.length > 0) {", 102 | " pm.expect(responseData[0].id).to.be.greaterThan(10);", 103 | " }", 104 | " });", 105 | "}" 106 | ] 107 | } 108 | } 109 | ], 110 | "request": { 111 | "method": "GET", 112 | "url": { 113 | "raw": "http://localhost:8000/todos?page=1&limit=10", 114 | "host": ["http://localhost:8000"], 115 | "path": ["todos"], 116 | "query": [ 117 | { 118 | "key": "page", 119 | "value": "1", 120 | "description": "Page number" 121 | }, 122 | { 123 | "key": "limit", 124 | "value": "10", 125 | "description": "Items per page" 126 | } 127 | ] 128 | } 129 | } 130 | }, 131 | { 132 | "name": "Get Single Todo - Success", 133 | "event": [ 134 | { 135 | "listen": "test", 136 | "script": { 137 | "exec": [ 138 | "pm.test(\"Status code is 200\", function () {", 139 | " pm.response.to.have.status(200);", 140 | "});", 141 | "", 142 | "pm.test(\"Todo has correct structure\", function () {", 143 | " const responseData = pm.response.json();", 144 | " pm.expect(responseData).to.have.property('id');", 145 | " pm.expect(responseData).to.have.property('title');", 146 | " pm.expect(responseData).to.have.property('description');", 147 | " pm.expect(responseData).to.have.property('done');", 148 | "});" 149 | ] 150 | } 151 | } 152 | ], 153 | "request": { 154 | "method": "GET", 155 | "url": "http://localhost:8000/todos/1" 156 | } 157 | }, 158 | { 159 | "name": "Get Single Todo - Not Found", 160 | "event": [ 161 | { 162 | "listen": "test", 163 | "script": { 164 | "exec": [ 165 | "pm.test(\"Status code is 404\", function () {", 166 | " pm.response.to.have.status(404);", 167 | "});", 168 | "", 169 | "pm.test(\"Error message is correct\", function () {", 170 | " const responseData = pm.response.json();", 171 | " pm.expect(responseData.error).to.eql('Todo not found');", 172 | "});" 173 | ] 174 | } 175 | } 176 | ], 177 | "request": { 178 | "method": "GET", 179 | "url": "http://localhost:8000/todos/999" 180 | } 181 | }, 182 | { 183 | "name": "Create Todo - Success", 184 | "event": [ 185 | { 186 | "listen": "test", 187 | "script": { 188 | "exec": [ 189 | "pm.test(\"Status code is 201\", function () {", 190 | " pm.response.to.have.status(201);", 191 | "});", 192 | "", 193 | "pm.test(\"Created todo has correct structure\", function () {", 194 | " const responseData = pm.response.json();", 195 | " pm.expect(responseData).to.have.property('id');", 196 | " pm.expect(responseData.title).to.eql('Test Todo');", 197 | " pm.expect(responseData.description).to.eql('This is a test todo item');", 198 | " pm.expect(responseData.done).to.be.false;", 199 | "});", 200 | "", 201 | "if (pm.response.code === 201) {", 202 | " pm.collectionVariables.set(\"todo_id\", pm.response.json().id);", 203 | "}" 204 | ] 205 | } 206 | } 207 | ], 208 | "request": { 209 | "method": "POST", 210 | "url": "http://localhost:8000/todos", 211 | "header": [ 212 | { 213 | "key": "Content-Type", 214 | "value": "application/json" 215 | } 216 | ], 217 | "body": { 218 | "mode": "raw", 219 | "raw": "{\n \"title\": \"Test Todo\",\n \"description\": \"This is a test todo item\",\n \"done\": false\n}" 220 | } 221 | } 222 | }, 223 | { 224 | "name": "Create Todo - Missing Title", 225 | "event": [ 226 | { 227 | "listen": "test", 228 | "script": { 229 | "exec": [ 230 | "pm.test(\"Status code is 400\", function () {", 231 | " pm.response.to.have.status(400);", 232 | "});", 233 | "", 234 | "pm.test(\"Error message is correct\", function () {", 235 | " const responseData = pm.response.json();", 236 | " pm.expect(responseData.error).to.eql('Invalid request. \\'title\\' is required.');", 237 | "});" 238 | ] 239 | } 240 | } 241 | ], 242 | "request": { 243 | "method": "POST", 244 | "url": "http://localhost:8000/todos", 245 | "header": [ 246 | { 247 | "key": "Content-Type", 248 | "value": "application/json" 249 | } 250 | ], 251 | "body": { 252 | "mode": "raw", 253 | "raw": "{\n \"description\": \"Missing required title field\",\n \"done\": false\n}" 254 | } 255 | } 256 | }, 257 | { 258 | "name": "Update Todo - Success", 259 | "event": [ 260 | { 261 | "listen": "test", 262 | "script": { 263 | "exec": [ 264 | "pm.test(\"Status code is 200\", function () {", 265 | " pm.response.to.have.status(200);", 266 | "});", 267 | "", 268 | "pm.test(\"Todo was updated correctly\", function () {", 269 | " const responseData = pm.response.json();", 270 | " pm.expect(responseData.title).to.eql('Updated Todo');", 271 | " pm.expect(responseData.description).to.eql('This todo has been updated');", 272 | " pm.expect(responseData.done).to.be.true;", 273 | "});" 274 | ] 275 | } 276 | } 277 | ], 278 | "request": { 279 | "method": "PUT", 280 | "url": "http://localhost:8000/todos/{{todo_id}}", 281 | "header": [ 282 | { 283 | "key": "Content-Type", 284 | "value": "application/json" 285 | } 286 | ], 287 | "body": { 288 | "mode": "raw", 289 | "raw": "{\n \"title\": \"Updated Todo\",\n \"description\": \"This todo has been updated\",\n \"done\": true\n}" 290 | } 291 | } 292 | }, 293 | { 294 | "name": "Update Todo - Missing Fields", 295 | "event": [ 296 | { 297 | "listen": "test", 298 | "script": { 299 | "exec": [ 300 | "pm.test(\"Status code is 400\", function () {", 301 | " pm.response.to.have.status(400);", 302 | "});", 303 | "", 304 | "pm.test(\"Error message is correct\", function () {", 305 | " const responseData = pm.response.json();", 306 | " pm.expect(responseData.error).to.eql('Invalid request. \\'title\\', \\'done\\', and \\'description\\' fields are required.');", 307 | "});" 308 | ] 309 | } 310 | } 311 | ], 312 | "request": { 313 | "method": "PUT", 314 | "url": "http://localhost:8000/todos/{{todo_id}}", 315 | "header": [ 316 | { 317 | "key": "Content-Type", 318 | "value": "application/json" 319 | } 320 | ], 321 | "body": { 322 | "mode": "raw", 323 | "raw": "{\n \"title\": \"Updated Todo\"\n}" 324 | } 325 | } 326 | }, 327 | { 328 | "name": "Patch Todo - Success", 329 | "event": [ 330 | { 331 | "listen": "test", 332 | "script": { 333 | "exec": [ 334 | "pm.test(\"Status code is 200\", function () {", 335 | " pm.response.to.have.status(200);", 336 | "});", 337 | "", 338 | "pm.test(\"Only specified fields were updated\", function () {", 339 | " const responseData = pm.response.json();", 340 | " pm.expect(responseData.done).to.be.true;", 341 | "});" 342 | ] 343 | } 344 | } 345 | ], 346 | "request": { 347 | "method": "PATCH", 348 | "url": "http://localhost:8000/todos/{{todo_id}}", 349 | "header": [ 350 | { 351 | "key": "Content-Type", 352 | "value": "application/json" 353 | } 354 | ], 355 | "body": { 356 | "mode": "raw", 357 | "raw": "{\n \"done\": true\n}" 358 | } 359 | } 360 | }, 361 | { 362 | "name": "Delete Todo - Success", 363 | "event": [ 364 | { 365 | "listen": "test", 366 | "script": { 367 | "exec": [ 368 | "pm.test(\"Status code is 204\", function () {", 369 | " pm.response.to.have.status(204);", 370 | "});" 371 | ] 372 | } 373 | } 374 | ], 375 | "request": { 376 | "method": "DELETE", 377 | "url": "http://localhost:8000/todos/{{todo_id}}" 378 | } 379 | }, 380 | { 381 | "name": "Delete Todo - Not Found", 382 | "event": [ 383 | { 384 | "listen": "test", 385 | "script": { 386 | "exec": [ 387 | "pm.test(\"Status code is 404\", function () {", 388 | " pm.response.to.have.status(404);", 389 | "});", 390 | "", 391 | "pm.test(\"Error message is correct\", function () {", 392 | " const responseData = pm.response.json();", 393 | " pm.expect(responseData.error).to.eql('Todo not found');", 394 | "});" 395 | ] 396 | } 397 | } 398 | ], 399 | "request": { 400 | "method": "DELETE", 401 | "url": "http://localhost:8000/todos/999" 402 | } 403 | }, 404 | { 405 | "name": "Get API Documentation", 406 | "event": [ 407 | { 408 | "listen": "test", 409 | "script": { 410 | "exec": [ 411 | "pm.test(\"Status code is 200\", function () {", 412 | " pm.response.to.have.status(200);", 413 | "});", 414 | "", 415 | "pm.test(\"Response has correct structure\", function () {", 416 | " const responseData = pm.response.json();", 417 | " ", 418 | " // Check todos endpoints", 419 | " pm.expect(responseData).to.have.property('/todos');", 420 | " pm.expect(responseData[\"/todos\"]).to.have.property('GET');", 421 | " pm.expect(responseData[\"/todos\"]).to.have.property('POST');", 422 | " ", 423 | " // Check single todo endpoints", 424 | " pm.expect(responseData).to.have.property('/todos/');", 425 | " pm.expect(responseData[\"/todos/\"]).to.have.property('GET');", 426 | " pm.expect(responseData[\"/todos/\"]).to.have.property('PUT');", 427 | " pm.expect(responseData[\"/todos/\"]).to.have.property('PATCH');", 428 | " pm.expect(responseData[\"/todos/\"]).to.have.property('DELETE');", 429 | " ", 430 | " // Check notes endpoints", 431 | " pm.expect(responseData).to.have.property('/notes');", 432 | " pm.expect(responseData[\"/notes\"]).to.have.property('POST');", 433 | " pm.expect(responseData).to.have.property('/notes/');", 434 | " pm.expect(responseData[\"/notes/\"]).to.have.property('GET');", 435 | " pm.expect(responseData[\"/notes/\"]).to.have.property('DELETE');", 436 | "});", 437 | "", 438 | "// Test authentication documentation based on config", 439 | "pm.test(\"Authentication documentation is correct\", function () {", 440 | " const responseData = pm.response.json();", 441 | " ", 442 | " // If no auth is configured, authentication section should not exist", 443 | " if (!pm.environment.get('auth_method') || pm.environment.get('auth_method') === 'none') {", 444 | " pm.expect(responseData).to.not.have.property('authentication');", 445 | " }", 446 | " // If auth is configured, check its structure", 447 | " else if (responseData.authentication) {", 448 | " pm.expect(responseData.authentication).to.have.property('method');", 449 | " pm.expect(responseData.authentication).to.have.property('description');", 450 | " pm.expect(responseData.authentication).to.have.property('protected_endpoints');", 451 | " ", 452 | " // Check specific auth method properties", 453 | " const authMethod = responseData.authentication.method;", 454 | " if (authMethod === 'api_key') {", 455 | " pm.expect(responseData.authentication).to.have.property('how_to_authenticate');", 456 | " pm.expect(responseData.authentication).to.have.property('example');", 457 | " } else if (authMethod === 'jwt' || authMethod === 'session') {", 458 | " pm.expect(responseData.authentication).to.have.property('endpoints');", 459 | " pm.expect(responseData.authentication.endpoints).to.have.property('/auth/login');", 460 | " pm.expect(responseData.authentication.endpoints).to.have.property('/auth/signup');", 461 | " }", 462 | " }", 463 | "});" 464 | ] 465 | } 466 | } 467 | ], 468 | "request": { 469 | "method": "GET", 470 | "url": "http://localhost:8000/docs" 471 | } 472 | } 473 | ], 474 | "variable": [ 475 | { 476 | "key": "todo_id", 477 | "value": "" 478 | } 479 | ] 480 | } -------------------------------------------------------------------------------- /test/auth_api_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "Todo API - Auth Tests", 4 | "description": "Collection for testing authentication routes", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "API Key Authentication", 10 | "item": [ 11 | { 12 | "name": "Get Todos with API Key", 13 | "event": [ 14 | { 15 | "listen": "test", 16 | "script": { 17 | "exec": [ 18 | "pm.test('Status code is 200', function() {", 19 | " pm.response.to.have.status(200);", 20 | "});" 21 | ], 22 | "type": "text/javascript" 23 | } 24 | } 25 | ], 26 | "request": { 27 | "method": "GET", 28 | "header": [ 29 | { 30 | "key": "X-API-Key", 31 | "value": "your-secure-api-key", 32 | "type": "text" 33 | } 34 | ], 35 | "url": { 36 | "raw": "http://localhost:8000/todos", 37 | "protocol": "http", 38 | "host": ["localhost"], 39 | "port": "8000", 40 | "path": ["todos"] 41 | } 42 | } 43 | }, 44 | { 45 | "name": "Get Todos without API Key (Should Fail)", 46 | "event": [ 47 | { 48 | "listen": "test", 49 | "script": { 50 | "exec": [ 51 | "pm.test('Status code is 401', function() {", 52 | " pm.response.to.have.status(401);", 53 | "});", 54 | "", 55 | "pm.test('Error message is correct', function() {", 56 | " var jsonData = pm.response.json();", 57 | " pm.expect(jsonData.error).to.equal('API key is required');", 58 | "});" 59 | ], 60 | "type": "text/javascript" 61 | } 62 | } 63 | ], 64 | "request": { 65 | "method": "GET", 66 | "url": { 67 | "raw": "http://localhost:8000/todos", 68 | "protocol": "http", 69 | "host": ["localhost"], 70 | "port": "8000", 71 | "path": ["todos"] 72 | } 73 | } 74 | }, 75 | { 76 | "name": "Get Todos with Invalid API Key (Should Fail)", 77 | "event": [ 78 | { 79 | "listen": "test", 80 | "script": { 81 | "exec": [ 82 | "pm.test('Status code is 401', function() {", 83 | " pm.response.to.have.status(401);", 84 | "});", 85 | "", 86 | "pm.test('Error message is correct', function() {", 87 | " var jsonData = pm.response.json();", 88 | " pm.expect(jsonData.error).to.equal('Invalid API key');", 89 | "});" 90 | ], 91 | "type": "text/javascript" 92 | } 93 | } 94 | ], 95 | "request": { 96 | "method": "GET", 97 | "header": [ 98 | { 99 | "key": "X-API-Key", 100 | "value": "wrong-api-key", 101 | "type": "text" 102 | } 103 | ], 104 | "url": { 105 | "raw": "http://localhost:8000/todos", 106 | "protocol": "http", 107 | "host": ["localhost"], 108 | "port": "8000", 109 | "path": ["todos"] 110 | } 111 | } 112 | }, 113 | { 114 | "name": "Get API Documentation with API Key", 115 | "event": [ 116 | { 117 | "listen": "test", 118 | "script": { 119 | "exec": [ 120 | "pm.test('Status code is 200', function() {", 121 | " pm.response.to.have.status(200);", 122 | "});", 123 | "", 124 | "pm.test('API Key auth documentation is correct', function() {", 125 | " const responseData = pm.response.json();", 126 | " pm.expect(responseData).to.have.property('authentication');", 127 | " const auth = responseData.authentication;", 128 | "", 129 | " pm.expect(auth.method).to.equal('api_key');", 130 | " pm.expect(auth).to.have.property('description');", 131 | " pm.expect(auth).to.have.property('how_to_authenticate');", 132 | " pm.expect(auth).to.have.property('example');", 133 | " pm.expect(auth.example.headers).to.have.property('X-API-Key');", 134 | " pm.expect(auth.protected_endpoints).to.include('/todos/*');", 135 | " pm.expect(auth.protected_endpoints).to.include('/notes/*');", 136 | "});" 137 | ] 138 | } 139 | } 140 | ], 141 | "request": { 142 | "method": "GET", 143 | "url": { 144 | "raw": "http://localhost:8000/docs", 145 | "protocol": "http", 146 | "host": ["localhost"], 147 | "port": "8000", 148 | "path": ["docs"] 149 | } 150 | } 151 | } 152 | ] 153 | }, 154 | { 155 | "name": "JWT Authentication", 156 | "item": [ 157 | { 158 | "name": "1. Try to Get Todos without JWT (Should Fail)", 159 | "event": [ 160 | { 161 | "listen": "test", 162 | "script": { 163 | "exec": [ 164 | "pm.test('Status code is 401', function() {", 165 | " pm.response.to.have.status(401);", 166 | "});", 167 | "", 168 | "pm.test('Error message is correct', function() {", 169 | " var jsonData = pm.response.json();", 170 | " pm.expect(jsonData.error).to.equal('JWT token is required');", 171 | "});" 172 | ], 173 | "type": "text/javascript" 174 | } 175 | } 176 | ], 177 | "request": { 178 | "method": "GET", 179 | "url": { 180 | "raw": "http://localhost:8000/todos", 181 | "protocol": "http", 182 | "host": ["localhost"], 183 | "port": "8000", 184 | "path": ["todos"] 185 | } 186 | } 187 | }, 188 | { 189 | "name": "2. Signup with JWT", 190 | "event": [ 191 | { 192 | "listen": "test", 193 | "script": { 194 | "exec": [ 195 | "var jsonData = pm.response.json();", 196 | "pm.test('Status code is 201', function() {", 197 | " pm.response.to.have.status(201);", 198 | "});", 199 | "", 200 | "pm.test('Signup successful message', function() {", 201 | " pm.expect(jsonData.message).to.equal('Signup successful. Please log in to continue.');", 202 | "});" 203 | ], 204 | "type": "text/javascript" 205 | } 206 | } 207 | ], 208 | "request": { 209 | "method": "POST", 210 | "header": [ 211 | { 212 | "key": "Content-Type", 213 | "value": "application/json", 214 | "type": "text" 215 | } 216 | ], 217 | "body": { 218 | "mode": "raw", 219 | "raw": "{\n \"username\": \"testuser\",\n \"password\": \"testpass123\"\n}" 220 | }, 221 | "url": { 222 | "raw": "http://localhost:8000/auth/signup", 223 | "protocol": "http", 224 | "host": ["localhost"], 225 | "port": "8000", 226 | "path": ["auth", "signup"] 227 | } 228 | } 229 | }, 230 | { 231 | "name": "3. Signup with Same Username (Should Fail)", 232 | "event": [ 233 | { 234 | "listen": "test", 235 | "script": { 236 | "exec": [ 237 | "var jsonData = pm.response.json();", 238 | "pm.test('Status code is 400', function() {", 239 | " pm.response.to.have.status(400);", 240 | "});", 241 | "", 242 | "pm.test('Error message is correct', function() {", 243 | " pm.expect(jsonData.error).to.equal('Username already exists');", 244 | "});" 245 | ], 246 | "type": "text/javascript" 247 | } 248 | } 249 | ], 250 | "request": { 251 | "method": "POST", 252 | "header": [ 253 | { 254 | "key": "Content-Type", 255 | "value": "application/json", 256 | "type": "text" 257 | } 258 | ], 259 | "body": { 260 | "mode": "raw", 261 | "raw": "{\n \"username\": \"testuser\",\n \"password\": \"testpass123\"\n}" 262 | }, 263 | "url": { 264 | "raw": "http://localhost:8000/auth/signup", 265 | "protocol": "http", 266 | "host": ["localhost"], 267 | "port": "8000", 268 | "path": ["auth", "signup"] 269 | } 270 | } 271 | }, 272 | { 273 | "name": "4. Login with JWT", 274 | "event": [ 275 | { 276 | "listen": "test", 277 | "script": { 278 | "exec": [ 279 | "var jsonData = pm.response.json();", 280 | "if (jsonData.access_token) {", 281 | " pm.environment.set('jwt_token', jsonData.access_token);", 282 | " console.log('JWT token saved to environment');", 283 | "}", 284 | "if (jsonData.refresh_token) {", 285 | " pm.environment.set('refresh_token', jsonData.refresh_token);", 286 | " console.log('Refresh token saved to environment');", 287 | "}", 288 | "", 289 | "pm.test('Status code is 200', function() {", 290 | " pm.response.to.have.status(200);", 291 | "});", 292 | "", 293 | "pm.test('Response has access token', function() {", 294 | " pm.expect(jsonData.access_token).to.exist;", 295 | "});", 296 | "", 297 | "pm.test('Response has refresh token', function() {", 298 | " pm.expect(jsonData.refresh_token).to.exist;", 299 | "});" 300 | ], 301 | "type": "text/javascript" 302 | } 303 | } 304 | ], 305 | "request": { 306 | "method": "POST", 307 | "header": [ 308 | { 309 | "key": "Content-Type", 310 | "value": "application/json", 311 | "type": "text" 312 | } 313 | ], 314 | "body": { 315 | "mode": "raw", 316 | "raw": "{\n \"username\": \"testuser\",\n \"password\": \"testpass123\"\n}" 317 | }, 318 | "url": { 319 | "raw": "http://localhost:8000/auth/login", 320 | "protocol": "http", 321 | "host": ["localhost"], 322 | "port": "8000", 323 | "path": ["auth", "login"] 324 | } 325 | } 326 | }, 327 | { 328 | "name": "5. Refresh Token While Logged In", 329 | "event": [ 330 | { 331 | "listen": "test", 332 | "script": { 333 | "exec": [ 334 | "var jsonData = pm.response.json();", 335 | "pm.test('Status code is 200', function() {", 336 | " pm.response.to.have.status(200);", 337 | "});", 338 | "", 339 | "pm.test('Response has new access token', function() {", 340 | " pm.expect(jsonData.access_token).to.exist;", 341 | " pm.environment.set('jwt_token', jsonData.access_token);", 342 | " console.log('New access token saved to environment');", 343 | "});" 344 | ], 345 | "type": "text/javascript" 346 | } 347 | } 348 | ], 349 | "request": { 350 | "method": "POST", 351 | "header": [ 352 | { 353 | "key": "Content-Type", 354 | "value": "application/json", 355 | "type": "text" 356 | } 357 | ], 358 | "body": { 359 | "mode": "raw", 360 | "raw": "{\n \"refresh_token\": \"{{refresh_token}}\"\n}" 361 | }, 362 | "url": { 363 | "raw": "http://localhost:8000/auth/refresh", 364 | "protocol": "http", 365 | "host": ["localhost"], 366 | "port": "8000", 367 | "path": ["auth", "refresh"] 368 | } 369 | } 370 | }, 371 | { 372 | "name": "6. Get Todos with JWT", 373 | "event": [ 374 | { 375 | "listen": "test", 376 | "script": { 377 | "exec": [ 378 | "pm.test('Status code is 200', function() {", 379 | " pm.response.to.have.status(200);", 380 | "});" 381 | ], 382 | "type": "text/javascript" 383 | } 384 | } 385 | ], 386 | "request": { 387 | "method": "GET", 388 | "header": [ 389 | { 390 | "key": "Authorization", 391 | "value": "Bearer {{jwt_token}}", 392 | "type": "text" 393 | } 394 | ], 395 | "url": { 396 | "raw": "http://localhost:8000/todos", 397 | "protocol": "http", 398 | "host": ["localhost"], 399 | "port": "8000", 400 | "path": ["todos"] 401 | } 402 | } 403 | }, 404 | { 405 | "name": "7a. Try Logout without Both Tokens (Should Fail)", 406 | "event": [ 407 | { 408 | "listen": "test", 409 | "script": { 410 | "exec": [ 411 | "pm.test('Status code is 415', function() {", 412 | " pm.response.to.have.status(415);", 413 | "});", 414 | "", 415 | "pm.test('Error message is correct', function() {", 416 | " var jsonData = pm.response.json();", 417 | " pm.expect(jsonData.error).to.equal('Request must be JSON');", 418 | "});" 419 | ], 420 | "type": "text/javascript" 421 | } 422 | } 423 | ], 424 | "request": { 425 | "method": "POST", 426 | "header": [ 427 | { 428 | "key": "Authorization", 429 | "value": "Bearer {{jwt_token}}", 430 | "type": "text" 431 | } 432 | ], 433 | "url": { 434 | "raw": "http://localhost:8000/auth/logout", 435 | "protocol": "http", 436 | "host": ["localhost"], 437 | "port": "8000", 438 | "path": ["auth", "logout"] 439 | } 440 | } 441 | }, 442 | { 443 | "name": "7b. Try Logout with Only Refresh Token (Should Fail)", 444 | "event": [ 445 | { 446 | "listen": "test", 447 | "script": { 448 | "exec": [ 449 | "pm.test('Status code is 401', function() {", 450 | " pm.response.to.have.status(401);", 451 | "});", 452 | "", 453 | "pm.test('Error message is correct', function() {", 454 | " var jsonData = pm.response.json();", 455 | " pm.expect(jsonData.error).to.equal('Access token is required in Authorization header');", 456 | "});" 457 | ], 458 | "type": "text/javascript" 459 | } 460 | } 461 | ], 462 | "request": { 463 | "method": "POST", 464 | "header": [ 465 | { 466 | "key": "Content-Type", 467 | "value": "application/json", 468 | "type": "text" 469 | } 470 | ], 471 | "body": { 472 | "mode": "raw", 473 | "raw": "{\n \"refresh_token\": \"{{refresh_token}}\"\n}" 474 | }, 475 | "url": { 476 | "raw": "http://localhost:8000/auth/logout", 477 | "protocol": "http", 478 | "host": ["localhost"], 479 | "port": "8000", 480 | "path": ["auth", "logout"] 481 | } 482 | } 483 | }, 484 | { 485 | "name": "7c. Try Logout with Only Access Token (Should Fail)", 486 | "event": [ 487 | { 488 | "listen": "test", 489 | "script": { 490 | "exec": [ 491 | "pm.test('Status code is 400', function() {", 492 | " pm.response.to.have.status(400);", 493 | "});", 494 | "", 495 | "pm.test('Error message is correct', function() {", 496 | " var jsonData = pm.response.json();", 497 | " pm.expect(jsonData.error).to.equal('Refresh token is required in request body');", 498 | "});" 499 | ], 500 | "type": "text/javascript" 501 | } 502 | } 503 | ], 504 | "request": { 505 | "method": "POST", 506 | "header": [ 507 | { 508 | "key": "Content-Type", 509 | "value": "application/json", 510 | "type": "text" 511 | }, 512 | { 513 | "key": "Authorization", 514 | "value": "Bearer {{jwt_token}}", 515 | "type": "text" 516 | } 517 | ], 518 | "body": { 519 | "mode": "raw", 520 | "raw": "{}" 521 | }, 522 | "url": { 523 | "raw": "http://localhost:8000/auth/logout", 524 | "protocol": "http", 525 | "host": ["localhost"], 526 | "port": "8000", 527 | "path": ["auth", "logout"] 528 | } 529 | } 530 | }, 531 | { 532 | "name": "7d. Try Logout with Invalid JSON (Should Return JSON Error)", 533 | "event": [ 534 | { 535 | "listen": "test", 536 | "script": { 537 | "exec": [ 538 | "pm.test('Status code is 400', function() {", 539 | " pm.response.to.have.status(400);", 540 | "});", 541 | "", 542 | "pm.test('Response is JSON', function() {", 543 | " pm.response.to.be.json;", 544 | "});", 545 | "", 546 | "pm.test('Error message is correct', function() {", 547 | " var jsonData = pm.response.json();", 548 | " pm.expect(jsonData.error).to.include('400 Bad Request');", 549 | " pm.expect(jsonData.error).to.include('The browser (or proxy) sent a request that this server could not understand');", 550 | " pm.expect(jsonData.endpoint).to.equal('/auth/logout');", 551 | "});" 552 | ], 553 | "type": "text/javascript" 554 | } 555 | } 556 | ], 557 | "request": { 558 | "method": "POST", 559 | "header": [ 560 | { 561 | "key": "Content-Type", 562 | "value": "application/json", 563 | "type": "text" 564 | }, 565 | { 566 | "key": "Authorization", 567 | "value": "Bearer {{jwt_token}}", 568 | "type": "text" 569 | } 570 | ], 571 | "body": { 572 | "mode": "raw", 573 | "raw": "{ invalid json here" 574 | }, 575 | "url": { 576 | "raw": "http://localhost:8000/auth/logout", 577 | "protocol": "http", 578 | "host": ["localhost"], 579 | "port": "8000", 580 | "path": ["auth", "logout"] 581 | } 582 | } 583 | }, 584 | { 585 | "name": "7e. Successful Logout with Both Tokens", 586 | "event": [ 587 | { 588 | "listen": "test", 589 | "script": { 590 | "exec": [ 591 | "// Save the current token as blacklisted_token for later tests", 592 | "pm.environment.set('blacklisted_token', pm.environment.get('jwt_token'));", 593 | "", 594 | "pm.test('Status code is 200', function() {", 595 | " pm.response.to.have.status(200);", 596 | "});", 597 | "", 598 | "pm.test('Logout successful message', function() {", 599 | " var jsonData = pm.response.json();", 600 | " pm.expect(jsonData.message).to.equal('Logout successful');", 601 | "});" 602 | ], 603 | "type": "text/javascript" 604 | } 605 | } 606 | ], 607 | "request": { 608 | "method": "POST", 609 | "header": [ 610 | { 611 | "key": "Content-Type", 612 | "value": "application/json", 613 | "type": "text" 614 | }, 615 | { 616 | "key": "Authorization", 617 | "value": "Bearer {{jwt_token}}", 618 | "type": "text" 619 | } 620 | ], 621 | "body": { 622 | "mode": "raw", 623 | "raw": "{\n \"refresh_token\": \"{{refresh_token}}\"\n}" 624 | }, 625 | "url": { 626 | "raw": "http://localhost:8000/auth/logout", 627 | "protocol": "http", 628 | "host": ["localhost"], 629 | "port": "8000", 630 | "path": ["auth", "logout"] 631 | } 632 | } 633 | }, 634 | { 635 | "name": "8. Try to Get Todos with Invalid JWT (Should Fail)", 636 | "event": [ 637 | { 638 | "listen": "test", 639 | "script": { 640 | "exec": [ 641 | "pm.test('Status code is 401', function() {", 642 | " pm.response.to.have.status(401);", 643 | "});", 644 | "", 645 | "pm.test('Error message is correct', function() {", 646 | " var jsonData = pm.response.json();", 647 | " pm.expect(jsonData.error).to.include('Invalid JWT token');", 648 | "});" 649 | ], 650 | "type": "text/javascript" 651 | } 652 | } 653 | ], 654 | "request": { 655 | "method": "GET", 656 | "header": [ 657 | { 658 | "key": "Authorization", 659 | "value": "Bearer invalid.token.here", 660 | "type": "text" 661 | } 662 | ], 663 | "url": { 664 | "raw": "http://localhost:8000/todos", 665 | "protocol": "http", 666 | "host": ["localhost"], 667 | "port": "8000", 668 | "path": ["todos"] 669 | } 670 | } 671 | }, 672 | { 673 | "name": "9. Try to Refresh with Invalidated Token (Should Fail)", 674 | "event": [ 675 | { 676 | "listen": "test", 677 | "script": { 678 | "exec": [ 679 | "pm.test('Status code is 401', function() {", 680 | " pm.response.to.have.status(401);", 681 | "});", 682 | "", 683 | "pm.test('Error message is correct', function() {", 684 | " var jsonData = pm.response.json();", 685 | " pm.expect(jsonData.error).to.equal('Invalid refresh token');", 686 | "});" 687 | ], 688 | "type": "text/javascript" 689 | } 690 | } 691 | ], 692 | "request": { 693 | "method": "POST", 694 | "header": [ 695 | { 696 | "key": "Content-Type", 697 | "value": "application/json", 698 | "type": "text" 699 | } 700 | ], 701 | "body": { 702 | "mode": "raw", 703 | "raw": "{\n \"refresh_token\": \"{{refresh_token}}\"\n}" 704 | }, 705 | "url": { 706 | "raw": "http://localhost:8000/auth/refresh", 707 | "protocol": "http", 708 | "host": ["localhost"], 709 | "port": "8000", 710 | "path": ["auth", "refresh"] 711 | } 712 | } 713 | }, 714 | { 715 | "name": "10. Try to Use Blacklisted Token (Should Fail)", 716 | "event": [ 717 | { 718 | "listen": "test", 719 | "script": { 720 | "exec": [ 721 | "pm.test('Status code is 401', function() {", 722 | " pm.response.to.have.status(401);", 723 | "});", 724 | "", 725 | "pm.test('Error message is correct', function() {", 726 | " var jsonData = pm.response.json();", 727 | " pm.expect(jsonData.error).to.equal('You have been logged out. Please log in again.');", 728 | "});" 729 | ], 730 | "type": "text/javascript" 731 | } 732 | } 733 | ], 734 | "request": { 735 | "method": "GET", 736 | "header": [ 737 | { 738 | "key": "Authorization", 739 | "value": "Bearer {{blacklisted_token}}", 740 | "type": "text" 741 | } 742 | ], 743 | "url": { 744 | "raw": "http://localhost:8000/todos", 745 | "protocol": "http", 746 | "host": ["localhost"], 747 | "port": "8000", 748 | "path": ["todos"] 749 | } 750 | } 751 | }, 752 | { 753 | "name": "11. Get API Documentation with JWT", 754 | "event": [ 755 | { 756 | "listen": "test", 757 | "script": { 758 | "exec": [ 759 | "pm.test('Status code is 200', function() {", 760 | " pm.response.to.have.status(200);", 761 | "});", 762 | "", 763 | "pm.test('JWT auth documentation is correct', function() {", 764 | " const responseData = pm.response.json();", 765 | " pm.expect(responseData).to.have.property('authentication');", 766 | " const auth = responseData.authentication;", 767 | "", 768 | " pm.expect(auth.method).to.equal('jwt');", 769 | " pm.expect(auth).to.have.property('description');", 770 | " pm.expect(auth).to.have.property('how_to_authenticate');", 771 | " pm.expect(auth).to.have.property('endpoints');", 772 | " pm.expect(auth.endpoints).to.have.property('/auth/login');", 773 | " pm.expect(auth.endpoints).to.have.property('/auth/signup');", 774 | " pm.expect(auth.example.headers).to.have.property('Authorization');", 775 | " pm.expect(auth.protected_endpoints).to.include('/todos/*');", 776 | " pm.expect(auth.protected_endpoints).to.include('/notes/*');", 777 | "});" 778 | ] 779 | } 780 | } 781 | ], 782 | "request": { 783 | "method": "GET", 784 | "header": [ 785 | { 786 | "key": "Authorization", 787 | "value": "Bearer {{jwt_token}}", 788 | "type": "text" 789 | } 790 | ], 791 | "url": { 792 | "raw": "http://localhost:8000/docs", 793 | "protocol": "http", 794 | "host": ["localhost"], 795 | "port": "8000", 796 | "path": ["docs"] 797 | } 798 | } 799 | } 800 | ] 801 | }, 802 | { 803 | "name": "Session Authentication", 804 | "item": [ 805 | { 806 | "name": "1. Try to Get Todos without Login (Should Fail)", 807 | "event": [ 808 | { 809 | "listen": "test", 810 | "script": { 811 | "exec": [ 812 | "pm.test('Status code is 401', function() {", 813 | " pm.response.to.have.status(401);", 814 | "});", 815 | "", 816 | "pm.test('Error message is correct', function() {", 817 | " var jsonData = pm.response.json();", 818 | " pm.expect(jsonData.error).to.equal('Valid session required');", 819 | "});" 820 | ], 821 | "type": "text/javascript" 822 | } 823 | } 824 | ], 825 | "request": { 826 | "method": "GET", 827 | "url": { 828 | "raw": "http://localhost:8000/todos", 829 | "protocol": "http", 830 | "host": ["localhost"], 831 | "port": "8000", 832 | "path": ["todos"] 833 | } 834 | } 835 | }, 836 | { 837 | "name": "2. Signup with Session", 838 | "event": [ 839 | { 840 | "listen": "test", 841 | "script": { 842 | "exec": [ 843 | "var jsonData = pm.response.json();", 844 | "pm.test('Status code is 201', function() {", 845 | " pm.response.to.have.status(201);", 846 | "});", 847 | "", 848 | "pm.test('Signup successful message', function() {", 849 | " pm.expect(jsonData.message).to.equal('Signup successful. Please log in to continue.');", 850 | "});" 851 | ], 852 | "type": "text/javascript" 853 | } 854 | } 855 | ], 856 | "request": { 857 | "method": "POST", 858 | "header": [ 859 | { 860 | "key": "Content-Type", 861 | "value": "application/json", 862 | "type": "text" 863 | } 864 | ], 865 | "body": { 866 | "mode": "raw", 867 | "raw": "{\n \"username\": \"testuser\",\n \"password\": \"testpass123\"\n}" 868 | }, 869 | "url": { 870 | "raw": "http://localhost:8000/auth/signup", 871 | "protocol": "http", 872 | "host": ["localhost"], 873 | "port": "8000", 874 | "path": ["auth", "signup"] 875 | } 876 | } 877 | }, 878 | { 879 | "name": "3. Signup with Same Username (Should Fail)", 880 | "event": [ 881 | { 882 | "listen": "test", 883 | "script": { 884 | "exec": [ 885 | "var jsonData = pm.response.json();", 886 | "pm.test('Status code is 400', function() {", 887 | " pm.response.to.have.status(400);", 888 | "});", 889 | "", 890 | "pm.test('Error message is correct', function() {", 891 | " pm.expect(jsonData.error).to.equal('Username already exists');", 892 | "});" 893 | ], 894 | "type": "text/javascript" 895 | } 896 | } 897 | ], 898 | "request": { 899 | "method": "POST", 900 | "header": [ 901 | { 902 | "key": "Content-Type", 903 | "value": "application/json", 904 | "type": "text" 905 | } 906 | ], 907 | "body": { 908 | "mode": "raw", 909 | "raw": "{\n \"username\": \"testuser\",\n \"password\": \"testpass123\"\n}" 910 | }, 911 | "url": { 912 | "raw": "http://localhost:8000/auth/signup", 913 | "protocol": "http", 914 | "host": ["localhost"], 915 | "port": "8000", 916 | "path": ["auth", "signup"] 917 | } 918 | } 919 | }, 920 | { 921 | "name": "4. Login with Session", 922 | "event": [ 923 | { 924 | "listen": "test", 925 | "script": { 926 | "exec": [ 927 | "var jsonData = pm.response.json();", 928 | "pm.test('Status code is 200', function() {", 929 | " pm.response.to.have.status(200);", 930 | "});", 931 | "", 932 | "pm.test('Login successful message', function() {", 933 | " pm.expect(jsonData.message).to.equal('Login successful');", 934 | "});" 935 | ], 936 | "type": "text/javascript" 937 | } 938 | } 939 | ], 940 | "request": { 941 | "method": "POST", 942 | "header": [ 943 | { 944 | "key": "Content-Type", 945 | "value": "application/json", 946 | "type": "text" 947 | } 948 | ], 949 | "body": { 950 | "mode": "raw", 951 | "raw": "{\n \"username\": \"testuser\",\n \"password\": \"testpass123\"\n}" 952 | }, 953 | "url": { 954 | "raw": "http://localhost:8000/auth/login", 955 | "protocol": "http", 956 | "host": ["localhost"], 957 | "port": "8000", 958 | "path": ["auth", "login"] 959 | } 960 | } 961 | }, 962 | { 963 | "name": "5. Get Todos with Session", 964 | "event": [ 965 | { 966 | "listen": "test", 967 | "script": { 968 | "exec": [ 969 | "pm.test('Status code is 200', function() {", 970 | " pm.response.to.have.status(200);", 971 | "});" 972 | ], 973 | "type": "text/javascript" 974 | } 975 | } 976 | ], 977 | "request": { 978 | "method": "GET", 979 | "url": { 980 | "raw": "http://localhost:8000/todos", 981 | "protocol": "http", 982 | "host": ["localhost"], 983 | "port": "8000", 984 | "path": ["todos"] 985 | } 986 | } 987 | }, 988 | { 989 | "name": "6. Save Session Cookie Before Logout", 990 | "event": [ 991 | { 992 | "listen": "test", 993 | "script": { 994 | "exec": [ 995 | "// Save the current session cookie for later testing", 996 | "const sessionCookie = pm.cookies.get('session');", 997 | "if (sessionCookie) {", 998 | " pm.environment.set('old_session', sessionCookie);", 999 | " console.log('Saved session cookie:', sessionCookie);", 1000 | "}", 1001 | "", 1002 | "pm.test('Status code is 200', function() {", 1003 | " pm.response.to.have.status(200);", 1004 | "});" 1005 | ], 1006 | "type": "text/javascript" 1007 | } 1008 | } 1009 | ], 1010 | "request": { 1011 | "method": "GET", 1012 | "url": { 1013 | "raw": "http://localhost:8000/todos", 1014 | "protocol": "http", 1015 | "host": ["localhost"], 1016 | "port": "8000", 1017 | "path": ["todos"] 1018 | } 1019 | } 1020 | }, 1021 | { 1022 | "name": "7. Logout with Session", 1023 | "event": [ 1024 | { 1025 | "listen": "test", 1026 | "script": { 1027 | "exec": [ 1028 | "pm.test('Status code is 200', function() {", 1029 | " pm.response.to.have.status(200);", 1030 | "});" 1031 | ], 1032 | "type": "text/javascript" 1033 | } 1034 | } 1035 | ], 1036 | "request": { 1037 | "method": "POST", 1038 | "url": { 1039 | "raw": "http://localhost:8000/auth/logout", 1040 | "protocol": "http", 1041 | "host": ["localhost"], 1042 | "port": "8000", 1043 | "path": ["auth", "logout"] 1044 | } 1045 | } 1046 | }, 1047 | { 1048 | "name": "8. Try to Get Todos with Normal Request after Logout (Should Fail)", 1049 | "event": [ 1050 | { 1051 | "listen": "test", 1052 | "script": { 1053 | "exec": [ 1054 | "pm.test('Status code is 401', function() {", 1055 | " pm.response.to.have.status(401);", 1056 | "});", 1057 | "", 1058 | "pm.test('Error message is correct', function() {", 1059 | " var jsonData = pm.response.json();", 1060 | " pm.expect(jsonData.error).to.equal('Valid session required');", 1061 | "});" 1062 | ], 1063 | "type": "text/javascript" 1064 | } 1065 | } 1066 | ], 1067 | "request": { 1068 | "method": "GET", 1069 | "url": { 1070 | "raw": "http://localhost:8000/todos", 1071 | "protocol": "http", 1072 | "host": ["localhost"], 1073 | "port": "8000", 1074 | "path": ["todos"] 1075 | } 1076 | } 1077 | }, 1078 | { 1079 | "name": "9. Try to Get Todos with Old Session Cookie (Should Fail)", 1080 | "event": [ 1081 | { 1082 | "listen": "test", 1083 | "script": { 1084 | "exec": [ 1085 | "pm.test('Status code is 401', function() {", 1086 | " pm.response.to.have.status(401);", 1087 | "});", 1088 | "", 1089 | "pm.test('Error message is correct', function() {", 1090 | " var jsonData = pm.response.json();", 1091 | " pm.expect(jsonData.error).to.equal('Session has been invalidated');", 1092 | "});" 1093 | ], 1094 | "type": "text/javascript" 1095 | } 1096 | } 1097 | ], 1098 | "request": { 1099 | "method": "GET", 1100 | "header": [ 1101 | { 1102 | "key": "Cookie", 1103 | "value": "session={{old_session}}", 1104 | "type": "text" 1105 | } 1106 | ], 1107 | "url": { 1108 | "raw": "http://localhost:8000/todos", 1109 | "protocol": "http", 1110 | "host": ["localhost"], 1111 | "port": "8000", 1112 | "path": ["todos"] 1113 | } 1114 | } 1115 | }, 1116 | { 1117 | "name": "10. Get API Documentation with Session", 1118 | "event": [ 1119 | { 1120 | "listen": "test", 1121 | "script": { 1122 | "exec": [ 1123 | "pm.test('Status code is 200', function() {", 1124 | " pm.response.to.have.status(200);", 1125 | "});", 1126 | "", 1127 | "pm.test('Session auth documentation is correct', function() {", 1128 | " const responseData = pm.response.json();", 1129 | " pm.expect(responseData).to.have.property('authentication');", 1130 | " const auth = responseData.authentication;", 1131 | "", 1132 | " pm.expect(auth.method).to.equal('session');", 1133 | " pm.expect(auth).to.have.property('description');", 1134 | " pm.expect(auth).to.have.property('how_to_authenticate');", 1135 | " pm.expect(auth).to.have.property('endpoints');", 1136 | " pm.expect(auth.endpoints).to.have.property('/auth/login');", 1137 | " pm.expect(auth.endpoints).to.have.property('/auth/signup');", 1138 | " pm.expect(auth.endpoints).to.have.property('/auth/logout');", 1139 | " pm.expect(auth.protected_endpoints).to.include('/todos/*');", 1140 | " pm.expect(auth.protected_endpoints).to.include('/notes/*');", 1141 | "});" 1142 | ] 1143 | } 1144 | } 1145 | ], 1146 | "request": { 1147 | "method": "GET", 1148 | "url": { 1149 | "raw": "http://localhost:8000/docs", 1150 | "protocol": "http", 1151 | "host": ["localhost"], 1152 | "port": "8000", 1153 | "path": ["docs"] 1154 | } 1155 | } 1156 | } 1157 | ] 1158 | } 1159 | ] 1160 | } --------------------------------------------------------------------------------