chore: reorganize test files into domain-specific subdirectories under tests/

Split the monolithic test directory into three tiers (unit, api, e2e) with a path-mirroring directory structure. Added corresponding Makefile targets (test-unit, test-api, test-e2e) and updated all documentation references (CLAUDE.md, README.md, testing-cicd.html, testing-framework.html, testing-make.html) to reflect the new layout and naming conventions.
This commit is contained in:
2026-06-13 14:32:33 +00:00
parent 014c4c6bb3
commit 43f4011005
268 changed files with 20634 additions and 9206 deletions
View File
View File
View File
+127
View File
@@ -0,0 +1,127 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_admin_ai_quota_resets_recorded(seeded_db):
admin = _admin(seeded_db)
_, target = _member()
uid = _db_user(target)["uid"]
admin.post(f"{BASE_URL}/admin/users/{uid}/reset-ai-quota", headers=JSON_audit_log, allow_redirects=False)
admin.post(f"{BASE_URL}/admin/ai-quota/reset-guests", headers=JSON_audit_log, allow_redirects=False)
admin.post(f"{BASE_URL}/admin/ai-quota/reset-all", headers=JSON_audit_log, allow_redirects=False)
assert _find(admin, "admin.user.ai_quota.reset", lambda e: e.get("target_uid") == uid) is not None
assert _find(admin, "admin.ai_quota.reset_guests", lambda e: True) is not None
assert _find(admin, "admin.ai_quota.reset_all", lambda e: True) is not None
+180
View File
@@ -0,0 +1,180 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_audit_log_requires_admin(seeded_db):
# guest: JSON_audit_log -> 401, browser -> redirect to login
assert (
requests.get(
f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, allow_redirects=False
).status_code
== 401
)
assert (
requests.get(
f"{BASE_URL}/admin/audit-log", allow_redirects=False
).status_code
== 303
)
# authenticated member -> 403 for JSON_audit_log
key = _member_key()
assert (
requests.get(
f"{BASE_URL}/admin/audit-log",
headers={**JSON_audit_log, "X-API-KEY": key},
allow_redirects=False,
).status_code
== 403
)
# admin -> 200
admin = _admin(seeded_db)
assert admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log).status_code == 200
def test_audit_event_detail_requires_admin(seeded_db):
admin = _admin(seeded_db)
uid = _audit(admin)["entries"][0]["uid"]
assert (
requests.get(
f"{BASE_URL}/admin/audit-log/{uid}", headers=JSON_audit_log, allow_redirects=False
).status_code
== 401
)
key = _member_key()
assert (
requests.get(
f"{BASE_URL}/admin/audit-log/{uid}",
headers={**JSON_audit_log, "X-API-KEY": key},
allow_redirects=False,
).status_code
== 403
)
assert admin.get(f"{BASE_URL}/admin/audit-log/{uid}", headers=JSON_audit_log).status_code == 200
def test_audit_event_detail_404(seeded_db):
admin = _admin(seeded_db)
r = admin.get(f"{BASE_URL}/admin/audit-log/does-not-exist-uid", headers=JSON_audit_log)
assert r.status_code == 404
def test_audit_event_detail_json_shape(seeded_db):
admin = _admin(seeded_db)
uid = _audit(admin)["entries"][0]["uid"]
detail = admin.get(f"{BASE_URL}/admin/audit-log/{uid}", headers=JSON_audit_log).json()
assert "event" in detail and "links" in detail
assert detail["event"]["uid"] == uid
assert isinstance(detail["links"], list)
+18
View File
@@ -0,0 +1,18 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
def test_containers_admin_data_denied_for_non_admin(app_server, seeded_db):
s = requests.Session()
s.post(
f"{BASE_URL}/auth/login",
data={
"email": seeded_db["bob"]["email"],
"password": seeded_db["bob"]["password"],
},
)
r = s.get(f"{BASE_URL}/admin/containers/data", allow_redirects=False)
assert r.status_code in (302, 303, 403)
+10
View File
@@ -0,0 +1,10 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
def test_containers_admin_page_requires_login(app_server):
r = requests.get(f"{BASE_URL}/admin/containers", allow_redirects=False)
assert r.status_code in (302, 303)
View File
+115
View File
@@ -0,0 +1,115 @@
# retoor <retoor@molodetz.nl>
import io
import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
def test_admin_trash_requires_admin(seeded_db):
member = _login_media(seeded_db, "bob")
r = member.get(f"{BASE_URL}/admin/media", allow_redirects=False)
assert r.status_code in (302, 303)
anon = requests.Session()
assert anon.get(f"{BASE_URL}/admin/media", allow_redirects=False).status_code in (
302,
303,
)
+131
View File
@@ -0,0 +1,131 @@
# retoor <retoor@molodetz.nl>
import io
import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
def test_purge_requires_admin(seeded_db):
owner, name = _signup_media()
uid = _upload_media(owner)
_create_post_media(owner, uid)
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
member = _login_media(seeded_db, "bob")
r = member.post(f"{BASE_URL}/admin/media/{uid}/purge", allow_redirects=False)
assert r.status_code in (302, 303)
def test_admin_purge_hard_deletes(seeded_db):
owner, name = _signup_media()
uid = _upload_media(owner)
_create_post_media(owner, uid)
file_url = next(m["url"] for m in _media_json(owner, name)["media"] if m["uid"] == uid)
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
admin = _login_media(seeded_db, "alice")
assert owner.get(f"{BASE_URL}{file_url}").status_code == 200
r = admin.post(f"{BASE_URL}/admin/media/{uid}/purge", headers=JSON_media)
assert r.status_code == 200
# row gone (not in trash, cannot restore) and file removed from disk
trash = admin.get(f"{BASE_URL}/admin/media", headers=JSON_media).json()
assert all(m["uid"] != uid for m in trash["media"])
assert owner.get(f"{BASE_URL}{file_url}").status_code == 404
View File
+126
View File
@@ -0,0 +1,126 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_admin_news_actions_recorded(seeded_db):
admin = _admin(seeded_db)
uid = _seed_news_audit_log()
admin.post(f"{BASE_URL}/admin/news/{uid}/toggle", headers=JSON_audit_log, allow_redirects=False)
admin.post(f"{BASE_URL}/admin/news/{uid}/publish", headers=JSON_audit_log, allow_redirects=False)
admin.post(f"{BASE_URL}/admin/news/{uid}/landing", headers=JSON_audit_log, allow_redirects=False)
admin.post(f"{BASE_URL}/admin/news/{uid}/delete", headers=JSON_audit_log, allow_redirects=False)
for key in ("news.featured.toggle", "news.publish.toggle", "news.landing.toggle", "news.delete"):
assert _find(admin, key, lambda e: e.get("target_uid") == uid) is not None, key
+128
View File
@@ -0,0 +1,128 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_service_lifecycle_recorded(seeded_db):
admin = _admin(seeded_db)
try:
admin.post(f"{BASE_URL}/admin/services/audit/stop", headers=JSON_audit_log, allow_redirects=False)
admin.post(f"{BASE_URL}/admin/services/audit/start", headers=JSON_audit_log, allow_redirects=False)
admin.post(f"{BASE_URL}/admin/services/audit/run", headers=JSON_audit_log, allow_redirects=False)
assert _find(admin, "service.stop", lambda e: e.get("target_uid") == "audit") is not None
assert _find(admin, "service.start", lambda e: e.get("target_uid") == "audit") is not None
assert _find(admin, "service.run_now", lambda e: e.get("target_uid") == "audit") is not None
finally:
admin.post(f"{BASE_URL}/admin/services/audit/start", allow_redirects=False)
+145
View File
@@ -0,0 +1,145 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_admin_setting_update_recorded(seeded_db):
admin = _admin(seeded_db)
settings = get_table("site_settings")
original = (settings.find_one(key="site_tagline") or {}).get("value", "")
newval = _unique("autag")
# keep the booleans pinned: the settings form inserts every missing key, so
# an unset registration_open/maintenance_mode would otherwise default to ""
# and silently close registration for the rest of the suite.
keep = {"registration_open": "1", "maintenance_mode": "0"}
try:
admin.post(
f"{BASE_URL}/admin/settings",
headers=JSON_audit_log,
data={"site_tagline": newval, **keep},
allow_redirects=False,
)
event = _find(
admin, "admin.setting.update", lambda e: e.get("new_value") == newval
)
assert event is not None
assert event["target_type"] == "setting"
assert event["target_uid"] == "site_tagline"
finally:
admin.post(
f"{BASE_URL}/admin/settings",
data={"site_tagline": original, **keep},
allow_redirects=False,
)
View File
+61
View File
@@ -0,0 +1,61 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON_content_negotiation = {"Accept": "application/json"}
_counter_content_negotiation = [0]
def _session_content_negotiation(password="secret123"):
_counter_content_negotiation[0] += 1
name = f"cn{int(time.time() * 1000)}{_counter_content_negotiation[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": password,
"confirm_password": password,
},
allow_redirects=True,
)
return s, name
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
def test_admin_pages_negotiate(seeded_db):
users = get_table("users")
# unauthenticated: JSON_content_negotiation -> 401, browser -> redirect to login
assert (
requests.get(
f"{BASE_URL}/admin/users", headers=JSON_content_negotiation, allow_redirects=False
).status_code
== 401
)
assert (
requests.get(f"{BASE_URL}/admin/users", allow_redirects=False).status_code
== 303
)
# authenticated non-admin (a fresh member): JSON_content_negotiation -> 403
_, member = _session_content_negotiation()
member_key = users.find_one(username=member)["api_key"]
assert (
requests.get(
f"{BASE_URL}/admin/users",
headers={**JSON_content_negotiation, "X-API-KEY": member_key},
allow_redirects=False,
).status_code
== 403
)
# admin: promote a user whose key has not been cached yet
admin = _session_content_negotiation()[1]
admin_row = users.find_one(username=admin)
users.update({"uid": admin_row["uid"], "role": "Admin"}, ["uid"])
r = requests.get(
f"{BASE_URL}/admin/users", headers={**JSON_content_negotiation, "X-API-KEY": admin_row["api_key"]}
)
assert r.status_code == 200
assert "users" in r.json()
first = r.json()["users"][0]
assert "password_hash" not in first and "api_key" not in first
+129
View File
@@ -0,0 +1,129 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_admin_password_reset_recorded(seeded_db):
admin = _admin(seeded_db)
_, target = _member()
uid = _db_user(target)["uid"]
admin.post(f"{BASE_URL}/admin/users/{uid}/password", headers=JSON_audit_log, data={"password": "newsecret123"}, allow_redirects=False)
event = _find(admin, "admin.user.password.reset", lambda e: e.get("target_uid") == uid)
assert event is not None
# the password value is never recorded
assert "newsecret123" not in (event.get("summary") or "")
import json as _json
assert "newsecret123" not in _json.dumps(event.get("metadata") or {})
+148
View File
@@ -0,0 +1,148 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_admin_role_change_and_self_denied(seeded_db):
admin = _admin(seeded_db)
_, target = _member()
target_uid = _db_user(target)["uid"]
admin.post(
f"{BASE_URL}/admin/users/{target_uid}/role",
headers=JSON_audit_log,
data={"role": "admin"},
allow_redirects=False,
)
changed = _find(
admin,
"admin.user.role.change",
lambda e: e.get("target_uid") == target_uid and e.get("result") == "success",
)
assert changed is not None
assert changed["new_value"] == "Admin"
# self role change is denied and recorded
alice_uid = _db_user("alice_test")["uid"]
admin.post(
f"{BASE_URL}/admin/users/{alice_uid}/role",
headers=JSON_audit_log,
data={"role": "member"},
allow_redirects=False,
)
denied = _find(
admin,
"admin.user.role.change",
lambda e: e.get("target_uid") == alice_uid and e.get("result") == "denied",
)
assert denied is not None
+131
View File
@@ -0,0 +1,131 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_admin_user_toggle_and_self_disable_denied(seeded_db):
admin = _admin(seeded_db)
_, target = _member()
uid = _db_user(target)["uid"]
admin.post(f"{BASE_URL}/admin/users/{uid}/toggle", headers=JSON_audit_log, allow_redirects=False)
assert _find(admin, "admin.user.active.disable", lambda e: e.get("target_uid") == uid) is not None
alice_uid = _db_user("alice_test")["uid"]
admin.post(f"{BASE_URL}/admin/users/{alice_uid}/toggle", headers=JSON_audit_log, allow_redirects=False)
denied = _find(
admin,
"admin.user.active.disable",
lambda e: e.get("target_uid") == alice_uid and e.get("result") == "denied",
)
assert denied is not None
View File
+34
View File
@@ -0,0 +1,34 @@
# retoor <retoor@molodetz.nl>
import base64
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_api_auth = [0]
def _signup_api_auth(password="secret123"):
_counter_api_auth[0] += 1
name = f"apiauth{int(time.time() * 1000)}{_counter_api_auth[0]}"
email = f"{name}@t.dev"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": email,
"password": password,
"confirm_password": password,
},
allow_redirects=True,
)
return session, name, email, password
def _user_api_auth(name):
return get_table("users").find_one(username=name)
def _key_api_auth(name):
return _user_api_auth(name)["api_key"]
def test_signup_assigns_api_key(app_server):
_, name, _, _ = _signup_api_auth()
key = _key_api_auth(name)
assert key and len(key) == 36
View File
+161
View File
@@ -0,0 +1,161 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_audit_log_json_shape(seeded_db):
admin = _admin(seeded_db)
data = _audit(admin)
assert set(["entries", "pagination", "filters", "options"]).issubset(data.keys())
assert isinstance(data["entries"], list)
assert data["pagination"]["per_page"] == 25
entry = data["entries"][0]
for field in ("uid", "event_key", "category", "actor_kind", "actor_role", "result"):
assert field in entry
for opt in ("event_key", "category", "actor_role", "origin", "result"):
assert opt in data["options"]
def test_signup_is_recorded(seeded_db):
_, name = _member()
admin = _admin(seeded_db)
event = _find(admin, "auth.signup", lambda e: e.get("target_label") == name)
assert event is not None
assert event["category"] == "auth"
assert event["result"] == "success"
def test_filter_by_event_key(seeded_db):
admin = _admin(seeded_db)
data = _audit(admin, event_key="auth.signup")
assert data["pagination"]["total"] >= 1
assert all(e["event_key"] == "auth.signup" for e in data["entries"])
assert data["filters"]["event_key"] == "auth.signup"
def test_filter_by_category(seeded_db):
admin = _admin(seeded_db)
data = _audit(admin, category="auth")
assert data["pagination"]["total"] >= 1
assert all(e["category"] == "auth" for e in data["entries"])
def test_post_create_emits_reward_side_effects(seeded_db):
s, name = _member()
uid = _db_user(name)["uid"]
_new_post(s)
admin = _admin(seeded_db)
assert _find(admin, "reward.xp.grant", lambda e: e.get("actor_uid") == uid) is not None
assert _find(admin, "reward.badge.award", lambda e: e.get("actor_uid") == uid) is not None
View File
+181
View File
@@ -0,0 +1,181 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_login_failure_is_recorded(seeded_db):
email = f"{_unique('aufail')}@t.dev"
requests.post(
f"{BASE_URL}/auth/login",
data={"email": email, "password": "wrongpassword"},
allow_redirects=False,
)
admin = _admin(seeded_db)
event = _find(
admin,
"auth.login.failure",
lambda e: email in (e.get("summary") or ""),
)
assert event is not None
assert event["result"] == "failure"
assert event["actor_kind"] == "guest"
def test_filter_by_result(seeded_db):
# ensure at least one failure exists
requests.post(
f"{BASE_URL}/auth/login",
data={"email": f"{_unique('aurf')}@t.dev", "password": "nope"},
allow_redirects=False,
)
admin = _admin(seeded_db)
data = _audit(admin, result="failure")
assert data["pagination"]["total"] >= 1
assert all(e["result"] == "failure" for e in data["entries"])
def test_rate_limit_block_recorded(monkeypatch):
# The limiter is disabled suite-wide (conftest sets DEVPLACE_DISABLE_RATE_LIMIT),
# and the shared server runs in its own process, so this runs in-process with the
# limiter re-enabled for itself - the same approach as test_ratelimit.py - and
# asserts the block emits the security.rate_limit.block audit row.
import devplacepy.main as m
from starlette.testclient import TestClient
monkeypatch.setattr(m, "RATE_LIMIT_DISABLED", False)
monkeypatch.setattr(
m,
"get_int_setting",
lambda key, default: {
"rate_limit_per_minute": 1,
"rate_limit_window_seconds": 60,
}.get(key, default),
)
m._rate_limit_store.clear()
ip = "203.0.113.77"
client = TestClient(m.app)
codes = [
client.post(
"/auth/login",
data={"email": "x@y.dev", "password": "nope"},
headers={"X-Real-IP": ip},
).status_code
for _ in range(4)
]
assert 429 in codes, codes
event = get_table("audit_log").find_one(
event_key="security.rate_limit.block", result="denied"
)
assert event is not None
+145
View File
@@ -0,0 +1,145 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_login_logout_recorded(seeded_db):
name = _unique("aulog")
email = f"{name}@t.dev"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": email,
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
# explicit login generates auth.login.success (signup alone does not)
s.post(
f"{BASE_URL}/auth/login",
data={"email": email, "password": "secret123"},
allow_redirects=True,
)
s.get(f"{BASE_URL}/auth/logout", allow_redirects=False)
admin = _admin(seeded_db)
success = _find(
admin, "auth.login.success", lambda e: e.get("target_label") == name
)
logout = _find(admin, "auth.logout", lambda e: e.get("target_label") == name)
assert success is not None
assert logout is not None
@@ -1,18 +1,15 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.docs_api import API_GROUPS, build_services_group
from devplacepy.services.manager import service_manager
JSON = {"Accept": "application/json"}
_counter = [0]
JSON_auth_matrix = {"Accept": "application/json"}
_counter_auth_matrix = [0]
LOGIN_REDIRECT = "/auth/login"
FEED_REDIRECT = "/feed"
JSON_BODY_OVERRIDES = {
"push-register": {
"endpoint": "https://example.test/p",
@@ -20,11 +17,9 @@ JSON_BODY_OVERRIDES = {
},
"gateway-chat": {"model": "x", "messages": [{"role": "user", "content": "hi"}]},
}
def _signup(role="Member"):
_counter[0] += 1
name = f"authmx{int(time.time() * 1000)}{_counter[0]}"
def _signup_auth_matrix(role="Member"):
_counter_auth_matrix[0] += 1
name = f"authmx{int(time.time() * 1000)}{_counter_auth_matrix[0]}"
requests.post(
f"{BASE_URL}/auth/signup",
data={
@@ -38,8 +33,6 @@ def _signup(role="Member"):
row = get_table("users").find_one(username=name)
get_table("users").update({"uid": row["uid"], "role": role}, ["uid"])
return get_table("users").find_one(username=name)["api_key"]
def _all_endpoints():
endpoints = []
for group in API_GROUPS:
@@ -48,8 +41,6 @@ def _all_endpoints():
build_services_group(service_manager.describe_all(), BASE_URL)["endpoints"]
)
return endpoints
def _build(endpoint):
path = endpoint["path"]
params, data, files = {}, {}, None
@@ -82,8 +73,6 @@ def _build(endpoint):
if endpoint["encoding"] == "multipart" and files is None:
files = {"file": ("probe.txt", b"hello", "text/plain")}
return path, params, data, json_body, files
def _call(endpoint, headers):
path, params, data, json_body, files = _build(endpoint)
kwargs = dict(headers=headers, params=params, allow_redirects=False, timeout=15)
@@ -96,8 +85,6 @@ def _call(endpoint, headers):
elif data:
kwargs["data"] = data
return requests.request(endpoint["method"], f"{BASE_URL}{path}", **kwargs)
def _redirects_to(response, target):
return response.status_code in (
302,
@@ -105,16 +92,10 @@ def _redirects_to(response, target):
307,
308,
) and target in response.headers.get("location", "")
def _is_auth_rejected(response):
return response.status_code == 401 or _redirects_to(response, LOGIN_REDIRECT)
def _is_role_rejected(response):
return response.status_code == 403 or _redirects_to(response, FEED_REDIRECT)
def _is_allowed(response):
return not _is_auth_rejected(response) and response.status_code != 403
@@ -122,8 +103,8 @@ def _is_allowed(response):
def test_documented_minimal_role_matches_enforcement(seeded_db):
# depend on seeded_db so alice_test keeps the is_first -> Admin slot; our signups
# below are never the first user and cannot demote the suite's admin fixture.
member_key = _signup("Member")
member = {**JSON, "X-API-KEY": member_key}
member_key = _signup_auth_matrix("Member")
member = {**JSON_auth_matrix, "X-API-KEY": member_key}
endpoints = _all_endpoints()
assert len(endpoints) >= 50
@@ -134,7 +115,7 @@ def test_documented_minimal_role_matches_enforcement(seeded_db):
label = (
f"{endpoint['method']} {endpoint['path']} ({endpoint['id']}, doc={auth})"
)
anon = _call(endpoint, JSON)
anon = _call(endpoint, JSON_auth_matrix)
if auth == "public":
if not _is_allowed(anon):
+34
View File
@@ -0,0 +1,34 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
def _session_validation():
s = requests.Session()
name = f"val_{int(time.time() * 1000)}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s
def test_signup_short_username_rerenders_with_message(app_server):
r = requests.post(
f"{BASE_URL}/auth/signup",
data={
"username": "ab",
"email": "x@y.zz",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=False,
)
assert r.status_code == 400
assert "Username must be between 3 and 32 characters" in r.text
+12
View File
@@ -0,0 +1,12 @@
# retoor <retoor@molodetz.nl>
import requests
from devplacepy.avatar import avatar_url, generate_avatar_svg
from tests.conftest import BASE_URL
def test_avatar_endpoint_serves_svg(app_server):
r = requests.get(f"{BASE_URL}/avatar/multiavatar/alice_test?size=64")
assert r.status_code == 200
assert "svg" in r.headers.get("content-type", "").lower()
assert "<svg" in r.text
View File
+217
View File
@@ -0,0 +1,217 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
_counter_bookmarks = [0]
AJAX_bookmarks = {"X-Requested-With": "fetch"}
def _session_bookmarks():
_counter_bookmarks[0] += 1
name = f"bkm{int(time.time() * 1000)}{_counter_bookmarks[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _uid_bookmarks(username):
return get_table("users").find_one(username=username)["uid"]
def _make_post_bookmarks(owner_uid, title):
uid = generate_uid()
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner_uid,
"slug": f"{uid[:8]}-bookmark-post",
"title": title,
"content": "bookmark target content",
"topic": "random",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _make_gist(owner_uid, title):
uid = generate_uid()
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner_uid,
"slug": f"{uid[:8]}-bookmark-gist",
"title": title,
"description": None,
"source_code": "print('x')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def test_bookmark_recorded(seeded_db):
s, _ = _member()
post = _new_post(s)
s.post(f"{BASE_URL}/bookmarks/post/{post['uid']}", allow_redirects=False)
admin = _admin(seeded_db)
assert _find(admin, "bookmark.add", lambda e: e.get("target_uid") == post["uid"]) is not None
def test_bookmark_toggle_on(app_server):
s, name = _session_bookmarks()
post_uid = _make_post_bookmarks(_uid_bookmarks(name), "Bookmark toggle on")
r = s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX_bookmarks)
assert r.json() == {"saved": True}
assert get_table("bookmarks").count(user_uid=_uid_bookmarks(name), target_uid=post_uid) == 1
def test_bookmark_toggle_off(app_server):
s, name = _session_bookmarks()
post_uid = _make_post_bookmarks(_uid_bookmarks(name), "Bookmark toggle off")
s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX_bookmarks)
r = s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX_bookmarks)
assert r.json() == {"saved": False}
assert (
get_table("bookmarks").count(
user_uid=_uid_bookmarks(name), target_uid=post_uid, deleted_at=None
)
== 0
)
def test_invalid_target_type_returns_400(app_server):
s, name = _session_bookmarks()
post_uid = _make_post_bookmarks(_uid_bookmarks(name), "Bad target")
r = s.post(f"{BASE_URL}/bookmarks/widget/{post_uid}", headers=AJAX_bookmarks)
assert r.status_code == 400
def test_bookmark_requires_login(app_server):
owner_s, owner_name = _session_bookmarks()
post_uid = _make_post_bookmarks(_uid_bookmarks(owner_name), "Needs login")
anon = requests.Session()
r = anon.post(
f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX_bookmarks, allow_redirects=False
)
assert r.status_code == 303
assert get_table("bookmarks").count(target_uid=post_uid) == 0
@@ -1,19 +1,16 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
_counter = [0]
AJAX = {"X-Requested-With": "fetch"}
def _session():
_counter[0] += 1
name = f"bkm{int(time.time() * 1000)}{_counter[0]}"
_counter_bookmarks = [0]
AJAX_bookmarks = {"X-Requested-With": "fetch"}
def _session_bookmarks():
_counter_bookmarks[0] += 1
name = f"bkm{int(time.time() * 1000)}{_counter_bookmarks[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
@@ -26,13 +23,9 @@ def _session():
allow_redirects=True,
)
return s, name
def _uid(username):
def _uid_bookmarks(username):
return get_table("users").find_one(username=username)["uid"]
def _make_post(owner_uid, title):
def _make_post_bookmarks(owner_uid, title):
uid = generate_uid()
get_table("posts").insert(
{
@@ -49,8 +42,6 @@ def _make_post(owner_uid, title):
}
)
return uid
def _make_gist(owner_uid, title):
uid = generate_uid()
get_table("gists").insert(
@@ -69,55 +60,15 @@ def _make_gist(owner_uid, title):
return uid
def test_bookmark_toggle_on(app_server):
s, name = _session()
post_uid = _make_post(_uid(name), "Bookmark toggle on")
r = s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
assert r.json() == {"saved": True}
assert get_table("bookmarks").count(user_uid=_uid(name), target_uid=post_uid) == 1
def test_bookmark_toggle_off(app_server):
s, name = _session()
post_uid = _make_post(_uid(name), "Bookmark toggle off")
s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
r = s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
assert r.json() == {"saved": False}
assert (
get_table("bookmarks").count(
user_uid=_uid(name), target_uid=post_uid, deleted_at=None
)
== 0
)
def test_saved_page_lists_post_and_gist(app_server):
s, name = _session()
owner_uid = _uid(name)
s, name = _session_bookmarks()
owner_uid = _uid_bookmarks(name)
post_title = f"Saved post {int(time.time() * 1000)}"
gist_title = f"Saved gist {int(time.time() * 1000)}"
post_uid = _make_post(owner_uid, post_title)
post_uid = _make_post_bookmarks(owner_uid, post_title)
gist_uid = _make_gist(owner_uid, gist_title)
s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
s.post(f"{BASE_URL}/bookmarks/gist/{gist_uid}", headers=AJAX)
s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX_bookmarks)
s.post(f"{BASE_URL}/bookmarks/gist/{gist_uid}", headers=AJAX_bookmarks)
html = s.get(f"{BASE_URL}/bookmarks/saved").text
assert post_title in html
assert gist_title in html
def test_invalid_target_type_returns_400(app_server):
s, name = _session()
post_uid = _make_post(_uid(name), "Bad target")
r = s.post(f"{BASE_URL}/bookmarks/widget/{post_uid}", headers=AJAX)
assert r.status_code == 400
def test_bookmark_requires_login(app_server):
owner_s, owner_name = _session()
post_uid = _make_post(_uid(owner_name), "Needs login")
anon = requests.Session()
r = anon.post(
f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX, allow_redirects=False
)
assert r.status_code == 303
assert get_table("bookmarks").count(target_uid=post_uid) == 0
View File
+272
View File
@@ -0,0 +1,272 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
import asyncio
from devplacepy.database import (
get_table,
init_db,
refresh_snapshot,
set_setting,
)
from devplacepy.services.gitea import runtime, store
from devplacepy.services.gitea.config import gitea_config
from devplacepy.services.gitea.enhance import enhance_ticket
from devplacepy.services.gitea.fake import FakeGiteaClient
from devplacepy.services.gitea.service import BugTrackerService
from devplacepy.services.jobs import queue
from devplacepy.services.jobs.bug_create_service import BugCreateService
from tests.conftest import run_async
_counter_bugs_gitea = [0]
@pytest.fixture(autouse=True)
def _init_db_bugs_gitea():
init_db()
yield
@pytest.fixture
def gitea_env():
fake = FakeGiteaClient()
runtime.set_client(fake)
set_setting("gitea_base_url", "https://gitea.test")
set_setting("gitea_owner", "retoor")
set_setting("gitea_repo", "pydevplace")
set_setting("gitea_token", "test-token")
set_setting("bug_ai_enhance", "0")
yield fake
runtime.set_client(None)
for table in ("bug_tickets", "bug_comment_authors"):
for row in list(get_table(table).find()):
get_table(table).delete(uid=row["uid"])
for row in list(get_table("jobs").find(kind="bug_create")):
get_table("jobs").delete(uid=row["uid"])
def _make_user_bugs_gitea():
_counter_bugs_gitea[0] += 1
uid = f"bugtest-user-{_counter_bugs_gitea[0]}"
username = f"bugtester{_counter_bugs_gitea[0]}"
get_table("users").insert(
{
"uid": uid,
"username": username,
"role": "member",
"xp": 0,
"level": 1,
"is_active": True,
}
)
return uid, username
def _unread(user_uid):
return [
n
for n in get_table("notifications").find(user_uid=user_uid)
if n.get("type") == "bug"
]
def _drive_jobs():
async def run():
svc = BugCreateService()
for _ in range(200):
await svc.run_once()
refresh_snapshot()
pending = [
r
for r in get_table("jobs").find(kind="bug_create")
if r["status"] in ("pending", "running")
]
if not pending and not svc._inflight:
return
await asyncio.sleep(0.02)
run_async(run())
def _role_user(role: str):
uid, username = _make_user_bugs_gitea()
get_table("users").update({"uid": uid, "role": role}, ["uid"])
return uid, username
def _open_ticket(fake, role: str):
import devplacepy.main as m
from starlette.testclient import TestClient
from devplacepy.utils import create_session
uid, _ = _role_user(role)
issue = run_async(fake.create_issue("Submit does nothing", "details"))
number = int(issue["number"])
store.record_ticket(
number=number,
author_uid=uid,
original_title="Submit does nothing",
original_description="details",
enhanced_title="Submit does nothing",
html_url=issue.get("html_url", ""),
status="open",
)
client = TestClient(m.app)
client.cookies.set("session", create_session(uid))
return number, client
def test_bug_create_recorded(seeded_db):
# Filing is async + Gitea-gated: POST /bugs/create only enqueues a job (returns
# {uid, status_url}) and records the synchronous "bug.create.request" event; the
# final "bug.create" fires from BugCreateService, which the tests never run
# (DEVPLACE_DISABLE_SERVICES=1). Configure Gitea so the enqueue path is taken
# (no network call happens here) and assert the request event. Restore after.
s, _ = _member()
title = _unique("aubug")
for key, value in {
"gitea_base_url": "https://gitea.test",
"gitea_owner": "o",
"gitea_repo": "devplacepy",
"gitea_token": "audit-test-token",
}.items():
set_setting(key, value)
try:
resp = s.post(
f"{BASE_URL}/bugs/create",
headers=JSON_audit_log,
data={"title": title, "description": "audited bug description text"},
)
assert resp.status_code == 200, resp.text[:300]
assert resp.json().get("uid")
admin = _admin(seeded_db)
assert (
_find(
admin,
"bug.create.request",
lambda e: title in (e.get("summary") or ""),
)
is not None
)
finally:
for key in ("gitea_base_url", "gitea_owner", "gitea_repo", "gitea_token"):
set_setting(key, "")
def test_bug_detail_renders_for_member(gitea_env):
number, client = _open_ticket(gitea_env, "Member")
html = client.get(f"/bugs/{number}")
assert html.status_code == 200, html.text[:300]
assert "Submit does nothing" in html.text
assert "Close ticket" not in html.text
data = client.get(
f"/bugs/{number}", headers={"Accept": "application/json"}
).json()
assert data["viewer_is_admin"] is False
def test_bug_detail_renders_for_admin(gitea_env):
number, client = _open_ticket(gitea_env, "Admin")
html = client.get(f"/bugs/{number}")
assert html.status_code == 200, html.text[:300]
assert "Close ticket" in html.text
data = client.get(
f"/bugs/{number}", headers={"Accept": "application/json"}
).json()
assert data["viewer_is_admin"] is True
View File
+338
View File
@@ -0,0 +1,338 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
from devplacepy.database import get_table
AJAX_comments = {"X-Requested-With": "fetch"}
_counter_comments = [0]
def _session_comments():
_counter_comments[0] += 1
name = f"cmt{int(time.time() * 1000)}{_counter_comments[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _create_post_comments(session, title):
r = session.post(
f"{BASE_URL}/posts/create",
data={
"content": "Post for comment test.",
"title": title,
"topic": "devlog",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
return get_table("posts").find_one(slug=slug)["uid"]
import io
import re
import uuid
from PIL import Image
def _user_uploads(prefix="up"):
s = requests.Session()
name = f"{prefix}_{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, name
def _session_uploads():
s, _ = _user_uploads()
return s
def _png_bytes_uploads():
buf = io.BytesIO()
Image.new("RGB", (4, 4), (255, 0, 0)).save(buf, "PNG")
return buf.getvalue()
def _upload_uploads(s, name="a.png"):
r = s.post(
f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes_uploads(), "image/png")}
)
assert r.status_code == 201, r.text
return r.json()["uid"]
def test_comment_create_and_delete_recorded(seeded_db):
s, _ = _member()
post = _new_post(s)
comment = s.post(
f"{BASE_URL}/comments/create",
headers=JSON_audit_log,
data={"content": "audited comment body", "target_type": "post", "post_uid": post["uid"]},
).json()["data"]
admin = _admin(seeded_db)
assert _find(admin, "comment.create.post", lambda e: e.get("target_uid") == comment["uid"]) is not None
s.post(f"{BASE_URL}/comments/delete/{comment['uid']}", headers=JSON_audit_log, allow_redirects=False)
assert _find(admin, "comment.delete", lambda e: e.get("target_uid") == comment["uid"]) is not None
def test_create_comment_on_post(app_server):
s, _ = _session_comments()
title = f"comment-test-{int(time.time() * 1000)}"
post_uid = _create_post_comments(s, title)
r = s.post(
f"{BASE_URL}/comments/create",
data={
"content": "Nice post!",
"target_type": "post",
"post_uid": post_uid,
"target_uid": post_uid,
},
allow_redirects=False,
)
assert r.status_code in (302, 303)
comment = get_table("comments").find_one(target_uid=post_uid)
assert comment is not None
assert comment["content"] == "Nice post!"
def test_create_reply_to_comment(app_server):
s, _ = _session_comments()
title = f"reply-test-{int(time.time() * 1000)}"
post_uid = _create_post_comments(s, title)
s.post(
f"{BASE_URL}/comments/create",
data={
"content": "Parent comment",
"target_type": "post",
"post_uid": post_uid,
"target_uid": post_uid,
},
allow_redirects=False,
)
parent = get_table("comments").find_one(target_uid=post_uid)
r = s.post(
f"{BASE_URL}/comments/create",
data={
"content": "Reply to parent",
"target_type": "post",
"post_uid": post_uid,
"target_uid": post_uid,
"parent_uid": parent["uid"],
},
allow_redirects=False,
)
assert r.status_code in (302, 303)
replies = list(get_table("comments").find(parent_uid=parent["uid"]))
assert len(replies) == 1
assert replies[0]["content"] == "Reply to parent"
def test_delete_own_comment(app_server):
s, _ = _session_comments()
title = f"del-cmt-{int(time.time() * 1000)}"
post_uid = _create_post_comments(s, title)
s.post(
f"{BASE_URL}/comments/create",
data={
"content": "Delete me",
"target_type": "post",
"post_uid": post_uid,
"target_uid": post_uid,
},
allow_redirects=False,
)
comment = get_table("comments").find_one(target_uid=post_uid)
r = s.post(
f"{BASE_URL}/comments/delete/{comment['uid']}",
allow_redirects=False,
)
assert r.status_code in (302, 303)
assert get_table("comments").find_one(uid=comment["uid"], deleted_at=None) is None
def test_delete_comment_requires_owner(app_server):
s_alice, _ = _session_comments()
title = f"owner-test-{int(time.time() * 1000)}"
post_uid = _create_post_comments(s_alice, title)
s_alice.post(
f"{BASE_URL}/comments/create",
data={
"content": "Alice comment",
"target_type": "post",
"post_uid": post_uid,
"target_uid": post_uid,
},
allow_redirects=False,
)
comment = get_table("comments").find_one(target_uid=post_uid)
s_bob, _ = _session_comments()
r = s_bob.post(
f"{BASE_URL}/comments/delete/{comment['uid']}",
allow_redirects=False,
)
assert r.status_code in (302, 303)
assert get_table("comments").find_one(uid=comment["uid"]) is not None
def test_create_comment_requires_login(app_server):
title = f"anon-cmt-{int(time.time() * 1000)}"
s, name = _session_comments()
post_uid = _create_post_comments(s, title)
anon = requests.Session()
r = anon.post(
f"{BASE_URL}/comments/create",
data={
"content": "Anon comment",
"target_type": "post",
"post_uid": post_uid,
"target_uid": post_uid,
},
allow_redirects=False,
)
assert r.status_code == 303
assert get_table("comments").find_one(target_uid=post_uid) is None
def test_comment_links_multiple_attachments(app_server):
s = _session_uploads()
post = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Host post for comment attachments",
"title": "host",
"topic": "random",
},
allow_redirects=True,
)
target_uid = re.search(r'name="target_uid"\s+value="([^"]+)"', post.text).group(1)
u1, u2 = _upload_uploads(s), _upload_uploads(s)
r = s.post(
f"{BASE_URL}/comments/create",
data={
"content": "Comment with attachments",
"target_uid": target_uid,
"target_type": "post",
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed on the comment"
)
+103
View File
@@ -0,0 +1,103 @@
# retoor <retoor@molodetz.nl>
from datetime import timedelta
import pytest
import requests
from tests.conftest import BASE_URL, run_async
from devplacepy.database import db, get_table, init_db, refresh_snapshot
from devplacepy import config, project_files
from devplacepy.services.containers import api, store, runtime
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
from devplacepy.services.containers.backend.docker_cli import build_run_argv, parse_size
from devplacepy.services.containers.backend.fake import FakeBackend
from devplacepy.services.containers.service import ContainerService
from devplacepy.services.containers.api import INSTANCE_LABEL
from devplacepy.services.devii.tasks.schedule import Schedule, now_utc
_CONTAINER_TABLES = (
"instances",
"instance_events",
"instance_metrics",
"instance_schedules",
)
@pytest.fixture(autouse=True)
def _init_db_containers():
init_db()
yield
@pytest.fixture
def env(tmp_path, monkeypatch):
fake = FakeBackend()
runtime.set_backend(fake)
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path / "ws")
pid = "ctest-p1"
project = {"uid": pid, "slug": "ctest", "title": "C", "user_uid": "ctest-u1"}
user = {"uid": "ctest-u1", "username": "ctestadmin"}
project_files.write_text_file(pid, user, "app.py", "print(1)\n")
yield {"fake": fake, "project": project, "user": user}
runtime.set_backend(None)
for table in _CONTAINER_TABLES:
if table in db.tables:
for row in [r for r in get_table(table).find()]:
if str(row.get("project_uid", "")).startswith("ctest"):
get_table(table).delete(uid=row["uid"])
for row in list(get_table("project_files").find()):
if str(row.get("project_uid", "")).startswith("ctest"):
get_table("project_files").delete(uid=row["uid"])
def _ready_instance(env, **kwargs):
return run_async(
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
)
def _promote_admin(username: str) -> None:
users = get_table("users")
user = users.find_one(username=username)
if user:
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
def _api_key(username: str) -> str:
refresh_snapshot()
return get_table("users").find_one(username=username)["api_key"]
def test_http_ingress_proxy(app_server):
import http.server
import socket
import socketserver
import threading
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"hello from upstream " + self.path.encode())
def log_message(self, *args):
pass
httpd = socketserver.TCPServer(("127.0.0.1", port), Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
slug = f"ing{port}"
uid = f"ingtest-{port}"
get_table("instances").insert(
{
"uid": uid,
"name": "ingress",
"project_uid": "ingtest",
"status": "running",
"ingress_slug": slug,
"ingress_port": 8000,
"ports_json": f'[{{"host": {port}, "container": 8000, "proto": "tcp"}}]',
}
)
try:
r = requests.get(f"{BASE_URL}/p/{slug}/foo")
assert r.status_code == 200, r.text
assert "hello from upstream" in r.text and "/foo" in r.text
assert requests.get(f"{BASE_URL}/p/does-not-exist-slug").status_code == 404
finally:
httpd.shutdown()
get_table("instances").delete(uid=uid)
View File
+46
View File
@@ -0,0 +1,46 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON_content_negotiation = {"Accept": "application/json"}
_counter_content_negotiation = [0]
def _session_content_negotiation(password="secret123"):
_counter_content_negotiation[0] += 1
name = f"cn{int(time.time() * 1000)}{_counter_content_negotiation[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": password,
"confirm_password": password,
},
allow_redirects=True,
)
return s, name
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
def test_public_pages_serve_json_and_html(app_server):
for path in PUBLIC_PAGES:
rj = requests.get(f"{BASE_URL}{path}", headers=JSON_content_negotiation)
assert rj.status_code == 200, path
assert rj.headers["content-type"].startswith("application/json"), path
assert isinstance(rj.json(), dict), path
rh = requests.get(f"{BASE_URL}{path}")
assert rh.status_code == 200, path
assert rh.headers["content-type"].startswith("text/html"), path
def test_authed_pages_serve_json_and_html(app_server):
s, name = _session_content_negotiation()
for path in ["/messages", "/notifications", "/bookmarks/saved", f"/profile/{name}"]:
rj = s.get(f"{BASE_URL}{path}", headers=JSON_content_negotiation)
assert rj.status_code == 200, path
assert rj.headers["content-type"].startswith("application/json"), path
rh = s.get(f"{BASE_URL}{path}")
assert rh.status_code == 200, path
assert rh.headers["content-type"].startswith("text/html"), path
View File
+24
View File
@@ -0,0 +1,24 @@
# retoor <retoor@molodetz.nl>
import html
import json
import re
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.docs_api import API_GROUPS
from devplacepy.utils import clear_user_cache
def _configs_by_id(text):
configs = re.findall(r"data-config='(.*?)'", text, re.S)
return {
json.loads(html.unescape(cfg))["id"]: json.loads(html.unescape(cfg))
for cfg in configs
}
def test_docs_download_html_is_self_contained(app_server):
r = requests.get(f"{BASE_URL}/docs/download.html")
assert r.status_code == 200
assert 'filename="devplace-docs.html"' in r.headers["content-disposition"]
assert "application/x-markdown-base64" in r.text
assert "marked" in r.text and "hljs" in r.text # libraries inlined
+54
View File
@@ -0,0 +1,54 @@
# retoor <retoor@molodetz.nl>
import html
import json
import re
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.docs_api import API_GROUPS
from devplacepy.utils import clear_user_cache
def _configs_by_id(text):
configs = re.findall(r"data-config='(.*?)'", text, re.S)
return {
json.loads(html.unescape(cfg))["id"]: json.loads(html.unescape(cfg))
for cfg in configs
}
def test_docs_every_api_group_page_loads(app_server):
for group in API_GROUPS:
r = requests.get(f"{BASE_URL}/docs/{group['slug']}.html")
if group.get("admin"):
assert r.status_code == 404, group["slug"]
continue
assert r.status_code == 200, group["slug"]
if group["endpoints"]:
assert "data-api-tester" in r.text
def test_docs_download_markdown(app_server):
r = requests.get(f"{BASE_URL}/docs/download.md")
assert r.status_code == 200
assert "text/markdown" in r.headers["content-type"]
assert 'filename="devplace-docs.md"' in r.headers["content-disposition"]
assert "# DevPlace Documentation" in r.text
assert "/profile/search" in r.text
assert "| Name | In |" in r.text
assert "/admin/services/" not in r.text # admin pages excluded for guests
def test_docs_download_includes_admin_pages_for_admin(seeded_db):
users = get_table("users")
bob = users.find_one(username="bob_test")
assert "/admin/services/" not in requests.get(f"{BASE_URL}/docs/download.md").text
users.update({"uid": bob["uid"], "role": "Admin"}, ["uid"])
clear_user_cache(bob["uid"])
try:
admin = requests.get(
f"{BASE_URL}/docs/download.md", headers={"X-API-KEY": bob["api_key"]}
)
assert "/admin/services/" in admin.text
finally:
users.update({"uid": bob["uid"], "role": "Member"}, ["uid"])
clear_user_cache(bob["uid"])
+442
View File
@@ -0,0 +1,442 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
import html
import json
import re
from devplacepy.database import get_table
from devplacepy.docs_api import API_GROUPS
from devplacepy.utils import clear_user_cache
def _configs_by_id(text):
configs = re.findall(r"data-config='(.*?)'", text, re.S)
return {
json.loads(html.unescape(cfg))["id"]: json.loads(html.unescape(cfg))
for cfg in configs
}
import io
import uuid
from PIL import Image
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
def test_audit_docs_page_admin_only(seeded_db):
# guest cannot see the admin-only audit-log docs page
assert requests.get(f"{BASE_URL}/docs/audit-log.html").status_code == 404
admin = _db_user("alice_test")
r = requests.get(
f"{BASE_URL}/docs/audit-log.html", headers={"X-API-KEY": admin["api_key"]}
)
assert r.status_code == 200
assert "Audit Log" in r.text
def test_docs_root_redirects_to_index(app_server):
r = requests.get(f"{BASE_URL}/docs", allow_redirects=False)
assert r.status_code == 302
assert r.headers["location"].endswith("/docs/index.html")
def test_docs_index_loads(app_server):
r = requests.get(f"{BASE_URL}/docs/index.html")
assert r.status_code == 200
assert "Documentation" in r.text
assert "/docs/authentication.html" in r.text
def test_docs_authentication_page_loads(app_server):
r = requests.get(f"{BASE_URL}/docs/authentication.html")
assert r.status_code == 200
for needle in ("X-API-KEY", "Bearer", "Basic"):
assert needle in r.text
def test_docs_unknown_page_404(app_server):
r = requests.get(f"{BASE_URL}/docs/nope.html")
assert r.status_code == 404
def test_docs_index_links_every_public_group(app_server):
r = requests.get(f"{BASE_URL}/docs/index.html")
assert r.status_code == 200
for group in API_GROUPS:
if group.get("admin"):
assert f"/docs/{group['slug']}.html" not in r.text
else:
assert f"/docs/{group['slug']}.html" in r.text
def test_docs_operator_pages_require_admin(seeded_db):
users = get_table("users")
bob = users.find_one(username="bob_test")
assert requests.get(f"{BASE_URL}/docs/services.html").status_code == 404
assert requests.get(f"{BASE_URL}/docs/admin.html").status_code == 404
users.update({"uid": bob["uid"], "role": "Admin"}, ["uid"])
clear_user_cache(bob["uid"])
try:
headers = {"X-API-KEY": bob["api_key"]}
services = requests.get(f"{BASE_URL}/docs/services.html", headers=headers)
assert services.status_code == 200
for name in ("news", "bots", "openai"):
assert name in services.text
configs = re.findall(r"data-config='(.*?)'", services.text, re.S)
assert configs
for cfg in configs:
json.loads(html.unescape(cfg))
assert (
requests.get(f"{BASE_URL}/docs/admin.html", headers=headers).status_code
== 200
)
admin_index = requests.get(f"{BASE_URL}/docs/index.html", headers=headers)
assert "/docs/services.html" in admin_index.text
finally:
users.update({"uid": bob["uid"], "role": "Member"}, ["uid"])
def test_docs_endpoint_config_is_valid_json(app_server):
r = requests.get(f"{BASE_URL}/docs/social-actions.html")
assert r.status_code == 200
configs = re.findall(r"data-config='(.*?)'", r.text, re.S)
assert configs
ids = {json.loads(html.unescape(cfg))["id"] for cfg in configs}
assert "votes-cast" in ids
def test_docs_lookups_group_renamed_and_works(app_server):
# the former "search" API group lives at /docs/lookups.html now
r = requests.get(f"{BASE_URL}/docs/lookups.html")
assert r.status_code == 200
assert "/profile/search" in r.text
assert "data-api-tester" in r.text
assert "/docs/lookups.html" in requests.get(f"{BASE_URL}/docs/index.html").text
def test_docs_search_page_loads(app_server):
r = requests.get(f"{BASE_URL}/docs/search.html")
assert r.status_code == 200
assert 'name="q"' in r.text
assert "Type a query" in r.text
def test_docs_search_ranks_relevant_results(seeded_db):
admin = get_table("users").find_one(username="alice_test")
vision = requests.get(
f"{BASE_URL}/docs/search.html",
params={"q": "vision image"},
headers={"X-API-KEY": admin["api_key"]},
)
assert vision.status_code == 200
assert "/docs/gateway.html" in vision.text
assert "<mark>" in vision.text
auth = requests.get(f"{BASE_URL}/docs/search.html", params={"q": "api key"})
assert "/docs/authentication.html" in auth.text
def test_docs_search_respects_admin_visibility(seeded_db):
users = get_table("users")
bob = users.find_one(username="bob_test")
public = requests.get(
f"{BASE_URL}/docs/search.html", params={"q": "background services"}
)
assert "/docs/services.html" not in public.text
users.update({"uid": bob["uid"], "role": "Admin"}, ["uid"])
clear_user_cache(bob["uid"])
try:
admin = requests.get(
f"{BASE_URL}/docs/search.html",
params={"q": "background services"},
headers={"X-API-KEY": bob["api_key"]},
)
assert "/docs/services.html" in admin.text
finally:
users.update({"uid": bob["uid"], "role": "Member"}, ["uid"])
clear_user_cache(bob["uid"])
def test_docs_pages_inject_runtime_context(app_server):
r = requests.get(f"{BASE_URL}/docs/conventions.html")
assert "window.DEVPLACE_DOCS" in r.text
assert "{{ base }}" not in r.text
def test_docs_endpoint_config_has_negotiation(app_server):
by_id = _configs_by_id(requests.get(f"{BASE_URL}/docs/content.html").text)
assert by_id["feed-list"]["negotiation"] == "negotiable"
assert by_id["feed-list"]["sample_response"] is not None
assert by_id["feed-list"]["interactive"] is True
assert by_id["posts-create"]["negotiation"] == "negotiable"
def test_docs_ajax_endpoint_negotiation(app_server):
by_id = _configs_by_id(requests.get(f"{BASE_URL}/docs/social-actions.html").text)
assert by_id["votes-cast"]["negotiation"] == "ajax"
def test_docs_lookups_endpoint_is_json_negotiation(app_server):
by_id = _configs_by_id(requests.get(f"{BASE_URL}/docs/lookups.html").text)
assert by_id["search-users"]["negotiation"] == "json"
def test_media_gallery_doc_is_public_and_member_safe(app_server):
r = requests.get(f"{BASE_URL}/docs/media-gallery.html")
assert r.status_code == 200
assert "Media gallery" in r.text
lowered = r.text.lower()
for leak in ("soft delete", "soft-delete", "trash", "purge", "restore"):
assert leak not in lowered, leak
def test_media_moderation_doc_is_admin_only(seeded_db):
# hidden from guests and members
assert requests.get(f"{BASE_URL}/docs/media-moderation.html").status_code == 404
bob = get_table("users").find_one(username="bob_test")
member = requests.get(
f"{BASE_URL}/docs/media-moderation.html", headers={"X-API-KEY": bob["api_key"]}
)
assert member.status_code == 404
# visible to admins, and this is where soft-delete is explained
admin = get_table("users").find_one(username="alice_test")
page = requests.get(
f"{BASE_URL}/docs/media-moderation.html",
headers={"X-API-KEY": admin["api_key"]},
)
assert page.status_code == 200
assert "soft delete" in page.text.lower()
def test_media_moderation_hidden_from_member_search(seeded_db):
public = requests.get(f"{BASE_URL}/docs/search.html", params={"q": "soft delete media"})
assert "/docs/media-moderation.html" not in public.text
admin = get_table("users").find_one(username="alice_test")
found = requests.get(
f"{BASE_URL}/docs/search.html",
params={"q": "soft delete media"},
headers={"X-API-KEY": admin["api_key"]},
)
assert "/docs/media-moderation.html" in found.text
def test_profiles_api_doc_delete_is_member_safe(app_server):
text = requests.get(f"{BASE_URL}/docs/profiles.html").text
assert "/media/" in text # the member delete endpoint is documented
lowered = text.lower()
assert "soft delete" not in lowered
assert "purge" not in lowered
def test_admin_api_doc_has_moderation(seeded_db):
admin = get_table("users").find_one(username="alice_test")
text = requests.get(
f"{BASE_URL}/docs/admin.html", headers={"X-API-KEY": admin["api_key"]}
).text
assert "/admin/media" in text
lowered = text.lower()
assert "restore" in lowered
assert "purge" in lowered
assert "soft" in lowered
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
+342
View File
@@ -0,0 +1,342 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
from devplacepy.database import get_table
_counter_polls = [0]
AJAX_polls = {"X-Requested-With": "fetch"}
def _session_polls():
_counter_polls[0] += 1
name = f"pol{int(time.time() * 1000)}{_counter_polls[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _create_post_polls(session, title, question, options):
fields = [
("content", "Poll host post content for tests."),
("title", title),
("topic", "question"),
("poll_question", question),
]
fields.extend(("poll_options", option) for option in options)
r = session.post(f"{BASE_URL}/posts/create", data=fields, allow_redirects=False)
slug = r.headers["location"].split("/posts/")[-1]
return get_table("posts").find_one(slug=slug)["uid"]
def _poll_for(post_uid):
poll = get_table("polls").find_one(post_uid=post_uid)
options = list(
get_table("poll_options").find(poll_uid=poll["uid"], order_by=["position"])
)
return poll, options
def _create_plain_post_polls(session, title):
r = session.post(
f"{BASE_URL}/posts/create",
data={
"content": "Plain post awaiting a poll on edit.",
"title": title,
"topic": "question",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
return slug, get_table("posts").find_one(slug=slug)["uid"]
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_maintenance_block_recorded(seeded_db):
admin = _admin(seeded_db)
set_setting("maintenance_mode", "1")
try:
requests.get(f"{BASE_URL}/feed", allow_redirects=False)
finally:
set_setting("maintenance_mode", "0")
event = _find(admin, "security.maintenance.block", lambda e: True)
assert event is not None
assert event["result"] == "denied"
def test_feed_shows_poll_results_without_voting(app_server):
s, _ = _session_polls()
title = f"feedpoll-{int(time.time() * 1000)}"
_create_post_polls(s, title, "Visible results question?", ["Yes", "No"])
html = s.get(f"{BASE_URL}/feed").text
assert "Visible results question?" in html
assert "poll-option-bar" in html
def test_x_robots_tag_header(app_server):
r = requests.get(f"{BASE_URL}/feed", allow_redirects=True)
assert "X-Robots-Tag" in r.headers
def test_x_content_type_options_header(app_server):
r = requests.get(f"{BASE_URL}/feed", allow_redirects=True)
assert r.headers.get("X-Content-Type-Options") == "nosniff"
View File
+317
View File
@@ -0,0 +1,317 @@
# retoor <retoor@molodetz.nl>
import base64
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_api_auth = [0]
def _signup_api_auth(password="secret123"):
_counter_api_auth[0] += 1
name = f"apiauth{int(time.time() * 1000)}{_counter_api_auth[0]}"
email = f"{name}@t.dev"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": email,
"password": password,
"confirm_password": password,
},
allow_redirects=True,
)
return session, name, email, password
def _user_api_auth(name):
return get_table("users").find_one(username=name)
def _key_api_auth(name):
return _user_api_auth(name)["api_key"]
from datetime import datetime, timezone
import pytest
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
_counter_follow_api = [0]
def _session_follow_api():
_counter_follow_api[0] += 1
name = f"flw{int(time.time() * 1000)}{_counter_follow_api[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _uid_follow_api(username):
return get_table("users").find_one(username=username)["uid"]
def test_x_api_key_authenticates_action(app_server):
_, follower, _, _ = _signup_api_auth()
_, target, _, _ = _signup_api_auth()
r = requests.post(
f"{BASE_URL}/follow/{target}",
headers={"X-API-KEY": _key_api_auth(follower)},
allow_redirects=False,
)
assert r.status_code in (302, 200)
assert (
get_table("follows").count(
follower_uid=_user_api_auth(follower)["uid"], following_uid=_user_api_auth(target)["uid"]
)
== 1
)
def test_bearer_token_authenticates_action(app_server):
_, follower, _, _ = _signup_api_auth()
_, target, _, _ = _signup_api_auth()
r = requests.post(
f"{BASE_URL}/follow/{target}",
headers={"Authorization": f"Bearer {_key_api_auth(follower)}"},
allow_redirects=False,
)
assert r.status_code in (302, 200)
assert (
get_table("follows").count(
follower_uid=_user_api_auth(follower)["uid"], following_uid=_user_api_auth(target)["uid"]
)
== 1
)
def test_basic_auth_with_username(app_server):
_, follower, _, password = _signup_api_auth()
_, target, _, _ = _signup_api_auth()
r = requests.post(
f"{BASE_URL}/follow/{target}", auth=(follower, password), allow_redirects=False
)
assert r.status_code in (302, 200)
assert (
get_table("follows").count(
follower_uid=_user_api_auth(follower)["uid"], following_uid=_user_api_auth(target)["uid"]
)
== 1
)
def test_basic_auth_with_email(app_server):
_, follower, email, password = _signup_api_auth()
_, target, _, _ = _signup_api_auth()
r = requests.post(
f"{BASE_URL}/follow/{target}", auth=(email, password), allow_redirects=False
)
assert r.status_code in (302, 200)
assert (
get_table("follows").count(
follower_uid=_user_api_auth(follower)["uid"], following_uid=_user_api_auth(target)["uid"]
)
== 1
)
def test_invalid_api_key_returns_401(app_server):
_, target, _, _ = _signup_api_auth()
r = requests.post(
f"{BASE_URL}/follow/{target}",
headers={"X-API-KEY": "not-a-real-key"},
allow_redirects=False,
)
assert r.status_code == 401
def test_invalid_basic_password_returns_401(app_server):
_, follower, _, _ = _signup_api_auth()
_, target, _, _ = _signup_api_auth()
token = base64.b64encode(f"{follower}:wrongpass".encode()).decode()
r = requests.post(
f"{BASE_URL}/follow/{target}",
headers={"Authorization": f"Basic {token}"},
allow_redirects=False,
)
assert r.status_code == 401
def test_no_credentials_redirects(app_server):
_, target, _, _ = _signup_api_auth()
r = requests.post(f"{BASE_URL}/follow/{target}", allow_redirects=False)
assert r.status_code == 303
def test_follow_recorded(seeded_db):
s, _ = _member()
s.post(f"{BASE_URL}/follow/bob_test", headers=JSON_audit_log, allow_redirects=False)
admin = _admin(seeded_db)
event = _find(
admin, "follow.follow", lambda e: e.get("target_label") == "bob_test"
)
assert event is not None
def test_free_text_search(seeded_db):
s, name = _member()
s.post(f"{BASE_URL}/follow/bob_test", headers=JSON_audit_log, allow_redirects=False)
admin = _admin(seeded_db)
data = _audit(admin, q=name)
assert data["pagination"]["total"] >= 1
assert all(name in (e["summary"] or "") for e in data["entries"])
def test_follow_emits_notification_create(seeded_db):
s, name = _member()
s.post(f"{BASE_URL}/follow/bob_test", headers=JSON_audit_log, allow_redirects=False)
admin = _admin(seeded_db)
event = _find(
admin, "notification.create", lambda e: name in (e.get("summary") or "")
)
assert event is not None
assert event["actor_kind"] == "system"
def test_follow_creates_notification_and_awards_xp(app_server):
s_a, a_name = _session_follow_api()
_, b_name = _session_follow_api()
a_uid, b_uid = _uid_follow_api(a_name), _uid_follow_api(b_name)
before = get_table("users").find_one(uid=b_uid).get("xp", 0) or 0
s_a.post(f"{BASE_URL}/follow/{b_name}", allow_redirects=False)
assert get_table("follows").count(follower_uid=a_uid, following_uid=b_uid) == 1
assert get_table("notifications").count(user_uid=b_uid, type="follow") == 1
after = get_table("users").find_one(uid=b_uid).get("xp", 0) or 0
assert after - before == 5
def test_duplicate_follow_is_idempotent(app_server):
s_a, a_name = _session_follow_api()
_, b_name = _session_follow_api()
a_uid, b_uid = _uid_follow_api(a_name), _uid_follow_api(b_name)
s_a.post(f"{BASE_URL}/follow/{b_name}", allow_redirects=False)
s_a.post(f"{BASE_URL}/follow/{b_name}", allow_redirects=False)
assert get_table("follows").count(follower_uid=a_uid, following_uid=b_uid) == 1
assert get_table("notifications").count(user_uid=b_uid, type="follow") == 1
def test_self_follow_rejected(app_server):
s_a, a_name = _session_follow_api()
a_uid = _uid_follow_api(a_name)
s_a.post(f"{BASE_URL}/follow/{a_name}", allow_redirects=False)
assert get_table("follows").count(follower_uid=a_uid, following_uid=a_uid) == 0
def test_follow_nonexistent_user_redirects(app_server):
s_a, _ = _session_follow_api()
r = s_a.post(f"{BASE_URL}/follow/no_such_user_zzz", allow_redirects=False)
assert r.status_code == 302
+42
View File
@@ -0,0 +1,42 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_follow_api = [0]
def _session_follow_api():
_counter_follow_api[0] += 1
name = f"flw{int(time.time() * 1000)}{_counter_follow_api[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _uid_follow_api(username):
return get_table("users").find_one(username=username)["uid"]
def test_unfollow_removes_follow(app_server):
s_a, a_name = _session_follow_api()
_, b_name = _session_follow_api()
a_uid, b_uid = _uid_follow_api(a_name), _uid_follow_api(b_name)
s_a.post(f"{BASE_URL}/follow/{b_name}", allow_redirects=False)
s_a.post(f"{BASE_URL}/follow/unfollow/{b_name}", allow_redirects=False)
assert (
get_table("follows").count(
follower_uid=a_uid, following_uid=b_uid, deleted_at=None
)
== 0
)
r = s_a.post(f"{BASE_URL}/follow/unfollow/{b_name}", allow_redirects=False)
assert r.status_code == 302
+111
View File
@@ -0,0 +1,111 @@
# retoor <retoor@molodetz.nl>
import asyncio
from datetime import datetime, timezone
from pathlib import Path
import pytest
from devplacepy.database import init_db, get_table, refresh_snapshot
from devplacepy import project_files
from devplacepy.services.jobs import queue
from devplacepy.services.jobs.fork_service import ForkService
from tests.conftest import run_async
@pytest.fixture(autouse=True)
def _init_db_fork_jobs():
init_db()
yield
@pytest.fixture
def fork_env(tmp_path, monkeypatch):
monkeypatch.setattr(
"devplacepy.services.jobs.fork_service.STAGING_DIR", tmp_path / "staging"
)
monkeypatch.setattr("devplacepy.project_files.PROJECT_FILES_DIR", tmp_path / "pf")
yield tmp_path
jobs = get_table("jobs")
for row in list(jobs.find(kind="fork")):
jobs.delete(uid=row["uid"])
projects = get_table("projects")
files = get_table("project_files")
for project in list(projects.find()):
if str(project.get("user_uid", "")).startswith("forktest-owner"):
for node in list(files.find(project_uid=project["uid"])):
files.delete(uid=node["uid"])
projects.delete(uid=project["uid"])
for node in list(files.find()):
if str(node.get("project_uid", "")).startswith("forktest"):
files.delete(uid=node["uid"])
forks = get_table("project_forks")
for relation in list(forks.find()):
if str(relation.get("forked_by_uid", "")).startswith("forktest-owner"):
forks.delete(uid=relation["uid"])
users = get_table("users")
for user in list(users.find()):
if str(user.get("uid", "")).startswith("forktest-owner"):
users.delete(uid=user["uid"])
_counter_fork_jobs = [0]
def _make_source_project(*, is_private=False, binary=False):
_counter_fork_jobs[0] += 1
pid = f"forktest-{_counter_fork_jobs[0]}"
owner_uid = f"forktest-owner-{_counter_fork_jobs[0]}"
user = {"uid": owner_uid, "username": f"forktester{_counter_fork_jobs[0]}"}
get_table("users").insert(
{"uid": owner_uid, "username": user["username"], "xp": 0, "level": 1}
)
get_table("projects").insert(
{
"uid": pid,
"user_uid": owner_uid,
"slug": f"{pid}-source",
"title": "Source Project",
"description": "the original",
"project_type": "software",
"platforms": "linux",
"status": "Released",
"is_private": 1 if is_private else 0,
"read_only": 0,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
project_files.write_text_file(pid, user, "README.md", "# hello\nworld")
project_files.write_text_file(pid, user, "src/app.py", "print(1)\n")
if binary:
project_files.store_upload(pid, user, "assets", "logo.bin", bytes(range(256)))
return pid, owner_uid
def _tree(directory):
root = Path(directory)
out = {}
for path in sorted(root.rglob("*")):
if path.is_file():
out[path.relative_to(root).as_posix()] = path.read_bytes()
return out
def _process_fork_jobs():
async def drive():
svc = ForkService()
for _ in range(400):
await svc.run_once()
refresh_snapshot()
pending = [
r
for r in get_table("jobs").find(kind="fork")
if r["status"] in ("pending", "running")
]
if not pending and not svc._inflight:
return
await asyncio.sleep(0.05)
run_async(drive())
def _enqueue(source_uid, owner_uid, title="My Fork"):
return queue.enqueue(
"fork",
{"source_project_uid": source_uid, "title": title, "forked_by_uid": owner_uid},
"user",
owner_uid,
title,
)
def test_fork_status_http_unknown_returns_404(app_server):
import requests
from tests.conftest import BASE_URL
r = requests.get(f"{BASE_URL}/forks/nonexistent-uid")
assert r.status_code == 404
View File
+360
View File
@@ -0,0 +1,360 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
import re
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
DPBOT_SIZE = 112147
SOURCE_LENGTH_LIMIT = 400000
def _make_source(length):
unit = "def handler():\n return 0\n"
return (unit * (length // len(unit) + 1))[:length]
def _register_session(prefix):
name = f"{prefix}{int(time.time() * 1000)}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return session, name
def _seed_gists(count):
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"pag_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
gists = get_table("gists")
for i in range(count):
uid = str(uuid4())
title = f"Pag Gist {i}"
gists.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"language": "python",
"description": "paginated",
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return owner
def _set_cm_value(page, value):
page.evaluate(f"""() => {{
const cm = document.querySelector(".CodeMirror")?.CodeMirror;
if (cm) {{
cm.setValue({repr(value)});
cm.save();
}}
}}""")
def _create_gist(
page,
title="Test Gist",
description="Test description",
language="python",
source_code="print('hello')",
):
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
page.locator("#create-gist-btn").wait_for(state="visible", timeout=10000)
page.locator("#create-gist-btn").click()
page.wait_for_timeout(800)
page.fill("#gist-title", title)
page.fill("#gist-description", description)
page.select_option("#gist-language", language)
page.wait_for_timeout(300)
_set_cm_value(page, source_code)
page.wait_for_timeout(300)
page.locator("button.btn-primary:has-text('Create Gist')").click()
page.wait_for_timeout(2000)
current = page.url
if "/gists/" in current and current != f"{BASE_URL}/gists":
return
page.wait_for_url("**/gists/*", timeout=15000, wait_until="domcontentloaded")
def _seed_searchable_gists(language="python"):
token = uuid4().hex[:10]
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"gsrch_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
gists = get_table("gists")
match_title = f"Findable gist {token}"
other_title = f"Unrelated gist {uuid4().hex[:10]}"
for offset, title in ((0, match_title), (1, other_title)):
uid = str(uuid4())
gists.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"language": language,
"description": f"Description for {title}",
"source_code": "x = 1",
"stars": 0,
"created_at": (base - timedelta(seconds=offset)).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return token, match_title, other_title
import io
import uuid
from PIL import Image
def _user_uploads(prefix="up"):
s = requests.Session()
name = f"{prefix}_{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, name
def _session_uploads():
s, _ = _user_uploads()
return s
def _png_bytes_uploads():
buf = io.BytesIO()
Image.new("RGB", (4, 4), (255, 0, 0)).save(buf, "PNG")
return buf.getvalue()
def _upload_uploads(s, name="a.png"):
r = s.post(
f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes_uploads(), "image/png")}
)
assert r.status_code == 201, r.text
return r.json()["uid"]
def test_gist_create_recorded(seeded_db):
s, _ = _member()
gist = s.post(
f"{BASE_URL}/gists/create",
headers=JSON_audit_log,
data={"title": _unique("aug"), "description": "audited gist", "source_code": "print(1)", "language": "python"},
).json()["data"]
admin = _admin(seeded_db)
assert _find(admin, "gist.create", lambda e: e.get("target_uid") == gist["uid"]) is not None
def test_create_triple_dpbot_gist_saves(app_server):
session, _ = _register_session("gbig")
title = f"Triple Dpbot {int(time.time() * 1000)}"
source = _make_source(3 * DPBOT_SIZE)
assert len(source) <= SOURCE_LENGTH_LIMIT
response = session.post(
f"{BASE_URL}/gists/create",
data={
"title": title,
"description": "large source",
"language": "python",
"source_code": source,
},
allow_redirects=True,
)
assert "/gists/" in response.url
assert response.url.rstrip("/") != f"{BASE_URL}/gists"
saved = get_table("gists").find_one(title=title)
assert saved is not None
assert saved["source_code"] == source.strip()
assert len(saved["source_code"]) > 2 * DPBOT_SIZE
def test_create_gist_at_limit_saves(app_server):
session, _ = _register_session("glim")
title = f"At Limit {int(time.time() * 1000)}"
source = _make_source(SOURCE_LENGTH_LIMIT)
response = session.post(
f"{BASE_URL}/gists/create",
data={
"title": title,
"description": "",
"language": "python",
"source_code": source,
},
allow_redirects=True,
)
assert "/gists/" in response.url
assert response.url.rstrip("/") != f"{BASE_URL}/gists"
assert get_table("gists").find_one(title=title) is not None
def test_oversized_gist_rejected_server_side(app_server):
session, _ = _register_session("govr")
title = f"Oversized {int(time.time() * 1000)}"
source = _make_source(SOURCE_LENGTH_LIMIT + 1)
session.post(
f"{BASE_URL}/gists/create",
data={
"title": title,
"description": "",
"language": "python",
"source_code": source,
},
allow_redirects=True,
)
assert get_table("gists").find_one(title=title) is None
def test_gist_links_multiple_attachments(app_server):
s = _session_uploads()
u1, u2 = _upload_uploads(s), _upload_uploads(s)
r = s.post(
f"{BASE_URL}/gists/create",
data={
"title": "Attach Gist",
"description": "Gist with attachments",
"source_code": "print('hi')",
"language": "python",
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/gists/" in r.url, r.url
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed on the gist"
)
+160
View File
@@ -0,0 +1,160 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_gist_uid_redirects_to_canonical_slug(app_server):
slug, uid = _seed_gist()
r = requests.get(f"{BASE_URL}/gists/{uid}", allow_redirects=False)
assert r.status_code == 301
assert r.headers["location"].endswith(f"/gists/{slug}"), r.headers.get("location")
+24
View File
@@ -0,0 +1,24 @@
# retoor <retoor@molodetz.nl>
import uuid
import requests
from tests.conftest import BASE_URL
def _session_push():
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_manifest_served(app_server):
r = requests.get(f"{BASE_URL}/manifest.json")
assert r.status_code == 200
View File
+191
View File
@@ -0,0 +1,191 @@
# retoor <retoor@molodetz.nl>
import io
import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
def test_delete_requires_login(app_server):
owner, name = _signup_media()
uid = _upload_media(owner)
_create_post_media(owner, uid)
anon = requests.Session()
r = anon.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
assert r.status_code == 401
def test_delete_nonexistent_media(app_server):
s, _ = _signup_media()
r = s.post(f"{BASE_URL}/media/nonexistent-uid/delete", headers=JSON_media)
assert r.status_code in (302, 303, 404)
def test_member_cannot_delete_other_members_media(app_server):
owner, owner_name = _signup_media("owner")
uid = _upload_media(owner)
_create_post_media(owner, uid)
attacker, _ = _signup_media("attacker")
r = attacker.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
assert r.status_code == 403
# still visible to the owner
assert uid in _media_uids(owner, owner_name)
def test_owner_soft_deletes_own_media(app_server):
owner, name = _signup_media()
uid = _upload_media(owner)
_create_post_media(owner, uid)
assert uid in _media_uids(owner, name)
r = owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
assert r.status_code == 200
assert r.json()["ok"] is True
# gone from the gallery (parent-object hiding is asserted in
# test_soft_delete_hides_attachment_from_feed)
assert uid not in _media_uids(owner, name)
def test_soft_delete_hides_attachment_from_feed(app_server):
# The parent-object invariant: a soft-deleted attachment disappears from the place it
# was attached. The feed reads attachments via the raw-query batch helper, so this is
# a reliable end-to-end check that the relation is hidden (not unlinked).
s, name = _signup_media()
uid = _upload_media(s)
_create_post_media(s, uid, title="feed invariant post")
def attached_on_feed():
posts = s.get(f"{BASE_URL}/feed", headers=JSON_media).json().get("posts", [])
for item in posts:
uids = [a.get("uid") for a in (item.get("attachments") or [])]
if uid in uids:
return True
return False
assert attached_on_feed()
r = s.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
assert r.status_code == 200
assert not attached_on_feed()
def test_admin_can_delete_any_media(seeded_db):
owner, owner_name = _signup_media("victim")
uid = _upload_media(owner)
_create_post_media(owner, uid)
admin = _login_media(seeded_db, "alice")
r = admin.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
assert r.status_code == 200
assert uid not in _media_uids(owner, owner_name)
def test_admin_trash_lists_deleted_media(seeded_db):
owner, owner_name = _signup_media("trashed")
uid = _upload_media(owner)
_create_post_media(owner, uid)
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
admin = _login_media(seeded_db, "alice")
data = admin.get(f"{BASE_URL}/admin/media", headers=JSON_media).json()
item = next((m for m in data["media"] if m["uid"] == uid), None)
assert item is not None
assert item["uploader"] == owner_name
assert item["deleted_at"]
+164
View File
@@ -0,0 +1,164 @@
# retoor <retoor@molodetz.nl>
import io
import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
def test_media_tab_lists_linked_attachment(app_server):
s, name = _signup_media()
uid = _upload_media(s)
_create_post_media(s, uid)
data = _media_json(s, name)
uids = [m["uid"] for m in data["media"]]
assert uid in uids
item = next(m for m in data["media"] if m["uid"] == uid)
assert item["is_image"] is True
assert item["target_type"] == "post"
assert item["target_url"].startswith("/posts/")
def test_media_tab_is_public(app_server):
owner, name = _signup_media()
uid = _upload_media(owner)
_create_post_media(owner, uid)
# an anonymous visitor can browse the gallery
anon = requests.Session()
assert uid in _media_uids(anon, name)
def test_orphan_upload_not_listed(app_server):
s, name = _signup_media()
uid = _upload_media(s) # uploaded but never linked to any content
assert uid not in _media_uids(s, name)
def test_media_newest_first(app_server):
s, name = _signup_media()
first = _upload_media(s)
_create_post_media(s, first, title="older")
second = _upload_media(s)
_create_post_media(s, second, title="newer")
uids = _media_uids(s, name)
assert uids.index(second) < uids.index(first)
def test_media_covers_multiple_target_types(app_server):
s, name = _signup_media()
post_att = _upload_media(s)
_create_post_media(s, post_att)
project_att = _upload_media(s)
_create_project_media(s, project_att)
items = {m["uid"]: m for m in _media_json(s, name)["media"]}
assert items[post_att]["target_type"] == "post"
assert items[project_att]["target_type"] == "project"
def test_media_json_omits_deleted_at(app_server):
# Members must never be shown the soft-delete column.
s, name = _signup_media()
uid = _upload_media(s)
_create_post_media(s, uid)
data = _media_json(s, name)
assert data["media"], "expected at least one media item"
for item in data["media"]:
assert "deleted_at" not in item
+134
View File
@@ -0,0 +1,134 @@
# retoor <retoor@molodetz.nl>
import io
import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
def test_restore_requires_admin(seeded_db):
owner, name = _signup_media()
uid = _upload_media(owner)
_create_post_media(owner, uid)
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
# a plain member may not restore
member = _login_media(seeded_db, "bob")
r = member.post(f"{BASE_URL}/media/{uid}/restore", headers=JSON_media)
assert r.status_code in (302, 303, 403)
assert uid not in _media_uids(owner, name)
def test_admin_restore_brings_media_back(seeded_db):
owner, name = _signup_media()
uid = _upload_media(owner)
post_url = _create_post_media(owner, uid)
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
assert uid not in _media_uids(owner, name)
admin = _login_media(seeded_db, "alice")
r = admin.post(f"{BASE_URL}/media/{uid}/restore", headers=JSON_media)
assert r.status_code == 200
# reappears in the gallery, and its relation to the parent post is intact
# (delete never cleared target_type/target_uid), so it is back on the post too
restored = {m["uid"]: m for m in _media_json(owner, name)["media"]}
assert uid in restored
assert restored[uid]["target_type"] == "post"
assert post_url.endswith(restored[uid]["target_url"])
View File
+154
View File
@@ -0,0 +1,154 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
from devplacepy.database import get_table
JSON_content_negotiation = {"Accept": "application/json"}
_counter_content_negotiation = [0]
def _session_content_negotiation(password="secret123"):
_counter_content_negotiation[0] += 1
name = f"cn{int(time.time() * 1000)}{_counter_content_negotiation[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": password,
"confirm_password": password,
},
allow_redirects=True,
)
return s, name
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
def test_security_authz_denied_recorded(seeded_db):
requests.get(f"{BASE_URL}/messages", allow_redirects=False)
admin = _admin(seeded_db)
event = _find(
admin,
"security.authz.denied",
lambda e: e.get("request_path") == "/messages",
)
assert event is not None
assert event["result"] == "denied"
def test_unauthenticated_json_request_is_401_not_redirect(app_server):
r = requests.get(f"{BASE_URL}/messages", headers=JSON_content_negotiation, allow_redirects=False)
assert r.status_code == 401
# browser guest still redirects to login
rh = requests.get(f"{BASE_URL}/messages", allow_redirects=False)
assert rh.status_code == 303
+59
View File
@@ -0,0 +1,59 @@
# retoor <retoor@molodetz.nl>
import io
import re
import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL
def _user_uploads(prefix="up"):
s = requests.Session()
name = f"{prefix}_{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, name
def _session_uploads():
s, _ = _user_uploads()
return s
def _png_bytes_uploads():
buf = io.BytesIO()
Image.new("RGB", (4, 4), (255, 0, 0)).save(buf, "PNG")
return buf.getvalue()
def _upload_uploads(s, name="a.png"):
r = s.post(
f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes_uploads(), "image/png")}
)
assert r.status_code == 201, r.text
return r.json()["uid"]
def test_message_links_multiple_attachments(app_server):
alice = _session_uploads()
bob, bob_name = _user_uploads("bob")
found = alice.get(f"{BASE_URL}/messages/search", params={"q": bob_name}).json()[
"results"
]
bob_uid = found[0]["uid"]
u1, u2 = _upload_uploads(alice), _upload_uploads(alice)
r = alice.post(
f"{BASE_URL}/messages/send",
data={
"content": "Message with attachments",
"receiver_uid": bob_uid,
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed in the conversation"
)
+127
View File
@@ -0,0 +1,127 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_message_send_recorded(seeded_db):
s, _ = _member()
receiver = _db_user("bob_test")["uid"]
msg = s.post(
f"{BASE_URL}/messages/send",
headers=JSON_audit_log,
data={"content": "audited direct message", "receiver_uid": receiver},
).json()["data"]
admin = _admin(seeded_db)
assert _find(admin, "message.send", lambda e: e.get("target_uid") == msg["uid"]) is not None
View File
+160
View File
@@ -0,0 +1,160 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_news_uid_redirects_to_canonical_slug(app_server):
slug, uid = _seed_news_seo()
r = requests.get(f"{BASE_URL}/news/{uid}", allow_redirects=False)
assert r.status_code == 301
assert r.headers["location"].endswith(f"/news/{slug}"), r.headers.get("location")
+83
View File
@@ -0,0 +1,83 @@
# retoor <retoor@molodetz.nl>
import httpx
from devplacepy.services import news as news_mod
from devplacepy.services import base as base_mod
from devplacepy.services.news import (
NewsService,
_extract_grade,
_get_ai_key,
_get_article_images,
)
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
from tests.conftest import run_async
API_URL = "http://news.test/api"
AI_URL = "http://ai.test/v1/chat"
LINK_HIGH = "http://news.test/high"
LINK_LOW = "http://news.test/low"
class FakeResp_news_service:
def __init__(self, json_data=None, text="", status=200):
self._json = json_data
self.text = text
self.status_code = status
def raise_for_status(self):
if self.status_code >= 400:
raise httpx.HTTPError(f"status {self.status_code}")
def json(self):
return self._json
class FakeClient_news_service:
def __init__(self, articles):
self.articles = articles
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def get(self, url, timeout=None):
if url == API_URL:
return FakeResp_news_service(json_data={"articles": self.articles})
return FakeResp_news_service(text='<img src="http://img.test/a.png">')
async def post(self, url, json=None, headers=None, timeout=None):
prompt = json["messages"][0]["content"]
if "FailArticle" in prompt:
return FakeResp_news_service(status=500, text="err")
if "EmptyArticle" in prompt:
return FakeResp_news_service(json_data={"choices": [{"message": {"content": ""}}]})
if "BadArticle" in prompt:
return FakeResp_news_service(
json_data={"choices": [{"message": {"content": "no number"}}]}
)
grade = "9" if "HighArticle" in prompt else "3"
return FakeResp_news_service(json_data={"choices": [{"message": {"content": grade}}]})
class FailingApiClient(FakeClient_news_service):
async def get(self, url, timeout=None):
if url == API_URL:
raise httpx.HTTPError("api down")
return FakeResp_news_service(text="")
def _settings_stub(threshold="7"):
def fake_get_setting(key, default=None):
return {
"news_api_url": API_URL,
"news_ai_url": AI_URL,
"news_ai_model": "test-model",
"news_grade_threshold": threshold,
"news_ai_key": "",
}.get(key, default)
return fake_get_setting
def test_get_article_images_network_error_returns_empty():
client = FakeClient_news_service([])
async def failing_get(url, timeout=None):
raise httpx.HTTPError("down")
client.get = failing_get
assert run_async(_get_article_images("http://news.test/page", client)) == []
+126
View File
@@ -0,0 +1,126 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_pagination_spans_pages(seeded_db):
for _ in range(30):
requests.get(f"{BASE_URL}/notifications", allow_redirects=False)
admin = _admin(seeded_db)
data = _audit(admin, event_key="security.authz.denied")
assert data["pagination"]["total"] >= 30
assert data["pagination"]["total_pages"] >= 2
page2 = _audit(admin, event_key="security.authz.denied", page=2)
assert len(page2["entries"]) > 0
+169
View File
@@ -0,0 +1,169 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_polls = [0]
AJAX_polls = {"X-Requested-With": "fetch"}
def _session_polls():
_counter_polls[0] += 1
name = f"pol{int(time.time() * 1000)}{_counter_polls[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _create_post_polls(session, title, question, options):
fields = [
("content", "Poll host post content for tests."),
("title", title),
("topic", "question"),
("poll_question", question),
]
fields.extend(("poll_options", option) for option in options)
r = session.post(f"{BASE_URL}/posts/create", data=fields, allow_redirects=False)
slug = r.headers["location"].split("/posts/")[-1]
return get_table("posts").find_one(slug=slug)["uid"]
def _poll_for(post_uid):
poll = get_table("polls").find_one(post_uid=post_uid)
options = list(
get_table("poll_options").find(poll_uid=poll["uid"], order_by=["position"])
)
return poll, options
def _create_plain_post_polls(session, title):
r = session.post(
f"{BASE_URL}/posts/create",
data={
"content": "Plain post awaiting a poll on edit.",
"title": title,
"topic": "question",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
return slug, get_table("posts").find_one(slug=slug)["uid"]
def test_valid_poll_persists(app_server):
s, _ = _session_polls()
title = f"valid-poll-{int(time.time() * 1000)}"
post_uid = _create_post_polls(s, title, "Tabs or spaces?", ["Tabs", "Spaces", "Both"])
poll, options = _poll_for(post_uid)
assert poll is not None
assert len(options) == 3
def test_poll_dropped_with_one_option(app_server):
s, _ = _session_polls()
title = f"one-option-{int(time.time() * 1000)}"
post_uid = _create_post_polls(s, title, "Only one?", ["Solo"])
assert get_table("polls").find_one(post_uid=post_uid) is None
def test_poll_dropped_without_question(app_server):
s, _ = _session_polls()
title = f"no-question-{int(time.time() * 1000)}"
post_uid = _create_post_polls(s, title, "", ["Tabs", "Spaces"])
assert get_table("polls").find_one(post_uid=post_uid) is None
def test_vote_records_choice(app_server):
s, _ = _session_polls()
title = f"vote-{int(time.time() * 1000)}"
post_uid = _create_post_polls(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
r = s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[0]["uid"]},
headers=AJAX_polls,
)
payload = r.json()
assert payload["total"] == 1
assert payload["my_choice"] == options[0]["uid"]
assert get_table("poll_votes").count(poll_uid=poll["uid"]) == 1
def test_switch_vote_moves_choice(app_server):
s, _ = _session_polls()
title = f"switch-{int(time.time() * 1000)}"
post_uid = _create_post_polls(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[0]["uid"]},
headers=AJAX_polls,
)
r = s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[1]["uid"]},
headers=AJAX_polls,
)
payload = r.json()
assert payload["my_choice"] == options[1]["uid"]
assert payload["total"] == 1
def test_repeat_same_option_retracts(app_server):
s, _ = _session_polls()
title = f"retract-{int(time.time() * 1000)}"
post_uid = _create_post_polls(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[0]["uid"]},
headers=AJAX_polls,
)
r = s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[0]["uid"]},
headers=AJAX_polls,
)
payload = r.json()
assert payload["my_choice"] is None
assert payload["total"] == 0
assert get_table("poll_votes").count(poll_uid=poll["uid"], deleted_at=None) == 0
def test_vote_invalid_option_returns_400(app_server):
s, _ = _session_polls()
title = f"badopt-{int(time.time() * 1000)}"
post_uid = _create_post_polls(s, title, "Pick one", ["A", "B"])
poll, _ = _poll_for(post_uid)
r = s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": "not-a-real-option"},
headers=AJAX_polls,
)
assert r.status_code == 400
def test_vote_requires_login(app_server):
s, _ = _session_polls()
title = f"authvote-{int(time.time() * 1000)}"
post_uid = _create_post_polls(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
anon = requests.Session()
r = anon.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[0]["uid"]},
headers=AJAX_polls,
allow_redirects=False,
)
assert r.status_code == 303
assert get_table("poll_votes").count(poll_uid=poll["uid"]) == 0
def test_multi_field_options_keep_embedded_commas(app_server):
s, _ = _session_polls()
title = f"comma-label-{int(time.time() * 1000)}"
post_uid = _create_post_polls(s, title, "Sure?", ["Yes, definitely", "No, never"])
_, options = _poll_for(post_uid)
assert [option["label"] for option in options] == ["Yes, definitely", "No, never"]
View File
+700
View File
@@ -0,0 +1,700 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
from devplacepy.database import get_table
JSON_content_negotiation = {"Accept": "application/json"}
_counter_content_negotiation = [0]
def _session_content_negotiation(password="secret123"):
_counter_content_negotiation[0] += 1
name = f"cn{int(time.time() * 1000)}{_counter_content_negotiation[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": password,
"confirm_password": password,
},
allow_redirects=True,
)
return s, name
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
_counter_polls = [0]
AJAX_polls = {"X-Requested-With": "fetch"}
def _session_polls():
_counter_polls[0] += 1
name = f"pol{int(time.time() * 1000)}{_counter_polls[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _create_post_polls(session, title, question, options):
fields = [
("content", "Poll host post content for tests."),
("title", title),
("topic", "question"),
("poll_question", question),
]
fields.extend(("poll_options", option) for option in options)
r = session.post(f"{BASE_URL}/posts/create", data=fields, allow_redirects=False)
slug = r.headers["location"].split("/posts/")[-1]
return get_table("posts").find_one(slug=slug)["uid"]
def _poll_for(post_uid):
poll = get_table("polls").find_one(post_uid=post_uid)
options = list(
get_table("poll_options").find(poll_uid=poll["uid"], order_by=["position"])
)
return poll, options
def _create_plain_post_polls(session, title):
r = session.post(
f"{BASE_URL}/posts/create",
data={
"content": "Plain post awaiting a poll on edit.",
"title": title,
"topic": "question",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
return slug, get_table("posts").find_one(slug=slug)["uid"]
import sqlite3
from devplacepy.config import DATABASE_URL
from devplacepy.utils import generate_uid
from devplacepy.constants import REACTION_EMOJI
_counter_reactions = [0]
AJAX_reactions = {"X-Requested-With": "fetch"}
ROCKET = REACTION_EMOJI[2]
HEART = REACTION_EMOJI[1]
def _session_reactions():
_counter_reactions[0] += 1
name = f"rxn{int(time.time() * 1000)}{_counter_reactions[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _uid_reactions(username):
return get_table("users").find_one(username=username)["uid"]
def _live_query(sql, params):
conn = sqlite3.connect(DATABASE_URL.replace("sqlite:///", "", 1))
try:
return conn.execute(sql, params).fetchone()
finally:
conn.close()
def _live_reaction_count(target_type, target_uid):
return _live_query(
"SELECT COUNT(*) FROM reactions "
"WHERE target_type=? AND target_uid=? AND deleted_at IS NULL",
(target_type, target_uid),
)[0]
def _live_post_uid(slug):
row = _live_query("SELECT uid FROM posts WHERE slug=?", (slug,))
return row[0] if row else None
def _make_post_reactions(owner_uid):
uid = generate_uid()
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner_uid,
"slug": f"{uid[:8]}-reaction-post",
"title": None,
"content": "reaction target content",
"topic": "random",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _make_comment(post_uid, owner_uid):
uid = generate_uid()
get_table("comments").insert(
{
"uid": uid,
"target_type": "post",
"target_uid": post_uid,
"post_uid": post_uid,
"user_uid": owner_uid,
"content": "reaction target comment",
"parent_uid": None,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
import io
import re
import uuid
from PIL import Image
def _user_uploads(prefix="up"):
s = requests.Session()
name = f"{prefix}_{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, name
def _session_uploads():
s, _ = _user_uploads()
return s
def _png_bytes_uploads():
buf = io.BytesIO()
Image.new("RGB", (4, 4), (255, 0, 0)).save(buf, "PNG")
return buf.getvalue()
def _upload_uploads(s, name="a.png"):
r = s.post(
f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes_uploads(), "image/png")}
)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _session_validation():
s = requests.Session()
name = f"val_{int(time.time() * 1000)}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s
def test_post_create_and_delete_recorded(seeded_db):
s, _ = _member()
create = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aupost"), "content": "audit body", "topic": "devlog"},
).json()
uid = create["data"]["uid"]
slug = create["data"]["slug"]
admin = _admin(seeded_db)
assert _find(admin, "post.create", lambda e: e.get("target_uid") == uid) is not None
s.post(f"{BASE_URL}/posts/delete/{slug}", headers=JSON_audit_log, allow_redirects=False)
assert _find(admin, "post.delete", lambda e: e.get("target_uid") == uid) is not None
def test_vote_recorded(seeded_db):
s, _ = _member()
create = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("auvote"), "content": "vote target", "topic": "devlog"},
).json()
uid = create["data"]["uid"]
s.post(f"{BASE_URL}/votes/post/{uid}", data={"value": "1"}, allow_redirects=False)
admin = _admin(seeded_db)
event = _find(admin, "vote.post.up", lambda e: e.get("target_uid") == uid)
assert event is not None
assert event["new_value"] == "1"
def test_devii_origin_header_attribution(seeded_db):
s, _ = _member()
create = s.post(
f"{BASE_URL}/posts/create",
headers={**JSON_audit_log, "X-Devii-Agent": "1"},
data={"title": _unique("audevii"), "content": "audited via the agent", "topic": "devlog"},
).json()
uid = create["data"]["uid"]
admin = _admin(seeded_db)
event = _find(admin, "post.create", lambda e: e.get("target_uid") == uid)
assert event is not None
assert event["origin"] == "devii"
assert event["via_agent"] == 1
def test_poll_create_recorded(seeded_db):
s, _ = _member()
question = _unique("auq") + "?"
s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": _unique("aupoll"),
"content": "post with an audited poll",
"topic": "devlog",
"poll_question": question,
"poll_options": ["Alpha", "Beta"],
},
)
admin = _admin(seeded_db)
event = _find(
admin, "poll.create", lambda e: (e.get("metadata") or {}).get("question") == question
)
assert event is not None
def test_feed_json_shape_hides_sensitive_author_fields(app_server):
s, _ = _session_content_negotiation()
s.post(
f"{BASE_URL}/posts/create",
data={"content": "negotiation post body", "title": "CN", "topic": "devlog"},
)
data = requests.get(f"{BASE_URL}/feed", headers=JSON_content_negotiation).json()
assert isinstance(data["posts"], list)
author = data["posts"][0]["author"]
assert (
"email" not in author
and "api_key" not in author
and "password_hash" not in author
)
def test_action_returns_envelope_for_json(app_server):
s, _ = _session_content_negotiation()
r = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_content_negotiation,
data={"content": "json action body", "title": "JA", "topic": "devlog"},
allow_redirects=False,
)
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["redirect"].startswith("/posts/")
assert body["data"]["slug"]
assert get_table("posts").find_one(uid=body["data"]["uid"]) is not None
def test_action_redirects_for_browser(app_server):
s, _ = _session_content_negotiation()
r = s.post(
f"{BASE_URL}/posts/create",
data={"content": "browser action body", "title": "BA", "topic": "devlog"},
allow_redirects=False,
)
assert r.status_code == 302
assert r.headers["location"].startswith("/posts/")
def test_json_validation_returns_422(app_server):
s, _ = _session_content_negotiation()
r = s.post(f"{BASE_URL}/posts/create", headers=JSON_content_negotiation, json={})
assert r.status_code == 422
body = r.json()
assert body["error"] == "validation"
assert "fields" in body
def test_poll_from_comma_separated_string(app_server):
s, _ = _session_polls()
title = f"comma-poll-{int(time.time() * 1000)}"
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Poll built from a comma separated string.",
"title": title,
"topic": "question",
"poll_question": "Tabs or spaces?",
"poll_options": "Tabs, Spaces, Both",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
post_uid = get_table("posts").find_one(slug=slug)["uid"]
poll, options = _poll_for(post_uid)
assert poll is not None
assert [option["label"] for option in options] == ["Tabs", "Spaces", "Both"]
def test_poll_from_newline_separated_string(app_server):
s, _ = _session_polls()
title = f"newline-poll-{int(time.time() * 1000)}"
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Poll built from a newline separated string.",
"title": title,
"topic": "question",
"poll_question": "Pick a language",
"poll_options": "Python\nRust\nGo",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
post_uid = get_table("posts").find_one(slug=slug)["uid"]
poll, options = _poll_for(post_uid)
assert poll is not None
assert [option["label"] for option in options] == ["Python", "Rust", "Go"]
def test_delete_post_cascades_reactions(app_server):
s, name = _session_reactions()
title = f"cascade-{int(time.time() * 1000)}"
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Post created via the server for the reaction cascade test.",
"title": title,
"topic": "random",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
post_uid = _live_post_uid(slug)
s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX_reactions
)
assert _live_reaction_count("post", post_uid) == 1
s.post(f"{BASE_URL}/posts/delete/{slug}", allow_redirects=False)
assert s.get(f"{BASE_URL}/posts/{slug}").status_code == 404
assert _live_reaction_count("post", post_uid) == 0
def test_post_uid_redirects_to_canonical_slug(app_server):
slug, uid = _seed_post_seo()
r = requests.get(f"{BASE_URL}/posts/{uid}", allow_redirects=False)
assert r.status_code == 301
assert r.headers["location"].endswith(f"/posts/{slug}"), r.headers.get("location")
def test_post_slug_served_without_redirect(app_server):
slug, uid = _seed_post_seo()
r = requests.get(f"{BASE_URL}/posts/{slug}", allow_redirects=False)
assert r.status_code == 200
def test_post_links_multiple_attachments(app_server):
s = _session_uploads()
u1, u2 = _upload_uploads(s), _upload_uploads(s)
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Post body with two attachments here",
"title": "attach post",
"topic": "random",
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed on the post"
)
def test_single_attachment_links_to_post(app_server):
s = _session_uploads()
u1 = _upload_uploads(s)
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Post body with one attachment",
"title": "one",
"topic": "random",
"attachment_uids": u1,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text, "single attachment must be linked and displayed"
def test_oversized_post_content_rejected(app_server):
s = _session_validation()
r = s.post(
f"{BASE_URL}/posts/create",
data={"content": "x" * 125001, "topic": "random"},
allow_redirects=False,
)
assert r.status_code in (302, 303, 400)
assert "/posts/" not in (r.headers.get("location") or "")
def test_short_post_content_rejected(app_server):
s = _session_validation()
r = s.post(
f"{BASE_URL}/posts/create",
data={"content": "short", "topic": "random"},
allow_redirects=False,
)
assert "/posts/" not in (r.headers.get("location") or "")
+131
View File
@@ -0,0 +1,131 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_content_delete_denied_recorded(seeded_db):
owner, _ = _member()
post = _new_post(owner)
other, _ = _member()
other.post(
f"{BASE_URL}/posts/delete/{post['slug']}", headers=JSON_audit_log, allow_redirects=False
)
admin = _admin(seeded_db)
event = _find(
admin,
"post.delete",
lambda e: e.get("target_uid") == post["uid"] and e.get("result") == "denied",
)
assert event is not None
+239
View File
@@ -0,0 +1,239 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
from devplacepy.database import get_table
_counter_polls = [0]
AJAX_polls = {"X-Requested-With": "fetch"}
def _session_polls():
_counter_polls[0] += 1
name = f"pol{int(time.time() * 1000)}{_counter_polls[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _create_post_polls(session, title, question, options):
fields = [
("content", "Poll host post content for tests."),
("title", title),
("topic", "question"),
("poll_question", question),
]
fields.extend(("poll_options", option) for option in options)
r = session.post(f"{BASE_URL}/posts/create", data=fields, allow_redirects=False)
slug = r.headers["location"].split("/posts/")[-1]
return get_table("posts").find_one(slug=slug)["uid"]
def _poll_for(post_uid):
poll = get_table("polls").find_one(post_uid=post_uid)
options = list(
get_table("poll_options").find(poll_uid=poll["uid"], order_by=["position"])
)
return poll, options
def _create_plain_post_polls(session, title):
r = session.post(
f"{BASE_URL}/posts/create",
data={
"content": "Plain post awaiting a poll on edit.",
"title": title,
"topic": "question",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
return slug, get_table("posts").find_one(slug=slug)["uid"]
def test_post_edit_recorded(seeded_db):
s, _ = _member()
post = _new_post(s)
s.post(
f"{BASE_URL}/posts/edit/{post['slug']}",
headers=JSON_audit_log,
data={"title": "edited audited title", "content": "edited body content", "topic": "devlog"},
allow_redirects=False,
)
admin = _admin(seeded_db)
assert _find(admin, "post.edit", lambda e: e.get("target_uid") == post["uid"]) is not None
def test_add_poll_to_existing_post_via_edit(app_server):
s, _ = _session_polls()
title = f"edit-add-poll-{int(time.time() * 1000)}"
slug, post_uid = _create_plain_post_polls(s, title)
assert get_table("polls").find_one(post_uid=post_uid) is None
s.post(
f"{BASE_URL}/posts/edit/{slug}",
data={
"content": "Plain post awaiting a poll on edit.",
"title": title,
"topic": "question",
"poll_question": "Do you like hedgehogs?",
"poll_options": "Yes, Only on weekends, They are spiky",
},
allow_redirects=False,
)
poll, options = _poll_for(post_uid)
assert poll is not None
assert poll["question"] == "Do you like hedgehogs?"
assert [option["label"] for option in options] == [
"Yes",
"Only on weekends",
"They are spiky",
]
def test_edit_does_not_replace_existing_poll(app_server):
s, _ = _session_polls()
title = f"edit-keep-poll-{int(time.time() * 1000)}"
post_uid = _create_post_polls(s, title, "Original question?", ["A", "B"])
slug = get_table("posts").find_one(uid=post_uid)["slug"]
s.post(
f"{BASE_URL}/posts/edit/{slug}",
data={
"content": "Poll host post content for tests, edited.",
"title": title,
"topic": "question",
"poll_question": "Replacement question?",
"poll_options": "C, D, E",
},
allow_redirects=False,
)
assert get_table("polls").count(post_uid=post_uid) == 1
poll, options = _poll_for(post_uid)
assert poll["question"] == "Original question?"
assert [option["label"] for option in options] == ["A", "B"]
def test_edit_without_poll_fields_leaves_post_pollless(app_server):
s, _ = _session_polls()
title = f"edit-no-poll-{int(time.time() * 1000)}"
slug, post_uid = _create_plain_post_polls(s, title)
s.post(
f"{BASE_URL}/posts/edit/{slug}",
data={
"content": "Plain post edited without any poll fields.",
"title": title,
"topic": "question",
},
allow_redirects=False,
)
assert get_table("polls").find_one(post_uid=post_uid) is None
+30
View File
@@ -0,0 +1,30 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON_content_negotiation = {"Accept": "application/json"}
_counter_content_negotiation = [0]
def _session_content_negotiation(password="secret123"):
_counter_content_negotiation[0] += 1
name = f"cn{int(time.time() * 1000)}{_counter_content_negotiation[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": password,
"confirm_password": password,
},
allow_redirects=True,
)
return s, name
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
def test_json_404_error_envelope(app_server):
r = requests.get(f"{BASE_URL}/posts/does-not-exist-xyz", headers=JSON_content_negotiation)
assert r.status_code == 404
assert r.json()["error"]["status"] == 404
View File
+158
View File
@@ -0,0 +1,158 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_missing_profile_returns_404(app_server):
r = requests.get(f"{BASE_URL}/profile/no-such-user-xyz", allow_redirects=False)
assert r.status_code == 404
+51
View File
@@ -0,0 +1,51 @@
# retoor <retoor@molodetz.nl>
import base64
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_api_auth = [0]
def _signup_api_auth(password="secret123"):
_counter_api_auth[0] += 1
name = f"apiauth{int(time.time() * 1000)}{_counter_api_auth[0]}"
email = f"{name}@t.dev"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": email,
"password": password,
"confirm_password": password,
},
allow_redirects=True,
)
return session, name, email, password
def _user_api_auth(name):
return get_table("users").find_one(username=name)
def _key_api_auth(name):
return _user_api_auth(name)["api_key"]
def test_regenerate_invalidates_old_key(app_server):
session, name, _, _ = _signup_api_auth()
old_key = _key_api_auth(name)
_, target, _, _ = _signup_api_auth()
resp = session.post(f"{BASE_URL}/profile/regenerate-api-key")
new_key = resp.json()["api_key"]
assert new_key != old_key
old = requests.post(
f"{BASE_URL}/follow/{target}",
headers={"X-API-KEY": old_key},
allow_redirects=False,
)
assert old.status_code == 401
new = requests.post(
f"{BASE_URL}/follow/{target}",
headers={"X-API-KEY": new_key},
allow_redirects=False,
)
assert new.status_code in (302, 200)
+269
View File
@@ -0,0 +1,269 @@
# retoor <retoor@molodetz.nl>
import base64
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_api_auth = [0]
def _signup_api_auth(password="secret123"):
_counter_api_auth[0] += 1
name = f"apiauth{int(time.time() * 1000)}{_counter_api_auth[0]}"
email = f"{name}@t.dev"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": email,
"password": password,
"confirm_password": password,
},
allow_redirects=True,
)
return session, name, email, password
def _user_api_auth(name):
return get_table("users").find_one(username=name)
def _key_api_auth(name):
return _user_api_auth(name)["api_key"]
import io
import uuid
from PIL import Image
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
from datetime import datetime, timedelta, timezone
from devplacepy.database import (
get_table,
get_activity_calendar,
get_streaks,
get_activity_heatmap,
get_activity_months,
)
from devplacepy.utils import generate_uid, check_milestone_badges
_counter_streaks = [0]
def _make_user_streaks():
_counter_streaks[0] += 1
uid = generate_uid()
name = f"stk{int(time.time() * 1000)}{_counter_streaks[0]}"
get_table("users").insert(
{
"uid": uid,
"username": name,
"email": f"{name}@t.dev",
"role": "Member",
"is_active": True,
"xp": 0,
"level": 1,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _insert_post(user_uid, dt):
uid = generate_uid()
get_table("posts").insert(
{
"uid": uid,
"user_uid": user_uid,
"slug": f"{uid[:8]}-streak",
"title": None,
"content": "streak activity",
"topic": "random",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": dt.isoformat(),
}
)
return uid
def _insert_comment(user_uid, post_uid, dt):
uid = generate_uid()
get_table("comments").insert(
{
"uid": uid,
"target_type": "post",
"target_uid": post_uid,
"post_uid": post_uid,
"user_uid": user_uid,
"content": "streak comment",
"parent_uid": None,
"created_at": dt.isoformat(),
}
)
return uid
def test_owner_sees_own_key_on_profile(app_server):
session, name, _, _ = _signup_api_auth()
html = session.get(f"{BASE_URL}/profile/{name}").text
assert "data-api-key-card" in html
assert _key_api_auth(name) in html
def test_member_cannot_see_others_key(app_server):
_signup_api_auth() # absorb the admin slot if the DB is empty
session_a, name_a, _, _ = _signup_api_auth()
_, name_b, _, _ = _signup_api_auth()
assert _user_api_auth(name_a)["role"] != "Admin"
html = session_a.get(f"{BASE_URL}/profile/{name_b}").text
assert "data-api-key-card" not in html
def test_media_tab_html_renders_grid(app_server):
s, name = _signup_media()
uid = _upload_media(s)
_create_post_media(s, uid)
r = s.get(f"{BASE_URL}/profile/{name}?tab=media")
assert r.status_code == 200
assert "media-grid" in r.text
assert "data-lightbox" in r.text
assert uid in r.text
def test_media_tab_empty_state_for_new_user(app_server):
s, name = _signup_media()
r = s.get(f"{BASE_URL}/profile/{name}?tab=media")
assert r.status_code == 200
assert "No media yet." in r.text
assert _media_json(s, name)["media"] == []
def test_media_pagination(app_server):
s, name = _signup_media()
# per_page is 24; create 26 linked attachments
for i in range(26):
uid = _upload_media(s)
_create_post_media(s, uid, title=f"p{i}")
data = _media_json(s, name)
pag = data["media_pagination"]
assert pag["total"] >= 26
assert pag["total_pages"] >= 2
assert len(data["media"]) == pag["per_page"] == 24
r2 = s.get(f"{BASE_URL}/profile/{name}?tab=media&page=2", headers=JSON_media)
assert r2.status_code == 200
assert len(r2.json()["media"]) >= 2
# the HTML pager keeps the tab so links do not fall back to posts
# (the ampersand is entity-escaped in the rendered href)
html = s.get(f"{BASE_URL}/profile/{name}?tab=media").text
assert "tab=media&amp;page=2" in html or "tab=media&page=2" in html
def test_profile_renders_heatmap_and_streak(app_server):
_counter_streaks[0] += 1
name = f"stkp{int(time.time() * 1000)}{_counter_streaks[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post created today for the streak heatmap.",
"title": "Streak heatmap post",
"topic": "devlog",
},
allow_redirects=True,
)
html = s.get(f"{BASE_URL}/profile/{name}").text
assert "heatmap-grid" in html
assert "1 day streak" in html
+157
View File
@@ -0,0 +1,157 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def _session_validation():
s = requests.Session()
name = f"val_{int(time.time() * 1000)}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s
def test_profile_update_recorded(seeded_db):
s, name = _member()
s.post(
f"{BASE_URL}/profile/update",
headers=JSON_audit_log,
data={"bio": "audited bio", "location": "", "git_link": "", "website": ""},
allow_redirects=False,
)
admin = _admin(seeded_db)
event = _find(admin, "profile.update", lambda e: e.get("target_label") == name)
assert event is not None
def test_profile_update_overlong_bio_rejected(app_server):
s = _session_validation()
r = s.post(
f"{BASE_URL}/profile/update",
data={
"bio": "x" * 600,
"location": "",
"git_link": "",
"website": "",
},
allow_redirects=False,
)
assert r.status_code in (302, 303, 400)
View File
+248
View File
@@ -0,0 +1,248 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_project_files = [0]
def _signup_project_files():
_counter_project_files[0] += 1
name = f"pf{int(time.time() * 1000)}{_counter_project_files[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_project_files(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_project_files(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_project_files(key),
data={
"title": title,
"description": "filesystem test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_project_files(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_files(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _mkdir(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/mkdir",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _move(key, slug, from_path, to_path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/move",
headers=_h_project_files(key),
data={"from_path": from_path, "to_path": to_path},
allow_redirects=False,
)
def _delete(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/delete",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _list(slug, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files",
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _raw_project_files(slug, path, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": path},
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _paths(slug, key=None):
return sorted(f["path"] for f in _list(slug, key).json()["files"])
def _make_project_ui(page, title):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for filesystem UI test")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
return page.url
def _open_files(page, title):
proj_url = _make_project_ui(page, title)
slug = proj_url.rstrip("/").split("/")[-1]
page.goto(proj_url + "/files", wait_until="domcontentloaded")
return slug
def _dialog_fill(page, value):
page.locator(".dialog-overlay.visible .dialog-input").wait_for(state="visible")
page.fill(".dialog-overlay.visible .dialog-input", value)
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_confirm(page):
page.locator(".dialog-overlay.visible .dialog-confirm").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_cancel(page):
page.locator(".dialog-overlay.visible .dialog-cancel").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-cancel")
def _new_folder(page, name):
page.click("#pf-new-folder")
_dialog_fill(page, name)
page.wait_for_selector(f".pf-node-row:has-text('{name.split('/')[-1]}')")
def _new_file(page, name):
page.click("#pf-new-file")
_dialog_fill(page, name)
def _row(page, name):
return page.locator(f".pf-node-row:has-text('{name}')").first
def _alice_key():
return get_table("users").find_one(username="alice_test")["api_key"]
def test_write_creates_file_and_parents(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS Write")["slug"]
r = _write_project_files(key, slug, "src/app/main.py", "print('hi')\n")
assert r.status_code == 200 and r.json()["ok"] is True
assert _paths(slug, key) == ["src", "src/app", "src/app/main.py"]
def test_read_returns_content(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS Read")["slug"]
_write_project_files(key, slug, "notes.txt", "hello world")
r = _raw_project_files(slug, "notes.txt", key)
body = r.json()
assert r.status_code == 200
assert body["content"] == "hello world"
assert body["is_binary"] is False and body["type"] == "file"
def test_write_overwrites_existing(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS Overwrite")["slug"]
_write_project_files(key, slug, "a.py", "one")
_write_project_files(key, slug, "a.py", "two")
assert _raw_project_files(slug, "a.py", key).json()["content"] == "two"
assert _paths(slug, key) == ["a.py"]
def test_list_empty_project(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS Empty")["slug"]
r = _list(slug, key)
assert (
r.status_code == 200
and r.json()["files"] == []
and r.json()["is_owner"] is True
)
def test_mkdir_recursive(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS Mkdir")["slug"]
r = _mkdir(key, slug, "a/b/c")
assert r.status_code == 200
files = {f["path"]: f["type"] for f in _list(slug, key).json()["files"]}
assert files == {"a": "dir", "a/b": "dir", "a/b/c": "dir"}
def test_mkdir_idempotent(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS MkdirTwice")["slug"]
assert _mkdir(key, slug, "docs").status_code == 200
assert _mkdir(key, slug, "docs").status_code == 200
assert _paths(slug, key) == ["docs"]
def test_move_file(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS MoveFile")["slug"]
_write_project_files(key, slug, "old.py", "data")
assert _move(key, slug, "old.py", "src/new.py").status_code == 200
assert _paths(slug, key) == ["src", "src/new.py"]
assert _raw_project_files(slug, "src/new.py", key).json()["content"] == "data"
def test_move_directory_with_descendants(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS MoveDir")["slug"]
_write_project_files(key, slug, "src/a.py", "1")
_write_project_files(key, slug, "src/sub/b.py", "2")
assert _move(key, slug, "src", "lib").status_code == 200
assert _paths(slug, key) == ["lib", "lib/a.py", "lib/sub", "lib/sub/b.py"]
def test_delete_file(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS DeleteFile")["slug"]
_write_project_files(key, slug, "x.py", "1")
assert _delete(key, slug, "x.py").status_code == 200
assert _paths(slug, key) == []
def test_delete_directory_recursive(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS DeleteDir")["slug"]
_write_project_files(key, slug, "src/a.py", "1")
_write_project_files(key, slug, "src/sub/b.py", "2")
_write_project_files(key, slug, "keep.py", "3")
assert _delete(key, slug, "src").status_code == 200
assert _paths(slug, key) == ["keep.py"]
def test_write_rejects_traversal(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS Traversal")["slug"]
r = _write_project_files(key, slug, "../../etc/passwd", "x")
assert r.status_code == 400
assert _paths(slug, key) == []
def test_mkdir_rejects_dotdot(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS Dotdot")["slug"]
assert _mkdir(key, slug, "a/../../b").status_code == 400
def test_raw_missing_file_404(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS Missing")["slug"]
assert _raw_project_files(slug, "nope.py", key).status_code == 404
def test_public_read_without_auth(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS Public")["slug"]
_write_project_files(key, slug, "README.md", "# hi")
r = _list(slug)
assert r.status_code == 200 and r.json()["is_owner"] is False
assert _raw_project_files(slug, "README.md").json()["content"] == "# hi"
def test_non_owner_cannot_write(app_server):
_, owner_key = _signup_project_files()
_, other_key = _signup_project_files()
slug = _create_project_project_files(owner_key, "FS Perms")["slug"]
r = _write_project_files(other_key, slug, "evil.py", "x")
assert r.status_code == 403
assert _paths(slug, owner_key) == []
def test_unknown_project_404(app_server):
assert _list("does-not-exist").status_code == 404
+155
View File
@@ -0,0 +1,155 @@
# retoor <retoor@molodetz.nl>
import time
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import clear_user_cache
from devplacepy import project_files
from devplacepy.project_files import ProjectFileError
from devplacepy.services.devii.actions.dispatcher import (
confirmation_error,
_is_confirmed,
)
_counter_project_visibility = [0]
def _signup_project_visibility():
_counter_project_visibility[0] += 1
name = f"pv{int(time.time() * 1000)}{_counter_project_visibility[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
row = get_table("users").find_one(username=name)
return name, row["uid"], row["api_key"]
def _make_admin_project_visibility():
name, uid, key = _signup_project_visibility()
get_table("users").update({"uid": uid, "role": "Admin"}, ["uid"])
clear_user_cache(uid)
return name, uid, key
def _h_project_visibility(key=None):
headers = {"Accept": "application/json"}
if key:
headers["X-API-KEY"] = key
return headers
def _create_project_project_visibility(key, title, is_private=False):
data = {
"title": title,
"description": "visibility test",
"project_type": "software",
"status": "In Development",
}
if is_private:
data["is_private"] = "on"
r = requests.post(f"{BASE_URL}/projects/create", headers=_h_project_visibility(key), data=data)
assert r.status_code == 200, r.text
return r.json()["data"]
def _project_uid(slug):
return get_table("projects").find_one(slug=slug)["uid"]
def _write_project_visibility(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_visibility(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _set_private(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/private",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _set_readonly(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/readonly",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _list_slugs(key=None, user_uid=None):
params = {"user_uid": user_uid} if user_uid else None
r = requests.get(f"{BASE_URL}/projects", headers=_h_project_visibility(key), params=params)
return [p["slug"] for p in r.json()["projects"]]
def test_private_project_hidden_from_guest_listing(app_server):
_, owner_uid, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Private Listing", is_private=True)["slug"]
assert slug not in _list_slugs(key=None, user_uid=owner_uid)
assert slug in _list_slugs(key=key, user_uid=owner_uid)
def test_private_project_hidden_from_other_member(app_server):
_, owner_uid, owner_key = _signup_project_visibility()
_, _, other_key = _signup_project_visibility()
slug = _create_project_project_visibility(owner_key, "Private FromOther", is_private=True)["slug"]
assert slug not in _list_slugs(key=other_key, user_uid=owner_uid)
def test_private_project_visible_to_admin(app_server):
_, owner_uid, owner_key = _signup_project_visibility()
_, _, admin_key = _make_admin_project_visibility()
slug = _create_project_project_visibility(owner_key, "Private AdminSees", is_private=True)["slug"]
assert slug in _list_slugs(key=admin_key, user_uid=owner_uid)
def test_toggle_private_then_public(app_server):
_, owner_uid, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Toggle Privacy")["slug"]
assert slug in _list_slugs(key=None, user_uid=owner_uid)
assert _set_private(key, slug, True).status_code == 200
assert slug not in _list_slugs(key=None, user_uid=owner_uid)
assert _set_private(key, slug, False).status_code == 200
assert slug in _list_slugs(key=None, user_uid=owner_uid)
def test_non_owner_cannot_toggle_flags(app_server):
_, _, owner_key = _signup_project_visibility()
_, _, other_key = _signup_project_visibility()
slug = _create_project_project_visibility(owner_key, "Toggle Guarded")["slug"]
assert _set_private(other_key, slug, True).status_code == 403
assert _set_readonly(other_key, slug, True).status_code == 403
def test_toggle_readonly_off_restores_writes(app_server):
_, _, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Readonly Restore")["slug"]
_write_project_visibility(key, slug, "a.txt", "one")
_set_readonly(key, slug, True)
assert _write_project_visibility(key, slug, "a.txt", "two").status_code == 400
assert _set_readonly(key, slug, False).status_code == 200
assert _write_project_visibility(key, slug, "a.txt", "two").status_code == 200
def test_readonly_guards_service_layer(app_server):
_, owner_uid, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Readonly Service")["slug"]
project_uid = _project_uid(slug)
user = get_table("users").find_one(uid=owner_uid)
project_files.write_text_file(project_uid, user, "seed.txt", "seed")
_set_readonly(key, slug, True)
assert project_files.is_readonly(project_uid) is True
with pytest.raises(ProjectFileError):
project_files.write_text_file(project_uid, user, "seed.txt", "blocked")
with pytest.raises(ProjectFileError):
project_files.make_dir(project_uid, user, "docs")
def test_readonly_blocks_import_from_dir(app_server, tmp_path):
_, owner_uid, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Readonly Import")["slug"]
project_uid = _project_uid(slug)
user = get_table("users").find_one(uid=owner_uid)
(tmp_path / "imported.txt").write_text("data")
_set_readonly(key, slug, True)
with pytest.raises(ProjectFileError):
project_files.import_from_dir(project_uid, str(tmp_path), user)
View File
+57
View File
@@ -0,0 +1,57 @@
# retoor <retoor@molodetz.nl>
import io
import re
import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL
def _user_uploads(prefix="up"):
s = requests.Session()
name = f"{prefix}_{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, name
def _session_uploads():
s, _ = _user_uploads()
return s
def _png_bytes_uploads():
buf = io.BytesIO()
Image.new("RGB", (4, 4), (255, 0, 0)).save(buf, "PNG")
return buf.getvalue()
def _upload_uploads(s, name="a.png"):
r = s.post(
f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes_uploads(), "image/png")}
)
assert r.status_code == 201, r.text
return r.json()["uid"]
def test_project_links_multiple_attachments(app_server):
s = _session_uploads()
u1, u2 = _upload_uploads(s), _upload_uploads(s)
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": "Attach Project",
"description": "Project with attachments",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed on the project"
)
+220
View File
@@ -0,0 +1,220 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_project_files = [0]
def _signup_project_files():
_counter_project_files[0] += 1
name = f"pf{int(time.time() * 1000)}{_counter_project_files[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_project_files(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_project_files(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_project_files(key),
data={
"title": title,
"description": "filesystem test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_project_files(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_files(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _mkdir(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/mkdir",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _move(key, slug, from_path, to_path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/move",
headers=_h_project_files(key),
data={"from_path": from_path, "to_path": to_path},
allow_redirects=False,
)
def _delete(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/delete",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _list(slug, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files",
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _raw_project_files(slug, path, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": path},
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _paths(slug, key=None):
return sorted(f["path"] for f in _list(slug, key).json()["files"])
def _make_project_ui(page, title):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for filesystem UI test")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
return page.url
def _open_files(page, title):
proj_url = _make_project_ui(page, title)
slug = proj_url.rstrip("/").split("/")[-1]
page.goto(proj_url + "/files", wait_until="domcontentloaded")
return slug
def _dialog_fill(page, value):
page.locator(".dialog-overlay.visible .dialog-input").wait_for(state="visible")
page.fill(".dialog-overlay.visible .dialog-input", value)
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_confirm(page):
page.locator(".dialog-overlay.visible .dialog-confirm").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_cancel(page):
page.locator(".dialog-overlay.visible .dialog-cancel").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-cancel")
def _new_folder(page, name):
page.click("#pf-new-folder")
_dialog_fill(page, name)
page.wait_for_selector(f".pf-node-row:has-text('{name.split('/')[-1]}')")
def _new_file(page, name):
page.click("#pf-new-file")
_dialog_fill(page, name)
def _row(page, name):
return page.locator(f".pf-node-row:has-text('{name}')").first
def _alice_key():
return get_table("users").find_one(username="alice_test")["api_key"]
import pytest
from devplacepy.utils import clear_user_cache
from devplacepy import project_files
from devplacepy.project_files import ProjectFileError
from devplacepy.services.devii.actions.dispatcher import (
confirmation_error,
_is_confirmed,
)
_counter_project_visibility = [0]
def _signup_project_visibility():
_counter_project_visibility[0] += 1
name = f"pv{int(time.time() * 1000)}{_counter_project_visibility[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
row = get_table("users").find_one(username=name)
return name, row["uid"], row["api_key"]
def _make_admin_project_visibility():
name, uid, key = _signup_project_visibility()
get_table("users").update({"uid": uid, "role": "Admin"}, ["uid"])
clear_user_cache(uid)
return name, uid, key
def _h_project_visibility(key=None):
headers = {"Accept": "application/json"}
if key:
headers["X-API-KEY"] = key
return headers
def _create_project_project_visibility(key, title, is_private=False):
data = {
"title": title,
"description": "visibility test",
"project_type": "software",
"status": "In Development",
}
if is_private:
data["is_private"] = "on"
r = requests.post(f"{BASE_URL}/projects/create", headers=_h_project_visibility(key), data=data)
assert r.status_code == 200, r.text
return r.json()["data"]
def _project_uid(slug):
return get_table("projects").find_one(slug=slug)["uid"]
def _write_project_visibility(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_visibility(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _set_private(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/private",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _set_readonly(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/readonly",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _list_slugs(key=None, user_uid=None):
params = {"user_uid": user_uid} if user_uid else None
r = requests.get(f"{BASE_URL}/projects", headers=_h_project_visibility(key), params=params)
return [p["slug"] for p in r.json()["projects"]]
def test_project_delete_purges_files(app_server):
_, key = _signup_project_files()
project = _create_project_project_files(key, "FS Cascade")
slug, uid = project["slug"], project["uid"]
_write_project_files(key, slug, "src/a.py", "1")
_write_project_files(key, slug, "src/b.py", "2")
assert get_table("project_files").count(project_uid=uid) == 3
r = requests.post(
f"{BASE_URL}/projects/delete/{slug}", headers=_h_project_files(key), allow_redirects=False
)
assert r.status_code in (200, 302)
from devplacepy.database import refresh_snapshot
refresh_snapshot()
assert get_table("project_files").count(project_uid=uid, deleted_at=None) == 0
def test_delete_project_works_when_readonly(app_server):
_, _, key = _signup_project_visibility()
project = _create_project_project_visibility(key, "Readonly Delete")
slug = project["slug"]
_write_project_visibility(key, slug, "a.txt", "one")
_set_readonly(key, slug, True)
r = requests.post(
f"{BASE_URL}/projects/delete/{slug}", headers=_h_project_visibility(key), allow_redirects=False
)
assert r.status_code == 200 and r.json()["ok"] is True
assert (
requests.get(f"{BASE_URL}/projects/{slug}", headers=_h_project_visibility(key)).status_code == 404
)
+117
View File
@@ -0,0 +1,117 @@
# retoor <retoor@molodetz.nl>
import time
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import clear_user_cache
from devplacepy import project_files
from devplacepy.project_files import ProjectFileError
from devplacepy.services.devii.actions.dispatcher import (
confirmation_error,
_is_confirmed,
)
_counter_project_visibility = [0]
def _signup_project_visibility():
_counter_project_visibility[0] += 1
name = f"pv{int(time.time() * 1000)}{_counter_project_visibility[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
row = get_table("users").find_one(username=name)
return name, row["uid"], row["api_key"]
def _make_admin_project_visibility():
name, uid, key = _signup_project_visibility()
get_table("users").update({"uid": uid, "role": "Admin"}, ["uid"])
clear_user_cache(uid)
return name, uid, key
def _h_project_visibility(key=None):
headers = {"Accept": "application/json"}
if key:
headers["X-API-KEY"] = key
return headers
def _create_project_project_visibility(key, title, is_private=False):
data = {
"title": title,
"description": "visibility test",
"project_type": "software",
"status": "In Development",
}
if is_private:
data["is_private"] = "on"
r = requests.post(f"{BASE_URL}/projects/create", headers=_h_project_visibility(key), data=data)
assert r.status_code == 200, r.text
return r.json()["data"]
def _project_uid(slug):
return get_table("projects").find_one(slug=slug)["uid"]
def _write_project_visibility(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_visibility(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _set_private(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/private",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _set_readonly(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/readonly",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _list_slugs(key=None, user_uid=None):
params = {"user_uid": user_uid} if user_uid else None
r = requests.get(f"{BASE_URL}/projects", headers=_h_project_visibility(key), params=params)
return [p["slug"] for p in r.json()["projects"]]
def test_owner_can_edit_project(app_server):
_, _, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Editable Via Api")["slug"]
r = requests.post(
f"{BASE_URL}/projects/edit/{slug}",
headers=_h_project_visibility(key),
data={
"title": "Edited Via Api",
"description": "updated description body",
"project_type": "website",
"status": "Released",
"platforms": "Linux,Web",
},
allow_redirects=False,
)
assert r.status_code == 200 and r.json()["ok"] is True
row = get_table("projects").find_one(slug=slug)
assert row["title"] == "Edited Via Api"
assert row["status"] == "Released"
assert row["project_type"] == "website"
assert row["platforms"] == "Linux,Web"
def test_non_owner_cannot_edit_project(app_server):
_, _, owner_key = _signup_project_visibility()
slug = _create_project_project_visibility(owner_key, "Owner Edit Guard")["slug"]
_, _, other_key = _signup_project_visibility()
r = requests.post(
f"{BASE_URL}/projects/edit/{slug}",
headers=_h_project_visibility(other_key),
data={"title": "Hijacked", "description": "should not persist"},
allow_redirects=False,
)
assert r.status_code == 403
assert get_table("projects").find_one(slug=slug)["title"] == "Owner Edit Guard"
+113
View File
@@ -0,0 +1,113 @@
# retoor <retoor@molodetz.nl>
import time
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import init_db, get_table
from devplacepy import project_files as pf
from devplacepy.project_files import ProjectFileError
@pytest.fixture(autouse=True)
def _init_db_project_file_lines():
init_db()
yield
_pid = [0]
def _project():
_pid[0] += 1
pid = f"plines-{_pid[0]}"
user = {"uid": f"plines-owner-{_pid[0]}"}
return pid, user
_counter_project_file_lines = [0]
def _signup_project_file_lines():
_counter_project_file_lines[0] += 1
name = f"pl{int(time.time() * 1000)}{_counter_project_file_lines[0]}"
requests.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return name, get_table("users").find_one(username=name)["api_key"]
def _h_project_file_lines(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_project_file_lines(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_project_file_lines(key),
data={
"title": title,
"description": "lines test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_project_file_lines(key, slug, path, content):
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_file_lines(key),
data={"path": path, "content": content},
allow_redirects=False,
)
assert r.status_code in (200, 302), r.text
def _raw_project_file_lines(key, slug, path):
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw", headers=_h_project_file_lines(key), params={"path": path}
).json()
class _FakeResponse:
def __init__(self, status_code=200):
self.status_code = status_code
class _FakeClient:
authenticated = True
username = "u"
def __init__(self):
self.calls = []
async def call(
self, method, path, params=None, data=None, file_field=None, headers=None
):
self.calls.append((method, path))
return _FakeResponse()
def _make_dispatcher():
import devplacepy.services.devii.actions.dispatcher as disp
from devplacepy.services.devii.actions.catalog import PLATFORM_CATALOG
d = disp.Dispatcher.__new__(disp.Dispatcher)
d._actions = PLATFORM_CATALOG.by_name()
d._client = _FakeClient()
d._read_files = set()
return disp, d
def test_http_read_lines(app_server):
_, key = _signup_project_file_lines()
proj = _create_project_project_file_lines(key, "HTTP Read Lines")
slug = proj["slug"] or proj["uid"]
_write_project_file_lines(key, slug, "f.txt", "a\nb\nc\nd")
r = requests.get(
f"{BASE_URL}/projects/{slug}/files/lines",
headers=_h_project_file_lines(key),
params={"path": "f.txt", "start": 2, "end": 3},
)
assert r.status_code == 200
body = r.json()
assert body["lines"] == ["b", "c"]
assert body["total_lines"] == 4
def test_http_lines_missing_file_404(app_server):
_, key = _signup_project_file_lines()
proj = _create_project_project_file_lines(key, "HTTP Lines 404")
slug = proj["slug"] or proj["uid"]
r = requests.get(
f"{BASE_URL}/projects/{slug}/files/lines",
headers=_h_project_file_lines(key),
params={"path": "nope.txt"},
)
assert r.status_code == 404
+116
View File
@@ -0,0 +1,116 @@
# retoor <retoor@molodetz.nl>
import time
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import clear_user_cache
from devplacepy import project_files
from devplacepy.project_files import ProjectFileError
from devplacepy.services.devii.actions.dispatcher import (
confirmation_error,
_is_confirmed,
)
_counter_project_visibility = [0]
def _signup_project_visibility():
_counter_project_visibility[0] += 1
name = f"pv{int(time.time() * 1000)}{_counter_project_visibility[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
row = get_table("users").find_one(username=name)
return name, row["uid"], row["api_key"]
def _make_admin_project_visibility():
name, uid, key = _signup_project_visibility()
get_table("users").update({"uid": uid, "role": "Admin"}, ["uid"])
clear_user_cache(uid)
return name, uid, key
def _h_project_visibility(key=None):
headers = {"Accept": "application/json"}
if key:
headers["X-API-KEY"] = key
return headers
def _create_project_project_visibility(key, title, is_private=False):
data = {
"title": title,
"description": "visibility test",
"project_type": "software",
"status": "In Development",
}
if is_private:
data["is_private"] = "on"
r = requests.post(f"{BASE_URL}/projects/create", headers=_h_project_visibility(key), data=data)
assert r.status_code == 200, r.text
return r.json()["data"]
def _project_uid(slug):
return get_table("projects").find_one(slug=slug)["uid"]
def _write_project_visibility(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_visibility(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _set_private(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/private",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _set_readonly(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/readonly",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _list_slugs(key=None, user_uid=None):
params = {"user_uid": user_uid} if user_uid else None
r = requests.get(f"{BASE_URL}/projects", headers=_h_project_visibility(key), params=params)
return [p["slug"] for p in r.json()["projects"]]
def test_private_files_hidden_from_guest(app_server):
_, _, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Private Files", is_private=True)["slug"]
_write_project_visibility(key, slug, "secret.txt", "classified")
assert (
requests.get(f"{BASE_URL}/projects/{slug}/files", headers=_h_project_visibility()).status_code
== 404
)
assert (
requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": "secret.txt"},
headers=_h_project_visibility(),
).status_code
== 404
)
assert (
requests.get(f"{BASE_URL}/projects/{slug}/files", headers=_h_project_visibility(key)).status_code
== 200
)
def test_readonly_unchanged_content(app_server):
_, _, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Readonly Content")["slug"]
_write_project_visibility(key, slug, "a.txt", "original")
_set_readonly(key, slug, True)
_write_project_visibility(key, slug, "a.txt", "tampered")
body = requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": "a.txt"},
headers=_h_project_visibility(key),
).json()
assert body["content"] == "original"
+272
View File
@@ -0,0 +1,272 @@
# retoor <retoor@molodetz.nl>
import time
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import init_db, get_table
from devplacepy import project_files as pf
from devplacepy.project_files import ProjectFileError
@pytest.fixture(autouse=True)
def _init_db_project_file_lines():
init_db()
yield
_pid = [0]
def _project():
_pid[0] += 1
pid = f"plines-{_pid[0]}"
user = {"uid": f"plines-owner-{_pid[0]}"}
return pid, user
_counter_project_file_lines = [0]
def _signup_project_file_lines():
_counter_project_file_lines[0] += 1
name = f"pl{int(time.time() * 1000)}{_counter_project_file_lines[0]}"
requests.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return name, get_table("users").find_one(username=name)["api_key"]
def _h_project_file_lines(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_project_file_lines(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_project_file_lines(key),
data={
"title": title,
"description": "lines test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_project_file_lines(key, slug, path, content):
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_file_lines(key),
data={"path": path, "content": content},
allow_redirects=False,
)
assert r.status_code in (200, 302), r.text
def _raw_project_file_lines(key, slug, path):
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw", headers=_h_project_file_lines(key), params={"path": path}
).json()
class _FakeResponse:
def __init__(self, status_code=200):
self.status_code = status_code
class _FakeClient:
authenticated = True
username = "u"
def __init__(self):
self.calls = []
async def call(
self, method, path, params=None, data=None, file_field=None, headers=None
):
self.calls.append((method, path))
return _FakeResponse()
def _make_dispatcher():
import devplacepy.services.devii.actions.dispatcher as disp
from devplacepy.services.devii.actions.catalog import PLATFORM_CATALOG
d = disp.Dispatcher.__new__(disp.Dispatcher)
d._actions = PLATFORM_CATALOG.by_name()
d._client = _FakeClient()
d._read_files = set()
return disp, d
from devplacepy.database import get_table
from devplacepy.utils import clear_user_cache
from devplacepy import project_files
from devplacepy.services.devii.actions.dispatcher import (
confirmation_error,
_is_confirmed,
)
_counter_project_visibility = [0]
def _signup_project_visibility():
_counter_project_visibility[0] += 1
name = f"pv{int(time.time() * 1000)}{_counter_project_visibility[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
row = get_table("users").find_one(username=name)
return name, row["uid"], row["api_key"]
def _make_admin_project_visibility():
name, uid, key = _signup_project_visibility()
get_table("users").update({"uid": uid, "role": "Admin"}, ["uid"])
clear_user_cache(uid)
return name, uid, key
def _h_project_visibility(key=None):
headers = {"Accept": "application/json"}
if key:
headers["X-API-KEY"] = key
return headers
def _create_project_project_visibility(key, title, is_private=False):
data = {
"title": title,
"description": "visibility test",
"project_type": "software",
"status": "In Development",
}
if is_private:
data["is_private"] = "on"
r = requests.post(f"{BASE_URL}/projects/create", headers=_h_project_visibility(key), data=data)
assert r.status_code == 200, r.text
return r.json()["data"]
def _project_uid(slug):
return get_table("projects").find_one(slug=slug)["uid"]
def _write_project_visibility(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_visibility(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _set_private(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/private",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _set_readonly(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/readonly",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _list_slugs(key=None, user_uid=None):
params = {"user_uid": user_uid} if user_uid else None
r = requests.get(f"{BASE_URL}/projects", headers=_h_project_visibility(key), params=params)
return [p["slug"] for p in r.json()["projects"]]
def test_http_replace_insert_delete_append_roundtrip(app_server):
_, key = _signup_project_file_lines()
proj = _create_project_project_file_lines(key, "HTTP Edit Roundtrip")
slug = proj["slug"] or proj["uid"]
_write_project_file_lines(key, slug, "f.txt", "a\nb\nc\nd")
requests.post(
f"{BASE_URL}/projects/{slug}/files/replace-lines",
headers=_h_project_file_lines(key),
data={"path": "f.txt", "start": 2, "end": 3, "content": "X\nY"},
allow_redirects=False,
)
assert _raw_project_file_lines(key, slug, "f.txt")["content"] == "a\nX\nY\nd"
requests.post(
f"{BASE_URL}/projects/{slug}/files/insert-lines",
headers=_h_project_file_lines(key),
data={"path": "f.txt", "at": 1, "content": "TOP"},
allow_redirects=False,
)
assert _raw_project_file_lines(key, slug, "f.txt")["content"] == "TOP\na\nX\nY\nd"
requests.post(
f"{BASE_URL}/projects/{slug}/files/delete-lines",
headers=_h_project_file_lines(key),
data={"path": "f.txt", "start": 1, "end": 1},
allow_redirects=False,
)
assert _raw_project_file_lines(key, slug, "f.txt")["content"] == "a\nX\nY\nd"
requests.post(
f"{BASE_URL}/projects/{slug}/files/append",
headers=_h_project_file_lines(key),
data={"path": "f.txt", "content": "END"},
allow_redirects=False,
)
assert _raw_project_file_lines(key, slug, "f.txt")["content"] == "a\nX\nY\nd\nEND"
def test_http_replace_lines_non_owner_denied(app_server):
_, owner_key = _signup_project_file_lines()
_, other_key = _signup_project_file_lines()
proj = _create_project_project_file_lines(owner_key, "HTTP Owner Guard")
slug = proj["slug"] or proj["uid"]
_write_project_file_lines(owner_key, slug, "f.txt", "a\nb")
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/replace-lines",
headers=_h_project_file_lines(other_key),
data={"path": "f.txt", "start": 1, "end": 1, "content": "x"},
allow_redirects=False,
)
assert r.status_code == 403
def test_readonly_blocks_every_mutation(app_server):
_, _, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Readonly Block")["slug"]
_write_project_visibility(key, slug, "main.py", "print(1)\n")
assert _set_readonly(key, slug, True).status_code == 200
assert _write_project_visibility(key, slug, "main.py", "print(2)\n").status_code == 400
assert _write_project_visibility(key, slug, "new.py", "print(3)\n").status_code == 400
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/mkdir",
headers=_h_project_visibility(key),
data={"path": "docs"},
allow_redirects=False,
).status_code
== 400
)
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/append",
headers=_h_project_visibility(key),
data={"path": "main.py", "content": "x"},
allow_redirects=False,
).status_code
== 400
)
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/replace-lines",
headers=_h_project_visibility(key),
data={"path": "main.py", "start": 1, "end": 1, "content": "z"},
allow_redirects=False,
).status_code
== 400
)
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/move",
headers=_h_project_visibility(key),
data={"from_path": "main.py", "to_path": "renamed.py"},
allow_redirects=False,
).status_code
== 400
)
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/delete",
headers=_h_project_visibility(key),
data={"path": "main.py"},
allow_redirects=False,
).status_code
== 400
)
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/upload",
headers=_h_project_visibility(key),
files={"file": ("u.py", b"x=1\n")},
data={"path": ""},
).status_code
== 400
)
+147
View File
@@ -0,0 +1,147 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_project_files = [0]
def _signup_project_files():
_counter_project_files[0] += 1
name = f"pf{int(time.time() * 1000)}{_counter_project_files[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_project_files(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_project_files(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_project_files(key),
data={
"title": title,
"description": "filesystem test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_project_files(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_files(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _mkdir(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/mkdir",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _move(key, slug, from_path, to_path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/move",
headers=_h_project_files(key),
data={"from_path": from_path, "to_path": to_path},
allow_redirects=False,
)
def _delete(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/delete",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _list(slug, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files",
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _raw_project_files(slug, path, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": path},
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _paths(slug, key=None):
return sorted(f["path"] for f in _list(slug, key).json()["files"])
def _make_project_ui(page, title):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for filesystem UI test")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
return page.url
def _open_files(page, title):
proj_url = _make_project_ui(page, title)
slug = proj_url.rstrip("/").split("/")[-1]
page.goto(proj_url + "/files", wait_until="domcontentloaded")
return slug
def _dialog_fill(page, value):
page.locator(".dialog-overlay.visible .dialog-input").wait_for(state="visible")
page.fill(".dialog-overlay.visible .dialog-input", value)
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_confirm(page):
page.locator(".dialog-overlay.visible .dialog-confirm").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_cancel(page):
page.locator(".dialog-overlay.visible .dialog-cancel").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-cancel")
def _new_folder(page, name):
page.click("#pf-new-folder")
_dialog_fill(page, name)
page.wait_for_selector(f".pf-node-row:has-text('{name.split('/')[-1]}')")
def _new_file(page, name):
page.click("#pf-new-file")
_dialog_fill(page, name)
def _row(page, name):
return page.locator(f".pf-node-row:has-text('{name}')").first
def _alice_key():
return get_table("users").find_one(username="alice_test")["api_key"]
def test_upload_text_is_editable(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS UploadText")["slug"]
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/upload",
headers=_h_project_files(key),
files={"file": ("util.py", b"x = 1\n")},
data={"path": "src"},
)
assert r.status_code == 200
node = _raw_project_files(slug, "src/util.py", key).json()
assert node["is_binary"] is False and node["content"] == "x = 1\n"
def test_upload_binary_is_served(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS UploadBinary")["slug"]
blob = bytes(range(256))
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/upload",
headers=_h_project_files(key),
files={"file": ("logo.bin", blob)},
data={"path": "assets"},
)
assert r.status_code == 200
node = _raw_project_files(slug, "assets/logo.bin", key).json()
assert node["is_binary"] is True and node["url"].startswith(
"/static/uploads/project_files/"
)
served = requests.get(BASE_URL + node["url"])
assert served.status_code == 200 and served.content == blob
+126
View File
@@ -0,0 +1,126 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_project_files = [0]
def _signup_project_files():
_counter_project_files[0] += 1
name = f"pf{int(time.time() * 1000)}{_counter_project_files[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_project_files(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_project_files(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_project_files(key),
data={
"title": title,
"description": "filesystem test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_project_files(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_files(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _mkdir(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/mkdir",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _move(key, slug, from_path, to_path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/move",
headers=_h_project_files(key),
data={"from_path": from_path, "to_path": to_path},
allow_redirects=False,
)
def _delete(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/delete",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _list(slug, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files",
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _raw_project_files(slug, path, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": path},
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _paths(slug, key=None):
return sorted(f["path"] for f in _list(slug, key).json()["files"])
def _make_project_ui(page, title):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for filesystem UI test")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
return page.url
def _open_files(page, title):
proj_url = _make_project_ui(page, title)
slug = proj_url.rstrip("/").split("/")[-1]
page.goto(proj_url + "/files", wait_until="domcontentloaded")
return slug
def _dialog_fill(page, value):
page.locator(".dialog-overlay.visible .dialog-input").wait_for(state="visible")
page.fill(".dialog-overlay.visible .dialog-input", value)
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_confirm(page):
page.locator(".dialog-overlay.visible .dialog-confirm").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_cancel(page):
page.locator(".dialog-overlay.visible .dialog-cancel").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-cancel")
def _new_folder(page, name):
page.click("#pf-new-folder")
_dialog_fill(page, name)
page.wait_for_selector(f".pf-node-row:has-text('{name.split('/')[-1]}')")
def _new_file(page, name):
page.click("#pf-new-file")
_dialog_fill(page, name)
def _row(page, name):
return page.locator(f".pf-node-row:has-text('{name}')").first
def _alice_key():
return get_table("users").find_one(username="alice_test")["api_key"]
def test_guest_write_blocked(app_server):
_, key = _signup_project_files()
slug = _create_project_project_files(key, "FS Guest")["slug"]
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
data={"path": "x.py", "content": "1"},
allow_redirects=False,
)
assert r.status_code in (303, 401)
assert _paths(slug, key) == []
+143
View File
@@ -0,0 +1,143 @@
# retoor <retoor@molodetz.nl>
import asyncio
import io
import json
import time
import zipfile
from pathlib import Path
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.jobs import queue
from devplacepy.services.jobs.zip_service import ZipService
from tests.conftest import run_async
_counter_zip_download = [0]
def _signup_zip_download():
_counter_zip_download[0] += 1
name = f"zd{int(time.time() * 1000)}{_counter_zip_download[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
refresh_snapshot()
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_zip_download(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_zip_download(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_zip_download(key),
data={
"title": title,
"description": "zip download test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_zip_download(key, slug, path, content):
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_zip_download(key),
data={"path": path, "content": content},
allow_redirects=False,
)
assert r.status_code in (200, 302), r.text
def _process_uid(uid):
async def drive():
svc = ZipService()
for _ in range(400):
await svc.run_once()
refresh_snapshot()
job = queue.get_job(uid)
if job and job["status"] in ("done", "failed"):
return
await asyncio.sleep(0.05)
run_async(drive())
def _drain_pending():
async def drive():
svc = ZipService()
appeared = False
for _ in range(600):
refresh_snapshot()
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if pending:
appeared = True
await svc.run_once()
refresh_snapshot()
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if appeared and not pending and not svc._inflight:
return
await asyncio.sleep(0.05)
run_async(drive())
def _cleanup_archives():
refresh_snapshot()
jobs = get_table("jobs")
for row in list(jobs.find(kind="zip")):
result = json.loads(row.get("result") or "{}")
local_path = result.get("local_path")
if local_path:
Path(local_path).unlink(missing_ok=True)
jobs.delete(uid=row["uid"])
def _login_zip_download(page, name):
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", f"{name}@t.dev")
page.fill("#password", "secret123")
page.click("button:has-text('Sign in')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
def test_files_zip_subtree_download(app_server):
try:
_, key = _signup_zip_download()
proj = _create_project_zip_download(key, "Subtree Flow")
slug = proj["slug"] or proj["uid"]
_write_zip_download(key, slug, "src/app.py", "print(1)\n")
_write_zip_download(key, slug, "docs/readme.md", "x")
uid = requests.post(
f"{BASE_URL}/projects/{slug}/files/zip",
headers=_h_zip_download(key),
params={"path": "src"},
).json()["uid"]
_process_uid(uid)
done = requests.get(
f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"}
).json()
dl = requests.get(f"{BASE_URL}{done['download_url']}")
names = sorted(zipfile.ZipFile(io.BytesIO(dl.content)).namelist())
assert names == ["src/", "src/app.py"]
finally:
_cleanup_archives()
def test_files_zip_rejects_traversal(app_server):
_, key = _signup_zip_download()
proj = _create_project_zip_download(key, "Traversal Guard")
slug = proj["slug"] or proj["uid"]
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/zip",
headers=_h_zip_download(key),
params={"path": "../../etc/passwd"},
)
assert r.status_code == 400, r.text
+416
View File
@@ -0,0 +1,416 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
from devplacepy.database import get_table
from devplacepy.utils import clear_user_cache
from devplacepy import project_files
from devplacepy.project_files import ProjectFileError
from devplacepy.services.devii.actions.dispatcher import (
confirmation_error,
_is_confirmed,
)
_counter_project_visibility = [0]
def _signup_project_visibility():
_counter_project_visibility[0] += 1
name = f"pv{int(time.time() * 1000)}{_counter_project_visibility[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
row = get_table("users").find_one(username=name)
return name, row["uid"], row["api_key"]
def _make_admin_project_visibility():
name, uid, key = _signup_project_visibility()
get_table("users").update({"uid": uid, "role": "Admin"}, ["uid"])
clear_user_cache(uid)
return name, uid, key
def _h_project_visibility(key=None):
headers = {"Accept": "application/json"}
if key:
headers["X-API-KEY"] = key
return headers
def _create_project_project_visibility(key, title, is_private=False):
data = {
"title": title,
"description": "visibility test",
"project_type": "software",
"status": "In Development",
}
if is_private:
data["is_private"] = "on"
r = requests.post(f"{BASE_URL}/projects/create", headers=_h_project_visibility(key), data=data)
assert r.status_code == 200, r.text
return r.json()["data"]
def _project_uid(slug):
return get_table("projects").find_one(slug=slug)["uid"]
def _write_project_visibility(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_visibility(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _set_private(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/private",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _set_readonly(key, slug, value):
return requests.post(
f"{BASE_URL}/projects/{slug}/readonly",
headers=_h_project_visibility(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _list_slugs(key=None, user_uid=None):
params = {"user_uid": user_uid} if user_uid else None
r = requests.get(f"{BASE_URL}/projects", headers=_h_project_visibility(key), params=params)
return [p["slug"] for p in r.json()["projects"]]
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_project_create_visibility_readonly_recorded(seeded_db):
s, _ = _member()
proj = _new_project(s)
admin = _admin(seeded_db)
assert _find(admin, "project.create", lambda e: e.get("target_uid") == proj["uid"]) is not None
s.post(f"{BASE_URL}/projects/{proj['slug']}/private", headers=JSON_audit_log, data={"value": "true"}, allow_redirects=False)
assert _find(admin, "project.visibility.private", lambda e: e.get("target_uid") == proj["uid"]) is not None
s.post(f"{BASE_URL}/projects/{proj['slug']}/readonly", headers=JSON_audit_log, data={"value": "true"}, allow_redirects=False)
assert _find(admin, "project.readonly.enable", lambda e: e.get("target_uid") == proj["uid"]) is not None
def test_project_fork_and_zip_request_recorded(seeded_db):
s, _ = _member()
proj = _new_project(s)
s.post(f"{BASE_URL}/projects/{proj['slug']}/fork", headers=JSON_audit_log, data={"title": _unique("aufork")}, allow_redirects=False)
s.post(f"{BASE_URL}/projects/{proj['slug']}/zip", headers=JSON_audit_log, allow_redirects=False)
admin = _admin(seeded_db)
assert _find(admin, "project.fork.request", lambda e: e.get("target_uid") == proj["uid"]) is not None
assert _find(admin, "project.zip.request", lambda e: e.get("target_uid") == proj["uid"]) is not None
def test_project_file_write_and_readonly_denied_recorded(seeded_db):
s, _ = _member()
proj = _new_project(s)
s.post(
f"{BASE_URL}/projects/{proj['slug']}/files/write",
headers=JSON_audit_log,
data={"path": "audit_a.txt", "content": "hello world"},
allow_redirects=False,
)
admin = _admin(seeded_db)
assert _find(
admin, "file.write.create", lambda e: e.get("target_uid") == f"{proj['uid']}:audit_a.txt"
) is not None
# turn the project read-only, then a write must be recorded as denied
s.post(f"{BASE_URL}/projects/{proj['slug']}/readonly", headers=JSON_audit_log, data={"value": "true"}, allow_redirects=False)
s.post(
f"{BASE_URL}/projects/{proj['slug']}/files/write",
headers=JSON_audit_log,
data={"path": "audit_b.txt", "content": "blocked"},
allow_redirects=False,
)
denied = _find(
admin,
"file.write.create",
lambda e: e.get("target_uid") == f"{proj['uid']}:audit_b.txt" and e.get("result") == "denied",
)
assert denied is not None
def test_private_detail_404_for_guest_200_for_owner(app_server):
_, _, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Private Detail", is_private=True)["slug"]
assert requests.get(f"{BASE_URL}/projects/{slug}", headers=_h_project_visibility()).status_code == 404
assert (
requests.get(f"{BASE_URL}/projects/{slug}", headers=_h_project_visibility(key)).status_code == 200
)
def test_private_detail_visible_to_admin(app_server):
_, _, owner_key = _signup_project_visibility()
_, _, admin_key = _make_admin_project_visibility()
slug = _create_project_project_visibility(owner_key, "Private AdminDetail", is_private=True)["slug"]
assert (
requests.get(f"{BASE_URL}/projects/{slug}", headers=_h_project_visibility(admin_key)).status_code
== 200
)
def test_project_uid_redirects_to_canonical_slug(app_server):
slug, uid = _seed_project()
r = requests.get(f"{BASE_URL}/projects/{uid}", allow_redirects=False)
assert r.status_code == 301
assert r.headers["location"].endswith(f"/projects/{slug}"), r.headers.get(
"location"
)
+159
View File
@@ -0,0 +1,159 @@
# retoor <retoor@molodetz.nl>
import asyncio
import io
import json
import time
import zipfile
from pathlib import Path
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.jobs import queue
from devplacepy.services.jobs.zip_service import ZipService
from tests.conftest import run_async
_counter_zip_download = [0]
def _signup_zip_download():
_counter_zip_download[0] += 1
name = f"zd{int(time.time() * 1000)}{_counter_zip_download[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
refresh_snapshot()
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_zip_download(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_zip_download(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_zip_download(key),
data={
"title": title,
"description": "zip download test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_zip_download(key, slug, path, content):
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_zip_download(key),
data={"path": path, "content": content},
allow_redirects=False,
)
assert r.status_code in (200, 302), r.text
def _process_uid(uid):
async def drive():
svc = ZipService()
for _ in range(400):
await svc.run_once()
refresh_snapshot()
job = queue.get_job(uid)
if job and job["status"] in ("done", "failed"):
return
await asyncio.sleep(0.05)
run_async(drive())
def _drain_pending():
async def drive():
svc = ZipService()
appeared = False
for _ in range(600):
refresh_snapshot()
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if pending:
appeared = True
await svc.run_once()
refresh_snapshot()
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if appeared and not pending and not svc._inflight:
return
await asyncio.sleep(0.05)
run_async(drive())
def _cleanup_archives():
refresh_snapshot()
jobs = get_table("jobs")
for row in list(jobs.find(kind="zip")):
result = json.loads(row.get("result") or "{}")
local_path = result.get("local_path")
if local_path:
Path(local_path).unlink(missing_ok=True)
jobs.delete(uid=row["uid"])
def _login_zip_download(page, name):
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", f"{name}@t.dev")
page.fill("#password", "secret123")
page.click("button:has-text('Sign in')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
def test_enqueue_project_zip_returns_uid(app_server):
try:
_, key = _signup_zip_download()
proj = _create_project_zip_download(key, "Enqueue Returns Uid")
slug = proj["slug"] or proj["uid"]
r = requests.post(f"{BASE_URL}/projects/{slug}/zip", headers=_h_zip_download(key))
assert r.status_code == 200, r.text
body = r.json()
assert body["uid"]
assert body["status_url"] == f"/zips/{body['uid']}"
finally:
_cleanup_archives()
def test_status_pending_then_done_then_download(app_server):
try:
_, key = _signup_zip_download()
proj = _create_project_zip_download(key, "Full Flow")
slug = proj["slug"] or proj["uid"]
_write_zip_download(key, slug, "README.md", "# hello")
_write_zip_download(key, slug, "src/app.py", "print(1)\n")
uid = requests.post(f"{BASE_URL}/projects/{slug}/zip", headers=_h_zip_download(key)).json()[
"uid"
]
pending = requests.get(
f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"}
).json()
assert pending["status"] in ("pending", "running")
assert pending["download_url"] is None
_process_uid(uid)
done = requests.get(
f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"}
).json()
assert done["status"] == "done", done
assert done["download_url"] == f"/zips/{uid}/download"
assert done["file_count"] == 2 and done["dir_count"] == 1
dl = requests.get(f"{BASE_URL}{done['download_url']}")
assert dl.status_code == 200
assert dl.headers["content-type"] == "application/zip"
assert "attachment" in dl.headers.get("content-disposition", "")
assert ".zip" in dl.headers.get("content-disposition", "")
names = sorted(zipfile.ZipFile(io.BytesIO(dl.content)).namelist())
assert names == ["README.md", "src/", "src/app.py"]
finally:
_cleanup_archives()
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
+6 -50
View File
@@ -1,11 +1,9 @@
# retoor <retoor@molodetz.nl>
import uuid
import requests
from tests.conftest import BASE_URL
def _session():
def _session_push():
s = requests.Session()
name = f"push_{uuid.uuid4().hex[:10]}"
s.post(
@@ -40,7 +38,7 @@ def test_register_requires_auth(app_server):
def test_register_success(app_server):
s = _session()
s = _session_push()
r = s.post(
f"{BASE_URL}/push.json",
json={
@@ -53,58 +51,16 @@ def test_register_success(app_server):
def test_register_missing_keys_rejected(app_server):
s = _session()
s = _session_push()
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()
s = _session_push()
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
def test_browser_base64_is_url_safe_unpadded():
import base64
from devplacepy import push
encoded = push.browser_base64(b"\xff\xfe\x00 hello")
assert "=" not in encoded
assert "+" not in encoded and "/" not in encoded
assert base64.urlsafe_b64decode(encoded + "==") == b"\xff\xfe\x00 hello"
def test_hkdf_returns_requested_length():
from devplacepy import push
derived = push.hkdf(b"input-key-material", b"salt", b"info", 16)
assert isinstance(derived, bytes)
assert len(derived) == 16
def test_public_key_standard_b64_non_empty():
from devplacepy import push
assert push.public_key_standard_b64()
def test_create_notification_authorization_is_jwt():
from devplacepy import push
token = push.create_notification_authorization("https://push.example.com/endpoint")
assert token.count(".") == 2
+297
View File
@@ -0,0 +1,297 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
import sqlite3
from devplacepy.config import DATABASE_URL
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
from devplacepy.constants import REACTION_EMOJI
_counter_reactions = [0]
AJAX_reactions = {"X-Requested-With": "fetch"}
ROCKET = REACTION_EMOJI[2]
HEART = REACTION_EMOJI[1]
def _session_reactions():
_counter_reactions[0] += 1
name = f"rxn{int(time.time() * 1000)}{_counter_reactions[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _uid_reactions(username):
return get_table("users").find_one(username=username)["uid"]
def _live_query(sql, params):
conn = sqlite3.connect(DATABASE_URL.replace("sqlite:///", "", 1))
try:
return conn.execute(sql, params).fetchone()
finally:
conn.close()
def _live_reaction_count(target_type, target_uid):
return _live_query(
"SELECT COUNT(*) FROM reactions "
"WHERE target_type=? AND target_uid=? AND deleted_at IS NULL",
(target_type, target_uid),
)[0]
def _live_post_uid(slug):
row = _live_query("SELECT uid FROM posts WHERE slug=?", (slug,))
return row[0] if row else None
def _make_post_reactions(owner_uid):
uid = generate_uid()
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner_uid,
"slug": f"{uid[:8]}-reaction-post",
"title": None,
"content": "reaction target content",
"topic": "random",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _make_comment(post_uid, owner_uid):
uid = generate_uid()
get_table("comments").insert(
{
"uid": uid,
"target_type": "post",
"target_uid": post_uid,
"post_uid": post_uid,
"user_uid": owner_uid,
"content": "reaction target comment",
"parent_uid": None,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def test_reaction_recorded(seeded_db):
s, _ = _member()
post = _new_post(s)
s.post(f"{BASE_URL}/reactions/post/{post['uid']}", data={"emoji": "\U0001F44D"}, allow_redirects=False)
admin = _admin(seeded_db)
event = _find(admin, "reaction.add", lambda e: e.get("target_uid") == post["uid"])
assert event is not None
assert (event.get("metadata") or {}).get("emoji") == "\U0001F44D"
def test_add_reaction_returns_counts(app_server):
s, name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(name))
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX_reactions
)
payload = r.json()
assert payload["counts"][ROCKET] == 1
assert ROCKET in payload["mine"]
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 1
def test_same_emoji_toggles_off(app_server):
s, name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(name))
s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX_reactions
)
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX_reactions
)
payload = r.json()
assert payload["counts"].get(ROCKET, 0) == 0
assert payload["mine"] == []
assert (
get_table("reactions").count(
target_type="post", target_uid=post_uid, deleted_at=None
)
== 0
)
def test_two_distinct_emoji_both_counted(app_server):
s, name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(name))
s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX_reactions
)
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": HEART}, headers=AJAX_reactions
)
payload = r.json()
assert payload["counts"][ROCKET] == 1
assert payload["counts"][HEART] == 1
assert set(payload["mine"]) == {ROCKET, HEART}
def test_emoji_outside_palette_rejected(app_server):
s, name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(name))
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}",
data={"emoji": "notanemoji"},
headers=AJAX_reactions,
allow_redirects=False,
)
assert r.status_code == 303
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
def test_invalid_target_type_returns_400(app_server):
s, name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(name))
r = s.post(
f"{BASE_URL}/reactions/news/{post_uid}", data={"emoji": ROCKET}, headers=AJAX_reactions
)
assert r.status_code == 400
def test_reaction_requires_login(app_server):
owner_s, owner_name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(owner_name))
anon = requests.Session()
r = anon.post(
f"{BASE_URL}/reactions/post/{post_uid}",
data={"emoji": ROCKET},
headers=AJAX_reactions,
allow_redirects=False,
)
assert r.status_code == 303
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
def test_react_on_comment_target(app_server):
s, name = _session_reactions()
owner_uid = _uid_reactions(name)
post_uid = _make_post_reactions(owner_uid)
comment_uid = _make_comment(post_uid, owner_uid)
r = s.post(
f"{BASE_URL}/reactions/comment/{comment_uid}",
data={"emoji": HEART},
headers=AJAX_reactions,
)
assert r.json()["counts"][HEART] == 1
assert (
get_table("reactions").count(target_type="comment", target_uid=comment_uid) == 1
)
+190
View File
@@ -0,0 +1,190 @@
# retoor <retoor@molodetz.nl>
import devplacepy.main as m
from starlette.testclient import TestClient
def _patch_limits(monkeypatch, limit, window=60):
def fake_get_int_setting(key, default):
return {
"rate_limit_per_minute": limit,
"rate_limit_window_seconds": window,
}.get(key, default)
monkeypatch.setattr(m, "get_int_setting", fake_get_int_setting)
# conftest disables the limiter suite-wide; re-enable it for these tests only.
monkeypatch.setattr(m, "RATE_LIMIT_DISABLED", False)
m._rate_limit_store.clear()
import requests
from tests.conftest import BASE_URL
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_get_requests_not_rate_limited(monkeypatch):
_patch_limits(monkeypatch, 2)
client = TestClient(m.app)
codes = [
client.get("/robots.txt", headers={"X-Real-IP": "3.3.3.3"}).status_code
for _ in range(5)
]
assert all(c == 200 for c in codes), codes
def test_robots_txt_exists(app_server):
r = requests.get(f"{BASE_URL}/robots.txt")
assert r.status_code == 200
assert "User-agent:" in r.text
assert "Disallow: /auth/" in r.text
assert "Sitemap:" in r.text
def test_robots_disallows_admin_and_uploads(app_server):
r = requests.get(f"{BASE_URL}/robots.txt")
assert "Disallow: /admin/" in r.text
assert "Disallow: /uploads/" in r.text
+2 -12
View File
@@ -1,7 +1,7 @@
# retoor <retoor@molodetz.nl>
import devplacepy.main as m
from starlette.testclient import TestClient
def _patch_limits(monkeypatch, limit, window=60):
def fake_get_int_setting(key, default):
return {
@@ -36,16 +36,6 @@ def test_rate_limit_is_per_ip(monkeypatch):
assert other_ip != 429
def test_get_requests_not_rate_limited(monkeypatch):
_patch_limits(monkeypatch, 2)
client = TestClient(m.app)
codes = [
client.get("/robots.txt", headers={"X-Real-IP": "3.3.3.3"}).status_code
for _ in range(5)
]
assert all(c == 200 for c in codes), codes
def test_rate_limit_reads_configured_setting(monkeypatch):
_patch_limits(monkeypatch, 1)
client = TestClient(m.app)

Some files were not shown because too many files have changed in this diff Show More