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
|
2026-08-08 13:52:02 +02:00
|
|
|
from tests.conftest import BASE_URL, run_async
|
2026-08-07 10:53:08 +02:00
|
|
|
|
|
|
|
|
OWNER = "user-owner"
|
|
|
|
|
OTHER = "user-other"
|
2026-08-08 13:52:02 +02:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
2026-08-08 13:52:02 +02:00
|
|
|
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():
|
2026-08-08 13:52:02 +02:00
|
|
|
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:
|
2026-08-08 13:52:02 +02:00
|
|
|
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("")
|
2026-08-08 13:52:02 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
2026-08-08 13:52:02 +02:00
|
|
|
},
|
|
|
|
|
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"]]
|