807 lines
28 KiB
Python
Raw Normal View History

2026-08-07 10:53:08 +02:00
# retoor <retoor@molodetz.nl>
Make dev workspaces serve a working browser IDE end to end The workspace feature shipped its routes, agent tools and docs, but the editor was never reachable: the project page had no entry point, the ppy image had no code-server binary, no certificate was ever requested for a tunnel, and both nginx and the proxy dropped what the editor needs. - Add a VS Code button to the project action row and a Workspace item to the overflow menu, gated by can_open_workspace plus a running instance (viewer_can_workspace and workspace_editor_url on ProjectDetailOut). - Install a pinned code-server in ppy.Dockerfile before USER pravda and assert it in the build smoke test, so an image that cannot run the editor no longer builds green. - Run the editor with --auth password and a per workspace 8 character pronounceable secret, minted once at the ensure_editor_password choke point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the admin listing shares. - Publish the editor tunnel when a workspace is created and issue its certificate from a new WorkspaceService phase against the molohttp admin API, then notify the owner with the live URL and the password. Only pending rows are retried, so a broken host cannot burn the ACME failure rate limit. Renewal stays molohttp's job. - Forward the original Host on proxied requests and the client cookie on proxied websockets, so code-server scopes its session cookie to the public hostname and authenticates the workbench socket. - Recreate a container stuck in the created state instead of retrying docker start forever against an image it can no longer run. - Return a JSON string from WorkspaceController.dispatch; raw dicts landed in a tool message and aborted the turn at the model endpoint. - Let the nginx catch-all carry websocket upgrades, keeping upstream keepalive, so tunnelled apps and the editor both connect.
2026-08-07 13:46:40 +02:00
import json
2026-08-07 10:53:08 +02:00
import pytest
from devplacepy.content import can_manage_workspace, can_open_workspace
from devplacepy.database import get_table, init_db, set_setting
Make dev workspaces serve a working browser IDE end to end The workspace feature shipped its routes, agent tools and docs, but the editor was never reachable: the project page had no entry point, the ppy image had no code-server binary, no certificate was ever requested for a tunnel, and both nginx and the proxy dropped what the editor needs. - Add a VS Code button to the project action row and a Workspace item to the overflow menu, gated by can_open_workspace plus a running instance (viewer_can_workspace and workspace_editor_url on ProjectDetailOut). - Install a pinned code-server in ppy.Dockerfile before USER pravda and assert it in the build smoke test, so an image that cannot run the editor no longer builds green. - Run the editor with --auth password and a per workspace 8 character pronounceable secret, minted once at the ensure_editor_password choke point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the admin listing shares. - Publish the editor tunnel when a workspace is created and issue its certificate from a new WorkspaceService phase against the molohttp admin API, then notify the owner with the live URL and the password. Only pending rows are retried, so a broken host cannot burn the ACME failure rate limit. Renewal stays molohttp's job. - Forward the original Host on proxied requests and the client cookie on proxied websockets, so code-server scopes its session cookie to the public hostname and authenticates the workbench socket. - Recreate a container stuck in the created state instead of retrying docker start forever against an image it can no longer run. - Return a JSON string from WorkspaceController.dispatch; raw dicts landed in a tool message and aborted the turn at the model endpoint. - Let the nginx catch-all carry websocket upgrades, keeping upstream keepalive, so tunnelled apps and the editor both connect.
2026-08-07 13:46:40 +02:00
from devplacepy.services.containers import activity, api, store
2026-08-07 10:53:08 +02:00
from devplacepy.services.containers.workspace import (
flags,
naming,
provision,
quota,
tunnels,
)
from devplacepy.services.containers.workspace.provision import WorkspaceError
from tests.conftest import BASE_URL, run_async
2026-08-07 10:53:08 +02:00
OWNER = "user-owner"
OTHER = "user-other"
# A workspace route that answers wrongly must fail the test, never wedge the serial suite.
HTTP_TIMEOUT = 30
2026-08-07 10:53:08 +02:00
@pytest.fixture(autouse=True)
def _workspace_db():
init_db()
set_setting("workspace_enabled", "1")
yield
for table in ("instances", "tunnels", "workspace_flags", "workspace_quota_rules"):
get_table(table).delete()
def _project(uid: str = "proj-ws") -> dict:
return {"uid": uid, "slug": "demo", "title": "Demo", "user_uid": OWNER}
def _instance(**overrides) -> dict:
row = {
"project_uid": "proj-ws",
"name": "ws-demo",
"status": "running",
"desired_state": "running",
"is_workspace": 1,
"workspace_owner_uid": OWNER,
"tunnel_name": naming.generate(),
"ports_json": '[{"host": 20500, "container": 8080, "proto": "tcp"}]',
}
row.update(overrides)
return store.create_instance(row)
def test_open_workspace_requires_enabled_setting():
set_setting("workspace_enabled", "0")
assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is False
set_setting("workspace_enabled", "1")
assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is True
def test_guest_can_never_open_workspace():
assert can_open_workspace(_project(), None) is False
assert can_open_workspace(_project(), {}) is False
def test_non_owner_member_cannot_open_workspace():
assert can_open_workspace(_project(), {"uid": OTHER, "role": "Member"}) is False
def test_admin_can_open_any_project_workspace():
assert can_open_workspace(_project(), {"uid": OTHER, "role": "Admin"}) is True
def test_owner_manages_own_workspace_without_admin():
instance = _instance()
assert can_manage_workspace(instance, _project(), {"uid": OWNER, "role": "Member"})
assert not can_manage_workspace(
instance, _project(), {"uid": OTHER, "role": "Member"}
)
def test_create_or_resume_is_idempotent():
project = _project()
user = {"uid": OWNER, "username": "owner"}
first = run_async(provision.ensure(project, user))
second = run_async(provision.ensure(project, user))
assert first["uid"] == second["uid"]
assert provision.count_for_owner(OWNER) == 1
def test_workspace_quota_blocks_beyond_limit():
set_setting("workspace_max_per_user", "1")
user = {"uid": OWNER, "username": "owner"}
run_async(provision.ensure(_project("p-a"), user))
with pytest.raises(WorkspaceError):
run_async(provision.ensure(_project("p-b"), user))
set_setting("workspace_max_per_user", "2")
def test_tunnel_revives_rather_than_duplicates():
instance = _instance()
first = tunnels.create(instance, "web", 8080, OWNER)
tunnels.soft_delete(first["uid"], OWNER)
assert tunnels.count_for_instance(instance["uid"]) == 0
revived = tunnels.create(instance, "web", 8080, OWNER)
assert revived["uid"] == first["uid"]
assert tunnels.count_for_instance(instance["uid"]) == 1
def test_tunnel_hostname_is_a_single_dns_label():
instance = _instance()
row = tunnels.create(instance, "web", 3000, OWNER)
host = row["hostname"]
suffix = "." + naming.domain()
assert host.endswith(suffix)
label = host[: -len(suffix)]
assert "." not in label
assert naming.is_valid_label(label)
def test_suspend_stops_tunnels_without_deleting_them():
instance = _instance()
tunnels.create(instance, "web", 8080, OWNER)
provision.suspend(instance, "admin-uid", "abuse")
rows = tunnels.list_for_instance(instance["uid"])
assert rows and rows[0]["status"] == "suspended"
refreshed = store.get_instance(instance["uid"])
assert refreshed["suspended_at"]
provision.unsuspend(refreshed)
assert tunnels.list_for_instance(instance["uid"])[0]["status"] == "pending"
def test_suspended_workspace_cannot_resume():
instance = _instance()
provision.suspend(instance, "admin-uid", "abuse")
with pytest.raises(WorkspaceError):
provision.resume(store.get_instance(instance["uid"]))
def test_flags_are_idempotent_while_open():
instance = _instance()
first = flags.raise_flag(instance, flags.KIND_EGRESS, "warn", "a", 1.0, 0.5)
second = flags.raise_flag(instance, flags.KIND_EGRESS, "warn", "b", 2.0, 0.5)
assert first["uid"] == second["uid"]
assert len(flags.list_flags(instance_uid=instance["uid"])) == 1
flags.clear_flag(instance["uid"], flags.KIND_EGRESS, "admin")
assert flags.list_flags(instance_uid=instance["uid"]) == []
def test_activity_accumulates_egress_and_requests():
instance = _instance()
activity.forget(instance["uid"])
for _ in range(3):
activity.touch(instance["uid"], egress_bytes=100)
activity.flush(instance["uid"])
row = store.get_instance(instance["uid"])
assert row["egress_bytes"] == 300
assert row["request_count"] == 3
assert row["last_active_at"]
def test_quota_rule_overrides_only_its_owner():
get_table("workspace_quota_rules").insert(
{
"uid": "rule-x",
"owner_kind": "user",
"owner_id": OWNER,
"label": "power",
"max_workspaces": 7,
"deleted_at": None,
"deleted_by": None,
}
)
assert quota.resolve(OWNER).max_workspaces == 7
assert quota.resolve(OTHER).max_workspaces == quota.resolve().max_workspaces
def test_workspace_env_contract_is_complete():
from devplacepy.services.containers import api
instance = _instance()
env = api.workspace_env(instance, "https://example.test")
for key in (
"DEVPLACE_WORKSPACE",
"DEVPLACE_WORKSPACE_UID",
"DEVPLACE_TUNNEL_NAME",
"DEVPLACE_TUNNEL_DOMAIN",
"DEVPLACE_TUNNEL_MANIFEST",
"VSCODE_PROXY_URI",
"DEVPLACE_EDITOR_PORT",
"DEVPLACE_QUOTA_DISK_MB",
"DEVPLACE_RETENTION_DAYS",
):
assert key in env, key
assert all(isinstance(value, str) for value in env.values())
assert "{{port}}" in env["VSCODE_PROXY_URI"]
def test_non_workspace_instance_gets_no_workspace_env():
from devplacepy.services.containers import api
env = api.workspace_env({"is_workspace": 0}, "https://example.test")
assert env == {"DEVPLACE_WORKSPACE": ""}
def test_devii_workspace_tools_are_role_gated():
from devplacepy.services.devii.registry import CATALOG
names = {a.name for a in CATALOG.actions if a.handler == "workspace"}
admin_only = {
a.name for a in CATALOG.actions if a.handler == "workspace" and a.requires_admin
}
guest = {s["function"]["name"] for s in CATALOG.tool_schemas_for(False, False)}
member = {s["function"]["name"] for s in CATALOG.tool_schemas_for(True, False)}
admin = {s["function"]["name"] for s in CATALOG.tool_schemas_for(True, True)}
assert not (names & guest)
assert not ((names & member) & admin_only)
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
Make dev workspaces serve a working browser IDE end to end The workspace feature shipped its routes, agent tools and docs, but the editor was never reachable: the project page had no entry point, the ppy image had no code-server binary, no certificate was ever requested for a tunnel, and both nginx and the proxy dropped what the editor needs. - Add a VS Code button to the project action row and a Workspace item to the overflow menu, gated by can_open_workspace plus a running instance (viewer_can_workspace and workspace_editor_url on ProjectDetailOut). - Install a pinned code-server in ppy.Dockerfile before USER pravda and assert it in the build smoke test, so an image that cannot run the editor no longer builds green. - Run the editor with --auth password and a per workspace 8 character pronounceable secret, minted once at the ensure_editor_password choke point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the admin listing shares. - Publish the editor tunnel when a workspace is created and issue its certificate from a new WorkspaceService phase against the molohttp admin API, then notify the owner with the live URL and the password. Only pending rows are retried, so a broken host cannot burn the ACME failure rate limit. Renewal stays molohttp's job. - Forward the original Host on proxied requests and the client cookie on proxied websockets, so code-server scopes its session cookie to the public hostname and authenticates the workbench socket. - Recreate a container stuck in the created state instead of retrying docker start forever against an image it can no longer run. - Return a JSON string from WorkspaceController.dispatch; raw dicts landed in a tool message and aborted the turn at the model endpoint. - Let the nginx catch-all carry websocket upgrades, keeping upstream keepalive, so tunnelled apps and the editor both connect.
2026-08-07 13:46:40 +02:00
def test_opening_a_workspace_publishes_the_editor_tunnel_automatically():
project = _project()
user = {"uid": OWNER, "username": "owner"}
instance = run_async(provision.ensure(project, user))
rows = tunnels.list_for_instance(instance["uid"])
assert len(rows) == 1
editor = rows[0]
assert editor["container_port"] == api.EDITOR_DEFAULT_PORT
assert editor["hostname"].startswith(f"{api.EDITOR_DEFAULT_PORT}-")
assert editor["status"] == tunnels.STATUS_PENDING
assert provision.is_editor_tunnel(instance, editor)
assert instance["editor_password"]
def test_active_editor_tunnel_notifies_the_owner_with_url_and_password():
from devplacepy.services.containers.workspace import certs
Make dev workspaces serve a working browser IDE end to end The workspace feature shipped its routes, agent tools and docs, but the editor was never reachable: the project page had no entry point, the ppy image had no code-server binary, no certificate was ever requested for a tunnel, and both nginx and the proxy dropped what the editor needs. - Add a VS Code button to the project action row and a Workspace item to the overflow menu, gated by can_open_workspace plus a running instance (viewer_can_workspace and workspace_editor_url on ProjectDetailOut). - Install a pinned code-server in ppy.Dockerfile before USER pravda and assert it in the build smoke test, so an image that cannot run the editor no longer builds green. - Run the editor with --auth password and a per workspace 8 character pronounceable secret, minted once at the ensure_editor_password choke point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the admin listing shares. - Publish the editor tunnel when a workspace is created and issue its certificate from a new WorkspaceService phase against the molohttp admin API, then notify the owner with the live URL and the password. Only pending rows are retried, so a broken host cannot burn the ACME failure rate limit. Renewal stays molohttp's job. - Forward the original Host on proxied requests and the client cookie on proxied websockets, so code-server scopes its session cookie to the public hostname and authenticates the workbench socket. - Recreate a container stuck in the created state instead of retrying docker start forever against an image it can no longer run. - Return a JSON string from WorkspaceController.dispatch; raw dicts landed in a tool message and aborted the turn at the model endpoint. - Let the nginx catch-all carry websocket upgrades, keeping upstream keepalive, so tunnelled apps and the editor both connect.
2026-08-07 13:46:40 +02:00
project = _project()
instance = run_async(provision.ensure(project, {"uid": OWNER, "username": "o"}))
row = tunnels.list_for_instance(instance["uid"])[0]
try:
certs.announce(row, row["hostname"])
Make dev workspaces serve a working browser IDE end to end The workspace feature shipped its routes, agent tools and docs, but the editor was never reachable: the project page had no entry point, the ppy image had no code-server binary, no certificate was ever requested for a tunnel, and both nginx and the proxy dropped what the editor needs. - Add a VS Code button to the project action row and a Workspace item to the overflow menu, gated by can_open_workspace plus a running instance (viewer_can_workspace and workspace_editor_url on ProjectDetailOut). - Install a pinned code-server in ppy.Dockerfile before USER pravda and assert it in the build smoke test, so an image that cannot run the editor no longer builds green. - Run the editor with --auth password and a per workspace 8 character pronounceable secret, minted once at the ensure_editor_password choke point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the admin listing shares. - Publish the editor tunnel when a workspace is created and issue its certificate from a new WorkspaceService phase against the molohttp admin API, then notify the owner with the live URL and the password. Only pending rows are retried, so a broken host cannot burn the ACME failure rate limit. Renewal stays molohttp's job. - Forward the original Host on proxied requests and the client cookie on proxied websockets, so code-server scopes its session cookie to the public hostname and authenticates the workbench socket. - Recreate a container stuck in the created state instead of retrying docker start forever against an image it can no longer run. - Return a JSON string from WorkspaceController.dispatch; raw dicts landed in a tool message and aborted the turn at the model endpoint. - Let the nginx catch-all carry websocket upgrades, keeping upstream keepalive, so tunnelled apps and the editor both connect.
2026-08-07 13:46:40 +02:00
sent = list(
get_table("notifications").find(
user_uid=OWNER, type="workspace", order_by=["-id"]
)
)
assert sent, "owner was not notified"
message = sent[0]["message"]
assert f"https://{row['hostname']}" in message
assert instance["editor_password"] in message
assert sent[0]["target_url"].endswith("/workspace")
finally:
get_table("notifications").delete(user_uid=OWNER)
def test_editor_password_is_pronounceable_and_eight_chars():
seen = {api.generate_editor_password() for _ in range(50)}
assert len(seen) > 40
for password in seen:
assert len(password) == 8
assert password.isalpha() and password.islower()
for index, char in enumerate(password):
expected = (
api.EDITOR_PASSWORD_CONSONANTS
if index % 2 == 0
else api.EDITOR_PASSWORD_VOWELS
)
assert char in expected
def test_editor_runs_with_password_auth_and_an_eight_char_secret():
from devplacepy.services.containers import api
instance = _instance()
password = api.ensure_editor_password(instance)
assert len(password) == api.EDITOR_PASSWORD_LENGTH == 8
assert password.isalnum()
command = api.editor_command(store.get_instance(instance["uid"]))
assert "--auth" in command
assert command[command.index("--auth") + 1] == "password"
assert "none" not in command
def test_editor_password_is_stable_and_reaches_the_container_env():
from devplacepy.services.containers import api
instance = _instance()
first = api.ensure_editor_password(instance)
again = api.ensure_editor_password(store.get_instance(instance["uid"]))
assert first == again
env = api.pravda_env(store.get_instance(instance["uid"]))
assert env["PASSWORD"] == first
def test_every_workspace_launch_gets_a_password_even_if_never_provisioned():
from devplacepy.services.containers import api
instance = _instance()
store.update_instance(instance["uid"], {"editor_password": ""})
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
assert len(spec.env["PASSWORD"]) == 8
assert store.get_instance(instance["uid"])["editor_password"] == spec.env["PASSWORD"]
def test_non_workspace_instance_never_gets_an_editor_password():
from devplacepy.services.containers import api
instance = _instance(is_workspace=0)
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
assert not spec.env.get("PASSWORD")
def test_awaiting_certificate_only_returns_pending_present_tunnels():
instance = _instance()
tunnels.create(instance, "web", 8080, OWNER)
tunnels.create(instance, "api", 9090, OWNER)
active = tunnels.list_for_instance(instance["uid"])[0]
tunnels.update(active["uid"], {"status": tunnels.STATUS_ACTIVE})
waiting = [row["uid"] for row in tunnels.awaiting_certificate()]
assert active["uid"] not in waiting
assert len(waiting) == 1
absent = tunnels.get(waiting[0])
tunnels.mark_absent(absent["uid"])
assert not tunnels.awaiting_certificate()
def test_certificate_phase_marks_active_on_success_and_failed_on_error(monkeypatch):
from devplacepy.services.containers.workspace import certs
from devplacepy.services.containers.workspace_service import WorkspaceService
instance = _instance()
tunnels.create(instance, "web", 8080, OWNER)
row = tunnels.awaiting_certificate()[0]
service = WorkspaceService()
monkeypatch.setattr(certs, "configured", lambda: True)
async def _ok(hostname):
return None
monkeypatch.setattr(certs, "issue", _ok)
run_async(service._issue_tunnel_certificates())
assert tunnels.get(row["uid"])["status"] == tunnels.STATUS_ACTIVE
tunnels.update(row["uid"], {"status": tunnels.STATUS_PENDING})
async def _boom(hostname):
raise certs.CertError("molohttp cert issue failed (503): upstream down")
monkeypatch.setattr(certs, "issue", _boom)
run_async(service._issue_tunnel_certificates())
failed = tunnels.get(row["uid"])
assert failed["status"] == tunnels.STATUS_FAILED
assert "503" in failed["last_error"]
def test_certificate_phase_is_a_noop_without_molohttp_credentials(monkeypatch):
from devplacepy.services.containers.workspace import certs
from devplacepy.services.containers.workspace_service import WorkspaceService
instance = _instance()
tunnels.create(instance, "web", 8080, OWNER)
row = tunnels.awaiting_certificate()[0]
monkeypatch.setattr(certs, "configured", lambda: False)
run_async(WorkspaceService()._issue_tunnel_certificates())
assert tunnels.get(row["uid"])["status"] == tunnels.STATUS_PENDING
def test_workspace_controller_always_returns_a_json_string():
from devplacepy.services.devii.registry import CATALOG
from devplacepy.services.devii.workspace.controller import WorkspaceController
controller = WorkspaceController("user", OWNER, admin=True)
names = [a.name for a in CATALOG.actions if a.handler == "workspace"]
assert names
for name in names + ["workspace_does_not_exist"]:
result = run_async(controller.dispatch(name, {}))
assert isinstance(result, str), f"{name} returned {type(result).__name__}"
json.loads(result)
2026-08-07 10:53:08 +02:00
def test_every_confirm_gated_tool_declares_a_confirm_param():
from devplacepy.services.devii.actions.dispatcher import CONFIRM_REQUIRED
from devplacepy.services.devii.registry import CATALOG
for action in CATALOG.actions:
if action.handler != "workspace" or action.name not in CONFIRM_REQUIRED:
continue
assert any(p.name == "confirm" for p in action.params), action.name
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"]]