The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
467 lines
16 KiB
Python
467 lines
16 KiB
Python
# 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",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
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(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"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_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",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
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(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"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]}",
|
|
"terms_version": "1",
|
|
"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(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"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(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"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(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"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(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"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(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"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:
|
|
deadline = time.time() + 5
|
|
status = None
|
|
while time.time() < deadline:
|
|
status = requests.get(f"{BASE_URL}/feed", allow_redirects=False).status_code
|
|
if status == 503:
|
|
break
|
|
time.sleep(0.2)
|
|
assert status == 503
|
|
finally:
|
|
set_setting("maintenance_mode", "0")
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline:
|
|
if requests.get(f"{BASE_URL}/feed", allow_redirects=False).status_code != 503:
|
|
break
|
|
time.sleep(0.2)
|
|
event = _find(admin, "security.maintenance.block", lambda e: True)
|
|
assert event is not None
|
|
assert event["result"] == "denied"
|
|
|
|
|
|
def test_feed_avatar_has_presence_dot(app_server):
|
|
s, name = _member()
|
|
uid = _db_user(name)["uid"]
|
|
s.post(
|
|
f"{BASE_URL}/posts/create",
|
|
data={"title": _unique("dot"), "content": "presence dot feed post", "topic": "devlog"},
|
|
allow_redirects=True,
|
|
)
|
|
html = s.get(f"{BASE_URL}/feed").text
|
|
assert "presence-dot" in html
|
|
assert f'data-presence-uid="{uid}"' in html
|
|
|
|
|
|
def test_feed_shows_online_now_section(app_server):
|
|
name = _unique("0onl")
|
|
s = requests.Session()
|
|
s.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
html = s.get(f"{BASE_URL}/feed").text
|
|
assert "online-users" in html
|
|
assert "data-online-users-list" in html
|
|
assert "data-online-count" in html
|
|
assert f'class="online-user" title="{name}"' in html
|
|
|
|
|
|
def test_online_now_avatar_uses_the_shared_presence_dot(app_server):
|
|
name = _unique("0ros")
|
|
s = requests.Session()
|
|
s.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
uid = _db_user(name)["uid"]
|
|
html = s.get(f"{BASE_URL}/feed").text
|
|
panel = html.split('data-online-users-list', 1)[1].split("</div>", 2)[0]
|
|
# the roster avatar is driven by the same subscribed dot as every other avatar,
|
|
# never a hardcoded green marker
|
|
assert f'data-presence-uid="{uid}"' in panel
|
|
assert "data-presence-last-seen" in panel
|
|
assert '<span class="presence-dot online" aria-hidden="true"></span>' not in panel
|
|
|
|
|
|
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"
|
|
|
|
|
|
def test_strict_transport_security_header(app_server):
|
|
r = requests.get(f"{BASE_URL}/feed", allow_redirects=True)
|
|
assert "includeSubDomains" in r.headers.get("Strict-Transport-Security", "")
|
|
|
|
|
|
def test_referrer_policy_header(app_server):
|
|
r = requests.get(f"{BASE_URL}/feed", allow_redirects=True)
|
|
assert r.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
|
|
|
|
|
|
def test_x_frame_options_header(app_server):
|
|
r = requests.get(f"{BASE_URL}/feed", allow_redirects=True)
|
|
assert "X-Frame-Options" not in r.headers
|
|
|
|
|
|
def test_x_frame_options_excluded_for_ingress_proxy(app_server):
|
|
r = requests.get(f"{BASE_URL}/p/nonexistent-instance", allow_redirects=False)
|
|
assert "X-Frame-Options" not in r.headers
|
|
|
|
|
|
def test_content_security_policy_header(app_server):
|
|
r = requests.get(f"{BASE_URL}/feed", allow_redirects=True)
|
|
csp = r.headers.get("Content-Security-Policy", "")
|
|
assert "object-src 'none'" in csp
|
|
assert "base-uri 'self'" in csp
|
|
assert "frame-ancestors 'self'" in csp
|
|
assert "form-action 'self'" in csp
|
|
|
|
|
|
def test_content_security_policy_excluded_for_ingress_proxy(app_server):
|
|
r = requests.get(f"{BASE_URL}/p/nonexistent-instance", allow_redirects=False)
|
|
assert "Content-Security-Policy" not in r.headers
|