Files
bambuddy/backend/tests/unit/test_library_file_path_guard.py
T
Nathen Fredrick 3adce435ee feat: add embedded GCode viewer (#963)
* feat: add embedded GCode viewer

Adds PrettyGCode as a built-in GCode visualiser embedded directly in the
Bambuddy layout, so users can preview and inspect GCode files without
leaving the dashboard.
2026-04-22 11:14:42 +02:00

116 lines
4.3 KiB
Python

"""Tests for to_absolute_path() path-traversal guard in library routes.
Covers three behaviours added/changed by the GCode viewer PR:
1. Relative paths that escape base_dir are rejected with ValueError.
2. Path.is_relative_to() is used instead of startswith(str(base)),
avoiding the /data/app vs /data/app_evil prefix-confusion bug.
3. Legacy absolute paths (pre-migration DB rows) are returned verbatim
instead of raising ValueError.
"""
from pathlib import Path
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _call(relative_path, base_dir):
"""Call to_absolute_path with base_dir patched to *base_dir*."""
from backend.app.api.routes.library import to_absolute_path
with patch("backend.app.api.routes.library.app_settings") as mock_settings:
mock_settings.base_dir = str(base_dir)
return to_absolute_path(relative_path)
# ---------------------------------------------------------------------------
# None / empty guard
# ---------------------------------------------------------------------------
class TestNullInputs:
def test_none_returns_none(self, tmp_path):
assert _call(None, tmp_path) is None
def test_empty_string_returns_none(self, tmp_path):
assert _call("", tmp_path) is None
# ---------------------------------------------------------------------------
# Relative path traversal guard
# ---------------------------------------------------------------------------
class TestRelativePathTraversal:
def test_normal_relative_path_resolves(self, tmp_path):
"""A safe relative path resolves to base_dir / rel."""
base = tmp_path / "data"
base.mkdir()
result = _call("files/model.gcode", base)
assert result == (base / "files" / "model.gcode").resolve()
def test_traversal_via_dotdot_raises(self, tmp_path):
"""../etc/passwd must be rejected."""
base = tmp_path / "data"
base.mkdir()
with pytest.raises(ValueError, match="escapes base directory"):
_call("../etc/passwd", base)
def test_traversal_via_nested_dotdot_raises(self, tmp_path):
"""files/../../etc/passwd must be rejected."""
base = tmp_path / "data"
base.mkdir()
with pytest.raises(ValueError, match="escapes base directory"):
_call("files/../../etc/passwd", base)
def test_prefix_confusion_is_blocked(self, tmp_path):
"""Ensure /data/app_evil/secret is not permitted when base is /data/app.
A naive startswith(str(base)) check would allow this because
'/data/app_evil'.startswith('/data/app') is True.
Path.is_relative_to() must be used instead.
"""
# Simulate: base = /tmp/.../data_app, sibling = /tmp/.../data_app_evil
base = tmp_path / "data_app"
sibling = tmp_path / "data_app_evil"
base.mkdir()
sibling.mkdir()
# Construct a relative path that resolves into the *sibling* dir.
# from base: ../data_app_evil/secret
with pytest.raises(ValueError, match="escapes base directory"):
_call("../data_app_evil/secret", base)
# ---------------------------------------------------------------------------
# Legacy absolute path pass-through
# ---------------------------------------------------------------------------
class TestLegacyAbsolutePaths:
def test_absolute_path_inside_base_is_returned(self, tmp_path):
"""An absolute path that happens to be inside base_dir is returned as-is."""
base = tmp_path / "data"
base.mkdir()
abs_path = str(base / "archive" / "old.3mf")
result = _call(abs_path, base)
assert result == Path(abs_path).resolve()
def test_absolute_path_outside_base_is_returned(self, tmp_path):
"""An absolute path outside base_dir is returned verbatim (legacy compat).
Pre-migration DB rows may store absolute paths that predate the
base_dir layout. These must NOT raise ValueError; callers are
responsible for further existence checks.
"""
base = tmp_path / "data"
base.mkdir()
outside = tmp_path / "old_archive" / "legacy.3mf"
result = _call(str(outside), base)
assert result == outside.resolve()