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']('