diff --git a/.gitignore b/.gitignore index 1b5990a..0a445cb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.egg-info/ .env devplace.db* +devplace-services.lock notification-private.pem notification-private.pkcs8.pem notification-public.pem diff --git a/README.md b/README.md index ea1f34c..6461231 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ Open `http://localhost:10500`. | Layer | Technology | |-------|-----------| -| Backend | Python 3.13+, FastAPI, Uvicorn (single worker) | +| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) | | Templates | Jinja2 (server-side rendered) | | Frontend | Pure ES6 JavaScript, one class per file | | Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) | -| Auth | Session cookies, SHA256+SALT via passlib | +| Auth | Session cookies, PBKDF2-SHA256 via passlib | | Avatars | Multiavatar (local SVG generation, no external API, <5ms) | | Validation | `hawk` (Python/JS/CSS/HTML) | | Load testing | Locust (locustfile.py) | diff --git a/devplacepy/config.py b/devplacepy/config.py index b4e9c65..81d02c7 100644 --- a/devplacepy/config.py +++ b/devplacepy/config.py @@ -13,6 +13,8 @@ SESSION_MAX_AGE = 86400 * 7 PORT = 10500 SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/") +SERVICE_LOCK_FILE = BASE_DIR / "devplace-services.lock" + VAPID_PRIVATE_KEY_FILE = BASE_DIR / "notification-private.pem" VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem" VAPID_PUBLIC_KEY_FILE = BASE_DIR / "notification-public.pem" diff --git a/devplacepy/content.py b/devplacepy/content.py index b6fea85..aeb29fa 100644 --- a/devplacepy/content.py +++ b/devplacepy/content.py @@ -75,13 +75,18 @@ def delete_content_item(table_name: str, target_type: str, user: dict, slug: str item = resolve_by_slug(table, slug) if is_owner(item, user): delete_target_attachments(target_type, item["uid"]) + comment_uids = [] if "comments" in db.tables: comments = get_table("comments") for comment in comments.find(target_uid=item["uid"]): + comment_uids.append(comment["uid"]) delete_target_attachments("comment", comment["uid"]) comments.delete(target_uid=item["uid"]) if "votes" in db.tables: - get_table("votes").delete(target_uid=item["uid"]) + votes = get_table("votes") + votes.delete(target_uid=item["uid"]) + if comment_uids: + votes.delete(votes.table.columns.target_uid.in_(comment_uids), target_type="comment") if inline_image_field: delete_inline_image(item.get(inline_image_field)) table.delete(id=item["id"]) diff --git a/devplacepy/main.py b/devplacepy/main.py index 73b9692..348e243 100644 --- a/devplacepy/main.py +++ b/devplacepy/main.py @@ -1,4 +1,5 @@ import asyncio +import fcntl import logging import os import time @@ -7,7 +8,7 @@ from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.exceptions import RequestValidationError -from devplacepy.config import STATIC_DIR, PORT +from devplacepy.config import STATIC_DIR, PORT, SERVICE_LOCK_FILE from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids from devplacepy.templating import templates from devplacepy.utils import get_current_user, time_ago @@ -26,6 +27,20 @@ _rate_limit_store = defaultdict(list) RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60")) RATE_WINDOW = 60 +_service_lock_handle = None + + +def acquire_service_lock() -> bool: + global _service_lock_handle + handle = open(SERVICE_LOCK_FILE, "w") + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + return False + _service_lock_handle = handle + return True + class UploadStaticFiles(StaticFiles): async def get_response(self, path, scope): response = await super().get_response(path, scope) @@ -145,9 +160,12 @@ async def startup(): from devplacepy.push import ensure_certificates ensure_certificates() if not os.environ.get("DEVPLACE_DISABLE_SERVICES"): - news_service = NewsService() - service_manager.register(news_service) - asyncio.create_task(service_manager.start_all()) + if acquire_service_lock(): + service_manager.register(NewsService()) + asyncio.create_task(service_manager.start_all()) + logger.info(f"Background services started in worker pid {os.getpid()}") + else: + logger.info(f"Worker pid {os.getpid()} declined service lock; another worker owns background services") logger.info(f"DevPlace started on port {PORT}") diff --git a/devplacepy/routers/auth.py b/devplacepy/routers/auth.py index 948e2e6..df08772 100644 --- a/devplacepy/routers/auth.py +++ b/devplacepy/routers/auth.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form from fastapi.responses import RedirectResponse, HTMLResponse from devplacepy.database import get_table from devplacepy.templating import templates -from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge +from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge, clear_session_cache from devplacepy.seo import base_seo_context from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm @@ -205,6 +205,7 @@ async def logout(request: Request): session = sessions.find_one(session_token=token) if session: sessions.delete(id=session["id"]) + clear_session_cache(token) response = RedirectResponse(url="/", status_code=302) response.delete_cookie("session") return response diff --git a/devplacepy/routers/messages.py b/devplacepy/routers/messages.py index 611b654..0133de3 100644 --- a/devplacepy/routers/messages.py +++ b/devplacepy/routers/messages.py @@ -152,6 +152,9 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]): content = data.content.strip() receiver_uid = data.receiver_uid + if not get_table("users").find_one(uid=receiver_uid): + return RedirectResponse(url="/messages", status_code=302) + messages_table = get_table("messages") msg_uid = generate_uid() messages_table.insert({ diff --git a/devplacepy/routers/votes.py b/devplacepy/routers/votes.py index 22c1930..de6ee7c 100644 --- a/devplacepy/routers/votes.py +++ b/devplacepy/routers/votes.py @@ -39,8 +39,8 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota }) did_upvote = value == 1 - up_count = votes.count(target_uid=target_uid, value=1) - down_count = votes.count(target_uid=target_uid, value=-1) + up_count = votes.count(target_uid=target_uid, target_type=target_type, value=1) + down_count = votes.count(target_uid=target_uid, target_type=target_type, value=-1) net = up_count - down_count update_target_stars(target_type, target_uid, net) diff --git a/devplacepy/static/js/Avatar.js b/devplacepy/static/js/Avatar.js index b81cb61..9f4a1e4 100644 --- a/devplacepy/static/js/Avatar.js +++ b/devplacepy/static/js/Avatar.js @@ -1,7 +1,14 @@ export class Avatar { - static imgHtml(username, size = 24) { - const url = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`; - return ``; + static imgElement(username, size = 24) { + const img = document.createElement("img"); + img.src = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`; + img.className = "avatar-img"; + img.style.width = `${size}px`; + img.style.height = `${size}px`; + img.style.borderRadius = "50%"; + img.alt = ""; + img.loading = "lazy"; + return img; } } diff --git a/devplacepy/static/js/MentionInput.js b/devplacepy/static/js/MentionInput.js index 3a3571a..7e02d65 100644 --- a/devplacepy/static/js/MentionInput.js +++ b/devplacepy/static/js/MentionInput.js @@ -75,7 +75,9 @@ export class MentionInput { item.type = "button"; item.className = "mention-dropdown-item"; item.dataset.username = r.username; - item.innerHTML = Avatar.imgHtml(r.username) + "@" + r.username + ""; + const label = document.createElement("span"); + label.textContent = "@" + r.username; + item.append(Avatar.imgElement(r.username), label); item.addEventListener("mousedown", (e) => { e.preventDefault(); this.insert(r.username); diff --git a/devplacepy/static/js/MessageSearch.js b/devplacepy/static/js/MessageSearch.js index c77b75f..c9bf5ac 100644 --- a/devplacepy/static/js/MessageSearch.js +++ b/devplacepy/static/js/MessageSearch.js @@ -41,7 +41,9 @@ export class MessageSearch { const item = document.createElement("a"); item.className = "search-dropdown-item"; item.href = `/messages?with_uid=${r.uid}`; - item.innerHTML = `${Avatar.imgHtml(r.username)}${r.username}`; + const label = document.createElement("span"); + label.textContent = r.username; + item.append(Avatar.imgElement(r.username), label); dropdown.appendChild(item); } DomUtils.show(dropdown); diff --git a/devplacepy/static/js/ProfileEditor.js b/devplacepy/static/js/ProfileEditor.js index f7acf6c..b298c3a 100644 --- a/devplacepy/static/js/ProfileEditor.js +++ b/devplacepy/static/js/ProfileEditor.js @@ -34,6 +34,7 @@ export class ProfileEditor { const tag = document.createElement("span"); tag.className = "platform-tag"; tag.textContent = val; + tag.dataset.value = val; const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "x"; @@ -57,7 +58,7 @@ export class ProfileEditor { const updatePlatforms = () => { const values = []; tagsContainer.querySelectorAll(".platform-tag").forEach((t) => { - values.push(t.textContent.replace("x", "").trim()); + values.push(t.dataset.value); }); hiddenInput.value = values.join(","); }; diff --git a/devplacepy/templates/_comment_section.html b/devplacepy/templates/_comment_section.html index 4d4f9af..ec82627 100644 --- a/devplacepy/templates/_comment_section.html +++ b/devplacepy/templates/_comment_section.html @@ -20,7 +20,7 @@ {{ item.author['username'] if item.author else '?' }} - {{ item.author['username'] if item.author else 'Unknown' }} + {% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %} {{ item.time_ago }}
{{ item.comment['content'] }}
diff --git a/devplacepy/templates/_post_card.html b/devplacepy/templates/_post_card.html index f0ec215..34b2a21 100644 --- a/devplacepy/templates/_post_card.html +++ b/devplacepy/templates/_post_card.html @@ -35,7 +35,7 @@
- +
{% endif %} diff --git a/devplacepy/templates/_post_header.html b/devplacepy/templates/_post_header.html index 4d3de44..6924cc0 100644 --- a/devplacepy/templates/_post_header.html +++ b/devplacepy/templates/_post_header.html @@ -1,7 +1,7 @@
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
- + {% set _user = _author %}{% set _class = "post-author-link" %}{% include "_user_link.html" %} {% if _author and _author.get('role') %} {% endif %} diff --git a/devplacepy/templates/_user_link.html b/devplacepy/templates/_user_link.html new file mode 100644 index 0000000..4ddfa8a --- /dev/null +++ b/devplacepy/templates/_user_link.html @@ -0,0 +1,5 @@ +{% if _user %} +{{ _user['username'] }} +{% else %} +Unknown +{% endif %} \ No newline at end of file diff --git a/devplacepy/templates/feed.html b/devplacepy/templates/feed.html index 8e3bf2d..898aa4f 100644 --- a/devplacepy/templates/feed.html +++ b/devplacepy/templates/feed.html @@ -105,7 +105,7 @@
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %} - {{ author['username'] }} + {% set _user = author %}{% set _class = "top-author-name" %}{% include "_user_link.html" %} {{ author.get('stars', 0) }}
diff --git a/devplacepy/templates/gist_detail.html b/devplacepy/templates/gist_detail.html index 3230f6b..ce0b838 100644 --- a/devplacepy/templates/gist_detail.html +++ b/devplacepy/templates/gist_detail.html @@ -19,7 +19,7 @@
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
- {{ author['username'] if author else 'Unknown' }} + {% set _user = author %}{% set _class = none %}{% include "_user_link.html" %} {% if author and author.get('role') %} · {{ author['role'] }} {% endif %} diff --git a/devplacepy/templates/landing.html b/devplacepy/templates/landing.html index 74b9c30..8f69ee0 100644 --- a/devplacepy/templates/landing.html +++ b/devplacepy/templates/landing.html @@ -50,7 +50,7 @@ {% set _size_class = "sm" %} {% include "_avatar_link.html" %} {{ item.post['topic'] }} diff --git a/devplacepy/templates/leaderboard.html b/devplacepy/templates/leaderboard.html index f1a4c46..58ce439 100644 --- a/devplacepy/templates/leaderboard.html +++ b/devplacepy/templates/leaderboard.html @@ -28,7 +28,7 @@
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %} - {{ author['username'] }} + {% set _user = author %}{% set _class = "top-author-name" %}{% include "_user_link.html" %} {{ author.get('stars', 0) }}
@@ -52,7 +52,7 @@
  • #{{ entry['rank'] }} {% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %} - {{ entry['username'] }} + {% set _user = entry %}{% set _class = "leaderboard-name" %}{% include "_user_link.html" %} Level {{ entry.get('level', 1) }} {{ entry['stars'] }}
  • diff --git a/devplacepy/templates/messages.html b/devplacepy/templates/messages.html index d3fe632..588f1f1 100644 --- a/devplacepy/templates/messages.html +++ b/devplacepy/templates/messages.html @@ -34,7 +34,7 @@ {{ other_user['username'] }} -

    {{ other_user['username'] }}

    +

    {% set _user = other_user %}{% set _class = none %}{% include "_user_link.html" %}

    diff --git a/devplacepy/templates/post.html b/devplacepy/templates/post.html index a1d8c67..f3fdecd 100644 --- a/devplacepy/templates/post.html +++ b/devplacepy/templates/post.html @@ -14,7 +14,7 @@ {{ author['username'] if author else '?' }}
    - {{ author['username'] if author else 'Unknown' }} + {% set _user = author %}{% set _class = "post-detail-author" %}{% include "_user_link.html" %} {% if author and author.get('role') %} · {{ author['role'] }} {% endif %} diff --git a/devplacepy/templates/project_detail.html b/devplacepy/templates/project_detail.html index 326559f..f1b443a 100644 --- a/devplacepy/templates/project_detail.html +++ b/devplacepy/templates/project_detail.html @@ -28,7 +28,7 @@
    {% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
    - {{ author['username'] if author else 'Unknown' }} + {% set _user = author %}{% set _class = none %}{% include "_user_link.html" %} {% if author and author.get('role') %} · {{ author['role'] }} {% endif %} diff --git a/devplacepy/utils.py b/devplacepy/utils.py index 58be498..fad6c40 100644 --- a/devplacepy/utils.py +++ b/devplacepy/utils.py @@ -43,6 +43,10 @@ def clear_user_cache(user_uid: str) -> None: _user_cache.pop(token) +def clear_session_cache(token: str) -> None: + _user_cache.pop(token) + + def get_current_user(request: Request): token = request.cookies.get("session") if not token: diff --git a/locustfile.py b/locustfile.py index 97baf00..168161d 100644 --- a/locustfile.py +++ b/locustfile.py @@ -23,6 +23,7 @@ NEWS_UIDS = [] NEWS_SLUGS = [] GIST_SLUGS = [] GIST_UIDS = [] +BUG_UIDS = [] NOTIFICATION_UIDS = [] ADMIN_USER = {} ADMIN_TARGETS = {} @@ -30,10 +31,6 @@ TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random"] PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"] GIST_LANGUAGES = ["python", "javascript", "go", "rust", "bash", "sql", "json"] UPLOAD_FILE = ("load_test.txt", b"locust upload payload", "text/plain") -AVATAR_STYLES = [ - "adventurer", "adventurer-neutral", "avataaars", "bottts", "identicon", - "initials", "lorelei", "micah", "open-peeps", "pixel-art", "shapes", -] UUID_RE = r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}' PASSWORD = "testpass123" @@ -182,6 +179,20 @@ def seed_data(environment, **kwargs): logger.info(f"Created {len(GIST_SLUGS)} seed gists") + for seed in SEED_USERS[:2]: + try: + opener = logged_in_opener(seed["email"]) + body = urllib.parse.urlencode({ + "title": f"Bug {uuid.uuid4().hex[:6]}", + "description": "Seed bug report for load testing.", + }).encode() + opener.open( + urllib.request.Request(f"{host}/bugs/create", data=body), + timeout=10, + ) + except Exception as e: + logger.warning(f"Create seed bug failed: {e}") + # ── Seed news articles (direct DB insert) ─────────────────── try: from devplacepy.database import get_table, init_db @@ -298,11 +309,20 @@ def seed_data(environment, **kwargs): except Exception as e: logger.warning(f"Harvest comment UIDs failed: {e}") + try: + opener = logged_in_opener(SEED_USERS[0]["email"]) + resp = opener.open(urllib.request.Request(f"{host}/bugs"), timeout=10) + html = resp.read().decode("utf-8", errors="replace") + bug_matches = re.findall(rf'/votes/bug/{uuid_pat}', html) + BUG_UIDS.extend(b for b in bug_matches if b not in BUG_UIDS) + except Exception as e: + logger.warning(f"Harvest bug UIDs failed: {e}") + logger.info( f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, " f"{len(PROJECT_UIDS)} projects ({len(PROJECT_SLUGS)} slugs), " f"{len(GIST_UIDS)} gists, {len(COMMENT_UIDS)} comments, " - f"{len(USER_UIDS)} uids" + f"{len(BUG_UIDS)} bugs, {len(USER_UIDS)} uids" ) @@ -334,7 +354,11 @@ class DevPlaceUser(HttpUser): params = {"tab": tab} if topic: params["topic"] = topic - self.client.get("/feed", params=params, name="feed") + resp = self.client.get("/feed", params=params, name="feed") + cursor = re.search(r'/feed\?before=([^"&]+)', resp.text) + if cursor: + params["before"] = cursor.group(1) + self.client.get("/feed", params=params, name="feed?before") @task(4) def view_post(self): @@ -350,14 +374,19 @@ class DevPlaceUser(HttpUser): if not ALL_USERNAMES: return self.client.get( - f"/profile/{random.choice(ALL_USERNAMES)}", name="profile/[username]" + f"/profile/{random.choice(ALL_USERNAMES)}", + params={"tab": random.choice(["posts", "activity"])}, + name="profile/[username]", ) @task(3) def view_projects(self): - self.client.get("/projects", params={ - "tab": random.choice(["recent", "released", "popular", "new"]), - }, name="projects") + params = {"tab": random.choice(["recent", "released", "popular", "new"])} + if random.random() < 0.3: + params["project_type"] = random.choice(PROJECT_TYPES) + if random.random() < 0.2 and USER_UIDS: + params["user_uid"] = random.choice(USER_UIDS) + self.client.get("/projects", params=params, name="projects") @task(1) def view_landing(self): @@ -396,7 +425,23 @@ class DevPlaceUser(HttpUser): @task(2) def view_gists(self): - self.client.get("/gists", name="gists") + params = {} + if random.random() < 0.3: + params["language"] = random.choice(GIST_LANGUAGES) + if random.random() < 0.2 and USER_UIDS: + params["user_uid"] = random.choice(USER_UIDS) + self.client.get("/gists", params=params, name="gists") + + @task(1) + def view_leaderboard(self): + self.client.get("/leaderboard", name="leaderboard") + + @task(1) + def view_pwa(self): + self.client.get( + random.choice(["/push.json", "/service-worker.js", "/manifest.json"]), + name="pwa", + ) @task(2) def view_gist_detail(self): @@ -551,45 +596,58 @@ class DevPlaceUser(HttpUser): # ── social interaction ────────────────────────────────────── + def _vote(self, target_type, uid): + data = {"value": random.choice([1, -1])} + name = f"votes/{target_type}" + if random.random() < 0.5: + self.client.post( + f"/votes/{target_type}/{uid}", data=data, name=name + ) + return + with self.client.post( + f"/votes/{target_type}/{uid}", data=data, + headers={"x-requested-with": "fetch"}, + catch_response=True, name=f"{name}/ajax", + ) as resp: + try: + payload = resp.json() + except ValueError: + resp.failure("vote ajax response not JSON") + return + if all(k in payload for k in ("net", "up", "down", "value")): + resp.success() + else: + resp.failure(f"vote ajax missing keys: {payload}") + @task(2) def vote_on_post(self): if not POST_UIDS: return - self.client.post( - f"/votes/post/{random.choice(POST_UIDS)}", - data={"value": random.choice([1, -1])}, - name="votes/post", - ) + self._vote("post", random.choice(POST_UIDS)) @task(1) def vote_on_project(self): if not PROJECT_UIDS: return - self.client.post( - f"/votes/project/{random.choice(PROJECT_UIDS)}", - data={"value": random.choice([1, -1])}, - name="votes/project", - ) + self._vote("project", random.choice(PROJECT_UIDS)) @task(1) def vote_on_comment(self): if not COMMENT_UIDS: return - self.client.post( - f"/votes/comment/{random.choice(COMMENT_UIDS)}", - data={"value": random.choice([1, -1])}, - name="votes/comment", - ) + self._vote("comment", random.choice(COMMENT_UIDS)) @task(1) def vote_on_gist(self): if not GIST_UIDS: return - self.client.post( - f"/votes/gist/{random.choice(GIST_UIDS)}", - data={"value": random.choice([1, -1])}, - name="votes/gist", - ) + self._vote("gist", random.choice(GIST_UIDS)) + + @task(1) + def vote_on_bug(self): + if not BUG_UIDS: + return + self._vote("bug", random.choice(BUG_UIDS)) @task(1) def follow_user(self): @@ -632,6 +690,15 @@ class DevPlaceUser(HttpUser): def view_messages(self): self.client.get("/messages", name="messages") + @task(1) + def view_conversation(self): + if not USER_UIDS: + return + self.client.get( + "/messages", params={"with_uid": random.choice(USER_UIDS)}, + name="messages?with_uid", + ) + @task(1) def view_notifications(self): resp = self.client.get("/notifications", name="notifications") @@ -639,6 +706,21 @@ class DevPlaceUser(HttpUser): NOTIFICATION_UIDS.extend( m for m in matches if m not in NOTIFICATION_UIDS ) + cursor = re.search(r'/notifications\?before=([^"]+)', resp.text) + if cursor: + self.client.get( + "/notifications", params={"before": cursor.group(1)}, + name="notifications?before", + ) + + @task(1) + def open_notification(self): + if not NOTIFICATION_UIDS: + return + self.client.get( + f"/notifications/open/{random.choice(NOTIFICATION_UIDS)}", + name="notifications/open", + ) @task(1) def mark_all_notifications_read(self): @@ -741,14 +823,45 @@ class DevPlaceUser(HttpUser): else: resp.failure(f"upload failed: status={resp.status_code}") + @task(1) + def register_push(self): + token = uuid.uuid4().hex + body = { + "endpoint": f"https://push.locust/endpoint/{token}", + "keys": { + "auth": uuid.uuid4().hex[:22], + "p256dh": (uuid.uuid4().hex + uuid.uuid4().hex)[:43], + }, + } + with self.client.post( + "/push.json", json=body, + catch_response=True, name="push.json/register", + ) as resp: + if resp.status_code in (200, 400): + resp.success() + else: + resp.failure(f"push register: status={resp.status_code}") + # ── static / media ────────────────────────────────────────── @task(2) def view_multiavatar(self): seed = random.choice(ALL_USERNAMES) if ALL_USERNAMES else "anon" - self.client.get( + resp = self.client.get( f"/avatar/multiavatar/{seed}?size=32", name="avatar/multiavatar" ) + etag = resp.headers.get("ETag") + if not etag: + return + with self.client.get( + f"/avatar/multiavatar/{seed}?size=32", + headers={"If-None-Match": etag}, + catch_response=True, name="avatar/multiavatar/304", + ) as cached: + if cached.status_code == 304: + cached.success() + else: + cached.failure(f"expected 304, got {cached.status_code}") @task(1) def comment_on_news(self): @@ -760,6 +873,49 @@ class DevPlaceUser(HttpUser): "target_type": "news", }, name="comments/news") + @task(1) + def comment_on_target(self): + pools = [] + if PROJECT_UIDS: + pools.append(("project", PROJECT_UIDS)) + if GIST_UIDS: + pools.append(("gist", GIST_UIDS)) + if BUG_UIDS: + pools.append(("bug", BUG_UIDS)) + if not pools: + return + target_type, uids = random.choice(pools) + self.client.post("/comments/create", data={ + "content": f"{target_type} comment {uuid.uuid4().hex[:6]} " * 2, + "target_uid": random.choice(uids), + "target_type": target_type, + }, name=f"comments/{target_type}") + + @task(1) + def reply_to_comment(self): + if not POST_UIDS or not COMMENT_UIDS: + return + self.client.post("/comments/create", data={ + "content": f"Reply {uuid.uuid4().hex[:6]} " * 2, + "post_uid": random.choice(POST_UIDS), + "parent_uid": random.choice(COMMENT_UIDS), + }, name="comments/reply") + + @task(2) + def browse_and_engage(self): + slugs = POST_SLUGS if POST_SLUGS else POST_UIDS + if not slugs: + return + resp = self.client.get( + f"/posts/{random.choice(slugs)}", name="posts/[uid]" + ) + vote_uid = re.search(rf'/votes/post/({UUID_RE})', resp.text) + if vote_uid: + self._vote("post", vote_uid.group(1)) + comment_uid = re.search(rf'/comments/delete/({UUID_RE})', resp.text) + if comment_uid and comment_uid.group(1) not in COMMENT_UIDS: + COMMENT_UIDS.append(comment_uid.group(1)) + class AdminUser(HttpUser): weight = 1 diff --git a/tests/test_auth.py b/tests/test_auth.py index 4a2529c..8d86012 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,7 @@ import hashlib import time from datetime import datetime, timedelta, timezone +from playwright.sync_api import expect from tests.conftest import BASE_URL from devplacepy.database import get_table from devplacepy.utils import hash_password, generate_uid @@ -99,8 +100,7 @@ def test_signup_existing_username(page, app_server): page.fill("#password", "secret123") page.fill("#confirm_password", "secret123") page.click("button:has-text('Create account')") - page.wait_for_timeout(300) - assert page.is_visible("text=Username already taken") + expect(page.locator("text=Username already taken")).to_be_visible() def test_signup_existing_email(page, app_server): @@ -119,8 +119,7 @@ def test_signup_existing_email(page, app_server): page.fill("#password", "secret123") page.fill("#confirm_password", "secret123") page.click("button:has-text('Create account')") - page.wait_for_timeout(300) - assert page.is_visible("text=Email already registered") + expect(page.locator("text=Email already registered")).to_be_visible() def test_signup_password_mismatch(page, app_server): @@ -130,8 +129,7 @@ def test_signup_password_mismatch(page, app_server): page.fill("#password", "secret123") page.fill("#confirm_password", "different456") page.click("button:has-text('Create account')") - page.wait_for_timeout(300) - assert page.is_visible("text=Passwords do not match") + expect(page.locator("text=Passwords do not match")).to_be_visible() def test_signup_short_password(page, app_server): @@ -141,8 +139,7 @@ def test_signup_short_password(page, app_server): page.fill("#password", "ab") page.fill("#confirm_password", "ab") page.click("button:has-text('Create account')") - page.wait_for_timeout(300) - assert page.is_visible("text=Password must be at least 6 characters") + expect(page.locator("text=Password must be at least 6 characters")).to_be_visible() def test_signup_invalid_username(page, app_server): @@ -152,8 +149,7 @@ def test_signup_invalid_username(page, app_server): page.fill("#password", "secret123") page.fill("#confirm_password", "secret123") page.click("button:has-text('Create account')") - page.wait_for_timeout(300) - assert page.is_visible("text=Username must be between 3 and 32 characters") + expect(page.locator("text=Username must be between 3 and 32 characters")).to_be_visible() def test_login_page_loads(page, app_server): @@ -198,8 +194,7 @@ def test_login_wrong_password(page, app_server): page.fill("#email", "wrongpw@test.devplace") page.fill("#password", "badpassword") page.click("button:has-text('Sign in')") - page.wait_for_timeout(300) - assert page.is_visible("text=Invalid email or password") + expect(page.locator("text=Invalid email or password")).to_be_visible() def test_login_nonexistent_email(page, app_server): @@ -207,8 +202,7 @@ def test_login_nonexistent_email(page, app_server): page.fill("#email", "nobody@nowhere.devplace") page.fill("#password", "secret123") page.click("button:has-text('Sign in')") - page.wait_for_timeout(300) - assert page.is_visible("text=Invalid email or password") + expect(page.locator("text=Invalid email or password")).to_be_visible() def test_login_remember_me(page, app_server): diff --git a/tests/test_feed.py b/tests/test_feed.py index 92076a1..4e34225 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -171,19 +171,24 @@ def test_feed_inline_comment(alice): page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") inline_form = page.locator(".feed-comment-form").first - if inline_form.is_visible(): - inline_form.locator("input[name='content']").fill("Inline comment from feed") - inline_form.locator(".feed-comment-submit").click() - page.wait_for_timeout(500) + expect(inline_form).to_be_visible() + inline_form.locator("input[name='content']").fill("Inline comment from feed") + inline_form.locator(".feed-comment-submit").click() + page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded") + expect(page.locator(".comment-text:has-text('Inline comment from feed')")).to_be_visible() def test_feed_inline_comment_placeholder(alice): page, _ = alice page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") - inline_input = page.locator(".feed-comment-form input").first - if inline_input.is_visible(): - placeholder = inline_input.get_attribute("placeholder") - assert placeholder == "Your opinion goes here..." + page.locator(".feed-fab").first.click() + page.fill("#post-content", "Placeholder check post " + "x" * 20) + page.locator("#create-post-modal button.btn-primary:has-text('Post')").click() + page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded") + page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") + inline_input = page.locator(".feed-comment-form input[name='content']").first + expect(inline_input).to_be_visible() + assert inline_input.get_attribute("placeholder") == "Your opinion goes here..." def test_feed_signals_topic(alice): diff --git a/tests/test_post.py b/tests/test_post.py index ebfa62a..b14eb2d 100644 --- a/tests/test_post.py +++ b/tests/test_post.py @@ -77,9 +77,9 @@ def test_comment_voted_state_persists(alice): textarea = page.locator(".comment-form textarea[name='content']") textarea.fill("Comment whose vote should persist") page.locator(".comment-form button:has-text('Post')").click() - page.wait_for_timeout(500) + expect(page.locator(".comment-text:has-text('Comment whose vote should persist')")).to_be_visible() page.locator(".comment-vote-btn").first.click() - page.wait_for_timeout(500) + expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b")) page.reload(wait_until="domcontentloaded") expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b")) @@ -113,8 +113,7 @@ def test_add_comment(alice): textarea = page.locator(".comment-form textarea[name='content']") textarea.fill("This is a test comment from Playwright") page.locator(".comment-form button:has-text('Post')").click() - page.wait_for_timeout(500) - assert page.is_visible("text=This is a test comment from Playwright") + expect(page.locator(".comment-text:has-text('This is a test comment from Playwright')")).to_be_visible() def test_comment_voting(alice): @@ -123,11 +122,11 @@ def test_comment_voting(alice): textarea = page.locator(".comment-form textarea[name='content']") textarea.fill("Votable comment") page.locator(".comment-form button:has-text('Post')").click() - page.wait_for_timeout(500) + expect(page.locator(".comment-text:has-text('Votable comment')")).to_be_visible() vote_btns = page.locator(".comment-vote-btn") upvote = vote_btns.first upvote.click() - page.wait_for_timeout(500) + expect(vote_btns.first).to_have_class(re.compile(r"\bvoted\b")) def test_delete_own_comment(alice): @@ -136,11 +135,12 @@ def test_delete_own_comment(alice): textarea = page.locator(".comment-form textarea[name='content']") textarea.fill("Comment to delete") page.locator(".comment-form button:has-text('Post')").click() - page.wait_for_timeout(500) + comment = page.locator(".comment-text:has-text('Comment to delete')") + expect(comment).to_be_visible() delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last - if delete_btn.is_visible(): - delete_btn.click() - page.wait_for_timeout(500) + expect(delete_btn).to_be_visible() + delete_btn.click() + expect(comment).to_have_count(0) def test_comment_form_elements(alice): @@ -170,7 +170,7 @@ def test_multiple_comments_on_post(alice): for i in range(3): page.locator(".comment-form textarea[name='content']").fill(f"Comment number {i + 1}") page.locator(".comment-form button:has-text('Post')").click() - page.wait_for_timeout(300) + expect(page.locator(f".comment-text:has-text('Comment number {i + 1}')")).to_be_visible() assert page.is_visible("text=Comment number 1") assert page.is_visible("text=Comment number 3") @@ -180,17 +180,17 @@ def test_comment_and_vote_then_delete(alice): create_post(page, "showcase", "Full comment lifecycle post") page.locator(".comment-form textarea[name='content']").fill("Lifecycle comment") page.locator(".comment-form button:has-text('Post')").click() - page.wait_for_timeout(300) - assert page.is_visible("text=Lifecycle comment") + comment = page.locator(".comment-text:has-text('Lifecycle comment')") + expect(comment).to_be_visible() vote_up = page.locator(".comment-vote-btn").first vote_up.click() - page.wait_for_timeout(300) + expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b")) - delete_btn = page.locator(".comment-action-btn:has-text('Delete')") - if delete_btn.is_visible(): - delete_btn.click() - page.wait_for_timeout(300) + delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last + expect(delete_btn).to_be_visible() + delete_btn.click() + expect(comment).to_have_count(0) def test_post_edit_button(alice): @@ -229,8 +229,7 @@ def test_post_edit_submit(alice): page.fill("#edit-title", "Edited Title") page.fill("#edit-content", "Edited content for the post") page.click("button:has-text('Save Changes')") - page.wait_for_timeout(500) - assert page.is_visible("text=Edited Title") + expect(page.locator("text=Edited Title")).to_be_visible() def test_post_across_all_topics(alice): @@ -239,8 +238,7 @@ def test_post_across_all_topics(alice): for topic in topics: create_post(page, topic, f"Topic test post for {topic}") badge = page.locator(f".badge-{topic}") - assert badge.is_visible() - page.wait_for_timeout(200) + expect(badge).to_be_visible() def test_delete_own_post(alice): diff --git a/tests/test_push.py b/tests/test_push.py new file mode 100644 index 0000000..8324d10 --- /dev/null +++ b/tests/test_push.py @@ -0,0 +1,64 @@ +import uuid + +import requests + +from tests.conftest import BASE_URL + + +def _session(): + s = requests.Session() + name = f"push_{uuid.uuid4().hex[:10]}" + s.post(f"{BASE_URL}/auth/signup", data={ + "username": name, + "email": f"{name}@test.dev", + "password": "secret123", + "confirm_password": "secret123", + }, allow_redirects=True) + return s + + +def test_public_key_endpoint(app_server): + r = requests.get(f"{BASE_URL}/push.json") + assert r.status_code == 200 + assert r.json().get("publicKey") + + +def test_register_requires_auth(app_server): + r = requests.post(f"{BASE_URL}/push.json", json={ + "endpoint": "https://push.example.com/anon", + "keys": {"p256dh": "key", "auth": "auth"}, + }, allow_redirects=False) + assert r.status_code == 401 + + +def test_register_success(app_server): + s = _session() + r = s.post(f"{BASE_URL}/push.json", json={ + "endpoint": "https://push.example.com/sub-1", + "keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"}, + }) + assert r.status_code == 200, r.text + assert r.json().get("registered") is True + + +def test_register_missing_keys_rejected(app_server): + s = _session() + r = s.post(f"{BASE_URL}/push.json", json={"endpoint": "https://push.example.com/x"}) + assert r.status_code == 400 + + +def test_register_invalid_json_rejected(app_server): + s = _session() + r = s.post(f"{BASE_URL}/push.json", data="not-json", headers={"Content-Type": "application/json"}) + assert r.status_code == 400 + + +def test_service_worker_served(app_server): + r = requests.get(f"{BASE_URL}/service-worker.js") + assert r.status_code == 200 + assert r.headers.get("Service-Worker-Allowed") == "/" + + +def test_manifest_served(app_server): + r = requests.get(f"{BASE_URL}/manifest.json") + assert r.status_code == 200 diff --git a/tests/test_xss.py b/tests/test_xss.py new file mode 100644 index 0000000..8d6fc19 --- /dev/null +++ b/tests/test_xss.py @@ -0,0 +1,35 @@ +from tests.conftest import BASE_URL +from tests.test_post import create_post + +POST_PAYLOAD = "XSSPROBE end of post" +COMMENT_PAYLOAD = "CMTPROBE end of comment" + + +def test_post_content_is_sanitized(alice): + page, _ = alice + fired = [] + page.on("dialog", lambda d: (fired.append(d.message), d.dismiss())) + create_post(page, "random", POST_PAYLOAD) + content = page.locator(".post-detail-content") + content.wait_for(state="visible") + page.locator(".post-detail-content p").first.wait_for(state="visible") + html = content.inner_html().lower() + assert "