diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..25f8183 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,47 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + unit-and-route-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --requirement requirements.txt + - name: Check Python syntax + run: python -m compileall -q app review tests + - name: Run fast tests + run: pytest --cov=app --cov=review --cov-branch --cov-report=term-missing --cov-fail-under=75 + + mongodb-integration-tests: + runs-on: ubuntu-latest + services: + mongodb: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --quiet --eval 'db.runCommand({ ping: 1 })'" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + env: + TEST_MONGODB_URL: mongodb://127.0.0.1:27017 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --requirement requirements.txt + - name: Run tests against MongoDB + run: pytest diff --git a/app/__init__.py b/app/__init__.py index 5add4c5..e430742 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -17,20 +17,25 @@ from app.services.view import register_filters -def create_app(config_path=None): - """Create and configure the Flask application.""" +def create_app(config_path=None, config=None): + """Create and configure the Flask application. + + ``config`` allows tests and other callers to supply configuration without + creating or modifying the production JSON file. Production continues to + load ``config_path`` when no configuration mapping is supplied. + """ app = Flask(__name__, template_folder='../templates', static_folder='../static') app.url_map.strict_slashes = False - # Load configuration - if config_path is None: - config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), - 'config', 'config.json') + if config is None: + if config_path is None: + config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), + 'config', 'config.json') - with open(config_path, 'r') as f: - config = json.load(f) + with open(config_path, 'r') as f: + config = json.load(f) # Configure Flask app app.config['SECRET_KEY'] = config['Session']['SecretKey'] diff --git a/app/services/database.py b/app/services/database.py index 15a39ab..5cd3a03 100644 --- a/app/services/database.py +++ b/app/services/database.py @@ -18,7 +18,9 @@ def init_db(app, config): mongo_url = config.get('URL', 'mongodb://127.0.0.1:27017') database_name = config.get('Name', 'crackmesone') - mongo_client = MongoClient(mongo_url) + # Tests can inject a disposable compatible client (for example, + # mongomock) while production always constructs a real MongoClient. + mongo_client = config.get('Client') or MongoClient(mongo_url) # Verify connection mongo_client.admin.command('ping') diff --git a/app/services/passhash.py b/app/services/passhash.py index c148b6e..6d7d5a1 100644 --- a/app/services/passhash.py +++ b/app/services/passhash.py @@ -23,7 +23,7 @@ def match_string(hashed: str, password: str) -> bool: """Check if the password matches the hash.""" try: return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) - except (ValueError, TypeError): + except (AttributeError, ValueError, TypeError): return False diff --git a/requirements.txt b/requirements.txt index 9960a76..a86bbf6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ resend==2.0.0 rustyzipper==1.0.6 # Testing +setuptools<81 # mongomock 4.1.2 imports pkg_resources pytest==7.4.3 pytest-cov==4.1.0 mongomock==4.1.2 diff --git a/review/auth.py b/review/auth.py new file mode 100644 index 0000000..cc29136 --- /dev/null +++ b/review/auth.py @@ -0,0 +1,87 @@ +"""Reviewer authentication, authorization, and CSRF helpers. + +This module deliberately owns only reviewer-session security. Main-site user +authentication remains separate. +""" + +from functools import wraps +import hashlib +import os + +from flask import abort, redirect, request, session, url_for + + +REVIEWER_SESSION_KEY = '_reviewer_user' +REVIEWER_ADMIN_KEY = '_reviewer_is_admin' +REVIEWER_CSRF_KEY = '_reviewer_csrf_token' + +_users = {} + + +def configure(users): + """Use the reviewer credential mapping loaded by the reviewer package.""" + global _users + _users = users + + +def hash_string(input_string): + """Return a SHA-256 hexadecimal password digest input.""" + return hashlib.sha256(input_string.encode('utf-8')).hexdigest() + + +def get_current_reviewer(): + """Return the authenticated reviewer, or ``None`` for a stale session.""" + username = session.get(REVIEWER_SESSION_KEY) + if not username or username not in _users: + return None + return { + 'username': username, + 'is_admin': _users[username].get('is_admin', False), + } + + +def clear_reviewer_session(): + """Remove reviewer authentication without touching main-site auth.""" + session.pop(REVIEWER_SESSION_KEY, None) + session.pop(REVIEWER_ADMIN_KEY, None) + + +def token_required(view): + """Require a current reviewer account.""" + @wraps(view) + def decorated(*args, **kwargs): + current_user = get_current_reviewer() + if not current_user: + clear_reviewer_session() + return redirect(url_for('reviewer.login')) + return view(current_user, *args, **kwargs) + return decorated + + +def admin_required(view): + """Require a current reviewer account with administrator privileges.""" + @wraps(view) + def decorated(*args, **kwargs): + current_user = get_current_reviewer() + if not current_user: + clear_reviewer_session() + return redirect(url_for('reviewer.login')) + if not current_user['is_admin']: + abort(403) + return view(current_user, *args, **kwargs) + return decorated + + +def generate_csrf_token(): + """Generate or retrieve the reviewer-specific CSRF token.""" + if REVIEWER_CSRF_KEY not in session: + session[REVIEWER_CSRF_KEY] = hashlib.sha256(os.urandom(32)).hexdigest() + return session[REVIEWER_CSRF_KEY] + + +def validate_csrf_token(): + """Reject a missing or mismatched reviewer CSRF token.""" + token = request.form.get('csrf_token') + expected = session.get(REVIEWER_CSRF_KEY) + if not token or not expected or token != expected: + abort(403, description='CSRF token validation failed') diff --git a/review/routes.py b/review/routes.py index 3f458ef..f189040 100644 --- a/review/routes.py +++ b/review/routes.py @@ -12,11 +12,9 @@ Blueprint, render_template, request, redirect, url_for, abort, send_file, session, jsonify ) -from functools import wraps from html import escape as html_escape import datetime from datetime import timezone -import hashlib import json import os import random @@ -31,6 +29,19 @@ from bson.objectid import ObjectId from review.logger import log_reviewer_operation +from review.auth import ( + REVIEWER_ADMIN_KEY, + REVIEWER_CSRF_KEY, + REVIEWER_SESSION_KEY, + admin_required, + clear_reviewer_session, + configure as configure_reviewer_auth, + generate_csrf_token, + get_current_reviewer, + hash_string, + token_required, + validate_csrf_token, +) from app.services.crypto import get_obfuscation_salt from app.services.view import is_valid_hexid @@ -56,11 +67,6 @@ users = {} USERS_FILE = os.path.join(os.path.dirname(__file__), 'users.json') -# Session keys for reviewer authentication (prefixed to avoid conflicts) -REVIEWER_SESSION_KEY = '_reviewer_user' -REVIEWER_ADMIN_KEY = '_reviewer_is_admin' -REVIEWER_CSRF_KEY = '_reviewer_csrf_token' - # Archive password for approved submissions ARCHIVE_PASSWORD = 'crackmes.one' @@ -104,6 +110,8 @@ def init_reviewer(app): with open(USERS_FILE, 'r') as f: users.update(json.load(f)) + configure_reviewer_auth(users) + def save_users(): """ @@ -116,110 +124,6 @@ def save_users(): json.dump(users, f, indent=2) -# ============================================================================= -# Authentication Helpers -# ============================================================================= - -def hash_string(input_string): - """ - Hash a string using SHA256. - - Args: - input_string: Plain text string to hash - - Returns: - Hexadecimal string representation of the SHA256 hash - """ - return hashlib.sha256(input_string.encode('utf-8')).hexdigest() - - -def get_current_reviewer(): - """ - Get the current authenticated reviewer from session. - - Returns: - Dict with 'username' and 'is_admin' keys if authenticated, - None if not authenticated or user no longer exists. - """ - username = session.get(REVIEWER_SESSION_KEY) - if not username or username not in users: - return None - return { - 'username': username, - 'is_admin': users[username].get('is_admin', False) - } - - -def clear_reviewer_session(): - """Remove reviewer authentication from session.""" - session.pop(REVIEWER_SESSION_KEY, None) - session.pop(REVIEWER_ADMIN_KEY, None) - - -def token_required(f): - """ - Decorator requiring reviewer authentication. - - Redirects to login page if not authenticated. Passes current_user - dict as first argument to the decorated function. - """ - @wraps(f) - def decorated(*args, **kwargs): - current_user = get_current_reviewer() - if not current_user: - clear_reviewer_session() - return redirect(url_for('reviewer.login')) - return f(current_user, *args, **kwargs) - return decorated - - -def admin_required(f): - """ - Decorator requiring admin authentication. - - Redirects to login if not authenticated, returns 403 if authenticated - but not an admin. Passes current_user dict as first argument. - """ - @wraps(f) - def decorated(*args, **kwargs): - current_user = get_current_reviewer() - if not current_user: - clear_reviewer_session() - return redirect(url_for('reviewer.login')) - if not current_user['is_admin']: - abort(403) - return f(current_user, *args, **kwargs) - return decorated - - -# ============================================================================= -# CSRF Protection -# ============================================================================= - -def generate_csrf_token(): - """ - Generate or retrieve CSRF token for the current session. - - Returns: - 32-byte hex string CSRF token - """ - if REVIEWER_CSRF_KEY not in session: - session[REVIEWER_CSRF_KEY] = hashlib.sha256(os.urandom(32)).hexdigest() - return session[REVIEWER_CSRF_KEY] - - -def validate_csrf_token(): - """ - Validate CSRF token from form submission. - - Aborts with 403 if token is missing or invalid. - """ - token = request.form.get('csrf_token') - expected = session.get(REVIEWER_CSRF_KEY) - if not token or not expected or token != expected: - abort(403, description="CSRF token validation failed") - - @reviewer_bp.context_processor def inject_csrf_token(): """Make csrf_token function available in all templates.""" diff --git a/tests/conftest.py b/tests/conftest.py index bf26364..6c5343b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,91 +1,182 @@ -""" -Pytest configuration and fixtures for crackmes.one tests. -""" -import pytest -from unittest.mock import MagicMock, patch +"""Shared, isolated fixtures for application and model tests.""" + +import os + import mongomock -from flask import Flask +import pytest +from pymongo import MongoClient +from app.services.passhash import hash_string -@pytest.fixture -def app(): - """Create a test Flask application.""" - from app import create_app - # Mock the config to use test settings - test_config = { +def _test_config(mongo_client, database_name): + return { 'Database': { - 'MongoDB': { - 'URL': 'mongodb://localhost:27017', - 'Database': 'test_crackmesone' - } - }, - 'Recaptcha': { - 'Enabled': False, - 'SiteKey': 'test-site-key', - 'Secret': 'test-secret' - }, - 'Session': { - 'SecretKey': 'test-secret-key' + 'URL': os.getenv('TEST_MONGODB_URL', 'mongodb://127.0.0.1:27017'), + 'Name': database_name, + 'Client': mongo_client, }, - 'Server': { - 'HTTPPort': 5000 - } + 'Recaptcha': {'Enabled': False, 'SiteKey': '', 'Secret': ''}, + 'Session': {'SecretKey': 'test-secret-key', 'CookieName': 'test-session'}, + 'RateLimiter': {'Enabled': False, 'StorageUri': 'memory://'}, + 'Discord': {'Enabled': False}, + 'Email': {'Enabled': False}, + 'Reviewer': {'Enabled': True, 'PasswordSalt': 'test-reviewer-salt'}, + 'Site': {'BaseURL': 'http://localhost'}, + 'Writeup': {'ObfuscationSalt': 'test-obfuscation-salt'}, } - with patch('app.services.database.load_config', return_value=test_config): - with patch('app.services.database.MongoClient') as mock_client: - mock_client.return_value = mongomock.MongoClient() - app = create_app() - app.config['TESTING'] = True - app.config['WTF_CSRF_ENABLED'] = False - yield app + +@pytest.fixture(scope='session') +def mongo_client(): + """Use mongomock locally or a disposable real MongoDB when configured.""" + mongo_url = os.getenv('TEST_MONGODB_URL') + client = MongoClient(mongo_url) if mongo_url else mongomock.MongoClient() + if mongo_url: + client.admin.command('ping') + yield client + client.drop_database('test_crackmesone') + client.close() + + +@pytest.fixture(scope='session') +def app(mongo_client): + from app import create_app + + application = create_app(config=_test_config(mongo_client, 'test_crackmesone')) + application.config.update(TESTING=True, WTF_CSRF_ENABLED=False) + yield application + + +@pytest.fixture +def db(app): + database = app.config['MONGO_DB'] + for collection_name in database.list_collection_names(): + database.drop_collection(collection_name) + yield database + for collection_name in database.list_collection_names(): + database.drop_collection(collection_name) @pytest.fixture def client(app): - """Create a test client.""" return app.test_client() +@pytest.fixture(autouse=True) +def reset_external_service_config(app): + """Prevent tests that change process-global service config leaking state.""" + from app.services.recaptcha import init_recaptcha + from review import auth, routes + + init_recaptcha(app, app.config['APP_CONFIG']['Recaptcha']) + auth.configure(routes.users) + + @pytest.fixture def runner(app): - """Create a test CLI runner.""" return app.test_cli_runner() @pytest.fixture -def mock_db(): - """Create a mock MongoDB database.""" - return mongomock.MongoClient().test_crackmesone +def alice(db): + user = { + 'name': 'alice', + 'email': 'alice@example.test', + 'password': hash_string('alice-password'), + 'visible': True, + 'deleted': False, + 'unread_notifications': 0, + } + db.user.insert_one(user) + return user + + +@pytest.fixture +def bob(db): + user = { + 'name': 'bob', + 'email': 'bob@example.test', + 'password': hash_string('bob-password'), + 'visible': True, + 'deleted': False, + 'unread_notifications': 0, + } + db.user.insert_one(user) + return user + + +def _authenticate(client, user): + with client.session_transaction() as session: + session['name'] = user['name'] + session['email'] = user['email'] + return client @pytest.fixture -def sample_user(): - """Sample user data for testing.""" - return { - 'name': 'testuser', - 'email': 'test@example.com', - 'password': 'password123', - 'level': 0, - 'nbcrackmes': 0, - 'nbsolutions': 0, - 'nbcomments': 0 +def alice_client(app, alice): + return _authenticate(app.test_client(), alice) + + +@pytest.fixture +def bob_client(app, bob): + return _authenticate(app.test_client(), bob) + + +@pytest.fixture +def reviewer_account(): + from review import routes + + routes.users['reviewer'] = { + 'password_hash': routes.hash_string( + 'reviewer-password' + 'test-reviewer-salt' + ), + 'is_admin': False, } + yield routes.users['reviewer'] + routes.users.pop('reviewer', None) @pytest.fixture -def sample_crackme(): - """Sample crackme data for testing.""" - return { +def reviewer_client(app, reviewer_account): + from review.routes import ( + REVIEWER_ADMIN_KEY, + REVIEWER_CSRF_KEY, + REVIEWER_SESSION_KEY, + ) + + client = app.test_client() + with client.session_transaction() as session: + session[REVIEWER_SESSION_KEY] = 'reviewer' + session[REVIEWER_ADMIN_KEY] = False + session[REVIEWER_CSRF_KEY] = 'test-csrf-token' + return client + + +@pytest.fixture +def sample_crackme(db, alice): + from bson import ObjectId + from datetime import datetime, timezone + + object_id = ObjectId() + crackme = { + '_id': object_id, + 'hexid': str(object_id), 'name': 'Test Crackme', - 'author': 'testuser', - 'info': 'A test crackme for unit testing', + 'author': alice['name'], + 'info': 'A test crackme', 'lang': 'C/C++', 'arch': 'x86-64', 'platform': 'Linux', 'difficulty': 3.0, 'quality': 4.0, + 'visible': True, + 'deleted': False, 'nbsolutions': 0, - 'nbcomments': 0 + 'nbcomments': 0, + 'nbdownloads': 0, + 'size': 100, + 'created_at': datetime.now(timezone.utc), } + db.crackme.insert_one(crackme) + return crackme diff --git a/tests/test_comments_and_crackmes.py b/tests/test_comments_and_crackmes.py new file mode 100644 index 0000000..61d8348 --- /dev/null +++ b/tests/test_comments_and_crackmes.py @@ -0,0 +1,118 @@ +"""Integration tests for comments and crackme ownership/upload workflows.""" + +from io import BytesIO + + +def test_comment_creation_updates_count_and_notifies_owner( + bob_client, db, sample_crackme, bob): + response = bob_client.post( + f"/comment/{sample_crackme['hexid']}", + data={'comment': 'Useful challenge, thanks!'}, + ) + + assert response.status_code == 302 + comment = db.comment.find_one({'author': 'bob'}) + assert comment['info'] == 'Useful challenge, thanks!' + assert comment['spoiler'] is False + assert db.crackme.find_one({'_id': sample_crackme['_id']})['nbcomments'] == 1 + assert db.notifications.count_documents({'user': 'alice'}) == 1 + + +def test_comment_mentions_only_existing_thread_participants( + alice_client, bob_client, db, sample_crackme, bob): + alice_client.post( + f"/comment/{sample_crackme['hexid']}", data={'comment': 'Initial note'} + ) + db.notifications.delete_many({}) + + bob_client.post( + f"/comment/{sample_crackme['hexid']}", + data={'comment': '@alice and @outsider please review'}, + ) + + assert db.notifications.count_documents({'user': 'alice'}) == 1 + assert db.notifications.count_documents({'user': 'outsider'}) == 0 + + +def test_crackme_author_can_toggle_any_comment_spoiler( + alice_client, db, sample_crackme, bob): + from app.models.comment import comment_create + + comment = comment_create('Potential spoiler', 'bob', sample_crackme['hexid']) + path = f"/comment/{comment['_id']}/spoiler" + + assert alice_client.post(path).status_code == 302 + assert db.comment.find_one({'_id': comment['_id']})['spoiler'] is True + assert alice_client.post(path).status_code == 302 + assert db.comment.find_one({'_id': comment['_id']})['spoiler'] is False + + +def test_comment_author_cannot_remove_own_spoiler( + bob_client, db, sample_crackme, bob): + from app.models.comment import comment_create + + comment = comment_create( + 'My spoiler', 'bob', sample_crackme['hexid'], spoiler=True + ) + response = bob_client.post(f"/comment/{comment['_id']}/spoiler") + + assert response.status_code == 302 + assert db.comment.find_one({'_id': comment['_id']})['spoiler'] is True + + +def test_crackme_upload_creates_pending_record_file_and_ratings( + alice_client, db, alice, tmp_path, monkeypatch): + from app.controllers import crackme as crackme_controller + + monkeypatch.setattr(crackme_controller, 'UPLOAD_FOLDER', str(tmp_path)) + response = alice_client.post('/upload/crackme', data={ + 'name': 'Uploaded Challenge', + 'info': 'Analyze this small challenge.', + 'lang': 'C/C++', + 'difficulty': '3', + 'platform': 'Linux', + 'arch': 'x86-64', + 'file': (BytesIO(b'not-an-archive-binary'), '../../challenge.bin'), + }, content_type='multipart/form-data') + + assert response.status_code == 200 + crackme = db.crackme.find_one({'name': 'Uploaded Challenge'}) + assert crackme['visible'] is False + assert crackme['original_filename'] == 'challenge.bin' + assert (tmp_path / crackme['hexid']).read_bytes() == b'not-an-archive-binary' + assert db.rating_difficulty.find_one({'crackmehexid': crackme['hexid']})['rating'] == 3 + assert db.rating_quality.find_one({'crackmehexid': crackme['hexid']})['rating'] == 4 + + +def test_crackme_upload_requires_all_metadata_and_file(alice_client, db, alice): + missing_metadata = alice_client.post('/upload/crackme', data={'name': 'Incomplete'}) + missing_file = alice_client.post('/upload/crackme', data={ + 'name': 'No File', 'info': 'Info', 'lang': 'C/C++', 'difficulty': '3', + 'platform': 'Linux', 'arch': 'x86-64', + }) + + assert missing_metadata.status_code == 200 + assert b'Field missing: info' in missing_metadata.data + assert missing_file.status_code == 200 + assert b'Field missing: file' in missing_file.data + assert db.crackme.count_documents({}) == 0 + + +def test_owner_can_edit_crackme_but_other_user_cannot( + alice_client, bob_client, db, sample_crackme, bob): + path = f"/crackme/{sample_crackme['hexid']}/edit" + + denied = bob_client.post(path, data={ + 'info': 'Unauthorized', 'lang': 'Python', 'arch': 'ARM', + 'platform': 'Windows', + }) + allowed = alice_client.post(path, data={ + 'info': 'Updated information', 'lang': 'Rust', 'arch': 'ARM', + 'platform': 'Linux', + }) + + assert denied.status_code == 302 + assert allowed.status_code == 302 + updated = db.crackme.find_one({'_id': sample_crackme['_id']}) + assert updated['info'] == 'Updated information' + assert updated['lang'] == 'Rust' diff --git a/tests/test_controller_edge_cases.py b/tests/test_controller_edge_cases.py new file mode 100644 index 0000000..5702954 --- /dev/null +++ b/tests/test_controller_edge_cases.py @@ -0,0 +1,233 @@ +"""High-value validation and failure branches across public controllers.""" + +from io import BytesIO +from unittest.mock import patch + +import pytest + + +def test_login_by_email_and_safe_referrer(client, alice): + page = client.get('/login', headers={'Referer': 'http://localhost/crackme/abc'}) + assert page.status_code == 200 + response = client.post('/login', data={ + 'name': 'alice@example.test', 'password': 'alice-password', + }) + assert response.status_code == 302 + assert response.location == '/crackme/abc' + + +def test_login_does_not_redirect_to_external_referrer(client, alice): + client.get('/login', headers={'Referer': 'https://evil.example/phish'}) + response = client.post('/login', data={ + 'name': 'alice', 'password': 'alice-password', + }) + assert response.location == '/' + + +def test_login_missing_and_invalid_name(client): + missing = client.post('/login', data={'name': '', 'password': ''}) + invalid = client.post('/login', data={'name': 'safe'}, + ) + assert missing.status_code == captcha.status_code == sanitized.status_code == 302 + stored = db.comment.find_one({})['info'] + assert '', + }}) + db.rating_difficulty.insert_one({ + 'crackmehexid': sample_crackme['hexid'], 'rating': 5, + }) + + response = client.get('/rss/crackme') + + assert response.status_code == 200 + assert response.mimetype == 'application/rss+xml' + assert b'A & B <challenge>' in response.data + assert b'Very Hard' in response.data + assert b'<script>alert(1)</script>' in response.data + assert b'http://localhost/crackme/' in response.data + + +def test_rss_database_failure_returns_500(client): + with patch('app.controllers.rss.last_crackmes', side_effect=RuntimeError): + response = client.get('/rss/crackme') + + assert response.status_code == 500 + assert response.data == b'Error generating RSS feed' + + +def test_index_degrades_to_zero_counts_on_database_error(client): + with patch('app.controllers.index.count_users', side_effect=RuntimeError): + response = client.get('/') + + assert response.status_code == 200 + + +def test_static_and_well_known_missing_files_are_404(client): + assert client.get('/static/does-not-exist.txt').status_code == 404 + assert client.get('/.well-known/does-not-exist.txt').status_code == 404 diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..b0eb772 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,68 @@ +"""Route-level coverage for search filtering, sorting, and input fallback.""" + + +def _another_crackme(db, sample_crackme): + item = dict(sample_crackme) + item.pop('_id') + item.update({ + 'hexid': '507f1f77bcf86cd799439011', + 'name': 'Windows Puzzle', + 'author': 'bob', + 'lang': 'Python', + 'arch': 'ARM', + 'platform': 'Windows', + 'difficulty': 5.0, + 'quality': 3.0, + 'size': 2 * 1024 * 1024, + }) + db.crackme.insert_one(item) + return item + + +def test_search_filters_by_name_and_platform(client, db, sample_crackme): + _another_crackme(db, sample_crackme) + + response = client.post('/search', data={ + 'name': 'Windows', + 'platform': 'Windows', + 'difficulty-min': '1', 'difficulty-max': '6', + 'quality-min': '1', 'quality-max': '6', + }) + + assert response.status_code == 200 + assert b'Windows Puzzle' in response.data + assert b'Test Crackme' not in response.data + + +def test_search_applies_size_units_and_sorting(client, db, sample_crackme): + _another_crackme(db, sample_crackme) + + response = client.post('/search', data={ + 'difficulty-min': '1', 'difficulty-max': '6', + 'quality-min': '1', 'quality-max': '6', + 'size-min': '1', 'size-min-unit': 'MB', + 'sort_by': 'size', 'sort_order': 'desc', + }) + + assert response.status_code == 200 + assert b'Windows Puzzle' in response.data + assert b'Test Crackme' not in response.data + + +def test_search_invalid_numeric_and_sort_inputs_fall_back_safely( + client, sample_crackme): + response = client.post('/search', data={ + 'difficulty-min': 'invalid', 'difficulty-max': 'invalid', + 'quality-min': 'invalid', 'quality-max': 'invalid', + 'downloads-min': '-20', 'page': '-4', + 'sort_by': 'drop-table', 'sort_order': 'sideways', + }) + + assert response.status_code == 200 + assert b'Test Crackme' in response.data + +def test_random_route_returns_visible_crackmes(client, sample_crackme): + response = client.get('/random?sort_by=difficulty&sort_order=asc') + + assert response.status_code == 200 + assert b'Test Crackme' in response.data diff --git a/tests/test_services_and_failures.py b/tests/test_services_and_failures.py new file mode 100644 index 0000000..def1177 --- /dev/null +++ b/tests/test_services_and_failures.py @@ -0,0 +1,150 @@ +"""Service integration boundaries and injected controller failure paths.""" + +from unittest.mock import MagicMock, patch + +import pytest + + +def test_discord_configuration_and_webhook_delivery(app): + from app.services import discord + + config = { + 'Enabled': True, + 'WebhookPublic': 'https://discord.test/public', + 'WebhookPrivate': 'https://discord.test/private', + 'WebhookModeration': 'https://discord.test/moderation', + } + discord.init_discord(app, config) + assert discord.get_public_webhook().endswith('/public') + assert discord.get_private_webhook().endswith('/private') + assert discord.get_moderation_webhook().endswith('/moderation') + with patch.object( + discord.requests, 'post', return_value=MagicMock(status_code=204) + ) as post: + assert discord.send_to_webhook('https://discord.test/hook', message='hello') is True + assert post.call_args.kwargs['json']['content'] == 'hello' + with patch.object(discord.requests, 'post', side_effect=TimeoutError): + assert discord.send_to_webhook('https://discord.test/hook', message='hello') is False + assert discord.send_to_webhook('', message='hello') is False + discord.init_discord(app, {'Enabled': False}) + + +def test_discord_notification_payload_builders(app): + from app.services import discord + + discord.init_discord(app, {'Enabled': True, 'WebhookModeration': 'hook'}) + with patch.object(discord, 'send_moderation_notification', return_value=True) as send: + assert discord.notify_new_comment( + 'alice', 'Challenge', 'abc', 'x' * 600, + comment_id='comment', spoiler_token='token', + ) is True + embed = send.call_args.args[0] + assert embed['fields'][2]['value'].endswith('...') + assert any(field['name'] == 'Actions' for field in embed['fields']) + discord.notify_spoiler_toggle('alice', 'Challenge', 'abc', 'bob', True) + assert 'Marked As Spoiler' in send.call_args.args[0]['title'] + discord.notify_password_reset_request('alice@example.test') + assert send.call_args.args[0]['title'] == 'Password Reset Requested' + discord.notify_password_reset_complete('alice', 'alice@example.test') + assert send.call_args.args[0]['title'] == 'Password Reset Completed' + discord.init_discord(app, {'Enabled': False}) + + +def test_pending_discord_notifications_use_private_channel(app): + from app.services import discord + + discord.init_discord(app, {'Enabled': True}) + with patch.object(discord, 'send_private_notification', return_value=True) as send: + assert discord.notify_new_crackme('alice', 'Challenge') is True + assert send.call_args.kwargs['embed']['title'] == 'Pending Crackme Submission' + assert discord.notify_new_solution('bob', 'Challenge') is True + assert send.call_args.kwargs['embed']['title'] == 'Pending Solution Submission' + discord.init_discord(app, {'Enabled': False}) + + +@pytest.mark.parametrize('error,expected', [ + (None, None), + (RuntimeError('no documents'), 'ErrNoResult'), + (RuntimeError('not found'), 'ErrNoResult'), + (RuntimeError('other'), 'RuntimeError'), +]) +def test_standardize_database_errors(error, expected): + from app.models.errors import standardize_error + result = standardize_error(error) + assert (type(result).__name__ if result is not None else None) == expected + + +def test_password_hash_matching_rejects_malformed_hashes(): + from app.services.passhash import match_bytes, match_string + assert match_string('not-a-hash', 'password') is False + assert match_string(None, 'password') is False + assert match_bytes(b'not-a-hash', b'password') is False + assert match_bytes(None, b'password') is False + + +def test_notification_controller_database_failures(alice_client, alice): + with patch( + 'app.controllers.notifications.notifications_by_user', side_effect=RuntimeError + ): + page = alice_client.get('/notifications') + with patch( + 'app.controllers.notifications.notification_mark_seen_single', + side_effect=RuntimeError, + ): + seen = alice_client.post('/notifications/mark-seen', data={'hexid': 'x'}) + with patch( + 'app.controllers.notifications.notification_remove', side_effect=RuntimeError + ): + deleted = alice_client.post('/notifications/delete', data={'hexid': 'x'}) + assert page.status_code == 200 + assert seen.status_code == deleted.status_code == 500 + + +def test_password_change_hash_and_update_failures(alice_client, alice): + payload = { + 'current_password': 'alice-password', + 'new_password': 'replacement-password', + 'new_password_verify': 'replacement-password', + } + with patch('app.controllers.password.hash_string', side_effect=RuntimeError): + hashing = alice_client.post('/change-password', data=payload) + with patch('app.controllers.password.update_user_password', side_effect=RuntimeError): + updating = alice_client.post('/change-password', data=payload) + assert hashing.status_code == 500 + assert updating.status_code == 500 + + +def test_rating_missing_fields_and_database_failure(alice_client, sample_crackme): + diff_path = f"/crackme/rate-diff/{sample_crackme['hexid']}" + qual_path = f"/crackme/rate-qual/{sample_crackme['hexid']}" + assert alice_client.post(diff_path, data={}).status_code == 302 + assert alice_client.post(qual_path, data={}).status_code == 302 + with patch( + 'app.controllers.rating.is_already_rated_difficulty', side_effect=RuntimeError + ): + assert alice_client.post(diff_path, data={'difficulty': '3'}).status_code == 500 + with patch( + 'app.controllers.rating.is_already_rated_quality', side_effect=RuntimeError + ): + assert alice_client.post(qual_path, data={'quality': '3'}).status_code == 500 + + +def test_search_show_all_and_database_failure(client): + with patch('app.controllers.search.search_crackme', return_value=([], False)) as search: + response = client.post('/search', data={ + 'show_all': '1', 'size-min': '2', 'size-min-unit': 'GB', + 'size-max': '3', 'size-max-unit': 'KB', + }) + assert response.status_code == 200 + assert search.call_args.kwargs['per_page'] == 10000 + assert search.call_args.kwargs['size_min'] == 2 * 1024**3 + assert search.call_args.kwargs['size_max'] == 3 * 1024 + with patch('app.controllers.search.search_crackme', side_effect=RuntimeError): + assert client.post('/search', data={}).status_code == 200 + + +def test_random_search_database_unavailable(client): + from app.models.errors import ErrUnavailable + with patch('app.controllers.search.random_crackmes', side_effect=ErrUnavailable): + response = client.get('/random') + assert response.status_code == 200 diff --git a/tests/test_services_extended.py b/tests/test_services_extended.py new file mode 100644 index 0000000..3e819d9 --- /dev/null +++ b/tests/test_services_extended.py @@ -0,0 +1,147 @@ +"""Focused unit coverage for deterministic service boundaries and helpers.""" + +import io +import struct +import tarfile +import zipfile +from datetime import datetime, timedelta +from unittest.mock import patch + +import pytest + +from app.services import archive, email + + +def _zip(files): + output = io.BytesIO() + with zipfile.ZipFile(output, 'w') as zipped: + for name, data in files.items(): + zipped.writestr(name, data) + return output.getvalue() + + +def test_archive_counts_real_files_but_ignores_metadata(): + data = _zip({ + 'one.bin': b'1', + 'two.txt': b'2', + '__MACOSX/._one.bin': b'metadata', + '.DS_Store': b'metadata', + }) + + assert archive.get_archive_file_count(data) == 2 + assert archive.is_single_file_archive(data) is False + assert archive.get_archive_file_count(b'not a zip') is None + + +def test_single_file_zip_is_detected(): + assert archive.is_single_file_archive(_zip({'only.bin': b'data'})) is True + + +def test_rar_and_tar_are_rejected_but_plain_data_is_allowed(): + tar_buffer = io.BytesIO() + with tarfile.open(fileobj=tar_buffer, mode='w') as tar: + payload = b'content' + info = tarfile.TarInfo('file.txt') + info.size = len(payload) + tar.addfile(info, io.BytesIO(payload)) + + assert archive.is_unsupported_archive(b'Rar!payload') is True + assert archive.is_unsupported_archive(tar_buffer.getvalue()) is True + assert archive.is_unsupported_archive(b'plain binary') is False + + +def test_pe_detection_uses_extension_and_header_signature(): + header = bytearray(128) + header[:2] = b'MZ' + header[0x3C:0x40] = struct.pack('Body', 'Body' + ) is True + assert send.call_args.args[0]['html'] == 'Body' + assert send.call_args.args[0]['text'] == 'Body' + + with patch.object(email.resend.Emails, 'send', side_effect=RuntimeError): + assert email.send_email('user@example.test', 'Subject', 'Body') is False + assert email.send_html_email('user@example.test', 'Subject', 'x') is False + + email.configure({}) + assert email.send_email('user@example.test', 'Subject', 'Body') is False + assert email.send_html_email('user@example.test', 'Subject', 'x') is False + + +def test_template_filters_cover_dates_sizes_mentions_and_invalid_values(app): + filters = app.jinja_env.filters + globals_ = app.jinja_env.globals + now = datetime(2026, 1, 2, 3, 4) + + assert filters['PRETTYTIME'](now) == '2026-01-02 03:04' + assert filters['PRETTYTIME']('not-a-date') == 'not-a-date' + assert filters['PRETTYTIMEFORMAT'](now, '%Y') == '2026' + assert filters['FILESIZE'](0) == '-' + assert filters['FILESIZE'](2048) == '2.00 KB' + assert filters['FILESIZE'](2**21) == '2.00 MB' + assert filters['FILESIZE'](2**31) == '2.00 GB' + rendered = str(filters['render_mentions']('