2026-06-13 16:32:33 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
2026-06-09 06:41:27 +02:00
|
|
|
|
2026-06-13 16:32:33 +02:00
|
|
|
from datetime import timedelta
|
2026-06-09 06:41:27 +02:00
|
|
|
import pytest
|
|
|
|
|
import requests
|
2026-06-09 20:02:50 +02:00
|
|
|
from tests.conftest import BASE_URL, run_async
|
2026-06-09 06:41:27 +02:00
|
|
|
from devplacepy.database import db, get_table, init_db, refresh_snapshot
|
2026-06-14 16:46:36 +02:00
|
|
|
from devplacepy.utils import generate_uid
|
2026-06-09 16:06:02 +02:00
|
|
|
from devplacepy import config, project_files
|
Make the workspace editor reachable through the sub-path proxy
code-server runs authenticateOrigin on every websocket and resolves the
request host as Forwarded, then X-Forwarded-Host, then Host. The forward
core put the public host into additional_headers, but the websockets
client already writes its own Host for the real TCP target and Headers
appends, so the handshake carried two Host lines; Node keeps the first
(the internal gateway:port), the origin check failed, and code-server
answered 403. Because the browser socket was accepted before the upstream
was dialled, that surfaced as a 101 followed by 1011 and the editor died
on "the workbench failed to connect to the server". Dialling first and
carrying the public host in the connect URI fixes both planes.
The two header builders that had drifted apart are now one core, so a
websocket carries the same client and forwarded headers as an HTTP
request. Responses stream instead of buffering whole, which is what makes
a large tunnel download cost constant memory and lets SSE work; byte
accounting moved onto the completion callback. Subprotocols negotiate,
the upstream client is reused across requests, and the path and query are
forwarded byte-exactly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:26:17 +02:00
|
|
|
from starlette.requests import Request
|
|
|
|
|
from starlette.websockets import WebSocket
|
|
|
|
|
from devplacepy.services.containers import api, forward, store, runtime
|
2026-06-09 06:41:27 +02:00
|
|
|
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
|
|
|
|
|
from devplacepy.services.containers.backend.docker_cli import build_run_argv, parse_size
|
|
|
|
|
from devplacepy.services.containers.backend.fake import FakeBackend
|
|
|
|
|
from devplacepy.services.containers.service import ContainerService
|
|
|
|
|
from devplacepy.services.containers.api import INSTANCE_LABEL
|
2026-06-09 20:02:50 +02:00
|
|
|
from devplacepy.services.devii.tasks.schedule import Schedule, now_utc
|
2026-06-09 18:48:08 +02:00
|
|
|
_CONTAINER_TABLES = (
|
|
|
|
|
"instances",
|
|
|
|
|
"instance_events",
|
|
|
|
|
"instance_metrics",
|
|
|
|
|
"instance_schedules",
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
@pytest.fixture(autouse=True)
|
2026-06-13 16:32:33 +02:00
|
|
|
def _init_db_containers():
|
2026-06-09 06:41:27 +02:00
|
|
|
init_db()
|
|
|
|
|
yield
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def env(tmp_path, monkeypatch):
|
|
|
|
|
fake = FakeBackend()
|
|
|
|
|
runtime.set_backend(fake)
|
|
|
|
|
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path / "ws")
|
|
|
|
|
pid = "ctest-p1"
|
|
|
|
|
project = {"uid": pid, "slug": "ctest", "title": "C", "user_uid": "ctest-u1"}
|
|
|
|
|
user = {"uid": "ctest-u1", "username": "ctestadmin"}
|
2026-06-14 16:46:36 +02:00
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": user["uid"],
|
|
|
|
|
"username": user["username"],
|
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
|
|
|
"terms_version": "1",
|
2026-06-14 16:46:36 +02:00
|
|
|
"email": "ctestadmin@example.com",
|
|
|
|
|
"api_key": generate_uid(),
|
|
|
|
|
"password_hash": "",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"level": 1,
|
|
|
|
|
"xp": 0,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
refresh_snapshot()
|
2026-06-09 06:41:27 +02:00
|
|
|
project_files.write_text_file(pid, user, "app.py", "print(1)\n")
|
|
|
|
|
yield {"fake": fake, "project": project, "user": user}
|
|
|
|
|
runtime.set_backend(None)
|
2026-06-14 16:46:36 +02:00
|
|
|
get_table("users").delete(uid=user["uid"])
|
2026-06-09 16:06:02 +02:00
|
|
|
for table in _CONTAINER_TABLES:
|
2026-06-09 06:41:27 +02:00
|
|
|
if table in db.tables:
|
2026-06-09 16:06:02 +02:00
|
|
|
for row in [r for r in get_table(table).find()]:
|
|
|
|
|
if str(row.get("project_uid", "")).startswith("ctest"):
|
2026-06-09 06:41:27 +02:00
|
|
|
get_table(table).delete(uid=row["uid"])
|
|
|
|
|
for row in list(get_table("project_files").find()):
|
|
|
|
|
if str(row.get("project_uid", "")).startswith("ctest"):
|
|
|
|
|
get_table("project_files").delete(uid=row["uid"])
|
2026-06-13 16:32:33 +02:00
|
|
|
def _ready_instance(env, **kwargs):
|
|
|
|
|
return run_async(
|
|
|
|
|
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
|
|
|
|
|
)
|
|
|
|
|
def _promote_admin(username: str) -> None:
|
|
|
|
|
users = get_table("users")
|
|
|
|
|
user = users.find_one(username=username)
|
|
|
|
|
if user:
|
|
|
|
|
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
|
|
|
|
|
def _api_key(username: str) -> str:
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
return get_table("users").find_one(username=username)["api_key"]
|
2026-06-09 06:41:27 +02:00
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-09 06:41:27 +02:00
|
|
|
def test_build_run_argv_exact():
|
2026-06-09 18:48:08 +02:00
|
|
|
spec = RunSpec(
|
|
|
|
|
image="ppy:latest",
|
|
|
|
|
name="inst",
|
|
|
|
|
labels={INSTANCE_LABEL: "u1"},
|
|
|
|
|
env={"A": "B"},
|
|
|
|
|
cpu_limit="1.5",
|
|
|
|
|
mem_limit="512m",
|
|
|
|
|
ports=[PortMapping(8080, 80)],
|
|
|
|
|
mounts=[Mount("/ws", "/app")],
|
|
|
|
|
restart_policy="on-failure",
|
|
|
|
|
command=["python", "app.py"],
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
argv = build_run_argv(spec)
|
|
|
|
|
assert argv[:5] == ["docker", "run", "-d", "--name", "inst"]
|
|
|
|
|
assert "--label" in argv and f"{INSTANCE_LABEL}=u1" in argv
|
|
|
|
|
assert "--cpus" in argv and "1.5" in argv
|
|
|
|
|
assert "-p" in argv and "8080:80/tcp" in argv
|
|
|
|
|
assert "-v" in argv and "/ws:/app:rw" in argv
|
|
|
|
|
assert "--restart" in argv and "on-failure" in argv
|
2026-06-09 16:06:02 +02:00
|
|
|
assert argv[-3:] == ["ppy:latest", "python", "app.py"]
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_never_policy_not_passed_to_docker():
|
2026-06-09 16:06:02 +02:00
|
|
|
spec = RunSpec(image="ppy:latest", name="n", restart_policy="never")
|
2026-06-09 06:41:27 +02:00
|
|
|
assert "--restart" not in build_run_argv(spec)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_size():
|
2026-06-09 18:48:08 +02:00
|
|
|
assert parse_size("1.0GiB") == 1024**3
|
|
|
|
|
assert parse_size("512MB") == 512 * 1024**2
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
2026-06-09 16:06:02 +02:00
|
|
|
def test_create_instance_uses_shared_image(env):
|
2026-06-09 18:48:08 +02:00
|
|
|
inst = run_async(
|
|
|
|
|
api.create_instance(
|
|
|
|
|
env["project"], name="inst", actor=("user", env["user"]["uid"])
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-06-09 16:06:02 +02:00
|
|
|
assert inst["name"] == "inst"
|
|
|
|
|
assert inst["owner_uid"] == "ctest-u1"
|
|
|
|
|
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
|
|
|
|
|
assert spec.image == config.CONTAINER_IMAGE
|
|
|
|
|
assert any(m.container == "/app" for m in spec.mounts)
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
2026-06-09 16:06:02 +02:00
|
|
|
def test_create_instance_requires_built_image(env):
|
|
|
|
|
async def no_image(ref):
|
|
|
|
|
return False
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-09 16:06:02 +02:00
|
|
|
env["fake"].image_exists = no_image
|
2026-06-09 06:41:27 +02:00
|
|
|
with pytest.raises(api.ContainerError):
|
2026-06-09 16:06:02 +02:00
|
|
|
run_async(api.create_instance(env["project"], name="inst"))
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reconcile_launches_and_stops(env):
|
|
|
|
|
inst = _ready_instance(env, restart_policy="never", autostart=True)
|
|
|
|
|
assert inst["desired_state"] == store.DESIRED_RUNNING
|
|
|
|
|
service = ContainerService()
|
|
|
|
|
run_async(service.run_once())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
inst = store.get_instance(inst["uid"])
|
|
|
|
|
assert inst["status"] == store.ST_RUNNING and inst["container_id"]
|
|
|
|
|
assert [r.name for r in run_async(env["fake"].ps())] == [inst["slug"]]
|
|
|
|
|
api.set_desired_state(inst, store.DESIRED_STOPPED)
|
|
|
|
|
run_async(service.run_once())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
assert store.get_instance(inst["uid"])["status"] == store.ST_STOPPED
|
|
|
|
|
|
|
|
|
|
|
feat: add admin/internal database API with CRUD, read-only query, and natural-language SQL endpoints
Add a new `/dbapi` router package providing a generic database API over `dataset`, restricted to admin sessions, admin API keys, and the internal gateway key. Includes:
- `tables.py`: list all tables and inspect table schemas
- `crud.py`: full CRUD operations (GET, POST, PATCH, DELETE) with soft-delete awareness, born-live inserts, `?include_deleted`, `.../restore`, and `?hard=true` purge
- `query.py`: validated read-only SELECT execution via sqlglot parsing, classification, and EXPLAIN dry-run; async query jobs with WebSocket streaming via `DbApiJobService`
- `nl.py`: natural-language-to-SQL conversion using the platform AI gateway with re-prompting until validation passes
Also register `DbApiJobService` and `PubSubService` in the service manager, add `DBAPI_DIR` to config data paths, and force cleartext `http://` connections to HTTP/1.1 in `curl_transport` to fix large request failures against uvicorn's HTTP/1.1-only internal gateway.
2026-06-15 01:00:30 +02:00
|
|
|
def test_manual_start_relaunches_after_never_policy_exit(env):
|
|
|
|
|
fake = env["fake"]
|
|
|
|
|
inst = _ready_instance(env, restart_policy="never", autostart=True)
|
|
|
|
|
service = ContainerService()
|
|
|
|
|
run_async(service.run_once())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
inst = store.get_instance(inst["uid"])
|
|
|
|
|
cid = inst["container_id"]
|
|
|
|
|
assert inst["status"] == store.ST_RUNNING and cid
|
|
|
|
|
|
|
|
|
|
fake._set_state(cid, "exited", 0)
|
|
|
|
|
run_async(service.run_once())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
inst = store.get_instance(inst["uid"])
|
|
|
|
|
assert inst["status"] == store.ST_STOPPED
|
|
|
|
|
assert inst["desired_state"] == store.DESIRED_STOPPED
|
|
|
|
|
|
|
|
|
|
api.set_desired_state(inst, store.DESIRED_RUNNING)
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
run_async(service.run_once())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
inst = store.get_instance(inst["uid"])
|
|
|
|
|
assert inst["status"] == store.ST_RUNNING
|
|
|
|
|
assert inst["desired_state"] == store.DESIRED_RUNNING
|
|
|
|
|
assert cid in fake.removed
|
|
|
|
|
assert inst["container_id"] and inst["container_id"] != cid
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 06:41:27 +02:00
|
|
|
def test_reconcile_reaps_orphan(env):
|
|
|
|
|
fake = env["fake"]
|
2026-06-09 18:48:08 +02:00
|
|
|
run_async(
|
|
|
|
|
fake.run(
|
|
|
|
|
RunSpec(
|
|
|
|
|
image="ppy:latest", name="ghost", labels={INSTANCE_LABEL: "missing-uid"}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
service = ContainerService()
|
|
|
|
|
run_async(service.run_once())
|
|
|
|
|
assert not run_async(fake.ps())
|
|
|
|
|
assert fake.removed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reconcile_removes_marked_instance(env):
|
|
|
|
|
inst = _ready_instance(env, autostart=True)
|
|
|
|
|
service = ContainerService()
|
|
|
|
|
run_async(service.run_once())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
inst = store.get_instance(inst["uid"])
|
|
|
|
|
api.mark_for_removal(inst)
|
|
|
|
|
run_async(service.run_once())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
assert store.get_instance(inst["uid"]) is None
|
|
|
|
|
assert not run_async(env["fake"].ps())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_schedule_fires(env):
|
|
|
|
|
inst = _ready_instance(env, autostart=False)
|
|
|
|
|
assert inst["desired_state"] == store.DESIRED_STOPPED
|
|
|
|
|
past = Schedule(kind="once", run_at=now_utc() - timedelta(hours=1))
|
|
|
|
|
api.add_schedule(inst, "start", past)
|
|
|
|
|
service = ContainerService()
|
|
|
|
|
run_async(service._fire_schedules())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
assert store.get_instance(inst["uid"])["desired_state"] == store.DESIRED_RUNNING
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ingress_validation(env):
|
|
|
|
|
from devplacepy.services.containers.backend.base import PortMapping
|
2026-06-09 18:48:08 +02:00
|
|
|
|
|
|
|
|
assert api.validate_ingress("zwoeks", 8899, [PortMapping(8899, 8899)]) == (
|
|
|
|
|
"zwoeks",
|
|
|
|
|
8899,
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
assert api.validate_ingress("", None, []) == ("", 0)
|
|
|
|
|
with pytest.raises(api.ContainerError):
|
|
|
|
|
api.validate_ingress("BAD SLUG", None, [PortMapping(80, 80)])
|
|
|
|
|
with pytest.raises(api.ContainerError):
|
|
|
|
|
api.validate_ingress("x", 9999, [PortMapping(80, 80)])
|
2026-06-09 18:48:08 +02:00
|
|
|
store.create_instance(
|
|
|
|
|
{
|
|
|
|
|
"uid": "z",
|
|
|
|
|
"project_uid": "ctest-p1",
|
|
|
|
|
"name": "z",
|
|
|
|
|
"ports_json": "[]",
|
|
|
|
|
"ingress_slug": "taken",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
with pytest.raises(api.ContainerError):
|
|
|
|
|
api.validate_ingress("taken", None, [PortMapping(80, 80)])
|
2026-06-14 16:46:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_boot_languages():
|
|
|
|
|
assert api.validate_boot("none", "ignored") == ("none", "")
|
|
|
|
|
assert api.validate_boot("python", "print(1)") == ("python", "print(1)")
|
|
|
|
|
with pytest.raises(api.ContainerError):
|
|
|
|
|
api.validate_boot("ruby", "puts 1")
|
|
|
|
|
with pytest.raises(api.ContainerError):
|
|
|
|
|
api.validate_boot("bash", " ")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_script_precedence_in_run_spec(env):
|
|
|
|
|
inst = _ready_instance(
|
|
|
|
|
env,
|
|
|
|
|
boot_language="python",
|
|
|
|
|
boot_script="print('boot')\n",
|
|
|
|
|
boot_command="python other.py",
|
|
|
|
|
autostart=False,
|
|
|
|
|
)
|
|
|
|
|
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
|
|
|
|
|
assert spec.command == ["python", "/app/.devplace_boot.py"]
|
|
|
|
|
inst2 = _ready_instance(
|
|
|
|
|
env, name="i2", boot_command="python serve.py", autostart=False
|
|
|
|
|
)
|
|
|
|
|
spec2 = api.run_spec_for(inst2, config.CONTAINER_IMAGE)
|
|
|
|
|
assert spec2.command == ["/bin/sh", "-c", "python serve.py"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_materialize_boot_script_writes_file(env, tmp_path):
|
|
|
|
|
workspace = tmp_path / "boot-ws"
|
|
|
|
|
inst = _ready_instance(
|
|
|
|
|
env, name="boot", boot_language="bash", boot_script="echo hi\n", autostart=False
|
|
|
|
|
)
|
|
|
|
|
store.update_instance(inst["uid"], {"workspace_dir": str(workspace)})
|
|
|
|
|
inst = store.get_instance(inst["uid"])
|
|
|
|
|
api.materialize_boot_script(inst)
|
|
|
|
|
written = workspace / ".devplace_boot.sh"
|
|
|
|
|
assert written.is_file()
|
|
|
|
|
assert written.read_text() == "echo hi\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_as_uid_drives_pravda_env(env):
|
|
|
|
|
_promote_admin(env["user"]["username"])
|
|
|
|
|
key = _api_key(env["user"]["username"])
|
|
|
|
|
inst = _ready_instance(
|
|
|
|
|
env, name="runas", run_as_uid=env["user"]["uid"], autostart=False
|
|
|
|
|
)
|
|
|
|
|
pravda = api.pravda_env(inst)
|
2026-07-06 05:57:47 +02:00
|
|
|
assert pravda["DEVPLACE_API_KEY"] == key
|
|
|
|
|
assert pravda["DEVPLACE_USER_UID"] == env["user"]["uid"]
|
2026-06-14 16:46:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_as_uid_rejects_unknown_user(env):
|
|
|
|
|
with pytest.raises(api.ContainerError):
|
|
|
|
|
run_async(
|
|
|
|
|
api.create_instance(
|
|
|
|
|
env["project"], name="bad", run_as_uid="nope-uid", autostart=False
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
def _other_member(uid="ctest-u2", username="ctestmember"):
|
|
|
|
|
users = get_table("users")
|
|
|
|
|
if not users.find_one(uid=uid):
|
|
|
|
|
users.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"username": username,
|
|
|
|
|
"terms_version": "1",
|
|
|
|
|
"email": f"{username}@example.com",
|
|
|
|
|
"api_key": generate_uid(),
|
|
|
|
|
"password_hash": "",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": "2020-01-01T00:00:00+00:00",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
return uid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_as_another_member_needs_their_credential_consent(env):
|
|
|
|
|
from devplacepy.database import set_consent
|
|
|
|
|
|
|
|
|
|
other = _other_member()
|
|
|
|
|
set_consent("user", other, "container_credentials", False)
|
|
|
|
|
with pytest.raises(api.ContainerError) as refused:
|
|
|
|
|
run_async(
|
|
|
|
|
api.create_instance(
|
|
|
|
|
env["project"],
|
|
|
|
|
name="noconsent",
|
|
|
|
|
run_as_uid=other,
|
|
|
|
|
autostart=False,
|
|
|
|
|
actor=("user", env["user"]["uid"]),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert "consent" in str(refused.value).lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_as_another_member_is_allowed_once_they_consent(env):
|
|
|
|
|
from devplacepy.database import set_consent
|
|
|
|
|
|
|
|
|
|
other = _other_member()
|
|
|
|
|
set_consent("user", other, "container_credentials", True)
|
|
|
|
|
instance = run_async(
|
|
|
|
|
api.create_instance(
|
|
|
|
|
env["project"],
|
|
|
|
|
name="withconsent",
|
|
|
|
|
run_as_uid=other,
|
|
|
|
|
autostart=False,
|
|
|
|
|
actor=("user", env["user"]["uid"]),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert instance["run_as_uid"] == other
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_as_yourself_never_needs_a_consent(env):
|
|
|
|
|
instance = run_async(
|
|
|
|
|
api.create_instance(
|
|
|
|
|
env["project"],
|
|
|
|
|
name="selfrunas",
|
|
|
|
|
run_as_uid=env["user"]["uid"],
|
|
|
|
|
autostart=False,
|
|
|
|
|
actor=("user", env["user"]["uid"]),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert instance["run_as_uid"] == env["user"]["uid"]
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 16:46:36 +02:00
|
|
|
def test_start_on_boot_pass_forces_running(env):
|
|
|
|
|
inst = _ready_instance(env, name="boot", start_on_boot=True, autostart=False)
|
|
|
|
|
assert inst["desired_state"] == store.DESIRED_STOPPED
|
|
|
|
|
service = ContainerService()
|
|
|
|
|
service._boot_pass(store.all_instances())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
assert store.get_instance(inst["uid"])["desired_state"] == store.DESIRED_RUNNING
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_status_change_records_event(env):
|
|
|
|
|
inst = _ready_instance(env, name="hist", restart_policy="never", autostart=True)
|
|
|
|
|
service = ContainerService()
|
|
|
|
|
run_async(service.run_once())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
inst = store.get_instance(inst["uid"])
|
|
|
|
|
api.set_desired_state(inst, store.DESIRED_STOPPED)
|
|
|
|
|
run_async(service.run_once())
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
events = [e["event"] for e in store.list_events(inst["uid"])]
|
|
|
|
|
assert "status_change" in events
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bidirectional_sync_newer_wins(env, tmp_path):
|
|
|
|
|
workspace = tmp_path / "sync-ws"
|
|
|
|
|
workspace.mkdir()
|
|
|
|
|
pid = env["project"]["uid"]
|
|
|
|
|
user = env["user"]
|
|
|
|
|
project_files.write_text_file(pid, user, "shared.txt", "from project\n")
|
|
|
|
|
fs_only = workspace / "fromfs.txt"
|
|
|
|
|
fs_only.write_text("from fs\n")
|
|
|
|
|
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
|
|
|
|
assert counts["exported"] >= 1
|
|
|
|
|
assert counts["imported"] >= 1
|
|
|
|
|
assert (workspace / "shared.txt").read_text() == "from project\n"
|
|
|
|
|
imported = project_files.read_file(pid, "fromfs.txt")
|
|
|
|
|
assert imported["content"] == "from fs\n"
|
Make the workspace editor reachable through the sub-path proxy
code-server runs authenticateOrigin on every websocket and resolves the
request host as Forwarded, then X-Forwarded-Host, then Host. The forward
core put the public host into additional_headers, but the websockets
client already writes its own Host for the real TCP target and Headers
appends, so the handshake carried two Host lines; Node keeps the first
(the internal gateway:port), the origin check failed, and code-server
answered 403. Because the browser socket was accepted before the upstream
was dialled, that surfaced as a 101 followed by 1011 and the editor died
on "the workbench failed to connect to the server". Dialling first and
carrying the public host in the connect URI fixes both planes.
The two header builders that had drifted apart are now one core, so a
websocket carries the same client and forwarded headers as an HTTP
request. Responses stream instead of buffering whole, which is what makes
a large tunnel download cost constant memory and lets SSE work; byte
accounting moved onto the completion callback. Subprotocols negotiate,
the upstream client is reused across requests, and the path and query are
forwarded byte-exactly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:26:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _proxy_scope(kind: str, headers: dict, scheme: str, query: str = "") -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"type": kind,
|
|
|
|
|
"scheme": scheme,
|
|
|
|
|
"server": ("devplace.net", 443),
|
|
|
|
|
"path": "/projects/demo/containers/instances/abc/code/stable-1",
|
|
|
|
|
"query_string": query.encode(),
|
|
|
|
|
"headers": [
|
|
|
|
|
(name.encode(), value.encode()) for name, value in headers.items()
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _proxy_websocket(headers: dict, scheme: str = "wss", query: str = "") -> WebSocket:
|
|
|
|
|
return WebSocket(_proxy_scope("websocket", headers, scheme, query), None, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _proxy_request(headers: dict, scheme: str = "https", query: str = "") -> Request:
|
|
|
|
|
return Request(_proxy_scope("http", headers, scheme, query))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ws_headers_send_the_public_host_only_as_x_forwarded_host():
|
|
|
|
|
headers = forward.ws_headers(
|
|
|
|
|
_proxy_websocket(
|
|
|
|
|
{
|
|
|
|
|
"host": "devplace.net",
|
|
|
|
|
"origin": "https://devplace.net",
|
|
|
|
|
"cookie": "session=abc",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert headers["X-Forwarded-Host"] == "devplace.net"
|
|
|
|
|
assert headers["origin"] == "https://devplace.net"
|
|
|
|
|
assert headers["cookie"] == "session=abc"
|
|
|
|
|
assert not [name for name in headers if name.lower() == "host"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ws_headers_drop_only_the_handshake_headers_the_client_regenerates():
|
|
|
|
|
headers = forward.ws_headers(
|
|
|
|
|
_proxy_websocket(
|
|
|
|
|
{
|
|
|
|
|
"host": "devplace.net",
|
|
|
|
|
"x-real-ip": "203.0.113.7",
|
|
|
|
|
"accept-language": "nl-NL",
|
|
|
|
|
"sec-websocket-key": "should-not-survive",
|
|
|
|
|
"sec-websocket-version": "13",
|
|
|
|
|
"sec-websocket-extensions": "permessage-deflate",
|
|
|
|
|
"sec-websocket-protocol": "v1",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert headers["x-real-ip"] == "203.0.113.7"
|
|
|
|
|
assert headers["accept-language"] == "nl-NL"
|
|
|
|
|
for name in forward.WS_HANDSHAKE_HEADERS:
|
|
|
|
|
assert name not in headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ws_headers_derive_the_forwarded_proto_from_the_socket_scheme():
|
|
|
|
|
assert forward.ws_headers(_proxy_websocket({"host": "d.net"}))[
|
|
|
|
|
"X-Forwarded-Proto"
|
|
|
|
|
] == "https"
|
|
|
|
|
assert forward.ws_headers(_proxy_websocket({"host": "d.net"}, scheme="ws"))[
|
|
|
|
|
"X-Forwarded-Proto"
|
|
|
|
|
] == "http"
|
|
|
|
|
assert forward.ws_headers(
|
|
|
|
|
_proxy_websocket({"host": "d.net", "x-forwarded-proto": "https"}, scheme="ws")
|
|
|
|
|
)["X-Forwarded-Proto"] == "https"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_forward_headers_send_the_public_host_and_the_prefix():
|
|
|
|
|
headers = forward.forward_headers(
|
|
|
|
|
_proxy_request({"host": "devplace.net", "x-real-ip": "203.0.113.7"}),
|
|
|
|
|
prefix="/p/demo",
|
|
|
|
|
)
|
|
|
|
|
assert headers["Host"] == "devplace.net"
|
|
|
|
|
assert headers["X-Forwarded-Host"] == "devplace.net"
|
|
|
|
|
assert headers["X-Forwarded-Prefix"] == "/p/demo"
|
|
|
|
|
assert headers["X-Script-Name"] == "/p/demo"
|
|
|
|
|
assert headers["x-real-ip"] == "203.0.113.7"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ws_subprotocols_are_parsed_for_negotiation():
|
|
|
|
|
assert forward.ws_subprotocols(_proxy_websocket({"host": "d.net"})) is None
|
|
|
|
|
assert forward.ws_subprotocols(
|
|
|
|
|
_proxy_websocket({"host": "d.net", "sec-websocket-protocol": "v2, v1"})
|
|
|
|
|
) == ["v2", "v1"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upstream_url_encodes_the_path_and_keeps_the_query_verbatim():
|
|
|
|
|
assert (
|
|
|
|
|
forward.upstream_url("http", "h:1", "dir/a b#c", "keep=1")
|
|
|
|
|
== "http://h:1/dir/a%20b%23c?keep=1"
|
|
|
|
|
)
|
|
|
|
|
assert forward.upstream_url("ws", "h:1", "", "") == "ws://h:1/"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_raw_query_survives_a_hash_in_the_path():
|
|
|
|
|
request = _proxy_request({"host": "d.net"}, query="keep=1")
|
|
|
|
|
request.scope["path"] = "/weird/a b#c"
|
|
|
|
|
assert forward.raw_query(request) == "keep=1"
|
|
|
|
|
assert request.url.query == ""
|