809 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",
Add the trust and safety subsystem and the App Store compliance work Implements the moderation and consent obligations a social platform carries, so the web version and any client that speaks to it enforce the same rules. Moderation core (services/moderation/, database/moderation.py): a reportable target registry, the content filter and its choke points, the report queue with atomic resolution, enforcement actions, consent tracking, maturity gating, and account deletion with a grace window. Surfaces: POST /reports plus the member report list, /admin/moderation and the per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and account deletion under /profile, the report button and dialog partials, the maturity gate, and the moderation stylesheet and ReportDialog client. Every user-generated surface stays reportable by construction: new content tables are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a reason, and the registry test fails the suite on anything left unclassified. Docs: community guidelines, content moderation, intellectual property, privacy, terms, contact, and the admin-only moderation operations page, plus the moderation API group and the Devii moderation actions. Compliance record: applecomp.md is the requirement register, applechanges.md the gap analysis against this codebase, and appleimpl.md the implementation design they resolve to. Tests cover the report flow, admin moderation, consent, account deletion, terms acceptance, workspaces, and the registry invariant across the unit, api, and e2e tiers.
2026-08-09 00:18:20 +02:00
"birth_date": "1990-01-01",
"accept_terms": "1",
},
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"]]