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/
.env
devplace.db*
devplace-services.lock
notification-private.pem
notification-private.pkcs8.pem
notification-public.pem

View File

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

View File

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

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)
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"])

View File

@ -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}")

View File

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

View File

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

View File

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

View File

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

View File

@ -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) + "<span>@" + r.username + "</span>";
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);

View File

@ -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)}<span>${r.username}</span>`;
const label = document.createElement("span");
label.textContent = r.username;
item.append(Avatar.imgElement(r.username), label);
dropdown.appendChild(item);
}
DomUtils.show(dropdown);

View File

@ -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(",");
};

View File

@ -20,7 +20,7 @@
<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">
</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>
</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">
<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">
<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>
</form>
{% endif %}

View File

@ -1,7 +1,7 @@
<div class="post-header">
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<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') %}
<span class="post-author-role">{{ _author['role'] }}</span>
{% 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">
<span class="label">
{% 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 class="value">{{ author.get('stars', 0) }}</span>
</div>

View File

@ -19,7 +19,7 @@
<div class="gist-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<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') %}
<span style="font-size: 0.75rem; color: var(--text-muted);">&middot; {{ author['role'] }}</span>
{% endif %}

View File

@ -50,7 +50,7 @@
{% set _size_class = "sm" %}
{% include "_avatar_link.html" %}
<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>
</div>
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>

View File

@ -28,7 +28,7 @@
<div class="stat-row">
<span class="label">
{% 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 class="value">{{ author.get('stars', 0) }}</span>
</div>
@ -52,7 +52,7 @@
<li class="leaderboard-row{% if user and entry['uid'] == user['uid'] %} leaderboard-row-self{% endif %}">
<span class="leaderboard-rank">#{{ entry['rank'] }}</span>
{% 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-stars">{{ entry['stars'] }} <span class="leaderboard-star-icon">&#x2605;</span></span>
</li>

View File

@ -34,7 +34,7 @@
<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">
</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 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">
</a>
<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') %}
<span class="post-detail-role">&middot; {{ author['role'] }}</span>
{% endif %}

View File

@ -28,7 +28,7 @@
<div class="project-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<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') %}
<span style="font-size: 0.75rem; color: var(--text-muted);">&middot; {{ author['role'] }}</span>
{% endif %}

View File

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

View File

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

View File

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

View File

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

View File

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

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}"