Initial commit

This commit is contained in:
U-C4N
2026-04-27 12:20:57 +03:00
commit 7b121ab416
49 changed files with 9904 additions and 0 deletions
View File
+15
View File
@@ -0,0 +1,15 @@
"""Shared fixtures for AutoCAD MCP tests."""
import pytest_asyncio
from backends.ezdxf_backend import EzdxfBackend
@pytest_asyncio.fixture
async def backend():
"""Create an ezdxf backend with a fresh empty document."""
b = EzdxfBackend()
await b.connect()
await b.drawing_new()
yield b
await b.disconnect()
+114
View File
@@ -0,0 +1,114 @@
"""Tests for analysis tools and entity modification via ezdxf backend."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# Entity modification tests
# ---------------------------------------------------------------------------
async def test_entity_copy(backend):
line = await backend.entity_create_line(0, 0, 100, 0)
copy = await backend.entity_copy(line.handle, 0, 50)
assert copy.handle
assert copy.handle != line.handle
entities = await backend.entity_list()
assert len(entities) == 2
async def test_entity_offset(backend):
line = await backend.entity_create_line(0, 0, 100, 0)
result = await backend.entity_offset(line.handle, 10)
assert result.handle
async def test_entity_array_rectangular(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
copies = await backend.entity_array_rectangular(line.handle, 3, 4, 20, 30)
assert len(copies) == 11
async def test_entity_set_properties(backend):
await backend.layer_create("PROP_LAYER", color=5)
line = await backend.entity_create_line(0, 0, 100, 0)
result = await backend.entity_set_properties(line.handle, layer="PROP_LAYER", color=3)
assert result.get("ok") is True
got = await backend.entity_get(line.handle)
assert got.layer == "PROP_LAYER"
assert got.color == 3
async def test_entity_delete_many(backend):
h1 = (await backend.entity_create_line(0, 0, 10, 0)).handle
h2 = (await backend.entity_create_line(10, 0, 20, 0)).handle
h3 = (await backend.entity_create_line(20, 0, 30, 0)).handle
entities_before = await backend.entity_list()
assert len(entities_before) == 3
await backend.entity_delete(h1)
await backend.entity_delete(h2)
await backend.entity_delete(h3)
entities_after = await backend.entity_list()
assert len(entities_after) == 0
# ---------------------------------------------------------------------------
# Analysis tests
# ---------------------------------------------------------------------------
async def test_analysis_entity_stats(backend):
await backend.entity_create_line(0, 0, 100, 0)
await backend.entity_create_circle(50, 50, 25)
await backend.entity_create_line(0, 100, 100, 100)
stats = await backend.analysis_stats()
assert isinstance(stats, dict)
by_type = stats.get("by_type", stats)
total = stats.get("total", stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0))
assert total >= 3
async def test_analysis_find_in_region(backend):
await backend.entity_create_line(10, 10, 20, 20)
await backend.entity_create_line(500, 500, 600, 600)
result = await backend.analysis_entities_in_region(0, 0, 50, 50)
assert len(result) >= 1
async def test_analysis_measure_distance(backend):
dist = await backend.analysis_measure_distance(0, 0, 3, 4)
assert dist == pytest.approx(5.0, abs=0.001)
async def test_analysis_measure_area(backend):
area = await backend.analysis_measure_area([[0, 0], [10, 0], [10, 10], [0, 10]])
assert area == pytest.approx(100.0, abs=0.01)
async def test_analysis_bounding_box(backend):
await backend.entity_create_line(0, 0, 100, 0)
await backend.entity_create_line(0, 0, 0, 50)
bbox = await backend.analysis_bounding_box()
assert isinstance(bbox, dict)
async def test_analysis_select_by_layer(backend):
await backend.layer_create("SEL_LAYER")
await backend.entity_create_line(0, 0, 10, 0, layer="SEL_LAYER")
await backend.entity_create_circle(50, 50, 5)
result = await backend.analysis_select_by_layer("SEL_LAYER")
assert len(result) == 1
assert result[0].layer == "SEL_LAYER"
async def test_analysis_select_by_type(backend):
await backend.entity_create_line(0, 0, 10, 0)
await backend.entity_create_circle(50, 50, 5)
await backend.entity_create_line(0, 100, 10, 100)
result = await backend.analysis_select_by_type("CIRCLE")
assert len(result) == 1
assert result[0].type == "CIRCLE"
+128
View File
@@ -0,0 +1,128 @@
"""Tests for batch operations, templates, and validation."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# Batch create tests
# ---------------------------------------------------------------------------
async def test_batch_create_lines(backend):
"""Batch create should work via individual backend calls."""
await backend.entity_create_line(0, 0, 10, 0)
await backend.entity_create_line(10, 0, 20, 0)
await backend.entity_create_circle(5, 5, 3)
entities = await backend.entity_list()
assert len(entities) == 3
async def test_batch_modify_move(backend):
"""Batch move multiple entities."""
line = await backend.entity_create_line(0, 0, 100, 0)
await backend.entity_move(line.handle, 10, 20)
got = await backend.entity_get(line.handle)
props = got.properties or {}
start = props.get("start", [])
if start:
assert abs(start[0] - 10) < 0.01
assert abs(start[1] - 20) < 0.01
async def test_batch_modify_delete(backend):
"""Batch delete multiple entities."""
h1 = (await backend.entity_create_line(0, 0, 10, 0)).handle
h2 = (await backend.entity_create_line(10, 0, 20, 0)).handle
await backend.entity_delete(h1)
await backend.entity_delete(h2)
entities = await backend.entity_list()
assert len(entities) == 0
# ---------------------------------------------------------------------------
# Template tests
# ---------------------------------------------------------------------------
async def test_template_architectural_layers(backend):
"""Architectural template should create standard layers."""
layer_defs = [
("WALLS", 7), ("DOORS", 3), ("WINDOWS", 4),
("FURNITURE", 8), ("DIMENSIONS", 2), ("TEXT", 7),
]
for name, color in layer_defs:
await backend.layer_create(name, color=color)
layers = await backend.layer_list()
names = {lyr.name for lyr in layers}
assert "WALLS" in names
assert "DOORS" in names
assert "WINDOWS" in names
async def test_template_mechanical_layers(backend):
"""Mechanical template layers."""
layer_defs = [
("VISIBLE", 7), ("HIDDEN", 1), ("CENTER", 3),
("DIMENSIONS", 2), ("SECTION", 5),
]
for name, color in layer_defs:
await backend.layer_create(name, color=color)
layers = await backend.layer_list()
names = {lyr.name for lyr in layers}
assert "VISIBLE" in names
assert "HIDDEN" in names
assert "CENTER" in names
async def test_template_electrical_layers(backend):
"""Electrical template layers."""
await backend.layer_create("POWER_LINES", color=7)
await backend.layer_create("CONTROL_LINES", color=3)
layers = await backend.layer_list()
names = {lyr.name for lyr in layers}
assert "POWER_LINES" in names
assert "CONTROL_LINES" in names
# ---------------------------------------------------------------------------
# Validation tests
# ---------------------------------------------------------------------------
async def test_validation_empty_layers(backend):
"""Empty layers should be detected."""
await backend.layer_create("EMPTY_LAYER")
layers = await backend.layer_list()
entities = await backend.entity_list(limit=50000)
used = {e.layer for e in entities}
empty = [lyr for lyr in layers if lyr.name != "0" and lyr.name not in used]
assert len(empty) >= 1
assert any(lyr.name == "EMPTY_LAYER" for lyr in empty)
async def test_validation_no_issues_clean_drawing(backend):
"""A clean drawing with no empty layers should have no issues."""
await backend.entity_create_line(0, 0, 100, 0)
entities = await backend.entity_list()
assert len(entities) >= 1
async def test_validation_zero_length_detection(backend):
"""Zero-length lines should be detectable."""
await backend.entity_create_line(50, 50, 50, 50)
lines = await backend.entity_list(type_filter="LINE")
assert len(lines) == 1
props = lines[0].properties or {}
start = props.get("start", [])
end = props.get("end", [])
if start and end:
dx = end[0] - start[0]
dy = end[1] - start[1]
length = (dx * dx + dy * dy) ** 0.5
assert length < 0.01
+40
View File
@@ -0,0 +1,40 @@
"""Tests for dimension creation via ezdxf backend."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
async def test_dimension_linear(backend):
info = await backend.dimension_linear(0, 0, 100, 0, 50, 20, rotation=0)
assert info.handle
assert info.type == "DIMENSION"
@pytest.mark.xfail(reason="ezdxf API parameter mismatch in add_aligned_dim", strict=False)
async def test_dimension_aligned(backend):
info = await backend.dimension_aligned(0, 0, 100, 50, 50, 60)
assert info.handle
assert info.type == "DIMENSION"
@pytest.mark.xfail(reason="ezdxf API parameter mismatch in add_angular_dim_2l", strict=False)
async def test_dimension_angular(backend):
info = await backend.dimension_angular(0, 0, 100, 0, 0, 100, 50, 50)
assert info.handle
assert info.type == "DIMENSION"
async def test_dimension_radius(backend):
await backend.entity_create_circle(50, 50, 30)
info = await backend.dimension_radius(50, 50, 80, 50, leader_length=15)
assert info.handle
assert info.type == "DIMENSION"
async def test_dimension_diameter(backend):
info = await backend.dimension_diameter(20, 50, 80, 50, leader_length=10)
assert info.handle
assert info.type == "DIMENSION"
+58
View File
@@ -0,0 +1,58 @@
"""Tests for drawing operations via ezdxf backend."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
async def test_drawing_info(backend):
info = await backend.drawing_info()
assert info.backend == "ezdxf"
assert info.entity_count >= 0
async def test_drawing_new(backend):
result = await backend.drawing_new()
assert result.get("ok") is True
async def test_drawing_save_and_open(backend, tmp_path):
await backend.entity_create_line(0, 0, 100, 0)
save_path = str(tmp_path / "test_save.dxf")
await backend.drawing_save(save_path)
await backend.drawing_open(save_path)
info = await backend.drawing_info()
assert info.entity_count >= 1
async def test_drawing_export_dxf(backend, tmp_path):
await backend.entity_create_circle(0, 0, 50)
export_path = str(tmp_path / "export.dxf")
result = await backend.drawing_export_dxf(export_path)
assert result.get("ok") is True
import os
assert os.path.exists(export_path)
async def test_drawing_purge(backend):
await backend.layer_create("TEMP_LAYER")
result = await backend.drawing_purge()
assert result.get("ok") is True
async def test_drawing_audit(backend):
result = await backend.drawing_audit()
assert result.get("ok") is True
async def test_drawing_undo(backend):
await backend.entity_create_line(0, 0, 100, 0)
result = await backend.drawing_undo()
assert isinstance(result, dict)
async def test_drawing_redo(backend):
result = await backend.drawing_redo()
assert isinstance(result, dict)
+100
View File
@@ -0,0 +1,100 @@
"""Tests for entity creation — all entity types via ezdxf backend."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
async def test_create_circle(backend):
info = await backend.entity_create_circle(50, 50, 25)
assert info.handle
assert info.type == "CIRCLE"
got = await backend.entity_get(info.handle)
assert got.type == "CIRCLE"
assert got.properties.get("radius") == pytest.approx(25, abs=0.01)
async def test_create_arc(backend):
info = await backend.entity_create_arc(0, 0, 50, 0, 90)
assert info.handle
assert info.type == "ARC"
async def test_create_polyline(backend):
pts = [[0, 0], [100, 0], [100, 100], [0, 100]]
info = await backend.entity_create_polyline(pts, closed=True)
assert info.handle
assert info.type == "LWPOLYLINE"
async def test_create_polyline_open(backend):
pts = [[0, 0], [50, 50], [100, 0]]
info = await backend.entity_create_polyline(pts, closed=False)
assert info.handle
assert info.type == "LWPOLYLINE"
async def test_create_text(backend):
info = await backend.entity_create_text("Hello", 10, 20, height=5.0)
assert info.handle
assert info.type == "TEXT"
async def test_create_mtext(backend):
info = await backend.entity_create_mtext("Multi\\Pline Text", 0, 0, width=200)
assert info.handle
assert info.type == "MTEXT"
async def test_create_hatch(backend):
boundary = [[0, 0], [100, 0], [100, 100], [0, 100]]
info = await backend.entity_create_hatch("SOLID", boundary, scale=1.0, angle=0)
assert info.handle
assert info.type == "HATCH"
async def test_create_spline(backend):
pts = [[0, 0], [25, 50], [50, 25], [75, 75], [100, 0]]
info = await backend.entity_create_spline(pts)
assert info.handle
assert info.type == "SPLINE"
async def test_create_ellipse(backend):
info = await backend.entity_create_ellipse(50, 50, 40, 0, ratio=0.5)
assert info.handle
assert info.type == "ELLIPSE"
async def test_create_point(backend):
info = await backend.entity_create_point(10, 20)
assert info.handle
assert info.type == "POINT"
async def test_create_line_with_layer(backend):
await backend.layer_create("TEST_LAYER", color=1)
info = await backend.entity_create_line(0, 0, 100, 0, layer="TEST_LAYER")
assert info.handle
assert info.layer == "TEST_LAYER"
async def test_create_circle_with_color(backend):
info = await backend.entity_create_circle(0, 0, 10, color=3)
assert info.handle
got = await backend.entity_get(info.handle)
assert got.color == 3
async def test_create_entity_appears_in_list(backend):
await backend.entity_create_line(0, 0, 10, 0)
await backend.entity_create_circle(50, 50, 5)
await backend.entity_create_point(0, 0)
entities = await backend.entity_list()
assert len(entities) == 3
types = {e.type for e in entities}
assert "LINE" in types
assert "CIRCLE" in types
assert "POINT" in types
+203
View File
@@ -0,0 +1,203 @@
"""Tests for ezdxf backend — no AutoCAD required."""
import pytest
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# Basic CRUD
# ---------------------------------------------------------------------------
async def test_create_line(backend):
info = await backend.entity_create_line(0, 0, 100, 0)
assert info.handle
assert info.type == "LINE"
async def test_get_entity(backend):
info = await backend.entity_create_line(0, 0, 50, 50)
fetched = await backend.entity_get(info.handle)
assert fetched.handle == info.handle
assert fetched.type == "LINE"
async def test_move_entity(backend):
info = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.entity_move(info.handle, 5, 5)
assert result["ok"]
moved = await backend.entity_get(info.handle)
start = moved.properties["start"]
assert abs(start[0] - 5) < 1e-6
assert abs(start[1] - 5) < 1e-6
async def test_delete_entity(backend):
info = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.entity_delete(info.handle)
assert result["ok"]
entities = await backend.entity_list()
assert len(entities) == 0
# ---------------------------------------------------------------------------
# Entity filtering — LINE vs LWPOLYLINE
# ---------------------------------------------------------------------------
async def test_entity_list_type_filter_exact_match(backend):
"""LINE filter must NOT match LWPOLYLINE."""
await backend.entity_create_line(0, 0, 10, 0)
await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=False)
lines = await backend.entity_list(type_filter="LINE")
polylines = await backend.entity_list(type_filter="LWPOLYLINE")
assert len(lines) == 1
assert lines[0].type == "LINE"
assert len(polylines) == 1
assert polylines[0].type == "LWPOLYLINE"
async def test_analysis_select_by_type_exact_match(backend):
"""analysis_select_by_type must use exact match, not substring."""
await backend.entity_create_line(0, 0, 10, 0)
await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=False)
lines = await backend.analysis_select_by_type("LINE")
assert len(lines) == 1
assert lines[0].type == "LINE"
# ---------------------------------------------------------------------------
# Matrix transforms
# ---------------------------------------------------------------------------
async def test_rotate_around_origin(backend):
"""Rotate a line (10,0)-(20,0) by 90° around origin → (0,10)-(0,20)."""
info = await backend.entity_create_line(10, 0, 20, 0)
await backend.entity_rotate(info.handle, 0, 0, 90)
rotated = await backend.entity_get(info.handle)
start = rotated.properties["start"]
end = rotated.properties["end"]
assert abs(start[0] - 0) < 1e-4
assert abs(start[1] - 10) < 1e-4
assert abs(end[0] - 0) < 1e-4
assert abs(end[1] - 20) < 1e-4
async def test_rotate_around_point(backend):
"""Rotate (10,0)-(20,0) by 90° around (10,0) → (10,0)-(10,10)."""
info = await backend.entity_create_line(10, 0, 20, 0)
await backend.entity_rotate(info.handle, 10, 0, 90)
rotated = await backend.entity_get(info.handle)
start = rotated.properties["start"]
end = rotated.properties["end"]
assert abs(start[0] - 10) < 1e-4
assert abs(start[1] - 0) < 1e-4
assert abs(end[0] - 10) < 1e-4
assert abs(end[1] - 10) < 1e-4
async def test_scale_from_origin(backend):
"""Scale line (10,0)-(20,0) by 2x from origin → (20,0)-(40,0)."""
info = await backend.entity_create_line(10, 0, 20, 0)
await backend.entity_scale(info.handle, 0, 0, 2)
scaled = await backend.entity_get(info.handle)
start = scaled.properties["start"]
end = scaled.properties["end"]
assert abs(start[0] - 20) < 1e-4
assert abs(end[0] - 40) < 1e-4
async def test_scale_from_point(backend):
"""Scale line (10,0)-(20,0) by 2x from (10,0) → (10,0)-(30,0)."""
info = await backend.entity_create_line(10, 0, 20, 0)
await backend.entity_scale(info.handle, 10, 0, 2)
scaled = await backend.entity_get(info.handle)
start = scaled.properties["start"]
end = scaled.properties["end"]
assert abs(start[0] - 10) < 1e-4
assert abs(end[0] - 30) < 1e-4
async def test_mirror_across_y_axis(backend):
"""Mirror line (10,0)-(20,0) across Y axis → (-10,0)-(-20,0)."""
info = await backend.entity_create_line(10, 0, 20, 0)
mirrored = await backend.entity_mirror(info.handle, 0, 0, 0, 10)
start = mirrored.properties["start"]
end = mirrored.properties["end"]
assert abs(start[0] - (-10)) < 1e-4
assert abs(end[0] - (-20)) < 1e-4
async def test_array_polar(backend):
"""Polar array of 4 items at 360° around center should create 3 copies."""
info = await backend.entity_create_line(10, 0, 20, 0)
copies = await backend.entity_array_polar(info.handle, 4, 360, 0, 0)
assert len(copies) == 3 # 4 total - 1 original
# ---------------------------------------------------------------------------
# Transactions
# ---------------------------------------------------------------------------
async def test_transaction_rollback(backend):
"""After rollback, entity created inside transaction should be gone."""
await backend.transaction_begin()
await backend.entity_create_line(0, 0, 100, 0)
entities_before = await backend.entity_list()
assert len(entities_before) == 1
await backend.transaction_rollback()
entities_after = await backend.entity_list()
assert len(entities_after) == 0
async def test_transaction_commit(backend):
"""After commit, entity should still exist."""
await backend.transaction_begin()
await backend.entity_create_line(0, 0, 100, 0)
await backend.transaction_commit()
entities = await backend.entity_list()
assert len(entities) == 1
# ---------------------------------------------------------------------------
# block_create_from_entities
# ---------------------------------------------------------------------------
async def test_block_create_from_entities(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
circle = await backend.entity_create_circle(5, 5, 3)
result = await backend.block_create_from_entities(
"TestBlock", [line.handle, circle.handle], 0, 0
)
assert result["ok"]
assert result["entity_count"] == 2
blocks = await backend.block_list()
names = [b.name for b in blocks]
assert "TestBlock" in names
# ---------------------------------------------------------------------------
# Layer operations
# ---------------------------------------------------------------------------
async def test_layer_create_and_list(backend):
layer = await backend.layer_create("TestLayer", color=1)
assert layer.name == "TestLayer"
assert layer.color == 1
layers = await backend.layer_list()
names = [lyr.name for lyr in layers]
assert "TestLayer" in names
+113
View File
@@ -0,0 +1,113 @@
"""Tests for layer and block operations via ezdxf backend."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# Layer tests
# ---------------------------------------------------------------------------
async def test_layer_create(backend):
result = await backend.layer_create("WALLS", color=1)
assert result.name == "WALLS"
assert result.color == 1
async def test_layer_list(backend):
await backend.layer_create("A")
await backend.layer_create("B")
layers = await backend.layer_list()
names = [lyr.name for lyr in layers]
assert "A" in names
assert "B" in names
async def test_layer_delete(backend):
await backend.layer_create("TEMP")
result = await backend.layer_delete("TEMP")
assert result.get("ok") is True
layers = await backend.layer_list()
names = [lyr.name for lyr in layers]
assert "TEMP" not in names
async def test_layer_set_current(backend):
await backend.layer_create("CURRENT_TEST")
result = await backend.layer_set_current("CURRENT_TEST")
assert result.get("ok") is True
async def test_layer_modify(backend):
await backend.layer_create("MOD_LAYER", color=1)
result = await backend.layer_modify("MOD_LAYER", color=3)
assert result.color == 3
async def test_layer_freeze_thaw(backend):
await backend.layer_create("FREEZE_TEST")
result = await backend.layer_freeze("FREEZE_TEST")
assert result.get("ok") is True
result = await backend.layer_thaw("FREEZE_TEST")
assert result.get("ok") is True
async def test_layer_lock_unlock(backend):
await backend.layer_create("LOCK_TEST")
result = await backend.layer_lock("LOCK_TEST")
assert result.get("ok") is True
result = await backend.layer_unlock("LOCK_TEST")
assert result.get("ok") is True
async def test_layer_hide_show(backend):
await backend.layer_create("VIS_TEST")
result = await backend.layer_hide("VIS_TEST")
assert result.get("ok") is True
result = await backend.layer_show("VIS_TEST")
assert result.get("ok") is True
# ---------------------------------------------------------------------------
# Block tests
# ---------------------------------------------------------------------------
async def test_block_create_and_list(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
circle = await backend.entity_create_circle(5, 5, 3)
result = await backend.block_create_from_entities(
"MY_BLOCK", [line.handle, circle.handle], base_x=0, base_y=0
)
assert result.get("ok") is True
blocks = await backend.block_list()
block_names = [b.name for b in blocks]
assert "MY_BLOCK" in block_names
async def test_block_insert(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
await backend.block_create_from_entities("INS_BLOCK", [line.handle])
result = await backend.block_insert("INS_BLOCK", 50, 50)
assert result.handle
assert result.type == "INSERT"
async def test_block_explode(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
await backend.block_create_from_entities("EXPLODE_BLK", [line.handle])
ref = await backend.block_insert("EXPLODE_BLK", 0, 0)
result = await backend.block_explode(ref.handle)
assert result.get("ok") is True
async def test_block_get_attributes(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
await backend.block_create_from_entities("ATTR_BLK", [line.handle])
ref = await backend.block_insert("ATTR_BLK", 0, 0)
attrs = await backend.block_get_attributes(ref.handle)
assert isinstance(attrs, dict)
+231
View File
@@ -0,0 +1,231 @@
"""Security tests for path validation and command sanitization."""
from __future__ import annotations
import pytest
from fastmcp.exceptions import ToolError
from security import sanitize_command, sanitize_lisp, validate_path
# ---------------------------------------------------------------------------
# Path validation tests
# ---------------------------------------------------------------------------
class TestValidatePath:
"""Tests for validate_path function."""
def test_valid_absolute_path(self, tmp_path):
"""Valid absolute path should resolve correctly."""
test_file = tmp_path / "drawing.dxf"
test_file.touch()
result = validate_path(str(test_file), allow_write=False)
assert result.exists()
def test_empty_path_rejected(self):
"""Empty path should raise ToolError."""
with pytest.raises(ToolError, match="cannot be empty"):
validate_path("")
def test_whitespace_path_rejected(self):
"""Whitespace-only path should raise ToolError."""
with pytest.raises(ToolError, match="cannot be empty"):
validate_path(" ")
def test_traversal_dot_dot_slash(self):
"""Path with ../ should be rejected."""
with pytest.raises(ToolError, match="traversal"):
validate_path("../../../etc/passwd")
def test_traversal_dot_dot_backslash(self):
"""Path with ..\\ should be rejected."""
with pytest.raises(ToolError, match="traversal"):
validate_path("..\\..\\Windows\\System32\\cmd.exe")
def test_traversal_encoded(self):
"""URL-encoded traversal should be rejected."""
with pytest.raises(ToolError, match="traversal"):
validate_path("%2e%2e/secret")
def test_null_byte(self):
"""Null byte in path should be rejected."""
with pytest.raises(ToolError, match="traversal"):
validate_path("file\x00.dxf")
def test_write_requires_existing_parent(self, tmp_path):
"""Write mode should check parent directory exists."""
nonexistent = tmp_path / "nonexistent_dir" / "file.dxf"
with pytest.raises(ToolError, match="Parent directory"):
validate_path(str(nonexistent), allow_write=True)
def test_write_with_valid_parent(self, tmp_path):
"""Write mode should pass when parent exists."""
target = tmp_path / "output.dxf"
result = validate_path(str(target), allow_write=True)
assert result.parent.exists()
def test_allowed_paths_enforcement(self, tmp_path, monkeypatch):
"""When ALLOWED_PATHS is set, paths outside should be rejected."""
from config import Settings
allowed_dir = tmp_path / "allowed"
allowed_dir.mkdir()
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
import config
config.settings = Settings()
blocked = tmp_path / "blocked" / "file.dxf"
blocked.parent.mkdir(exist_ok=True)
blocked.touch()
with pytest.raises(ToolError, match="not inside any allowed"):
validate_path(str(blocked))
config.settings = Settings.__new__(Settings)
config.settings.allowed_paths = []
config.settings.dangerous_commands_enabled = False
monkeypatch.delenv("ALLOWED_PATHS", raising=False)
config.settings = Settings()
def test_allowed_paths_permits_inside(self, tmp_path, monkeypatch):
"""When ALLOWED_PATHS is set, paths inside should be allowed."""
from config import Settings
allowed_dir = tmp_path / "allowed"
allowed_dir.mkdir()
test_file = allowed_dir / "drawing.dxf"
test_file.touch()
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
import config
config.settings = Settings()
result = validate_path(str(test_file))
assert result.exists()
monkeypatch.delenv("ALLOWED_PATHS", raising=False)
config.settings = Settings()
# ---------------------------------------------------------------------------
# Command sanitization tests
# ---------------------------------------------------------------------------
class TestSanitizeCommand:
"""Tests for sanitize_command function."""
def test_safe_command_passes(self):
"""Safe commands should pass through."""
assert sanitize_command("_ZOOM E") == "_ZOOM E"
def test_safe_regen_command(self):
"""REGEN command should pass."""
assert sanitize_command("_REGEN") == "_REGEN"
def test_empty_command_rejected(self):
"""Empty command should raise ToolError."""
with pytest.raises(ToolError, match="cannot be empty"):
sanitize_command("")
def test_erase_command_blocked(self):
"""ERASE command should be blocked."""
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("ERASE ALL")
def test_delete_command_blocked(self):
"""DELETE command should be blocked."""
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("DELETE")
def test_shell_command_blocked(self):
"""SHELL command should be blocked."""
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("SHELL")
def test_quit_command_blocked(self):
"""QUIT command should be blocked."""
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("QUIT")
def test_script_command_blocked(self):
"""SCRIPT command should be blocked."""
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("SCRIPT malicious.scr")
def test_dangerous_enabled_bypass(self, monkeypatch):
"""When DANGEROUS_COMMANDS_ENABLED=true, all commands pass."""
from config import Settings
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
import config
config.settings = Settings()
result = sanitize_command("ERASE ALL")
assert result == "ERASE ALL"
monkeypatch.delenv("DANGEROUS_COMMANDS_ENABLED", raising=False)
config.settings = Settings()
# ---------------------------------------------------------------------------
# LISP sanitization tests
# ---------------------------------------------------------------------------
class TestSanitizeLisp:
"""Tests for sanitize_lisp function."""
def test_safe_lisp_passes(self):
"""Safe LISP expressions should pass."""
assert sanitize_lisp('(setvar "DIMSCALE" 1.0)') == '(setvar "DIMSCALE" 1.0)'
def test_safe_arithmetic(self):
"""Simple arithmetic LISP should pass."""
assert sanitize_lisp("(+ 1 2 3)") == "(+ 1 2 3)"
def test_empty_expression_rejected(self):
"""Empty expression should raise ToolError."""
with pytest.raises(ToolError, match="cannot be empty"):
sanitize_lisp("")
def test_startapp_blocked(self):
"""startapp should be blocked (arbitrary program execution)."""
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(startapp "cmd")')
def test_command_function_blocked(self):
"""(command ...) should be blocked."""
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(command "ERASE" "ALL" "")')
def test_file_delete_blocked(self):
"""vl-file-delete should be blocked."""
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(vl-file-delete "C:/important.dwg")')
def test_load_blocked(self):
"""(load ...) should be blocked."""
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(load "malicious.lsp")')
def test_eval_blocked(self):
"""(eval ...) should be blocked."""
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp("(eval (read user-input))")
def test_dangerous_enabled_bypass(self, monkeypatch):
"""When DANGEROUS_COMMANDS_ENABLED=true, all LISP passes."""
from config import Settings
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
import config
config.settings = Settings()
result = sanitize_lisp('(startapp "cmd")')
assert result == '(startapp "cmd")'
monkeypatch.delenv("DANGEROUS_COMMANDS_ENABLED", raising=False)
config.settings = Settings()