@@ -8,6 +8,7 @@ from devplacepy.content import can_manage_workspace, can_open_workspace
|
||||
from devplacepy.database import get_table, init_db, set_setting
|
||||
from devplacepy.services.containers import activity, api, store
|
||||
from devplacepy.services.containers.workspace import (
|
||||
editor,
|
||||
flags,
|
||||
naming,
|
||||
provision,
|
||||
@@ -28,7 +29,13 @@ def _workspace_db():
|
||||
init_db()
|
||||
set_setting("workspace_enabled", "1")
|
||||
yield
|
||||
for table in ("instances", "tunnels", "workspace_flags", "workspace_quota_rules"):
|
||||
for table in (
|
||||
"instances",
|
||||
"tunnels",
|
||||
"workspace_flags",
|
||||
"workspace_quota_rules",
|
||||
"workspace_editor_prefs",
|
||||
):
|
||||
get_table(table).delete()
|
||||
|
||||
|
||||
@@ -391,7 +398,8 @@ def test_editor_runs_with_password_auth_and_an_eight_char_secret():
|
||||
assert len(password) == api.EDITOR_PASSWORD_LENGTH == 8
|
||||
assert password.isalnum()
|
||||
|
||||
command = api.editor_command(store.get_instance(instance["uid"]))
|
||||
row = store.get_instance(instance["uid"])
|
||||
command = editor.argv(row, editor.resolve(OWNER, row))
|
||||
assert "--auth" in command
|
||||
assert command[command.index("--auth") + 1] == "password"
|
||||
assert "none" not in command
|
||||
@@ -806,3 +814,177 @@ def test_no_certificate_is_ordered_when_molohttp_is_unconfigured(monkeypatch):
|
||||
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"]]
|
||||
|
||||
|
||||
def _editor_workspace(owner: dict, title: str):
|
||||
project = _http_project(owner["uid"], title)
|
||||
instance = store.create_instance(
|
||||
{
|
||||
"project_uid": project["uid"],
|
||||
"name": "ws-editor-http",
|
||||
"status": "running",
|
||||
"desired_state": "running",
|
||||
"is_workspace": 1,
|
||||
"workspace_owner_uid": owner["uid"],
|
||||
"tunnel_name": naming.generate(),
|
||||
"ports_json": '[{"host": 20911, "container": 8080, "proto": "tcp"}]',
|
||||
}
|
||||
)
|
||||
return project, instance
|
||||
|
||||
|
||||
def _get(path: str, key: str):
|
||||
import requests
|
||||
|
||||
return requests.get(
|
||||
f"{BASE_URL}{path}",
|
||||
headers={"Accept": "application/json", "X-API-KEY": key},
|
||||
timeout=HTTP_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def test_editor_profile_reads_back_over_http(app_server, seeded_db):
|
||||
owner = _seeded_user("bob_test")
|
||||
project, _ = _editor_workspace(owner, "WS Editor Read")
|
||||
slug = project["slug"]
|
||||
assert _await_workspaces_enabled(slug, owner["api_key"])
|
||||
|
||||
body = _get(f"/projects/{slug}/workspace/editor", owner["api_key"]).json()
|
||||
assert body["editor"]["theme"] == editor.DEFAULTS["theme"]
|
||||
assert body["editor"]["sources"]["theme"] == editor.SOURCE_SITE
|
||||
assert body["editor"]["cpu_millicores"] == quota.resolve(owner["uid"]).cpu_millicores
|
||||
assert body["restart_required"] is False
|
||||
|
||||
|
||||
def test_editor_preferences_save_and_reset_over_http(app_server, seeded_db):
|
||||
owner = _seeded_user("bob_test")
|
||||
project, _ = _editor_workspace(owner, "WS Editor Write")
|
||||
slug = project["slug"]
|
||||
assert _await_workspaces_enabled(slug, owner["api_key"])
|
||||
try:
|
||||
saved = _post(
|
||||
f"/projects/{slug}/workspace/editor",
|
||||
owner["api_key"],
|
||||
data={"theme": "devplace-light", "font_size": "21"},
|
||||
)
|
||||
assert saved.status_code == 200, saved.text
|
||||
profile = saved.json()["data"]["editor"]
|
||||
assert profile["theme"] == "devplace-light"
|
||||
assert profile["font_size"] == 21
|
||||
assert profile["sources"]["font_size"] == editor.SOURCE_USER
|
||||
|
||||
reset = _post(
|
||||
f"/projects/{slug}/workspace/editor",
|
||||
owner["api_key"],
|
||||
data={"reset": "true"},
|
||||
)
|
||||
assert reset.status_code == 200, reset.text
|
||||
after = reset.json()["data"]["editor"]
|
||||
assert after["font_size"] == editor.DEFAULTS["font_size"]
|
||||
assert after["sources"]["font_size"] == editor.SOURCE_SITE
|
||||
finally:
|
||||
get_table("workspace_editor_prefs").delete(owner_id=owner["uid"])
|
||||
|
||||
|
||||
def test_an_out_of_range_editor_value_is_refused_not_clamped(app_server, seeded_db):
|
||||
owner = _seeded_user("bob_test")
|
||||
project, _ = _editor_workspace(owner, "WS Editor Range")
|
||||
slug = project["slug"]
|
||||
assert _await_workspaces_enabled(slug, owner["api_key"])
|
||||
|
||||
refused = _post(
|
||||
f"/projects/{slug}/workspace/editor",
|
||||
owner["api_key"],
|
||||
data={"font_size": "500"},
|
||||
)
|
||||
assert refused.status_code == 422, refused.text
|
||||
|
||||
|
||||
def test_editor_preferences_are_refused_to_a_non_owner(app_server, seeded_db):
|
||||
owner = _seeded_user("bob_test")
|
||||
intruder = _fresh_member()
|
||||
project, _ = _editor_workspace(owner, "WS Editor Foreign")
|
||||
slug = project["slug"]
|
||||
|
||||
denied = _get(f"/projects/{slug}/workspace/editor", intruder["api_key"])
|
||||
assert denied.status_code in (403, 404), denied.text
|
||||
|
||||
|
||||
def test_editor_preferences_reject_a_guest_with_401_not_422(app_server, seeded_db):
|
||||
import requests
|
||||
|
||||
owner = _seeded_user("bob_test")
|
||||
project, _ = _editor_workspace(owner, "WS Editor Guest")
|
||||
guest = requests.post(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace/editor",
|
||||
headers={"Accept": "application/json"},
|
||||
data={},
|
||||
timeout=HTTP_TIMEOUT,
|
||||
)
|
||||
assert guest.status_code == 401, guest.status_code
|
||||
|
||||
|
||||
def test_a_saved_preference_asks_for_a_restart_while_running(app_server, seeded_db):
|
||||
owner = _seeded_user("bob_test")
|
||||
project, instance = _editor_workspace(owner, "WS Editor Restart")
|
||||
slug = project["slug"]
|
||||
assert _await_workspaces_enabled(slug, owner["api_key"])
|
||||
try:
|
||||
editor.seed_state(instance, editor.resolve(owner["uid"], instance))
|
||||
body = _post(
|
||||
f"/projects/{slug}/workspace/editor",
|
||||
owner["api_key"],
|
||||
data={"font_size": "29"},
|
||||
).json()
|
||||
assert body["data"]["restart_required"] is True
|
||||
finally:
|
||||
get_table("workspace_editor_prefs").delete(owner_id=owner["uid"])
|
||||
|
||||
|
||||
def test_the_run_spec_applies_the_quota_cpu_and_memory_to_a_workspace():
|
||||
from devplacepy.services.containers import api
|
||||
|
||||
instance = _instance(editor_port=api.EDITOR_DEFAULT_PORT)
|
||||
limits = quota.resolve(OWNER, store.get_instance(instance["uid"]))
|
||||
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
|
||||
assert spec.cpu_limit == limits.cpu_limit()
|
||||
assert spec.mem_limit == limits.mem_limit()
|
||||
assert spec.command[0] == "code-server"
|
||||
assert "--app-name" in spec.command
|
||||
assert "--disable-workspace-trust" in spec.command
|
||||
|
||||
|
||||
def test_the_run_spec_seeds_the_editor_state_and_stamps_a_boot_marker(monkeypatch, tmp_path):
|
||||
from devplacepy import config
|
||||
from devplacepy.services.containers import api
|
||||
|
||||
monkeypatch.setattr(config, "WORKSPACE_STATE_DIR", tmp_path / "state")
|
||||
instance = _instance()
|
||||
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
|
||||
|
||||
marker = store.get_instance(instance["uid"])["boot_marker"]
|
||||
assert marker
|
||||
assert spec.env["DEVPLACE_CONTAINER_BOOT"] == marker
|
||||
assert spec.env["DEVPLACE_EDITOR_APP_NAME"] == editor.APP_NAME
|
||||
seeded = tmp_path / "state" / instance["uid"] / "data" / "User" / "settings.json"
|
||||
assert seeded.exists()
|
||||
|
||||
|
||||
def test_a_non_workspace_instance_keeps_its_own_limits():
|
||||
from devplacepy.services.containers import api
|
||||
|
||||
instance = _instance(is_workspace=0, cpu_limit="0.5", mem_limit="256m")
|
||||
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
|
||||
assert spec.cpu_limit == "0.5"
|
||||
assert spec.mem_limit == "256m"
|
||||
assert not spec.env.get("DEVPLACE_EDITOR_APP_NAME")
|
||||
|
||||
|
||||
def test_a_workspace_without_an_editor_port_still_gets_its_size():
|
||||
from devplacepy.services.containers import api
|
||||
|
||||
instance = _instance()
|
||||
limits = quota.resolve(OWNER, store.get_instance(instance["uid"]))
|
||||
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
|
||||
assert spec.cpu_limit == limits.cpu_limit()
|
||||
assert spec.command == ["sleep", "infinity"]
|
||||
|
||||
@@ -32,6 +32,7 @@ def _workspaces_on():
|
||||
get_table("tunnels").delete(instance_uid=uid)
|
||||
get_table("workspace_flags").delete(instance_uid=uid)
|
||||
instances.delete(uid=uid)
|
||||
get_table("workspace_editor_prefs").delete()
|
||||
|
||||
|
||||
def _row_for(user: dict) -> dict:
|
||||
@@ -111,7 +112,7 @@ def test_project_page_shows_a_direct_vscode_button_for_a_running_workspace(alice
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
|
||||
)
|
||||
button = page.locator(".project-detail-actions a:has-text('VS Code')")
|
||||
button = page.locator(".project-detail-actions a[data-editor-open]")
|
||||
button.wait_for(state="visible")
|
||||
expect(button).to_have_attribute("target", "_blank")
|
||||
expect(button).to_have_attribute(
|
||||
@@ -129,7 +130,7 @@ def test_direct_vscode_button_is_absent_while_the_workspace_is_stopped(alice):
|
||||
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
|
||||
)
|
||||
page.locator(".project-detail-actions").wait_for(state="visible")
|
||||
assert not page.locator(".project-detail-actions a:has-text('VS Code')").count()
|
||||
assert not page.locator(".project-detail-actions a[data-editor-open]").count()
|
||||
|
||||
|
||||
def _await_workspace_flag(slug: str, key: str, expected: bool) -> bool:
|
||||
@@ -310,3 +311,107 @@ def test_workspaces_sidebar_link_is_present_for_admin(alice):
|
||||
link = page.locator(".sidebar-link:has-text('Workspaces')")
|
||||
link.wait_for(state="visible")
|
||||
expect(link).to_have_class(re.compile("active"))
|
||||
|
||||
|
||||
def test_the_editor_card_shows_the_resolved_profile_and_its_sources(alice):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Editor Card")
|
||||
_workspace_for(project, row["uid"])
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
card = page.locator(".workspace-editor")
|
||||
card.wait_for(state="visible")
|
||||
expect(card.locator(".workspace-editor-summary").first).to_contain_text("devplace-dark")
|
||||
expect(card.locator(".workspace-editor-resources")).to_contain_text("cores")
|
||||
expect(card.locator(".workspace-editor-form select[name='theme']")).to_be_visible()
|
||||
|
||||
|
||||
def test_saving_an_editor_preference_changes_its_source_to_the_member(alice):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Editor Save")
|
||||
_workspace_for(project, row["uid"])
|
||||
try:
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
page.locator(".workspace-editor-form").wait_for(state="visible")
|
||||
page.select_option(".workspace-editor-form select[name='theme']", "devplace-light")
|
||||
page.locator(".workspace-editor-actions button[type='submit']").click()
|
||||
page.wait_for_url(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
summary = page.locator(".workspace-editor-summary").first
|
||||
summary.wait_for(state="visible")
|
||||
expect(summary).to_contain_text("devplace-light")
|
||||
expect(summary).to_contain_text("user")
|
||||
finally:
|
||||
get_table("workspace_editor_prefs").delete(owner_id=row["uid"])
|
||||
|
||||
|
||||
def test_resetting_editor_preferences_restores_the_site_default(alice):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Editor Reset")
|
||||
_workspace_for(project, row["uid"])
|
||||
try:
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
page.locator(".workspace-editor-form").wait_for(state="visible")
|
||||
page.select_option(".workspace-editor-form select[name='theme']", "devplace-light")
|
||||
page.locator(".workspace-editor-actions button[type='submit']").click()
|
||||
page.wait_for_url(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
page.locator(".workspace-editor-reset button").click()
|
||||
page.wait_for_url(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
summary = page.locator(".workspace-editor-summary").first
|
||||
summary.wait_for(state="visible")
|
||||
expect(summary).to_contain_text("devplace-dark")
|
||||
finally:
|
||||
get_table("workspace_editor_prefs").delete(owner_id=row["uid"])
|
||||
|
||||
|
||||
def test_the_editor_launch_control_carries_the_window_profile(alice):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Editor Launch")
|
||||
instance = _workspace_for(project, row["uid"])
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
launch = page.locator(".workspace-actions a[data-editor-open]")
|
||||
launch.wait_for(state="visible")
|
||||
expect(launch).to_have_attribute("data-editor-mode", "tab")
|
||||
expect(launch).to_have_attribute("target", "_blank")
|
||||
expect(launch).to_have_attribute(
|
||||
"href",
|
||||
f"/projects/{project['slug']}/containers/instances/{instance['uid']}/code/",
|
||||
)
|
||||
|
||||
|
||||
def test_the_workspace_help_states_that_every_folder_is_trusted(alice):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Editor Trust")
|
||||
_workspace_for(project, row["uid"])
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
help_card = page.locator(".workspace-help")
|
||||
help_card.wait_for(state="visible")
|
||||
expect(help_card).to_contain_text("Restricted Mode")
|
||||
expect(help_card).to_contain_text("dpc")
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_table, init_db
|
||||
|
||||
FILTERED_COLUMNS = {
|
||||
"messages": ("uid", "sender_uid", "receiver_uid", "content", "read", "created_at"),
|
||||
"workspace_quota_rules": ("uid", "owner_kind", "owner_id", "deleted_at"),
|
||||
"workspace_editor_prefs": ("uid", "owner_kind", "owner_id", "deleted_at"),
|
||||
}
|
||||
|
||||
|
||||
def test_init_db_ensures_every_filtered_column(local_db):
|
||||
for table, columns in FILTERED_COLUMNS.items():
|
||||
target = get_table(table)
|
||||
missing = [column for column in columns if not target.has_column(column)]
|
||||
assert not missing, f"{table} is missing {missing}"
|
||||
|
||||
|
||||
def test_a_partial_insert_cannot_reduce_the_messages_schema(local_db):
|
||||
table = get_table("messages")
|
||||
table.insert(
|
||||
{
|
||||
"uid": "schema-msg-1",
|
||||
"sender_uid": "schema-user-1",
|
||||
"receiver_uid": "schema-user-2",
|
||||
"content": "partial row, no created_at",
|
||||
}
|
||||
)
|
||||
try:
|
||||
init_db()
|
||||
assert get_table("messages").has_column("created_at")
|
||||
rows = list(
|
||||
local_db.query(
|
||||
"SELECT uid FROM messages WHERE created_at IS NULL AND uid = :u",
|
||||
u="schema-msg-1",
|
||||
)
|
||||
)
|
||||
assert len(rows) == 1
|
||||
finally:
|
||||
get_table("messages").delete(uid="schema-msg-1")
|
||||
|
||||
|
||||
def test_the_workspace_rule_tables_filter_on_a_column_that_exists(local_db):
|
||||
from devplacepy.services.containers.workspace import editor, quota
|
||||
|
||||
assert quota._rule_for("user", "nobody-at-all") is None
|
||||
assert editor.prefs_for("nobody-at-all") is None
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,362 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy import config
|
||||
from devplacepy.database import get_table, init_db, set_setting
|
||||
from devplacepy.services.containers.backend.base import (
|
||||
WORKSPACE_MOUNT,
|
||||
WORKSPACE_STATE_MOUNT,
|
||||
)
|
||||
from devplacepy.services.containers.workspace import editor, quota
|
||||
|
||||
OWNER = "editor-owner"
|
||||
OTHER = "editor-other"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _editor_db(tmp_path, monkeypatch):
|
||||
init_db()
|
||||
monkeypatch.setattr(config, "WORKSPACE_STATE_DIR", tmp_path / "state")
|
||||
yield
|
||||
get_table(editor.PREFS_TABLE).delete()
|
||||
get_table(quota.RULES_TABLE).delete()
|
||||
for key, default in editor.DEFAULTS.items():
|
||||
setting = editor.SETTING_KEYS[key]
|
||||
if isinstance(default, bool):
|
||||
set_setting(setting, "1" if default else "0")
|
||||
else:
|
||||
set_setting(setting, str(default))
|
||||
|
||||
|
||||
def _instance(uid: str = "ws-editor") -> dict:
|
||||
return {"uid": uid, "editor_port": editor.EDITOR_DEFAULT_PORT, "is_workspace": 1}
|
||||
|
||||
|
||||
def _prefs(**values) -> dict:
|
||||
return editor.save_prefs(OWNER, values)
|
||||
|
||||
|
||||
def test_resolve_uses_site_defaults():
|
||||
profile = editor.resolve(OWNER)
|
||||
assert profile.theme == editor.DEFAULTS["theme"]
|
||||
assert profile.font_size == editor.DEFAULTS["font_size"]
|
||||
assert profile.zoom_level == editor.DEFAULTS["zoom_level"]
|
||||
assert profile.boot_agent == editor.DEFAULTS["boot_agent"]
|
||||
assert profile.trust_all is True
|
||||
|
||||
|
||||
def test_resolve_reads_a_changed_site_setting():
|
||||
set_setting("workspace_editor_font_size", "22")
|
||||
assert editor.resolve(OWNER).font_size == 22
|
||||
|
||||
|
||||
def test_resolve_prefers_the_user_row():
|
||||
_prefs(font_size=20, theme="devplace-light")
|
||||
profile = editor.resolve(OWNER)
|
||||
assert profile.font_size == 20
|
||||
assert profile.theme == "devplace-light"
|
||||
|
||||
|
||||
def test_a_user_row_does_not_leak_to_another_user():
|
||||
_prefs(font_size=20)
|
||||
assert editor.resolve(OTHER).font_size == editor.DEFAULTS["font_size"]
|
||||
|
||||
|
||||
def test_zero_and_empty_inherit_but_zoom_zero_does_not():
|
||||
set_setting("workspace_editor_font_size", "22")
|
||||
set_setting("workspace_editor_zoom_level", "3")
|
||||
_prefs(font_size=0, theme="", zoom_level=0)
|
||||
profile = editor.resolve(OWNER)
|
||||
assert profile.font_size == 22
|
||||
assert profile.theme == editor.DEFAULTS["theme"]
|
||||
assert profile.zoom_level == 0
|
||||
|
||||
|
||||
def test_the_zoom_sentinel_inherits():
|
||||
set_setting("workspace_editor_zoom_level", "3")
|
||||
_prefs(zoom_level=editor.INHERIT_ZOOM)
|
||||
assert editor.resolve(OWNER).zoom_level == 3
|
||||
|
||||
|
||||
def test_the_boot_shell_sentinel_inherits_but_zero_does_not():
|
||||
_prefs(boot_shell=editor.INHERIT_FLAG)
|
||||
assert editor.resolve(OWNER).boot_shell is True
|
||||
_prefs(boot_shell=0)
|
||||
assert editor.resolve(OWNER).boot_shell is False
|
||||
|
||||
|
||||
def test_out_of_range_stored_values_are_clamped():
|
||||
_prefs(font_size=999, zoom_level=99, window_width=1)
|
||||
profile = editor.resolve(OWNER)
|
||||
assert profile.font_size == editor.BOUNDS["font_size"][1]
|
||||
assert profile.zoom_level == editor.BOUNDS["zoom_level"][1]
|
||||
assert profile.window_width == editor.BOUNDS["window_width"][0]
|
||||
|
||||
|
||||
def test_an_unknown_choice_falls_back_to_the_default():
|
||||
_prefs(theme="hot-pink", layout="chaos", window_mode="teleport")
|
||||
profile = editor.resolve(OWNER)
|
||||
assert profile.theme == editor.DEFAULTS["theme"]
|
||||
assert profile.layout == editor.DEFAULTS["layout"]
|
||||
assert profile.window_mode == editor.DEFAULTS["window_mode"]
|
||||
|
||||
|
||||
def test_the_container_size_comes_from_the_quota_resolver():
|
||||
profile = editor.resolve(OWNER, {"workspace_cpu_millicores": 3000})
|
||||
assert profile.cpu_millicores == 3000
|
||||
assert profile.cpu_cores() == 3.0
|
||||
assert profile.cpu_limit() == "3"
|
||||
assert profile.mem_limit() == quota.format_memory(profile.memory_mb)
|
||||
|
||||
|
||||
def test_source_map_reports_where_each_value_came_from():
|
||||
_prefs(font_size=20)
|
||||
sources = editor.source_map(OWNER)
|
||||
assert sources["font_size"] == editor.SOURCE_USER
|
||||
assert sources["theme"] == editor.SOURCE_SITE
|
||||
assert sources["trust_all"] == editor.SOURCE_SITE
|
||||
|
||||
|
||||
def test_reset_removes_the_row_and_restores_the_defaults():
|
||||
_prefs(font_size=20)
|
||||
assert editor.reset_prefs(OWNER, OWNER) is True
|
||||
assert editor.resolve(OWNER).font_size == editor.DEFAULTS["font_size"]
|
||||
assert editor.source_map(OWNER)["font_size"] == editor.SOURCE_SITE
|
||||
|
||||
|
||||
def test_reset_on_a_user_with_no_row_is_a_no_op():
|
||||
assert editor.reset_prefs(OWNER, OWNER) is False
|
||||
|
||||
|
||||
def test_saving_twice_updates_one_row():
|
||||
_prefs(font_size=20)
|
||||
_prefs(font_size=21)
|
||||
rows = list(get_table(editor.PREFS_TABLE).find(owner_id=OWNER, deleted_at=None))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["font_size"] == 21
|
||||
|
||||
|
||||
def test_merge_managed_seeds_an_absent_key():
|
||||
merged, managed = editor.merge_managed({}, {}, {"editor.fontSize": 14})
|
||||
assert merged["editor.fontSize"] == 14
|
||||
assert managed == {"editor.fontSize": 14}
|
||||
|
||||
|
||||
def test_merge_managed_updates_a_value_we_still_own():
|
||||
merged, _ = editor.merge_managed(
|
||||
{"editor.fontSize": 14}, {"editor.fontSize": 14}, {"editor.fontSize": 18}
|
||||
)
|
||||
assert merged["editor.fontSize"] == 18
|
||||
|
||||
|
||||
def test_merge_managed_respects_a_member_edit_forever():
|
||||
current = {"editor.fontSize": 30}
|
||||
managed = {"editor.fontSize": 14}
|
||||
for desired in (16, 18, 20):
|
||||
current, managed = editor.merge_managed(
|
||||
current, managed, {"editor.fontSize": desired}
|
||||
)
|
||||
assert current["editor.fontSize"] == 30
|
||||
|
||||
|
||||
def test_merge_managed_keeps_unrelated_member_keys():
|
||||
merged, _ = editor.merge_managed(
|
||||
{"files.autoSave": "on"}, {}, {"editor.fontSize": 14}
|
||||
)
|
||||
assert merged["files.autoSave"] == "on"
|
||||
|
||||
|
||||
def test_merge_managed_is_idempotent():
|
||||
desired = editor.settings_for(editor.resolve(OWNER))
|
||||
first, managed = editor.merge_managed({}, {}, desired)
|
||||
second, _ = editor.merge_managed(first, managed, desired)
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_settings_disable_workspace_trust_when_trust_all():
|
||||
settings = editor.settings_for(editor.resolve(OWNER))
|
||||
assert settings["security.workspace.trust.enabled"] is False
|
||||
assert settings["security.workspace.trust.startupPrompt"] == "never"
|
||||
assert settings["task.allowAutomaticTasks"] == "on"
|
||||
|
||||
|
||||
def test_settings_leave_trust_alone_when_the_kill_switch_is_off():
|
||||
set_setting("workspace_editor_trust_all", "0")
|
||||
settings = editor.settings_for(editor.resolve(OWNER))
|
||||
assert "security.workspace.trust.enabled" not in settings
|
||||
|
||||
|
||||
def test_settings_carry_the_devplace_terminal_profile():
|
||||
settings = editor.settings_for(editor.resolve(OWNER))
|
||||
profiles = settings["terminal.integrated.profiles.linux"]
|
||||
assert profiles["DevPlace Code"]["path"] == "/usr/bin/dpc"
|
||||
assert settings["terminal.integrated.defaultProfile.linux"] == "bash"
|
||||
|
||||
|
||||
def test_settings_apply_the_layout_preset():
|
||||
_prefs(layout="zen")
|
||||
settings = editor.settings_for(editor.resolve(OWNER))
|
||||
assert settings["workbench.activityBar.location"] == "hidden"
|
||||
assert settings["editor.minimap.enabled"] is False
|
||||
|
||||
|
||||
def test_the_system_theme_is_left_to_the_member():
|
||||
_prefs(theme="system")
|
||||
assert "workbench.colorTheme" not in editor.settings_for(editor.resolve(OWNER))
|
||||
|
||||
|
||||
def test_seed_state_writes_the_whole_tree():
|
||||
instance = _instance()
|
||||
profile = editor.resolve(OWNER)
|
||||
assert editor.seed_state(instance, profile) is True
|
||||
root = editor.state_dir(instance)
|
||||
settings = json.loads((root / "data" / "User" / "settings.json").read_text())
|
||||
managed = json.loads((root / "data" / "User" / editor.MANAGED_FILE).read_text())
|
||||
payload = json.loads((root / editor.PROFILE_FILE).read_text())
|
||||
assert settings["editor.fontSize"] == profile.font_size
|
||||
assert managed["editor.fontSize"] == profile.font_size
|
||||
assert payload["editor"]["theme"] == profile.theme
|
||||
assert payload["app_name"] == editor.APP_NAME
|
||||
|
||||
|
||||
def test_seed_state_never_clobbers_a_member_edit():
|
||||
instance = _instance()
|
||||
editor.seed_state(instance, editor.resolve(OWNER))
|
||||
settings_path = editor.state_dir(instance) / "data" / "User" / "settings.json"
|
||||
stored = json.loads(settings_path.read_text())
|
||||
stored["editor.fontSize"] = 30
|
||||
stored["files.autoSave"] = "on"
|
||||
settings_path.write_text(json.dumps(stored))
|
||||
|
||||
set_setting("workspace_editor_font_size", "18")
|
||||
set_setting("workspace_editor_terminal_font_size", "20")
|
||||
editor.seed_state(instance, editor.resolve(OWNER))
|
||||
|
||||
written = json.loads(settings_path.read_text())
|
||||
assert written["editor.fontSize"] == 30
|
||||
assert written["files.autoSave"] == "on"
|
||||
assert written["terminal.integrated.fontSize"] == 20
|
||||
|
||||
|
||||
def test_seed_state_replaces_an_unparseable_settings_file():
|
||||
instance = _instance()
|
||||
user_dir = editor.state_dir(instance) / "data" / "User"
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
(user_dir / "settings.json").write_text("{not json at all")
|
||||
assert editor.seed_state(instance, editor.resolve(OWNER)) is True
|
||||
assert json.loads((user_dir / "settings.json").read_text())["editor.fontSize"]
|
||||
|
||||
|
||||
def test_seed_state_fails_soft_on_an_unwritable_directory(monkeypatch):
|
||||
def explode(*args, **kwargs):
|
||||
raise OSError("read-only file system")
|
||||
|
||||
monkeypatch.setattr("pathlib.Path.mkdir", explode)
|
||||
assert editor.seed_state(_instance(), editor.resolve(OWNER)) is False
|
||||
|
||||
|
||||
def test_argv_carries_the_devplace_brand():
|
||||
instance = _instance()
|
||||
command = editor.argv(instance, editor.resolve(OWNER))
|
||||
assert command[0] == "code-server"
|
||||
assert command[command.index("--app-name") + 1] == editor.APP_NAME
|
||||
assert "--disable-getting-started-override" in command
|
||||
assert "--disable-telemetry" in command
|
||||
|
||||
|
||||
def test_argv_disables_workspace_trust_when_trust_all():
|
||||
command = editor.argv(_instance(), editor.resolve(OWNER))
|
||||
assert "--disable-workspace-trust" in command
|
||||
|
||||
|
||||
def test_argv_keeps_workspace_trust_when_the_kill_switch_is_off():
|
||||
set_setting("workspace_editor_trust_all", "0")
|
||||
command = editor.argv(_instance(), editor.resolve(OWNER))
|
||||
assert "--disable-workspace-trust" not in command
|
||||
|
||||
|
||||
def test_argv_keeps_password_auth():
|
||||
command = editor.argv(_instance(), editor.resolve(OWNER))
|
||||
assert command[command.index("--auth") + 1] == "password"
|
||||
assert "none" not in command
|
||||
|
||||
|
||||
def test_argv_paths_sit_under_the_state_mount():
|
||||
command = editor.argv(_instance(), editor.resolve(OWNER))
|
||||
assert command[command.index("--user-data-dir") + 1].startswith(
|
||||
WORKSPACE_STATE_MOUNT
|
||||
)
|
||||
assert command[command.index("--extensions-dir") + 1].startswith(
|
||||
WORKSPACE_STATE_MOUNT
|
||||
)
|
||||
assert command[-1] == WORKSPACE_MOUNT
|
||||
|
||||
|
||||
def test_argv_binds_the_instance_editor_port():
|
||||
instance = _instance()
|
||||
instance["editor_port"] = 9443
|
||||
command = editor.argv(instance, editor.resolve(OWNER))
|
||||
assert command[command.index("--bind-addr") + 1] == "0.0.0.0:9443"
|
||||
|
||||
|
||||
def test_every_optional_flag_is_declared():
|
||||
command = editor.argv(_instance(), editor.resolve(OWNER))
|
||||
for flag in editor.OPTIONAL_FLAGS:
|
||||
assert flag in command
|
||||
|
||||
|
||||
def test_env_for_exports_the_profile_to_the_container():
|
||||
env = editor.env_for(editor.resolve(OWNER))
|
||||
assert env["DEVPLACE_EDITOR_APP_NAME"] == editor.APP_NAME
|
||||
assert env["DEVPLACE_EDITOR_PROFILE"].endswith(editor.PROFILE_FILE)
|
||||
assert env["DEVPLACE_EDITOR_BOOT_AGENT"] == editor.DEFAULTS["boot_agent"]
|
||||
assert env["DEVPLACE_EDITOR_TRUST_ALL"] == "1"
|
||||
assert all(isinstance(value, str) for value in env.values())
|
||||
|
||||
|
||||
def test_restart_is_not_required_before_the_first_boot():
|
||||
instance = _instance()
|
||||
assert editor.restart_required(instance, editor.resolve(OWNER)) is False
|
||||
|
||||
|
||||
def test_restart_is_not_required_when_nothing_changed():
|
||||
instance = _instance()
|
||||
profile = editor.resolve(OWNER)
|
||||
editor.seed_state(instance, profile)
|
||||
assert editor.restart_required(instance, profile) is False
|
||||
|
||||
|
||||
def test_restart_is_required_after_a_preference_change():
|
||||
instance = _instance()
|
||||
editor.seed_state(instance, editor.resolve(OWNER))
|
||||
_prefs(font_size=27)
|
||||
assert editor.restart_required(instance, editor.resolve(OWNER)) is True
|
||||
|
||||
|
||||
def test_view_carries_the_profile_and_its_sources():
|
||||
_prefs(theme="devplace-light")
|
||||
payload = editor.view(OWNER)
|
||||
assert payload["theme"] == "devplace-light"
|
||||
assert payload["sources"]["theme"] == editor.SOURCE_USER
|
||||
assert payload["cpu_cores"] == editor.resolve(OWNER).cpu_cores()
|
||||
|
||||
|
||||
def test_settings_suppress_a_foreign_ai_assistant():
|
||||
settings = editor.settings_for(editor.resolve(OWNER))
|
||||
assert settings["chat.disableAIFeatures"] is True
|
||||
assert settings["workbench.secondarySideBar.defaultVisibility"] == "hidden"
|
||||
|
||||
|
||||
def test_settings_open_straight_into_the_workspace():
|
||||
assert editor.settings_for(editor.resolve(OWNER))["workbench.startupEditor"] == "none"
|
||||
|
||||
|
||||
def test_the_agent_working_files_never_sync_into_the_project():
|
||||
from devplacepy.project_files import SYNC_SKIP_NAMES
|
||||
|
||||
assert ".dpc" in SYNC_SKIP_NAMES
|
||||
assert "dpc.log" in SYNC_SKIP_NAMES
|
||||
assert ".devplace" in SYNC_SKIP_NAMES
|
||||
@@ -0,0 +1,112 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import get_table, init_db, set_setting
|
||||
from devplacepy.services.containers.workspace import quota
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
OWNER = "quota-owner"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _quota_db():
|
||||
init_db()
|
||||
yield
|
||||
get_table(quota.RULES_TABLE).delete()
|
||||
for key in quota.SETTING_KEYS.values():
|
||||
set_setting(key, str(quota.DEFAULTS[_key_for(key)]))
|
||||
|
||||
|
||||
def _key_for(setting: str) -> str:
|
||||
for key, value in quota.SETTING_KEYS.items():
|
||||
if value == setting:
|
||||
return key
|
||||
raise KeyError(setting)
|
||||
|
||||
|
||||
def _rule(**values) -> dict:
|
||||
row = {
|
||||
"uid": generate_uid(),
|
||||
"owner_kind": "user",
|
||||
"owner_id": OWNER,
|
||||
"label": "test",
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
**{column: 0 for column in quota.RULE_COLUMNS},
|
||||
}
|
||||
row.update(values)
|
||||
get_table(quota.RULES_TABLE).insert(row)
|
||||
return row
|
||||
|
||||
|
||||
def test_format_cpu_renders_docker_values():
|
||||
assert quota.format_cpu(2000) == "2"
|
||||
assert quota.format_cpu(1500) == "1.5"
|
||||
assert quota.format_cpu(250) == "0.25"
|
||||
assert quota.format_cpu(0) == ""
|
||||
assert quota.format_cpu(-1) == ""
|
||||
|
||||
|
||||
def test_format_memory_renders_docker_values():
|
||||
assert quota.format_memory(2048) == "2048m"
|
||||
assert quota.format_memory(0) == ""
|
||||
|
||||
|
||||
def test_limits_expose_the_docker_strings():
|
||||
limits = quota.resolve()
|
||||
assert limits.cpu_limit() == quota.format_cpu(limits.cpu_millicores)
|
||||
assert limits.mem_limit() == quota.format_memory(limits.memory_mb)
|
||||
|
||||
|
||||
def test_defaults_resolve_without_a_rule():
|
||||
limits = quota.resolve(OWNER)
|
||||
assert limits.cpu_millicores == quota.DEFAULTS["cpu_millicores"]
|
||||
assert limits.memory_mb == quota.DEFAULTS["memory_mb"]
|
||||
|
||||
|
||||
def test_a_user_rule_resolves():
|
||||
_rule(cpu_millicores=4000, memory_mb=8192, disk_quota_mb=512)
|
||||
limits = quota.resolve(OWNER)
|
||||
assert limits.cpu_millicores == 4000
|
||||
assert limits.memory_mb == 8192
|
||||
assert limits.disk_quota_mb == 512
|
||||
|
||||
|
||||
def test_a_user_rule_does_not_leak_to_another_user():
|
||||
_rule(cpu_millicores=4000)
|
||||
assert quota.resolve("someone-else").cpu_millicores == (
|
||||
quota.DEFAULTS["cpu_millicores"]
|
||||
)
|
||||
|
||||
|
||||
def test_a_soft_deleted_rule_is_ignored():
|
||||
row = _rule(cpu_millicores=4000)
|
||||
get_table(quota.RULES_TABLE).update(
|
||||
{"uid": row["uid"], "deleted_at": "2026-01-01T00:00:00+00:00"}, ["uid"]
|
||||
)
|
||||
assert quota.resolve(OWNER).cpu_millicores == quota.DEFAULTS["cpu_millicores"]
|
||||
|
||||
|
||||
def test_an_instance_override_beats_the_rule():
|
||||
_rule(cpu_millicores=4000, memory_mb=8192)
|
||||
limits = quota.resolve(
|
||||
OWNER, {"workspace_cpu_millicores": 1000, "workspace_memory_mb": 512}
|
||||
)
|
||||
assert limits.cpu_millicores == 1000
|
||||
assert limits.memory_mb == 512
|
||||
|
||||
|
||||
def test_only_declared_instance_overrides_are_read():
|
||||
limits = quota.resolve(OWNER, {"workspace_max_tunnels": 99})
|
||||
assert limits.max_tunnels == quota.DEFAULTS["max_tunnels"]
|
||||
assert "max_tunnels" not in quota.INSTANCE_OVERRIDE_COLUMNS
|
||||
|
||||
|
||||
def test_the_idle_warning_stays_below_the_idle_stop():
|
||||
set_setting("workspace_idle_stop_minutes", "10")
|
||||
set_setting("workspace_idle_warn_minutes", "30")
|
||||
limits = quota.resolve()
|
||||
assert limits.idle_warn_minutes < limits.idle_stop_minutes
|
||||
Reference in New Issue
Block a user