"""Stack storage and status derivation. ``compose_service`` is where the file-is-the-truth model lives: slugs become directory names, directory names become compose project names, and container states become the one status the UI shows. All three are pure enough to test without a Docker daemon. """ from __future__ import annotations import os import pytest @pytest.fixture def svc(): from services import compose_service return compose_service # --------------------------------------------------------------------------- # # Slugs — these become directory names and compose project names # --------------------------------------------------------------------------- # @pytest.mark.parametrize( "name,expected", [ ("Jellyfin", "jellyfin"), ("My Media Server", "my-media-server"), ("Paperless-NGX", "paperless-ngx"), (" spaces ", "spaces"), ("Wiki.js", "wiki-js"), ("a---b", "a-b"), ("--leading-and-trailing--", "leading-and-trailing"), ("Ümlaut Stack", "mlaut-stack"), ], ) def test_slugify(svc, name, expected): assert svc.slugify(name) == expected @pytest.mark.parametrize("name", ["", " ", "///", "..."]) def test_slugify_never_returns_an_empty_or_traversing_slug(svc, name): """The slug is joined onto STACKS_DIR, so an empty or dotted result would point the stack directory at the root itself.""" slug = svc.slugify(name) assert slug assert slug not in (".", "..") assert "/" not in slug def test_stack_dir_stays_under_the_root(svc): root = svc.stacks_root() assert svc.stack_dir("jellyfin").startswith(root + os.sep) # --------------------------------------------------------------------------- # # Status derivation # --------------------------------------------------------------------------- # @pytest.mark.parametrize( "states,expected", [ ([], "stopped"), (["running"], "running"), (["running", "running"], "running"), (["running", "exited"], "partial"), (["exited", "exited"], "stopped"), (["created"], "stopped"), (["running", "dead"], "error"), (["dead"], "error"), ], ) def test_status_from_states(svc, states, expected): assert svc._status_from_states(states) == expected def test_busy_stacks_report_as_updating(svc): """The busy flag has to win over the container states, or a stack shows 'stopped' for the moment between `down` and `up` during an update.""" svc.mark_busy("busy-stack") try: assert svc.compute_status("busy-stack", containers=[]) == "updating" finally: svc.clear_busy("busy-stack") assert svc.compute_status("busy-stack", containers=[]) == "stopped" # --------------------------------------------------------------------------- # # Reading and writing stack files # --------------------------------------------------------------------------- # def test_write_compose_keeps_a_backup_of_the_previous_version(svc, tmp_path): """Every save writes a .bak — the raw material for a rollback feature.""" stack_id = "backup-check" svc.write_compose(stack_id, "services:\n a:\n image: alpine\n", override=str(tmp_path)) svc.write_compose(stack_id, "services:\n b:\n image: nginx\n", override=str(tmp_path)) directory = tmp_path / stack_id assert "image: nginx" in (directory / "compose.yaml").read_text() assert "image: alpine" in (directory / "compose.yaml.bak").read_text() def test_reading_a_missing_stack_returns_empty_not_an_error(svc, tmp_path): assert svc.read_compose("does-not-exist", override=str(tmp_path)) == "" assert svc.read_env("does-not-exist", override=str(tmp_path)) == "" def test_discover_stacks_finds_only_directories_with_a_compose_file(svc, tmp_path): (tmp_path / "real").mkdir() (tmp_path / "real" / "compose.yaml").write_text("services: {}\n") (tmp_path / "legacy").mkdir() (tmp_path / "legacy" / "docker-compose.yml").write_text("services: {}\n") (tmp_path / "not-a-stack").mkdir() (tmp_path / "not-a-stack" / "readme.txt").write_text("hi\n") assert svc.discover_stacks(override=str(tmp_path)) == ["legacy", "real"] def test_clone_refuses_to_overwrite_an_existing_stack(svc, tmp_path): svc.write_compose("source", "services: {}\n", override=str(tmp_path)) svc.write_compose("target", "services: {}\n", override=str(tmp_path)) with pytest.raises(svc.StackFileError): svc.clone_stack_files("source", "target", override=str(tmp_path)) # --------------------------------------------------------------------------- # # Per-stack secrets # --------------------------------------------------------------------------- # @pytest.mark.parametrize("name", ["../escape", "a/b", ".hidden", "", "na me"]) def test_secret_names_reject_traversal_and_hidden_files(name): """Secret names become filenames inside the stack's .secrets directory.""" from services import secret_service with pytest.raises(secret_service.SecretError): secret_service._check_name(name) def test_secret_files_are_written_owner_only(tmp_path): from services import secret_service secret_service.write_secret("s", "secret", "db_password", "hunter2", override=str(tmp_path)) path = tmp_path / "s" / ".secrets" / "db_password" assert path.read_text() == "hunter2" assert oct(path.stat().st_mode)[-3:] == "600" assert oct(path.parent.stat().st_mode)[-3:] == "700" def test_listing_secrets_never_returns_their_content(tmp_path): from services import secret_service secret_service.write_secret("s2", "secret", "token", "top-secret", override=str(tmp_path)) listed = secret_service.list_secrets("s2", "secret", override=str(tmp_path)) assert [item["name"] for item in listed] == ["token"] assert "top-secret" not in str(listed)