Skip to content

Commit 26c3985

Browse files
committed
frozen_dataclass(test): Enhance test suite with NamedTuple parametrized tests
why: Improve test coverage and documentation of security considerations what: - Add NamedTuple-based parametrized tests for dimensions, frozen flag, and inheritance - Discover and document security vulnerability with _frozen flag reassignment - Add test cases for edge dimensions (zero, negative, large values) - Improve type annotations for better static analysis All tests pass: 14 pytest tests and 22 doctests confirm functionality
1 parent 8e56bfa commit 26c3985

File tree

2 files changed

+246
-0
lines changed

2 files changed

+246
-0
lines changed

src/libtmux/_internal/frozen_dataclass.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,23 @@ def frozen_dataclass(cls: type[_T]) -> type[_T]:
8686
... except AttributeError as e:
8787
... print(f"Error: {e}")
8888
Error: ImmutableSub is immutable: cannot modify field 'value'
89+
90+
Security consideration - modifying the _frozen flag:
91+
92+
>>> @frozen_dataclass
93+
... class SecureData:
94+
... secret: str
95+
>>> data = SecureData(secret="password123")
96+
>>> try:
97+
... data.secret = "hacked"
98+
... except AttributeError as e:
99+
... print(f"Protected: {e}")
100+
Protected: SecureData is immutable: cannot modify field 'secret'
101+
>>> # However, the _frozen flag can be modified to bypass protection:
102+
>>> data._frozen = False
103+
>>> data.secret = "hacked"
104+
>>> data.secret
105+
'hacked'
89106
"""
90107
# A. Convert to a dataclass with frozen=False
91108
cls = dataclasses.dataclass(cls)

tests/_internal/test_frozen_dataclass.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import dataclasses
6+
import typing as t
67
from datetime import datetime
78

89
import pytest
@@ -181,3 +182,231 @@ def test_bidirectional_references() -> None:
181182
# This is a "leaky abstraction" in Python's immutability model
182183
# In real code, consider using immutable collections (tuple, frozenset)
183184
# or deep freezing containers
185+
186+
187+
# NamedTuple-based parametrized tests
188+
# ----------------------------------
189+
190+
191+
class DimensionTestCase(t.NamedTuple):
192+
"""Test fixture for validating dimensions in PaneSnapshot."""
193+
194+
test_id: str
195+
width: int
196+
height: int
197+
expected_error: bool
198+
error_match: str | None = None
199+
200+
201+
DIMENSION_TEST_CASES: list[DimensionTestCase] = [
202+
DimensionTestCase(
203+
test_id="standard_dimensions",
204+
width=80,
205+
height=24,
206+
expected_error=False,
207+
),
208+
DimensionTestCase(
209+
test_id="zero_dimensions",
210+
width=0,
211+
height=0,
212+
expected_error=False,
213+
),
214+
DimensionTestCase(
215+
test_id="negative_dimensions",
216+
width=-10,
217+
height=-5,
218+
expected_error=False,
219+
),
220+
DimensionTestCase(
221+
test_id="extreme_dimensions",
222+
width=9999,
223+
height=9999,
224+
expected_error=False,
225+
),
226+
]
227+
228+
229+
@pytest.mark.parametrize(
230+
list(DimensionTestCase._fields),
231+
DIMENSION_TEST_CASES,
232+
ids=[test.test_id for test in DIMENSION_TEST_CASES],
233+
)
234+
def test_snapshot_dimensions(
235+
test_id: str, width: int, height: int, expected_error: bool, error_match: str | None
236+
) -> None:
237+
"""Test PaneSnapshot initialization with various dimensions."""
238+
# Initialize the PaneSnapshot
239+
pane = PaneSnapshot(pane_id="test", width=width, height=height)
240+
241+
# Verify dimensions were set correctly
242+
assert pane.width == width
243+
assert pane.height == height
244+
245+
# Verify immutability
246+
with pytest.raises(AttributeError, match="immutable"):
247+
pane.width = 100 # type: ignore
248+
249+
250+
class FrozenFlagTestCase(t.NamedTuple):
251+
"""Test fixture for testing _frozen flag behavior."""
252+
253+
test_id: str
254+
unfreeze_attempt: bool
255+
expect_mutation_error: bool
256+
error_match: str | None = None
257+
258+
259+
FROZEN_FLAG_TEST_CASES: list[FrozenFlagTestCase] = [
260+
FrozenFlagTestCase(
261+
test_id="attempt_unfreeze",
262+
unfreeze_attempt=True,
263+
expect_mutation_error=False,
264+
error_match=None,
265+
),
266+
FrozenFlagTestCase(
267+
test_id="no_unfreeze_attempt",
268+
unfreeze_attempt=False,
269+
expect_mutation_error=True,
270+
error_match="immutable.*cannot modify field",
271+
),
272+
]
273+
274+
275+
@pytest.mark.parametrize(
276+
list(FrozenFlagTestCase._fields),
277+
FROZEN_FLAG_TEST_CASES,
278+
ids=[test.test_id for test in FROZEN_FLAG_TEST_CASES],
279+
)
280+
def test_frozen_flag(
281+
test_id: str,
282+
unfreeze_attempt: bool,
283+
expect_mutation_error: bool,
284+
error_match: str | None,
285+
) -> None:
286+
"""Test behavior when attempting to manipulate the _frozen flag.
287+
288+
Note: We discovered that setting _frozen=False actually allows mutation,
289+
which could be a potential security issue if users know about this behavior.
290+
In a more secure implementation, the _frozen attribute might need additional
291+
protection to prevent this bypass mechanism.
292+
"""
293+
# Create a frozen dataclass
294+
pane = PaneSnapshot(pane_id="test_frozen", width=80, height=24)
295+
296+
# Attempt to unfreeze if requested
297+
if unfreeze_attempt:
298+
pane._frozen = False # type: ignore
299+
300+
# Attempt mutation and check if it fails as expected
301+
if expect_mutation_error:
302+
with pytest.raises(AttributeError, match=error_match):
303+
pane.width = 200 # type: ignore
304+
else:
305+
pane.width = 200 # type: ignore
306+
assert pane.width == 200
307+
308+
309+
class MutationMethodTestCase(t.NamedTuple):
310+
"""Test fixture for testing mutation methods."""
311+
312+
test_id: str
313+
method_name: str
314+
args: tuple[t.Any, ...]
315+
error_type: type[Exception]
316+
error_match: str
317+
318+
319+
MUTATION_METHOD_TEST_CASES: list[MutationMethodTestCase] = [
320+
MutationMethodTestCase(
321+
test_id="resize_method",
322+
method_name="resize",
323+
args=(100, 50),
324+
error_type=NotImplementedError,
325+
error_match="immutable.*resize.*not allowed",
326+
),
327+
]
328+
329+
330+
@pytest.mark.parametrize(
331+
list(MutationMethodTestCase._fields),
332+
MUTATION_METHOD_TEST_CASES,
333+
ids=[test.test_id for test in MUTATION_METHOD_TEST_CASES],
334+
)
335+
def test_mutation_methods(
336+
test_id: str,
337+
method_name: str,
338+
args: tuple[t.Any, ...],
339+
error_type: type[Exception],
340+
error_match: str,
341+
) -> None:
342+
"""Test that methods attempting to modify state raise appropriate exceptions."""
343+
# Create a frozen dataclass
344+
pane = PaneSnapshot(pane_id="test_methods", width=80, height=24)
345+
346+
# Get the method and attempt to call it
347+
method = getattr(pane, method_name)
348+
with pytest.raises(error_type, match=error_match):
349+
method(*args)
350+
351+
352+
class InheritanceTestCase(t.NamedTuple):
353+
"""Test fixture for testing inheritance behavior."""
354+
355+
test_id: str
356+
create_base: bool
357+
mutate_base: bool
358+
mutate_derived: bool
359+
expect_base_error: bool
360+
expect_derived_error: bool
361+
362+
363+
INHERITANCE_TEST_CASES: list[InheritanceTestCase] = [
364+
InheritanceTestCase(
365+
test_id="mutable_base_immutable_derived",
366+
create_base=True,
367+
mutate_base=True,
368+
mutate_derived=True,
369+
expect_base_error=False,
370+
expect_derived_error=True,
371+
),
372+
]
373+
374+
375+
@pytest.mark.parametrize(
376+
list(InheritanceTestCase._fields),
377+
INHERITANCE_TEST_CASES,
378+
ids=[test.test_id for test in INHERITANCE_TEST_CASES],
379+
)
380+
def test_inheritance_behavior(
381+
test_id: str,
382+
create_base: bool,
383+
mutate_base: bool,
384+
mutate_derived: bool,
385+
expect_base_error: bool,
386+
expect_derived_error: bool,
387+
) -> None:
388+
"""Test inheritance behavior with mutable base class and immutable derived class."""
389+
# Create base class if requested
390+
if create_base:
391+
base = BasePane(pane_id="base", width=80, height=24)
392+
393+
# Create derived class
394+
derived = PaneSnapshot(pane_id="derived", width=80, height=24)
395+
396+
# Attempt to mutate base class if requested
397+
if create_base and mutate_base:
398+
if expect_base_error:
399+
with pytest.raises(AttributeError):
400+
base.width = 100
401+
else:
402+
base.width = 100
403+
assert base.width == 100
404+
405+
# Attempt to mutate derived class if requested
406+
if mutate_derived:
407+
if expect_derived_error:
408+
with pytest.raises(AttributeError):
409+
derived.width = 100 # type: ignore
410+
else:
411+
derived.width = 100 # type: ignore
412+
assert derived.width == 100

0 commit comments

Comments
 (0)