forked from retoor/devplacepy
Order the workspace editor certificate at creation
A new tunnel row was inert at pending until the next WorkspaceService tick picked it up, so the editor certificate waited up to 30 seconds before the ACME order even started. Opening a workspace and reaching its public hostname in that window served the wildcard default certificate and produced ERR_CERT_COMMON_NAME_INVALID in the browser. certs.certify now owns the whole transition - provisioning, issue, active or failed, then the owner notification - and both callers use it. provision.ensure schedules it for the editor tunnel the moment the workspace exists, claiming the row synchronously so the tick can never issue the same host twice and burn a duplicate-certificate slot. The service loop keeps calling certify per pending row as the safety net for user-created tunnels and for rows created while molohttp was unconfigured. Issuance is a loop task rather than an await: an order takes about ten seconds and certs.issue allows up to 180, which would hang the workspace POST. The task is held until it completes so it cannot be collected mid flight. resume also restarts the idle clock, and the workspace routes gain HTTP-level tests covering every refusal path they had none for.
This commit is contained in:
@@ -15,10 +15,12 @@ from devplacepy.services.containers.workspace import (
|
||||
tunnels,
|
||||
)
|
||||
from devplacepy.services.containers.workspace.provision import WorkspaceError
|
||||
from tests.conftest import run_async
|
||||
from tests.conftest import BASE_URL, run_async
|
||||
|
||||
OWNER = "user-owner"
|
||||
OTHER = "user-other"
|
||||
# A workspace route that answers wrongly must fail the test, never wedge the serial suite.
|
||||
HTTP_TIMEOUT = 30
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -216,6 +218,120 @@ def test_devii_workspace_tools_are_role_gated():
|
||||
assert names <= admin
|
||||
|
||||
|
||||
def _idle_for(minutes: float) -> str:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
return (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||
).isoformat()
|
||||
|
||||
|
||||
def _lifecycle(rows, stop="auto", delete="auto"):
|
||||
from devplacepy.services.containers.workspace_service import WorkspaceService
|
||||
|
||||
WorkspaceService()._advance_lifecycle(
|
||||
rows, {"workspace_auto_stop": stop, "workspace_auto_delete": delete}
|
||||
)
|
||||
|
||||
|
||||
def _reload(uid: str) -> dict:
|
||||
return store.get_instance(uid)
|
||||
|
||||
|
||||
def test_resuming_restarts_the_idle_clock():
|
||||
instance = _instance(status="stopped", desired_state="stopped")
|
||||
store.update_instance(instance["uid"], {"last_active_at": _idle_for(600)})
|
||||
resumed = provision.resume(_reload(instance["uid"]))
|
||||
assert resumed["desired_state"] == "running"
|
||||
|
||||
running = dict(resumed, status="running")
|
||||
_lifecycle([running])
|
||||
assert _reload(instance["uid"])["desired_state"] == "running"
|
||||
|
||||
|
||||
def test_idle_workspace_is_warned_then_stopped():
|
||||
instance = _instance(status="running", desired_state="running")
|
||||
store.update_instance(instance["uid"], {"last_active_at": _idle_for(50)})
|
||||
_lifecycle([_reload(instance["uid"])])
|
||||
warned = _reload(instance["uid"])
|
||||
assert warned["idle_warned_at"]
|
||||
assert warned["desired_state"] == "running"
|
||||
|
||||
store.update_instance(instance["uid"], {"last_active_at": _idle_for(90)})
|
||||
_lifecycle([_reload(instance["uid"])])
|
||||
stopped = _reload(instance["uid"])
|
||||
assert stopped["desired_state"] == "stopped"
|
||||
assert not stopped["idle_warned_at"]
|
||||
|
||||
|
||||
def test_fresh_workspace_is_left_alone():
|
||||
instance = _instance(status="running", desired_state="running")
|
||||
store.update_instance(instance["uid"], {"last_active_at": _idle_for(1)})
|
||||
_lifecycle([_reload(instance["uid"])])
|
||||
fresh = _reload(instance["uid"])
|
||||
assert fresh["desired_state"] == "running"
|
||||
assert not fresh["idle_warned_at"]
|
||||
|
||||
|
||||
def test_auto_stop_off_never_stops_an_idle_workspace():
|
||||
instance = _instance(status="running", desired_state="running")
|
||||
store.update_instance(instance["uid"], {"last_active_at": _idle_for(900)})
|
||||
_lifecycle([_reload(instance["uid"])], stop="off")
|
||||
assert _reload(instance["uid"])["desired_state"] == "running"
|
||||
|
||||
|
||||
def test_suspended_workspace_is_skipped_entirely():
|
||||
instance = _instance(status="running", desired_state="running")
|
||||
store.update_instance(
|
||||
instance["uid"],
|
||||
{
|
||||
"last_active_at": _idle_for(60 * 24 * 400),
|
||||
"suspended_at": "2026-01-01T00:00:00+00:00",
|
||||
},
|
||||
)
|
||||
_lifecycle([_reload(instance["uid"])])
|
||||
survivor = _reload(instance["uid"])
|
||||
assert survivor is not None
|
||||
assert survivor["desired_state"] == "running"
|
||||
|
||||
|
||||
def test_workspace_without_activity_is_never_touched():
|
||||
instance = _instance(status="running", desired_state="running")
|
||||
store.update_instance(instance["uid"], {"last_active_at": ""})
|
||||
_lifecycle([_reload(instance["uid"])])
|
||||
assert _reload(instance["uid"])["desired_state"] == "running"
|
||||
|
||||
|
||||
def test_stopped_workspace_past_retention_is_deleted_with_its_tunnels():
|
||||
instance = _instance(status="stopped", desired_state="stopped")
|
||||
tunnels.create(instance, "web", 8080, OWNER)
|
||||
store.update_instance(
|
||||
instance["uid"], {"last_active_at": _idle_for(60 * 24 * 20)}
|
||||
)
|
||||
_lifecycle([_reload(instance["uid"])])
|
||||
assert _reload(instance["uid"]) is None
|
||||
for row in tunnels.list_for_instance(instance["uid"]):
|
||||
assert row["status"] == tunnels.STATUS_SUSPENDED
|
||||
|
||||
|
||||
def test_running_workspace_is_never_deleted_by_retention():
|
||||
instance = _instance(status="running", desired_state="running")
|
||||
store.update_instance(
|
||||
instance["uid"], {"last_active_at": _idle_for(60 * 24 * 400)}
|
||||
)
|
||||
_lifecycle([_reload(instance["uid"])], stop="off")
|
||||
assert _reload(instance["uid"]) is not None
|
||||
|
||||
|
||||
def test_auto_delete_off_keeps_an_expired_workspace():
|
||||
instance = _instance(status="stopped", desired_state="stopped")
|
||||
store.update_instance(
|
||||
instance["uid"], {"last_active_at": _idle_for(60 * 24 * 400)}
|
||||
)
|
||||
_lifecycle([_reload(instance["uid"])], delete="off")
|
||||
assert _reload(instance["uid"]) is not None
|
||||
|
||||
|
||||
def test_opening_a_workspace_publishes_the_editor_tunnel_automatically():
|
||||
project = _project()
|
||||
user = {"uid": OWNER, "username": "owner"}
|
||||
@@ -231,13 +347,13 @@ def test_opening_a_workspace_publishes_the_editor_tunnel_automatically():
|
||||
|
||||
|
||||
def test_active_editor_tunnel_notifies_the_owner_with_url_and_password():
|
||||
from devplacepy.services.containers.workspace_service import WorkspaceService
|
||||
from devplacepy.services.containers.workspace import certs
|
||||
|
||||
project = _project()
|
||||
instance = run_async(provision.ensure(project, {"uid": OWNER, "username": "o"}))
|
||||
row = tunnels.list_for_instance(instance["uid"])[0]
|
||||
try:
|
||||
WorkspaceService()._announce_tunnel(row, row["hostname"])
|
||||
certs.announce(row, row["hostname"])
|
||||
sent = list(
|
||||
get_table("notifications").find(
|
||||
user_uid=OWNER, type="workspace", order_by=["-id"]
|
||||
@@ -395,3 +511,296 @@ def test_tunnel_host_detection_never_matches_the_site():
|
||||
assert naming.is_tunnel_host("abc." + naming.domain())
|
||||
assert not naming.is_tunnel_host("pravda.education")
|
||||
assert not naming.is_tunnel_host("")
|
||||
|
||||
|
||||
def _seeded_user(username: str) -> dict:
|
||||
from devplacepy.database import db, refresh_snapshot
|
||||
|
||||
refresh_snapshot()
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT uid, username, api_key FROM users WHERE username = :username",
|
||||
username=username,
|
||||
)
|
||||
)
|
||||
assert rows, f"user {username} was not created by the server"
|
||||
return rows[0]
|
||||
|
||||
|
||||
def _fresh_member() -> dict:
|
||||
import time as _time
|
||||
|
||||
import requests
|
||||
|
||||
name = f"wsmem{int(_time.time() * 1000)}"
|
||||
requests.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
allow_redirects=True,
|
||||
timeout=HTTP_TIMEOUT,
|
||||
)
|
||||
return _seeded_user(name)
|
||||
|
||||
|
||||
def _http_project(owner_uid: str, title: str) -> dict:
|
||||
from devplacepy.utils import generate_uid, make_combined_slug
|
||||
|
||||
uid = generate_uid()
|
||||
row = {
|
||||
"uid": uid,
|
||||
"user_uid": owner_uid,
|
||||
"title": title,
|
||||
"description": "workspace host project",
|
||||
"slug": make_combined_slug(title, uid),
|
||||
"project_type": "software",
|
||||
"status": "In Development",
|
||||
"platforms": "",
|
||||
"is_private": 0,
|
||||
"read_only": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
get_table("projects").insert(row)
|
||||
_CREATED_PROJECTS.append(uid)
|
||||
return row
|
||||
|
||||
|
||||
_CREATED_PROJECTS: list = []
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _drop_created_projects():
|
||||
yield
|
||||
while _CREATED_PROJECTS:
|
||||
get_table("projects").delete(uid=_CREATED_PROJECTS.pop())
|
||||
|
||||
|
||||
def _await_workspaces_enabled(slug: str, key: str) -> bool:
|
||||
import time as _time
|
||||
|
||||
import requests
|
||||
|
||||
deadline = _time.time() + 10.0
|
||||
headers = {"Accept": "application/json", "X-API-KEY": key}
|
||||
while _time.time() < deadline:
|
||||
body = requests.get(
|
||||
f"{BASE_URL}/projects/{slug}", headers=headers, timeout=HTTP_TIMEOUT
|
||||
).json()
|
||||
if body.get("viewer_can_workspace"):
|
||||
return True
|
||||
_time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def _post(path: str, key: str, **kwargs):
|
||||
import requests
|
||||
|
||||
return requests.post(
|
||||
f"{BASE_URL}{path}",
|
||||
headers={"Accept": "application/json", "X-API-KEY": key},
|
||||
timeout=HTTP_TIMEOUT,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_opening_a_workspace_over_quota_answers_400_not_500(app_server, seeded_db):
|
||||
owner = _seeded_user("bob_test")
|
||||
limit = quota.resolve(owner["uid"]).max_workspaces
|
||||
for index in range(limit):
|
||||
_instance(
|
||||
project_uid=f"proj-quota-{index}",
|
||||
name=f"ws-quota-{index}",
|
||||
workspace_owner_uid=owner["uid"],
|
||||
)
|
||||
project = _http_project(owner["uid"], "WS Quota Http")
|
||||
assert _await_workspaces_enabled(project["slug"], owner["api_key"])
|
||||
|
||||
response = _post(f"/projects/{project['slug']}/workspace", owner["api_key"])
|
||||
|
||||
assert response.status_code == 400, response.text[:400]
|
||||
assert "limit" in response.json()["error"]["message"]
|
||||
assert provision.count_for_owner(owner["uid"]) == limit
|
||||
|
||||
|
||||
def test_opening_a_workspace_under_quota_is_not_refused(app_server, seeded_db):
|
||||
owner = _seeded_user("bob_test")
|
||||
project = _http_project(owner["uid"], "WS Room Http")
|
||||
assert _await_workspaces_enabled(project["slug"], owner["api_key"])
|
||||
|
||||
response = _post(f"/projects/{project['slug']}/workspace", owner["api_key"])
|
||||
|
||||
assert response.status_code != 500, response.text[:400]
|
||||
assert response.status_code != 400 or "limit" not in response.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_tunnel_creation_rejects_a_zero_port_with_400(app_server, seeded_db):
|
||||
owner = _seeded_user("bob_test")
|
||||
project = _http_project(owner["uid"], "WS Port Http")
|
||||
_instance(project_uid=project["uid"], workspace_owner_uid=owner["uid"])
|
||||
assert _await_workspaces_enabled(project["slug"], owner["api_key"])
|
||||
|
||||
response = _post(
|
||||
f"/projects/{project['slug']}/workspace/tunnels",
|
||||
owner["api_key"],
|
||||
data={"label": "web", "container_port": "0"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text[:400]
|
||||
assert "container_port" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_tunnel_limit_answers_400(app_server, seeded_db):
|
||||
owner = _seeded_user("bob_test")
|
||||
project = _http_project(owner["uid"], "WS Tunnel Cap Http")
|
||||
instance = _instance(project_uid=project["uid"], workspace_owner_uid=owner["uid"])
|
||||
limit = quota.resolve(owner["uid"], instance).max_tunnels
|
||||
for port in range(9000, 9000 + limit):
|
||||
tunnels.create(instance, f"t{port}", port, owner["uid"])
|
||||
assert _await_workspaces_enabled(project["slug"], owner["api_key"])
|
||||
|
||||
response = _post(
|
||||
f"/projects/{project['slug']}/workspace/tunnels",
|
||||
owner["api_key"],
|
||||
data={"label": "over", "container_port": "9999"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text[:400]
|
||||
assert "tunnel limit reached" in response.json()["error"]["message"]
|
||||
assert tunnels.count_for_instance(instance["uid"]) == limit
|
||||
|
||||
|
||||
def test_editor_proxy_denies_a_stranger_with_403(app_server, seeded_db):
|
||||
import requests
|
||||
|
||||
owner = _seeded_user("bob_test")
|
||||
stranger = _fresh_member()
|
||||
project = _http_project(owner["uid"], "WS Editor Http")
|
||||
instance = _instance(project_uid=project["uid"], workspace_owner_uid=owner["uid"])
|
||||
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/projects/{project['slug']}/containers/instances/{instance['uid']}/code/",
|
||||
headers={"Accept": "application/json", "X-API-KEY": stranger["api_key"]},
|
||||
timeout=HTTP_TIMEOUT,
|
||||
)
|
||||
|
||||
assert response.status_code == 403, response.text[:400]
|
||||
|
||||
|
||||
def test_admin_suspend_without_a_reason_answers_400(app_server, seeded_db):
|
||||
admin = _seeded_user("alice_test")
|
||||
owner = _seeded_user("bob_test")
|
||||
project = _http_project(owner["uid"], "WS Suspend Http")
|
||||
instance = _instance(project_uid=project["uid"], workspace_owner_uid=owner["uid"])
|
||||
|
||||
response = _post(
|
||||
f"/admin/workspaces/{instance['uid']}/suspend",
|
||||
admin["api_key"],
|
||||
data={"reason": " "},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text[:400]
|
||||
assert "reason" in response.json()["error"]["message"]
|
||||
assert not store.get_instance(instance["uid"])["suspended_at"]
|
||||
|
||||
|
||||
def test_admin_quota_rule_without_an_owner_answers_400(app_server, seeded_db):
|
||||
admin = _seeded_user("alice_test")
|
||||
|
||||
response = _post(
|
||||
"/admin/workspaces/quota", admin["api_key"], data={"owner_id": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text[:400]
|
||||
assert "owner_id" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
def _certified(project: dict, monkeypatch, issue) -> dict:
|
||||
from devplacepy.services.containers.workspace import certs
|
||||
|
||||
monkeypatch.setattr(certs, "configured", lambda: True)
|
||||
monkeypatch.setattr(certs, "issue", issue)
|
||||
|
||||
async def _open_and_drain() -> dict:
|
||||
import asyncio
|
||||
|
||||
instance = await provision.ensure(project, {"uid": OWNER, "username": "o"})
|
||||
pending = list(provision._pending_certificates)
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
return instance
|
||||
|
||||
return run_async(_open_and_drain())
|
||||
|
||||
|
||||
def test_opening_a_workspace_certifies_its_editor_tunnel_without_waiting_for_a_tick(
|
||||
monkeypatch,
|
||||
):
|
||||
ordered = []
|
||||
|
||||
async def _ok(hostname: str) -> None:
|
||||
ordered.append(hostname)
|
||||
|
||||
instance = _certified(_project(), monkeypatch, _ok)
|
||||
|
||||
row = tunnels.list_for_instance(instance["uid"])[0]
|
||||
assert ordered == [row["hostname"]], "the editor certificate was not ordered eagerly"
|
||||
assert row["status"] == tunnels.STATUS_ACTIVE
|
||||
assert not tunnels.awaiting_certificate()
|
||||
|
||||
|
||||
def test_eagerly_certified_editor_tunnel_still_announces_url_and_password(monkeypatch):
|
||||
async def _ok(hostname: str) -> None:
|
||||
return None
|
||||
|
||||
instance = _certified(_project(), monkeypatch, _ok)
|
||||
row = tunnels.list_for_instance(instance["uid"])[0]
|
||||
try:
|
||||
sent = list(
|
||||
get_table("notifications").find(
|
||||
user_uid=OWNER, type="workspace", order_by=["-id"]
|
||||
)
|
||||
)
|
||||
assert sent, "owner was not notified after eager issuance"
|
||||
assert f"https://{row['hostname']}" in sent[0]["message"]
|
||||
assert instance["editor_password"] in sent[0]["message"]
|
||||
finally:
|
||||
get_table("notifications").delete(user_uid=OWNER)
|
||||
|
||||
|
||||
def test_an_eager_certificate_failure_leaves_the_tunnel_failed_not_pending(monkeypatch):
|
||||
from devplacepy.services.containers.workspace import certs
|
||||
|
||||
async def _boom(hostname: str) -> None:
|
||||
raise certs.CertError("molohttp cert issue failed (503): upstream down")
|
||||
|
||||
instance = _certified(_project(), monkeypatch, _boom)
|
||||
|
||||
row = tunnels.list_for_instance(instance["uid"])[0]
|
||||
assert row["status"] == tunnels.STATUS_FAILED
|
||||
assert "upstream down" in row["last_error"]
|
||||
assert not tunnels.awaiting_certificate()
|
||||
|
||||
|
||||
def test_no_certificate_is_ordered_when_molohttp_is_unconfigured(monkeypatch):
|
||||
from devplacepy.services.containers.workspace import certs
|
||||
|
||||
ordered = []
|
||||
|
||||
async def _ok(hostname: str) -> None:
|
||||
ordered.append(hostname)
|
||||
|
||||
monkeypatch.setattr(certs, "configured", lambda: False)
|
||||
monkeypatch.setattr(certs, "issue", _ok)
|
||||
instance = run_async(provision.ensure(_project(), {"uid": OWNER, "username": "o"}))
|
||||
|
||||
assert ordered == []
|
||||
row = tunnels.list_for_instance(instance["uid"])[0]
|
||||
assert row["status"] == tunnels.STATUS_PENDING
|
||||
assert [r["uid"] for r in tunnels.awaiting_certificate()] == [row["uid"]]
|
||||
|
||||
Reference in New Issue
Block a user