parent
8ab8bfba83
commit
00eb91e4cd
3 changed files with 166 additions and 0 deletions
@ -0,0 +1,70 @@ |
||||
"""Path handling""" |
||||
|
||||
from .identity import Identity |
||||
from .types import ALPHA_LOWER, ALPHA_UPPER, DIGIT |
||||
|
||||
PATH_PUNCTUATION = "/'()-._~!$&+,:=@%" |
||||
PATH_CHARACTER = ALPHA_LOWER + ALPHA_UPPER + DIGIT + PATH_PUNCTUATION |
||||
|
||||
|
||||
class Path: |
||||
"""A document path""" |
||||
|
||||
_SEGMENT_PATTERN = f'/[{PATH_CHARACTER}]+' |
||||
_PATTERN = f'^({_SEGMENT_PATTERN})+$' |
||||
|
||||
def __init__(self, path: str) -> None: |
||||
self.validate(path, allow_ephemeral=True) |
||||
self.path = path |
||||
|
||||
@staticmethod |
||||
def validate(path: str, allow_ephemeral: bool = False) -> None: |
||||
"""Validate a path""" |
||||
|
||||
if not 2 <= len(path) <= 512: |
||||
raise ValueError('Path length must be between 2 and 512') |
||||
|
||||
if not path.startswith('/'): |
||||
raise ValueError('Paths must start with a /') |
||||
|
||||
if path.endswith('/'): |
||||
raise ValueError('Paths must not end with a /') |
||||
|
||||
if path.startswith('/@'): |
||||
raise ValueError('Paths must not start with /@') |
||||
|
||||
if '//' in path: |
||||
raise ValueError('Paths must not contain //') |
||||
|
||||
if path.count('!') > 1: |
||||
raise ValueError('Only one ! is allowed in paths') |
||||
|
||||
if '!' in path and not allow_ephemeral: |
||||
raise ValueError('Only ephemeral paths may contain !') |
||||
|
||||
@property |
||||
def is_shared(self) -> bool: |
||||
"""Check if the path is shared""" |
||||
|
||||
return '~' not in self.path |
||||
|
||||
@property |
||||
def is_ephemeral(self) -> bool: |
||||
"""Check if the path is ephemeral""" |
||||
|
||||
return '!' in self.path |
||||
|
||||
def can_write(self, author: Identity) -> bool: |
||||
"""Check if a specific author has write access over a document""" |
||||
|
||||
if self.is_shared: |
||||
return True |
||||
|
||||
segments = self.path.split('/') |
||||
|
||||
for segment in segments: |
||||
for allowed_author in segment.split('~'): |
||||
if Identity.valid_address(allowed_author) and str(author) == allowed_author: |
||||
return True |
||||
|
||||
return False |
@ -0,0 +1,95 @@ |
||||
"""Tests for path handling |
||||
""" |
||||
|
||||
import pytest |
||||
|
||||
from earthsnake.identity import Identity |
||||
from earthsnake.path import Path |
||||
|
||||
|
||||
@pytest.mark.parametrize( |
||||
'path,error_msg', |
||||
[ |
||||
pytest.param('/', 'Path length must be between 2 and 512', id='short'), |
||||
pytest.param('/' + 'a' * 512, 'Path length must be between 2 and 512', id='long'), |
||||
pytest.param('badstart', 'Paths must start with a /', id='no_slash_start'), |
||||
pytest.param('/@atstart', 'Paths must not start with /@', id='slashat_start'), |
||||
pytest.param('/double//slash', 'Paths must not contain //', id='double_slash'), |
||||
pytest.param('/multiple!excl!', 'Only one ! is allowed in paths', id='multi_excl'), |
||||
pytest.param('/test/', 'Paths must not end with a /', id='trailing_slash'), |
||||
], |
||||
) |
||||
def test_invalid(path: str, error_msg: str) -> None: |
||||
"""Test if validation fails for invalid paths""" |
||||
|
||||
with pytest.raises(ValueError) as ctx: |
||||
Path.validate(path) |
||||
|
||||
assert error_msg in str(ctx.value) |
||||
|
||||
|
||||
def test_invalid_non_ephemeral() -> None: |
||||
"""Test validating ephemeral paths when ephemeral is not allowed""" |
||||
|
||||
with pytest.raises(ValueError) as ctx: |
||||
Path.validate('/ephemeral!') |
||||
|
||||
assert 'Only ephemeral paths may contain !' in str(ctx.value) |
||||
|
||||
|
||||
def test_validate_ephemeral() -> None: |
||||
"""Test validation of ephemeral paths""" |
||||
|
||||
assert Path.validate('/ephemeral!', allow_ephemeral=True) is None |
||||
|
||||
|
||||
def test_is_ephemeral() -> None: |
||||
"""Test the is_ephemeral property""" |
||||
|
||||
assert Path('/ephemeral!').is_ephemeral |
||||
assert not Path('/non-ephemeral').is_ephemeral |
||||
|
||||
|
||||
def test_is_shared() -> None: |
||||
"""Test the is_shared property""" |
||||
|
||||
assert Path('/test').is_shared |
||||
assert not Path('/test/~').is_shared |
||||
|
||||
|
||||
@pytest.mark.parametrize( |
||||
'author,path,allowed', |
||||
[ |
||||
pytest.param( |
||||
'@test.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', |
||||
'/test', |
||||
True, |
||||
id='shared', |
||||
), |
||||
pytest.param( |
||||
'@test.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', |
||||
'/test/~@test.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', |
||||
True, |
||||
id='allowed', |
||||
), |
||||
pytest.param( |
||||
'@test.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', |
||||
'/test/~@abob.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', |
||||
False, |
||||
id='someone_else', |
||||
), |
||||
pytest.param( |
||||
'@test.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', |
||||
'/test/~', |
||||
False, |
||||
id='strictly_forbidden', |
||||
), |
||||
], |
||||
) |
||||
def test_can_write(author: str, path: str, allowed: bool) -> None: |
||||
"""Test the can_write method""" |
||||
|
||||
path_obj = Path(path) |
||||
identity = Identity.from_address(author) |
||||
|
||||
assert path_obj.can_write(identity) is allowed |
Loading…
Reference in new issue