diff --git a/app/__init__.py b/app/__init__.py index 9315507..99490df 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -16,8 +16,12 @@ db = SQLAlchemy() login_manager = LoginManager() -def create_app(): - """Build and configure the Flask application instance.""" +def create_app(config_overrides=None): + """Build and configure the Flask application instance. + + Optional configuration overrides make it possible to run the same app + logic against an isolated test database without changing production code. + """ app = Flask(__name__) # Build an absolute path to the application directory so the SQLite @@ -32,6 +36,11 @@ def create_app(): app.config["REMEMBER_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + # Tests can inject their own database and runtime settings here so each run + # stays isolated from the real application data. + if config_overrides: + app.config.update(config_overrides) + # Connect the shared extension objects to this specific app instance. db.init_app(app) login_manager.init_app(app) diff --git a/app/models/gamestate.py b/app/models/gamestate.py index ddb1ab2..61247c7 100644 --- a/app/models/gamestate.py +++ b/app/models/gamestate.py @@ -1,12 +1,21 @@ +"""Database models for persisted puzzle save points.""" + from .. import db class GameState(db.Model): + """Persist one saved puzzle session for a specific user.""" + __tablename__ = "game_state" + # These identifiers connect a save point to one concrete database row and + # to the user who owns it, which is necessary so save points stay private. id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, nullable=False) + # The remaining fields describe the board snapshot and the surrounding + # progress information, because a meaningful restore needs more than just + # piece coordinates. board = db.Column(db.Text, nullable=False) mode = db.Column(db.String(10), nullable=False) board_size = db.Column(db.Integer, nullable=False, default=8) @@ -16,6 +25,9 @@ class GameState(db.Model): step_count = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0")) elapsed_seconds = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0")) is_solved = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0")) + + # Timestamps make it possible to sort, filter, and explain save points in + # the UI without asking the user to remember when each run was created. created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now()) updated_at = db.Column( db.DateTime, diff --git a/app/models/login_attempt.py b/app/models/login_attempt.py index 5e9c2cf..e47f446 100644 --- a/app/models/login_attempt.py +++ b/app/models/login_attempt.py @@ -1,14 +1,26 @@ +"""Database model for temporary login-throttling information.""" + from .. import db class LoginAttempt(db.Model): + """Track failed login attempts per IP address.""" + __tablename__ = "login_attempts" + # The unique IP address is the central lookup key because the lockout + # policy is applied to the origin of failed requests, not to one account. id = db.Column(db.Integer, primary_key=True) ip_address = db.Column(db.String(45), unique=True, nullable=False) + + # These values explain how far an address is into the lock flow and when + # the application may safely allow another login attempt. failed_attempts = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0")) locked_until = db.Column(db.DateTime) last_failed_at = db.Column(db.DateTime) def __repr__(self): + """Return a compact identifier for debugging and logging. + """ + return f"" diff --git a/app/models/user.py b/app/models/user.py index ef6e199..0aa808d 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,17 +1,29 @@ +"""Database model for application user accounts.""" + from .. import db from flask_login import UserMixin class User(UserMixin, db.Model): + """Represent one authenticated account in the application.""" + __tablename__ = "users" + # These core identity fields are the minimum needed to create and safely + # authenticate an account. id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(20), unique=True, nullable=False) password = db.Column(db.String(255), nullable=False) + + # The metadata and 2FA fields support account history and stronger account + # protection without changing the basic login flow. created_at = db.Column(db.DateTime, server_default=db.func.now()) last_login = db.Column(db.DateTime) two_factor_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0")) two_factor_secret = db.Column(db.String(64)) def __repr__(self): + """Return a readable representation for debugging output. + """ + return f"" diff --git a/app/routes/login_routes.py b/app/routes/login_routes.py index 4f7bbe4..9dd8c77 100644 --- a/app/routes/login_routes.py +++ b/app/routes/login_routes.py @@ -1,10 +1,4 @@ -"""Authentication routes and login-related helper functions. - -This module handles user registration, login, logout, password changes, -login-attempt throttling, and authenticator-app based two-factor -authentication. It also contains the utility functions that keep these flows -consistent across the different route handlers. -""" +"""Authentication routes and security helpers for account access.""" import base64 import binascii @@ -24,10 +18,11 @@ from .. import db from ..models.login_attempt import LoginAttempt from ..models.user import User -# Blueprint that groups all authentication and account-security routes. +# The blueprint keeps all account-related routes in one security-focused module. login_bp = Blueprint("login", __name__) -# constants +# Central limits are defined once so registration, login, and settings all +# enforce the same rules and do not drift apart over time. MIN_PASSWORD_LENGTH = 8 MAX_PASSWORD_LENGTH = 128 MAX_LOGIN_FAILURES = 4 @@ -41,13 +36,16 @@ USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{3,20}$") DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protection") +# ----------------------------------------------------------------------------- +# General helper functions + def utcnow(): - """Return the current UTC time as datetime.""" + """Return the current UTC time for security-related comparisons.""" return datetime.utcnow() def get_utc_timestamp(moment=None): - """Convert a datetime into a Unix timestamp in UTC seconds.""" + """Convert a datetime into UTC seconds for time-based calculations.""" if moment is None: return int(datetime.now(timezone.utc).timestamp()) @@ -58,19 +56,50 @@ def get_utc_timestamp(moment=None): def normalize_username(username): - """Trim surrounding whitespace from the username field.""" + """Normalize usernames so harmless whitespace does not affect login or signup.""" return (username or "").strip() +def lookup_user_by_username(username): + """Find usernames case-insensitively to keep login user-friendly.""" + if not username: + return None + + return User.query.filter(db.func.lower(User.username) == username.lower()).first() + + +def minutes_until(target_time): + """Round remaining lock time up so the feedback stays conservative.""" + seconds = max(1, int((target_time - utcnow()).total_seconds())) + return max(1, (seconds + 59) // 60) + + +def render_login_settings(error=None): + """Render login settings with the current 2FA setup state included.""" + pending_secret = get_pending_two_factor_setup_secret(current_user) + formatted_secret = format_two_factor_secret(pending_secret) if pending_secret else None + provisioning_uri = build_totp_uri(current_user, pending_secret) if pending_secret else None + + return render_template( + "login_settings.html", + error=error, + pending_two_factor_secret=formatted_secret, + pending_two_factor_uri=provisioning_uri, + ) + + +# ----------------------------------------------------------------------------- +# 1. Password enforcement + def validate_username(username): - """Return an error message if the username violates the allowed pattern.""" + """Reject usernames that would not fit the project's account policy.""" if not USERNAME_PATTERN.fullmatch(username): return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores." return None def validate_password(password): - """Return an error message if the password length is outside the allowed range.""" + """Reject passwords that fall outside the shared account policy.""" if len(password) < MIN_PASSWORD_LENGTH: return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long." @@ -80,8 +109,11 @@ def validate_password(password): return None +# ----------------------------------------------------------------------------- +# 2. Anti brute force script + def get_request_ip(): - """Determine the client IP address, preferring a reverse-proxy header when present.""" + """Identify the request source so repeated failed attempts can be throttled.""" forwarded_for = request.headers.get("X-Forwarded-For", "") if forwarded_for: @@ -91,7 +123,7 @@ def get_request_ip(): def get_or_create_login_attempt(): - """Fetch or create the failed-login counter for the current IP address.""" + """Persist failed-attempt state so throttling survives across requests.""" ip_address = get_request_ip() attempt = LoginAttempt.query.filter_by(ip_address=ip_address).first() @@ -103,14 +135,14 @@ def get_or_create_login_attempt(): def clear_expired_lock(attempt, now): - """Reset the failure counter when a temporary lockout has expired.""" + """Remove stale lockouts so temporary blocks do not outlive their purpose.""" if attempt.locked_until and attempt.locked_until <= now: attempt.failed_attempts = 0 attempt.locked_until = None def get_active_lock(): - """Return the active lockout time for the current IP address.""" + """Check whether the current IP is still inside a lockout window.""" now = utcnow() attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first() @@ -118,13 +150,12 @@ def get_active_lock(): return None clear_expired_lock(attempt, now) - db.session.commit() return attempt.locked_until def record_failed_login(): - """Increase the failed-login counter for this IP and lock it if needed.""" + """Increase the failure counter so repeated guessing becomes expensive.""" now = utcnow() attempt = get_or_create_login_attempt() clear_expired_lock(attempt, now) @@ -139,7 +170,7 @@ def record_failed_login(): def clear_login_attempts(): - """Remove failed-login attempts for this IP after a successful login.""" + """Reset the throttle state after a legitimate login succeeds.""" attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first() if attempt: @@ -148,44 +179,33 @@ def clear_login_attempts(): db.session.commit() -def lookup_user_by_username(username): - """Look up a user case-insensitively so login is not case-sensitive.""" - if not username: - return None - - return User.query.filter(db.func.lower(User.username) == username.lower()).first() - - -def minutes_until(target_time): - """Return the remaining lockout time in rounded-up whole minutes.""" - seconds = max(1, int((target_time - utcnow()).total_seconds())) - return max(1, (seconds + 59) // 60) - +# ----------------------------------------------------------------------------- +# 3. 2-factor authentication def normalize_two_factor_secret(secret): - """Normalize a Base32 secret by removing spaces and uppercasing it.""" + """Normalize shared secrets so formatting differences do not break validation.""" return (secret or "").strip().replace(" ", "").upper() def generate_two_factor_secret(): - """Generate a new random Base32 secret for authenticator apps.""" + """Generate a fresh secret so every account has its own 2FA seed.""" return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=") def format_two_factor_secret(secret): - """Insert spaces into the secret to make manual entry easier.""" + """Group the secret for humans because setup may happen by manual typing.""" normalized = normalize_two_factor_secret(secret) return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4)) def clear_pending_two_factor(user=None): - """Remove the session values used during the login 2FA verification step.""" + """Clear unfinished login verification state from the session.""" session.pop("pending_2fa_user_id", None) session.pop("pending_2fa_attempts", None) def get_pending_two_factor_setup_secret(user): - """Return the temporary secret generated during 2FA setup for this user.""" + """Return only the setup secret that belongs to the current account.""" if session.get("pending_2fa_setup_user_id") != user.id: return None @@ -194,13 +214,13 @@ def get_pending_two_factor_setup_secret(user): def clear_pending_two_factor_setup(): - """Remove the session values used during authenticator setup.""" + """Remove unfinished authenticator setup state from the session.""" session.pop("pending_2fa_setup_user_id", None) session.pop("pending_2fa_setup_secret", None) def build_totp_uri(user, secret): - """Build the otpauth URI consumed by authenticator applications.""" + """Create the otpauth URI so authenticator apps can import the account.""" normalized_secret = normalize_two_factor_secret(secret) label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}") issuer = quote(TWO_FACTOR_ISSUER) @@ -212,14 +232,14 @@ def build_totp_uri(user, secret): def decode_two_factor_secret(secret): - """Decode the Base32 secret into raw bytes for HMAC processing.""" + """Decode the stored Base32 secret into raw bytes for code generation.""" normalized_secret = normalize_two_factor_secret(secret) padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8)) return base64.b32decode(padded_secret, casefold=True) def generate_totp_code(secret, for_time=None): - """Generate a time-based one-time password from the shared secret.""" + """Generate a code locally so the app can verify authenticator input itself.""" timestamp = get_utc_timestamp(for_time) counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS key = decode_two_factor_secret(secret) @@ -232,7 +252,7 @@ def generate_totp_code(secret, for_time=None): def verify_totp_code(secret, code, at_time=None): - """Validate a 2FA code, allowing a small time window for clock drift.""" + """Allow a small time drift so valid users are not locked out unnecessarily.""" normalized_code = (code or "").strip() if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS: @@ -253,7 +273,7 @@ def verify_totp_code(secret, code, at_time=None): def get_pending_two_factor_user(): - """Load the user who is currently in the middle of the login 2FA step.""" + """Load the account that is currently waiting for login verification.""" user_id = session.get("pending_2fa_user_id") if not user_id: @@ -262,23 +282,12 @@ def get_pending_two_factor_user(): return db.session.get(User, user_id) -def render_login_settings(error=None): - """Render the login settings page with optional 2FA setup data.""" - pending_secret = get_pending_two_factor_setup_secret(current_user) - formatted_secret = format_two_factor_secret(pending_secret) if pending_secret else None - provisioning_uri = build_totp_uri(current_user, pending_secret) if pending_secret else None - - return render_template( - "login_settings.html", - error=error, - pending_two_factor_secret=formatted_secret, - pending_two_factor_uri=provisioning_uri, - ) - +# ----------------------------------------------------------------------------- +# Routes @login_bp.route("/register", methods=["GET", "POST"]) def register(): - """Create a new user account after validating the submitted credentials.""" + """Create a user account after validating the configured account rules.""" if current_user.is_authenticated: return redirect(url_for("main.dashboard")) @@ -298,6 +307,7 @@ def register(): if lookup_user_by_username(username): return render_template("sign_up.html", error="Username already taken!", username=username) + # Passwords are stored hashed so a leaked database does not reveal them directly. new_user = User(username=username, password=generate_password_hash(password)) db.session.add(new_user) db.session.commit() @@ -310,7 +320,7 @@ def register(): @login_bp.route("/login", methods=["GET", "POST"]) def login(): - """Authenticate a user and start the 2FA step when required.""" + """Authenticate a user and branch into 2FA only when required.""" if current_user.is_authenticated: return redirect(url_for("main.dashboard")) @@ -321,7 +331,7 @@ def login(): if not username or not password: return render_template("login.html", error="Username and password are required.", username=username) - # Stop early if this IP address is currently locked. + # Locked IPs are rejected early so the expensive password check is not repeated. locked_until = get_active_lock() if locked_until and locked_until > utcnow(): @@ -332,8 +342,8 @@ def login(): ) user = lookup_user_by_username(username) - # Use a dummy hash when the user does not exist so the timing stays - # similar and does not reveal valid usernames. + + # A dummy hash keeps timing similar even when the username does not exist. password_hash = user.password if user else DUMMY_PASSWORD_HASH password_matches = check_password_hash(password_hash, password) @@ -351,7 +361,7 @@ def login(): clear_login_attempts() if user.two_factor_enabled: - # Delay the full login until the authenticator code has been verified. + # The actual login is delayed until the second factor proves account ownership. if not user.two_factor_secret: return render_template( "login.html", @@ -376,7 +386,7 @@ def login(): @login_bp.route("/login/verify", methods=["GET", "POST"]) def verify_two_factor(): - """Complete login by verifying the authenticator-app code.""" + """Finish login by validating the authenticator app code.""" if current_user.is_authenticated: return redirect(url_for("main.dashboard")) @@ -397,7 +407,7 @@ def verify_two_factor(): attempts = int(session.get("pending_2fa_attempts", 0)) - # Limit repeated guesses during the second factor step as well. + # The second factor gets its own retry limit so guessing stays impractical. if attempts >= MAX_TWO_FACTOR_ATTEMPTS: clear_pending_two_factor(user) db.session.commit() @@ -447,8 +457,7 @@ def login_settings(): if current_user.two_factor_enabled: return render_login_settings(error="Authenticator app 2FA is already enabled.") - # Store the generated secret in the session until the user confirms - # it with a valid authenticator code. + # The secret stays temporary until the user proves the app was configured correctly. secret = generate_two_factor_secret() session["pending_2fa_setup_user_id"] = current_user.id session["pending_2fa_setup_secret"] = secret @@ -491,6 +500,7 @@ def login_settings(): new_password = request.form.get("new_password") or "" confirm_password = request.form.get("confirm_password") or "" + # The old password is checked first so nobody can change credentials from a stolen session alone. if not check_password_hash(current_user.password, current_password): return render_login_settings(error="Your current password is incorrect.") @@ -515,7 +525,7 @@ def login_settings(): @login_bp.route("/logout", methods=["POST"]) @login_required def logout(): - """Log the user out and clear any unfinished authentication state.""" + """Log the user out and clear unfinished security state.""" clear_pending_two_factor() clear_pending_two_factor_setup() logout_user() diff --git a/app/routes/main_routes.py b/app/routes/main_routes.py index ed556fd..32d9fea 100644 --- a/app/routes/main_routes.py +++ b/app/routes/main_routes.py @@ -32,6 +32,8 @@ def build_board_from_positions(piece_positions, board_size): """Convert the legacy one-piece-per-row format into a board matrix.""" board = create_empty_board(board_size) + # Older save points may only store one column per row, so they are rebuilt + # here to keep the rest of the code on one consistent board format. for row, col in enumerate(piece_positions): if isinstance(col, int) and 0 <= col < board_size: board[row][col] = 1 @@ -47,6 +49,8 @@ def normalize_board(candidate_board, board_size): if all(isinstance(row, list) and len(row) == board_size for row in candidate_board): normalized_board = create_empty_board(board_size) + # Values are normalized to 0 or 1 so saved data stays tolerant toward + # older or slightly different truthy formats. for row_index, row in enumerate(candidate_board): for col_index, value in enumerate(row): normalized_board[row_index][col_index] = 1 if value else 0 @@ -120,6 +124,7 @@ def solve_queens(board_size): def place_queen(row): """Backtracking search that fills the board row by row.""" if row == board_size: + # A copy is stored because the same list is reused while backtracking. queen_solutions.append(queen_positions[:]) return @@ -136,6 +141,9 @@ def solve_queens(board_size): def solve_rooks(board_size): """Return one valid rook arrangement with exactly one rook per row/column.""" rook_positions = list(range(board_size)) + + # Randomizing the order keeps rook mode from always returning the same + # visually predictable arrangement. random.shuffle(rook_positions) return rook_positions @@ -251,6 +259,8 @@ def serialize_game_state(game_state): """Convert a database model into JSON-friendly data for the frontend.""" board = normalize_board(json.loads(game_state.board), game_state.board_size) + # The frontend receives ready-to-render metadata here so it does not need + # to re-derive common values from raw database fields. return { "id": game_state.id, "board": board, @@ -294,6 +304,7 @@ def build_save_point_query(): "favorites": GameState.is_favorite.desc() } + # Sorting is centralized here so list views and reloads stay consistent. return query.order_by(sort_options.get(sort_by, GameState.created_at.desc()), GameState.id.desc()) @@ -308,6 +319,9 @@ def home(): @login_required def dashboard(): """Render the logged-in dashboard.""" + + # The flag is consumed once so only the immediate next dashboard load starts + # from the default state after a fresh login. force_default_dashboard_mode = bool(session.pop("start_dashboard_with_default_mode", False)) return render_template( "dashboard.html", @@ -342,6 +356,8 @@ def check(): if current_row == row and current_col == col: continue + # The same conflict rules are shared between manual placement and the + # solver so the UI behaves consistently with the backend. same_row = current_row == row same_column = current_col == col same_diagonal = abs(current_col - col) == abs(current_row - row) @@ -474,7 +490,7 @@ def load_game(): .first() ) - # If the user has never saved a game, return a clean default board. + # Returning a default board keeps the dashboard usable even before the first save. if not latest_game: return jsonify({"board": create_empty_board(DEFAULT_BOARD_SIZE), "mode": "queens"}) diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..81c9f69 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,68 @@ +import sys +from pathlib import Path + +import pytest +from werkzeug.security import generate_password_hash + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app import create_app, db +from app.models.user import User + + +@pytest.fixture +def app_instance(tmp_path): + db_path = Path(tmp_path) / "test_app.db" + app = create_app( + { + "TESTING": True, + "SECRET_KEY": "test-secret-key", + "SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}", + } + ) + + with app.app_context(): + db.drop_all() + db.create_all() + + yield app + + with app.app_context(): + db.session.remove() + db.drop_all() + + +@pytest.fixture +def client(app_instance): + return app_instance.test_client() + + +@pytest.fixture +def user_factory(app_instance): + def _create_user(username="alice", password="Password123!", **extra_fields): + with app_instance.app_context(): + user = User( + username=username, + password=generate_password_hash(password), + **extra_fields, + ) + db.session.add(user) + db.session.commit() + return user.id + + return _create_user + + +@pytest.fixture +def login_as(client): + def _login(username="alice", password="Password123!"): + return client.post( + "/login", + data={"username": username, "password": password}, + follow_redirects=False, + ) + + return _login diff --git a/test/durchführung.md b/test/durchführung.md new file mode 100644 index 0000000..568c15c --- /dev/null +++ b/test/durchführung.md @@ -0,0 +1,61 @@ +# Testdurchführung + +## Durchführungsrahmen + +- Datum: 2026-04-24 +- Ausführung auf zwei Ebenen: + - Unit-Tests + - Integrations-/Routentests +- Werkzeug: `pytest 8.3.4` +- Testkommando: + +```bash +PYTHONDONTWRITEBYTECODE=1 /tmp/acht-damen-test-venv/bin/pytest -q test +``` + +## Bewertung der durchgeführten Testfälle + +| ID | Testfall | Ebene | Erwartung | Resultat | Bewertung | Nachvollziehbare Bewertung | +| --- | --- | --- | --- | --- | --- | --- | +| U-MAIN-01 | Brettgrösse wird begrenzt | Unit | Grenzwerte werden korrekt behandelt | Bestanden | OK | Kleine, grosse und ungültige Werte wurden korrekt auf den erlaubten Bereich abgebildet | +| U-MAIN-02 | Favoriten-Flag wird erkannt | Unit | Typische truthy Werte werden korrekt erkannt | Bestanden | OK | Die Hilfsfunktion unterschied erfolgreich zwischen gültigen truthy und anderen Werten | +| U-MAIN-03 | Nicht-negative Ganzzahlen werden abgesichert | Unit | Negative und ungültige Werte werden abgefangen | Bestanden | OK | Negative Werte wurden auf 0 begrenzt, ungültige Eingaben fielen auf den Default zurück | +| U-MAIN-04 | Legacy-Brett wird umgewandelt | Unit | Positionsliste wird zur Matrix | Bestanden | OK | Altes Speicherformat konnte verlustfrei in das aktülle Format übernommen werden | +| U-MAIN-05 | Matrixwerte werden normalisiert | Unit | Nur 0/1 bleiben übrig | Bestanden | OK | Truthy- und falsy-Werte wurden korrekt vereinheitlicht | +| U-MAIN-06 | Damen-Solver für 4x4 | Unit | Gültige Standardlösungen werden gefunden | Bestanden | OK | Der Solver lieferte die fachlich erwarteten zwei 4x4-Lösungen | +| U-MAIN-07 | Turm-Trace ist vollständig | Unit | Pro Reihe ein Schritt plus Abschluss | Bestanden | OK | Der Trace enthielt die erwartete Struktur für das Frontend | +| U-LOGIN-01 | Gültiger Username | Unit | Gültiger Name wird akzeptiert | Bestanden | OK | Ein Name im erlaubten Format wurde ohne Fehlermeldung akzeptiert | +| U-LOGIN-02 | Ungültiger Username | Unit | Fehlerhafte Zeichen werden abgelehnt | Bestanden | OK | Ein Name mit Leerzeichen/Sonderzeichen wurde korrekt abgewiesen | +| U-LOGIN-03 | Zu kurzes Passwort | Unit | Mindestlänge wird erzwungen | Bestanden | OK | Die Funktion lieferte die passende Fehlermeldung für zu kurze Passwörter | +| U-LOGIN-04 | Zu langes Passwort | Unit | Maximallänge wird erzwungen | Bestanden | OK | Auch überlange Passwörter wurden korrekt abgefangen | +| U-LOGIN-05 | 2FA-Secret-Normalisierung | Unit | Secret wird korrekt bereinigt und formatiert | Bestanden | OK | Schreibweisen mit Leerzeichen/Kleinschreibung wurden korrekt vereinheitlicht | +| U-LOGIN-06 | TOTP zur gleichen Zeit | Unit | Richtiger Code ist gültig | Bestanden | OK | Erzeugter Code wurde bei identischem Zeitpunkt akzeptiert | +| U-LOGIN-07 | TOTP mit Zeitfenster | Unit | Code bleibt im gültigen Nachbarfenster gültig | Bestanden | OK | Das erlaubte Zeitfenster funktionierte wie vorgesehen | +| U-LOGIN-08 | Fehlerhafte TOTP-Eingabe | Unit | Falsch formatierte Codes werden abgelehnt | Bestanden | OK | Nicht numerische oder zu kurze Eingaben wurden nicht akzeptiert | +| I-APP-01 | Dashboard ohne Login | Integration | Weiterleitung auf Login | Bestanden | OK | Die geschützte Route war ohne Authentifizierung nicht direkt erreichbar | +| I-APP-02 | Registrierung | Integration | Benutzer wird erstellt und umgeleitet | Bestanden | OK | Nach dem POST war der Benutzer in der Testdatenbank vorhanden | +| I-APP-03 | Login bei gesperrter IP | Integration | Sperrmeldung statt Login | Bestanden | OK | Die Anti-Brute-Force-Logik blockierte den Login im gesperrten Zeitraum korrekt | +| I-APP-04 | Dame vs. Turm bei Diagonale | Integration | Dame ungültig, Turm gültig | Bestanden | OK | Die Route `/check` setzte die unterschiedlichen Fachregeln korrekt um | +| I-APP-05 | Speichern und Laden | Integration | Spielstand bleibt konsistent | Bestanden | OK | Gespeicherter Spielstand konnte mit denselben Daten wieder geladen werden | +| I-APP-06 | Favoriten filtern | Integration | Nur markierte Save-Points erscheinen im Filter | Bestanden | OK | Das Umschalten des Favoritenstatus wirkte sich korrekt auf die Filterliste aus | + +## Ergebnis + +- Anzahl Testebenen: 2 +- Anzahl dokumentierter Testfälle: 21 +- Bestandene Testfälle: 21 +- Nicht bestandene Testfälle: 0 + +Die Testdurchführung zeigt, dass die zentrale Fachlogik, die +Authentifizierungslogik und die wichtigsten Datenflüsse der Anwendung für die +Abgabe nachvollziehbar und erfolgreich überprüft wurden. + +## Hinweis zu Warnungen + +Beim Testlauf traten nur Warnungen auf, aber keine Fehler: + +- DeprecationWarnings zu `datetime.utcnow()` in bestehendem Anwendungscode +- Pytest-Cache-Warnungen wegen schreibgeschütztem Dateisystem im Projektpfad + +Diese Warnungen haben das Ergebnis der Testfälle nicht beeinflusst. Alle +geplanten und ausgeführten Tests wurden bestanden. diff --git a/test/test_app_integration.py b/test/test_app_integration.py new file mode 100644 index 0000000..a455f7e --- /dev/null +++ b/test/test_app_integration.py @@ -0,0 +1,158 @@ +from datetime import timedelta + +from app import db +from app.models.gamestate import GameState +from app.models.login_attempt import LoginAttempt +from app.models.user import User +from app.routes.login_routes import utcnow + + +def test_dashboard_requires_login(client): + response = client.get("/dashboard") + + assert response.status_code == 302 + assert "/login" in response.headers["Location"] + + +def test_register_creates_user_and_redirects_to_login(client, app_instance): + response = client.post( + "/register", + data={"username": "newuser", "password": "Password123!"}, + follow_redirects=False, + ) + + assert response.status_code == 302 + assert "/login" in response.headers["Location"] + + with app_instance.app_context(): + saved_user = User.query.filter_by(username="newuser").first() + assert saved_user is not None + + +def test_login_blocks_requests_from_a_locked_ip(client, app_instance, user_factory): + user_factory(username="lockeduser", password="Password123!") + + with app_instance.app_context(): + db.session.add( + LoginAttempt( + ip_address="203.0.113.10", + failed_attempts=4, + locked_until=utcnow() + timedelta(minutes=5), + ) + ) + db.session.commit() + + response = client.post( + "/login", + data={"username": "lockeduser", "password": "Password123!"}, + headers={"X-Forwarded-For": "203.0.113.10"}, + ) + + assert response.status_code == 200 + assert b"Too many failed login attempts" in response.data + + +def test_check_endpoint_applies_different_rules_for_queens_and_rooks(client): + board = [ + [1, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ] + + queens_response = client.post( + "/check", + json={"board": board, "row": 1, "col": 1, "mode": "queens"}, + ) + rooks_response = client.post( + "/check", + json={"board": board, "row": 1, "col": 1, "mode": "rooks"}, + ) + + assert queens_response.status_code == 200 + assert rooks_response.status_code == 200 + assert queens_response.get_json() == {"valid": False} + assert rooks_response.get_json() == {"valid": True} + + +def test_authenticated_user_can_save_and_load_a_game_state(client, app_instance, user_factory, login_as): + user_factory(username="alice", password="Password123!") + + login_response = login_as("alice", "Password123!") + assert login_response.status_code == 302 + + payload = { + "board": [ + [0, 1, 0, 0], + [0, 0, 0, 1], + [1, 0, 0, 0], + [0, 0, 1, 0], + ], + "mode": "queens", + "boardSize": 4, + "saveName": "test-save", + "saveNote": "unit integration save", + "isFavorite": False, + "stepCount": 7, + "elapsedSeconds": 42, + "isSolved": True, + } + + save_response = client.post("/save", json=payload) + assert save_response.status_code == 200 + + saved_data = save_response.get_json()["save_point"] + load_response = client.get(f"/load/{saved_data['id']}") + + assert load_response.status_code == 200 + loaded_data = load_response.get_json() + assert loaded_data["board"] == payload["board"] + assert loaded_data["mode"] == payload["mode"] + assert loaded_data["step_count"] == payload["stepCount"] + assert loaded_data["elapsed_seconds"] == payload["elapsedSeconds"] + assert loaded_data["is_solved"] is True + + with app_instance.app_context(): + assert GameState.query.count() == 1 + + +def test_favorite_toggle_changes_filtered_save_point_list(client, user_factory, login_as): + user_factory(username="favuser", password="Password123!") + + login_response = login_as("favuser", "Password123!") + assert login_response.status_code == 302 + + save_response = client.post( + "/save", + json={ + "board": [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + "mode": "rooks", + "boardSize": 4, + "saveName": "favorite-candidate", + "saveNote": "", + "isFavorite": False, + "stepCount": 2, + "elapsedSeconds": 5, + "isSolved": False, + }, + ) + + save_id = save_response.get_json()["save_point"]["id"] + + filtered_before = client.get("/save_points?favorites_only=true") + assert filtered_before.status_code == 200 + assert filtered_before.get_json() == [] + + toggle_response = client.post(f"/save_points/{save_id}/favorite") + assert toggle_response.status_code == 200 + assert toggle_response.get_json()["is_favorite"] is True + + filtered_after = client.get("/save_points?favorites_only=true") + assert filtered_after.status_code == 200 + assert len(filtered_after.get_json()) == 1 + assert filtered_after.get_json()[0]["id"] == save_id diff --git a/test/test_login_routes_unit.py b/test/test_login_routes_unit.py new file mode 100644 index 0000000..bfb7ed9 --- /dev/null +++ b/test/test_login_routes_unit.py @@ -0,0 +1,57 @@ +from datetime import datetime, timedelta, timezone + +from app.routes.login_routes import ( + format_two_factor_secret, + generate_totp_code, + normalize_two_factor_secret, + validate_password, + validate_username, + verify_totp_code, +) + + +def test_validate_username_accepts_supported_format(): + assert validate_username("valid_user-01") is None + + +def test_validate_username_rejects_unsupported_characters(): + assert validate_username("bad user!") is not None + + +def test_validate_password_rejects_too_short_passwords(): + assert validate_password("short") == "Password must be at least 8 characters long." + + +def test_validate_password_rejects_too_long_passwords(): + assert validate_password("a" * 129) == "Password must be at most 128 characters long." + + +def test_normalize_and_format_two_factor_secret_prepare_manual_input(): + secret = "jbsw y3dp ehpk 3pxp" + + assert normalize_two_factor_secret(secret) == "JBSWY3DPEHPK3PXP" + assert format_two_factor_secret(secret) == "JBSW Y3DP EHPK 3PXP" + + +def test_verify_totp_code_accepts_code_generated_for_same_time(): + secret = "JBSWY3DPEHPK3PXP" + moment = datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc) + code = generate_totp_code(secret, moment) + + assert verify_totp_code(secret, code, moment) is True + + +def test_verify_totp_code_accepts_code_in_adjacent_time_window(): + secret = "JBSWY3DPEHPK3PXP" + moment = datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc) + code = generate_totp_code(secret, moment) + + assert verify_totp_code(secret, code, moment + timedelta(seconds=30)) is True + + +def test_verify_totp_code_rejects_malformed_code_input(): + secret = "JBSWY3DPEHPK3PXP" + moment = datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc) + + assert verify_totp_code(secret, "12ab56", moment) is False + assert verify_totp_code(secret, "12345", moment) is False diff --git a/test/test_main_routes_unit.py b/test/test_main_routes_unit.py new file mode 100644 index 0000000..6ff52f6 --- /dev/null +++ b/test/test_main_routes_unit.py @@ -0,0 +1,65 @@ +from app.routes.main_routes import ( + build_rooks_trace, + count_placed_pieces, + normalize_board, + parse_board_size, + parse_favorite_flag, + parse_non_negative_int, + solve_queens, +) + + +def test_parse_board_size_clamps_small_large_and_invalid_values(): + assert parse_board_size("2") == 4 + assert parse_board_size("99") == 13 + assert parse_board_size("abc") == 8 + + +def test_parse_favorite_flag_accepts_common_truthy_values(): + assert parse_favorite_flag("true") is True + assert parse_favorite_flag("1") is True + assert parse_favorite_flag("on") is True + assert parse_favorite_flag("false") is False + + +def test_parse_non_negative_int_clamps_negative_and_invalid_values(): + assert parse_non_negative_int("-7") == 0 + assert parse_non_negative_int("15") == 15 + assert parse_non_negative_int("abc", default=3) == 3 + + +def test_normalize_board_converts_legacy_position_list_into_matrix(): + normalized = normalize_board([1, 3, 0, 2], 4) + + assert normalized == [ + [0, 1, 0, 0], + [0, 0, 0, 1], + [1, 0, 0, 0], + [0, 0, 1, 0], + ] + + +def test_normalize_board_reduces_matrix_values_to_zero_or_one(): + normalized = normalize_board([[0, 2], [True, None]], 2) + + assert normalized == [[0, 1], [1, 0]] + + +def test_solve_queens_returns_the_two_expected_solutions_for_4x4(): + solutions = {tuple(solution) for solution in solve_queens(4)} + + assert solutions == { + (1, 3, 0, 2), + (2, 0, 3, 1), + } + + +def test_build_rooks_trace_contains_one_placement_per_row_plus_solution(): + trace = build_rooks_trace(5) + placement_steps = [step for step in trace["steps"] if step["type"] == "place"] + + assert trace["solved"] is True + assert len(trace["steps"]) == 6 + assert len(placement_steps) == 5 + assert sorted(step["col"] for step in placement_steps) == [0, 1, 2, 3, 4] + assert count_placed_pieces(trace["steps"][-1]["board"]) == 5 diff --git a/test/testkonzept.md b/test/testkonzept.md new file mode 100644 index 0000000..9d4fbe1 --- /dev/null +++ b/test/testkonzept.md @@ -0,0 +1,83 @@ +# Testkonzept + +## Ziel + +Dieses Testkonzept beschreibt, wie die wichtigsten Funktionen der Webanwendung +vor der Abgabe ueberprueft werden. Der Fokus liegt auf den fachlich und +technisch kritischen Bereichen: + +- Brettlogik fuer Damen und Tuerme +- Authentifizierung und Sicherheitsfunktionen +- Speichern, Laden und Filtern von Spielständen + +## Randbedingungen / Umfeld + +- Projekt: 8-Damen- und 8-Tuerme Solver +- Testdatum Planung: 2026-04-24 +- Betriebssystem: Linux 6.6.87.2-microsoft-standard-WSL2 x86_64 +- Python-Version: 3.12.3 +- Testwerkzeug: pytest 8.3.4 +- Webtestmittel: Flask Test Client +- Datenbank im Test: isolierte SQLite-Testdatenbank pro Testlauf +- Testordner: `test/` + +Die Tests werden absichtlich getrennt von der produktiven Datenbank +durchgefuehrt. Dadurch wird verhindert, dass echte Benutzerdaten oder +Spielstände verändert werden. + +## Testmittel und Methoden + +Es werden zwei voneinander unabhängige Testebenen eingesetzt: + +1. Unit-Tests + Diese prüfen einzelne Funktionen direkt, zum Beispiel Eingabevalidierung, + Brettnormalisierung oder TOTP-Prüfung. +2. Integrations- und Routentests + Diese prüfen das Zusammenspiel mehrerer Komponenten über HTTP-Routen, zum + Beispiel Registrierung, Login, Validierung, Speichern und Laden. + +Eingesetzte Mittel: + +- `pytest` für die Testausführung und Bewertung +- Flask Test Client für Request/Response-Tests +- temporäre SQLite-Datenbank für isolierte Testdaten + +## Testfälle (Drehbuch) + +| ID | Ebene | Testziel | Testmethode / Eingabe | Erwartetes Resultat | +| --- | --- | --- | --- | --- | +| U-MAIN-01 | Unit | Brettgroesse absichern | `parse_board_size()` mit zu kleinen, zu grossen und ungültigen Werten | Werte werden korrekt auf den erlaubten Bereich begrenzt | +| U-MAIN-02 | Unit | Favoriten-Flag normalisieren | `parse_favorite_flag()` mit typischen truthy/falsy Werten | Nur definierte truthy Werte werden als `True` interpretiert | +| U-MAIN-03 | Unit | Ganze Zahlen robust verarbeiten | `parse_non_negative_int()` mit negativen und ungültigen Werten | Negative Werte werden auf 0 begrenzt, ungültige Werte fallen auf den Default zurück | +| U-MAIN-04 | Unit | Alte Speicherformate unterstützen | `normalize_board()` mit Legacy-Positionsliste | Es entsteht ein korrektes Brettmatrix-Format | +| U-MAIN-05 | Unit | Uneinheitliche Matrixwerte normalisieren | `normalize_board()` mit truthy/falsy Werten | Es entstehen nur 0- und 1-Werte | +| U-MAIN-06 | Unit | Damen-Solver fachlich absichern | `solve_queens(4)` | Es werden genau die gültigen 4x4-Loesungen gefunden | +| U-MAIN-07 | Unit | Turm-Trace prüfen | `build_rooks_trace(5)` | Es gibt pro Reihe genau einen Platzierungsschritt plus Abschluss | +| U-LOGIN-01 | Unit | Gültigen Username akzeptieren | `validate_username()` mit gültigem Namen | Rückgabe ist `None` | +| U-LOGIN-02 | Unit | Ungültigen Username ablehnen | `validate_username()` mit unerlaubten Zeichen | Es wird eine Fehlermeldung geliefert | +| U-LOGIN-03 | Unit | Zu kurzes Passwort ablehnen | `validate_password()` mit zu kurzem Passwort | Es wird die Mindestlängen-Fehlermeldung geliefert | +| U-LOGIN-04 | Unit | Zu langes Passwort ablehnen | `validate_password()` mit zu langem Passwort | Es wird die Maximalfehler-Meldung geliefert | +| U-LOGIN-05 | Unit | 2FA-Secret vereinheitlichen | `normalize_two_factor_secret()` und `format_two_factor_secret()` | Secret wird korrekt bereinigt und formatiert | +| U-LOGIN-06 | Unit | TOTP im Normalfall prüfen | `generate_totp_code()` + `verify_totp_code()` zur gleichen Zeit | Gültiger Code wird akzeptiert | +| U-LOGIN-07 | Unit | TOTP mit Zeitfenster prüfen | Code wird mit 30 Sekunden Abweichung geprüft | Gültiger Code wird wegen Validierungsfenster akzeptiert | +| U-LOGIN-08 | Unit | Fehlerhafte TOTP-Eingabe ablehnen | `verify_totp_code()` mit falschem Format | Ungültige Codes werden abgelehnt | +| I-APP-01 | Integration | Schutz geschützter Seite prüfen | `GET /dashboard` ohne Login | Weiterleitung auf Login | +| I-APP-02 | Integration | Registrierung prüfen | `POST /register` mit gültigen Daten | Benutzer wird erstellt und Weiterleitung auf Login erfolgt | +| I-APP-03 | Integration | Anti-Brute-Force prüfen | `POST /login` mit gesperrter IP | Login wird mit Sperrmeldung abgelehnt | +| I-APP-04 | Integration | Fachregel für Brett prüfen | `POST /check` für Dame und Turm auf diagonalem Feld | Dame: ungültig, Turm: gültig | +| I-APP-05 | Integration | Speichern und Laden prüfen | Login, danach `POST /save` und `GET /load/` | Spielstand wird korrekt gespeichert und identisch geladen | +| I-APP-06 | Integration | Favoritenlogik prüfen | Login, Save-Point speichern, Favorit toggeln, gefiltert abfragen | Filter liefert nur entsprechend markierte Save-Points | + +## Erwartete Ergebnisse + +- Alle Unit-Tests müssen erfolgreich durchlaufen. +- Alle Integrations-Tests müssen erfolgreich durchlaufen. +- Keine produktiven Daten dürfen verändert werden. +- Fehler müssen in `pytest` klar sichtbar sein. +- Die Suite umfasst 21 konkrete Pytest-Testfälle auf zwei Ebenen. + +## Durchführung + +Die geplanten Tests werden mit `pytest` aus dem Projektwurzelverzeichnis +ausgeführt. Die konkrete Bewertung und das Resultat werden in +`test/testkonzept.md` festgehalten.