Update
Some checks failed
DevPlace CI / test (push) Failing after 4m52s

This commit is contained in:
retoor 2026-06-02 23:17:51 +02:00
parent c3a8347cc0
commit 503aab26ac
30 changed files with 406 additions and 103 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ __pycache__/
*.egg-info/ *.egg-info/
.env .env
devplace.db* devplace.db*
devplace-services.lock
notification-private.pem notification-private.pem
notification-private.pkcs8.pem notification-private.pkcs8.pem
notification-public.pem notification-public.pem

View File

@ -19,11 +19,11 @@ Open `http://localhost:10500`.
| Layer | Technology | | 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) | | Templates | Jinja2 (server-side rendered) |
| Frontend | Pure ES6 JavaScript, one class per file | | Frontend | Pure ES6 JavaScript, one class per file |
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) | | 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) | | Avatars | Multiavatar (local SVG generation, no external API, <5ms) |
| Validation | `hawk` (Python/JS/CSS/HTML) | | Validation | `hawk` (Python/JS/CSS/HTML) |
| Load testing | Locust (locustfile.py) | | Load testing | Locust (locustfile.py) |

View File

@ -13,6 +13,8 @@ SESSION_MAX_AGE = 86400 * 7
PORT = 10500 PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/") 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_FILE = BASE_DIR / "notification-private.pem"
VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem" VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem"
VAPID_PUBLIC_KEY_FILE = BASE_DIR / "notification-public.pem" VAPID_PUBLIC_KEY_FILE = BASE_DIR / "notification-public.pem"

View File

@ -75,13 +75,18 @@ def delete_content_item(table_name: str, target_type: str, user: dict, slug: str
item = resolve_by_slug(table, slug) item = resolve_by_slug(table, slug)
if is_owner(item, user): if is_owner(item, user):
delete_target_attachments(target_type, item["uid"]) delete_target_attachments(target_type, item["uid"])
comment_uids = []
if "comments" in db.tables: if "comments" in db.tables:
comments = get_table("comments") comments = get_table("comments")
for comment in comments.find(target_uid=item["uid"]): for comment in comments.find(target_uid=item["uid"]):
comment_uids.append(comment["uid"])
delete_target_attachments("comment", comment["uid"]) delete_target_attachments("comment", comment["uid"])
comments.delete(target_uid=item["uid"]) comments.delete(target_uid=item["uid"])
if "votes" in db.tables: 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: if inline_image_field:
delete_inline_image(item.get(inline_image_field)) delete_inline_image(item.get(inline_image_field))
table.delete(id=item["id"]) table.delete(id=item["id"])

View File

@ -1,4 +1,5 @@
import asyncio import asyncio
import fcntl
import logging import logging
import os import os
import time import time
@ -7,7 +8,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError 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.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.templating import templates
from devplacepy.utils import get_current_user, time_ago 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_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
RATE_WINDOW = 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): class UploadStaticFiles(StaticFiles):
async def get_response(self, path, scope): async def get_response(self, path, scope):
response = await super().get_response(path, scope) response = await super().get_response(path, scope)
@ -145,9 +160,12 @@ async def startup():
from devplacepy.push import ensure_certificates from devplacepy.push import ensure_certificates
ensure_certificates() ensure_certificates()
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"): if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
news_service = NewsService() if acquire_service_lock():
service_manager.register(news_service) service_manager.register(NewsService())
asyncio.create_task(service_manager.start_all()) 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}") logger.info(f"DevPlace started on port {PORT}")

View File

@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.templating import templates 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.seo import base_seo_context
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
@ -205,6 +205,7 @@ async def logout(request: Request):
session = sessions.find_one(session_token=token) session = sessions.find_one(session_token=token)
if session: if session:
sessions.delete(id=session["id"]) sessions.delete(id=session["id"])
clear_session_cache(token)
response = RedirectResponse(url="/", status_code=302) response = RedirectResponse(url="/", status_code=302)
response.delete_cookie("session") response.delete_cookie("session")
return response return response

View File

@ -152,6 +152,9 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
content = data.content.strip() content = data.content.strip()
receiver_uid = data.receiver_uid 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") messages_table = get_table("messages")
msg_uid = generate_uid() msg_uid = generate_uid()
messages_table.insert({ messages_table.insert({

View File

@ -39,8 +39,8 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
}) })
did_upvote = value == 1 did_upvote = value == 1
up_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, value=-1) down_count = votes.count(target_uid=target_uid, target_type=target_type, value=-1)
net = up_count - down_count net = up_count - down_count
update_target_stars(target_type, target_uid, net) update_target_stars(target_type, target_uid, net)

View File

@ -1,7 +1,14 @@
export class Avatar { export class Avatar {
static imgHtml(username, size = 24) { static imgElement(username, size = 24) {
const url = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`; const img = document.createElement("img");
return `<img src="${url}" class="avatar-img" style="width:${size}px;height:${size}px;border-radius:50%" alt="" loading="lazy">`; 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;
} }
} }

View File

@ -75,7 +75,9 @@ export class MentionInput {
item.type = "button"; item.type = "button";
item.className = "mention-dropdown-item"; item.className = "mention-dropdown-item";
item.dataset.username = r.username; item.dataset.username = r.username;
item.innerHTML = Avatar.imgHtml(r.username) + "<span>@" + r.username + "</span>"; const label = document.createElement("span");
label.textContent = "@" + r.username;
item.append(Avatar.imgElement(r.username), label);
item.addEventListener("mousedown", (e) => { item.addEventListener("mousedown", (e) => {
e.preventDefault(); e.preventDefault();
this.insert(r.username); this.insert(r.username);

View File

@ -41,7 +41,9 @@ export class MessageSearch {
const item = document.createElement("a"); const item = document.createElement("a");
item.className = "search-dropdown-item"; item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`; item.href = `/messages?with_uid=${r.uid}`;
item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`; const label = document.createElement("span");
label.textContent = r.username;
item.append(Avatar.imgElement(r.username), label);
dropdown.appendChild(item); dropdown.appendChild(item);
} }
DomUtils.show(dropdown); DomUtils.show(dropdown);

View File

@ -34,6 +34,7 @@ export class ProfileEditor {
const tag = document.createElement("span"); const tag = document.createElement("span");
tag.className = "platform-tag"; tag.className = "platform-tag";
tag.textContent = val; tag.textContent = val;
tag.dataset.value = val;
const remove = document.createElement("button"); const remove = document.createElement("button");
remove.type = "button"; remove.type = "button";
remove.textContent = "x"; remove.textContent = "x";
@ -57,7 +58,7 @@ export class ProfileEditor {
const updatePlatforms = () => { const updatePlatforms = () => {
const values = []; const values = [];
tagsContainer.querySelectorAll(".platform-tag").forEach((t) => { tagsContainer.querySelectorAll(".platform-tag").forEach((t) => {
values.push(t.textContent.replace("x", "").trim()); values.push(t.dataset.value);
}); });
hiddenInput.value = values.join(","); hiddenInput.value = values.join(",");
}; };

View File

@ -20,7 +20,7 @@
<a href="/profile/{{ item.author['username'] if item.author else '#' }}"> <a href="/profile/{{ item.author['username'] if item.author else '#' }}">
<img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy"> <img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy">
</a> </a>
<a href="/profile/{{ item.author['username'] if item.author else '#' }}" class="comment-author">{{ item.author['username'] if item.author else 'Unknown' }}</a> {% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
<span class="comment-time">{{ item.time_ago }}</span> <span class="comment-time">{{ item.time_ago }}</span>
</div> </div>
<div class="comment-text rendered-content" data-render>{{ item.comment['content'] }}</div> <div class="comment-text rendered-content" data-render>{{ item.comment['content'] }}</div>

View File

@ -35,7 +35,7 @@
<form class="feed-comment-form" method="POST" action="/comments/create"> <form class="feed-comment-form" method="POST" action="/comments/create">
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}"> <input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy"> <img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off"> <input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off" data-mention>
<button type="submit" class="feed-comment-submit">Post</button> <button type="submit" class="feed-comment-submit">Post</button>
</form> </form>
{% endif %} {% endif %}

View File

@ -1,7 +1,7 @@
<div class="post-header"> <div class="post-header">
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %} {% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<div class="post-author-wrap"> <div class="post-author-wrap">
<a href="/profile/{{ _author['username'] if _author else '#' }}" class="post-author-link">{{ _author['username'] if _author else 'Unknown' }}</a> {% set _user = _author %}{% set _class = "post-author-link" %}{% include "_user_link.html" %}
{% if _author and _author.get('role') %} {% if _author and _author.get('role') %}
<span class="post-author-role">{{ _author['role'] }}</span> <span class="post-author-role">{{ _author['role'] }}</span>
{% endif %} {% endif %}

View File

@ -0,0 +1,5 @@
{% if _user %}
<a href="/profile/{{ _user['username'] }}"{% if _class %} class="{{ _class }}"{% endif %}>{{ _user['username'] }}</a>
{% else %}
<a href="#"{% if _class %} class="{{ _class }}"{% endif %}>Unknown</a>
{% endif %}

View File

@ -105,7 +105,7 @@
<div class="stat-row"> <div class="stat-row">
<span class="label"> <span class="label">
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %} {% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
<a href="/profile/{{ author['username'] }}" class="top-author-name">{{ author['username'] }}</a> {% set _user = author %}{% set _class = "top-author-name" %}{% include "_user_link.html" %}
</span> </span>
<span class="value">{{ author.get('stars', 0) }}</span> <span class="value">{{ author.get('stars', 0) }}</span>
</div> </div>

View File

@ -19,7 +19,7 @@
<div class="gist-detail-author"> <div class="gist-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %} {% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div> <div>
<a href="/profile/{{ author['username'] if author else '#' }}">{{ author['username'] if author else 'Unknown' }}</a> {% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
{% if author and author.get('role') %} {% if author and author.get('role') %}
<span style="font-size: 0.75rem; color: var(--text-muted);">&middot; {{ author['role'] }}</span> <span style="font-size: 0.75rem; color: var(--text-muted);">&middot; {{ author['role'] }}</span>
{% endif %} {% endif %}

View File

@ -50,7 +50,7 @@
{% set _size_class = "sm" %} {% set _size_class = "sm" %}
{% include "_avatar_link.html" %} {% include "_avatar_link.html" %}
<div class="landing-post-author-wrap"> <div class="landing-post-author-wrap">
<a href="/profile/{{ item.author['username'] }}" class="landing-post-author">{{ item.author['username'] }}</a> {% set _user = item.author %}{% set _class = "landing-post-author" %}{% include "_user_link.html" %}
<span class="landing-post-time">{{ item.time_ago }}</span> <span class="landing-post-time">{{ item.time_ago }}</span>
</div> </div>
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span> <span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>

View File

@ -28,7 +28,7 @@
<div class="stat-row"> <div class="stat-row">
<span class="label"> <span class="label">
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %} {% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
<a href="/profile/{{ author['username'] }}" class="top-author-name">{{ author['username'] }}</a> {% set _user = author %}{% set _class = "top-author-name" %}{% include "_user_link.html" %}
</span> </span>
<span class="value">{{ author.get('stars', 0) }}</span> <span class="value">{{ author.get('stars', 0) }}</span>
</div> </div>
@ -52,7 +52,7 @@
<li class="leaderboard-row{% if user and entry['uid'] == user['uid'] %} leaderboard-row-self{% endif %}"> <li class="leaderboard-row{% if user and entry['uid'] == user['uid'] %} leaderboard-row-self{% endif %}">
<span class="leaderboard-rank">#{{ entry['rank'] }}</span> <span class="leaderboard-rank">#{{ entry['rank'] }}</span>
{% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %} {% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<a href="/profile/{{ entry['username'] }}" class="leaderboard-name">{{ entry['username'] }}</a> {% set _user = entry %}{% set _class = "leaderboard-name" %}{% include "_user_link.html" %}
<span class="leaderboard-level">Level {{ entry.get('level', 1) }}</span> <span class="leaderboard-level">Level {{ entry.get('level', 1) }}</span>
<span class="leaderboard-stars">{{ entry['stars'] }} <span class="leaderboard-star-icon">&#x2605;</span></span> <span class="leaderboard-stars">{{ entry['stars'] }} <span class="leaderboard-star-icon">&#x2605;</span></span>
</li> </li>

View File

@ -34,7 +34,7 @@
<a href="/profile/{{ other_user['username'] }}"> <a href="/profile/{{ other_user['username'] }}">
<img src="{{ avatar_url('multiavatar', other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}" loading="lazy"> <img src="{{ avatar_url('multiavatar', other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}" loading="lazy">
</a> </a>
<a href="/profile/{{ other_user['username'] }}"><h3>{{ other_user['username'] }}</h3></a> <h3>{% set _user = other_user %}{% set _class = none %}{% include "_user_link.html" %}</h3>
</div> </div>
<div class="messages-thread"> <div class="messages-thread">

View File

@ -14,7 +14,7 @@
<img src="{{ avatar_url('multiavatar', author['username'] if author else '?', 40) }}" class="avatar-img avatar-md" alt="{{ author['username'] if author else '?' }}" loading="lazy"> <img src="{{ avatar_url('multiavatar', author['username'] if author else '?', 40) }}" class="avatar-img avatar-md" alt="{{ author['username'] if author else '?' }}" loading="lazy">
</a> </a>
<div> <div>
<a href="/profile/{{ author['username'] if author else '#' }}" class="post-detail-author">{{ author['username'] if author else 'Unknown' }}</a> {% set _user = author %}{% set _class = "post-detail-author" %}{% include "_user_link.html" %}
{% if author and author.get('role') %} {% if author and author.get('role') %}
<span class="post-detail-role">&middot; {{ author['role'] }}</span> <span class="post-detail-role">&middot; {{ author['role'] }}</span>
{% endif %} {% endif %}

View File

@ -28,7 +28,7 @@
<div class="project-detail-author"> <div class="project-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %} {% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div> <div>
<a href="/profile/{{ author['username'] if author else '#' }}">{{ author['username'] if author else 'Unknown' }}</a> {% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
{% if author and author.get('role') %} {% if author and author.get('role') %}
<span style="font-size: 0.75rem; color: var(--text-muted);">&middot; {{ author['role'] }}</span> <span style="font-size: 0.75rem; color: var(--text-muted);">&middot; {{ author['role'] }}</span>
{% endif %} {% endif %}

View File

@ -43,6 +43,10 @@ def clear_user_cache(user_uid: str) -> None:
_user_cache.pop(token) _user_cache.pop(token)
def clear_session_cache(token: str) -> None:
_user_cache.pop(token)
def get_current_user(request: Request): def get_current_user(request: Request):
token = request.cookies.get("session") token = request.cookies.get("session")
if not token: if not token:

View File

@ -23,6 +23,7 @@ NEWS_UIDS = []
NEWS_SLUGS = [] NEWS_SLUGS = []
GIST_SLUGS = [] GIST_SLUGS = []
GIST_UIDS = [] GIST_UIDS = []
BUG_UIDS = []
NOTIFICATION_UIDS = [] NOTIFICATION_UIDS = []
ADMIN_USER = {} ADMIN_USER = {}
ADMIN_TARGETS = {} ADMIN_TARGETS = {}
@ -30,10 +31,6 @@ TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"] PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = ["python", "javascript", "go", "rust", "bash", "sql", "json"] GIST_LANGUAGES = ["python", "javascript", "go", "rust", "bash", "sql", "json"]
UPLOAD_FILE = ("load_test.txt", b"locust upload payload", "text/plain") 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}' 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" PASSWORD = "testpass123"
@ -182,6 +179,20 @@ def seed_data(environment, **kwargs):
logger.info(f"Created {len(GIST_SLUGS)} seed gists") 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) ─────────────────── # ── Seed news articles (direct DB insert) ───────────────────
try: try:
from devplacepy.database import get_table, init_db from devplacepy.database import get_table, init_db
@ -298,11 +309,20 @@ def seed_data(environment, **kwargs):
except Exception as e: except Exception as e:
logger.warning(f"Harvest comment UIDs failed: {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( logger.info(
f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, " f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, "
f"{len(PROJECT_UIDS)} projects ({len(PROJECT_SLUGS)} slugs), " f"{len(PROJECT_UIDS)} projects ({len(PROJECT_SLUGS)} slugs), "
f"{len(GIST_UIDS)} gists, {len(COMMENT_UIDS)} comments, " 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} params = {"tab": tab}
if topic: if topic:
params["topic"] = 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) @task(4)
def view_post(self): def view_post(self):
@ -350,14 +374,19 @@ class DevPlaceUser(HttpUser):
if not ALL_USERNAMES: if not ALL_USERNAMES:
return return
self.client.get( 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) @task(3)
def view_projects(self): def view_projects(self):
self.client.get("/projects", params={ params = {"tab": random.choice(["recent", "released", "popular", "new"])}
"tab": random.choice(["recent", "released", "popular", "new"]), if random.random() < 0.3:
}, name="projects") 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) @task(1)
def view_landing(self): def view_landing(self):
@ -396,7 +425,23 @@ class DevPlaceUser(HttpUser):
@task(2) @task(2)
def view_gists(self): 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) @task(2)
def view_gist_detail(self): def view_gist_detail(self):
@ -551,45 +596,58 @@ class DevPlaceUser(HttpUser):
# ── social interaction ────────────────────────────────────── # ── 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) @task(2)
def vote_on_post(self): def vote_on_post(self):
if not POST_UIDS: if not POST_UIDS:
return return
self.client.post( self._vote("post", random.choice(POST_UIDS))
f"/votes/post/{random.choice(POST_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/post",
)
@task(1) @task(1)
def vote_on_project(self): def vote_on_project(self):
if not PROJECT_UIDS: if not PROJECT_UIDS:
return return
self.client.post( self._vote("project", random.choice(PROJECT_UIDS))
f"/votes/project/{random.choice(PROJECT_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/project",
)
@task(1) @task(1)
def vote_on_comment(self): def vote_on_comment(self):
if not COMMENT_UIDS: if not COMMENT_UIDS:
return return
self.client.post( self._vote("comment", random.choice(COMMENT_UIDS))
f"/votes/comment/{random.choice(COMMENT_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/comment",
)
@task(1) @task(1)
def vote_on_gist(self): def vote_on_gist(self):
if not GIST_UIDS: if not GIST_UIDS:
return return
self.client.post( self._vote("gist", random.choice(GIST_UIDS))
f"/votes/gist/{random.choice(GIST_UIDS)}",
data={"value": random.choice([1, -1])}, @task(1)
name="votes/gist", def vote_on_bug(self):
) if not BUG_UIDS:
return
self._vote("bug", random.choice(BUG_UIDS))
@task(1) @task(1)
def follow_user(self): def follow_user(self):
@ -632,6 +690,15 @@ class DevPlaceUser(HttpUser):
def view_messages(self): def view_messages(self):
self.client.get("/messages", name="messages") 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) @task(1)
def view_notifications(self): def view_notifications(self):
resp = self.client.get("/notifications", name="notifications") resp = self.client.get("/notifications", name="notifications")
@ -639,6 +706,21 @@ class DevPlaceUser(HttpUser):
NOTIFICATION_UIDS.extend( NOTIFICATION_UIDS.extend(
m for m in matches if m not in NOTIFICATION_UIDS 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) @task(1)
def mark_all_notifications_read(self): def mark_all_notifications_read(self):
@ -741,14 +823,45 @@ class DevPlaceUser(HttpUser):
else: else:
resp.failure(f"upload failed: status={resp.status_code}") 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 ────────────────────────────────────────── # ── static / media ──────────────────────────────────────────
@task(2) @task(2)
def view_multiavatar(self): def view_multiavatar(self):
seed = random.choice(ALL_USERNAMES) if ALL_USERNAMES else "anon" 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" 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) @task(1)
def comment_on_news(self): def comment_on_news(self):
@ -760,6 +873,49 @@ class DevPlaceUser(HttpUser):
"target_type": "news", "target_type": "news",
}, name="comments/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): class AdminUser(HttpUser):
weight = 1 weight = 1

View File

@ -1,6 +1,7 @@
import hashlib import hashlib
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL from tests.conftest import BASE_URL
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.utils import hash_password, generate_uid 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("#password", "secret123")
page.fill("#confirm_password", "secret123") page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')") page.click("button:has-text('Create account')")
page.wait_for_timeout(300) expect(page.locator("text=Username already taken")).to_be_visible()
assert page.is_visible("text=Username already taken")
def test_signup_existing_email(page, app_server): 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("#password", "secret123")
page.fill("#confirm_password", "secret123") page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')") page.click("button:has-text('Create account')")
page.wait_for_timeout(300) expect(page.locator("text=Email already registered")).to_be_visible()
assert page.is_visible("text=Email already registered")
def test_signup_password_mismatch(page, app_server): 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("#password", "secret123")
page.fill("#confirm_password", "different456") page.fill("#confirm_password", "different456")
page.click("button:has-text('Create account')") page.click("button:has-text('Create account')")
page.wait_for_timeout(300) expect(page.locator("text=Passwords do not match")).to_be_visible()
assert page.is_visible("text=Passwords do not match")
def test_signup_short_password(page, app_server): 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("#password", "ab")
page.fill("#confirm_password", "ab") page.fill("#confirm_password", "ab")
page.click("button:has-text('Create account')") page.click("button:has-text('Create account')")
page.wait_for_timeout(300) expect(page.locator("text=Password must be at least 6 characters")).to_be_visible()
assert page.is_visible("text=Password must be at least 6 characters")
def test_signup_invalid_username(page, app_server): 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("#password", "secret123")
page.fill("#confirm_password", "secret123") page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')") page.click("button:has-text('Create account')")
page.wait_for_timeout(300) expect(page.locator("text=Username must be between 3 and 32 characters")).to_be_visible()
assert page.is_visible("text=Username must be between 3 and 32 characters")
def test_login_page_loads(page, app_server): 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("#email", "wrongpw@test.devplace")
page.fill("#password", "badpassword") page.fill("#password", "badpassword")
page.click("button:has-text('Sign in')") page.click("button:has-text('Sign in')")
page.wait_for_timeout(300) expect(page.locator("text=Invalid email or password")).to_be_visible()
assert page.is_visible("text=Invalid email or password")
def test_login_nonexistent_email(page, app_server): 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("#email", "nobody@nowhere.devplace")
page.fill("#password", "secret123") page.fill("#password", "secret123")
page.click("button:has-text('Sign in')") page.click("button:has-text('Sign in')")
page.wait_for_timeout(300) expect(page.locator("text=Invalid email or password")).to_be_visible()
assert page.is_visible("text=Invalid email or password")
def test_login_remember_me(page, app_server): def test_login_remember_me(page, app_server):

View File

@ -171,19 +171,24 @@ def test_feed_inline_comment(alice):
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded") page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
inline_form = page.locator(".feed-comment-form").first inline_form = page.locator(".feed-comment-form").first
if inline_form.is_visible(): expect(inline_form).to_be_visible()
inline_form.locator("input[name='content']").fill("Inline comment from feed") inline_form.locator("input[name='content']").fill("Inline comment from feed")
inline_form.locator(".feed-comment-submit").click() inline_form.locator(".feed-comment-submit").click()
page.wait_for_timeout(500) 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): def test_feed_inline_comment_placeholder(alice):
page, _ = alice page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
inline_input = page.locator(".feed-comment-form input").first page.locator(".feed-fab").first.click()
if inline_input.is_visible(): page.fill("#post-content", "Placeholder check post " + "x" * 20)
placeholder = inline_input.get_attribute("placeholder") page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
assert placeholder == "Your opinion goes here..." 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): def test_feed_signals_topic(alice):

View File

@ -77,9 +77,9 @@ def test_comment_voted_state_persists(alice):
textarea = page.locator(".comment-form textarea[name='content']") textarea = page.locator(".comment-form textarea[name='content']")
textarea.fill("Comment whose vote should persist") textarea.fill("Comment whose vote should persist")
page.locator(".comment-form button:has-text('Post')").click() 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.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") page.reload(wait_until="domcontentloaded")
expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b")) 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 = page.locator(".comment-form textarea[name='content']")
textarea.fill("This is a test comment from Playwright") textarea.fill("This is a test comment from Playwright")
page.locator(".comment-form button:has-text('Post')").click() page.locator(".comment-form button:has-text('Post')").click()
page.wait_for_timeout(500) expect(page.locator(".comment-text:has-text('This is a test comment from Playwright')")).to_be_visible()
assert page.is_visible("text=This is a test comment from Playwright")
def test_comment_voting(alice): def test_comment_voting(alice):
@ -123,11 +122,11 @@ def test_comment_voting(alice):
textarea = page.locator(".comment-form textarea[name='content']") textarea = page.locator(".comment-form textarea[name='content']")
textarea.fill("Votable comment") textarea.fill("Votable comment")
page.locator(".comment-form button:has-text('Post')").click() 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") vote_btns = page.locator(".comment-vote-btn")
upvote = vote_btns.first upvote = vote_btns.first
upvote.click() 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): 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 = page.locator(".comment-form textarea[name='content']")
textarea.fill("Comment to delete") textarea.fill("Comment to delete")
page.locator(".comment-form button:has-text('Post')").click() 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 delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
if delete_btn.is_visible(): expect(delete_btn).to_be_visible()
delete_btn.click() delete_btn.click()
page.wait_for_timeout(500) expect(comment).to_have_count(0)
def test_comment_form_elements(alice): def test_comment_form_elements(alice):
@ -170,7 +170,7 @@ def test_multiple_comments_on_post(alice):
for i in range(3): for i in range(3):
page.locator(".comment-form textarea[name='content']").fill(f"Comment number {i + 1}") page.locator(".comment-form textarea[name='content']").fill(f"Comment number {i + 1}")
page.locator(".comment-form button:has-text('Post')").click() 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 1")
assert page.is_visible("text=Comment number 3") 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") create_post(page, "showcase", "Full comment lifecycle post")
page.locator(".comment-form textarea[name='content']").fill("Lifecycle comment") page.locator(".comment-form textarea[name='content']").fill("Lifecycle comment")
page.locator(".comment-form button:has-text('Post')").click() page.locator(".comment-form button:has-text('Post')").click()
page.wait_for_timeout(300) comment = page.locator(".comment-text:has-text('Lifecycle comment')")
assert page.is_visible("text=Lifecycle comment") expect(comment).to_be_visible()
vote_up = page.locator(".comment-vote-btn").first vote_up = page.locator(".comment-vote-btn").first
vote_up.click() 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')") delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
if delete_btn.is_visible(): expect(delete_btn).to_be_visible()
delete_btn.click() delete_btn.click()
page.wait_for_timeout(300) expect(comment).to_have_count(0)
def test_post_edit_button(alice): 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-title", "Edited Title")
page.fill("#edit-content", "Edited content for the post") page.fill("#edit-content", "Edited content for the post")
page.click("button:has-text('Save Changes')") page.click("button:has-text('Save Changes')")
page.wait_for_timeout(500) expect(page.locator("text=Edited Title")).to_be_visible()
assert page.is_visible("text=Edited Title")
def test_post_across_all_topics(alice): def test_post_across_all_topics(alice):
@ -239,8 +238,7 @@ def test_post_across_all_topics(alice):
for topic in topics: for topic in topics:
create_post(page, topic, f"Topic test post for {topic}") create_post(page, topic, f"Topic test post for {topic}")
badge = page.locator(f".badge-{topic}") badge = page.locator(f".badge-{topic}")
assert badge.is_visible() expect(badge).to_be_visible()
page.wait_for_timeout(200)
def test_delete_own_post(alice): def test_delete_own_post(alice):

64
tests/test_push.py Normal file
View File

@ -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

35
tests/test_xss.py Normal file
View File

@ -0,0 +1,35 @@
from tests.conftest import BASE_URL
from tests.test_post import create_post
POST_PAYLOAD = "XSSPROBE <img src=x onerror=alert('p')><script>alert('s')</script> end of post"
COMMENT_PAYLOAD = "CMTPROBE <img src=x onerror=alert('c')><script>alert('s')</script> 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 "<script" not in html
assert "onerror" not in html
assert not fired, f"XSS payload executed: {fired}"
def test_comment_content_is_sanitized(alice):
page, _ = alice
fired = []
page.on("dialog", lambda d: (fired.append(d.message), d.dismiss()))
create_post(page, "random", "Clean host post for comment sanitization")
page.locator(".comment-form textarea[name='content']").fill(COMMENT_PAYLOAD)
page.locator(".comment-form button:has-text('Post')").click()
comment = page.locator(".comment-text:has-text('CMTPROBE')")
comment.wait_for(state="visible")
page.locator(".comment-text p").first.wait_for(state="visible")
html = comment.inner_html().lower()
assert "<script" not in html
assert "onerror" not in html
assert not fired, f"XSS payload executed: {fired}"