test_filename_validation.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. """Validator tests for FAT32/exFAT-safe print filenames (#1540)."""
  2. import pytest
  3. from backend.app.utils.filename import (
  4. INVALID_FILENAME_CHARS,
  5. InvalidFilenameError,
  6. derive_remote_filename,
  7. validate_print_filename,
  8. )
  9. class TestValidatePrintFilename:
  10. @pytest.mark.parametrize(
  11. "name",
  12. [
  13. "model.3mf",
  14. "Bersaglio.gcode.3mf",
  15. "Plate 1.3mf",
  16. "プリント.3mf",
  17. "model_v2-final.3mf",
  18. "a.3mf",
  19. ],
  20. )
  21. def test_valid_names_accepted(self, name: str) -> None:
  22. validate_print_filename(name)
  23. @pytest.mark.parametrize("char", list(INVALID_FILENAME_CHARS))
  24. def test_each_invalid_char_rejected(self, char: str) -> None:
  25. with pytest.raises(InvalidFilenameError) as exc_info:
  26. validate_print_filename(f"L{char}R.3mf")
  27. assert exc_info.value.char == char
  28. def test_pipe_from_issue_1540(self) -> None:
  29. """The exact reproducer from the bug report."""
  30. with pytest.raises(InvalidFilenameError) as exc_info:
  31. validate_print_filename("L|R.3mf")
  32. assert exc_info.value.char == "|"
  33. @pytest.mark.parametrize("name", ["", " ", " "])
  34. def test_empty_rejected(self, name: str) -> None:
  35. with pytest.raises(InvalidFilenameError, match="empty"):
  36. validate_print_filename(name)
  37. @pytest.mark.parametrize("name", [".", ".."])
  38. def test_dot_names_rejected(self, name: str) -> None:
  39. with pytest.raises(InvalidFilenameError):
  40. validate_print_filename(name)
  41. def test_control_char_rejected(self) -> None:
  42. with pytest.raises(InvalidFilenameError, match="control"):
  43. validate_print_filename("file\x01.3mf")
  44. @pytest.mark.parametrize("name", ["file.3mf.", "file.3mf "])
  45. def test_trailing_space_or_dot_rejected(self, name: str) -> None:
  46. with pytest.raises(InvalidFilenameError, match="space or dot"):
  47. validate_print_filename(name)
  48. def test_too_long_rejected(self) -> None:
  49. with pytest.raises(InvalidFilenameError, match="bytes"):
  50. validate_print_filename("a" * 256)
  51. def test_unicode_byte_length_not_codepoint(self) -> None:
  52. """255 multi-byte codepoints exceeds 255 bytes — must reject."""
  53. # 'ä' is 2 bytes in UTF-8
  54. with pytest.raises(InvalidFilenameError, match="bytes"):
  55. validate_print_filename("ä" * 200)
  56. class TestDeriveRemoteFilename:
  57. """SD-card upload-name derivation must match what the cleanup deletes (#1542)."""
  58. def test_strips_gcode_3mf(self) -> None:
  59. assert derive_remote_filename("Cube.gcode.3mf") == "Cube.3mf"
  60. def test_strips_3mf(self) -> None:
  61. assert derive_remote_filename("Cube.3mf") == "Cube.3mf"
  62. def test_bare_stem_appends_3mf(self) -> None:
  63. assert derive_remote_filename("Cube") == "Cube.3mf"
  64. def test_replaces_spaces_with_underscores(self) -> None:
  65. # firmware parses ftp://{filename} as a URL, spaces break it
  66. assert derive_remote_filename("Cube (1).gcode.3mf") == "Cube_(1).3mf"
  67. def test_doubled_gcode_3mf_fully_stripped(self) -> None:
  68. # The literal reproducer from #1542: library row had .gcode.3mf appended twice
  69. assert derive_remote_filename("Cube (1).gcode.3mf.gcode.3mf") == "Cube_(1).3mf"
  70. def test_doubled_3mf_fully_stripped(self) -> None:
  71. assert derive_remote_filename("Cube.3mf.3mf") == "Cube.3mf"
  72. def test_mixed_double_extensions_fully_stripped(self) -> None:
  73. assert derive_remote_filename("Cube.gcode.3mf.3mf") == "Cube.3mf"
  74. def test_raw_gcode_unchanged_stem(self) -> None:
  75. # Bare .gcode (no .3mf wrapper) is a valid sliced file — only the
  76. # .3mf wrapper gets stripped; .gcode survives and the result is
  77. # the printer's expected ftp:// target.
  78. assert derive_remote_filename("Cube.gcode") == "Cube.gcode.3mf"
  79. def test_idempotent(self) -> None:
  80. once = derive_remote_filename("Cube (1).gcode.3mf.gcode.3mf")
  81. assert derive_remote_filename(once) == once
  82. def test_unicode_stem_preserved(self) -> None:
  83. assert derive_remote_filename("プリント.gcode.3mf") == "プリント.3mf"
  84. def test_non_string_input_raises_typeerror(self) -> None:
  85. """A duck-typed object whose endswith always returns truthy must not be
  86. allowed to enter the strip loop — that's how a test mock OOM'd the
  87. container at 61 GB before the type guard was added."""
  88. from unittest.mock import MagicMock
  89. with pytest.raises(TypeError, match="requires str"):
  90. derive_remote_filename(MagicMock())
  91. with pytest.raises(TypeError, match="requires str"):
  92. derive_remote_filename(None) # type: ignore[arg-type]
  93. with pytest.raises(TypeError, match="requires str"):
  94. derive_remote_filename(123) # type: ignore[arg-type]