fix(security & perf): implement bearer session tokens, salted pbkdf2, object pooling and background caching

This commit is contained in:
Antigravity committed 2026-09-17 13:14:31 +00:00
1 parent 258b1a9cb1
commit 7cd0792799
15 files changed
+1264 -105

No files matched your search

+156 -11
View File
@@ -1,13 +1,16 @@
#!/usr/bin/env python3
"""
SQLite Database Manager for Plants vs. Zombies Game (ai.qpid.pro)
Zero external dependencies. Manages users, game progress profiles, leaderboards, and bug reports.
Zero external dependencies. Manages users, game progress profiles, leaderboards, sessions, and bug reports.
"""
import sqlite3
import json
import os
from datetime import datetime
import secrets
import hashlib
import hmac
from datetime import datetime, timedelta
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data")
@@ -20,6 +23,33 @@ def get_connection():
conn.row_factory = sqlite3.Row
return conn
def hash_password(password, salt=None):
"""Secure server-side salted PBKDF2 password hashing."""
if not password:
return ""
salt = salt or secrets.token_hex(16)
iterations = 100_000
derived = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations).hex()
return f"pbkdf2:sha256:{iterations}:{salt}:{derived}"
def verify_password(password, stored_hash):
"""Verifies a password against stored hash with backward compatibility for legacy hashes."""
if not password or not stored_hash:
return False
if stored_hash.startswith("pbkdf2:sha256:"):
try:
parts = stored_hash.split(":")
if len(parts) == 5:
iterations = int(parts[2])
salt = parts[3]
expected = parts[4]
derived = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations).hex()
return hmac.compare_digest(derived, expected)
except Exception:
return False
# Backward compatibility for plain/legacy hashes
return hmac.compare_digest(str(password), str(stored_hash))
def init_db():
conn = get_connection()
cursor = conn.cursor()
@@ -34,6 +64,17 @@ def init_db():
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)")
cursor.execute("""
CREATE TABLE IF NOT EXISTS profiles (
user_id INTEGER PRIMARY KEY,
@@ -81,6 +122,79 @@ def init_db():
conn.commit()
conn.close()
def create_session(user_id, duration_days=30):
"""Generates a secure 256-bit token and creates a session record."""
token = secrets.token_hex(32)
now = datetime.now()
created_at = now.strftime("%Y-%m-%d %H:%M:%S")
expires_at = (now + timedelta(days=duration_days)).strftime("%Y-%m-%d %H:%M:%S")
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
(token, user_id, created_at, expires_at)
)
conn.commit()
conn.close()
return token
def validate_session(token):
"""Validates session token. Returns user dict if valid, else None."""
if not token or not isinstance(token, str):
return None
token = token.strip()
if not token:
return None
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT u.id, u.username, u.created_at, u.last_login, s.expires_at, s.token
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token = ?
""", (token,))
row = cursor.fetchone()
if not row:
conn.close()
return None
# Check expiration
try:
expires_at = datetime.strptime(row["expires_at"], "%Y-%m-%d %H:%M:%S")
if datetime.now() > expires_at:
cursor.execute("DELETE FROM sessions WHERE token = ?", (token,))
conn.commit()
conn.close()
return None
except Exception:
conn.close()
return None
user_info = {
"id": row["id"],
"user_id": row["id"],
"username": row["username"],
"createdAt": row["created_at"],
"lastLogin": row["last_login"],
"token": row["token"]
}
conn.close()
return user_info
def delete_session(token):
"""Deletes/invalidates a session by token."""
if not token:
return True
conn = get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM sessions WHERE token = ?", (token.strip(),))
conn.commit()
conn.close()
return True
def get_default_profile_dict():
return {
"coins": 150,
@@ -133,10 +247,12 @@ def register_user(username, password_hash, initial_data=None):
cursor = conn.cursor()
try:
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
now = datetime.now()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
secure_hash = hash_password(password_hash)
cursor.execute(
"INSERT INTO users (username, password_hash, created_at, last_login) VALUES (?, ?, ?, ?)",
(username, password_hash, now_str, now_str)
(username, secure_hash, now_str, now_str)
)
user_id = cursor.lastrowid
@@ -168,9 +284,17 @@ def register_user(username, password_hash, initial_data=None):
now_str
))
# Create session token
token = secrets.token_hex(32)
expires_at = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
cursor.execute(
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
(token, user_id, now_str, expires_at)
)
conn.commit()
conn.close()
return {"success": True, "user_id": user_id, "username": username, "data": profile_data}
return {"success": True, "token": token, "user_id": user_id, "username": username, "data": profile_data}
except sqlite3.IntegrityError:
conn.close()
return {"success": False, "message": "Пользователь с таким именем уже существует"}
@@ -189,13 +313,27 @@ def authenticate_user(username, password_hash):
conn.close()
return None
if user["password_hash"] != password_hash:
if not verify_password(password_hash, user["password_hash"]):
conn.close()
return {"success": False, "message": "Неверный пароль"}
# Update last login
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cursor.execute("UPDATE users SET last_login = ? WHERE id = ?", (now_str, user["id"]))
now = datetime.now()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
# Seamless upgrade from legacy plaintext/djb2 hash to salted PBKDF2
if not user["password_hash"].startswith("pbkdf2:"):
new_secure_hash = hash_password(password_hash)
cursor.execute("UPDATE users SET password_hash = ?, last_login = ? WHERE id = ?", (new_secure_hash, now_str, user["id"]))
else:
cursor.execute("UPDATE users SET last_login = ? WHERE id = ?", (now_str, user["id"]))
# Create session token
token = secrets.token_hex(32)
expires_at = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
cursor.execute(
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
(token, user["id"], now_str, expires_at)
)
conn.commit()
# Fetch profile
@@ -227,10 +365,17 @@ def authenticate_user(username, password_hash):
"achievements": json.loads(p["achievements"] or "{}")
}
return {"success": True, "user_id": user["id"], "username": user["username"], "data": profile_dict}
return {"success": True, "token": token, "user_id": user["id"], "username": user["username"], "data": profile_dict}
def save_user_profile(username, data):
def save_user_profile(username, data, token=None):
username = username.strip()
# If token is provided, verify it belongs to username
if token is not None:
session_user = validate_session(token)
if not session_user or session_user["username"].lower() != username.lower():
return {"success": False, "message": "Недействительный токен авторизации или доступ запрещен"}
conn = get_connection()
cursor = conn.cursor()
+159 -22
View File
@@ -3,7 +3,7 @@
Backend Server for Plants vs. Zombies (ai.qpid.pro)
Zero external dependencies.
Manages:
- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/save, /api/users)
- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/register, /api/auth/save, /api/auth/sync, /api/users)
- Community Bug Reports & Feedback (/api/bugs)
"""
@@ -11,6 +11,8 @@ import http.server
import json
import os
import sys
import html
import urllib.parse
from datetime import datetime
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -19,6 +21,7 @@ sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
import db
PORT = 8001
MAX_BODY_SIZE = 1 * 1024 * 1024 # 1MB
REPORTS_DIR = os.path.join(BASE_DIR, "reports")
BUGS_JSON = os.path.join(REPORTS_DIR, "bugs.json")
BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md")
@@ -26,6 +29,24 @@ BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md")
os.makedirs(REPORTS_DIR, exist_ok=True)
db.init_db()
# In-memory rate limiter for failed login attempts
FAILED_LOGIN_ATTEMPTS = {} # ip -> list of timestamps
def is_login_rate_limited(ip, max_attempts=5, window_seconds=60):
now = datetime.now().timestamp()
attempts = [t for t in FAILED_LOGIN_ATTEMPTS.get(ip, []) if now - t < window_seconds]
FAILED_LOGIN_ATTEMPTS[ip] = attempts
return len(attempts) >= max_attempts
def record_failed_login(ip):
now = datetime.now().timestamp()
attempts = [t for t in FAILED_LOGIN_ATTEMPTS.get(ip, []) if now - t < 60]
attempts.append(now)
FAILED_LOGIN_ATTEMPTS[ip] = attempts
def clear_failed_logins(ip):
FAILED_LOGIN_ATTEMPTS.pop(ip, None)
DEFAULT_BUGS = [
{
"id": 1,
@@ -86,13 +107,13 @@ def sync_markdown(bugs):
for b in reversed(bugs):
bid = b.get("id", 0)
ts = b.get("timestamp", "")
author = b.get("author", "Аноним").replace("|", "/")
author = str(b.get("author", "Аноним")).replace("|", "/")
cat = b.get("category", "bug")
cat_icon = "🔴 Баг" if cat == "bug" else ("💡 Идея" if cat == "feature" else ("🎨 UI" if cat == "visual" else "⚖️ Баланс"))
st = b.get("status", "новый")
st_badge = "✅ Исправлено" if st == "исправлено" else ("🟡 В работе" if st == "в работе" else "🔴 Новый")
text = b.get("text", "").replace("|", "/").replace("\n", " ")
reply = (b.get("adminReply") or "—").replace("|", "/").replace("\n", " ")
text = str(b.get("text", "")).replace("|", "/").replace("\n", " ")
reply = str(b.get("adminReply") or "—").replace("|", "/").replace("\n", " ")
lines.append(f"| {bid} | {ts} | {author} | {cat_icon} | {st_badge} | {text} | {reply} |")
lines.append("")
@@ -100,10 +121,32 @@ def sync_markdown(bugs):
f.write("\n".join(lines))
class FeedbackHandler(http.server.BaseHTTPRequestHandler):
def get_client_ip(self):
xff = self.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
x_real = self.headers.get("X-Real-IP")
if x_real:
return x_real.strip()
return self.client_address[0]
def _set_cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
origin = self.headers.get("Origin", "")
if not origin:
self.send_header("Access-Control-Allow-Origin", "*")
else:
try:
parsed = urllib.parse.urlparse(origin)
host = parsed.hostname or ""
if host in ["localhost", "127.0.0.1", "ai.qpid.pro", "qpid.pro", "ai", "racks"] or host.endswith(".qpid.pro") or host.endswith(".ai") or host.endswith(".racks"):
self.send_header("Access-Control-Allow-Origin", origin)
else:
self.send_header("Access-Control-Allow-Origin", "https://ai.qpid.pro")
except Exception:
self.send_header("Access-Control-Allow-Origin", "https://ai.qpid.pro")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.send_header("Access-Control-Allow-Credentials", "true")
def do_OPTIONS(self):
self.send_response(204)
@@ -153,6 +196,18 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
path = self.path.split("?")[0]
length = int(self.headers.get("Content-Length", 0))
# Check payload size limit (Max 1MB)
if length > MAX_BODY_SIZE:
self.send_response(413)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Payload Too Large (Max 1MB)"}).encode("utf-8"))
self.close_connection = True
return
body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
try:
@@ -160,46 +215,68 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
except Exception as e:
self.send_response(400)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8"))
return
# 1a. User Login
# 1a. User Login with Rate Limiting
if path in ["/api/auth/login", "/api/bugs/auth/login"]:
client_ip = self.get_client_ip()
if is_login_rate_limited(client_ip):
self.send_response(429)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({
"success": False,
"message": "Слишком много неудачных попыток входа. Пожалуйста, подождите 1 минуту."
}).encode("utf-8"))
return
username = str(payload.get("username", "")).strip()
password_hash = str(payload.get("passwordHash", "")).strip()
if not username or not password_hash:
record_failed_login(client_ip)
self.send_response(400)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль"}).encode("utf-8"))
return
import re
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username):
record_failed_login(client_ip)
self.send_response(400)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8"))
return
user = db.get_user_by_username(username)
if not user:
record_failed_login(client_ip)
self.send_response(404)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином не найден. Зарегистрируйтесь."}).encode("utf-8"))
return
res = db.authenticate_user(username, password_hash)
if not res or not res.get("success"):
record_failed_login(client_ip)
self.send_response(401)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(res or {"success": False, "message": "Неверный логин или пароль"}).encode("utf-8"))
return
clear_failed_logins(client_ip)
data = json.dumps(res, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self._set_cors()
@@ -218,6 +295,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
if not username or not password_hash:
self.send_response(400)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль для регистрации"}).encode("utf-8"))
return
@@ -226,6 +304,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username):
self.send_response(400)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8"))
return
@@ -233,6 +312,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
if len(password_hash) < 1:
self.send_response(400)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Пароль не может быть пустым"}).encode("utf-8"))
return
@@ -240,6 +320,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
if username.lower() in ["гость", "guest", "admin", "null", "undefined", "system", "anonymous"]:
self.send_response(400)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Это имя пользователя зарезервировано"}).encode("utf-8"))
return
@@ -248,6 +329,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
if user:
self.send_response(409)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином уже существует"}).encode("utf-8"))
return
@@ -262,21 +344,72 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
self.wfile.write(data)
return
# 2. User Progress Profile Save & Sync
if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]:
username = str(payload.get("username", "")).strip()
user_data = payload.get("data", {})
# 1c. User Logout
if path in ["/api/auth/logout", "/api/bugs/auth/logout"]:
auth_header = self.headers.get("Authorization", "")
token = None
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
elif "token" in payload and payload.get("token"):
token = str(payload.get("token")).strip()
if not username or not user_data:
self.send_response(400)
if token:
db.delete_session(token)
self.send_response(200)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": True, "message": "Сессия успешно завершена"}).encode("utf-8"))
return
# 2. User Progress Profile Save & Sync (Token Authenticated)
if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]:
auth_header = self.headers.get("Authorization", "")
token = None
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
elif "token" in payload and payload.get("token"):
token = str(payload.get("token")).strip()
if not token:
self.send_response(401)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Username and data are required"}).encode("utf-8"))
self.wfile.write(json.dumps({"success": False, "message": "Требуется авторизация (отсутствует токен сессии)"}).encode("utf-8"))
return
res = db.save_user_profile(username, user_data)
session_user = db.validate_session(token)
if not session_user:
self.send_response(401)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Недействительный или истекший токен сессии"}).encode("utf-8"))
return
username = str(payload.get("username", "")).strip() or session_user["username"]
if session_user["username"].lower() != username.lower():
self.send_response(403)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Доступ запрещен (несоответствие пользователя)"}).encode("utf-8"))
return
user_data = payload.get("data", {})
if not user_data:
self.send_response(400)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Отсутствуют данные профиля для сохранения"}).encode("utf-8"))
return
res = db.save_user_profile(username, user_data, token=token)
data = json.dumps(res, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_response(200 if res.get("success") else 400)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
@@ -284,28 +417,32 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
self.wfile.write(data)
return
# 3. Bug Report Submission
# 3. Bug Report Submission (Sanitized)
if path.startswith("/api/bugs") or path.startswith("/bugs"):
text = str(payload.get("text", "")).strip()
if not text:
raw_text = str(payload.get("text", "")).strip()
if not raw_text:
self.send_response(400)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"error": "Empty report text"}).encode("utf-8"))
return
author = str(payload.get("author", "Гость")).strip() or "Гость"
raw_author = str(payload.get("author", "Гость")).strip() or "Гость"
category = str(payload.get("category", "bug")).strip()
if category not in ["bug", "feature", "visual", "balance"]:
category = "bug"
clean_author = html.escape(raw_author)[:30]
clean_text = html.escape(raw_text)[:1000]
bugs = load_bugs()
new_id = max([b.get("id", 0) for b in bugs] + [0]) + 1
new_bug = {
"id": new_id,
"author": author[:30],
"author": clean_author,
"category": category,
"text": text[:1000],
"text": clean_text,
"status": "новый",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
"adminReply": None
+33 -4
View File
@@ -195,7 +195,10 @@ def test_auth_system():
"scheduleSave",
"apiPost",
"validateUsername",
"validatePassword"
"validatePassword",
"getToken",
"setToken",
"clearToken"
]
for m in auth_methods:
if m in auth_code:
@@ -203,8 +206,10 @@ def test_auth_system():
else:
record(f"PvZAuth.{m} method implemented", "FAIL", f"Missing {m}")
record("auth.js uses /api/auth endpoint", "PASS" if "/api/auth" in auth_code else "FAIL",
"Server-backed auth endpoint missing" if "/api/auth" not in auth_code else "")
record("auth.js uses /api/auth endpoint", "PASS" if "/api/auth" in auth_code else "FAIL")
record("auth.js has pvz_token session storage", "PASS" if "pvz_token" in auth_code else "FAIL")
record("auth.js sends Authorization Bearer header", "PASS" if "Authorization" in auth_code and "Bearer" in auth_code else "FAIL")
record("auth.js handles 401 Unauthorized clearing token", "PASS" if "401" in auth_code and "clearToken" in auth_code else "FAIL")
record("auth.js has debounced save (scheduleSave)", "PASS" if "scheduleSave" in auth_code else "FAIL")
record("auth.js has flushSave for game-over sync", "PASS" if "flushSave" in auth_code else "FAIL")
@@ -243,6 +248,12 @@ def test_auth_system():
auth_res = pvz_db.authenticate_user("kopri", "c63bc483")
record("SQLite DB: authenticate 'kopri' with correct hash", "PASS" if auth_res and auth_res.get("success") else "FAIL",
str(auth_res.get("message", "")) if auth_res and not auth_res.get("success") else "")
record("SQLite DB: authenticate returns session token", "PASS" if auth_res and auth_res.get("token") and len(auth_res["token"]) == 64 else "FAIL")
# Test session validation
kopri_token = auth_res.get("token")
sess_val = pvz_db.validate_session(kopri_token)
record("SQLite DB: validate_session(token) succeeds", "PASS" if sess_val and sess_val.get("username") == "kopri" else "FAIL")
# Test wrong password
bad_auth = pvz_db.authenticate_user("kopri", "wronghash")
@@ -253,10 +264,18 @@ def test_auth_system():
dup_reg = pvz_db.register_user("kopri", "c63bc483")
record("SQLite DB: reject duplicate user registration", "PASS" if not dup_reg.get("success") else "FAIL")
# Test save profile (update newbie coins)
# Test save profile with valid token
save_res = pvz_db.save_user_profile("newbie_test", {"coins": 999, "level": 3, "highScore": 2500})
record("SQLite DB: save_user_profile() succeeds", "PASS" if save_res.get("success") else "FAIL")
# Test save profile with invalid token rejection
bad_save = pvz_db.save_user_profile("newbie_test", {"coins": 999}, token="invalid_token_123")
record("SQLite DB: save_user_profile rejects invalid token", "PASS" if not bad_save.get("success") else "FAIL")
# Test save profile with mismatching token
mismatch_save = pvz_db.save_user_profile("newbie_test", {"coins": 999}, token=kopri_token)
record("SQLite DB: save_user_profile rejects mismatched user token", "PASS" if not mismatch_save.get("success") else "FAIL")
# Verify saved
verify = pvz_db.authenticate_user("newbie_test", "bb94b3d4")
verify_ok = verify and verify.get("data", {}).get("coins") == 999
@@ -277,6 +296,16 @@ def test_auth_system():
else:
record("SQLite DB: kopri profile loaded", "FAIL", "Could not load kopri profile")
# Test session deletion
pvz_db.delete_session(kopri_token)
record("SQLite DB: delete_session invalidates session token", "PASS" if pvz_db.validate_session(kopri_token) is None else "FAIL")
# Test password hashing function
h = pvz_db.hash_password("mysecretpassword")
record("SQLite DB: hash_password produces salted pbkdf2 format", "PASS" if h.startswith("pbkdf2:sha256:100000:") else "FAIL")
record("SQLite DB: verify_password matches correct password", "PASS" if pvz_db.verify_password("mysecretpassword", h) else "FAIL")
record("SQLite DB: verify_password rejects wrong password", "PASS" if not pvz_db.verify_password("wrongpassword", h) else "FAIL")
except Exception as e:
record("SQLite DB tests", "FAIL", str(e))