forked from retoor/devplacepy
The primary administrator was the earliest Admin by created_at with no further condition, so a soft-deleted or deactivated account could hold the role and then be refused by the api-key path, leaving nobody able to use the database API, the backup download or cross-owner container management. Rows with no recorded signup time also sorted ahead of every real account. Scan the earliest admins instead and take the first that is neither deleted nor deactivated, with missing timestamps sorted last. Also stop the projects listing returning 500 when project_type or description is NULL (the dict default never applies to an existing NULL column), align the issue test fixture with its unit twin so a combined run cannot collide on a fixed uid, read the settings value from the database rather than a stale per-process cache, and give the seeded and fixture admins the is_active and created_at fields that every real signup writes.
397 lines
12 KiB
Python
397 lines
12 KiB
Python
import os
|
|
import sys
|
|
import tempfile
|
|
import subprocess
|
|
import time
|
|
import asyncio
|
|
import threading
|
|
from pathlib import Path
|
|
import pytest
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
SCREENSHOT_DIR = Path("/tmp/devplace_test_screenshots")
|
|
|
|
|
|
PORT = int(os.environ.get("DEVPLACE_TEST_PORT", "10501"))
|
|
BASE_URL = f"http://127.0.0.1:{PORT}"
|
|
# Older than any user a fixture can create, so the seeded admin stays primary administrator.
|
|
PRIMARY_ADMIN_CREATED_AT = "2000-01-01T00:00:00"
|
|
# The cross-worker cache-version read is 1s; give the server a beat to see the new admin set.
|
|
CACHE_VERSION_PROPAGATION_SECONDS = 1.5
|
|
|
|
_TEST_DB = tempfile.NamedTemporaryFile(suffix=f"_{PORT}.db", delete=False)
|
|
_TEST_DB.close()
|
|
_TEST_DATA_DIR = Path(tempfile.mkdtemp(suffix=f"_data_{PORT}"))
|
|
os.environ["DEVPLACE_DATABASE_URL"] = f"sqlite:///{_TEST_DB.name}"
|
|
os.environ["DEVPLACE_DATA_DIR"] = str(_TEST_DATA_DIR)
|
|
os.environ["SECRET_KEY"] = "test-secret-key"
|
|
os.environ["DEVPLACE_DISABLE_SERVICES"] = "1"
|
|
os.environ["DEVPLACE_RATE_LIMIT"] = "1000000"
|
|
# Pin a single web worker so the per-worker rate-limit divisor is 1 regardless of
|
|
# any DEVPLACE_WEB_WORKERS the host (or a server .env) exports.
|
|
os.environ["DEVPLACE_WEB_WORKERS"] = "1"
|
|
# Disable per-IP rate limiting outright for the suite: the middleware short-circuits
|
|
# when this is set, so request-dense tests (e.g. test_auth_matrix) are never throttled
|
|
# regardless of host environment. test_ratelimit.py re-enables it per test to exercise
|
|
# the limiter directly.
|
|
os.environ["DEVPLACE_DISABLE_RATE_LIMIT"] = "1"
|
|
os.environ["DEVPLACE_SITEMAP_TTL"] = "0"
|
|
os.environ["DEVPLACE_HOME_CACHE_TTL"] = "0"
|
|
os.environ["DEVPLACE_RANKING_TTL"] = "0"
|
|
os.environ["DEVPLACE_MARKET_SATURATION_TTL"] = "0"
|
|
|
|
|
|
def run_async(coro):
|
|
"""Run a coroutine from sync test code on a dedicated thread and fresh loop."""
|
|
result_box = []
|
|
exc_box = []
|
|
|
|
def _run():
|
|
try:
|
|
result_box.append(asyncio.run(coro))
|
|
except BaseException as e:
|
|
exc_box.append(e)
|
|
finally:
|
|
from devplacepy.database import refresh_snapshot
|
|
|
|
refresh_snapshot()
|
|
|
|
t = threading.Thread(target=_run, daemon=True)
|
|
t.start()
|
|
t.join(timeout=30)
|
|
from devplacepy.database import refresh_snapshot
|
|
|
|
refresh_snapshot()
|
|
if exc_box:
|
|
raise exc_box[0]
|
|
if result_box:
|
|
return result_box[0]
|
|
raise TimeoutError("run_async timed out waiting for coroutine to complete")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fresh_db_snapshot():
|
|
from devplacepy.database import refresh_snapshot
|
|
|
|
refresh_snapshot()
|
|
yield
|
|
|
|
|
|
def save_failure_screenshot(page, test_name):
|
|
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
if page and not page.is_closed():
|
|
path = str(SCREENSHOT_DIR / f"{test_name}.png")
|
|
page.screenshot(path=path, full_page=True)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_runtest_makereport(item, call):
|
|
outcome = yield
|
|
report = outcome.get_result()
|
|
if report.when == "call" and report.failed:
|
|
for name in ("page", "alice", "bob"):
|
|
if name in item.funcargs:
|
|
try:
|
|
obj = item.funcargs[name]
|
|
if name in ("alice", "bob"):
|
|
obj = obj[0]
|
|
save_failure_screenshot(
|
|
obj, item.nodeid.replace("::", "_").replace("/", "_")
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def test_db_path():
|
|
yield _TEST_DB.name
|
|
try:
|
|
os.unlink(_TEST_DB.name)
|
|
except OSError:
|
|
pass
|
|
import shutil
|
|
|
|
shutil.rmtree(_TEST_DATA_DIR, ignore_errors=True)
|
|
|
|
|
|
def _port_in_use(port):
|
|
import socket
|
|
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.settimeout(0.5)
|
|
return sock.connect_ex(("127.0.0.1", port)) == 0
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def app_server(test_db_path):
|
|
if _port_in_use(PORT):
|
|
raise RuntimeError(
|
|
f"Port {PORT} is already in use before app_server starts. A stale test "
|
|
f"server is running with a different database; kill it (the readiness "
|
|
f"check would otherwise bind tests to it and produce phantom failures)."
|
|
)
|
|
env = os.environ.copy()
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
log_file = tempfile.NamedTemporaryFile(suffix=f"_server_{PORT}.log", delete=False)
|
|
proc = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"uvicorn",
|
|
"devplacepy.main:app",
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
str(PORT),
|
|
"--log-level",
|
|
"warning",
|
|
],
|
|
cwd=str(PROJECT_ROOT),
|
|
env=env,
|
|
stdout=log_file,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
|
|
def server_log():
|
|
try:
|
|
with open(log_file.name, "r", errors="replace") as f:
|
|
return f.read()[-5000:]
|
|
except OSError:
|
|
return ""
|
|
|
|
deadline = time.time() + 30
|
|
while time.time() < deadline:
|
|
try:
|
|
import urllib.request
|
|
|
|
urllib.request.urlopen(f"{BASE_URL}/", timeout=2)
|
|
break
|
|
except Exception:
|
|
poll = proc.poll()
|
|
if poll is not None:
|
|
proc.wait()
|
|
raise RuntimeError(
|
|
f"Server process died (exit code {poll}). Log:\n{server_log()}"
|
|
)
|
|
time.sleep(0.5)
|
|
else:
|
|
proc.terminate()
|
|
proc.wait(timeout=5)
|
|
raise RuntimeError(f"Server did not start after 30s. Log:\n{server_log()}")
|
|
|
|
from devplacepy.database import set_setting as _set_setting
|
|
|
|
# Use set_setting (upsert + cache-version bump) rather than a raw insert: the
|
|
# server process caches settings and only reloads when the version bumps, so a
|
|
# bare insert can leave the worker on a stale/default rate limit and throttle
|
|
# request-dense tests (e.g. test_auth_matrix) with spurious 429s. Pin the limit
|
|
# high regardless of any pre-existing row or the host's DEVPLACE_* environment.
|
|
_set_setting("rate_limit_per_minute", "1000000")
|
|
_set_setting("rate_limit_window_seconds", "60")
|
|
|
|
yield proc
|
|
try:
|
|
proc.terminate()
|
|
proc.wait(timeout=10)
|
|
except Exception:
|
|
proc.kill()
|
|
proc.wait(timeout=5)
|
|
finally:
|
|
try:
|
|
log_file.close()
|
|
os.unlink(log_file.name)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def playwright_instance():
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
with sync_playwright() as p:
|
|
yield p
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def browser(playwright_instance):
|
|
headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1"
|
|
b = playwright_instance.chromium.launch(
|
|
headless=headless,
|
|
slow_mo=int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0")),
|
|
args=["--window-size=1400,900"],
|
|
)
|
|
yield b
|
|
b.close()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def browser_context(browser):
|
|
ctx = browser.new_context(
|
|
viewport={"width": 1400, "height": 900},
|
|
permissions=["clipboard-read", "clipboard-write"],
|
|
)
|
|
yield ctx
|
|
ctx.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def page(browser_context):
|
|
browser_context.clear_cookies()
|
|
p = browser_context.new_page()
|
|
p.set_default_timeout(15000)
|
|
p.bring_to_front()
|
|
yield p
|
|
p.close()
|
|
|
|
|
|
def signup_user(page, user):
|
|
page.bring_to_front()
|
|
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
|
|
page.fill("#username", user["username"])
|
|
page.fill("#email", user["email"])
|
|
page.fill("#password", user["password"])
|
|
page.fill("#confirm_password", user["password"])
|
|
page.click("button:has-text('Create account')")
|
|
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
|
|
|
|
|
|
def login_user(page, user):
|
|
page.bring_to_front()
|
|
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
|
|
page.fill("#email", user["email"])
|
|
page.fill("#password", user["password"])
|
|
page.click("button:has-text('Sign in')")
|
|
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
|
|
|
|
|
|
def assert_share_copies(page, expected_fragment):
|
|
from playwright.sync_api import expect
|
|
|
|
share = page.locator("button[data-share]").first
|
|
share.scroll_into_view_if_needed()
|
|
assert expected_fragment in (share.get_attribute("data-share") or ""), (
|
|
f"data-share={share.get_attribute('data-share')!r}"
|
|
)
|
|
share.click()
|
|
expect(share).to_have_text("Copied!", timeout=3000)
|
|
expect(share).not_to_have_text("Copied!", timeout=3000)
|
|
try:
|
|
clip = page.evaluate("navigator.clipboard.readText()")
|
|
except Exception:
|
|
clip = None
|
|
if clip:
|
|
assert clip.startswith("http") and expected_fragment in clip, (
|
|
f"clipboard={clip!r}"
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def seeded_db(app_server):
|
|
"""Create test users once at session level using requests directly."""
|
|
import urllib.request
|
|
import urllib.parse
|
|
|
|
users = [
|
|
("alice_test", "alice@test.devplace", "secret123"),
|
|
("bob_test", "bob@test.devplace", "secret456"),
|
|
]
|
|
for username, email, pw in users:
|
|
data = urllib.parse.urlencode(
|
|
{
|
|
"username": username,
|
|
"email": email,
|
|
"password": pw,
|
|
"confirm_password": pw,
|
|
}
|
|
).encode()
|
|
req = urllib.request.Request(
|
|
f"{BASE_URL}/auth/signup",
|
|
data=data,
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
)
|
|
urllib.request.urlopen(req)
|
|
|
|
from devplacepy.database import get_table as _get_table
|
|
|
|
users_table = _get_table("users")
|
|
for username, role in (("alice_test", "Admin"), ("bob_test", "Member")):
|
|
row = users_table.find_one(username=username)
|
|
if row and row.get("role") != role:
|
|
users_table.update({"uid": row["uid"], "role": role}, ["uid"])
|
|
|
|
# The primary administrator is the earliest-created Admin, and unit-tier fixtures
|
|
# create their own admins in this same database before the api tier seeds alice.
|
|
# Back-date her so she is unambiguously the platform's first administrator, which
|
|
# is what every primary-admin test (dbapi, backup download, container isolation)
|
|
# assumes. Without this, a combined unit+api run hands primary admin to a fixture
|
|
# user and those tests 403.
|
|
from devplacepy.database import invalidate_admins_cache as _invalidate_admins
|
|
|
|
alice_row = users_table.find_one(username="alice_test")
|
|
if alice_row:
|
|
users_table.update(
|
|
{"uid": alice_row["uid"], "created_at": PRIMARY_ADMIN_CREATED_AT}, ["uid"]
|
|
)
|
|
_invalidate_admins()
|
|
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
|
|
return {
|
|
"alice": {
|
|
"username": "alice_test",
|
|
"email": "alice@test.devplace",
|
|
"password": "secret123",
|
|
},
|
|
"bob": {
|
|
"username": "bob_test",
|
|
"email": "bob@test.devplace",
|
|
"password": "secret456",
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def alice(page, seeded_db):
|
|
login_user(page, seeded_db["alice"])
|
|
return page, seeded_db["alice"]
|
|
|
|
|
|
@pytest.fixture
|
|
def bob(browser, seeded_db):
|
|
ctx = browser.new_context(
|
|
viewport={"width": 1400, "height": 900},
|
|
permissions=["clipboard-read", "clipboard-write"],
|
|
)
|
|
p = ctx.new_page()
|
|
p.set_default_timeout(15000)
|
|
p.bring_to_front()
|
|
login_user(p, seeded_db["bob"])
|
|
yield p, seeded_db["bob"]
|
|
p.close()
|
|
ctx.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def mobile_page(browser, seeded_db):
|
|
ctx = browser.new_context(
|
|
viewport={"width": 412, "height": 840},
|
|
has_touch=True,
|
|
permissions=["clipboard-read", "clipboard-write"],
|
|
)
|
|
p = ctx.new_page()
|
|
p.set_default_timeout(15000)
|
|
p.bring_to_front()
|
|
login_user(p, seeded_db["alice"])
|
|
yield p, seeded_db["alice"]
|
|
p.close()
|
|
ctx.close()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def local_db():
|
|
from devplacepy.database import init_db, db
|
|
|
|
init_db()
|
|
return db
|