fix: correct "bugs" to "issues" in routing table and README references across multiple documentation files
This commit is contained in:
@@ -21,7 +21,7 @@ def _session_content_negotiation(password="secret123"):
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
|
||||
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/issues"]
|
||||
|
||||
|
||||
def test_admin_pages_negotiate(seeded_db):
|
||||
|
||||
+39
-27
@@ -85,6 +85,15 @@ def _call(endpoint, headers):
|
||||
elif data:
|
||||
kwargs["data"] = data
|
||||
return requests.request(endpoint["method"], f"{BASE_URL}{path}", **kwargs)
|
||||
def _clear_probe_jobs():
|
||||
from devplacepy.database import refresh_snapshot
|
||||
|
||||
refresh_snapshot()
|
||||
jobs = get_table("jobs")
|
||||
for row in list(jobs.find()):
|
||||
jobs.delete(uid=row["uid"])
|
||||
|
||||
|
||||
def _redirects_to(response, target):
|
||||
return response.status_code in (
|
||||
302,
|
||||
@@ -110,38 +119,41 @@ def test_documented_minimal_role_matches_enforcement(seeded_db):
|
||||
assert len(endpoints) >= 50
|
||||
|
||||
failures = []
|
||||
for endpoint in endpoints:
|
||||
auth = endpoint["auth"]
|
||||
label = (
|
||||
f"{endpoint['method']} {endpoint['path']} ({endpoint['id']}, doc={auth})"
|
||||
)
|
||||
anon = _call(endpoint, JSON_auth_matrix)
|
||||
|
||||
if auth == "public":
|
||||
if not _is_allowed(anon):
|
||||
failures.append(
|
||||
f"{label}: public but anonymous was rejected ({anon.status_code})"
|
||||
)
|
||||
continue
|
||||
|
||||
if not _is_auth_rejected(anon):
|
||||
failures.append(
|
||||
f"{label}: requires {auth} but anonymous was NOT rejected ({anon.status_code})"
|
||||
try:
|
||||
for endpoint in endpoints:
|
||||
auth = endpoint["auth"]
|
||||
label = (
|
||||
f"{endpoint['method']} {endpoint['path']} ({endpoint['id']}, doc={auth})"
|
||||
)
|
||||
anon = _call(endpoint, JSON_auth_matrix)
|
||||
|
||||
if auth == "user" and endpoint["method"] == "GET":
|
||||
mem = _call(endpoint, member)
|
||||
if not _is_allowed(mem):
|
||||
if auth == "public":
|
||||
if not _is_allowed(anon):
|
||||
failures.append(
|
||||
f"{label}: public but anonymous was rejected ({anon.status_code})"
|
||||
)
|
||||
continue
|
||||
|
||||
if not _is_auth_rejected(anon):
|
||||
failures.append(
|
||||
f"{label}: documented user but a member was rejected ({mem.status_code})"
|
||||
f"{label}: requires {auth} but anonymous was NOT rejected ({anon.status_code})"
|
||||
)
|
||||
|
||||
if auth == "admin":
|
||||
mem = _call(endpoint, member)
|
||||
if not _is_role_rejected(mem):
|
||||
failures.append(
|
||||
f"{label}: documented admin but a non-admin member was NOT rejected ({mem.status_code})"
|
||||
)
|
||||
if auth == "user" and endpoint["method"] == "GET":
|
||||
mem = _call(endpoint, member)
|
||||
if not _is_allowed(mem):
|
||||
failures.append(
|
||||
f"{label}: documented user but a member was rejected ({mem.status_code})"
|
||||
)
|
||||
|
||||
if auth == "admin":
|
||||
mem = _call(endpoint, member)
|
||||
if not _is_role_rejected(mem):
|
||||
failures.append(
|
||||
f"{label}: documented admin but a non-admin member was NOT rejected ({mem.status_code})"
|
||||
)
|
||||
finally:
|
||||
_clear_probe_jobs()
|
||||
|
||||
assert not failures, "Auth enforcement does not match documentation:\n" + "\n".join(
|
||||
failures
|
||||
|
||||
@@ -21,7 +21,7 @@ def _session_content_negotiation(password="secret123"):
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
|
||||
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/issues"]
|
||||
|
||||
|
||||
def test_public_pages_serve_json_and_html(app_server):
|
||||
|
||||
@@ -340,3 +340,23 @@ def test_x_robots_tag_header(app_server):
|
||||
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 r.headers.get("X-Frame-Options") == "DENY"
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -124,13 +124,13 @@ 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.gitea.service import IssueTrackerService
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.bug_create_service import BugCreateService
|
||||
from devplacepy.services.jobs.issue_create_service import IssueCreateService
|
||||
from tests.conftest import run_async
|
||||
_counter_bugs_gitea = [0]
|
||||
_counter_issues_gitea = [0]
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db_bugs_gitea():
|
||||
def _init_db_issues_gitea():
|
||||
init_db()
|
||||
yield
|
||||
@pytest.fixture
|
||||
@@ -141,20 +141,20 @@ def gitea_env():
|
||||
set_setting("gitea_owner", "retoor")
|
||||
set_setting("gitea_repo", "pydevplace")
|
||||
set_setting("gitea_token", "test-token")
|
||||
set_setting("bug_ai_enhance", "0")
|
||||
set_setting("issue_ai_enhance", "0")
|
||||
yield fake
|
||||
runtime.set_client(None)
|
||||
for key in ("gitea_base_url", "gitea_owner", "gitea_repo", "gitea_token"):
|
||||
set_setting(key, "")
|
||||
for table in ("bug_tickets", "bug_comment_authors"):
|
||||
for table in ("issue_tickets", "issue_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")):
|
||||
for row in list(get_table("jobs").find(kind="issue_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]}"
|
||||
def _make_user_issues_gitea():
|
||||
_counter_issues_gitea[0] += 1
|
||||
uid = f"issuetest-user-{_counter_issues_gitea[0]}"
|
||||
username = f"issuetester{_counter_issues_gitea[0]}"
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
@@ -170,17 +170,17 @@ def _unread(user_uid):
|
||||
return [
|
||||
n
|
||||
for n in get_table("notifications").find(user_uid=user_uid)
|
||||
if n.get("type") == "bug"
|
||||
if n.get("type") == "issue"
|
||||
]
|
||||
def _drive_jobs():
|
||||
async def run():
|
||||
svc = BugCreateService()
|
||||
svc = IssueCreateService()
|
||||
for _ in range(200):
|
||||
await svc.run_once()
|
||||
refresh_snapshot()
|
||||
pending = [
|
||||
r
|
||||
for r in get_table("jobs").find(kind="bug_create")
|
||||
for r in get_table("jobs").find(kind="issue_create")
|
||||
if r["status"] in ("pending", "running")
|
||||
]
|
||||
if not pending and not svc._inflight:
|
||||
@@ -189,7 +189,7 @@ def _drive_jobs():
|
||||
|
||||
run_async(run())
|
||||
def _role_user(role: str):
|
||||
uid, username = _make_user_bugs_gitea()
|
||||
uid, username = _make_user_issues_gitea()
|
||||
get_table("users").update({"uid": uid, "role": role}, ["uid"])
|
||||
return uid, username
|
||||
def _open_ticket(fake, role: str):
|
||||
@@ -214,14 +214,14 @@ def _open_ticket(fake, role: str):
|
||||
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
|
||||
def test_issue_create_recorded(seeded_db):
|
||||
# Filing is async + Gitea-gated: POST /issues/create only enqueues a job (returns
|
||||
# {uid, status_url}) and records the synchronous "issue.create.request" event; the
|
||||
# final "issue.create" fires from IssueCreateService, 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")
|
||||
title = _unique("auissue")
|
||||
for key, value in {
|
||||
"gitea_base_url": "https://gitea.test",
|
||||
"gitea_owner": "o",
|
||||
@@ -231,9 +231,9 @@ def test_bug_create_recorded(seeded_db):
|
||||
set_setting(key, value)
|
||||
try:
|
||||
resp = s.post(
|
||||
f"{BASE_URL}/bugs/create",
|
||||
f"{BASE_URL}/issues/create",
|
||||
headers=JSON_audit_log,
|
||||
data={"title": title, "description": "audited bug description text"},
|
||||
data={"title": title, "description": "audited issue description text"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text[:300]
|
||||
assert resp.json().get("uid")
|
||||
@@ -241,7 +241,7 @@ def test_bug_create_recorded(seeded_db):
|
||||
assert (
|
||||
_find(
|
||||
admin,
|
||||
"bug.create.request",
|
||||
"issue.create.request",
|
||||
lambda e: title in (e.get("summary") or ""),
|
||||
)
|
||||
is not None
|
||||
@@ -251,24 +251,24 @@ def test_bug_create_recorded(seeded_db):
|
||||
set_setting(key, "")
|
||||
|
||||
|
||||
def test_bug_detail_renders_for_member(gitea_env):
|
||||
def test_issue_detail_renders_for_member(gitea_env):
|
||||
number, client = _open_ticket(gitea_env, "Member")
|
||||
html = client.get(f"/bugs/{number}")
|
||||
html = client.get(f"/issues/{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"}
|
||||
f"/issues/{number}", headers={"Accept": "application/json"}
|
||||
).json()
|
||||
assert data["viewer_is_admin"] is False
|
||||
|
||||
|
||||
def test_bug_detail_renders_for_admin(gitea_env):
|
||||
def test_issue_detail_renders_for_admin(gitea_env):
|
||||
number, client = _open_ticket(gitea_env, "Admin")
|
||||
html = client.get(f"/bugs/{number}")
|
||||
html = client.get(f"/issues/{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"}
|
||||
f"/issues/{number}", headers={"Accept": "application/json"}
|
||||
).json()
|
||||
assert data["viewer_is_admin"] is True
|
||||
@@ -131,7 +131,7 @@ def _session_content_negotiation(password="secret123"):
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
|
||||
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/issues"]
|
||||
|
||||
|
||||
def test_security_authz_denied_recorded(seeded_db):
|
||||
|
||||
@@ -131,7 +131,7 @@ def _session_content_negotiation(password="secret123"):
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
|
||||
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/issues"]
|
||||
_counter_polls = [0]
|
||||
AJAX_polls = {"X-Requested-With": "fetch"}
|
||||
def _session_polls():
|
||||
|
||||
@@ -21,7 +21,7 @@ def _session_content_negotiation(password="secret123"):
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
|
||||
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/issues"]
|
||||
|
||||
|
||||
def test_json_404_error_envelope(app_server):
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def test_bugs_page_loads(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||
assert page.is_visible("h1:has-text('Bug Reports')")
|
||||
|
||||
|
||||
def test_bugs_filters_visible(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||
assert page.locator(".bugs-filter:has-text('Open')").is_visible()
|
||||
assert page.locator(".bugs-filter:has-text('Closed')").is_visible()
|
||||
assert page.locator(".bugs-filter:has-text('All')").is_visible()
|
||||
|
||||
|
||||
def test_bugs_not_configured_notice(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||
assert page.is_visible("text=The bug tracker is not configured yet.")
|
||||
|
||||
|
||||
def test_bugs_create_modal(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||
page.click("button:has-text('Report Bug')")
|
||||
assert page.is_visible("h3:has-text('Report a Bug')")
|
||||
|
||||
|
||||
def test_bugs_create_cancel(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||
page.click("button:has-text('Report Bug')")
|
||||
page.click("button:has-text('Cancel')")
|
||||
modal = page.locator("#create-bug-modal")
|
||||
assert not modal.is_visible()
|
||||
|
||||
|
||||
def test_bugs_unauth(page):
|
||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||
assert page.is_visible("h1:has-text('Bug Reports')")
|
||||
assert page.locator("a.login-required:has-text('Report Bug')").is_visible()
|
||||
assert page.locator("#create-bug-modal").count() == 0
|
||||
|
||||
|
||||
def test_bug_button_icon_spacing(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||
html = page.locator("button:has-text('Report Bug')").inner_html()
|
||||
assert "</span> Report Bug" in html
|
||||
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
|
||||
landing = page.locator("a:has-text('Bug Report')").inner_html()
|
||||
assert "</span> Bug Report" in landing
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
footer = page.locator("a:has-text('Bug Report')").inner_html()
|
||||
assert "</span> Bug Report" in footer
|
||||
|
||||
|
||||
def test_bug_detail_not_configured(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/bugs/999", wait_until="domcontentloaded")
|
||||
assert page.is_visible("h1.error-code:has-text('404')")
|
||||
assert page.is_visible("text=Page not found")
|
||||
+1
-1
@@ -121,7 +121,7 @@ def test_docs_panel_html_format_updates_snippet(page, app_server):
|
||||
|
||||
|
||||
def test_docs_panel_live_send_returns_json(page, app_server):
|
||||
page.goto(f"{BASE_URL}/docs/bugs.html", wait_until="domcontentloaded")
|
||||
page.goto(f"{BASE_URL}/docs/issues.html", wait_until="domcontentloaded")
|
||||
panel = page.locator(".api-tester").first
|
||||
panel.wait_for(state="visible")
|
||||
send = panel.locator(".try-send").first
|
||||
|
||||
+3
-3
@@ -383,13 +383,13 @@ def _seed_feed_posts(count):
|
||||
return topic
|
||||
|
||||
|
||||
def test_bugs_footer_link(alice):
|
||||
def test_issues_footer_link(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
link = page.locator("a:has-text('Bug Report')")
|
||||
link = page.locator("a:has-text('Issue Report')")
|
||||
assert link.is_visible()
|
||||
link.click()
|
||||
page.wait_for_url(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||
page.wait_for_url(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def test_global_toggle_suppresses_rendering_and_persists(bob):
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
SUBMIT = "#create-issue-modal button[type='submit']"
|
||||
SPINNER = "#create-issue-modal button[type='submit'] .btn-spinner"
|
||||
|
||||
|
||||
def _open_form(page):
|
||||
page.goto(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
||||
page.click("button:has-text('Report Issue')")
|
||||
page.fill("#issue-title", "Spinner regression title")
|
||||
page.fill("#issue-description", "Steps to reproduce the spinner regression")
|
||||
|
||||
|
||||
def test_submit_disables_button_and_shows_spinner(alice):
|
||||
page, _ = alice
|
||||
page.route(
|
||||
"**/issues/create",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"uid":"spin-job","status_url":"/issues/jobs/spin-job"}',
|
||||
),
|
||||
)
|
||||
page.route(
|
||||
"**/issues/jobs/spin-job",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"uid":"spin-job","kind":"issue_create","status":"pending"}',
|
||||
),
|
||||
)
|
||||
_open_form(page)
|
||||
page.click(SUBMIT)
|
||||
page.locator(f"{SUBMIT}.is-loading").wait_for(state="visible")
|
||||
assert page.locator(SUBMIT).is_disabled()
|
||||
assert page.locator(SPINNER).is_visible()
|
||||
|
||||
|
||||
def test_submit_prevents_double_posting(alice):
|
||||
page, _ = alice
|
||||
create_calls = []
|
||||
page.route(
|
||||
"**/issues/create",
|
||||
lambda route: (
|
||||
create_calls.append(1),
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"uid":"once-job","status_url":"/issues/jobs/once-job"}',
|
||||
),
|
||||
)[-1],
|
||||
)
|
||||
page.route(
|
||||
"**/issues/jobs/once-job",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"uid":"once-job","kind":"issue_create","status":"pending"}',
|
||||
),
|
||||
)
|
||||
_open_form(page)
|
||||
page.click(SUBMIT)
|
||||
page.locator(f"{SUBMIT}.is-loading").wait_for(state="visible")
|
||||
page.evaluate(
|
||||
"document.querySelector('form[data-issue-create]')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}))"
|
||||
)
|
||||
page.wait_for_timeout(300)
|
||||
assert len(create_calls) == 1
|
||||
|
||||
|
||||
def test_submit_reenables_button_on_error(alice):
|
||||
page, _ = alice
|
||||
page.route(
|
||||
"**/issues/create",
|
||||
lambda route: route.fulfill(
|
||||
status=503,
|
||||
content_type="application/json",
|
||||
body='{"ok":false,"message":"The issue tracker is not configured"}',
|
||||
),
|
||||
)
|
||||
_open_form(page)
|
||||
page.click(SUBMIT)
|
||||
page.locator(f"{SUBMIT}:not(.is-loading)").wait_for(state="visible")
|
||||
assert not page.locator(SUBMIT).is_disabled()
|
||||
assert not page.locator(SPINNER).is_visible()
|
||||
@@ -0,0 +1,66 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def test_issues_page_loads(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
||||
assert page.is_visible("h1:has-text('Issue Reports')")
|
||||
|
||||
|
||||
def test_issues_filters_visible(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
||||
assert page.locator(".issues-filter:has-text('Open')").is_visible()
|
||||
assert page.locator(".issues-filter:has-text('Closed')").is_visible()
|
||||
assert page.locator(".issues-filter:has-text('All')").is_visible()
|
||||
|
||||
|
||||
def test_issues_not_configured_notice(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
||||
assert page.is_visible("text=The issue tracker is not configured yet.")
|
||||
|
||||
|
||||
def test_issues_create_modal(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
||||
page.click("button:has-text('Report Issue')")
|
||||
assert page.is_visible("h3:has-text('Report an Issue')")
|
||||
|
||||
|
||||
def test_issues_create_cancel(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
||||
page.click("button:has-text('Report Issue')")
|
||||
page.click("button:has-text('Cancel')")
|
||||
modal = page.locator("#create-issue-modal")
|
||||
assert not modal.is_visible()
|
||||
|
||||
|
||||
def test_issues_unauth(page):
|
||||
page.goto(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
||||
assert page.is_visible("h1:has-text('Issue Reports')")
|
||||
assert page.locator("a.login-required:has-text('Report Issue')").is_visible()
|
||||
assert page.locator("#create-issue-modal").count() == 0
|
||||
|
||||
|
||||
def test_issue_button_icon_spacing(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
||||
html = page.locator("button:has-text('Report Issue')").inner_html()
|
||||
assert "</span> Report Issue" in html
|
||||
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
|
||||
landing = page.locator("a:has-text('Issue Report')").inner_html()
|
||||
assert "</span> Issue Report" in landing
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
footer = page.locator("a:has-text('Issue Report')").inner_html()
|
||||
assert "</span> Issue Report" in footer
|
||||
|
||||
|
||||
def test_issue_detail_not_configured(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/issues/999", wait_until="domcontentloaded")
|
||||
assert page.is_visible("h1.error-code:has-text('404')")
|
||||
assert page.is_visible("text=Page not found")
|
||||
@@ -3,8 +3,8 @@
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def test_bug_job_status_not_found(alice):
|
||||
def test_issue_job_status_not_found(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/bugs/jobs/nonexistent-job", wait_until="domcontentloaded")
|
||||
page.goto(f"{BASE_URL}/issues/jobs/nonexistent-job", wait_until="domcontentloaded")
|
||||
assert page.is_visible("h1.error-code:has-text('404')")
|
||||
assert page.is_visible("text=Page not found")
|
||||
+1
-1
@@ -199,7 +199,7 @@ def test_landing_nav_links(page, app_server):
|
||||
|
||||
def test_landing_footer_links(page, app_server):
|
||||
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
|
||||
assert page.is_visible("a:has-text('Bug Report')")
|
||||
assert page.is_visible("a:has-text('Issue Report')")
|
||||
|
||||
|
||||
def test_landing_hero_present(page, app_server):
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from curl_cffi.requests.exceptions import RequestException, Timeout
|
||||
|
||||
import devplacepy.curl_transport as ct
|
||||
from devplacepy.curl_transport import CurlTransport, resolve_timeout
|
||||
from tests.conftest import run_async
|
||||
|
||||
|
||||
class FakeCurlResponse:
|
||||
def __init__(self, status=200, headers=None, chunks=None, http_version=3):
|
||||
self.status_code = status
|
||||
self.headers = headers or {}
|
||||
self.http_version = http_version
|
||||
self._chunks = chunks if chunks is not None else [b"hello"]
|
||||
self.closed = False
|
||||
|
||||
async def aiter_content(self):
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
async def aclose(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, response=None, raise_exc=None):
|
||||
self.calls = []
|
||||
self.closed = False
|
||||
self.response = response if response is not None else FakeCurlResponse()
|
||||
self.raise_exc = raise_exc
|
||||
|
||||
async def request(self, method, url, **kwargs):
|
||||
self.calls.append({"method": method, "url": url, **kwargs})
|
||||
if self.raise_exc is not None:
|
||||
raise self.raise_exc
|
||||
return self.response
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def make_transport(monkeypatch, response=None, raise_exc=None):
|
||||
fake = FakeSession(response=response, raise_exc=raise_exc)
|
||||
monkeypatch.setattr(ct, "AsyncSession", lambda *a, **k: fake)
|
||||
return CurlTransport(), fake
|
||||
|
||||
|
||||
def test_request_headers_stripped_and_forwarded(monkeypatch):
|
||||
transport, fake = make_transport(monkeypatch)
|
||||
request = httpx.Request(
|
||||
"POST",
|
||||
"https://api.example.com/v1/x",
|
||||
headers={
|
||||
"user-agent": "leak/1.0",
|
||||
"accept-encoding": "identity",
|
||||
"authorization": "Bearer k",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
content=b'{"a":1}',
|
||||
)
|
||||
run_async(transport.handle_async_request(request))
|
||||
sent = {key.lower(): value for key, value in fake.calls[0]["headers"].items()}
|
||||
assert "user-agent" not in sent
|
||||
assert "accept-encoding" not in sent
|
||||
assert "host" not in sent
|
||||
assert "content-length" not in sent
|
||||
assert sent["authorization"] == "Bearer k"
|
||||
assert sent["content-type"] == "application/json"
|
||||
assert fake.calls[0]["data"] == b'{"a":1}'
|
||||
assert fake.calls[0]["allow_redirects"] is False
|
||||
assert fake.calls[0]["stream"] is True
|
||||
assert fake.calls[0]["impersonate"] == ct.IMPERSONATE_TARGET
|
||||
run_async(transport.aclose())
|
||||
|
||||
|
||||
def test_response_headers_stripped_and_body_streamed(monkeypatch):
|
||||
response = FakeCurlResponse(
|
||||
headers={
|
||||
"content-type": "text/html",
|
||||
"content-encoding": "gzip",
|
||||
"content-length": "5",
|
||||
"x-foo": "bar",
|
||||
},
|
||||
chunks=[b"he", b"llo"],
|
||||
)
|
||||
transport, fake = make_transport(monkeypatch, response=response)
|
||||
request = httpx.Request("GET", "https://example.com/")
|
||||
out = run_async(transport.handle_async_request(request))
|
||||
assert out.status_code == 200
|
||||
assert "content-encoding" not in out.headers
|
||||
assert "content-length" not in out.headers
|
||||
assert out.headers["x-foo"] == "bar"
|
||||
body = run_async(out.aread())
|
||||
assert body == b"hello"
|
||||
assert response.closed is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"curl_value,label",
|
||||
[(2, "HTTP/1.1"), (3, "HTTP/2"), (4, "HTTP/2"), (30, "HTTP/3"), (999, "HTTP/2")],
|
||||
)
|
||||
def test_http_version_mapping(monkeypatch, curl_value, label):
|
||||
response = FakeCurlResponse(http_version=curl_value)
|
||||
transport, _fake = make_transport(monkeypatch, response=response)
|
||||
out = run_async(transport.handle_async_request(httpx.Request("GET", "https://example.com/")))
|
||||
assert out.http_version == label
|
||||
|
||||
|
||||
def test_resolve_timeout_prefers_read_then_connect_then_default():
|
||||
read = httpx.Request("GET", "https://x/", extensions={"timeout": {"read": 12.0, "connect": 3.0}})
|
||||
assert resolve_timeout(read) == 12.0
|
||||
connect = httpx.Request("GET", "https://x/", extensions={"timeout": {"connect": 3.0}})
|
||||
assert resolve_timeout(connect) == 3.0
|
||||
none = httpx.Request("GET", "https://x/")
|
||||
assert resolve_timeout(none) == ct.DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def test_curl_timeout_maps_to_httpx_connecttimeout(monkeypatch):
|
||||
transport, _fake = make_transport(monkeypatch, raise_exc=Timeout("slow"))
|
||||
with pytest.raises(httpx.ConnectTimeout):
|
||||
run_async(transport.handle_async_request(httpx.Request("GET", "https://example.com/")))
|
||||
|
||||
|
||||
def test_curl_error_maps_to_httpx_connecterror(monkeypatch):
|
||||
transport, _fake = make_transport(monkeypatch, raise_exc=RequestException("boom"))
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
run_async(transport.handle_async_request(httpx.Request("GET", "https://example.com/")))
|
||||
|
||||
|
||||
def test_aclose_closes_session(monkeypatch):
|
||||
transport, fake = make_transport(monkeypatch)
|
||||
run_async(transport.aclose())
|
||||
assert fake.closed is True
|
||||
@@ -40,6 +40,8 @@ def _post(owner, **extra):
|
||||
"image": None,
|
||||
"stars": 0,
|
||||
"created_at": _now_db_helpers(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
@@ -54,6 +56,8 @@ def _comment(owner, target_uid):
|
||||
"target_type": "post",
|
||||
"content": "db helper comment",
|
||||
"created_at": _now_db_helpers(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
return uid
|
||||
@@ -66,6 +70,8 @@ def _vote(target_type, target_uid, value):
|
||||
"target_type": target_type,
|
||||
"value": value,
|
||||
"created_at": _now_db_helpers(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
from uuid import uuid4
|
||||
@@ -94,6 +100,8 @@ def test_get_vote_counts_groups_up_and_down(local_db):
|
||||
"target_type": "post",
|
||||
"value": value,
|
||||
"created_at": _now_db_helpers(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
ups, downs = get_vote_counts([target])
|
||||
@@ -112,6 +120,8 @@ def test_get_user_votes_returns_user_value(local_db):
|
||||
"target_type": "post",
|
||||
"value": 1,
|
||||
"created_at": _now_db_helpers(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
assert get_user_votes(user, [target]) == {target: 1}
|
||||
@@ -129,6 +139,8 @@ def test_get_comment_counts_by_post_uids(local_db):
|
||||
"target_uid": post,
|
||||
"content": "hi",
|
||||
"created_at": _now_db_helpers(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
assert get_comment_counts_by_post_uids([post]) == {post: 2}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import ipaddress
|
||||
import httpx
|
||||
import pytest
|
||||
from devplacepy import net_guard
|
||||
from devplacepy.curl_transport import CurlTransport
|
||||
from devplacepy.net_guard import BlockedAddressError
|
||||
from tests.conftest import run_async
|
||||
|
||||
|
||||
class _RecordingInner(httpx.AsyncBaseTransport):
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
self.closed = False
|
||||
|
||||
async def handle_async_request(self, request):
|
||||
self.called = True
|
||||
return httpx.Response(200, content=b"ok", request=request)
|
||||
|
||||
async def aclose(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_loopback_is_blocked():
|
||||
assert net_guard.is_blocked_address(ipaddress.ip_address("127.0.0.1"))
|
||||
|
||||
@@ -67,3 +82,41 @@ def test_guard_allow_private_skips_resolution():
|
||||
net_guard.guard_public_url("http://127.0.0.1:9000/x", allow_private=True)
|
||||
)
|
||||
assert host == "127.0.0.1"
|
||||
|
||||
|
||||
def test_guarded_client_wraps_stealth_transport():
|
||||
client = net_guard.guarded_async_client(timeout=5.0)
|
||||
transport = client._transport
|
||||
assert isinstance(transport, net_guard._GuardedTransport)
|
||||
assert isinstance(transport._inner, CurlTransport)
|
||||
run_async(client.aclose())
|
||||
|
||||
|
||||
def test_guarded_transport_blocks_before_delegating():
|
||||
inner = _RecordingInner()
|
||||
transport = net_guard._GuardedTransport(inner)
|
||||
request = httpx.Request("GET", "http://127.0.0.1/admin")
|
||||
with pytest.raises(BlockedAddressError):
|
||||
run_async(transport.handle_async_request(request))
|
||||
assert inner.called is False
|
||||
|
||||
|
||||
def test_guarded_transport_delegates_when_allowed(monkeypatch):
|
||||
async def _allow(url, **kwargs):
|
||||
return "example.com"
|
||||
|
||||
monkeypatch.setattr(net_guard, "guard_public_url", _allow)
|
||||
inner = _RecordingInner()
|
||||
transport = net_guard._GuardedTransport(inner)
|
||||
response = run_async(
|
||||
transport.handle_async_request(httpx.Request("GET", "https://example.com/x"))
|
||||
)
|
||||
assert inner.called is True
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_guarded_transport_aclose_closes_inner():
|
||||
inner = _RecordingInner()
|
||||
transport = net_guard._GuardedTransport(inner)
|
||||
run_async(transport.aclose())
|
||||
assert inner.closed is True
|
||||
|
||||
@@ -157,9 +157,10 @@ def test_record_never_raises(monkeypatch):
|
||||
raise RuntimeError("db exploded")
|
||||
|
||||
monkeypatch.setattr(store, "insert_event", boom)
|
||||
# must swallow the error and return None rather than propagate
|
||||
assert audit.record_system("test.unit.raise", actor_kind="system") is None
|
||||
assert audit.record(None, "test.unit.raise2", user=None) is None
|
||||
# persistence is deferred to the background task queue (inline under tests); a
|
||||
# failing write is swallowed there and must never propagate into the caller
|
||||
audit.record_system("test.unit.raise", actor_kind="system")
|
||||
audit.record(None, "test.unit.raise2", user=None)
|
||||
|
||||
|
||||
def test_record_with_none_request_uses_explicit_user():
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,96 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import random
|
||||
|
||||
from devplacepy.services.bot.config import SEARCH_TERMS
|
||||
from devplacepy.services.bot.handles import (
|
||||
MAX_HANDLE_LEN,
|
||||
MIN_HANDLE_LEN,
|
||||
apply_leet,
|
||||
make_handle,
|
||||
sanitize_handle,
|
||||
)
|
||||
|
||||
SAMPLE_COUNT = 300
|
||||
|
||||
|
||||
def _is_valid_username(handle: str) -> bool:
|
||||
return (
|
||||
handle.isascii()
|
||||
and bool(handle)
|
||||
and all(character.isalnum() or character in ("-", "_") for character in handle)
|
||||
)
|
||||
|
||||
|
||||
def test_make_handle_only_valid_username_characters():
|
||||
generator = random.Random(1)
|
||||
for _ in range(SAMPLE_COUNT):
|
||||
handle = make_handle([], generator)
|
||||
assert _is_valid_username(handle), handle
|
||||
|
||||
|
||||
def test_make_handle_never_contains_dot():
|
||||
generator = random.Random(2)
|
||||
for _ in range(SAMPLE_COUNT):
|
||||
handle = make_handle(["first app", "raspberry pi"], generator)
|
||||
assert "." not in handle
|
||||
|
||||
|
||||
def test_make_handle_respects_length_bounds():
|
||||
generator = random.Random(3)
|
||||
for _ in range(SAMPLE_COUNT):
|
||||
handle = make_handle([], generator)
|
||||
assert MIN_HANDLE_LEN <= len(handle) <= MAX_HANDLE_LEN
|
||||
|
||||
|
||||
def test_make_handle_valid_for_every_persona():
|
||||
generator = random.Random(4)
|
||||
for persona, interests in SEARCH_TERMS.items():
|
||||
for _ in range(SAMPLE_COUNT):
|
||||
handle = make_handle(interests, generator)
|
||||
assert _is_valid_username(handle), (persona, handle)
|
||||
assert MIN_HANDLE_LEN <= len(handle) <= MAX_HANDLE_LEN
|
||||
|
||||
|
||||
def test_make_handle_is_deterministic_for_seeded_rng():
|
||||
first = [make_handle(["rust", "kubernetes"], random.Random(99)) for _ in range(20)]
|
||||
second = [make_handle(["rust", "kubernetes"], random.Random(99)) for _ in range(20)]
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_make_handle_produces_variety():
|
||||
generator = random.Random(5)
|
||||
handles = {make_handle([], generator) for _ in range(SAMPLE_COUNT)}
|
||||
assert len(handles) > SAMPLE_COUNT // 2
|
||||
|
||||
|
||||
def test_sanitize_handle_strips_dots_and_invalid_characters():
|
||||
assert sanitize_handle("john.smith.dev!!") == "johnsmithdev"
|
||||
assert sanitize_handle("hello world") == "helloworld"
|
||||
assert sanitize_handle("a@b#c$d") == "abcd"
|
||||
|
||||
|
||||
def test_sanitize_handle_collapses_and_trims_separators():
|
||||
assert sanitize_handle("__null__byte__") == "null_byte"
|
||||
assert sanitize_handle("--seg--fault--") == "seg-fault"
|
||||
|
||||
|
||||
def test_sanitize_handle_caps_length():
|
||||
assert len(sanitize_handle("a" * 50)) == MAX_HANDLE_LEN
|
||||
|
||||
|
||||
def test_sanitize_handle_empty_input():
|
||||
assert sanitize_handle("...") == ""
|
||||
assert sanitize_handle("") == ""
|
||||
|
||||
|
||||
def test_apply_leet_only_substitutes_mapped_characters():
|
||||
generator = random.Random(7)
|
||||
result = apply_leet("xyzq", generator)
|
||||
assert result == "xyzq"
|
||||
|
||||
|
||||
def test_apply_leet_can_substitute_known_characters():
|
||||
always = random.Random(0)
|
||||
seen = {apply_leet("aeiost", always) for _ in range(50)}
|
||||
assert any(character.isdigit() for handle in seen for character in handle)
|
||||
@@ -0,0 +1,50 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from devplacepy.services.bot.handles import MAX_HANDLE_LEN
|
||||
from devplacepy.services.bot.llm import LLMClient
|
||||
|
||||
|
||||
def _client(reply: str) -> LLMClient:
|
||||
client = LLMClient(
|
||||
api_key="test",
|
||||
api_url="http://example.invalid",
|
||||
model="molodetz",
|
||||
input_cost_per_1m=0.0,
|
||||
output_cost_per_1m=0.0,
|
||||
)
|
||||
client._raw_call = lambda system, prompt, temperature=1.0: reply
|
||||
return client
|
||||
|
||||
|
||||
def test_generate_handle_candidates_parses_and_sanitizes():
|
||||
reply = json.dumps({"handles": ["null_byte", "john.smith", "seg fault!!", "c0d3r"]})
|
||||
handles = _client(reply).generate_handle_candidates("grumpy_senior")
|
||||
assert handles == ["null_byte", "johnsmith", "segfault", "c0d3r"]
|
||||
|
||||
|
||||
def test_generate_handle_candidates_dedupes_case_insensitively():
|
||||
reply = json.dumps({"handles": ["NullByte", "nullbyte", "Daemon", "daemon"]})
|
||||
handles = _client(reply).generate_handle_candidates("minimalist")
|
||||
assert [handle.lower() for handle in handles] == ["nullbyte", "daemon"]
|
||||
|
||||
|
||||
def test_generate_handle_candidates_drops_empty_results():
|
||||
reply = json.dumps({"handles": ["", "...", "!!!", "kernelpanic"]})
|
||||
handles = _client(reply).generate_handle_candidates("rebel")
|
||||
assert handles == ["kernelpanic"]
|
||||
|
||||
|
||||
def test_generate_handle_candidates_caps_length():
|
||||
reply = json.dumps({"handles": ["x" * 40]})
|
||||
handles = _client(reply).generate_handle_candidates("mentor")
|
||||
assert len(handles[0]) == MAX_HANDLE_LEN
|
||||
|
||||
|
||||
def test_generate_handle_candidates_handles_bad_json():
|
||||
assert _client("not json at all").generate_handle_candidates("academic_type") == []
|
||||
|
||||
|
||||
def test_generate_handle_candidates_handles_missing_key():
|
||||
assert _client(json.dumps({"other": []})).generate_handle_candidates("mentor") == []
|
||||
@@ -10,7 +10,7 @@ def _msgs(text):
|
||||
|
||||
def test_channels_isolate_one_owner(local_db):
|
||||
store = ConversationStore()
|
||||
owner = f"chan-{generate_uid()[:8]}"
|
||||
owner = f"chan-{generate_uid()[-12:]}"
|
||||
store.save("user", owner, _msgs("main thread"), "main")
|
||||
store.save("user", owner, _msgs("docs thread"), "docs")
|
||||
assert store.load("user", owner, "main") == _msgs("main thread")
|
||||
@@ -19,7 +19,7 @@ def test_channels_isolate_one_owner(local_db):
|
||||
|
||||
def test_clear_one_channel_keeps_other(local_db):
|
||||
store = ConversationStore()
|
||||
owner = f"chan-{generate_uid()[:8]}"
|
||||
owner = f"chan-{generate_uid()[-12:]}"
|
||||
store.save("user", owner, _msgs("main"), "main")
|
||||
store.save("user", owner, _msgs("docs"), "docs")
|
||||
store.clear("user", owner, "docs")
|
||||
@@ -29,7 +29,7 @@ def test_clear_one_channel_keeps_other(local_db):
|
||||
|
||||
def test_save_updates_in_place_per_channel(local_db):
|
||||
store = ConversationStore()
|
||||
owner = f"chan-{generate_uid()[:8]}"
|
||||
owner = f"chan-{generate_uid()[-12:]}"
|
||||
store.save("user", owner, _msgs("first"), "docs")
|
||||
store.save("user", owner, _msgs("second"), "docs")
|
||||
assert store.load("user", owner, "docs") == _msgs("second")
|
||||
@@ -37,7 +37,7 @@ def test_save_updates_in_place_per_channel(local_db):
|
||||
|
||||
def test_default_channel_is_main(local_db):
|
||||
store = ConversationStore()
|
||||
owner = f"chan-{generate_uid()[:8]}"
|
||||
owner = f"chan-{generate_uid()[-12:]}"
|
||||
store.save("user", owner, _msgs("default"))
|
||||
assert store.load("user", owner) == _msgs("default")
|
||||
assert store.load("user", owner, "main") == _msgs("default")
|
||||
|
||||
@@ -12,13 +12,13 @@ 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.gitea.service import IssueTrackerService
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.bug_create_service import BugCreateService
|
||||
from devplacepy.services.jobs.issue_create_service import IssueCreateService
|
||||
from tests.conftest import run_async
|
||||
_counter_bugs_gitea = [0]
|
||||
_counter_issues_gitea = [0]
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db_bugs_gitea():
|
||||
def _init_db_issues_gitea():
|
||||
init_db()
|
||||
yield
|
||||
@pytest.fixture
|
||||
@@ -29,18 +29,18 @@ def gitea_env():
|
||||
set_setting("gitea_owner", "retoor")
|
||||
set_setting("gitea_repo", "pydevplace")
|
||||
set_setting("gitea_token", "test-token")
|
||||
set_setting("bug_ai_enhance", "0")
|
||||
set_setting("issue_ai_enhance", "0")
|
||||
yield fake
|
||||
runtime.set_client(None)
|
||||
for table in ("bug_tickets", "bug_comment_authors"):
|
||||
for table in ("issue_tickets", "issue_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")):
|
||||
for row in list(get_table("jobs").find(kind="issue_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]}"
|
||||
def _make_user_issues_gitea():
|
||||
_counter_issues_gitea[0] += 1
|
||||
uid = f"issuetest-user-{_counter_issues_gitea[0]}"
|
||||
username = f"issuetester{_counter_issues_gitea[0]}"
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
@@ -56,17 +56,17 @@ def _unread(user_uid):
|
||||
return [
|
||||
n
|
||||
for n in get_table("notifications").find(user_uid=user_uid)
|
||||
if n.get("type") == "bug"
|
||||
if n.get("type") == "issue"
|
||||
]
|
||||
def _drive_jobs():
|
||||
async def run():
|
||||
svc = BugCreateService()
|
||||
svc = IssueCreateService()
|
||||
for _ in range(200):
|
||||
await svc.run_once()
|
||||
refresh_snapshot()
|
||||
pending = [
|
||||
r
|
||||
for r in get_table("jobs").find(kind="bug_create")
|
||||
for r in get_table("jobs").find(kind="issue_create")
|
||||
if r["status"] in ("pending", "running")
|
||||
]
|
||||
if not pending and not svc._inflight:
|
||||
@@ -75,7 +75,7 @@ def _drive_jobs():
|
||||
|
||||
run_async(run())
|
||||
def _role_user(role: str):
|
||||
uid, username = _make_user_bugs_gitea()
|
||||
uid, username = _make_user_issues_gitea()
|
||||
get_table("users").update({"uid": uid, "role": role}, ["uid"])
|
||||
return uid, username
|
||||
def _open_ticket(fake, role: str):
|
||||
@@ -130,7 +130,7 @@ def test_fake_client_state_and_comments(gitea_env):
|
||||
|
||||
|
||||
def test_store_records_and_maps(gitea_env):
|
||||
uid, _ = _make_user_bugs_gitea()
|
||||
uid, _ = _make_user_issues_gitea()
|
||||
store.record_ticket(7, uid, "orig", "desc", "Better", "https://x/7", "open")
|
||||
assert store.author_uid_for_issue(7) == uid
|
||||
assert store.author_map([7, 8]) == {7: uid}
|
||||
@@ -149,9 +149,9 @@ def test_enhance_fallback_when_disabled(gitea_env):
|
||||
|
||||
|
||||
def test_create_job_files_issue(gitea_env):
|
||||
uid, username = _make_user_bugs_gitea()
|
||||
uid, username = _make_user_issues_gitea()
|
||||
job_uid = queue.enqueue(
|
||||
"bug_create",
|
||||
"issue_create",
|
||||
{"author_uid": uid, "title": "Login broken", "description": "cannot log in"},
|
||||
"user",
|
||||
uid,
|
||||
@@ -171,9 +171,9 @@ def test_create_job_files_issue(gitea_env):
|
||||
|
||||
def test_create_job_fails_without_config(gitea_env):
|
||||
set_setting("gitea_token", "")
|
||||
uid, _ = _make_user_bugs_gitea()
|
||||
uid, _ = _make_user_issues_gitea()
|
||||
job_uid = queue.enqueue(
|
||||
"bug_create",
|
||||
"issue_create",
|
||||
{"author_uid": uid, "title": "x", "description": "y"},
|
||||
"user",
|
||||
uid,
|
||||
@@ -186,45 +186,45 @@ def test_create_job_fails_without_config(gitea_env):
|
||||
|
||||
|
||||
def test_poller_notifies_on_developer_reply(gitea_env):
|
||||
uid, _ = _make_user_bugs_gitea()
|
||||
issue = run_async(gitea_env.create_issue("bug", "body"))
|
||||
uid, _ = _make_user_issues_gitea()
|
||||
issue = run_async(gitea_env.create_issue("issue", "body"))
|
||||
number = issue["number"]
|
||||
store.record_ticket(number, uid, "bug", "body", "bug", issue["html_url"], "open")
|
||||
store.record_ticket(number, uid, "issue", "body", "issue", issue["html_url"], "open")
|
||||
store.update_ticket_cache(number, "open", 0)
|
||||
|
||||
gitea_env.add_external_comment(number, "developer", "looking into it")
|
||||
before = len(_unread(uid))
|
||||
run_async(BugTrackerService().run_once())
|
||||
run_async(IssueTrackerService().run_once())
|
||||
refresh_snapshot()
|
||||
assert len(_unread(uid)) == before + 1
|
||||
assert int(store.get_ticket(number)["last_comment_count"]) == 1
|
||||
|
||||
|
||||
def test_poller_ignores_local_comment(gitea_env):
|
||||
uid, _ = _make_user_bugs_gitea()
|
||||
issue = run_async(gitea_env.create_issue("bug", "body"))
|
||||
uid, _ = _make_user_issues_gitea()
|
||||
issue = run_async(gitea_env.create_issue("issue", "body"))
|
||||
number = issue["number"]
|
||||
store.record_ticket(number, uid, "bug", "body", "bug", issue["html_url"], "open")
|
||||
store.record_ticket(number, uid, "issue", "body", "issue", issue["html_url"], "open")
|
||||
store.update_ticket_cache(number, "open", 0)
|
||||
|
||||
comment = run_async(gitea_env.create_comment(number, "me again"))
|
||||
store.record_comment_author(comment["id"], number, uid)
|
||||
before = len(_unread(uid))
|
||||
run_async(BugTrackerService().run_once())
|
||||
run_async(IssueTrackerService().run_once())
|
||||
refresh_snapshot()
|
||||
assert len(_unread(uid)) == before
|
||||
|
||||
|
||||
def test_poller_notifies_on_status_change(gitea_env):
|
||||
uid, _ = _make_user_bugs_gitea()
|
||||
issue = run_async(gitea_env.create_issue("bug", "body"))
|
||||
uid, _ = _make_user_issues_gitea()
|
||||
issue = run_async(gitea_env.create_issue("issue", "body"))
|
||||
number = issue["number"]
|
||||
store.record_ticket(number, uid, "bug", "body", "bug", issue["html_url"], "open")
|
||||
store.record_ticket(number, uid, "issue", "body", "issue", issue["html_url"], "open")
|
||||
store.update_ticket_cache(number, "open", 0)
|
||||
|
||||
run_async(gitea_env.set_state(number, "closed"))
|
||||
before = len(_unread(uid))
|
||||
run_async(BugTrackerService().run_once())
|
||||
run_async(IssueTrackerService().run_once())
|
||||
refresh_snapshot()
|
||||
assert len(_unread(uid)) == before + 1
|
||||
assert store.get_ticket(number)["last_status"] == "closed"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.jobs.deepsearch.pdf import extract_pdf_text, is_pdf
|
||||
|
||||
|
||||
def _make_pdf(message: str) -> bytes:
|
||||
content = b"BT /F1 24 Tf 72 720 Td (" + message.encode("latin-1") + b") Tj ET"
|
||||
bodies = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
||||
b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
|
||||
b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n" + content + b"\nendstream",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]
|
||||
pdf = bytearray(b"%PDF-1.4\n")
|
||||
offsets: list[int] = []
|
||||
for index, body in enumerate(bodies, start=1):
|
||||
offsets.append(len(pdf))
|
||||
pdf += str(index).encode() + b" 0 obj\n" + body + b"\nendobj\n"
|
||||
xref_pos = len(pdf)
|
||||
pdf += b"xref\n0 " + str(len(bodies) + 1).encode() + b"\n"
|
||||
pdf += b"0000000000 65535 f \n"
|
||||
for offset in offsets:
|
||||
pdf += ("%010d 00000 n \n" % offset).encode()
|
||||
pdf += b"trailer\n<< /Size " + str(len(bodies) + 1).encode() + b" /Root 1 0 R >>\n"
|
||||
pdf += b"startxref\n" + str(xref_pos).encode() + b"\n%%EOF"
|
||||
return bytes(pdf)
|
||||
|
||||
|
||||
def test_is_pdf_detects_content_type():
|
||||
assert is_pdf("application/pdf", "https://example.com/file", b"") is True
|
||||
assert is_pdf("application/x-pdf; charset=binary", "https://example.com/file", b"") is True
|
||||
|
||||
|
||||
def test_is_pdf_detects_url_suffix():
|
||||
assert is_pdf("text/html", "https://example.com/report.PDF", b"<html>") is True
|
||||
assert is_pdf("", "https://example.com/docs/spec.pdf?v=2", b"") is True
|
||||
|
||||
|
||||
def test_is_pdf_detects_magic_bytes():
|
||||
assert is_pdf("application/octet-stream", "https://example.com/download", b"%PDF-1.7") is True
|
||||
|
||||
|
||||
def test_is_pdf_rejects_html():
|
||||
assert is_pdf("text/html", "https://example.com/page", b"<!DOCTYPE html>") is False
|
||||
|
||||
|
||||
def test_extract_pdf_text_returns_content():
|
||||
title, text = extract_pdf_text(_make_pdf("Hello DeepSearch PDF indexing"))
|
||||
assert "Hello DeepSearch PDF indexing" in text
|
||||
|
||||
|
||||
def test_extract_pdf_text_rejects_non_pdf():
|
||||
assert extract_pdf_text(b"this is not a pdf at all") == ("", "")
|
||||
assert extract_pdf_text(b"") == ("", "")
|
||||
|
||||
|
||||
def test_extract_pdf_text_handles_corrupt_pdf():
|
||||
title, text = extract_pdf_text(b"%PDF-1.4\ngarbage that is not valid pdf structure")
|
||||
assert title == ""
|
||||
assert text == ""
|
||||
@@ -16,7 +16,7 @@ def _init_db_fork_jobs():
|
||||
@pytest.fixture
|
||||
def fork_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.services.jobs.fork_service.STAGING_DIR", tmp_path / "staging"
|
||||
"devplacepy.services.jobs.fork_service.FORK_STAGING_DIR", tmp_path / "staging"
|
||||
)
|
||||
monkeypatch.setattr("devplacepy.project_files.PROJECT_FILES_DIR", tmp_path / "pf")
|
||||
yield tmp_path
|
||||
|
||||
@@ -22,7 +22,7 @@ def zip_env(tmp_path, monkeypatch):
|
||||
"devplacepy.services.jobs.zip_service.ZIPS_DIR", tmp_path / "zips"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.services.jobs.zip_service.STAGING_DIR", tmp_path / "staging"
|
||||
"devplacepy.services.jobs.zip_service.ZIP_STAGING_DIR", tmp_path / "staging"
|
||||
)
|
||||
monkeypatch.setattr("devplacepy.project_files.PROJECT_FILES_DIR", tmp_path / "pf")
|
||||
yield tmp_path
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.curl_transport import CurlTransport
|
||||
from tests.conftest import run_async
|
||||
|
||||
|
||||
def test_factory_returns_async_client():
|
||||
client = stealth.stealth_async_client(timeout=5.0)
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
run_async(client.aclose())
|
||||
|
||||
|
||||
def test_curl_active_true():
|
||||
assert stealth.curl_active() is True
|
||||
|
||||
|
||||
def test_stealth_transport_is_curl_when_active():
|
||||
transport = stealth.stealth_transport()
|
||||
assert isinstance(transport, CurlTransport)
|
||||
run_async(transport.aclose())
|
||||
|
||||
|
||||
def test_curl_mode_forwards_headers_without_chrome_base():
|
||||
client = stealth.stealth_async_client(headers={"Authorization": "Bearer k"})
|
||||
assert client.headers.get("authorization") == "Bearer k"
|
||||
assert client.headers.get("sec-ch-ua") is None
|
||||
run_async(client.aclose())
|
||||
|
||||
|
||||
def test_chrome_base_headers_match_chrome_149():
|
||||
headers = stealth.chrome_base_headers()
|
||||
assert "Chrome/149.0.0.0" in headers["user-agent"]
|
||||
assert headers["sec-ch-ua"] == (
|
||||
'"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"'
|
||||
)
|
||||
assert headers["accept-encoding"] == "gzip, deflate, br, zstd"
|
||||
|
||||
|
||||
def test_fallback_path_injects_chrome_headers(monkeypatch):
|
||||
monkeypatch.setattr(stealth, "CURL_AVAILABLE", False)
|
||||
transport = stealth.stealth_transport()
|
||||
assert isinstance(transport, httpx.AsyncHTTPTransport)
|
||||
client = stealth.stealth_async_client()
|
||||
assert client.headers.get("sec-ch-ua") is not None
|
||||
assert "Chrome/149.0.0.0" in client.headers.get("user-agent", "")
|
||||
run_async(client.aclose())
|
||||
run_async(transport.aclose())
|
||||
Reference in New Issue
Block a user