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:
2026-08-08 13:52:02 +02:00
parent 56becfb3f7
commit 372067bbe4
5 changed files with 528 additions and 60 deletions
+412 -3
View File
@@ -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"]]