Skip to content

Commit 7e3c691

Browse files
authored
Check storage availability only for a project owned by a current user (#70)
Ownership depends on matching username with namespace, not ownership permissions
1 parent 541e41c commit 7e3c691

File tree

5 files changed

+196
-7
lines changed

5 files changed

+196
-7
lines changed

.github/workflows/autotests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ env:
44
TEST_MERGIN_URL: https://test.dev.cloudmergin.com/
55
TEST_API_USERNAME: test_plugin
66
TEST_API_PASSWORD: ${{ secrets.MERGINTEST_API_PASSWORD }}
7+
TEST_API_USERNAME2: test_plugin2
8+
TEST_API_PASSWORD2: ${{ secrets.MERGINTEST_API_PASSWORD2 }}
79

810
jobs:
911
tests:

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,6 @@ For running test do:
118118
export TEST_MERGIN_URL=<url> # testing server
119119
export TEST_API_USERNAME=<username>
120120
export TEST_API_PASSWORD=<pwd>
121+
export TEST_API_USERNAME2=<username2>
122+
export TEST_API_PASSWORD2=<pwd2>
121123
pipenv run pytest --cov-report html --cov=mergin test/

mergin/client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,27 @@ def user_info(self):
373373
resp = self.get('/v1/user/' + self.username())
374374
return json.load(resp)
375375

376+
def set_project_access(self, project_path, access):
377+
"""
378+
Updates access for given project.
379+
:param project_path: project full name (<namespace>/<name>)
380+
:param access: dict <readersnames, writersnames, ownersnames> -> list of str username we want to give access to
381+
"""
382+
if not self._user_info:
383+
raise Exception("Authentication required")
384+
385+
params = {"access": access}
386+
path = "/v1/project/%s" % project_path
387+
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
388+
json_headers = {'Content-Type': 'application/json'}
389+
try:
390+
request = urllib.request.Request(url, data=json.dumps(params).encode(), headers=json_headers, method="PUT")
391+
self._do_request(request)
392+
except Exception as e:
393+
detail = f"Project path: {project_path}"
394+
raise ClientError(str(e), detail)
395+
396+
376397
def push_project(self, directory):
377398
"""
378399
Upload local changes to the repository.

mergin/client_push.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,14 @@ def push_project_async(mc, directory):
101101

102102
changes = mp.get_push_changes()
103103
mp.log.debug("push changes:\n" + pprint.pformat(changes))
104-
enough_free_space, freespace = mc.enough_storage_available(changes)
105-
if not enough_free_space:
106-
freespace = int(freespace/(1024*1024))
107-
mp.log.error(f"--- push {project_path} - not enough space")
108-
raise ClientError("Storage limit has been reached. Only " + str(freespace) + "MB left")
104+
105+
# currently proceed storage limit check only if a project is own by a current user.
106+
if username == project_path.split("/")[0]:
107+
enough_free_space, freespace = mc.enough_storage_available(changes)
108+
if not enough_free_space:
109+
freespace = int(freespace/(1024*1024))
110+
mp.log.error(f"--- push {project_path} - not enough space")
111+
raise ClientError("Storage limit has been reached. Only " + str(freespace) + "MB left")
109112

110113
if not sum(len(v) for v in changes.values()):
111114
mp.log.info(f"--- push {project_path} - nothing to do")

mergin/test/test_client.py

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
SERVER_URL = os.environ.get('TEST_MERGIN_URL')
1212
API_USER = os.environ.get('TEST_API_USERNAME')
1313
USER_PWD = os.environ.get('TEST_API_PASSWORD')
14+
API_USER2 = os.environ.get('TEST_API_USERNAME2')
15+
USER_PWD2 = os.environ.get('TEST_API_PASSWORD2')
1416
TMP_DIR = tempfile.gettempdir()
1517
TEST_DATA_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'test_data')
1618
CHANGED_SCHEMA_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'modified_schema')
@@ -22,8 +24,16 @@ def toggle_geodiff(enabled):
2224

2325
@pytest.fixture(scope='function')
2426
def mc():
25-
assert SERVER_URL and SERVER_URL.rstrip('/') != 'https://public.cloudmergin.com' and API_USER and USER_PWD
26-
return MerginClient(SERVER_URL, login=API_USER, password=USER_PWD)
27+
return create_client(API_USER, USER_PWD)
28+
29+
@pytest.fixture(scope='function')
30+
def mc2():
31+
return create_client(API_USER2, USER_PWD2)
32+
33+
34+
def create_client(user, pwd):
35+
assert SERVER_URL and SERVER_URL.rstrip('/') != 'https://public.cloudmergin.com' and user and pwd
36+
return MerginClient(SERVER_URL, login=user, password=pwd)
2737

2838

2939
def cleanup(mc, project, dirs):
@@ -32,6 +42,11 @@ def cleanup(mc, project, dirs):
3242
mc.delete_project(project)
3343
except ClientError:
3444
pass
45+
remove_folders(dirs)
46+
47+
48+
def remove_folders(dirs):
49+
# clean given directories
3550
for d in dirs:
3651
if os.path.exists(d):
3752
shutil.rmtree(d)
@@ -461,6 +476,7 @@ def test_empty_file_in_subdir(mc):
461476
mc.pull_project(project_dir_2)
462477
assert os.path.exists(os.path.join(project_dir_2, 'subdir2', 'empty2.txt'))
463478

479+
464480
def test_clone_project(mc):
465481
test_project = 'test_clone_project'
466482
test_project_fullname = API_USER + '/' + test_project
@@ -483,3 +499,148 @@ def test_clone_project(mc):
483499
mc.clone_project(test_project_fullname, cloned_project_name, API_USER)
484500
projects = mc.projects_list(flag='created')
485501
assert any(p for p in projects if p['name'] == cloned_project_name and p['namespace'] == API_USER)
502+
503+
504+
def test_set_read_write_access(mc):
505+
test_project = 'test_set_read_write_access'
506+
test_project_fullname = API_USER + '/' + test_project
507+
508+
# cleanups
509+
project_dir = os.path.join(TMP_DIR, test_project, API_USER)
510+
cleanup(mc, test_project_fullname, [project_dir])
511+
512+
# create new (empty) project on server
513+
mc.create_project(test_project)
514+
515+
# Add writer access to another client
516+
project_info = get_project_info(mc, API_USER, test_project)
517+
access = project_info['access']
518+
access['writersnames'].append(API_USER2)
519+
access['readersnames'].append(API_USER2)
520+
mc.set_project_access(test_project_fullname, access)
521+
522+
# check access
523+
project_info = get_project_info(mc, API_USER, test_project)
524+
access = project_info['access']
525+
assert API_USER2 in access['writersnames']
526+
assert API_USER2 in access['readersnames']
527+
528+
529+
def test_available_storage_validation(mc):
530+
"""
531+
Testing of storage limit - applies to user pushing changes into own project (namespace matching username).
532+
This test also tests giving read and write access to another user. Additionally tests also uploading of big file.
533+
"""
534+
test_project = 'test_available_storage_validation'
535+
test_project_fullname = API_USER + '/' + test_project
536+
537+
# cleanups
538+
project_dir = os.path.join(TMP_DIR, test_project, API_USER)
539+
cleanup(mc, test_project_fullname, [project_dir])
540+
541+
# create new (empty) project on server
542+
mc.create_project(test_project)
543+
544+
# download project
545+
mc.download_project(test_project_fullname, project_dir)
546+
547+
# get user_info about storage capacity
548+
user_info = mc.user_info()
549+
storage_remaining = user_info['storage'] - user_info['disk_usage']
550+
551+
# generate dummy data (remaining storage + extra 1024b)
552+
dummy_data_path = project_dir + "/data"
553+
file_size = storage_remaining + 1024
554+
_generate_big_file(dummy_data_path, file_size)
555+
556+
# try to upload
557+
got_right_err = False
558+
try:
559+
mc.push_project(project_dir)
560+
except ClientError as e:
561+
# Expecting "Storage limit has been reached" error msg.
562+
assert str(e).startswith("Storage limit has been reached")
563+
got_right_err = True
564+
assert got_right_err
565+
566+
# Expecting empty project
567+
project_info = get_project_info(mc, API_USER, test_project)
568+
assert project_info['meta']['files_count'] == 0
569+
assert project_info['meta']['size'] == 0
570+
571+
572+
def test_available_storage_validation2(mc, mc2):
573+
"""
574+
Testing of storage limit - should not be applied for user pushing changes into project with different namespace.
575+
This should cover the exception of mergin-py-client that a user can push changes to someone else's project regardless
576+
the user's own storage limitation. Of course, other limitations are still applied (write access, owner of
577+
a modified project has to have enough free storage).
578+
579+
Therefore NOTE that there are following assumptions:
580+
- API_USER2's free storage >= API_USER's free storage + 1024b (size of changes to be pushed)
581+
- both accounts should ideally have a free plan
582+
"""
583+
test_project = 'test_available_storage_validation2'
584+
test_project_fullname = API_USER2 + '/' + test_project
585+
586+
# cleanups
587+
project_dir = os.path.join(TMP_DIR, test_project, API_USER)
588+
cleanup(mc, test_project_fullname, [project_dir])
589+
cleanup(mc2, test_project_fullname, [project_dir])
590+
591+
# create new (empty) project on server
592+
mc2.create_project(test_project)
593+
594+
# Add writer access to another client
595+
project_info = get_project_info(mc2, API_USER2, test_project)
596+
access = project_info['access']
597+
access['writersnames'].append(API_USER)
598+
access['readersnames'].append(API_USER)
599+
mc2.set_project_access(test_project_fullname, access)
600+
601+
# download project
602+
mc.download_project(test_project_fullname, project_dir)
603+
604+
# get user_info about storage capacity
605+
user_info = mc.user_info()
606+
storage_remaining = user_info['storage'] - user_info['disk_usage']
607+
608+
# generate dummy data (remaining storage + extra 1024b)
609+
dummy_data_path = project_dir + "/data"
610+
file_size = storage_remaining + 1024
611+
_generate_big_file(dummy_data_path, file_size)
612+
613+
# try to upload
614+
mc.push_project(project_dir)
615+
616+
# Check project content
617+
project_info = get_project_info(mc2, API_USER2, test_project)
618+
assert project_info['meta']['files_count'] == 1
619+
assert project_info['meta']['size'] == file_size
620+
621+
# remove dummy big file from a disk
622+
remove_folders([project_dir])
623+
624+
625+
def get_project_info(mc, namespace, project_name):
626+
"""
627+
Returns first (and suppose to be just one) project info dict of project matching given namespace and name.
628+
:param mc: MerginClient instance
629+
:param namespace: project's namespace
630+
:param project_name: project's name
631+
:return: dict with project info
632+
"""
633+
projects = mc.projects_list(flag='created')
634+
test_project_list = [p for p in projects if p['name'] == project_name and p['namespace'] == namespace]
635+
assert len(test_project_list) == 1
636+
return test_project_list[0]
637+
638+
639+
def _generate_big_file(filepath, size):
640+
"""
641+
generate big binary file with the specified size in bytes
642+
:param filepath: full filepath
643+
:param size: the size in bytes
644+
"""
645+
with open(filepath, 'wb') as fout:
646+
fout.write(b"\0" * size)

0 commit comments

Comments
 (0)