"""Found clicking through the UI for 0.67.1. * The file browser opens at ``/``, which the default roots (/mnt, /opt, …) do not include: the page opened on "outside the allowed browse roots" and offered no way into any root. Paths above the roots now list the way to them. * A root could not be deleted, but it could be moved — the whole tree with it. * Locally built images (compose ``build:``) were checked against Docker Hub, which answers 401 for a repository that does not exist: "needs credentials". * Volumes, networks and images of a stack whose folder name compose had to normalise ("Nextcloud" -> project "nextcloud") were grouped as leftovers of a removed stack. """ from __future__ import annotations import asyncio import pytest @pytest.fixture def roots(tmp_path, monkeypatch): from config import settings (tmp_path / "srv" / "data").mkdir(parents=True) (tmp_path / "opt").mkdir() monkeypatch.setattr(settings, "HOST_ROOT_PREFIX", str(tmp_path), raising=False) monkeypatch.setattr( settings, "ALLOWED_BROWSE_ROOTS", ["/opt", "/srv/data", "/media"], raising=False ) return tmp_path def test_the_top_of_the_tree_lists_the_way_to_the_roots(roots): from services import device_service top = device_service.browse("/") assert top["virtual"] is True # /media does not exist on this host, so it is not offered. assert [e["name"] for e in top["entries"]] == ["opt", "srv"] assert [e["name"] for e in device_service.browse("/srv")["entries"]] == ["data"] inside = device_service.browse("/srv/data") assert "virtual" not in inside and inside["parent"] == "/srv" def test_a_path_leading_nowhere_is_still_refused(roots): from services import device_service with pytest.raises(device_service.BrowseError): device_service.browse("/etc") def test_a_root_cannot_be_moved_or_renamed(roots): from services import file_service with pytest.raises(file_service.BrowseError, match="browse root"): file_service.move("/srv/data", "/opt") with pytest.raises(file_service.BrowseError, match="browse root"): file_service.rename("/opt", "opt2") assert (roots / "srv" / "data").is_dir() def test_moving_inside_a_root_still_works(roots): from services import file_service (roots / "opt" / "a.txt").write_text("x") file_service.move("/opt/a.txt", "/srv/data") assert (roots / "srv" / "data" / "a.txt").read_text() == "x" @pytest.mark.parametrize( "attrs, local", [ ({"Identity": {"Build": [{"Ref": "x"}]}, "RepoDigests": ["app@sha256:1"]}, True), ({"Identity": {"Pull": [{"Repository": "docker.io/library/nginx"}]}}, False), ({"RepoDigests": []}, True), # older daemon, never pulled ({"RepoDigests": ["nginx@sha256:1"]}, False), ({"Identity": {}, "RepoDigests": ["nginx@sha256:1"]}, False), (None, False), ], ) def test_where_an_image_came_from(attrs, local): from services import update_service assert update_service._built_locally(attrs) is local def test_a_local_build_is_not_asked_about_at_the_registry(monkeypatch): from services import update_service monkeypatch.setattr( update_service, "_local_attrs", lambda image: {"Identity": {"Build": [{"Ref": "x"}]}} ) async def no_registry(image): raise AssertionError("registry contacted for a local build") monkeypatch.setattr(update_service, "remote_digest", no_registry) status = asyncio.run(update_service.check_image("myapp-web:latest")) assert status.local is True and status.error is None and not status.update_available def test_resources_of_a_normalised_stack_are_grouped_under_it(as_admin, db, monkeypatch): from sqlmodel import Session from database import engine from models.stack import Stack from services import network_service, volume_service with Session(engine) as session: if not session.get(Stack, "Nextcloud"): session.add(Stack(id="Nextcloud", name="Nextcloud")) session.commit() monkeypatch.setattr( volume_service, "list_volumes", lambda: [{"name": "nextcloud_db", "stack": "nextcloud"}] ) monkeypatch.setattr( network_service, "list_networks", lambda: [{"name": "x", "stack": "elsewhere"}, {"name": "bridge", "stack": None}], ) assert as_admin.get("/api/volumes").json()[0]["stack"] == "Nextcloud" assert [n["stack"] for n in as_admin.get("/api/networks").json()] == ["elsewhere", None] class _Container: def __init__(self, mounts): self.attrs = {"Mounts": mounts} @pytest.mark.parametrize( "mounts, host", [ # The usual: same path on both sides — no warning. ([{"Type": "bind", "Source": "/opt/stacks", "Destination": "/opt/stacks"}], None), # A real mismatch. ( [{"Type": "bind", "Source": "/srv/compose", "Destination": "/opt/stacks"}], "/srv/compose", ), # The folder above STACKS_DIR is what is mounted. ([{"Type": "bind", "Source": "/srv", "Destination": "/opt"}], "/srv/stacks"), # Not mounted at all. ([{"Type": "volume", "Source": "/var/lib/docker/volumes/x", "Destination": "/data"}], None), ], ) def test_the_host_stacks_path_comes_from_docker_not_mountinfo(monkeypatch, mounts, host): """mountinfo's root field is relative to the filesystem: a stacks folder on its own disk or btrfs subvolume showed a mismatch that was not there.""" from config import settings from services import self_update_service, stack_assets_service monkeypatch.setattr(settings, "STACKS_DIR", "/opt/stacks") monkeypatch.setattr(stack_assets_service, "_HOST_STACKS_DIR", None) monkeypatch.setattr(self_update_service, "_own_container", lambda: _Container(mounts)) mismatch = stack_assets_service.stacks_path_mismatch() assert (mismatch["host"] if mismatch else None) == host def test_backup_names_carry_the_time_on_the_users_clock(monkeypatch): """Named in UTC, a backup taken at 12:21 in Berlin read as …-102154.""" from datetime import datetime, timezone from services import backup_service, compose_service monkeypatch.setattr( compose_service, "now", lambda: datetime(2026, 10, 15, 10, 21, 54, tzinfo=timezone.utc) ) berlin = backup_service.backup_filename("app", True, tz="Europe/Berlin") assert berlin == "backup-app-full-20261015-122154.tar.gz" # No zone, or one that does not exist: UTC, as before. assert backup_service.backup_filename("app", False).endswith("-20261015-102154.tar.gz") assert backup_service.backup_filename("app", False, tz="Mars/Olympus").endswith("-102154.tar.gz") # Retention still recognises the files. assert backup_service.backup_name_pattern("app").match(berlin) COMPOSE_WITH_SECRETS = """services: db: image: postgres:16 environment: POSTGRES_PASSWORD: hunter2 POSTGRES_USER: app API_KEY: ${API_KEY:-changeme} DB_PASS: ${DB_PASS} KEYCLOAK_ADMIN: admin app: environment: - "TS_AUTHKEY=tskey-123" - TZ=Europe/Berlin """ def test_inline_credentials_are_masked_and_the_rest_left_alone(): from services.compose_service import MASK, mask_secrets masked = mask_secrets(COMPOSE_WITH_SECRETS) assert "hunter2" not in masked and "tskey-123" not in masked and "changeme" not in masked assert f"POSTGRES_PASSWORD: {MASK}" in masked assert f'- "TS_AUTHKEY={MASK}"' in masked # A bare variable is no secret; names that merely contain "key" are not either. for kept in ("DB_PASS: ${DB_PASS}", "KEYCLOAK_ADMIN: admin", "POSTGRES_USER: app", "TZ=Europe/Berlin"): assert kept in masked assert masked.count("\n") == COMPOSE_WITH_SECRETS.count("\n") def test_the_read_only_role_gets_the_masked_compose_file(as_admin, as_user, db): from services import compose_service assert as_admin.post("/api/stacks", json={"name": "masked", "yaml": COMPOSE_WITH_SECRETS}).status_code == 201 try: assert "hunter2" in as_admin.get("/api/stacks/masked").json()["yaml"] assert "hunter2" not in as_user.get("/api/stacks/masked").json()["yaml"] finally: compose_service.delete_stack_files("masked") def test_scan_all_covers_only_stackpilots_own_stacks(as_admin, db, monkeypatch): from sqlmodel import Session from database import engine from models.stack import Stack from services import image_service, scan_service with Session(engine) as session: if not session.get(Stack, "Mine"): session.add(Stack(id="Mine", name="Mine")) session.commit() monkeypatch.setattr( image_service, "list_images", lambda: [ {"tag": "mine:1", "stacks": ["mine"]}, {"tag": "theirs:1", "stacks": ["some-other-project"]}, {"tag": "loose:1", "stacks": []}, ], ) swept = [] monkeypatch.setattr( scan_service, "start_sweep", lambda images, on_done=None: swept.append(images) or {"active": True} ) assert as_admin.post("/api/images/scan-all?background=true").status_code == 202 assert swept == [["mine:1"]]