Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d182dc1c7 | |||
| 62bd333dc4 | |||
| 65a60356dd | |||
| 047ac6e239 | |||
| e57aa37623 | |||
| 7f66fa3ef4 | |||
| a4dc91e3ae |
@@ -1,6 +1,8 @@
|
||||
# acht-damen-tuerme-problem
|
||||
IDPA Arbeit im23a Kristian Anastasia und Jérôme
|
||||
|
||||
**Link to the Website**: https://bachmannj.ch/idpa/
|
||||
|
||||
```
|
||||
project-root/
|
||||
│
|
||||
|
||||
+11
-2
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,19 +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), nullable=False)
|
||||
username = db.Column(db.String(80), nullable=False)
|
||||
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)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("ip_address", "username", name="uq_login_attempt_ip_username"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LoginAttempt {self.ip_address}:{self.username}>"
|
||||
"""Return a compact identifier for debugging and logging.
|
||||
"""
|
||||
|
||||
return f"<LoginAttempt {self.ip_address}>"
|
||||
|
||||
@@ -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"<User {self.username}>"
|
||||
|
||||
+98
-110
@@ -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,14 +18,15 @@ 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 = 8
|
||||
LOGIN_LOCK_MINUTES = 15
|
||||
MAX_LOGIN_FAILURES = 4
|
||||
LOGIN_LOCK_MINUTES = 30
|
||||
TWO_FACTOR_CODE_DIGITS = 6
|
||||
TWO_FACTOR_TIME_STEP_SECONDS = 30
|
||||
TWO_FACTOR_VALID_WINDOW = 1
|
||||
@@ -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:
|
||||
@@ -90,81 +122,56 @@ def get_request_ip():
|
||||
return (request.remote_addr or "unknown")[:45]
|
||||
|
||||
|
||||
def login_attempt_buckets(username):
|
||||
"""Return the lockout buckets tracked for this login attempt.
|
||||
|
||||
The app tracks failures both per username+IP and per IP-only bucket to
|
||||
slow down brute-force attempts against many usernames from the same client.
|
||||
"""
|
||||
normalized_username = normalize_username(username).lower() or "__empty__"
|
||||
def get_or_create_login_attempt():
|
||||
"""Persist failed-attempt state so throttling survives across requests."""
|
||||
ip_address = get_request_ip()
|
||||
return [
|
||||
(ip_address, normalized_username),
|
||||
(ip_address, "__ip__"),
|
||||
]
|
||||
|
||||
|
||||
def get_or_create_attempt(ip_address, bucket_key):
|
||||
"""Fetch an existing login-attempt record or create a new one."""
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address).first()
|
||||
|
||||
if attempt is None:
|
||||
attempt = LoginAttempt(ip_address=ip_address, username=bucket_key)
|
||||
attempt = LoginAttempt(ip_address=ip_address)
|
||||
db.session.add(attempt)
|
||||
|
||||
return 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(username):
|
||||
"""Return the latest active lockout time for this username/IP combination."""
|
||||
def get_active_lock():
|
||||
"""Check whether the current IP is still inside a lockout window."""
|
||||
now = utcnow()
|
||||
active_until = None
|
||||
|
||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first()
|
||||
|
||||
if not attempt:
|
||||
continue
|
||||
return None
|
||||
|
||||
clear_expired_lock(attempt, now)
|
||||
|
||||
if attempt.locked_until and (active_until is None or attempt.locked_until > active_until):
|
||||
active_until = attempt.locked_until
|
||||
|
||||
db.session.commit()
|
||||
return active_until
|
||||
return attempt.locked_until
|
||||
|
||||
|
||||
def record_failed_login(username):
|
||||
"""Increase the failure counter and start a temporary lock when needed."""
|
||||
def record_failed_login():
|
||||
"""Increase the failure counter so repeated guessing becomes expensive."""
|
||||
now = utcnow()
|
||||
locked_until = None
|
||||
|
||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
||||
attempt = get_or_create_attempt(ip_address, bucket_key)
|
||||
attempt = get_or_create_login_attempt()
|
||||
clear_expired_lock(attempt, now)
|
||||
attempt.failed_attempts = (attempt.failed_attempts or 0) + 1
|
||||
attempt.last_failed_at = now
|
||||
|
||||
if attempt.failed_attempts >= MAX_LOGIN_FAILURES:
|
||||
attempt.locked_until = now + timedelta(minutes=LOGIN_LOCK_MINUTES)
|
||||
locked_until = attempt.locked_until
|
||||
|
||||
db.session.commit()
|
||||
return locked_until
|
||||
return attempt.locked_until
|
||||
|
||||
|
||||
def clear_login_attempts(username):
|
||||
"""Remove tracked failed attempts after a successful login."""
|
||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
||||
def clear_login_attempts():
|
||||
"""Reset the throttle state after a legitimate login succeeds."""
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first()
|
||||
|
||||
if attempt:
|
||||
db.session.delete(attempt)
|
||||
@@ -172,44 +179,33 @@ def clear_login_attempts(username):
|
||||
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
|
||||
|
||||
@@ -218,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)
|
||||
@@ -236,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)
|
||||
@@ -256,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:
|
||||
@@ -277,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:
|
||||
@@ -286,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"))
|
||||
|
||||
@@ -322,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()
|
||||
@@ -334,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"))
|
||||
|
||||
@@ -345,8 +331,8 @@ 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 user or IP bucket is currently locked.
|
||||
locked_until = get_active_lock(username)
|
||||
# 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():
|
||||
return render_template(
|
||||
@@ -356,13 +342,13 @@ 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)
|
||||
|
||||
if not user or not password_matches:
|
||||
locked_until = record_failed_login(username)
|
||||
locked_until = record_failed_login()
|
||||
error_message = "Invalid username or password."
|
||||
|
||||
if locked_until:
|
||||
@@ -372,10 +358,10 @@ def login():
|
||||
|
||||
return render_template("login.html", error=error_message, username=username)
|
||||
|
||||
clear_login_attempts(username)
|
||||
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",
|
||||
@@ -392,6 +378,7 @@ def login():
|
||||
db.session.commit()
|
||||
login_user(user)
|
||||
clear_pending_two_factor()
|
||||
session["start_dashboard_with_default_mode"] = True
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
return render_template("login.html")
|
||||
@@ -399,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"))
|
||||
|
||||
@@ -420,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()
|
||||
@@ -453,6 +440,7 @@ def verify_two_factor():
|
||||
db.session.commit()
|
||||
login_user(user)
|
||||
flash("Two-factor authentication successful.", "success")
|
||||
session["start_dashboard_with_default_mode"] = True
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
return render_template("two_factor_verify.html")
|
||||
@@ -469,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
|
||||
@@ -513,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.")
|
||||
|
||||
@@ -537,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()
|
||||
|
||||
@@ -9,7 +9,7 @@ import json
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
from flask import Blueprint, jsonify, render_template, request, session
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from .. import db
|
||||
@@ -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
|
||||
|
||||
@@ -170,7 +178,10 @@ def build_queens_trace(board_size):
|
||||
})
|
||||
return True
|
||||
|
||||
for col in range(board_size):
|
||||
candidate_columns = list(range(board_size))
|
||||
random.shuffle(candidate_columns)
|
||||
|
||||
for col in candidate_columns:
|
||||
if not is_queen_position_safe(row, col):
|
||||
continue
|
||||
|
||||
@@ -251,6 +262,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 +307,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,7 +322,15 @@ def home():
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Render the logged-in dashboard."""
|
||||
return render_template("dashboard.html", user=current_user)
|
||||
|
||||
# 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",
|
||||
user=current_user,
|
||||
force_default_dashboard_mode=force_default_dashboard_mode,
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route("/privacy_policy")
|
||||
@@ -337,6 +359,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)
|
||||
@@ -353,9 +377,14 @@ def check():
|
||||
|
||||
@main_bp.route("/solve")
|
||||
def solve_queens_route():
|
||||
"""Return all queens solutions for the requested board size."""
|
||||
"""Return one random queens solution for the requested board size."""
|
||||
board_size = parse_board_size(request.args.get("size"))
|
||||
return jsonify(solve_queens(board_size))
|
||||
queen_solutions = solve_queens(board_size)
|
||||
|
||||
if not queen_solutions:
|
||||
return jsonify([])
|
||||
|
||||
return jsonify(random.choice(queen_solutions))
|
||||
|
||||
|
||||
@main_bp.route("/solve_trace")
|
||||
@@ -469,7 +498,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"})
|
||||
|
||||
|
||||
+24
-11
@@ -5,11 +5,12 @@ const MAX_BOARD_SIZE = 13;
|
||||
const EMPTY_CELL = 0;
|
||||
const OCCUPIED_CELL = 1;
|
||||
const GAME_STORAGE_KEY = "eight-queens-dashboard-session-v1";
|
||||
const FORCE_DEFAULT_MODE = document.body?.dataset.forceDefaultMode === "true";
|
||||
|
||||
const AppLanguage = window.AppLanguage;
|
||||
|
||||
// Mutable game and solver state
|
||||
let mode = "queens";
|
||||
let mode = "rooks";
|
||||
let boardSize = DEFAULT_BOARD_SIZE;
|
||||
let pendingBoardSize = DEFAULT_BOARD_SIZE;
|
||||
let board = createEmptyBoard(boardSize);
|
||||
@@ -1328,13 +1329,13 @@ function renderTimeHistory(savePoints) {
|
||||
|
||||
async function refreshSavePoints() {
|
||||
const queryString = getSavePointFilters();
|
||||
const response = await fetch(`/save_points${queryString ? `?${queryString}` : ""}`);
|
||||
const response = await fetch(`save_points${queryString ? `?${queryString}` : ""}`);
|
||||
const savePoints = await response.json();
|
||||
renderSavePoints(savePoints);
|
||||
}
|
||||
|
||||
async function refreshTimeHistory() {
|
||||
const response = await fetch("/save_points?sort=updated");
|
||||
const response = await fetch("save_points?sort=updated");
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
@@ -1466,7 +1467,7 @@ function startSolverPlayback(stepImmediately = true) {
|
||||
// requestId prevents older async responses from overwriting a newer solver run
|
||||
// when the user changes size, mode, or solver action quickly.
|
||||
async function prepareSolutionTrace() {
|
||||
const endpoint = mode === "rooks" ? "/solve_rooks_trace" : "/solve_trace";
|
||||
const endpoint = mode === "rooks" ? "solve_rooks_trace" : "solve_trace";
|
||||
const requestId = ++solverRequestId;
|
||||
|
||||
toggleSidebar(false);
|
||||
@@ -1612,7 +1613,7 @@ async function instantSolve() {
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = mode === "rooks" ? "/solve_rooks" : "/solve";
|
||||
const endpoint = mode === "rooks" ? "solve_rooks" : "solve";
|
||||
const requestId = ++solverRequestId;
|
||||
|
||||
toggleSidebar(false);
|
||||
@@ -1665,7 +1666,7 @@ async function instantSolve() {
|
||||
// Save point persistence actions
|
||||
|
||||
async function saveGame() {
|
||||
const response = await fetch("/save", {
|
||||
const response = await fetch("save", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -1697,13 +1698,13 @@ async function saveGame() {
|
||||
}
|
||||
|
||||
async function loadGame() {
|
||||
const response = await fetch("/load");
|
||||
const response = await fetch("load");
|
||||
const data = await response.json();
|
||||
applyLoadedGame(data);
|
||||
}
|
||||
|
||||
async function loadSavePoint(savePointId) {
|
||||
const response = await fetch(`/load/${savePointId}`);
|
||||
const response = await fetch(`load/${savePointId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus(t("loadError"), "red");
|
||||
@@ -1718,7 +1719,7 @@ async function loadSavePoint(savePointId) {
|
||||
}
|
||||
|
||||
async function toggleSavePointFavorite(savePointId) {
|
||||
const response = await fetch(`/save_points/${savePointId}/favorite`, {
|
||||
const response = await fetch(`save_points/${savePointId}/favorite`, {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
@@ -1739,7 +1740,7 @@ async function deleteSavePoint(savePointId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/save_points/${savePointId}`, {
|
||||
const response = await fetch(`save_points/${savePointId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
@@ -1790,6 +1791,14 @@ function restoreSessionFromStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearStoredSession() {
|
||||
try {
|
||||
window.localStorage.removeItem(GAME_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
// Ignore unavailable storage so startup can continue.
|
||||
}
|
||||
}
|
||||
|
||||
// Boot order: translate UI, restore any local session, then load history data.
|
||||
async function initializeDashboard() {
|
||||
pendingBoardSize = boardSize;
|
||||
@@ -1799,7 +1808,11 @@ async function initializeDashboard() {
|
||||
updateSettingsTrigger(false);
|
||||
setSolverMessage(t("solverReady", { modeLabel: getModeLabel(mode) }));
|
||||
|
||||
if (!restoreSessionFromStorage()) {
|
||||
if (FORCE_DEFAULT_MODE) {
|
||||
clearStoredSession();
|
||||
}
|
||||
|
||||
if (FORCE_DEFAULT_MODE || !restoreSessionFromStorage()) {
|
||||
resetProgress();
|
||||
drawBoard();
|
||||
}
|
||||
|
||||
+14
-14
@@ -44,29 +44,29 @@ const translations = {
|
||||
dataProcessedItem2: "Daten zur Zwei-Faktor-Authentifizierung, falls diese freiwillig aktiviert wird",
|
||||
dataProcessedItem3: "gespeicherte Spielstände, Notizen, Schrittzahlen, Zeiten und Favoriten",
|
||||
dataProcessedItem4: "technische Verbindungsdaten, die durch den Server oder das Hosting entstehen koennen",
|
||||
processingPurposeTitle: "3. Zweck der Verarbeitung",
|
||||
processingPurposeTitle: "2. Zweck der Verarbeitung",
|
||||
processingPurposeText: "Die Verarbeitung erfolgt, um die Funktionen dieser Anwendung bereitzustellen. Dazu gehoeren insbesondere:",
|
||||
processingPurposeItem1: "Erstellung und Verwaltung von Benutzerkonten",
|
||||
processingPurposeItem2: "sicherer Login in das persönliche Dashboard",
|
||||
processingPurposeItem3: "optionale Absicherung des Kontos per Authenticator-App",
|
||||
processingPurposeItem4: "Speichern und Laden von Spielständen",
|
||||
processingPurposeItem5: "technischer Betrieb, Fehleranalyse und Missbrauchsschutz",
|
||||
legalBasisTitle: "4. Rechtsgrundlage",
|
||||
legalBasisTitle: "3. Rechtsgrundlage",
|
||||
legalBasisText: "Soweit personenbezogene Daten verarbeitet werden, erfolgt dies in der Regel zur Bereitstellung der Anwendung und ihrer Funktionen sowie aufgrund des berechtigten Interesses an einem sicheren und funktionsfähigen Webangebot. Falls eine ausdrückliche Einwilligung erforderlich ist, wird diese gesondert eingeholt.",
|
||||
storageTitle: "5. Speicherung und Löschung",
|
||||
storageTitle: "4. Speicherung und Löschung",
|
||||
storageText: "Personenbezogene Daten werden nur so lange gespeichert, wie sie für den jeweiligen Zweck benötigt werden. Benutzerkonten und zugehörige Spielstände bleiben grundsätzlich gespeichert, bis sie gelöscht oder für das Projekt nicht mehr benötigt werden.",
|
||||
thirdPartyTitle: "6. Weitergabe an Dritte",
|
||||
thirdPartyTitle: "5. Weitergabe an Dritte",
|
||||
thirdPartyText: "Eine Weitergabe personenbezogener Daten erfolgt nur, wenn dies für den Betrieb der Anwendung erforderlich ist, etwa durch einen Hosting-Anbieter oder technische Dienstleister. Ansonsten werden keine Daten weitergegeben.",
|
||||
rightsTitle: "7. Rechte betroffener Personen",
|
||||
rightsTitle: "6. Rechte betroffener Personen",
|
||||
rightsText: "Betroffene Personen haben im Rahmen des anwendbaren Datenschutzrechts insbesondere das Recht auf:",
|
||||
rightsItem1: "Auskunft über die verarbeiteten personenbezogenen Daten",
|
||||
rightsItem2: "Berichtigung unrichtiger Daten",
|
||||
rightsItem3: "Löschung oder Einschränkung der Verarbeitung",
|
||||
rightsItem4: "Widerspruch gegen die Verarbeitung",
|
||||
rightsItem5: "Herausgabe der bereitgestellten Daten, soweit anwendbar",
|
||||
securityTitle: "8. Datensicherheit",
|
||||
securityTitle: "7. Datensicherheit",
|
||||
securityText: "Es werden technische und organisatorische Massnahmen getroffen, um gespeicherte Daten vor Verlust, Missbrauch oder unberechtigtem Zugriff zu schützen. Dazu gehoeren insbesondere passwortgeschützte Konten und die Möglichkeit, Zwei-Faktor-Authentifizierung zu aktivieren.",
|
||||
versionTitle: "9. Stand",
|
||||
versionTitle: "8. Stand",
|
||||
versionText: "Stand dieser Vorlage: April 2026"
|
||||
}
|
||||
},
|
||||
@@ -108,29 +108,29 @@ const translations = {
|
||||
dataProcessedItem2: "two-factor authentication data if this feature is activated voluntarily",
|
||||
dataProcessedItem3: "saved game states, notes, step counts, times, and favorites",
|
||||
dataProcessedItem4: "technical connection data that may arise through the server or hosting",
|
||||
processingPurposeTitle: "3. Purpose of processing",
|
||||
processingPurposeTitle: "2. Purpose of processing",
|
||||
processingPurposeText: "The processing is carried out to provide the functions of this application. This includes in particular:",
|
||||
processingPurposeItem1: "creating and managing user accounts",
|
||||
processingPurposeItem2: "secure login to the personal dashboard",
|
||||
processingPurposeItem3: "optional account protection using an authenticator app",
|
||||
processingPurposeItem4: "saving and loading game states",
|
||||
processingPurposeItem5: "technical operation, error analysis, and abuse prevention",
|
||||
legalBasisTitle: "4. Legal basis",
|
||||
legalBasisTitle: "3. Legal basis",
|
||||
legalBasisText: "Where personal data is processed, this is generally done to provide the application and its functions, as well as on the basis of the legitimate interest in a secure and functional web offering. If explicit consent is required, it will be obtained separately.",
|
||||
storageTitle: "5. Storage and deletion",
|
||||
storageTitle: "4. Storage and deletion",
|
||||
storageText: "Personal data is stored only as long as it is needed for the respective purpose. User accounts and related game states generally remain stored until they are deleted or are no longer needed for the project.",
|
||||
thirdPartyTitle: "6. Disclosure to third parties",
|
||||
thirdPartyTitle: "5. Disclosure to third parties",
|
||||
thirdPartyText: "Personal data is shared only if this is necessary for operating the application, for example through a hosting provider or technical service providers. Otherwise, no data is shared.",
|
||||
rightsTitle: "7. Rights of data subjects",
|
||||
rightsTitle: "6. Rights of data subjects",
|
||||
rightsText: "Within the framework of applicable data protection law, data subjects have in particular the right to:",
|
||||
rightsItem1: "information about the personal data being processed",
|
||||
rightsItem2: "correction of incorrect data",
|
||||
rightsItem3: "deletion of data or restriction of processing",
|
||||
rightsItem4: "object to the processing",
|
||||
rightsItem5: "receive the data that has been provided, where applicable",
|
||||
securityTitle: "8. Data security",
|
||||
securityTitle: "7. Data security",
|
||||
securityText: "Technical and organizational measures are taken to protect stored data against loss, misuse, or unauthorized access. These include password-protected accounts and the option to enable two-factor authentication.",
|
||||
versionTitle: "9. Version",
|
||||
versionTitle: "8. Version",
|
||||
versionText: "Version of this template: April 2026"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body data-force-default-mode="{{ 'true' if force_default_dashboard_mode else 'false' }}">
|
||||
<nav class="dashboard-nav">
|
||||
<a class="brand" href="{{ url_for('main.home') }}">
|
||||
<span class="brand-mark">♛</span>
|
||||
|
||||
+2
-4
@@ -10,12 +10,10 @@ CREATE TABLE users (
|
||||
|
||||
CREATE TABLE login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
username VARCHAR(80) NOT NULL,
|
||||
ip_address VARCHAR(45) NOT NULL UNIQUE,
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until DATETIME,
|
||||
last_failed_at DATETIME,
|
||||
CONSTRAINT uq_login_attempt_ip_username UNIQUE (ip_address, username)
|
||||
last_failed_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE game_state (
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,86 @@
|
||||
# REST-API-Konzept
|
||||
|
||||
Dieses API-Konzept beschreibt die wichtigsten Schnittstellen der Anwendung zum Acht-Damen-und-Türme-Problem. Die API wird vom Dashboard verwendet, um Spielbretter zu prüfen, Loesungen zu berechnen und Spielstände zu speichern oder zu laden.
|
||||
|
||||
## Allgemeine Angaben
|
||||
|
||||
| Punkt | Beschreibung |
|
||||
|---|---|
|
||||
| Datenformat | JSON |
|
||||
| Authentifizierung | Session-Cookie nach Login |
|
||||
| Geschützte Endpunkte | Speicherstand-Funktionen benoetigen einen angemeldeten Benutzer |
|
||||
| Brettgroesse | 4 bis 13 Felder pro Seite |
|
||||
| Standard-Brettgroesse | 8 x 8 |
|
||||
| Spielmodi | `queens` für Damen, `rooks` für Türme |
|
||||
|
||||
## Solver-API
|
||||
|
||||
| Methode | Pfad | Zweck | Parameter / Body | Authentifizierung |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/check` | Prüft, ob eine Figur auf ein bestimmtes Feld gesetzt werden darf. | JSON: `board`, `row`, `col`, `mode` | Nein |
|
||||
| GET | `/solve` | Gibt Loesungen für das Damenproblem zurück. | Query: `size` | Nein |
|
||||
| GET | `/solve_trace` | Gibt eine Damen-Loesung Schritt für Schritt zurück. | Query: `size` | Nein |
|
||||
| GET | `/solve_rooks` | Gibt eine gültige Turm-Loesung zurück. | Query: `size` | Nein |
|
||||
| GET | `/solve_rooks_trace` | Gibt eine Turm-Loesung Schritt für Schritt zurück. | Query: `size` | Nein |
|
||||
|
||||
## Speicherstand-API
|
||||
|
||||
| Methode | Pfad | Zweck | Parameter / Body | Authentifizierung |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/save` | Speichert den aktuellen Spielstand des angemeldeten Benutzers. | JSON: Spielbrett, Modus, Name, Notiz, Favorit, Schritte, Zeit, geloest | Ja |
|
||||
| GET | `/save_points` | Listet alle gespeicherten Spielstände des angemeldeten Benutzers auf. | Optional: `mode`, `board_size`, `favorites_only`, `sort` | Ja |
|
||||
| GET | `/load` | Lädt den neusten gespeicherten Spielstand. | Keine | Ja |
|
||||
| GET | `/load/{id}` | Lädt einen bestimmten gespeicherten Spielstand. | Pfadparameter: `id` | Ja |
|
||||
| POST | `/save_points/{id}/favorite` | Schaltet den Favoritenstatus eines Speicherstandes um. | Pfadparameter: `id` | Ja |
|
||||
| DELETE | `/save_points/{id}` | Loescht einen bestimmten Speicherstand. | Pfadparameter: `id` | Ja |
|
||||
|
||||
## Login- und Seitenrouten
|
||||
|
||||
| Methode | Pfad | Zweck | Typ |
|
||||
|---|---|---|---|
|
||||
| GET / POST | `/register` | Neues Benutzerkonto erstellen. | Formularroute |
|
||||
| GET / POST | `/login` | Benutzer anmelden. | Formularroute |
|
||||
| GET / POST | `/login/verify` | Zwei-Faktor-Code prüfen. | Formularroute |
|
||||
| GET / POST | `/login/settings` | Passwort und Zwei-Faktor-Authentifizierung verwalten. | Formularroute |
|
||||
| POST | `/logout` | Benutzer abmelden. | Formularroute |
|
||||
| GET | `/` | Startseite anzeigen. | Seitenroute |
|
||||
| GET | `/dashboard` | Dashboard anzeigen. | Seitenroute |
|
||||
| GET | `/privacy_policy` | Datenschutzerklärung anzeigen. | Seitenroute |
|
||||
| GET | `/imprint` | Impressum anzeigen. | Seitenroute |
|
||||
|
||||
## Wichtige Request-Daten
|
||||
|
||||
| Feld | Verwendung | Beschreibung |
|
||||
|---|---|---|
|
||||
| `board` | `/check`, `/save` | Zweidimensionale Matrix des Spielbretts. `0` bedeutet leer, `1` bedeutet Figur. |
|
||||
| `row` | `/check` | Zeile des zu prüfenden Feldes. |
|
||||
| `col` | `/check` | Spalte des zu prüfenden Feldes. |
|
||||
| `mode` | `/check`, `/save`, `/save_points` | Spielmodus: `queens` oder `rooks`. |
|
||||
| `size` | Solver-Endpunkte | Gewünschte Brettgroesse. |
|
||||
| `boardSize` | `/save` | Groesse des gespeicherten Spielbretts. |
|
||||
| `saveName` | `/save` | Name des Speicherstandes. |
|
||||
| `saveNote` | `/save` | Optionale Notiz zum Speicherstand. |
|
||||
| `isFavorite` | `/save` | Gibt an, ob der Speicherstand als Favorit markiert ist. |
|
||||
| `stepCount` | `/save` | Anzahl der ausgeführten Schritte. |
|
||||
| `elapsedSeconds` | `/save` | Vergangene Spielzeit in Sekunden. |
|
||||
| `isSolved` | `/save` | Gibt an, ob das Spiel geloest wurde. |
|
||||
|
||||
## Wichtige Response-Daten
|
||||
|
||||
| Feld | Verwendung | Beschreibung |
|
||||
|---|---|---|
|
||||
| `valid` | `/check` | Gibt zurück, ob der Zug erlaubt ist. |
|
||||
| `status` | `/save`, `/save_points/{id}` | Statusmeldung, zum Beispiel `saved` oder `deleted`. |
|
||||
| `save_point` | `/save` | Neu gespeicherter Spielstand. |
|
||||
| `steps` | Trace-Endpunkte | Liste der einzelnen Loesungsschritte. |
|
||||
| `solved` | Trace-Endpunkte | Gibt an, ob eine Loesung gefunden wurde. |
|
||||
| `error` | Fehlerfälle | Fehlermeldung, zum Beispiel wenn ein Speicherstand nicht gefunden wurde. |
|
||||
|
||||
## Fehlerfälle
|
||||
|
||||
| Situation | Beispielantwort | HTTP-Status |
|
||||
|---|---|---|
|
||||
| Speicherstand existiert nicht oder gehoert einem anderen Benutzer. | `{ "error": "Save point not found" }` | 404 |
|
||||
| Benutzer ist nicht angemeldet und ruft geschützte Route auf. | Weiterleitung zur Login-Seite | 302 |
|
||||
| Ungültige Brettgroesse wird übergeben. | Wert wird auf erlaubten Bereich begrenzt. | 200 |
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,168 @@
|
||||
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_solve_endpoint_returns_one_valid_4x4_queens_solution(client):
|
||||
response = client.get("/solve?size=4")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert tuple(response.get_json()) in {
|
||||
(1, 3, 0, 2),
|
||||
(2, 0, 3, 1),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/<id>` | 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.
|
||||
Reference in New Issue
Block a user