@@ -43,4 +43,4 @@
-
+
\ No newline at end of file
diff --git a/addons/s3/tests/factories.py b/addons/s3/tests/factories.py
index a8b49856d47..bafdbaf9c84 100644
--- a/addons/s3/tests/factories.py
+++ b/addons/s3/tests/factories.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""Factories for the S3 addon."""
import factory
from factory.django import DjangoModelFactory
@@ -11,9 +10,9 @@
class S3AccountFactory(ExternalAccountFactory):
provider = 's3'
- provider_id = factory.Sequence(lambda n: 'id-{0}'.format(n))
- oauth_key = factory.Sequence(lambda n: 'key-{0}'.format(n))
- oauth_secret = factory.Sequence(lambda n: 'secret-{0}'.format(n))
+ provider_id = factory.Sequence(lambda n: f'id-{n}')
+ oauth_key = factory.Sequence(lambda n: f'key-{n}')
+ oauth_secret = factory.Sequence(lambda n: f'secret-{n}')
display_name = 'S3 Fake User'
diff --git a/addons/s3/tests/test_model.py b/addons/s3/tests/test_model.py
index b616a66c652..8e549c5d1ae 100644
--- a/addons/s3/tests/test_model.py
+++ b/addons/s3/tests/test_model.py
@@ -1,7 +1,4 @@
-# from nose.tools import * # noqa
-import mock
-from nose.tools import (assert_false, assert_true,
- assert_equal, assert_is_none)
+from unittest import mock
import pytest
import unittest
@@ -38,26 +35,33 @@ class TestNodeSettings(OAuthAddonNodeSettingsTestSuiteMixin, unittest.TestCase):
NodeSettingsClass = NodeSettings
UserSettingsFactory = S3UserSettingsFactory
+ def _node_settings_class_kwargs(self, node, user_settings):
+ return {
+ 'user_settings': self.user_settings,
+ 'folder_id': 'bucket_name:/path_goes_here/with_folder_id',
+ 'owner': self.node
+ }
+
def test_registration_settings(self):
registration = ProjectFactory()
clone, message = self.node_settings.after_register(
self.node, registration, self.user,
)
- assert_is_none(clone)
+ assert clone is None
def test_before_register_no_settings(self):
self.node_settings.user_settings = None
message = self.node_settings.before_register(self.node, self.user)
- assert_false(message)
+ assert not message
def test_before_register_no_auth(self):
self.node_settings.external_account = None
message = self.node_settings.before_register(self.node, self.user)
- assert_false(message)
+ assert not message
def test_before_register_settings_and_auth(self):
message = self.node_settings.before_register(self.node, self.user)
- assert_true(message)
+ assert message
@mock.patch('website.archiver.tasks.archive')
def test_does_not_get_copied_to_registrations(self, mock_archive):
@@ -66,7 +70,7 @@ def test_does_not_get_copied_to_registrations(self, mock_archive):
auth=Auth(user=self.user),
draft_registration=DraftRegistrationFactory(branched_from=self.node),
)
- assert_false(registration.has_addon('s3'))
+ assert not registration.has_addon('s3')
## Overrides ##
@@ -78,7 +82,7 @@ def test_serialize_credentials(self):
expected = {'access_key': self.node_settings.external_account.oauth_key,
'secret_key': self.node_settings.external_account.oauth_secret}
- assert_equal(credentials, expected)
+ assert credentials == expected
@mock.patch('addons.s3.models.bucket_exists')
@mock.patch('addons.s3.models.get_bucket_location_or_error')
@@ -89,13 +93,50 @@ def test_set_folder(self, mock_location, mock_exists):
self.node_settings.set_folder(folder_id, auth=Auth(self.user))
self.node_settings.save()
# Bucket was set
- assert_equal(self.node_settings.folder_id, folder_id)
+ assert self.node_settings.folder_id == folder_id
# Log was saved
last_log = self.node.logs.latest()
- assert_equal(last_log.action, '{0}_bucket_linked'.format(self.short_name))
+ assert last_log.action == f'{self.short_name}_bucket_linked'
+
+ @mock.patch('addons.s3.models.bucket_exists')
+ def test_set_folder_nonexistent_bucket(self, mock_exists):
+ from addons.base import exceptions
+ mock_exists.return_value = False
+ with pytest.raises(exceptions.InvalidFolderError):
+ self.node_settings.set_folder('nonexistent-bucket:/', auth=Auth(self.user))
+
+ @mock.patch('addons.s3.models.bucket_exists')
+ @mock.patch('addons.s3.models.get_bucket_location_or_error')
+ def test_set_folder_invalid_auth(self, mock_location, mock_exists):
+ from addons.base.exceptions import InvalidAuthError
+ mock_exists.return_value = True
+ mock_location.side_effect = InvalidAuthError()
+ with pytest.raises(InvalidAuthError):
+ self.node_settings.set_folder('valid-bucket:/', auth=Auth(self.user))
def test_serialize_settings(self):
settings = self.node_settings.serialize_waterbutler_settings()
- expected = {'bucket': self.node_settings.folder_id,
- 'encrypt_uploads': self.node_settings.encrypt_uploads}
- assert_equal(settings, expected)
+ expected = {
+ 'bucket': 'bucket_name',
+ 'encrypt_uploads': self.node_settings.encrypt_uploads,
+ 'id': 'bucket_name:/path_goes_here/with_folder_id'
+ }
+ assert settings == expected
+
+ def test_serialize_settings_supports_legacy_and_prefixed_folder_ids(self):
+ cases = [
+ ('bucket', 'bucket'),
+ ('bucket:/', 'bucket'),
+ ('bucket:/prefix/', 'bucket'),
+ ]
+
+ for folder_id, expected_bucket in cases:
+ self.node_settings.folder_id = folder_id
+
+ settings = self.node_settings.serialize_waterbutler_settings()
+
+ assert settings == {
+ 'bucket': expected_bucket,
+ 'encrypt_uploads': self.node_settings.encrypt_uploads,
+ 'id': folder_id,
+ }
diff --git a/addons/s3/tests/test_serializer.py b/addons/s3/tests/test_serializer.py
index a1c637020c3..f1fa025d49c 100644
--- a/addons/s3/tests/test_serializer.py
+++ b/addons/s3/tests/test_serializer.py
@@ -1,6 +1,5 @@
-# -*- coding: utf-8 -*-
"""Serializer tests for the S3 addon."""
-import mock
+from unittest import mock
import pytest
from addons.base.tests.serializers import StorageAddonSerializerTestSuiteMixin
@@ -24,8 +23,8 @@ def setUp(self):
self.mock_can_list = mock.patch('addons.s3.serializer.utils.can_list')
self.mock_can_list.return_value = True
self.mock_can_list.start()
- super(TestS3Serializer, self).setUp()
+ super().setUp()
def tearDown(self):
self.mock_can_list.stop()
- super(TestS3Serializer, self).tearDown()
+ super().tearDown()
diff --git a/addons/s3/tests/test_view.py b/addons/s3/tests/test_view.py
index 8db2b2a9091..c60d796b91d 100644
--- a/addons/s3/tests/test_view.py
+++ b/addons/s3/tests/test_view.py
@@ -1,10 +1,7 @@
-# -*- coding: utf-8 -*-
from rest_framework import status as http_status
-from boto.exception import S3ResponseError
-import mock
-from nose.tools import (assert_equal, assert_equals,
- assert_true, assert_in, assert_false)
+from botocore.exceptions import NoCredentialsError
+from unittest import mock
import pytest
from framework.auth import Auth
@@ -18,6 +15,7 @@
from addons.s3.utils import validate_bucket_name, validate_bucket_location
from website.util import api_url_for
from admin.rdm_addons.utils import get_rdm_addon_option
+from osf.models import ExternalAccount
pytestmark = pytest.mark.django_db
@@ -26,19 +24,19 @@ def setUp(self):
self.mock_can_list = mock.patch('addons.s3.views.utils.can_list')
self.mock_can_list.return_value = True
self.mock_can_list.start()
- self.mock_uid = mock.patch('addons.s3.views.utils.get_user_info')
- self.mock_uid.return_value = {'id': '1234567890', 'display_name': 's3.user'}
+ self.mock_uid = mock.patch('addons.s3.views.utils.get_user_info',
+ return_value=mock.Mock(id='1234567890', display_name='s3.user'))
self.mock_uid.start()
self.mock_exists = mock.patch('addons.s3.views.utils.bucket_exists')
self.mock_exists.return_value = True
self.mock_exists.start()
- super(TestS3Views, self).setUp()
+ super().setUp()
def tearDown(self):
self.mock_can_list.stop()
self.mock_uid.stop()
self.mock_exists.stop()
- super(TestS3Views, self).tearDown()
+ super().tearDown()
def test_s3_settings_input_empty_keys(self):
url = self.project.api_url_for('s3_add_user_account')
@@ -46,8 +44,8 @@ def test_s3_settings_input_empty_keys(self):
'access_key': '',
'secret_key': ''
}, auth=self.user.auth, expect_errors=True)
- assert_equals(rv.status_int, http_status.HTTP_400_BAD_REQUEST)
- assert_in('All the fields above are required.', rv.body.decode())
+ assert rv.status_code == http_status.HTTP_400_BAD_REQUEST
+ assert 'All the fields above are required.' in rv.text
def test_s3_settings_input_empty_access_key(self):
url = self.project.api_url_for('s3_add_user_account')
@@ -55,8 +53,8 @@ def test_s3_settings_input_empty_access_key(self):
'access_key': '',
'secret_key': 'Non-empty-secret-key'
}, auth=self.user.auth, expect_errors=True)
- assert_equals(rv.status_int, http_status.HTTP_400_BAD_REQUEST)
- assert_in('All the fields above are required.', rv.body.decode())
+ assert rv.status_code == http_status.HTTP_400_BAD_REQUEST
+ assert 'All the fields above are required.' in rv.text
def test_s3_settings_input_empty_secret_key(self):
url = self.project.api_url_for('s3_add_user_account')
@@ -64,8 +62,8 @@ def test_s3_settings_input_empty_secret_key(self):
'access_key': 'Non-empty-access-key',
'secret_key': ''
}, auth=self.user.auth, expect_errors=True)
- assert_equals(rv.status_int, http_status.HTTP_400_BAD_REQUEST)
- assert_in('All the fields above are required.', rv.body.decode())
+ assert rv.status_code == http_status.HTTP_400_BAD_REQUEST
+ assert 'All the fields above are required.' in rv.text
def test_s3_settings_rdm_addons_denied(self):
institution = InstitutionFactory()
@@ -75,12 +73,69 @@ def test_s3_settings_rdm_addons_denied(self):
rdm_addon_option.is_allowed = False
rdm_addon_option.save()
url = self.project.api_url_for('s3_add_user_account')
- rv = self.app.post_json(url,{
+ rv = self.app.post_json(url, {
'access_key': 'aldkjf',
'secret_key': 'las'
}, auth=self.user.auth, expect_errors=True)
- assert_equal(rv.status_int, http_status.HTTP_403_FORBIDDEN)
- assert_in(b'You are prohibited from using this add-on.', rv.body)
+ assert rv.status_code == http_status.HTTP_403_FORBIDDEN
+ assert 'You are prohibited from using this add-on.' in rv.text
+
+ # G5: GRDM-53044 provider_id = "{aws_account_id}\t{access_key}"
+ # setUp already patches get_user_info/can_list as truthy MagicMocks.
+ # Note: oauth_key/oauth_secret are EncryptedTextField (JWE, non-deterministic),
+ # so we query by provider_id (plain CharField) and verify via attribute access.
+ def test_provider_id_format(self):
+ url = self.project.api_url_for('s3_add_user_account')
+ access_key = 'AKIAIOSFODNN7EXAMPLE'
+ rv = self.app.post_json(url, {
+ 'access_key': access_key,
+ 'secret_key': 'wJalrXUtnFEMI/K7MDENG',
+ }, auth=self.user.auth)
+ assert rv.status_code == http_status.HTTP_200_OK
+ # provider_id is not encrypted — safe to query
+ provider_id = f'1234567890\t{access_key}'
+ account = ExternalAccount.objects.get(provider='s3', provider_id=provider_id)
+ # provider_id must be "{aws_account_id}\t{access_key}" (GRDM-53044)
+ assert account.provider_id.endswith(f'\t{access_key}')
+ assert '\t' in account.provider_id
+ assert account.oauth_key == access_key
+
+ def test_provider_id_dedup(self):
+ url = self.project.api_url_for('s3_add_user_account')
+ access_key = 'AKIAIOSFODNN7EXAMPLE'
+ provider_id = f'1234567890\t{access_key}'
+ rv1 = self.app.post_json(url, {
+ 'access_key': access_key,
+ 'secret_key': 'secret1',
+ }, auth=self.user.auth)
+ assert rv1.status_code == http_status.HTTP_200_OK
+ # Same AWS account (same provider_id) with new secret → update, no duplicate
+ rv2 = self.app.post_json(url, {
+ 'access_key': access_key,
+ 'secret_key': 'secret2',
+ }, auth=self.user.auth)
+ assert rv2.status_code == http_status.HTTP_200_OK
+ # Query by provider_id (plain CharField) — oauth_key is encrypted (JWE)
+ accounts = ExternalAccount.objects.filter(provider='s3', provider_id=provider_id)
+ assert accounts.count() == 1
+ assert accounts.first().oauth_secret == 'secret2'
+
+ # G1: missing key field → KeyError → 400
+ def test_s3_settings_missing_key_field(self):
+ url = self.project.api_url_for('s3_add_user_account')
+ rv = self.app.post_json(url, {}, auth=self.user.auth, expect_errors=True)
+ assert rv.status_code == http_status.HTTP_400_BAD_REQUEST
+
+ # G2: get_user_info returns None → 400 + "Unable to access account."
+ @mock.patch('addons.s3.views.utils.get_user_info', return_value=None)
+ def test_s3_settings_invalid_account(self, mock_get_user_info):
+ url = self.project.api_url_for('s3_add_user_account')
+ rv = self.app.post_json(url, {
+ 'access_key': 'aldkjf',
+ 'secret_key': 'las',
+ }, auth=self.user.auth, expect_errors=True)
+ assert rv.status_code == http_status.HTTP_400_BAD_REQUEST
+ assert 'Unable to access account.' in rv.text
def test_s3_set_bucket_no_settings(self):
user = AuthUserFactory()
@@ -88,9 +143,9 @@ def test_s3_set_bucket_no_settings(self):
url = self.project.api_url_for('s3_set_config')
res = self.app.put_json(
url, {'s3_bucket': 'hammertofall'}, auth=user.auth,
- expect_errors=True
+ expect_errors=True,
)
- assert_equal(res.status_code, http_status.HTTP_400_BAD_REQUEST)
+ assert res.status_code == http_status.HTTP_400_BAD_REQUEST
def test_s3_set_bucket_no_auth(self):
@@ -100,9 +155,9 @@ def test_s3_set_bucket_no_auth(self):
url = self.project.api_url_for('s3_set_config')
res = self.app.put_json(
url, {'s3_bucket': 'hammertofall'}, auth=user.auth,
- expect_errors=True
+ expect_errors=True,
)
- assert_equal(res.status_code, http_status.HTTP_403_FORBIDDEN)
+ assert res.status_code == http_status.HTTP_403_FORBIDDEN
def test_s3_set_bucket_registered(self):
registration = self.project.register_node(
@@ -115,7 +170,7 @@ def test_s3_set_bucket_registered(self):
expect_errors=True,
)
- assert_equal(res.status_code, http_status.HTTP_400_BAD_REQUEST)
+ assert res.status_code == http_status.HTTP_400_BAD_REQUEST
@mock.patch('addons.s3.views.utils.can_list', return_value=False)
def test_user_settings_cant_list(self, mock_can_list):
@@ -125,20 +180,20 @@ def test_user_settings_cant_list(self, mock_can_list):
'secret_key': 'las'
}, auth=self.user.auth, expect_errors=True)
- assert_in('Unable to list buckets.', rv.body.decode())
- assert_equals(rv.status_int, http_status.HTTP_400_BAD_REQUEST)
+ assert 'Unable to list buckets.' in rv.text
+ assert rv.status_code == http_status.HTTP_400_BAD_REQUEST
def test_s3_remove_node_settings_owner(self):
url = self.node_settings.owner.api_url_for('s3_deauthorize_node')
self.app.delete(url, auth=self.user.auth)
result = self.Serializer().serialize_settings(node_settings=self.node_settings, current_user=self.user)
- assert_equal(result['nodeHasAuth'], False)
+ assert result['nodeHasAuth'] == False
def test_s3_remove_node_settings_unauthorized(self):
url = self.node_settings.owner.api_url_for('s3_deauthorize_node')
ret = self.app.delete(url, auth=None, expect_errors=True)
- assert_equal(ret.status_code, 401)
+ assert ret.status_code == 401
def test_s3_get_node_settings_owner(self):
self.node_settings.set_auth(self.external_account, self.user)
@@ -148,23 +203,23 @@ def test_s3_get_node_settings_owner(self):
res = self.app.get(url, auth=self.user.auth)
result = res.json['result']
- assert_equal(result['nodeHasAuth'], True)
- assert_equal(result['userIsOwner'], True)
- assert_equal(result['folder']['path'], self.node_settings.folder_id)
+ assert result['nodeHasAuth'] == True
+ assert result['userIsOwner'] == True
+ assert result['folder']['path'] == self.node_settings.folder_id
def test_s3_get_node_settings_unauthorized(self):
url = self.node_settings.owner.api_url_for('s3_get_config')
unauthorized = AuthUserFactory()
ret = self.app.get(url, auth=unauthorized.auth, expect_errors=True)
- assert_equal(ret.status_code, 403)
+ assert ret.status_code == 403
## Overrides ##
@mock.patch('addons.s3.models.get_bucket_names')
def test_folder_list(self, mock_names):
mock_names.return_value = ['bucket1', 'bucket2']
- super(TestS3Views, self).test_folder_list()
+ super().test_folder_list()
@mock.patch('addons.s3.models.bucket_exists')
@mock.patch('addons.s3.models.get_bucket_location_or_error')
@@ -172,25 +227,22 @@ def test_set_config(self, mock_location, mock_exists):
mock_exists.return_value = True
mock_location.return_value = ''
self.node_settings.set_auth(self.external_account, self.user)
- url = self.project.api_url_for('{0}_set_config'.format(self.ADDON_SHORT_NAME))
+ url = self.project.api_url_for(f'{self.ADDON_SHORT_NAME}_set_config')
res = self.app.put_json(url, {
'selected': self.folder
}, auth=self.user.auth)
- assert_equal(res.status_code, http_status.HTTP_200_OK)
+ assert res.status_code == http_status.HTTP_200_OK
self.project.reload()
self.node_settings.reload()
- assert_equal(
- self.project.logs.latest().action,
- '{0}_bucket_linked'.format(self.ADDON_SHORT_NAME)
- )
- assert_equal(res.json['result']['folder']['name'], self.node_settings.folder_name)
+ assert self.project.logs.latest().action == f'{self.ADDON_SHORT_NAME}_bucket_linked'
+ assert res.json['result']['folder']['name'] == self.node_settings.folder_name
class TestCreateBucket(S3AddonTestCase, OsfTestCase):
def setUp(self):
- super(TestCreateBucket, self).setUp()
+ super().setUp()
self.user = AuthUserFactory()
self.consolidated_auth = Auth(user=self.user)
@@ -212,57 +264,57 @@ def setUp(self):
self.node_settings.save()
def test_bad_names(self):
- assert_false(validate_bucket_name(''))
- assert_false(validate_bucket_name('no'))
- assert_false(validate_bucket_name('a' * 64))
- assert_false(validate_bucket_name(' leadingspace'))
- assert_false(validate_bucket_name('trailingspace '))
- assert_false(validate_bucket_name('bogus naMe'))
- assert_false(validate_bucket_name('.cantstartwithp'))
- assert_false(validate_bucket_name('or.endwith.'))
- assert_false(validate_bucket_name('..nodoubles'))
- assert_false(validate_bucket_name('no_unders_in'))
- assert_false(validate_bucket_name('-leadinghyphen'))
- assert_false(validate_bucket_name('trailinghyphen-'))
- assert_false(validate_bucket_name('Mixedcase'))
- assert_false(validate_bucket_name('empty..label'))
- assert_false(validate_bucket_name('label-.trailinghyphen'))
- assert_false(validate_bucket_name('label.-leadinghyphen'))
- assert_false(validate_bucket_name('8.8.8.8'))
- assert_false(validate_bucket_name('600.9000.0.28'))
- assert_false(validate_bucket_name('no_underscore'))
- assert_false(validate_bucket_name('_nounderscoreinfront'))
- assert_false(validate_bucket_name('no-underscore-in-back_'))
- assert_false(validate_bucket_name('no-underscore-in_the_middle_either'))
+ assert not validate_bucket_name('')
+ assert not validate_bucket_name('no')
+ assert not validate_bucket_name('a' * 64)
+ assert not validate_bucket_name(' leadingspace')
+ assert not validate_bucket_name('trailingspace ')
+ assert not validate_bucket_name('bogus naMe')
+ assert not validate_bucket_name('.cantstartwithp')
+ assert not validate_bucket_name('or.endwith.')
+ assert not validate_bucket_name('..nodoubles')
+ assert not validate_bucket_name('no_unders_in')
+ assert not validate_bucket_name('-leadinghyphen')
+ assert not validate_bucket_name('trailinghyphen-')
+ assert not validate_bucket_name('Mixedcase')
+ assert not validate_bucket_name('empty..label')
+ assert not validate_bucket_name('label-.trailinghyphen')
+ assert not validate_bucket_name('label.-leadinghyphen')
+ assert not validate_bucket_name('8.8.8.8')
+ assert not validate_bucket_name('600.9000.0.28')
+ assert not validate_bucket_name('no_underscore')
+ assert not validate_bucket_name('_nounderscoreinfront')
+ assert not validate_bucket_name('no-underscore-in-back_')
+ assert not validate_bucket_name('no-underscore-in_the_middle_either')
def test_names(self):
- assert_true(validate_bucket_name('imagoodname'))
- assert_true(validate_bucket_name('still.passing'))
- assert_true(validate_bucket_name('can-have-dashes'))
- assert_true(validate_bucket_name('kinda.name.spaced'))
- assert_true(validate_bucket_name('a-o.valid'))
- assert_true(validate_bucket_name('11.12.m'))
- assert_true(validate_bucket_name('a--------a'))
- assert_true(validate_bucket_name('a' * 63))
+ assert validate_bucket_name('imagoodname')
+ assert validate_bucket_name('still.passing')
+ assert validate_bucket_name('can-have-dashes')
+ assert validate_bucket_name('kinda.name.spaced')
+ assert validate_bucket_name('a-o.valid')
+ assert validate_bucket_name('11.12.m')
+ assert validate_bucket_name('a--------a')
+ assert validate_bucket_name('a' * 63)
def test_bad_locations(self):
- assert_false(validate_bucket_location('Venus'))
- assert_false(validate_bucket_location('AlphaCentari'))
- assert_false(validate_bucket_location('CostaRica'))
+ assert not validate_bucket_location('Venus')
+ assert not validate_bucket_location('AlphaCentauri')
+ assert not validate_bucket_location('CostaRica')
def test_locations(self):
- assert_true(validate_bucket_location(''))
- assert_true(validate_bucket_location('eu-central-1'))
- assert_true(validate_bucket_location('ca-central-1'))
- assert_true(validate_bucket_location('us-west-1'))
- assert_true(validate_bucket_location('us-west-2'))
- assert_true(validate_bucket_location('ap-northeast-1'))
- assert_true(validate_bucket_location('ap-northeast-2'))
- assert_true(validate_bucket_location('ap-southeast-1'))
- assert_true(validate_bucket_location('ap-southeast-2'))
- assert_true(validate_bucket_location('sa-east-1'))
- assert_true(validate_bucket_location('eu-west-1'))
- assert_true(validate_bucket_location('eu-west-2'))
+ assert validate_bucket_location('')
+ assert validate_bucket_location('eu-central-1')
+ assert validate_bucket_location('ca-central-1')
+ assert validate_bucket_location('us-west-1')
+ assert validate_bucket_location('us-west-2')
+ assert validate_bucket_location('ap-northeast-1')
+ assert validate_bucket_location('ap-northeast-2')
+ assert validate_bucket_location('ap-southeast-1')
+ assert validate_bucket_location('ap-southeast-2')
+ assert validate_bucket_location('sa-east-1')
+ assert validate_bucket_location('eu-west-1')
+ assert validate_bucket_location('eu-west-2')
@mock.patch('addons.s3.views.utils.create_bucket')
@mock.patch('addons.s3.views.utils.get_bucket_names')
@@ -283,23 +335,23 @@ def test_create_bucket_pass(self, mock_names, mock_make):
auth=self.user.auth
)
- assert_equal(ret.status_int, http_status.HTTP_200_OK)
- assert_equal(ret.json, {})
+ assert ret.status_code == http_status.HTTP_200_OK
+ assert ret.json == {}
@mock.patch('addons.s3.views.utils.create_bucket')
def test_create_bucket_fail(self, mock_make):
- error = S3ResponseError(418, 'because Im a test')
- error.message = 'This should work'
+ error = NoCredentialsError(operation_name='create_bucket')
mock_make.side_effect = error
- url = '/api/v1/project/{0}/s3/newbucket/'.format(self.project._id)
- ret = self.app.post_json(url, {'bucket_name': 'doesntevenmatter'}, auth=self.user.auth, expect_errors=True)
+ url = f'/api/v1/project/{self.project._id}/s3/newbucket/'
+ ret = self.app.post_json(url, {'bucket_name': 'doesntevenmatter'}, auth=self.user.auth,
+ expect_errors=True)
- assert_equals(ret.body.decode(), '{"message": "This should work", "title": "Problem connecting to S3"}')
+ assert ret.text == '{"message": "Unable to locate credentials", "title": "Problem connecting to S3"}'
@mock.patch('addons.s3.views.utils.create_bucket')
def test_bad_location_fails(self, mock_make):
- url = '/api/v1/project/{0}/s3/newbucket/'.format(self.project._id)
+ url = f'/api/v1/project/{self.project._id}/s3/newbucket/'
ret = self.app.post_json(
url,
{
@@ -307,6 +359,151 @@ def test_bad_location_fails(self, mock_make):
'bucket_location': 'not a real bucket location',
},
auth=self.user.auth,
- expect_errors=True)
+ expect_errors=True,
+ )
+ assert ret.text == '{"message": "That bucket location is not valid.", "title": "Invalid bucket location"}'
+
+ # G4: invalid bucket name → 400 (name check fires before any S3 call)
+ def test_create_bucket_bad_name(self):
+ url = f'/api/v1/project/{self.project._id}/s3/newbucket/'
+ ret = self.app.post_json(
+ url,
+ {
+ 'bucket_name': 'no',
+ 'bucket_location': '',
+ },
+ auth=self.user.auth,
+ expect_errors=True,
+ )
+ assert ret.status_code == http_status.HTTP_400_BAD_REQUEST
+ assert 'That bucket name is not valid.' in ret.text
+
+ @mock.patch('addons.s3.views.utils.create_bucket')
+ def test_create_bucket_client_error(self, mock_make):
+ from botocore.exceptions import ClientError
+ error = ClientError(
+ {'Error': {'Code': 'BucketAlreadyExists', 'Message': 'The requested bucket name is not available.'}},
+ 'CreateBucket'
+ )
+ mock_make.side_effect = error
+ url = f'/api/v1/project/{self.project._id}/s3/newbucket/'
+ ret = self.app.post_json(
+ url,
+ {'bucket_name': 'valid-bucket-name', 'bucket_location': ''},
+ auth=self.user.auth,
+ expect_errors=True,
+ )
+ assert ret.status_code == http_status.HTTP_400_BAD_REQUEST
+ assert ret.json['title'] == "Problem creating bucket 'valid-bucket-name'"
+ assert 'BucketAlreadyExists' in ret.json['message']
+
+ @mock.patch('addons.s3.views.utils.create_bucket')
+ def test_create_bucket_boto_core_error(self, mock_make):
+ # BotoCoreError covers the non-response failures (bad parameters, endpoint
+ # unreachable, ...). Without the catchall these surface as a 500.
+ from botocore.exceptions import ParamValidationError
+ mock_make.side_effect = ParamValidationError(report='Invalid bucket configuration')
+ url = f'/api/v1/project/{self.project._id}/s3/newbucket/'
+ ret = self.app.post_json(
+ url,
+ {'bucket_name': 'valid-bucket-name', 'bucket_location': ''},
+ auth=self.user.auth,
+ expect_errors=True,
+ )
+ assert ret.status_code == http_status.HTTP_400_BAD_REQUEST
+ assert ret.json['title'] == 'Error connecting to S3'
+ assert 'Invalid bucket configuration' in ret.json['message']
+
+
+class TestS3Utils:
+ """Pure unit tests for utility functions — no DB required."""
+
+ def test_owner_from_dict_without_display_name(self):
+ from addons.s3.utils import Owner
+ owner = Owner.from_dict({'ID': 'owner-id'})
+ assert owner.id == 'owner-id'
+ assert owner.display_name == 'owner-id'
+
+ # G7: bucket_exists with empty name → False (early exit, no S3 call)
+ def test_bucket_exists_empty_name(self):
+ from addons.s3.utils import bucket_exists
+ assert bucket_exists('key', 'secret', '') is False
+
+ # G8: can_list(None, None) → False (early exit, no S3 call)
+ def test_can_list_no_keys(self):
+ from addons.s3.utils import can_list
+ assert can_list(None, None) is False
+
+ # G9: us-east-1 is reported as a null LocationConstraint, but BUCKET_LOCATIONS
+ # keys that region as ''. Passing the null through labels the bucket '(None)'.
+ @mock.patch('addons.s3.utils.connect_s3')
+ def test_null_location_constraint_becomes_empty_string(self, mock_connect):
+ from addons.s3.utils import get_bucket_location_or_error
+ mock_connect.return_value.get_bucket_location.return_value = {'LocationConstraint': None}
+ assert get_bucket_location_or_error('key', 'secret', 'bucket') == ''
+
+ # G10: every other region reports its own name and must survive untouched
+ @mock.patch('addons.s3.utils.connect_s3')
+ def test_named_region_is_passed_through(self, mock_connect):
+ from addons.s3.utils import get_bucket_location_or_error
+ mock_connect.return_value.get_bucket_location.return_value = {
+ 'LocationConstraint': 'ap-northeast-1'
+ }
+ assert get_bucket_location_or_error('key', 'secret', 'bucket') == 'ap-northeast-1'
+
+ # G11: the queried prefix comes back in CommonPrefixes and must not list itself
+ @mock.patch('addons.s3.utils.boto3.client')
+ def test_get_bucket_prefixes_skips_the_queried_prefix(self, mock_client):
+ from addons.s3.utils import get_bucket_prefixes
+ mock_client.return_value.list_objects.return_value = {
+ 'CommonPrefixes': [
+ {'Prefix': 'parent/'},
+ {'Prefix': 'parent/child/'},
+ ]
+ }
+
+ folders = get_bucket_prefixes('key', 'secret', 'parent/', 'bucket')
+
+ assert len(folders) == 1
+ assert folders[0]['name'] == 'child'
+ assert folders[0]['id'] == 'bucket:/parent/child/'
+ assert folders[0]['path'] == 'parent/child/'
+ assert folders[0]['kind'] == 'folder'
+
+ # G12: a bucket with no sub-prefixes omits CommonPrefixes entirely
+ @mock.patch('addons.s3.utils.boto3.client')
+ def test_get_bucket_prefixes_no_common_prefixes(self, mock_client):
+ from addons.s3.utils import get_bucket_prefixes
+ mock_client.return_value.list_objects.return_value = {}
+ assert get_bucket_prefixes('key', 'secret', '', 'bucket') == []
+
+ # G13: boto failures must become HTTPError, not an uncaught 500
+ @mock.patch('addons.s3.utils.boto3.client')
+ def test_get_bucket_prefixes_missing_credentials_raises_403(self, mock_client):
+ from framework.exceptions import HTTPError
+ from addons.s3.utils import get_bucket_prefixes
+ mock_client.return_value.list_objects.side_effect = NoCredentialsError()
+
+ with pytest.raises(HTTPError) as exc_info:
+ get_bucket_prefixes('key', 'secret', '', 'bucket')
+
+ assert exc_info.value.code == http_status.HTTP_403_FORBIDDEN
+
+ # G14: a ClientError keeps whatever status S3 itself returned
+ @mock.patch('addons.s3.utils.boto3.client')
+ def test_get_bucket_prefixes_client_error_keeps_status(self, mock_client):
+ from botocore.exceptions import ClientError
+ from framework.exceptions import HTTPError
+ from addons.s3.utils import get_bucket_prefixes
+ mock_client.return_value.list_objects.side_effect = ClientError(
+ {
+ 'Error': {'Code': 'NoSuchBucket', 'Message': 'denied'},
+ 'ResponseMetadata': {'HTTPStatusCode': http_status.HTTP_404_NOT_FOUND},
+ },
+ 'ListObjects',
+ )
+
+ with pytest.raises(HTTPError) as exc_info:
+ get_bucket_prefixes('key', 'secret', '', 'bucket')
- assert_equals(ret.body.decode(), '{"message": "That bucket location is not valid.", "title": "Invalid bucket location"}')
+ assert exc_info.value.code == http_status.HTTP_404_NOT_FOUND
diff --git a/addons/s3/tests/utils.py b/addons/s3/tests/utils.py
index 15b0153f23b..e83f4ebf489 100644
--- a/addons/s3/tests/utils.py
+++ b/addons/s3/tests/utils.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
from addons.base.tests.base import OAuthAddonTestCaseMixin, AddonTestCase
from addons.s3.tests.factories import S3AccountFactory
from addons.s3.provider import S3Provider
diff --git a/addons/s3/utils.py b/addons/s3/utils.py
index 0738b5bf397..f0cb70da46c 100644
--- a/addons/s3/utils.py
+++ b/addons/s3/utils.py
@@ -1,14 +1,19 @@
import re
+import logging
+from dataclasses import dataclass
+from typing import Optional # Python 3.6 does not support | operator for type hints
+
from rest_framework import status as http_status
-from boto import exception
-from boto.s3.connection import S3Connection
-from boto.s3.connection import OrdinaryCallingFormat
+import boto3
+from botocore import exceptions
from framework.exceptions import HTTPError
from addons.base.exceptions import InvalidAuthError, InvalidFolderError
from addons.s3.settings import BUCKET_LOCATIONS
+logger = logging.getLogger(__name__)
+
def connect_s3(access_key=None, secret_key=None, node_settings=None):
"""Helper to build an S3Connection object
@@ -18,19 +23,27 @@ def connect_s3(access_key=None, secret_key=None, node_settings=None):
if node_settings is not None:
if node_settings.external_account is not None:
access_key, secret_key = node_settings.external_account.oauth_key, node_settings.external_account.oauth_secret
- connection = S3Connection(access_key, secret_key, calling_format=OrdinaryCallingFormat())
+ connection = boto3.client(
+ 's3',
+ aws_access_key_id=access_key,
+ aws_secret_access_key=secret_key
+ )
return connection
+def get_status_for_error(e: exceptions.ClientError) -> int:
+ return e.response['ResponseMetadata']['HTTPStatusCode']
+
+
def get_bucket_names(node_settings):
try:
- buckets = connect_s3(node_settings=node_settings).get_all_buckets()
- except exception.NoAuthHandlerFound:
+ response = connect_s3(node_settings=node_settings).list_buckets()
+ except exceptions.NoCredentialsError:
raise HTTPError(http_status.HTTP_403_FORBIDDEN)
- except exception.BotoServerError as e:
- raise HTTPError(e.status)
+ except exceptions.ClientError as e:
+ raise HTTPError(get_status_for_error(e))
- return [bucket.name for bucket in buckets]
+ return [bucket['Name'] for bucket in response['Buckets']]
def validate_bucket_location(location):
@@ -51,28 +64,32 @@ def validate_bucket_name(name):
def create_bucket(node_settings, bucket_name, location=''):
- return connect_s3(node_settings=node_settings).create_bucket(bucket_name, location=location)
+ client = connect_s3(node_settings=node_settings)
+
+ # default bucket location won't work with location constraint
+ if not location or location == 'us-east-1':
+ return client.create_bucket(Bucket=bucket_name)
+ else:
+ return client.create_bucket(
+ Bucket=bucket_name,
+ CreateBucketConfiguration={
+ 'LocationConstraint': location,
+ }
+ )
def bucket_exists(access_key, secret_key, bucket_name):
- """Tests for the existance of a bucket and if the user
+ """Tests for the existence of a bucket and if the user
can access it with the given keys
"""
if not bucket_name:
return False
- connection = connect_s3(access_key, secret_key)
-
- if bucket_name != bucket_name.lower():
- # Must use ordinary calling format for mIxEdCaSe bucket names
- # otherwise use the default as it handles bucket outside of the US
- connection.calling_format = OrdinaryCallingFormat()
-
try:
# Will raise an exception if bucket_name doesn't exist
- connect_s3(access_key, secret_key).head_bucket(bucket_name)
- except exception.S3ResponseError as e:
- if e.status not in (301, 302):
+ connect_s3(access_key, secret_key).head_bucket(Bucket=bucket_name)
+ except exceptions.ClientError as e:
+ if get_status_for_error(e) not in (301, 302):
return False
return True
@@ -87,38 +104,78 @@ def can_list(access_key, secret_key):
return False
try:
- connect_s3(access_key, secret_key).get_all_buckets()
- except exception.S3ResponseError:
+ connect_s3(access_key, secret_key).list_buckets()
+ except exceptions.ClientError:
return False
return True
-def get_user_info(access_key, secret_key):
+
+@dataclass(frozen=True) # slots is not supported in python 3.6
+class Owner:
+ __slots__ = ['display_name', 'id']
+
+ display_name: str
+ id: str
+
+ @classmethod
+ def from_dict(cls, data):
+ owner_id = data['ID']
+ return cls(data.get('DisplayName') or owner_id, owner_id)
+
+
+def get_user_info(access_key: str, secret_key: str) -> Optional[Owner]:
"""Returns an S3 User with .display_name and .id, or None
"""
if not (access_key and secret_key):
return None
try:
- return connect_s3(access_key, secret_key).get_all_buckets().owner
- except exception.S3ResponseError:
+ return Owner.from_dict(connect_s3(access_key, secret_key).list_buckets()['Owner'])
+ except exceptions.ClientError:
return None
- return None
+
def get_bucket_location_or_error(access_key, secret_key, bucket_name):
"""Returns the location of a bucket or raises AddonError
"""
try:
- connection = connect_s3(access_key, secret_key)
- except Exception:
+ # Will raise an exception if bucket_name doesn't exist.
+ # us-east-1 is reported as a null LocationConstraint; BUCKET_LOCATIONS keys it as ''.
+ return connect_s3(access_key, secret_key).get_bucket_location(Bucket=bucket_name)['LocationConstraint'] or ''
+ except exceptions.NoCredentialsError:
raise InvalidAuthError()
+ except exceptions.ClientError:
+ raise InvalidFolderError()
- if bucket_name != bucket_name.lower() or '.' in bucket_name:
- # Must use ordinary calling format for mIxEdCaSe bucket names
- # otherwise use the default as it handles bucket outside of the US
- connection.calling_format = OrdinaryCallingFormat()
+
+def get_bucket_prefixes(access_key, secret_key, prefix, bucket_name):
+ s3 = boto3.client(
+ 's3',
+ aws_access_key_id=access_key,
+ aws_secret_access_key=secret_key
+ )
try:
- # Will raise an exception if bucket_name doesn't exist
- return connect_s3(access_key, secret_key).get_bucket(bucket_name, validate=False).get_location()
- except exception.S3ResponseError:
- raise InvalidFolderError()
+ result = s3.list_objects(Bucket=bucket_name, Prefix=prefix, Delimiter='/')
+ except exceptions.NoCredentialsError:
+ raise HTTPError(http_status.HTTP_403_FORBIDDEN)
+ except exceptions.ClientError as e:
+ raise HTTPError(get_status_for_error(e))
+
+ folders = []
+ for common_prefixes in result.get('CommonPrefixes', []):
+ key_name = common_prefixes.get('Prefix')
+ if key_name != prefix:
+ folders.append(
+ {
+ 'path': key_name,
+ 'id': f'{bucket_name}:/{key_name}',
+ 'folder_id': key_name,
+ 'kind': 'folder',
+ 'bucket_name': bucket_name,
+ 'name': key_name.split('/')[-2],
+ 'addon': 's3',
+ }
+ )
+
+ return folders
diff --git a/addons/s3/views.py b/addons/s3/views.py
index 672dd335305..5bccd9b3d3a 100644
--- a/addons/s3/views.py
+++ b/addons/s3/views.py
@@ -1,7 +1,8 @@
from rest_framework import status as http_status
-from boto import exception
+from botocore import exceptions
from django.core.exceptions import ValidationError
+from django.db import IntegrityError, transaction
from flask import request
from framework.exceptions import HTTPError
@@ -58,7 +59,9 @@ def _set_folder(node_addon, folder, auth):
def s3_folder_list(node_addon, **kwargs):
""" Returns all the subsequent folders under the folder id passed.
"""
- return node_addon.get_folders()
+ path = request.args.get('path', '')
+ folder_id = request.args.get('id', '')
+ return node_addon.get_folders(path=path, folder_id=folder_id)
@must_be_logged_in
@must_be_rdm_addons_allowed(SHORT_NAME)
@@ -95,16 +98,17 @@ def s3_add_user_account(auth, **kwargs):
masked_access_key = f'****{access_key[-4:]}' if len(access_key) > 4 else '****'
display_name = f'{user_info.display_name} ({masked_access_key})'
try:
- account = ExternalAccount(
- provider=SHORT_NAME,
- provider_name=FULL_NAME,
- oauth_key=access_key,
- oauth_secret=secret_key,
- provider_id=provider_id,
- display_name=display_name,
- )
- account.save()
- except ValidationError:
+ with transaction.atomic():
+ account = ExternalAccount(
+ provider=SHORT_NAME,
+ provider_name=FULL_NAME,
+ oauth_key=access_key,
+ oauth_secret=secret_key,
+ provider_id=provider_id,
+ display_name=display_name,
+ )
+ account.save()
+ except (ValidationError, IntegrityError):
# ... or get the old one
account = ExternalAccount.objects.get(
provider=SHORT_NAME,
@@ -148,19 +152,21 @@ def create_bucket(auth, node_addon, **kwargs):
try:
utils.create_bucket(node_addon, bucket_name, bucket_location)
- except exception.S3ResponseError as e:
+ except exceptions.NoCredentialsError as e:
return {
- 'message': e.message,
+ 'message': str(e),
'title': 'Problem connecting to S3',
}, http_status.HTTP_400_BAD_REQUEST
- except exception.S3CreateError as e:
+ except exceptions.ClientError as e:
return {
- 'message': e.message,
- 'title': "Problem creating bucket '{0}'".format(bucket_name),
+ 'message': str(e),
+ 'title': f"Problem creating bucket '{bucket_name}'",
}, http_status.HTTP_400_BAD_REQUEST
- except exception.BotoClientError as e: # Base class catchall
+ except exceptions.BotoCoreError as e:
+ # Base class catchall. ClientError is not a BotoCoreError, and NoCredentialsError
+ # is handled above, so neither of the clauses above is shadowed by this one.
return {
- 'message': e.message,
+ 'message': str(e),
'title': 'Error connecting to S3',
}, http_status.HTTP_400_BAD_REQUEST
diff --git a/addons/wiki/settings/defaults.py b/addons/wiki/settings/defaults.py
index 9fadefc5f61..bbf8ed0f0de 100644
--- a/addons/wiki/settings/defaults.py
+++ b/addons/wiki/settings/defaults.py
@@ -8,6 +8,11 @@
SHAREJS_PORT = 7007
SHAREJS_URL = '{}:{}'.format(SHAREJS_HOST, SHAREJS_PORT)
+Y_WEBSOCKET_SECRET = os.environ.get('Y_WEBSOCKET_SECRET', '')
+Y_WEBSOCKET_JWT_ALGORITHM = 'HS256'
+# Token validity for WebSocket connection (seconds). Not enforced after connection is established.
+Y_WEBSOCKET_TOKEN_TTL = int(os.environ.get('Y_WEBSOCKET_TOKEN_TTL', 8 * 60 * 60))
+
Y_WEBSOCKET_HOST = 'localhost'
Y_WEBSOCKET_PORT = 1234
Y_WEBSOCKET_URL = '{}:{}'.format(Y_WEBSOCKET_HOST, Y_WEBSOCKET_PORT)
diff --git a/addons/wiki/templates/edit.mako b/addons/wiki/templates/edit.mako
index c4fd10d92da..6356b6f15e6 100644
--- a/addons/wiki/templates/edit.mako
+++ b/addons/wiki/templates/edit.mako
@@ -522,6 +522,7 @@ ${parent.javascript_bottom()}
metadata: {
registration: true,
docId: ${ sharejs_uuid | sjson, n },
+ yWebsocketToken: ${ y_websocket_token | sjson, n },
userId: ${user_id | sjson, n },
userName: ${ user_full_name | sjson, n },
userUrl: ${ user_url | sjson, n },
diff --git a/addons/wiki/tests/test_wiki.py b/addons/wiki/tests/test_wiki.py
index 6daffd6dead..d6f267a6fb5 100644
--- a/addons/wiki/tests/test_wiki.py
+++ b/addons/wiki/tests/test_wiki.py
@@ -35,7 +35,7 @@
from addons.wiki.utils import (
get_sharejs_uuid, generate_private_uuid, share_db, delete_share_doc,
migrate_uuid, format_wiki_version, serialize_wiki_settings, serialize_wiki_widget,
- check_file_object_in_node
+ check_file_object_in_node, generate_y_websocket_token
)
from addons.wiki.views import WIKI_IMPORT_TASK_ALREADY_EXISTS
from addons.wiki import tasks
@@ -1861,11 +1861,21 @@ def test_project_wiki_validate_for_import(self, mock_check_file_object_in_node):
mock_check_file_object_in_node.return_value = True
dir_id = self.root_import_folder1._id
url = self.project.api_url_for('project_wiki_validate_for_import', dir_id=dir_id)
- res = self.app.get(url)
+ res = self.app.get(url, auth=self.user.auth)
response_json = res.json
task_id = response_json['taskId']
uuid_obj = uuid.UUID(task_id)
assert uuid_obj
+ task = WikiImportTask.objects.get(task_id=task_id)
+ self.assertEqual(task.node, self.project)
+ self.assertEqual(task.status, WikiImportTask.STATUS_COMPLETED)
+ self.assertIsNotNone(task.process_end)
+ self.assertEqual(
+ WikiImportTask.objects.filter(
+ node=self.project, status=WikiImportTask.STATUS_RUNNING
+ ).count(),
+ 0,
+ )
@mock.patch('addons.wiki.utils.check_file_object_in_node')
def test_project_wiki_validate_for_import_error(self, mock_check_file_object_in_node):
@@ -1875,7 +1885,7 @@ def test_project_wiki_validate_for_import_error(self, mock_check_file_object_in_
))
dir_id = self.root_import_folder1._id
url = self.project.api_url_for('project_wiki_validate_for_import', dir_id=dir_id)
- res = self.app.get(url, expect_errors=True)
+ res = self.app.get(url, auth=self.user.auth, expect_errors=True)
self.assertEqual(res.status_code, 400)
self.assertEqual(res.json['message_short'], 'directory id does not exist')
self.assertEqual(res.json['message_long'], 'directory id does not exist')
@@ -1964,6 +1974,9 @@ def test_project_wiki_import(self, mock_check_file_object_in_node, mock_project_
response_json = res.json
task_id = response_json['taskId']
uuid_obj = uuid.UUID(task_id)
+ task = WikiImportTask.objects.get(task_id=task_id)
+ self.assertEqual(task.node, self.project)
+ self.assertEqual(task.status, WikiImportTask.STATUS_RUNNING)
@mock.patch('addons.wiki.utils.check_file_object_in_node')
def test_project_wiki_import_error(self, mock_check_file_object_in_node):
@@ -1983,6 +1996,43 @@ def test_project_wiki_import_with_no_admin_permission(self):
res = self.app.post_json(url, { 'data': [{'test': 'test1'}] }, expect_errors=True)
assert_equal(res.status_code, 401)
+ @mock.patch('addons.wiki.views.AsyncResult')
+ def test_project_get_task_result_with_admin_permission(self, mock_async_result):
+ mock_res = mock.MagicMock()
+ mock_res.ready.return_value = True
+ mock_res.get.return_value = {'canStartImport': True, 'data': []}
+ mock_async_result.return_value = mock_res
+ WikiImportTask.objects.create(
+ node=self.project,
+ task_id='task-id',
+ status=WikiImportTask.STATUS_COMPLETED,
+ creator=self.user,
+ )
+ url = self.project.api_url_for('project_get_task_result', task_id='task-id')
+ res = self.app.get(url, auth=self.user.auth)
+ assert_equal(res.status_code, http_status.HTTP_200_OK)
+ assert_equal(res.json, {'canStartImport': True, 'data': []})
+
+ @mock.patch('addons.wiki.views.AsyncResult')
+ def test_project_get_task_result_rejects_task_for_other_node(self, mock_async_result):
+ other_project = ProjectFactory(creator=self.user)
+ WikiImportTask.objects.create(
+ node=other_project,
+ task_id='other-node-task-id',
+ status=WikiImportTask.STATUS_COMPLETED,
+ creator=self.user,
+ )
+ url = self.project.api_url_for('project_get_task_result', task_id='other-node-task-id')
+ res = self.app.get(url, auth=self.user.auth, expect_errors=True)
+ assert_equal(res.status_code, http_status.HTTP_404_NOT_FOUND)
+ mock_async_result.assert_not_called()
+
+ def test_project_get_task_result_rejects_unknown_task_id(self):
+ url = self.project.api_url_for('project_get_task_result', task_id='unknown-task-id')
+ res = self.app.get(url, auth=self.user.auth, expect_errors=True)
+ assert_equal(res.status_code, http_status.HTTP_404_NOT_FOUND)
+
+
@mock.patch('celery.contrib.abortable.AbortableAsyncResult')
def test_wiki_import_create_or_update_aborted(self, mock_task):
mock_task.is_aborted.return_value = True
@@ -3380,3 +3430,79 @@ def test_project_update_wiki_page_sort(self):
self.assertEqual(result_wiki_child_page1, {'parent_id': wiki_page2_id, 'sort_order': 1})
self.assertEqual(result_wiki_child_page2, {'parent_id': wiki_page2_id, 'sort_order': 2})
self.assertEqual(result_wiki_child_page3, {'parent_id': wiki_child_page2_id, 'sort_order': 1})
+
+@pytest.mark.enable_bookmark_creation
+class TestYWebsocketToken(OsfTestCase):
+
+ def setUp(self):
+ super(TestYWebsocketToken, self).setUp()
+ # Class-level @mock.patch is unreliable under pytest here; patch in setUp
+ # so generate_y_websocket_token sees a non-empty secret during the request.
+ self._y_websocket_secret = 'test-y-websocket-secret'
+ self._secret_patcher = mock.patch(
+ 'addons.wiki.settings.Y_WEBSOCKET_SECRET',
+ self._y_websocket_secret,
+ )
+ self._secret_patcher.start()
+ self.addCleanup(self._secret_patcher.stop)
+
+ self.user = AuthUserFactory()
+ self.project = ProjectFactory(is_public=True, creator=self.user)
+ self.wname = 'foo.bar'
+ self.wkey = to_mongo_key(self.wname)
+
+ def test_token_generated_for_editor(self):
+ import jwt
+ from addons.wiki import settings as wiki_settings
+
+ url = self.project.web_url_for('project_wiki_view', wname=self.wname)
+ res = self.app.get(url, auth=self.user.auth)
+ assert_equal(res.status_code, 200)
+
+ self.project.reload()
+ body = res.body.decode()
+ sharejs_uuid = get_sharejs_uuid(self.project, self.wname)
+ assert_true(sharejs_uuid)
+ assert_in(sharejs_uuid, body)
+
+ # edit.mako renders `yWebsocketToken: "..."`, not JSON `"yWebsocketToken": "..."`.
+ token_match = re.search(r'yWebsocketToken:\s*"([^"]+)"', body)
+ assert_true(token_match)
+ token = token_match.group(1)
+ assert_true(token)
+
+ payload = jwt.decode(
+ token,
+ self._y_websocket_secret,
+ algorithms=[wiki_settings.Y_WEBSOCKET_JWT_ALGORITHM],
+ )
+ assert_equal(payload['doc_id'], sharejs_uuid)
+ assert_equal(payload['sub'], self.user._id)
+
+ def test_token_not_visible_without_write_permission(self):
+ WikiPage.objects.create_for_node(self.project, self.wname, 'some content', Auth(self.user))
+
+ url = self.project.web_url_for('project_wiki_view', wname=self.wname)
+ # Generate sharejs uuid / token path as an editor first (same pattern as TestWikiUuid).
+ res = self.app.get(url, auth=self.user.auth)
+ assert_equal(res.status_code, 200)
+ self.project.reload()
+ sharejs_uuid = get_sharejs_uuid(self.project, self.wname)
+ assert_true(sharejs_uuid)
+
+ # Users without write permission should not receive uuid or a non-empty token.
+ res = self.app.get(url)
+ assert_equal(res.status_code, 200)
+ body = res.body.decode()
+ assert_not_in(sharejs_uuid, body)
+ token_match = re.search(r'yWebsocketToken:\s*"([^"]+)"', body)
+ assert_false(token_match)
+
+ def test_generate_y_websocket_token_without_secret(self):
+ with mock.patch('addons.wiki.settings.Y_WEBSOCKET_SECRET', ''):
+ assert_equal(generate_y_websocket_token('doc-id', 'user-id'), '')
+
+ def test_generate_y_websocket_token_without_user_id(self):
+ assert_equal(generate_y_websocket_token('doc-id', ''), '')
+ assert_equal(generate_y_websocket_token('doc-id', None), '')
+
diff --git a/addons/wiki/tests/test_wiki_import.py b/addons/wiki/tests/test_wiki_import.py
index 73fe2c3a941..b57bf6545c4 100644
--- a/addons/wiki/tests/test_wiki_import.py
+++ b/addons/wiki/tests/test_wiki_import.py
@@ -1245,11 +1245,15 @@ def test_project_wiki_validate_for_import(self, mock_check_file_object_in_node):
mock_check_file_object_in_node.return_value = True
dir_id = self.root_import_folder1._id
url = self.project.api_url_for('project_wiki_validate_for_import', dir_id=dir_id)
- res = self.app.get(url)
+ res = self.app.get(url, auth=self.user.auth)
response_json = res.json
task_id = response_json['taskId']
uuid_obj = uuid.UUID(task_id)
assert uuid_obj
+ task = WikiImportTask.objects.get(task_id=task_id)
+ assert_equal(task.node, self.project)
+ assert_equal(task.status, WikiImportTask.STATUS_COMPLETED)
+ assert_is_not_none(task.process_end)
def test_project_wiki_validate_for_import_process(self):
result = views.project_wiki_validate_for_import_process(
@@ -1323,6 +1327,9 @@ def test_project_wiki_import(self, mock_check_file_object_in_node, mock_project_
task_id = response_json['taskId']
uuid_obj = uuid.UUID(task_id)
assert_is_not_none(uuid_obj)
+ task = WikiImportTask.objects.get(task_id=task_id)
+ assert_equal(task.node, self.project)
+ assert_equal(task.status, WikiImportTask.STATUS_RUNNING)
@mock.patch('addons.wiki.views._get_md_content_from_wb')
@mock.patch('addons.wiki.views._get_or_create_wiki_folder')
diff --git a/addons/wiki/utils.py b/addons/wiki/utils.py
index 39af0660fb0..947cf31b834 100644
--- a/addons/wiki/utils.py
+++ b/addons/wiki/utils.py
@@ -4,10 +4,13 @@
import uuid
import unicodedata
import ssl
+import datetime
from future.moves.urllib.parse import quote
+import jwt
from pymongo import MongoClient
import requests
+from django.utils import timezone
from bs4 import BeautifulSoup
from django.apps import apps
@@ -65,6 +68,32 @@ def get_sharejs_uuid(node, wname):
)) if private_uuid else None
+def generate_y_websocket_token(doc_id, user_id):
+ """
+ Generate a signed JWT for y-websocket connection authorization.
+
+ Includes ``sub`` (OSF user GUID) for connection traceability on the
+ y-websocket side. Returns an empty string when Y_WEBSOCKET_SECRET is not
+ configured or required claims are missing.
+ """
+ secret = wiki_settings.Y_WEBSOCKET_SECRET
+ if not secret or not doc_id or not user_id:
+ return ''
+
+ payload = {
+ 'doc_id': doc_id,
+ 'sub': user_id,
+ 'exp': timezone.now() + datetime.timedelta(seconds=wiki_settings.Y_WEBSOCKET_TOKEN_TTL),
+ }
+ token = jwt.encode(
+ payload,
+ secret,
+ algorithm=wiki_settings.Y_WEBSOCKET_JWT_ALGORITHM,
+ )
+ if isinstance(token, bytes):
+ return token.decode()
+ return token
+
def delete_share_doc(node, wname):
"""Deletes share document and removes namespace from model."""
diff --git a/addons/wiki/views.py b/addons/wiki/views.py
index a1051ba9110..ef213addf82 100644
--- a/addons/wiki/views.py
+++ b/addons/wiki/views.py
@@ -325,10 +325,15 @@ def project_wiki_view(auth, wname, path=None, **kwargs):
rendered_before_update = False
markdown = ''
+ y_websocket_token = ''
if can_edit:
if wiki_key not in node.wiki_private_uuids:
wiki_utils.generate_private_uuid(node, wiki_name)
sharejs_uuid = wiki_utils.get_sharejs_uuid(node, wiki_name)
+ y_websocket_token = wiki_utils.generate_y_websocket_token(
+ sharejs_uuid,
+ auth.user._id,
+ )
else:
if not wiki_page and wiki_key != 'home':
raise WIKI_PAGE_NOT_FOUND_ERROR
@@ -366,6 +371,7 @@ def project_wiki_view(auth, wname, path=None, **kwargs):
'sharejs_uuid': sharejs_uuid or '',
'sharejs_url': settings.SHAREJS_URL,
'y_websocket_url': settings.Y_WEBSOCKET_URL,
+ 'y_websocket_token': y_websocket_token,
'is_current': is_current,
'version_settings': version_settings,
'pages_current': _get_wiki_pages_latest(node),
@@ -771,12 +777,40 @@ def serialize_component_wiki(node, auth):
return component
return None
+
+def _bind_wiki_task_to_node(task_id, node, auth, status, set_process_end=False):
+ """Record task_id as belonging to node for get_task_result authorization.
+
+ Validate tasks must not use STATUS_RUNNING so they do not affect
+ "importing" UI, abort, or concurrent-import checks.
+ """
+ defaults = {
+ 'node': node,
+ 'status': status,
+ 'creator': auth.user if auth else None,
+ }
+ if set_process_end:
+ defaults['process_end'] = timezone.make_naive(timezone.now(), timezone.utc)
+ WikiImportTask.objects.get_or_create(task_id=task_id, defaults=defaults)
+
+
+def _wiki_task_belongs_to_node(task_id, node):
+ return WikiImportTask.objects.filter(task_id=task_id, node=node).exists()
+
+
@must_be_valid_project
-def project_wiki_validate_for_import(dir_id, node, **kwargs):
+@must_have_permission(ADMIN)
+@must_not_be_registration
+@must_have_addon('wiki', 'node')
+def project_wiki_validate_for_import(dir_id, auth, node, **kwargs):
wiki_utils.check_file_object_in_node(dir_id, node)
node_id = node.guids.first()._id
task = tasks.run_project_wiki_validate_for_import.delay(dir_id, node_id)
task_id = task.id
+ # Completed + process_end: ownership only; not treated as a running import.
+ _bind_wiki_task_to_node(
+ task_id, node, auth, WikiImportTask.STATUS_COMPLETED, set_process_end=True
+ )
return {'taskId': task_id}
def project_wiki_validate_for_import_process(dir_id, node):
@@ -904,11 +938,15 @@ def project_wiki_import(dir_id, auth, node, **kwargs):
data_json = json.dumps(data)
task = tasks.run_project_wiki_import.delay(data_json, dir_id, current_user_id, node_id)
task_id = task.id
+ # Bind before returning so polling get_task_result does not race the worker.
+ _bind_wiki_task_to_node(task_id, node, auth, WikiImportTask.STATUS_RUNNING)
+ check_running_task(task_id, node)
return {'taskId': task_id}
def project_wiki_import_process(data, dir_id, task_id, auth, node):
logger.info('----WIKI IMPORT DIRECTORY_ID: {}, PROJECT_NAME: {} ----'.format(dir_id, node.title))
- WikiImportTask.objects.create(node=node, task_id=task_id, status=WikiImportTask.STATUS_RUNNING, creator=auth.user)
+ # May already exist if project_wiki_import bound the task before the worker ran.
+ _bind_wiki_task_to_node(task_id, node, auth, WikiImportTask.STATUS_RUNNING)
check_running_task(task_id, node)
ret = []
wiki_id_list = []
@@ -1306,8 +1344,15 @@ def _create_import_error_list(wiki_infos, imported_list):
return import_errors
@must_be_valid_project
+@must_have_permission(ADMIN)
+@must_not_be_registration
@must_have_addon('wiki', 'node')
def project_get_task_result(task_id, node, **kwargs):
+ if not _wiki_task_belongs_to_node(task_id, node):
+ raise HTTPError(http_status.HTTP_404_NOT_FOUND, data=dict(
+ message_short='Not found',
+ message_long='Task not found.',
+ ))
res = AsyncResult(task_id, app=celery_app)
result = None
if not res.ready():
diff --git a/admin/institutional_storage_quota_control/views.py b/admin/institutional_storage_quota_control/views.py
index 7b028da5986..725d464053d 100644
--- a/admin/institutional_storage_quota_control/views.py
+++ b/admin/institutional_storage_quota_control/views.py
@@ -1,16 +1,19 @@
+import logging
from django.contrib.auth.mixins import UserPassesTestMixin
from django.db import connection, transaction, IntegrityError
from django.db.models import Subquery, OuterRef
from django.http import Http404
-
from admin.institutions.views import QuotaUserList
-from osf.models import Institution, OSFUser, UserQuota
+from osf.models import Institution, OSFUser, UserQuota, InstitutionDefaultMaxQuota
from admin.base import settings
from addons.osfstorage.models import Region
from django.views.generic import ListView, View
from django.shortcuts import redirect
from admin.rdm.utils import RdmPermissionMixin
from django.core.urlresolvers import reverse
+from api.base import settings as api_settings
+
+logger = logging.getLogger(__name__)
class InstitutionStorageList(RdmPermissionMixin, UserPassesTestMixin, ListView):
@@ -122,6 +125,22 @@ def get_institution(self):
raise Http404
return institution
+ def get_default_max_quota(self):
+ """Get default max quota for the institution, fallback to DEFAULT_MAX_QUOTA when not found."""
+ try:
+ institution_default_max_quota = InstitutionDefaultMaxQuota.objects.get(
+ institution_id=self.institution_id
+ )
+ return institution_default_max_quota.default_max_quota
+ except InstitutionDefaultMaxQuota.DoesNotExist:
+ return api_settings.DEFAULT_MAX_QUOTA
+
+ def get_context_data(self, **kwargs):
+ """ Add default_max_quota to template context """
+ context = super().get_context_data(**kwargs)
+ context['default_max_quota'] = self.get_default_max_quota()
+ return context
+
class UpdateQuotaUserListByInstitutionStorageID(RdmPermissionMixin, UserPassesTestMixin, View):
""" Change max quota for an institution's users if that institution is not using NII Storage. """
@@ -163,17 +182,23 @@ def post(self, request, *args, **kwargs):
min_value, max_value = connection.ops.integer_field_range('PositiveIntegerField')
if min_value <= max_quota <= max_value:
# If max quota value is between 0 and 2147483647, update or create used quota for each user in the institution
- for user in OSFUser.objects.filter(
- affiliated_institutions=self.institution_id):
- try:
- with transaction.atomic():
- UserQuota.objects.update_or_create(
- user=user,
- storage_type=UserQuota.CUSTOM_STORAGE,
- defaults={'max_quota': max_quota}
- )
- except IntegrityError:
- UserQuota.objects.filter(user=user, storage_type=UserQuota.CUSTOM_STORAGE).update(max_quota=max_quota)
+ with transaction.atomic():
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution_id,
+ defaults={'default_max_quota': max_quota}
+ )
+ for user in OSFUser.objects.filter(
+ affiliated_institutions=self.institution_id):
+ try:
+ with transaction.atomic():
+ UserQuota.objects.update_or_create(
+ user=user,
+ storage_type=UserQuota.CUSTOM_STORAGE,
+ defaults={'max_quota': max_quota}
+ )
+ except IntegrityError as e:
+ logger.warning(u'IntegrityError while updating UserQuota: user={}, storage_type={}, max_quota={}: {}.'.format(user.id, UserQuota.CUSTOM_STORAGE, max_quota, str(e)))
+ UserQuota.objects.filter(user=user, storage_type=UserQuota.CUSTOM_STORAGE).update(max_quota=max_quota)
return redirect(
'institutional_storage_quota_control:institution_user_list',
institution_id=self.institution_id
diff --git a/admin/quota_recalc/views.py b/admin/quota_recalc/views.py
index 29484c3d559..abeb1bf6537 100644
--- a/admin/quota_recalc/views.py
+++ b/admin/quota_recalc/views.py
@@ -1,12 +1,14 @@
+import logging
from django.http import JsonResponse
from django.db import transaction, IntegrityError
from addons.osfstorage.models import Region
-from api.base import settings as api_settings
from osf.models import OSFUser, UserQuota, Node
from osf.models.node import set_project_storage_type
from osf.utils.requests import check_select_for_update
-from website.util.quota import used_quota
+from website.util.quota import get_default_max_quota, used_quota
+
+logger = logging.getLogger(__name__)
def calculate_quota(user):
@@ -23,6 +25,7 @@ def calculate_quota(user):
with transaction.atomic():
for storage_type in storage_type_list:
used = used_quota(user._id, storage_type)
+ max_quota = get_default_max_quota(user, storage_type)
try:
if check_select_for_update():
user_quota = UserQuota.objects.filter(
@@ -42,10 +45,11 @@ def calculate_quota(user):
UserQuota.objects.create(
user=user,
storage_type=storage_type,
- max_quota=api_settings.DEFAULT_MAX_QUOTA,
+ max_quota=max_quota,
used=used,
)
- except IntegrityError:
+ except IntegrityError as e:
+ logger.warning(u'IntegrityError while creating UserQuota in calculate_quota: user={}, storage_type={}: {}.'.format(user.id, storage_type, str(e)))
used = used_quota(user._id, storage_type)
if check_select_for_update():
user_quota = UserQuota.objects.filter(
diff --git a/admin/rdm_custom_storage_location/utils.py b/admin/rdm_custom_storage_location/utils.py
index df350ed2ae1..9959cda827d 100644
--- a/admin/rdm_custom_storage_location/utils.py
+++ b/admin/rdm_custom_storage_location/utils.py
@@ -13,6 +13,7 @@
import os
import owncloud
from django.core.exceptions import ValidationError
+from django.db import IntegrityError, transaction
from addons.dropboxbusiness.models import node_post_save as dropboxbusiness_post_save
from addons.onedrivebusiness.models import node_post_save as onedrivebusiness_post_save
@@ -45,7 +46,8 @@
from framework.exceptions import HTTPError
from osf.models import AbstractNode
from website import settings as osf_settings
-from osf.models import Node, OSFUser, ProjectStorageType, UserQuota
+from osf.models import Node, OSFUser, ProjectStorageType, UserQuota, InstitutionDefaultMaxQuota
+from api.base import settings as api_settings
from osf.models.external import ExternalAccountTemporary, ExternalAccount
from osf.utils import external_util
import datetime
@@ -261,7 +263,10 @@ def test_s3_connection(access_key, secret_key, bucket):
s3_response = {
'id': user_info.id,
'display_name': user_info.display_name,
- 'Owner': user_info.Owner,
+ 'Owner': {
+ 'ID': user_info.id,
+ 'DisplayName': user_info.display_name,
+ },
}
return ({
@@ -1387,3 +1392,51 @@ def add_node_settings_to_projects(institution, provider_name):
onedrivebusiness_post_save(None, project, created=project_has_no_node_settings)
else:
node_post_save(None, project, created=project_has_no_node_settings)
+
+def is_institution_using_nii_storage(institution):
+ """Return True if the institution is currently using NII storage (or has no region configured)."""
+ old_region = Region.objects.filter(_id=institution._id).first()
+ return (
+ old_region is None or
+ old_region.waterbutler_settings.get('storage', {}).get('type') == Region.NII_STORAGE
+ )
+
+
+def upsert_user_quota_for_institution(institution, provider_short_name, old_is_nii, old_quota_type=None):
+ """Determine quota migration params and upsert UserQuota for all users affiliated with the institution."""
+ new_quota_type = institution.get_user_quota_type_for_nii_storage()
+ users = OSFUser.objects.filter(affiliated_institutions=institution.id)
+
+ if provider_short_name == 'osfstorage' and not old_is_nii:
+ # 機関ストレージ → NII Storage: reset all users to default quota
+ storage_type = new_quota_type
+ quota_map = {}
+ elif provider_short_name != 'osfstorage' and old_is_nii:
+ # NII Storage → 機関ストレージ: set all users to institution default quota
+ default_max_quota = InstitutionDefaultMaxQuota.objects.filter(institution=institution).values_list('default_max_quota', flat=True).first()
+ fixed_quota = default_max_quota if default_max_quota is not None else api_settings.DEFAULT_MAX_QUOTA
+ storage_type = UserQuota.CUSTOM_STORAGE
+ quota_map = {user.id: fixed_quota for user in users}
+ elif old_quota_type is not None and old_quota_type != new_quota_type and new_quota_type is not None:
+ # NII Storage quota type changed (e.g. no-region → has-region): preserve each user's existing quota
+ storage_type = new_quota_type
+ quota_map = {
+ q.user_id: q.max_quota
+ for q in UserQuota.objects.filter(user__in=users, storage_type=old_quota_type)
+ }
+ else:
+ return
+
+ with transaction.atomic():
+ for user in users:
+ max_quota = quota_map.get(user.id, api_settings.DEFAULT_MAX_QUOTA)
+ try:
+ with transaction.atomic():
+ UserQuota.objects.update_or_create(
+ user=user,
+ storage_type=storage_type,
+ defaults={'max_quota': max_quota}
+ )
+ except IntegrityError as e:
+ logger.warning(u'IntegrityError while updating UserQuota: user={}, storage_type={}, max_quota={}: {}.'.format(user.id, storage_type, max_quota, str(e)))
+ UserQuota.objects.filter(user=user, storage_type=storage_type).update(max_quota=max_quota)
diff --git a/admin/rdm_custom_storage_location/views.py b/admin/rdm_custom_storage_location/views.py
index dff08d2b3ec..d91276ee0fa 100644
--- a/admin/rdm_custom_storage_location/views.py
+++ b/admin/rdm_custom_storage_location/views.py
@@ -291,6 +291,9 @@ def post(self, request, *args, **kwargs):
'message': 'Storage name is missing.'
}, status=http_status.HTTP_400_BAD_REQUEST)
+ old_is_nii = utils.is_institution_using_nii_storage(institution)
+ old_quota_type = institution.get_user_quota_type_for_nii_storage()
+
result = None
if provider_short_name == 's3':
@@ -436,6 +439,7 @@ def post(self, request, *args, **kwargs):
utils.change_allowed_for_institutions(
institution, provider_short_name)
utils.add_node_settings_to_projects(institution, provider_short_name)
+ utils.upsert_user_quota_for_institution(institution, provider_short_name, old_is_nii, old_quota_type)
return JsonResponse(result[0], status=status)
diff --git a/admin/templates/institutional_storage_quota_control/user_list_by_institute.html b/admin/templates/institutional_storage_quota_control/user_list_by_institute.html
index 2e2050514b0..3d747299e1c 100644
--- a/admin/templates/institutional_storage_quota_control/user_list_by_institute.html
+++ b/admin/templates/institutional_storage_quota_control/user_list_by_institute.html
@@ -121,7 +121,7 @@
{% trans "Institutional Storage" %} > {{ institution_name }}
size (int or None).
+ """
+ if not osf_file_ids:
+ return {}
+
+ # Access the M2M through-model (BaseFileVersionsThrough) without importing it directly.
+ # Fields: basefilenode_id (FK → BaseFileNode), fileversion_id (FK → FileVersion)
+ ThroughModel = OsfStorageFile.versions.through
+
+ # Single JOIN query: fetches all through-entries + related FileVersion data at once.
+ entries = (
+ ThroughModel.objects
+ .filter(basefilenode_id__in=osf_file_ids)
+ .select_related('fileversion')
+ )
+
+ # Group by file and pick the version with the highest numeric identifier.
+ # result maps: file_node_id → (version_number_int, size)
+ best = {} # file_node_id -> (ver_num, size)
+ for entry in entries:
+ fv = entry.fileversion
+ file_node_id = entry.basefilenode_id
+
+ # identifier is a text field ('1', '2', ...) — parse to int for correct ordering
+ try:
+ ver_num = int(fv.identifier)
+ except (TypeError, ValueError):
+ ver_num = -1 # non-numeric identifiers rank last
+
+ if file_node_id not in best or ver_num > best[file_node_id][0]:
+ # Resolve size: prefer fv.size, fall back to metadata dict
+ size = fv.size
+ if size is None and isinstance(fv.metadata, dict):
+ try:
+ size = int(fv.metadata.get('size'))
+ except (TypeError, ValueError):
+ size = None
+ best[file_node_id] = (ver_num, size)
+
+ return {file_id: info[1] for file_id, info in best.items()}
+
+
+def scan_broken_files():
+ """Scan for OsfStorageFile nodes with missing or zero-size FileInfo.
+
+ Fails fast: the moment a record is found that cannot be fully fixed (size cannot
+ be determined, or its project creator / storage_type cannot be resolved), this
+ raises so the administrator can fix that one record and re-run. No DB writes.
+ Returns a list of fully-fixable row dicts when every record is OK.
+ """
+ broken_files = list(get_broken_file_nodes())
+ total = len(broken_files)
+ logger.info('Scanning OsfStorageFile records...')
+ logger.info('Found {} file node(s) with missing or zero-size FileInfo.'.format(total))
+
+ # Bulk-fetch latest version sizes to avoid N+1 queries
+ file_ids = [f.id for f in broken_files]
+ size_map = _bulk_get_latest_version_sizes(file_ids)
+
+ rows = []
+ for osf_file in broken_files:
+ size = size_map.get(osf_file.id)
+
+ # Fail fast: the size must be determinable. (A genuine file_size == 0 is a
+ # valid size and is NOT an error — only an indeterminable size is.)
+ if size is None:
+ raise RuntimeError(
+ 'File {} (_id={}) — could not determine size from any FileVersion. '
+ 'Fix this record and re-run.'.format(osf_file.name, osf_file._id)
+ )
+
+ # Resolve the project target, then its creator / guid / storage_type.
+ project_guid = ''
+ creator_id = ''
+ storage_type_value = None
+ try:
+ # Direct lookup via ContentType to avoid GFK+TypedModel prefetch incompatibility.
+ # osf_file.target (GFK accessor) returns None when used after prefetch_related
+ # because TypedModel proxy resolution breaks the GFK prefetch cache.
+ target = None
+ if osf_file.target_content_type_id and osf_file.target_object_id:
+ ct = ContentType.objects.get_for_id(osf_file.target_content_type_id)
+ model_class = ct.model_class()
+ if model_class:
+ target = model_class.objects.filter(id=osf_file.target_object_id).first()
+
+ if target is not None:
+ first_guid = target.guids.values_list('_id', flat=True).first()
+ project_guid = first_guid or ''
+ creator = getattr(target, 'creator', None)
+ if creator:
+ creator_id = creator._id
+ storage_type_value = get_project_storage_type(target)
+ except Exception as e:
+ # Fail fast: do not swallow unexpected errors.
+ logger.error('Could not resolve target/creator for file {}: {} — aborting (fail fast).'.format(
+ osf_file._id, e
+ ))
+ raise
+
+ # Fail fast: creator and storage_type must be resolvable to recalculate quota.
+ if not creator_id:
+ raise RuntimeError(
+ 'File {} (_id={}, project_guid={}) — creator could not be resolved '
+ '(target missing or has no creator). Fix this record and re-run.'.format(
+ osf_file.name, osf_file._id, project_guid
+ )
+ )
+ if storage_type_value is None:
+ raise RuntimeError(
+ 'File {} (_id={}, project_guid={}) — project storage_type could not be '
+ 'determined. Fix this record and re-run.'.format(
+ osf_file.name, osf_file._id, project_guid
+ )
+ )
+
+ rows.append({
+ 'osf_file_id': osf_file._id, # hex _id string of OsfStorageFile
+ 'fileinfo_file_id': osf_file.id, # integer PK stored as file_id FK in FileInfo table
+ 'file_name': osf_file.name,
+ 'file_size_bytes': size,
+ 'project_guid': project_guid,
+ 'creator_id': creator_id,
+ 'storage_type': storage_type_value,
+ 'osf_file': osf_file,
+ })
+
+ logger.info('Scan complete. {} record(s) ready to fix.'.format(len(rows)))
+ return rows
+
+
+def print_results(rows):
+ """Display scanned records in a formatted summary table."""
+ if not rows:
+ print(' No broken FileInfo records found.')
+ return
+
+ def _disp(value):
+ # Display helper: never let None (or any value) break str formatting.
+ return '' if value is None else str(value)
+
+ print('\n' + '-' * 160)
+ print(' {:<26} {:<16} {:<22} {:<18} {:<16} {:<14} {:<18}'.format(
+ 'OSF_FILE_ID (_id)', 'FILEINFO_FILE_ID', 'FILE_NAME', 'PROJECT_GUID', 'CREATOR_ID', 'STORAGE_TYPE', 'SIZE (bytes)'
+ ))
+ print('-' * 160)
+ for r in rows:
+ print(' {:<26} {:<16} {:<22} {:<18} {:<16} {:<14} {:<18}'.format(
+ _disp(r.get('osf_file_id')),
+ _disp(r.get('fileinfo_file_id')),
+ _disp(r.get('file_name'))[:20],
+ _disp(r.get('project_guid')),
+ _disp(r.get('creator_id')),
+ _disp(r.get('storage_type')),
+ _disp(r.get('file_size_bytes')),
+ ))
+ print('-' * 160)
+ print(' Total: {} record(s) would be updated.\n'.format(len(rows)))
+
+
+# --- Updating Operations ---
+
+def apply_fixes(rows, dry_run=False):
+ """Apply FileInfo fixes and recalculate UserQuota for affected users.
+
+ This function wraps all database modifications in a single database transaction
+ when dry_run is False. If any unhandled exception occurs, the transaction is
+ rolled back, ensuring database consistency.
+
+ If dry_run is True, no database writes are performed.
+ Returns the list of result rows with 'action', 'quota_recalculated', and 'dry_run' fields added.
+ """
+ if dry_run:
+ logger.info('[DRY RUN] Simulating FileInfo updates (no DB writes)...')
+ return _apply_fixes_internal(rows, dry_run=True)
+ else:
+ logger.info('Applying FileInfo updates (wrapped in database transaction)...')
+ with transaction.atomic():
+ return _apply_fixes_internal(rows, dry_run=False)
+
+
+def _apply_fixes_internal(rows, dry_run=False):
+ """Internal helper to apply fixes, called inside/outside transaction.
+
+ Every row here is already fully fixable (scan_broken_files fails fast otherwise),
+ so each row gets a FileInfo update and contributes to a UserQuota recalculation.
+ """
+ result_rows = []
+ affected_user_storage = set() # set of (creator_id, storage_type)
+ fixed = 0
+
+ for row in rows:
+ # scan stores the osf_file object on the row; fall back to a lookup just in case.
+ osf_file = row.get('osf_file')
+ if osf_file is None:
+ osf_file = OsfStorageFile.objects.filter(_id=row['osf_file_id']).first()
+ if osf_file is None:
+ # Fail fast: a scanned row must resolve to a file.
+ raise RuntimeError('File {} not found when applying fix.'.format(row['osf_file_id']))
+
+ fileinfo_id = ''
+ fileinfo__id = ''
+ fileinfo_created = ''
+ if not dry_run:
+ fileinfo_obj, created = FileInfo.objects.update_or_create(
+ file=osf_file,
+ defaults={'file_size': row['file_size_bytes']},
+ )
+ fileinfo_id = fileinfo_obj.id # integer PK of FileInfo
+ fileinfo__id = fileinfo_obj._id # hex _id string of FileInfo
+ fileinfo_created = created # True = INSERT, False = UPDATE
+
+ creator_id = row['creator_id']
+ storage_type = row['storage_type']
+ affected_user_storage.add((creator_id, storage_type))
+
+ action = 'would_update' if dry_run else ('created' if fileinfo_created else 'updated')
+
+ logger.info('{} {} (osf_file_id={}, fileinfo_id={}, fileinfo__id={}, fileinfo_file_id={}, created={}) file_size={} bytes storage_type={}'.format(
+ '[DRY RUN]' if dry_run else 'Fixed:',
+ row['file_name'], row['osf_file_id'],
+ fileinfo_id, fileinfo__id, row['fileinfo_file_id'],
+ fileinfo_created, row['file_size_bytes'], storage_type,
+ ))
+
+ result_rows.append({
+ 'osf_file_id': row['osf_file_id'],
+ 'fileinfo_file_id': row['fileinfo_file_id'],
+ 'file_name': row['file_name'],
+ 'file_size_bytes': row['file_size_bytes'],
+ 'project_guid': row['project_guid'],
+ 'creator_id': row['creator_id'],
+ 'storage_type': row['storage_type'],
+ 'fileinfo_id': fileinfo_id,
+ 'fileinfo__id': fileinfo__id,
+ 'fileinfo_created': fileinfo_created,
+ 'action': action,
+ 'quota_recalculated': False, # will be updated below after quota recalc
+ 'dry_run': dry_run,
+ })
+ fixed += 1
+
+ logger.info('FileInfo backfill complete. fixed={} (dry={})'.format(fixed, dry_run))
+
+ # --- Recalculate UserQuota.used for all affected (user, storage_type) pairs ---
+ logger.info('Recalculating UserQuota.used for {} unique (user, storage_type) pair(s).'.format(
+ len(affected_user_storage)
+ ))
+
+ quota_updated = 0
+ recalculated_user_storage = set()
+
+ for user_id, storage_type in sorted(affected_user_storage):
+ try:
+ user = OSFUser.load(user_id)
+ if user is None:
+ # Fail fast: a creator_id resolved during scan must load here.
+ raise RuntimeError(
+ 'User {} (storage_type={}) could not be loaded for quota recalc.'.format(
+ user_id, storage_type
+ )
+ )
+
+ if not dry_run:
+ update_user_used_quota(user, storage_type=storage_type, is_recalculating_quota=True)
+
+ logger.info('{} Recalculated quota for user {} (storage_type={})'.format(
+ '[DRY RUN]' if dry_run else '', user_id, storage_type
+ ))
+ quota_updated += 1
+ recalculated_user_storage.add((user_id, storage_type))
+ except Exception as e:
+ # Fail fast: re-raise so transaction.atomic() rolls back ALL FileInfo updates.
+ logger.error('Failed to recalculate quota for user {} (storage_type={}): {} — '
+ 'rolling back transaction (fail fast).'.format(user_id, storage_type, e))
+ raise
+
+ logger.info('Quota recalculation complete. updated={} (dry={})'.format(quota_updated, dry_run))
+
+ # Update quota_recalculated flag in result rows
+ for row in result_rows:
+ if dry_run:
+ row['quota_recalculated'] = 'would_recalculate'
+ elif (row['creator_id'], row['storage_type']) in recalculated_user_storage:
+ row['quota_recalculated'] = True
+
+ return result_rows
+
+
+# --- CSV Export ---
+
+def save_result_csv(result_rows, output_path):
+ """Save full results to CSV file."""
+ with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
+ writer = csv.DictWriter(f, fieldnames=RESULT_FIELDNAMES)
+ writer.writeheader()
+ writer.writerows(result_rows)
+ total = len(result_rows)
+ print('\n Exported {} result(s) to: {}'.format(total, output_path))
+
+
+# --- Main ---
+
+def main():
+ ts = datetime.now().strftime('%Y%m%d_%H%M%S')
+ default_output = f'migrate_fix_fileinfo_quota_{ts}.csv'
+
+ parser = argparse.ArgumentParser(
+ description='Fix missing/zero-size FileInfo records and recalculate UserQuota.',
+ )
+ parser.add_argument(
+ '--output', '-o',
+ default=default_output,
+ help=f'Output CSV file path (default: {default_output})',
+ )
+ parser.add_argument(
+ '--dry', action='store_true', default=False,
+ help='Run in dry-run mode (do not commit updates to the database).',
+ )
+
+ args = parser.parse_args()
+
+ if not args.dry:
+ script_utils.add_file_logger(logger, __file__)
+
+ # Configure console logging format
+ logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s %(levelname)s %(message)s',
+ )
+
+ # 1. Scan for broken records
+ rows = scan_broken_files()
+
+ if not rows:
+ print('No broken FileInfo records found.')
+ return
+
+ # 2. Print summary table
+ print_results(rows)
+
+ # 3. Apply fixes (or simulate in dry-run mode). scan_broken_files() has already
+ # failed fast on any unfixable record, so every row here is fully fixable.
+ result_rows = apply_fixes(rows, dry_run=args.dry)
+
+ # 4. Export results to CSV
+ save_result_csv(result_rows, args.output)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tests/test_addons.py b/tests/test_addons.py
index e6f18031b56..2254a03fa75 100644
--- a/tests/test_addons.py
+++ b/tests/test_addons.py
@@ -25,7 +25,7 @@
DraftRegistrationFactory,
ExportDataLocationFactory,
)
-from website import settings
+from website import settings, mails
from api.base import settings as api_settings
from addons.base import views
from addons.github.exceptions import ApiError
@@ -113,12 +113,33 @@ def test_auth_download(self):
data = jwt.decode(jwe.decrypt(res.json['payload'].encode('utf-8'), self.JWE_KEY), settings.WATERBUTLER_JWT_SECRET, algorithm=settings.WATERBUTLER_JWT_ALGORITHM)['data']
assert_equal(data['auth'], views.make_auth(self.user))
assert_equal(data['credentials'], self.node_addon.serialize_waterbutler_credentials())
- assert_equal(data['settings'], self.node_addon.serialize_waterbutler_settings())
+ expected_settings = self.node_addon.serialize_waterbutler_settings().copy()
+ expected_settings['max_file_size'] = 100
+ assert_equal(data['settings'], expected_settings)
expected_url = furl.furl(self.node.api_url_for('create_waterbutler_log', _absolute=True, _internal=True))
observed_url = furl.furl(data['callback_url'])
observed_url.port = expected_url.port
assert_equal(expected_url, observed_url)
+ def test_auth_max_file_size_honors_high_upload_limit_tag(self):
+ """A user with the `high_upload_limit` system tag gets the high max_file_size
+ sent to WaterButler."""
+ self.user.add_system_tag('high_upload_limit')
+ self.user.save()
+ url = self.build_url()
+ with mock.patch.object(self.node_addon.config, 'high_max_file_size', 500, create=True):
+ res = self.app.get(url, auth=self.user.auth)
+ data = jwt.decode(jwe.decrypt(res.json['payload'].encode('utf-8'), self.JWE_KEY), settings.WATERBUTLER_JWT_SECRET, algorithm=settings.WATERBUTLER_JWT_ALGORITHM)['data']
+ assert_equal(data['settings']['max_file_size'], 500)
+
+ def test_auth_max_file_size_without_high_upload_limit_tag(self):
+ """An untagged user keeps the addon's ordinary limit even when a high limit exists."""
+ url = self.build_url()
+ with mock.patch.object(self.node_addon.config, 'high_max_file_size', 500, create=True):
+ res = self.app.get(url, auth=self.user.auth)
+ data = jwt.decode(jwe.decrypt(res.json['payload'].encode('utf-8'), self.JWE_KEY), settings.WATERBUTLER_JWT_SECRET, algorithm=settings.WATERBUTLER_JWT_ALGORITHM)['data']
+ assert_equal(data['settings']['max_file_size'], 100)
+
def test_auth_render_action_returns_200(self):
url = self.build_url(action='render')
res = self.app.get(url, auth=self.user.auth)
@@ -153,7 +174,9 @@ def test_auth_bad_cookie(self):
data = jwt.decode(jwe.decrypt(res.json['payload'].encode('utf-8'), self.JWE_KEY), settings.WATERBUTLER_JWT_SECRET, algorithm=settings.WATERBUTLER_JWT_ALGORITHM)['data']
assert_equal(data['auth'], views.make_auth(self.user))
assert_equal(data['credentials'], self.node_addon.serialize_waterbutler_credentials())
- assert_equal(data['settings'], self.node_addon.serialize_waterbutler_settings())
+ expected_settings = self.node_addon.serialize_waterbutler_settings().copy()
+ expected_settings['max_file_size'] = 100
+ assert_equal(data['settings'], expected_settings)
expected_url = furl.furl(self.node.api_url_for('create_waterbutler_log', _absolute=True, _internal=True))
observed_url = furl.furl(data['callback_url'])
observed_url.port = expected_url.port
@@ -1019,6 +1042,99 @@ def test_add_folder_osfstorage_log(self, mock_basefilenode):
assert_equal(self.node.logs.count(), nlogs + 1)
assert('urls' not in self.node.logs.filter(action='osf_storage_file_added')[0].params)
+ @mock.patch('website.mails.send_mail')
+ def test_create_waterbutler_log_quota_exceeded_error(self, mock_send_mail):
+ url = self.node.api_url_for('create_waterbutler_log')
+ payload = self.build_payload(
+ action='move',
+ metadata={'path': 'pizza'},
+ source={
+ 'provider': 'github',
+ 'materialized': '/src.txt',
+ 'path': '/src.txt',
+ 'name': 'src.txt',
+ 'nid': self.node._id,
+ },
+ destination={
+ 'provider': 'github',
+ 'materialized': '/dest.txt',
+ 'path': '/dest.txt',
+ 'name': 'dest.txt',
+ 'nid': self.node._id,
+ },
+ errors=['']
+ )
+ res = self.app.put_json(url, payload, headers={'Content-Type': 'application/json'})
+ assert_equal(res.status_code, 200)
+ assert_true(mock_send_mail.called)
+ call_args = mock_send_mail.call_args[1]
+ assert_equal(call_args['error_info'], {'type': 'quota_exceeded'})
+ assert_equal(mock_send_mail.call_args[0][1], mails.FILE_OPERATION_FAILED)
+
+ @mock.patch('website.mails.send_mail')
+ def test_create_waterbutler_log_oversized_error(self, mock_send_mail):
+ url = self.node.api_url_for('create_waterbutler_log')
+ payload = self.build_payload(
+ action='move',
+ metadata={'path': 'pizza'},
+ source={
+ 'provider': 'github',
+ 'materialized': '/src.txt',
+ 'path': '/src.txt',
+ 'name': 'src.txt',
+ 'nid': self.node._id,
+ },
+ destination={
+ 'provider': 'github',
+ 'materialized': '/dest.txt',
+ 'path': '/dest.txt',
+ 'name': 'dest.txt',
+ 'nid': self.node._id,
+ },
+ errors=['']
+ )
+ res = self.app.put_json(url, payload, headers={'Content-Type': 'application/json'})
+ assert_equal(res.status_code, 200)
+ assert_true(mock_send_mail.called)
+ call_args = mock_send_mail.call_args[1]
+ assert_equal(call_args['error_info'], {
+ 'type': 'oversized',
+ 'oversized_files': [{'name': 'large.zip', 'size': 2000000000}],
+ 'max_size': 1000000000
+ })
+ assert_equal(mock_send_mail.call_args[0][1], mails.FILE_OPERATION_FAILED)
+
+ @mock.patch('addons.base.views.logger.warning')
+ @mock.patch('website.mails.send_mail')
+ def test_create_waterbutler_log_unparseable_error(self, mock_send_mail, mock_logger_warning):
+ url = self.node.api_url_for('create_waterbutler_log')
+ payload = self.build_payload(
+ action='move',
+ metadata={'path': 'pizza'},
+ source={
+ 'provider': 'github',
+ 'materialized': '/src.txt',
+ 'path': '/src.txt',
+ 'name': 'src.txt',
+ 'nid': self.node._id,
+ },
+ destination={
+ 'provider': 'github',
+ 'materialized': '/dest.txt',
+ 'path': '/dest.txt',
+ 'name': 'dest.txt',
+ 'nid': self.node._id,
+ },
+ errors=['']
+ )
+ res = self.app.put_json(url, payload, headers={'Content-Type': 'application/json'})
+ assert_equal(res.status_code, 200)
+ assert_true(mock_send_mail.called)
+ assert_true(mock_logger_warning.called)
+ call_args = mock_send_mail.call_args[1]
+ assert_equal(call_args['error_info'], None)
+ assert_equal(mock_send_mail.call_args[0][1], mails.FILE_OPERATION_FAILED)
+
class TestAddonLogsDifferentProvider(OsfTestCase):
def setUp(self):
diff --git a/tests/test_institution_default_max_quota.py b/tests/test_institution_default_max_quota.py
new file mode 100644
index 00000000000..af02b5a0d63
--- /dev/null
+++ b/tests/test_institution_default_max_quota.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+from nose import tools as nt
+
+from osf.models import InstitutionDefaultMaxQuota
+from osf_tests.factories import AuthUserFactory, InstitutionFactory
+from tests.base import OsfTestCase
+
+
+class TestInstitutionDefaultMaxQuota(OsfTestCase):
+
+ def test_get_quota_by_user_has_institution(self):
+ user = AuthUserFactory()
+ institution = InstitutionFactory()
+
+ user.affiliated_institutions.add(institution)
+
+ InstitutionDefaultMaxQuota.objects.create(
+ institution=institution,
+ default_max_quota=500
+ )
+
+ result = InstitutionDefaultMaxQuota.get_quota_by_user(user.id)
+
+ nt.assert_equal(result, 500)
+
+ def test_get_quota_by_user_no_institution(self):
+ user = AuthUserFactory()
+
+ result = InstitutionDefaultMaxQuota.get_quota_by_user(user.id)
+
+ nt.assert_equal(result, None)
+
+ def test_get_quota_by_user_multiple_institutions(self):
+ user = AuthUserFactory()
+
+ inst1 = InstitutionFactory()
+ inst2 = InstitutionFactory()
+
+ user.affiliated_institutions.add(inst1)
+ user.affiliated_institutions.add(inst2)
+
+ InstitutionDefaultMaxQuota.objects.create(
+ institution=inst1,
+ default_max_quota=100
+ )
+
+ InstitutionDefaultMaxQuota.objects.create(
+ institution=inst2,
+ default_max_quota=200
+ )
+
+ result = InstitutionDefaultMaxQuota.get_quota_by_user(user.id)
+
+ nt.assert_equal(result, 100)
diff --git a/tests/test_quota.py b/tests/test_quota.py
index 2fc183213d0..d5b1efc339a 100644
--- a/tests/test_quota.py
+++ b/tests/test_quota.py
@@ -4,7 +4,7 @@
from nose.tools import * # noqa (PEP8 asserts)
import pytest
-from addons.osfstorage.models import OsfStorageFileNode
+from addons.osfstorage.models import OsfStorageFileNode, Region
from api.base import settings as api_settings
from framework.auth import signing
from tests.base import OsfTestCase
@@ -16,6 +16,7 @@
)
from osf.utils.requests import check_select_for_update
from website.util import web_url_for, quota
+from django.db import IntegrityError
@pytest.mark.enable_implicit_clean
@@ -268,7 +269,7 @@ def setUp(self):
)
self.file.save()
- def test_add_file_info(self):
+ def test_file_info_skipped_for_osfstorage_signal(self):
file_info_query = FileInfo.objects.filter(file=self.file)
assert_false(file_info_query.exists())
@@ -294,9 +295,8 @@ def test_add_file_info(self):
)
file_info_list = FileInfo.objects.filter(file=self.file).all()
- assert_equal(file_info_list.count(), 1)
- file_info = file_info_list.first()
- assert_equal(file_info.file_size, 1000)
+ # FileInfo was not created
+ assert_equal(file_info_list.count(), 0)
def test_update_file_info(self):
file_info = FileInfo(file=self.file, file_size=1000)
@@ -386,7 +386,7 @@ def setUp(self):
target_content_type_id=2
)
- def test_add_first_file(self):
+ def test_first_file_userquota_not_created_for_osfstorage_signal(self):
assert_false(UserQuota.objects.filter(user=self.project_creator).exists())
quota.update_used_quota(
@@ -414,9 +414,7 @@ def test_add_first_file(self):
storage_type=UserQuota.NII_STORAGE,
user=self.project_creator
).all()
- assert_equal(len(user_quota), 1)
- user_quota = user_quota[0]
- assert_equal(user_quota.used, 1000)
+ assert_equal(len(user_quota), 0)
def test_add_first_file_custom_storage(self):
assert_false(UserQuota.objects.filter(user=self.project_creator).exists())
@@ -453,11 +451,9 @@ def test_add_first_file_custom_storage(self):
storage_type=UserQuota.CUSTOM_STORAGE,
user=self.project_creator
).all()
- assert_equal(len(user_quota), 1)
- user_quota = user_quota[0]
- assert_equal(user_quota.used, 1200)
+ assert_equal(len(user_quota), 0)
- def test_add_file(self):
+ def test_userquota_unchanged_for_osfstorage_signal(self):
UserQuota.objects.create(
user=self.project_creator,
storage_type=UserQuota.NII_STORAGE,
@@ -492,7 +488,7 @@ def test_add_file(self):
).all()
assert_equal(len(user_quota), 1)
user_quota = user_quota[0]
- assert_equal(user_quota.used, 6500)
+ assert_equal(user_quota.used, 5500)
def test_add_file_custom_storage(self):
UserQuota.objects.create(
@@ -536,7 +532,7 @@ def test_add_file_custom_storage(self):
).all()
assert_equal(len(user_quota), 1)
user_quota = user_quota[0]
- assert_equal(user_quota.used, 6700)
+ assert_equal(user_quota.used, 5500)
def test_add_file_negative_size(self):
quota.update_used_quota(
@@ -545,9 +541,9 @@ def test_add_file_negative_size(self):
user=self.user,
event_type=FileLog.FILE_ADDED,
payload={
- 'provider': 'osfstorage',
+ 'provider': 's3compatinstitutions',
'metadata': {
- 'provider': 'osfstorage',
+ 'provider': 's3compatinstitutions',
'name': 'testfile',
'materialized': '/filename',
'path': self.file._id,
@@ -599,6 +595,51 @@ def test_delete_file(self):
)
assert_equal(user_quota.used, 4500)
+ @mock.patch('website.util.quota.check_select_for_update')
+ def test_delete_file_without_select_for_update(self, mock_check_select_for_update):
+ # Forces node_removed() into the `else` branch (FileInfo.objects.get
+ # without select_for_update), instead of relying on whether the test
+ # happens to run inside an atomic transaction.
+ mock_check_select_for_update.return_value = False
+
+ UserQuota.objects.create(
+ user=self.project_creator,
+ storage_type=UserQuota.NII_STORAGE,
+ max_quota=api_settings.DEFAULT_MAX_QUOTA,
+ used=5500
+ )
+ FileInfo.objects.create(file=self.file, file_size=1000)
+
+ self.file.deleted_on = datetime.datetime.now()
+ self.file.deleted_by = self.user
+ self.file.type = 'osf.trashedfile'
+ self.file.save()
+
+ quota.update_used_quota(
+ self=None,
+ target=self.node,
+ user=self.user,
+ event_type=FileLog.FILE_REMOVED,
+ payload={
+ 'provider': 'osfstorage',
+ 'metadata': {
+ 'provider': 'osfstorage',
+ 'name': 'testfile',
+ 'materialized': '/filename',
+ 'path': self.file._id,
+ 'kind': 'file',
+ 'extra': {}
+ }
+ }
+ )
+
+ mock_check_select_for_update.assert_called()
+ user_quota = UserQuota.objects.get(
+ storage_type=UserQuota.NII_STORAGE,
+ user=self.project_creator
+ )
+ assert_equal(user_quota.used, 4500)
+
def test_delete_file_custom_storage(self):
UserQuota.objects.create(
user=self.project_creator,
@@ -987,9 +1028,9 @@ def test_edit_file_negative_size(self):
user=self.user,
event_type=FileLog.FILE_UPDATED,
payload={
- 'provider': 'osfstorage',
+ 'provider': 's3compatinstitutions',
'metadata': {
- 'provider': 'osfstorage',
+ 'provider': 's3compatinstitutions',
'name': 'testfile',
'materialized': '/filename',
'path': self.file._id,
@@ -1358,7 +1399,7 @@ def test_delete_file_with_Amazon_S3_Compatible_Storage_for_Institution(self):
if check_select_for_update():
mock_file_info.objects.filter.return_value.select_for_update.return_value.get.return_value = FileInfo(
file=self.base_file_node, file_size=1000)
- mock_user_quota.objects.filter.return_value.select_for_update.return_value.first.return_value = UserQuota(
+ mock_user_quota.objects.filter.return_value.select_for_update.return_value.get.return_value = UserQuota(
user=self.project_creator,
storage_type=UserQuota.CUSTOM_STORAGE,
max_quota=api_settings.DEFAULT_MAX_QUOTA,
@@ -1366,12 +1407,14 @@ def test_delete_file_with_Amazon_S3_Compatible_Storage_for_Institution(self):
)
else:
mock_file_info.objects.get.return_value = FileInfo(file=self.base_file_node, file_size=1000)
- mock_user_quota.objects.filter.return_value.first.return_value = UserQuota(
+ _real_user_quota = UserQuota(
user=self.project_creator,
storage_type=UserQuota.CUSTOM_STORAGE,
max_quota=api_settings.DEFAULT_MAX_QUOTA,
used=5500
)
+ mock_user_quota.objects.get.return_value = _real_user_quota
+ mock_user_quota.objects.filter.return_value.first.return_value = _real_user_quota
with mock.patch('website.util.quota.BaseFileNode', mock_base_file_node):
with mock.patch('website.util.quota.FileInfo', mock_file_info):
with mock.patch('website.util.quota.UserQuota', mock_user_quota):
@@ -1414,7 +1457,7 @@ def test_delete_folder_with_Amazon_S3_Compatible_Storage_for_Institution(self):
if check_select_for_update():
mock_file_info.objects.filter.return_value.select_for_update.return_value.get.return_value = FileInfo(
file=self.base_file_node, file_size=1500)
- mock_user_quota.objects.filter.return_value.select_for_update.return_value.first.return_value = UserQuota(
+ mock_user_quota.objects.filter.return_value.select_for_update.return_value.get.return_value = UserQuota(
user=self.project_creator,
storage_type=UserQuota.CUSTOM_STORAGE,
max_quota=api_settings.DEFAULT_MAX_QUOTA,
@@ -1422,12 +1465,14 @@ def test_delete_folder_with_Amazon_S3_Compatible_Storage_for_Institution(self):
)
else:
mock_file_info.objects.get.return_value = FileInfo(file=self.base_file_node, file_size=1500)
- mock_user_quota.objects.filter.return_value.first.return_value = UserQuota(
+ _real_user_quota = UserQuota(
user=self.project_creator,
storage_type=UserQuota.CUSTOM_STORAGE,
max_quota=api_settings.DEFAULT_MAX_QUOTA,
used=5500
)
+ mock_user_quota.objects.get.return_value = _real_user_quota
+ mock_user_quota.objects.filter.return_value.first.return_value = _real_user_quota
with mock.patch('website.util.quota.BaseFileNode', mock_base_file_node):
with mock.patch('website.util.quota.FileInfo', mock_file_info):
with mock.patch('website.util.quota.UserQuota', mock_user_quota):
@@ -1532,6 +1577,36 @@ def test_update_user_used_quota_method_for_recalculate_quota_process__nii_custom
user_quota = user_quota.first()
assert_equal(user_quota.used, 1000)
+ @mock.patch('website.util.quota.check_select_for_update', return_value=False)
+ @mock.patch('website.util.quota.used_quota')
+ def test_update_user_used_quota_without_select_for_update_existing(self, mock_used, _mock_check):
+ """update_user_used_quota should work without select_for_update when UserQuota exists"""
+ mock_used.return_value = 750
+
+ quota.update_user_used_quota(
+ user=self.user,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ user_quota = UserQuota.objects.get(user=self.user, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(user_quota.used, 750)
+
+ @mock.patch('website.util.quota.check_select_for_update', return_value=False)
+ @mock.patch('website.util.quota.used_quota')
+ def test_update_user_used_quota_without_select_for_update_create(self, mock_used, _mock_check):
+ """update_user_used_quota should work without select_for_update when creating new UserQuota"""
+ another_user = UserFactory()
+ mock_used.return_value = 600
+
+ quota.update_user_used_quota(
+ user=another_user,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ user_quota = UserQuota.objects.get(user=another_user, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(user_quota.used, 600)
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+
class TestQuotaApiWaterbutler(OsfTestCase):
def setUp(self):
@@ -1551,6 +1626,20 @@ def test_default_values(self):
assert_equal(response.json['max'], api_settings.DEFAULT_MAX_QUOTA * api_settings.SIZE_UNIT_GB)
assert_equal(response.json['used'], 0)
+ def test_user_guid_and_storage_type_present_and_consistent_with_creator(self):
+ response = self.app.get(
+ '{}?payload={payload}&signature={signature}'.format(
+ self.node.api_url_for('waterbutler_creator_quota'),
+ **signing.sign_data(signing.default_signer, {})
+ )
+ )
+ assert_equal(response.status_code, 200)
+ assert_in('user_guid', response.json)
+ assert_in('storage_type', response.json)
+ from website.util import quota as quota_util
+ assert_equal(response.json['user_guid'], self.node.creator._id)
+ assert_equal(response.json['storage_type'], quota_util.get_project_storage_type(self.node))
+
def test_used_half_custom_quota(self):
UserQuota.objects.create(
storage_type=UserQuota.NII_STORAGE,
@@ -1617,6 +1706,15 @@ def test_default_values(self):
assert_equal(response.json['max'], api_settings.DEFAULT_MAX_QUOTA * api_settings.SIZE_UNIT_GB)
assert_equal(response.json['used'], 0)
+ def test_user_guid_and_storage_type_present_and_consistent_with_creator(self):
+ response = self.app.get(
+ self.node.api_url_for('get_creator_quota'),
+ auth=self.user.auth
+ )
+ assert_equal(response.status_code, 200)
+ assert_in('user_guid', response.json)
+ assert_in('storage_type', response.json)
+
def test_used_half_custom_quota(self):
UserQuota.objects.create(
storage_type=UserQuota.NII_STORAGE,
@@ -1661,3 +1759,755 @@ def test_used_half_custom_institution_quota(self):
assert_equal(response.status_code, 200)
assert_equal(response.json['max'], 200 * api_settings.SIZE_UNIT_GB)
assert_equal(response.json['used'], 100 * api_settings.SIZE_UNIT_GB)
+
+
+@pytest.mark.enable_implicit_clean
+class TestGetQuotaInfoWithInstitutionDefaultMaxQuota(OsfTestCase):
+ """Test get_quota_info() with InstitutionDefaultMaxQuota logic"""
+
+ def setUp(self):
+ super(TestGetQuotaInfoWithInstitutionDefaultMaxQuota, self).setUp()
+ self.user = AuthUserFactory()
+ self.institution = InstitutionFactory(name='Test Institution')
+ self.user.affiliated_institutions.add(self.institution)
+
+ @mock.patch('website.util.quota.used_quota')
+ def test_get_quota_info_nii_storage_uses_default(self, mock_used_quota):
+ """NII_STORAGE should always use DEFAULT_MAX_QUOTA, ignoring InstitutionDefaultMaxQuota"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+ mock_used_quota.return_value = 50
+
+ # Set institution quota
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution.id,
+ defaults={'default_max_quota': 500}
+ )
+
+ # NII_STORAGE should ignore institution quota
+ max_quota, used = quota.get_quota_info(self.user, UserQuota.NII_STORAGE)
+
+ assert_equal(max_quota, api_settings.DEFAULT_MAX_QUOTA)
+ assert_equal(used, 50)
+
+ @mock.patch('website.util.quota.used_quota')
+ def test_get_quota_info_custom_storage_with_institution_quota(self, mock_used_quota):
+ """CUSTOM_STORAGE should use InstitutionDefaultMaxQuota when available"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+ mock_used_quota.return_value = 100
+
+ # Set institution quota
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution.id,
+ defaults={'default_max_quota': 350}
+ )
+
+ # CUSTOM_STORAGE should use institution quota
+ max_quota, used = quota.get_quota_info(self.user, UserQuota.CUSTOM_STORAGE)
+
+ assert_equal(max_quota, 350)
+ assert_equal(used, 100)
+
+ @mock.patch('website.util.quota.used_quota')
+ def test_get_quota_info_custom_storage_fallback_to_default(self, mock_used_quota):
+ """CUSTOM_STORAGE should fallback to DEFAULT_MAX_QUOTA when no InstitutionDefaultMaxQuota"""
+ mock_used_quota.return_value = 75
+
+ # No InstitutionDefaultMaxQuota set
+ max_quota, used = quota.get_quota_info(self.user, UserQuota.CUSTOM_STORAGE)
+
+ assert_equal(max_quota, api_settings.DEFAULT_MAX_QUOTA)
+ assert_equal(used, 75)
+
+ @mock.patch('website.util.quota.used_quota')
+ def test_get_quota_info_with_existing_userquota(self, mock_used_quota):
+ """get_quota_info should return existing UserQuota, not use InstitutionDefaultMaxQuota"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+ mock_used_quota.return_value = 0
+
+ # Create UserQuota with custom max_quota
+ UserQuota.objects.create(
+ user=self.user,
+ storage_type=UserQuota.CUSTOM_STORAGE,
+ max_quota=600,
+ used=200
+ )
+
+ # Set institution quota (should be ignored)
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution.id,
+ defaults={'default_max_quota': 350}
+ )
+
+ # Should return existing UserQuota, not institution quota
+ max_quota, used = quota.get_quota_info(self.user, UserQuota.CUSTOM_STORAGE)
+
+ assert_equal(max_quota, 600)
+ assert_equal(used, 200)
+
+
+@pytest.mark.enable_implicit_clean
+class TestFileAddedWithInstitutionDefaultMaxQuota(OsfTestCase):
+ """Test file_added() with InstitutionDefaultMaxQuota logic"""
+
+ def setUp(self):
+ super(TestFileAddedWithInstitutionDefaultMaxQuota, self).setUp()
+ self.user = AuthUserFactory()
+ self.institution = InstitutionFactory(name='Test Institution')
+ self.region = RegionFactory(_id=self.institution._id)
+ self.region.waterbutler_settings['storage']['type'] = Region.INSTITUTIONS
+ self.region.save()
+ self.user.affiliated_institutions.add(self.institution)
+ self.project = ProjectFactory(creator=self.user, is_deleted=False)
+ self.file_node = mock.MagicMock()
+
+ def tearDown(self):
+ super(TestFileAddedWithInstitutionDefaultMaxQuota, self).tearDown()
+ if hasattr(self, 'project') and self.project:
+ self.project.delete()
+
+ @mock.patch('osf.models.FileInfo.objects.create')
+ @mock.patch('website.util.quota.used_quota')
+ def test_file_added_custom_storage_with_institution_quota(self, mock_used_quota, mock_create):
+ """file_added should create UserQuota with InstitutionDefaultMaxQuota value"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+ mock_used_quota.return_value = 0
+
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution.id,
+ defaults={'default_max_quota': 400}
+ )
+
+ payload = {'metadata': {'size': '100'}}
+ quota.file_added(self.project, payload, self.file_node, UserQuota.CUSTOM_STORAGE)
+
+ user_quota = UserQuota.objects.get(
+ user=self.project.creator,
+ storage_type=UserQuota.CUSTOM_STORAGE
+ )
+ assert_equal(user_quota.max_quota, 400)
+ assert_equal(user_quota.used, 100)
+
+ @mock.patch('osf.models.FileInfo.objects.create')
+ @mock.patch('website.util.quota.used_quota')
+ def test_file_added_custom_storage_without_institution_quota(self, mock_used_quota, mock_create):
+ """file_added should fallback to DEFAULT_MAX_QUOTA when no InstitutionDefaultMaxQuota"""
+ mock_used_quota.return_value = 0
+
+ # No InstitutionDefaultMaxQuota
+ payload = {'metadata': {'size': '150'}}
+ quota.file_added(self.project, payload, self.file_node, UserQuota.CUSTOM_STORAGE)
+
+ user_quota = UserQuota.objects.get(
+ user=self.project.creator,
+ storage_type=UserQuota.CUSTOM_STORAGE
+ )
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+ assert_equal(user_quota.used, 150)
+
+ @mock.patch('osf.models.FileInfo.objects.create')
+ @mock.patch('website.util.quota.used_quota')
+ def test_file_added_nii_storage_ignores_institution_quota(self, mock_used_quota, mock_create):
+ """file_added should use DEFAULT_MAX_QUOTA for NII_STORAGE, ignoring InstitutionDefaultMaxQuota"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+ mock_used_quota.return_value = 0
+
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution.id,
+ defaults={'default_max_quota': 400}
+ )
+
+ payload = {'metadata': {'size': '75'}}
+ quota.file_added(self.project, payload, self.file_node, UserQuota.NII_STORAGE)
+
+ user_quota = UserQuota.objects.get(
+ user=self.project.creator,
+ storage_type=UserQuota.NII_STORAGE
+ )
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+ assert_equal(user_quota.used, 75)
+
+
+@pytest.mark.enable_implicit_clean
+class TestUpdateUserUsedQuotaWithInstitutionDefaultMaxQuota(OsfTestCase):
+ """Test update_user_used_quota() with InstitutionDefaultMaxQuota logic"""
+
+ def setUp(self):
+ super(TestUpdateUserUsedQuotaWithInstitutionDefaultMaxQuota, self).setUp()
+ self.user = UserFactory()
+ self.user.save()
+ self.institution = InstitutionFactory(name='Test Institution')
+ self.user.affiliated_institutions.add(self.institution)
+
+ @mock.patch('website.util.quota.used_quota')
+ def test_update_user_used_quota_create_custom_storage_with_institution_quota(self, mock_used_quota):
+ """update_user_used_quota should create UserQuota with InstitutionDefaultMaxQuota value"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+ mock_used_quota.return_value = 300
+
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution.id,
+ defaults={'default_max_quota': 500}
+ )
+
+ quota.update_user_used_quota(self.user, UserQuota.CUSTOM_STORAGE)
+
+ user_quota = UserQuota.objects.get(
+ user=self.user,
+ storage_type=UserQuota.CUSTOM_STORAGE
+ )
+ assert_equal(user_quota.max_quota, 500)
+ assert_equal(user_quota.used, 300)
+
+ @mock.patch('website.util.quota.used_quota')
+ def test_update_user_used_quota_create_custom_storage_without_institution_quota(self, mock_used_quota):
+ """update_user_used_quota should fallback to DEFAULT_MAX_QUOTA when no InstitutionDefaultMaxQuota"""
+ mock_used_quota.return_value = 250
+
+ # No InstitutionDefaultMaxQuota
+ quota.update_user_used_quota(self.user, UserQuota.CUSTOM_STORAGE)
+
+ user_quota = UserQuota.objects.get(
+ user=self.user,
+ storage_type=UserQuota.CUSTOM_STORAGE
+ )
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+ assert_equal(user_quota.used, 250)
+
+ @mock.patch('website.util.quota.used_quota')
+ def test_update_user_used_quota_nii_storage_ignores_institution_quota(self, mock_used_quota):
+ """update_user_used_quota should use DEFAULT_MAX_QUOTA for NII_STORAGE"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+ mock_used_quota.return_value = 200
+
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution.id,
+ defaults={'default_max_quota': 500}
+ )
+
+ quota.update_user_used_quota(self.user, UserQuota.NII_STORAGE)
+
+ user_quota = UserQuota.objects.get(
+ user=self.user,
+ storage_type=UserQuota.NII_STORAGE
+ )
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+ assert_equal(user_quota.used, 200)
+
+
+class TestGetDefaultMaxQuota(OsfTestCase):
+ """Unit tests for get_default_max_quota()"""
+
+ def setUp(self):
+ super(TestGetDefaultMaxQuota, self).setUp()
+ self.user = AuthUserFactory()
+ self.institution = InstitutionFactory(name='Test Institution')
+ self.user.affiliated_institutions.add(self.institution)
+
+ def _make_non_nii_region(self):
+ region = RegionFactory(_id=self.institution._id)
+ region.waterbutler_settings['storage']['type'] = Region.INSTITUTIONS
+ region.save()
+ return region
+
+ def _make_nii_region(self):
+ region = RegionFactory(_id=self.institution._id)
+ region.waterbutler_settings['storage']['type'] = Region.NII_STORAGE
+ region.save()
+ return region
+
+ def test_nii_storage_returns_default(self):
+ """NII_STORAGE always returns DEFAULT_MAX_QUOTA regardless of institution quota"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+ self._make_non_nii_region()
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution.id,
+ defaults={'default_max_quota': 500}
+ )
+
+ result = quota.get_default_max_quota(self.user, UserQuota.NII_STORAGE)
+
+ assert_equal(result, api_settings.DEFAULT_MAX_QUOTA)
+
+ def test_custom_storage_no_institution_returns_default(self):
+ """CUSTOM_STORAGE returns DEFAULT_MAX_QUOTA when user has no affiliated institution"""
+ user_no_inst = AuthUserFactory()
+
+ result = quota.get_default_max_quota(user_no_inst, UserQuota.CUSTOM_STORAGE)
+
+ assert_equal(result, api_settings.DEFAULT_MAX_QUOTA)
+
+ def test_custom_storage_institution_uses_nii_storage_returns_default(self):
+ """CUSTOM_STORAGE returns DEFAULT_MAX_QUOTA when institution's region is NII_STORAGE"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+ self._make_nii_region()
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution.id,
+ defaults={'default_max_quota': 500}
+ )
+
+ result = quota.get_default_max_quota(self.user, UserQuota.CUSTOM_STORAGE)
+
+ assert_equal(result, api_settings.DEFAULT_MAX_QUOTA)
+
+ def test_custom_storage_no_institution_quota_returns_default(self):
+ """CUSTOM_STORAGE returns DEFAULT_MAX_QUOTA when no InstitutionDefaultMaxQuota is set"""
+ self._make_non_nii_region()
+ # No InstitutionDefaultMaxQuota created
+
+ result = quota.get_default_max_quota(self.user, UserQuota.CUSTOM_STORAGE)
+
+ assert_equal(result, api_settings.DEFAULT_MAX_QUOTA)
+
+ def test_custom_storage_with_institution_quota_returns_institution_value(self):
+ """CUSTOM_STORAGE returns institution quota when region is non-NII and quota is set"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+ self._make_non_nii_region()
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=self.institution.id,
+ defaults={'default_max_quota': 350}
+ )
+
+ result = quota.get_default_max_quota(self.user, UserQuota.CUSTOM_STORAGE)
+
+ assert_equal(result, 350)
+
+
+@pytest.mark.enable_implicit_clean
+class TestFileAddedWithoutSelectForUpdate(OsfTestCase):
+ """Test file_added() without select_for_update branch"""
+
+ def setUp(self):
+ super(TestFileAddedWithoutSelectForUpdate, self).setUp()
+ self.user = UserFactory()
+ self.project_creator = UserFactory()
+ self.node = ProjectFactory(creator=self.project_creator)
+ self.file = OsfStorageFileNode.create(
+ target=self.node,
+ path='/testfile',
+ _id='testfile',
+ name='testfile',
+ materialized_path='/testfile'
+ )
+ self.file.save()
+
+ @mock.patch('website.util.quota.check_select_for_update', return_value=False)
+ def test_file_added_without_select_for_update_new_userquota(self, _mock_check):
+ """file_added should work without select_for_update when creating new UserQuota"""
+ assert_false(UserQuota.objects.filter(user=self.project_creator).exists())
+
+ payload = {'metadata': {'size': 1000}}
+ quota.file_added(self.node, payload, self.file, UserQuota.NII_STORAGE)
+
+ user_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(user_quota.used, 1000)
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+
+ @mock.patch('website.util.quota.check_select_for_update', return_value=False)
+ def test_file_added_without_select_for_update_existing_userquota(self, _mock_check):
+ """file_added should work without select_for_update when updating existing UserQuota"""
+ UserQuota.objects.create(
+ user=self.project_creator,
+ storage_type=UserQuota.NII_STORAGE,
+ max_quota=500,
+ used=2000
+ )
+
+ payload = {'metadata': {'size': 500}}
+ quota.file_added(self.node, payload, self.file, UserQuota.NII_STORAGE)
+
+ user_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(user_quota.used, 2500)
+ assert_equal(user_quota.max_quota, 500)
+
+
+@pytest.mark.enable_implicit_clean
+class TestNodeRemovedWithoutSelectForUpdate(OsfTestCase):
+ """Test node_removed() without select_for_update branch"""
+
+ def setUp(self):
+ super(TestNodeRemovedWithoutSelectForUpdate, self).setUp()
+ self.user = UserFactory()
+ self.project_creator = UserFactory()
+ self.node = ProjectFactory(creator=self.project_creator)
+ self.file = OsfStorageFileNode.create(
+ target=self.node,
+ path='/testfile',
+ _id='testfile',
+ name='testfile',
+ materialized_path='/testfile'
+ )
+ self.file.save()
+
+ @mock.patch('website.util.quota.check_select_for_update', return_value=False)
+ def test_node_removed_without_select_for_update(self, _mock_check):
+ """node_removed should work without select_for_update when deleting file"""
+ UserQuota.objects.create(
+ user=self.project_creator,
+ storage_type=UserQuota.NII_STORAGE,
+ max_quota=api_settings.DEFAULT_MAX_QUOTA,
+ used=1500
+ )
+ FileInfo.objects.create(file=self.file, file_size=1000)
+
+ self.file.deleted_on = datetime.datetime.now()
+ self.file.deleted_by = self.user
+ self.file.type = 'osf.trashedfile'
+ self.file.save()
+
+ quota.node_removed(self.node, self.user, {}, self.file, UserQuota.NII_STORAGE)
+
+ user_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(user_quota.used, 500)
+
+
+@pytest.mark.enable_implicit_clean
+class TestUserQuotaGetOrCreate(OsfTestCase):
+ """Test get_or_create logic in file_modified() function"""
+
+ def setUp(self):
+ super(TestUserQuotaGetOrCreate, self).setUp()
+ self.user = UserFactory()
+ self.project_creator = UserFactory()
+ self.node = ProjectFactory(creator=self.project_creator)
+ self.file = OsfStorageFileNode.create(
+ target=self.node,
+ path='/testfile',
+ _id='testfile',
+ name='testfile',
+ materialized_path='/testfile'
+ )
+ self.file.save()
+
+ def test_get_or_create_creates_new_userquota(self):
+ """get_or_create should create new UserQuota if it doesn't exist"""
+ assert_false(UserQuota.objects.filter(user=self.project_creator).exists())
+
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 5000
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ user_quota = UserQuota.objects.filter(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(len(user_quota), 1)
+ user_quota = user_quota[0]
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+ assert_equal(user_quota.used, 5000)
+
+ def test_get_or_create_retrieves_existing_userquota(self):
+ """get_or_create should retrieve existing UserQuota without creating a new one"""
+ UserQuota.objects.create(
+ user=self.project_creator,
+ storage_type=UserQuota.NII_STORAGE,
+ max_quota=200,
+ used=100
+ )
+
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 200
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ user_quota_list = UserQuota.objects.filter(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(len(user_quota_list), 1)
+ user_quota = user_quota_list[0]
+ assert_equal(user_quota.max_quota, 200)
+ assert_equal(user_quota.used, 300)
+
+ def test_get_or_create_with_custom_storage_new_userquota(self):
+ """get_or_create should work correctly with CUSTOM_STORAGE"""
+ institution = InstitutionFactory()
+ self.project_creator.affiliated_institutions.add(institution)
+ RegionFactory(_id=institution._id)
+ ProjectStorageType.objects.filter(node=self.node).update(
+ storage_type=ProjectStorageType.CUSTOM_STORAGE
+ )
+
+ assert_false(UserQuota.objects.filter(user=self.project_creator, storage_type=UserQuota.CUSTOM_STORAGE).exists())
+
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 3000
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.CUSTOM_STORAGE
+ )
+
+ user_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.CUSTOM_STORAGE)
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+ assert_equal(user_quota.used, 3000)
+
+ def test_get_or_create_with_default_max_quota_logic(self):
+ """get_or_create should use get_default_max_quota() for max_quota value"""
+ from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
+
+ institution = InstitutionFactory()
+ self.project_creator.affiliated_institutions.add(institution)
+ RegionFactory(_id=institution._id)
+ ProjectStorageType.objects.filter(node=self.node).update(
+ storage_type=ProjectStorageType.CUSTOM_STORAGE
+ )
+
+ custom_max = 450
+ InstitutionDefaultMaxQuota.objects.update_or_create(
+ institution_id=institution.id,
+ defaults={'default_max_quota': custom_max}
+ )
+
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 2000
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.CUSTOM_STORAGE
+ )
+
+ user_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.CUSTOM_STORAGE)
+ assert_equal(user_quota.max_quota, custom_max)
+ assert_equal(user_quota.used, 2000)
+
+ def test_get_or_create_multiple_storage_types(self):
+ """get_or_create should handle multiple storage types for same user"""
+ institution = InstitutionFactory()
+ self.project_creator.affiliated_institutions.add(institution)
+ RegionFactory(_id=institution._id)
+
+ # Create separate files for each storage type
+ file_nii = OsfStorageFileNode.create(
+ target=self.node,
+ path='/testfile_nii',
+ _id='testfile_nii',
+ name='testfile_nii',
+ materialized_path='/testfile_nii'
+ )
+ file_nii.save()
+
+ file_custom = OsfStorageFileNode.create(
+ target=self.node,
+ path='/testfile_custom',
+ _id='testfile_custom',
+ name='testfile_custom',
+ materialized_path='/testfile_custom'
+ )
+ file_custom.save()
+
+ # Create NII_STORAGE first
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 1000
+ }
+ },
+ file_node=file_nii,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ # Create CUSTOM_STORAGE
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 2000
+ }
+ },
+ file_node=file_custom,
+ storage_type=UserQuota.CUSTOM_STORAGE
+ )
+
+ nii_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ custom_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.CUSTOM_STORAGE)
+
+ assert_equal(nii_quota.used, 1000)
+ assert_equal(custom_quota.used, 2000)
+
+ def test_get_or_create_preserves_existing_max_quota(self):
+ """get_or_create should not overwrite existing max_quota when record exists"""
+ custom_max = 300
+ UserQuota.objects.create(
+ user=self.project_creator,
+ storage_type=UserQuota.NII_STORAGE,
+ max_quota=custom_max,
+ used=0
+ )
+
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 1500
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ user_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(user_quota.max_quota, custom_max)
+ assert_equal(user_quota.used, 1500)
+
+ @mock.patch('website.util.quota.check_select_for_update', return_value=False)
+ def test_get_or_create_without_select_for_update_new_userquota(self, _mock_check_select):
+ """get_or_create without select_for_update should create new UserQuota"""
+ assert_false(UserQuota.objects.filter(user=self.project_creator).exists())
+
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 5000
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ user_quota = UserQuota.objects.filter(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(len(user_quota), 1)
+ user_quota = user_quota[0]
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+ assert_equal(user_quota.used, 5000)
+
+ @mock.patch('website.util.quota.check_select_for_update', return_value=False)
+ def test_get_or_create_without_select_for_update_existing_userquota(self, _mock_check_select):
+ """get_or_create without select_for_update should retrieve existing UserQuota"""
+ UserQuota.objects.create(
+ user=self.project_creator,
+ storage_type=UserQuota.NII_STORAGE,
+ max_quota=200,
+ used=100
+ )
+
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 200
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ user_quota_list = UserQuota.objects.filter(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(len(user_quota_list), 1)
+ user_quota = user_quota_list[0]
+ assert_equal(user_quota.max_quota, 200)
+ assert_equal(user_quota.used, 300)
+
+ @mock.patch('website.util.quota.check_select_for_update', return_value=False)
+ def test_get_or_create_without_select_for_update_custom_storage(self, mock_check_select):
+ """get_or_create without select_for_update should work with CUSTOM_STORAGE"""
+ institution = InstitutionFactory()
+ self.project_creator.affiliated_institutions.add(institution)
+ RegionFactory(_id=institution._id)
+ ProjectStorageType.objects.filter(node=self.node).update(
+ storage_type=ProjectStorageType.CUSTOM_STORAGE
+ )
+
+ assert_false(UserQuota.objects.filter(user=self.project_creator, storage_type=UserQuota.CUSTOM_STORAGE).exists())
+
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 3000
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.CUSTOM_STORAGE
+ )
+
+ user_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.CUSTOM_STORAGE)
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+ assert_equal(user_quota.used, 3000)
+
+ @mock.patch('website.util.quota.check_select_for_update')
+ def test_file_modified_else_branch_fileinfo_get(self, mock_check):
+ """Test the else branch at line 330 for FileInfo.get without select_for_update"""
+ mock_check.return_value = False
+
+ # Create initial FileInfo
+ FileInfo.objects.create(file=self.file, file_size=500)
+
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 1200
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ user_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ # used = 1200 - 500 = 700 (retrieved existing FileInfo via else branch)
+ assert_equal(user_quota.used, 700)
+
+ # Verify check_select_for_update was called multiple times (for UserQuota and FileInfo)
+ assert_true(mock_check.called)
+
+ @mock.patch('website.util.quota.check_select_for_update', return_value=False)
+ def test_file_modified_without_select_for_update_fileinfo_not_exists(self, mock_check_select):
+ """file_modified should handle FileInfo.DoesNotExist without select_for_update"""
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 2000
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ user_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ # used = 2000 - 0 = 2000 (no FileInfo exists, so old size is 0)
+ assert_equal(user_quota.used, 2000)
+
+ file_info = FileInfo.objects.get(file=self.file)
+ assert_equal(file_info.file_size, 2000)
+
+ @mock.patch('website.util.quota.check_select_for_update')
+ def test_file_modified_else_branch_get_or_create(self, mock_check):
+ """Test the else branch at line 315 for get_or_create without select_for_update"""
+ mock_check.return_value = False
+
+ assert_false(UserQuota.objects.filter(user=self.project_creator, storage_type=UserQuota.NII_STORAGE).exists())
+
+ quota.file_modified(
+ target=self.node,
+ payload={
+ 'metadata': {
+ 'size': 1500
+ }
+ },
+ file_node=self.file,
+ storage_type=UserQuota.NII_STORAGE
+ )
+
+ # Verify that the else branch (line 315) was executed successfully
+ user_quota = UserQuota.objects.get(user=self.project_creator, storage_type=UserQuota.NII_STORAGE)
+ assert_equal(user_quota.used, 1500)
+ assert_equal(user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)
+
+ # Verify check_select_for_update was called
+ assert_true(mock_check.called)
diff --git a/tests/test_quota_move_copy.py b/tests/test_quota_move_copy.py
new file mode 100644
index 00000000000..49081f7ad72
--- /dev/null
+++ b/tests/test_quota_move_copy.py
@@ -0,0 +1,287 @@
+import pytest
+from unittest import mock
+from website.util import quota
+from django.db import IntegrityError
+
+@pytest.fixture
+def mock_user():
+ user = mock.Mock()
+ user.id = 1
+ user._id = 'abc123'
+ user.userquota_set.get.side_effect = quota.UserQuota.DoesNotExist
+ return user
+
+@pytest.fixture
+def mock_node():
+ node = mock.Mock(spec=quota.AbstractNode)
+ node.id = 42
+ node.creator = mock.Mock()
+ node.type = 'osf.node'
+ return node
+
+def test_get_file_size_file():
+ children = {'children': [{'kind': 'file', 'size': 100}, {'kind': 'file', 'size': 200}]}
+ assert quota.get_file_size(children) == 300
+
+def test_get_file_size_nested():
+ children = {
+ 'children': [
+ {'kind': 'file', 'size': 100},
+ {'kind': 'folder', 'children': [{'kind': 'file', 'size': 50}]}
+ ]
+ }
+ assert quota.get_file_size(children) == 150
+
+@mock.patch('website.util.quota.logger')
+def test_get_file_size_none_size_treated_as_zero_and_warns(mock_logger):
+ """A child with size=None must not crash get_file_size."""
+ children = {'children': [{'kind': 'file', 'size': None, 'name': 'unknown.gdoc'}]}
+ assert quota.get_file_size(children) == 0
+ assert mock_logger.warning.called
+
+@mock.patch('website.util.quota.logger')
+def test_get_file_size_negative_size_treated_as_zero_and_warns(mock_logger):
+ """A negative child size must be treated as 0."""
+ children = {'children': [{'kind': 'file', 'size': -1, 'name': 'bad.txt'}]}
+ assert quota.get_file_size(children) == 0
+ assert mock_logger.warning.called
+
+def test_get_file_size_string_size():
+ """Providers that report size as a str (e.g. s3compat HEAD Content-Length header)
+ must not raise TypeError when compared against 0."""
+ children = {'children': [{'kind': 'file', 'size': '100'}, {'kind': 'file', 'size': '200'}]}
+ assert quota.get_file_size(children) == 300
+
+@mock.patch('website.util.quota.logger')
+def test_get_file_size_negative_string_size_treated_as_zero_and_warns(mock_logger):
+ """A negative child size reported as a str must also be treated as 0."""
+ children = {'children': [{'kind': 'file', 'size': '-1', 'name': 'bad.txt'}]}
+ assert quota.get_file_size(children) == 0
+ assert mock_logger.warning.called
+
+@mock.patch('website.util.quota.logger')
+def test_get_file_size_mixed_children_none_and_valid(mock_logger):
+ """A None-size sibling must not swallow the total contributed by valid siblings."""
+ children = {
+ 'children': [
+ {'kind': 'file', 'size': 100, 'name': 'ok.txt'},
+ {'kind': 'file', 'size': None, 'name': 'unknown.gdoc'},
+ ]
+ }
+ assert quota.get_file_size(children) == 100
+ assert mock_logger.warning.called
+
+@mock.patch('website.util.quota.ProjectStorageType.objects.get')
+@mock.patch('website.util.quota.AbstractNode.objects.get')
+@mock.patch('website.util.quota.update_quota')
+@pytest.mark.django_db
+def test_handle_move_copy_source_extend_dest_osfstorage(mock_update_quota, mock_node_get, mock_pst_get, mock_node):
+ """source=s3compatinstitutions, dest=osfstorage"""
+ mock_pst_get.return_value = mock.Mock(storage_type=1)
+ mock_node_get.return_value = mock_node
+
+ payload = {
+ 'destination': {'provider': 'osfstorage', 'size': 123, 'children': None},
+ 'source': {'nid': 'nid1', 'provider': 's3compatinstitutions'}
+ }
+ quota._handle_move_copy(quota.FileLog.FILE_MOVED, mock_node, mock.Mock(), payload)
+ mock_update_quota.assert_not_called()
+
+@mock.patch('website.util.quota.ProjectStorageType.objects.get')
+@mock.patch('website.util.quota.AbstractNode.objects.get')
+@mock.patch('website.util.quota.update_quota')
+@pytest.mark.django_db
+def test_handle_move_copy_source_osfstorage(mock_update_quota, mock_node_get, mock_pst_get, mock_user):
+ mock_node = mock.create_autospec(quota.AbstractNode, instance=True)
+ mock_node.type = 'osf.node'
+ mock_pst_get.return_value = mock.Mock(storage_type=1)
+ mock_node_get.return_value = mock_node
+
+ payload = {
+ 'destination': {'provider': 's3compatinstitutions', 'size': 123},
+ 'source': {'provider': 'osfstorage', 'size': 123, 'nid': 'nid1'}
+ }
+ quota._handle_move_copy(quota.FileLog.FILE_MOVED, mock_node, mock_user, payload)
+ mock_update_quota.assert_called_once()
+
+@mock.patch('website.util.quota.update_quota')
+@pytest.mark.django_db
+def test_handle_move_copy_no_osfstorage(mock_update_quota, mock_node, mock_user):
+ payload = {
+ 'destination': {'provider': 's3compatinstitutions'},
+ 'source': {'provider': 's3compatinstitutions'}
+ }
+ quota._handle_move_copy(quota.FileLog.FILE_MOVED, mock_node, mock_user, payload)
+ mock_update_quota.assert_not_called()
+
+@mock.patch('website.util.quota.ProjectStorageType.objects.get')
+@mock.patch('website.util.quota.AbstractNode.objects.get')
+@mock.patch('website.util.quota.update_quota')
+@pytest.mark.django_db
+def test_file_moved_file(mock_update_quota, mock_node_get, mock_pst_get, mock_node):
+ mock_pst_get.return_value = mock.Mock(storage_type=1)
+ mock_node_get.return_value = mock_node
+ payload = {
+ 'destination': {'size': 123, 'children': None},
+ 'source': {'nid': 'nid1', 'provider': 'osfstorage'}
+ }
+ quota.file_moved(mock_node, payload)
+ mock_update_quota.assert_called_once()
+
+@mock.patch('website.util.quota.ProjectStorageType.objects.get')
+@mock.patch('website.util.quota.AbstractNode.objects.get')
+@mock.patch('website.util.quota.update_quota')
+@pytest.mark.django_db
+def test_file_moved_folder(mock_update_quota, mock_node_get, mock_pst_get, mock_node):
+ mock_pst_get.return_value = mock.Mock(storage_type=1)
+ mock_node_get.return_value = mock_node
+ payload = {
+ 'destination': {'children': [{'kind': 'file', 'size': 100}]},
+ 'source': {'nid': 'nid1', 'provider': 'osfstorage'}
+ }
+ quota.file_moved(mock_node, payload)
+ mock_update_quota.assert_called_once()
+
+@mock.patch('website.util.quota.ProjectStorageType.objects.get', side_effect=Exception)
+@mock.patch('website.util.quota.AbstractNode.objects.get')
+@mock.patch('website.util.quota.update_quota')
+@pytest.mark.django_db
+def test_file_moved_exception(mock_update_quota, mock_node_get, mock_pst_get, mock_node):
+ mock_node_get.return_value = mock_node
+ payload = {
+ 'destination': {'size': 123, 'children': None},
+ 'source': {'nid': 'nid1', 'provider': 'osfstorage'}
+ }
+ with pytest.raises(Exception):
+ quota.file_moved(mock_node, payload)
+ mock_update_quota.assert_not_called()
+
+@mock.patch('website.util.quota.UserQuota.objects.get')
+@mock.patch('website.util.quota.UserQuota.objects.filter')
+@pytest.mark.django_db
+def test_update_quota_add(mock_filter, mock_get, mock_node):
+ uq = mock.Mock()
+ uq.used = 0
+ mock_get.return_value = uq
+ mock_filter.return_value.select_for_update.return_value.get.return_value = uq
+ quota.update_quota(mock_node, 100, 1, add=True)
+ assert uq.used == 100
+
+@mock.patch('website.util.quota.UserQuota.objects.get')
+@mock.patch('website.util.quota.UserQuota.objects.filter')
+@pytest.mark.django_db
+def test_update_quota_subtract(mock_filter, mock_get, mock_node):
+ uq = mock.Mock()
+ uq.used = 200
+ mock_get.return_value = uq
+ mock_filter.return_value.select_for_update.return_value.get.return_value = uq
+ quota.update_quota(mock_node, 50, 1, add=False)
+ assert uq.used == 150
+
+@mock.patch('website.util.quota.check_select_for_update', return_value=False)
+@mock.patch('website.util.quota.transaction.atomic')
+@mock.patch('website.util.quota.UserQuota.objects.create')
+@mock.patch('website.util.quota.UserQuota.objects.filter')
+@mock.patch('website.util.quota.UserQuota.objects.get', side_effect=quota.UserQuota.DoesNotExist)
+@pytest.mark.django_db
+def test_update_quota_userquota_not_exist(mock_get, mock_filter, mock_create, mock_atomic, mock_node):
+ mock_filter.return_value.select_for_update.return_value.get.side_effect = quota.UserQuota.DoesNotExist
+ quota.update_quota(mock_node, 50, 1, add=True)
+ mock_get.assert_called_once()
+
+@mock.patch('website.util.quota.UserQuota.objects.get', side_effect=Exception)
+@mock.patch('website.util.quota.UserQuota.objects.filter')
+@pytest.mark.django_db
+def test_update_quota_other_exception(mock_filter, mock_get, mock_node):
+ mock_filter.return_value.select_for_update.return_value.get.side_effect = Exception
+ with pytest.raises(Exception):
+ quota.update_quota(mock_node, 50, 1, add=True)
+ mock_get.assert_not_called()
+
+@mock.patch('website.util.quota._handle_move_copy')
+def test_update_used_quota_move_event(mock_handle_move_copy, mock_node, mock_user):
+ payload = {
+ 'destination': {'provider': 's3compatinstitutions'},
+ 'source': {'provider': 'osfstorage'}
+ }
+ quota.update_used_quota(None, mock_node, mock_user, quota.FileLog.FILE_MOVED, payload)
+ mock_handle_move_copy.assert_called_once()
+
+@mock.patch('website.util.quota._handle_move_copy')
+def test_update_used_quota_dest_osfstorage_returns_early(mock_handle_move_copy, mock_node, mock_user):
+ payload = {
+ 'destination': {'provider': 'osfstorage'}
+ }
+ result = quota.update_used_quota(None, mock_node, mock_user, quota.FileLog.FILE_MOVED, payload)
+ assert result is None
+ mock_handle_move_copy.assert_not_called()
+
+@mock.patch('website.util.quota.check_select_for_update', return_value=True)
+@mock.patch('website.util.quota.transaction.atomic')
+@mock.patch('website.util.quota.UserQuota.objects.create', side_effect=IntegrityError)
+@mock.patch('website.util.quota.UserQuota.objects.filter')
+@pytest.mark.django_db
+def test_update_quota_integrityerror_select_for_update_add(
+ mock_filter, mock_create, mock_atomic, mock_check, mock_node
+):
+ uq = mock.Mock()
+ uq.used = 10
+ mock_filter.return_value.select_for_update.return_value.get.side_effect = [
+ quota.UserQuota.DoesNotExist,
+ uq
+ ]
+ quota.update_quota(mock_node, 5, 1, add=True)
+ assert mock_filter.return_value.select_for_update.return_value.get.call_count == 2
+ assert uq.used == 15
+ uq.save.assert_called_once()
+
+@mock.patch('website.util.quota.check_select_for_update', return_value=True)
+@mock.patch('website.util.quota.transaction.atomic')
+@mock.patch('website.util.quota.UserQuota.objects.create', side_effect=IntegrityError)
+@mock.patch('website.util.quota.UserQuota.objects.filter')
+@pytest.mark.django_db
+def test_update_quota_integrityerror_select_for_update_subtract(
+ mock_filter, mock_create, mock_atomic, mock_check, mock_node
+):
+ uq = mock.Mock()
+ uq.used = 10
+ mock_filter.return_value.select_for_update.return_value.get.side_effect = [
+ quota.UserQuota.DoesNotExist,
+ uq
+ ]
+ quota.update_quota(mock_node, 3, 1, add=False)
+ assert mock_filter.return_value.select_for_update.return_value.get.call_count == 1
+ assert uq.used == 10
+ uq.save.assert_not_called()
+
+@mock.patch('website.util.quota.check_select_for_update', return_value=False)
+@mock.patch('website.util.quota.transaction.atomic')
+@mock.patch('website.util.quota.UserQuota.objects.create', side_effect=IntegrityError)
+@mock.patch('website.util.quota.UserQuota.objects.get')
+@mock.patch('website.util.quota.UserQuota.objects.filter')
+@pytest.mark.django_db
+def test_update_quota_integrityerror_get_add(
+ mock_filter, mock_get, mock_create, mock_atomic, mock_check, mock_node
+):
+ uq = mock.Mock()
+ uq.used = 20
+ mock_get.side_effect = [quota.UserQuota.DoesNotExist, uq]
+ quota.update_quota(mock_node, 8, 1, add=True)
+ assert uq.used == 28
+ uq.save.assert_called_once()
+
+@mock.patch('website.util.quota.check_select_for_update', return_value=False)
+@mock.patch('website.util.quota.transaction.atomic')
+@mock.patch('website.util.quota.UserQuota.objects.create', side_effect=IntegrityError)
+@mock.patch('website.util.quota.UserQuota.objects.get')
+@mock.patch('website.util.quota.UserQuota.objects.filter')
+@pytest.mark.django_db
+def test_update_quota_integrityerror_get_subtract(
+ mock_filter, mock_get, mock_create, mock_atomic, mock_check, mock_node
+):
+ uq = mock.Mock()
+ uq.used = 30
+ mock_get.side_effect = [quota.UserQuota.DoesNotExist, uq]
+ quota.update_quota(mock_node, 12, 1, add=False)
+ assert uq.used == 30
+ uq.save.assert_not_called()
diff --git a/website/project/views/quota.py b/website/project/views/quota.py
index 88814f268a0..8f78fbd5d83 100644
--- a/website/project/views/quota.py
+++ b/website/project/views/quota.py
@@ -17,10 +17,11 @@ def get_quota_from_pid(pid):
"""Auxiliary function for getting the quota from a project ID.
Used on requests by waterbutler and the user (from browser)."""
node = AbstractNode.load(pid)
- max_quota, used_quota = quota.get_quota_info(
- node.creator, quota.get_project_storage_type(node)
- )
+ storage_type = quota.get_project_storage_type(node)
+ max_quota, used_quota = quota.get_quota_info(node.creator, storage_type)
return {
'max': max_quota * api_settings.SIZE_UNIT_GB,
- 'used': used_quota
+ 'used': used_quota,
+ 'user_guid': node.creator._id,
+ 'storage_type': storage_type,
}
diff --git a/website/static/js/fangorn.js b/website/static/js/fangorn.js
index bb0e87f283f..d6b6b44829c 100644
--- a/website/static/js/fangorn.js
+++ b/website/static/js/fangorn.js
@@ -89,6 +89,10 @@ var OPERATIONS = {
}
};
+var MESSAGE_MAP = {
+ 'quota_exceeded': gettext('You do not have enough available quota.'),
+};
+
var isInUploadFolderProcess = false;
var isOngoingUploadFolder = false;
@@ -706,6 +710,111 @@ function checkConflictsRename(tb, item, name, cb) {
cb('replace');
}
+/**
+ * Recursively finds all individual files that exceed maxSizeBytes.
+ * Returns an array of offending item objects.
+ */
+function _findOversizedFiles(item, maxSizeBytes) {
+ var oversized = [];
+ if (item.kind === 'file') {
+ var size = item.data.size || 0;
+ if (size > 0 && size > maxSizeBytes) {
+ oversized.push(item);
+ }
+ } else if (item.kind === 'folder' && item.children && item.children.length > 0) {
+ for (var i = 0; i < item.children.length; i++) {
+ oversized = oversized.concat(_findOversizedFiles(item.children[i], maxSizeBytes));
+ }
+ }
+ return oversized;
+}
+
+/**
+ * Helper function to reject a conflict item (e.g. if file size or quota check fails)
+ * and advance the sync move/copy queue.
+ */
+function _rejectConflictItem(tb, from, to, notRenameOp, conflict) {
+ from.inProgress = false;
+ if (notRenameOp) {
+ addFileStatus(tb, from, false, '', '', conflict);
+ // Use object identity (not data.id): for MOVE, .done() replaces from.data
+ // in-place so from.data.id changes before .always() runs.
+ tb.pendingReadyFiles = (tb.pendingReadyFiles || []).filter(function(file) {
+ return file !== from;
+ });
+ if (!tb.pendingReadyFiles.length) {
+ doSyncMove(tb, to.data.provider);
+ }
+ }
+}
+
+/**
+ * True when `node` itself, or any folder anywhere beneath it, has not been
+ * lazy-loaded yet — meaning the subtree's total size cannot be known client-side.
+ *
+ * The file browser lazy-loads a single level at a time, so an expanded folder can
+ * still contain sub-folders whose own children have never been fetched. Summing
+ * only what happens to be loaded would under-count the freed size.
+ */
+function hasUnloadedFolder(node) {
+ if (node.kind === 'folder' && !node.load) {
+ return true;
+ }
+ return (node.children || []).some(hasUnloadedFolder);
+}
+
+/**
+ * Size freed up at the destination by an overwrite, in bytes.
+ *
+ * Mirrors WaterButler's get_replaced_size(): only conflict === 'replace' overwrites
+ * anything ('keep' renames the incoming item; omitted conflict defaults to 'warn' —
+ * both free 0 bytes), and only a same-kind destination item is replaced (osfstorage
+ * allows a file and folder to share a name).
+ *
+ * Returns null if the destination folder's subtree isn't fully loaded (size unknown) —
+ * callers must skip the client-side check and defer to the server.
+ */
+function getReplacedSize(to, from, conflict) {
+ if (conflict !== 'replace') {
+ return 0;
+ }
+ var existing = (to.children || []).filter(function(child) {
+ return child.data.name === from.data.name && child.kind === from.kind;
+ })[0];
+ if (!existing) {
+ return 0;
+ }
+ if (from.kind !== 'folder') {
+ return parseInt(existing.data.size, 10) || 0;
+ }
+ if (hasUnloadedFolder(existing)) {
+ // Part of the subtree is not lazy-loaded yet; total size unknown, let the
+ // server-side check decide.
+ return null;
+ }
+ return getAllChildren(existing).reduce(function(total, child) {
+ return child.kind === 'file' ? total + (parseInt(child.data.size, 10) || 0) : total;
+ }, 0);
+}
+
+// max_file_size skip narrows by provider only (browser can't know region);
+// server gives the authoritative 413 if it turns out not to qualify.
+function shouldSkipSizeCheck(operation, fromProvider, destProvider) {
+ return operation.status === 'move' && fromProvider === destProvider;
+}
+
+function isSameUserQuota(srcQuota, destQuota) {
+ return !!(srcQuota && destQuota &&
+ srcQuota.user_guid !== undefined && srcQuota.user_guid !== null &&
+ srcQuota.user_guid === destQuota.user_guid &&
+ srcQuota.storage_type === destQuota.storage_type);
+}
+
+function quotaCheckExceeds(destQuota, totalSize, replacedSize) {
+ replacedSize = replacedSize || 0;
+ return (destQuota.used + totalSize - replacedSize) > destQuota.max;
+}
+
function doItemOp(operation, to, from, rename, conflict) {
var tb = this;
// dismiss old modal immediately to prevent button mashing
@@ -714,6 +823,7 @@ function doItemOp(operation, to, from, rename, conflict) {
var filesRemaining;
var inConflictsQueue = false;
var syncMoves;
+ var showQuotaUsageAlert = false;
var notRenameOp = typeof rename === 'undefined';
if (notRenameOp) {
@@ -749,7 +859,113 @@ function doItemOp(operation, to, from, rename, conflict) {
return;
}
+ if (operation !== OPERATIONS.RENAME) {
+ var destProvider = to.data.provider;
+
+ // Sum size of all files in the item being moved/copied
+ var totalMoveSize = 0;
+ if (from.kind === 'file') {
+ totalMoveSize = parseInt(from.data.size, 10) || 0;
+ } else {
+ getAllChildren(from).forEach(function(child) {
+ if (child.kind === 'file') {
+ totalMoveSize += parseInt(child.data.size, 10) || 0;
+ }
+ });
+ }
+
+ var skipSizeCheck = shouldSkipSizeCheck(operation, from.data.provider, destProvider);
+
+ if (!skipSizeCheck) {
+ var destMaxSize = to.data && to.data.accept && to.data.accept.maxSize;
+ if (destMaxSize) {
+ var maxSizeBytes = destMaxSize * 1000000;
+ var oversizedFiles = _findOversizedFiles(from, maxSizeBytes);
+ if (oversizedFiles.length > 0) {
+ var maxSizeDisplay = $osf.humanFileSize(maxSizeBytes, true);
+ oversizedFiles.forEach(function(oversized) {
+ var displaySize = $osf.humanFileSize(oversized.data.size, true);
+ $osf.growl(sprintf(
+ gettext('File「%1$s」is too large (%2$s). Max file size is %3$s.'),
+ oversized.data.name, displaySize, maxSizeDisplay
+ ));
+ });
+ _rejectConflictItem(tb, from, to, notRenameOp, conflict);
+ return;
+ }
+ }
+ }
+
+ // Quota context is resolved only after the size check above has run, mirroring
+ // the server's run_pre_checks order: an oversized file is rejected with 413
+ // before creator_quota is ever fetched.
+ var destQuota = null;
+ var srcQuota = null;
+ var skipQuotaCheck = false;
+ if (destProvider === 'osfstorage' && totalMoveSize > 0) {
+ // Sequential async:false calls (kept in sync with doItemOp's control flow)
+ var destQuotaResp = $.ajax({
+ async: false,
+ method: 'GET',
+ url: to.data.nodeApiUrl + 'get_creator_quota/'
+ });
+ // Only a MOVE from osfstorage needs src quota — it serves just to detect a
+ // same-record move. A falsy srcQuota means "adds usage".
+ var srcQuotaResp = null;
+ if (operation.status === 'move' && from.data.provider === 'osfstorage') {
+ // Same node ⇒ same UserQuota record: reuse the response already in hand
+ // instead of asking the very same endpoint twice.
+ srcQuotaResp = (from.data.nodeApiUrl === to.data.nodeApiUrl) ? destQuotaResp : $.ajax({
+ async: false,
+ method: 'GET',
+ url: from.data.nodeApiUrl + 'get_creator_quota/'
+ });
+ }
+ if (destQuotaResp.responseJSON && (!srcQuotaResp || srcQuotaResp.responseJSON)) {
+ destQuota = destQuotaResp.responseJSON;
+ srcQuota = srcQuotaResp ? srcQuotaResp.responseJSON : null;
+ skipQuotaCheck = (operation.status === 'move' &&
+ isSameUserQuota(srcQuota, destQuota));
+ }
+ // A failed request leaves destQuota null: the browser evaluates nothing here —
+ // neither the quota check nor the threshold warning runs below — and defers
+ // entirely to the server-side check.
+ }
+
+ // destQuota is only set for an osfstorage destination with something to weigh.
+ // skipQuotaCheck is the single source of truth for same-UserQuota-record moves:
+ // by this point that case is already excluded, so nothing below needs to re-check it.
+ if (destQuota && !skipQuotaCheck) {
+ var replacedSize = getReplacedSize(to, from, conflict);
+ var quotaMsgText = '';
+
+ // replacedSize === null: destination folder not yet loaded, so its size
+ // is unknown — skip only this blocking check and let the move/copy proceed;
+ // the server-side check is authoritative.
+ if (replacedSize !== null && quotaCheckExceeds(destQuota, totalMoveSize, replacedSize)) {
+ if (from.kind === 'file') {
+ quotaMsgText = sprintf(gettext('Not enough quota to move/copy the file.'));
+ } else {
+ var folderSize = $osf.humanFileSize(totalMoveSize, true);
+ quotaMsgText = sprintf(gettext('Not enough quota to move/copy. The total size of the folder %1$s.'), folderSize);
+ }
+ from.notify.update(quotaMsgText, 'warning', undefined, 3000);
+ _rejectConflictItem(tb, from, to, notRenameOp, conflict);
+ return;
+ }
+ // Unknown replacedSize is treated as 0 (worst case) so the alert can still fire.
+ var quotaDelta = totalMoveSize - (replacedSize || 0);
+ // Shown only after a confirmed success below. The alert announces *crossing*
+ // the threshold, so an operation that adds no usage must stay silent.
+ showQuotaUsageAlert = (quotaDelta > 0 &&
+ destQuota.used + quotaDelta > destQuota.max * window.contextVars.threshold);
+ }
+ }
+
var origFrom = Object.assign({}, from);
+ // Must capture before COPY rebinds `from` to a new clone.
+ // .done() replaces from.data in-place, so ID-based filtering breaks for MOVE ops.
+ var originalFrom = from;
if (operation === OPERATIONS.COPY) {
from = tb.createItem($.extend(true, {status: operation.status}, from.data), to.id);
} else {
@@ -815,6 +1031,17 @@ function doItemOp(operation, to, from, rename, conflict) {
from.data.status = undefined;
from.notify.update(sprintf(gettext('Successfully %1$s.') , gettext(operation.passed)), 'success', null, 1000);
+ if (showQuotaUsageAlert) {
+ $osf.growl(
+ gettext('Quota usage alert'),
+ sprintf(
+ gettext('You have used more than %1$s%% of your quota.'),
+ (window.contextVars.threshold * 100)
+ ),
+ 'warning'
+ );
+ }
+
if (xhr.status === 200) {
to.children.forEach(function(child) {
if (child.data.name === from.data.name && child.id !== from.id) {
@@ -854,6 +1081,41 @@ function doItemOp(operation, to, from, rename, conflict) {
from.data.status = undefined;
}
+ if (xhr.status === 413 && xhr.responseJSON && xhr.responseJSON.oversized_files) {
+ var serverMaxSize = xhr.responseJSON.max_size;
+ var destMaxSize = to.data && to.data.accept && to.data.accept.maxSize;
+ var maxSizeDisplay = serverMaxSize ?
+ $osf.humanFileSize(serverMaxSize, true) :
+ (destMaxSize ? $osf.humanFileSize(destMaxSize * 1000000, true) : null);
+ xhr.responseJSON.oversized_files.forEach(function(fileObj) {
+ var displaySize = $osf.humanFileSize(fileObj.size, true);
+ var msg = maxSizeDisplay ?
+ sprintf(gettext('File「%1$s」is too large (%2$s). Max file size is %3$s.'), fileObj.name, displaySize, maxSizeDisplay) :
+ sprintf(gettext('File「%1$s」is too large (%2$s).'), fileObj.name, displaySize);
+ $osf.growl(msg);
+ });
+
+ if (notRenameOp) {
+ addFileStatus(tb, from, false, '', '', conflict);
+ }
+ orderFolder.call(tb, from.parent());
+ // NOTE: pendingReadyFiles filter, from.inProgress reset, and doSyncMove
+ // are all handled by .always() below. Do NOT duplicate them here.
+ return;
+ }
+ if (xhr.status === 406 && xhr.responseJSON && xhr.responseJSON.message) {
+ var key = xhr.responseJSON.message_key;
+ var msg = (key && MESSAGE_MAP[key]) ? MESSAGE_MAP[key] : xhr.responseJSON.message;
+ $osf.growl(msg);
+ if (notRenameOp) {
+ addFileStatus(tb, from, false, '', '', conflict);
+ }
+ orderFolder.call(tb, from.parent());
+ // NOTE: pendingReadyFiles filter, from.inProgress reset, and doSyncMove
+ // are all handled by .always() below. Do NOT duplicate them here.
+ return;
+ }
+
var message;
if (xhr.status !== 500 && xhr.responseJSON && (xhr.responseJSON.message || xhr.responseJSON.message_long)) {
@@ -878,7 +1140,9 @@ function doItemOp(operation, to, from, rename, conflict) {
orderFolder.call(tb, from.parent());
}).always(function(){
- tb.pendingReadyFiles = (tb.pendingReadyFiles || []).filter(function (file) { return file.data.id !== from.data.id; });
+ // Use object identity: .done() replaces from.data in-place (MOVE op),
+ // making ID-based comparison unreliable by the time .always() runs.
+ tb.pendingReadyFiles = (tb.pendingReadyFiles || []).filter(function (file) { return file !== originalFrom; });
from.inProgress = false;
if (notRenameOp && !tb.pendingReadyFiles.length){
doSyncMove(tb, to.data.provider);
@@ -3647,6 +3911,7 @@ function _dropLogic(event, items, folder) {
if (toMove.conflicts.length > 0) {
tb.syncFileMoveCache[folder.data.provider].conflicts = tb.syncFileMoveCache[folder.data.provider].conflicts || [];
+ tb.syncFileMoveCache[folder.data.provider].ready = tb.syncFileMoveCache[folder.data.provider].ready || [];
toMove.conflicts.forEach(function(item) {
tb.syncFileMoveCache[folder.data.provider].conflicts.push({'item' : item, 'folder' : folder});
});
@@ -4412,5 +4677,11 @@ module.exports = {
getCopyMode : getCopyMode,
showDeleteMultiple : showDeleteMultiple,
checkConflicts : checkConflicts,
- getPersistentLinkFor: getPersistentLinkFor
+ getPersistentLinkFor: getPersistentLinkFor,
+ quotaCheckExceeds: quotaCheckExceeds,
+ getReplacedSize: getReplacedSize,
+ isSameUserQuota: isSameUserQuota,
+ shouldSkipSizeCheck: shouldSkipSizeCheck,
+ doItemOp: doItemOp,
+ OPERATIONS: OPERATIONS
};
diff --git a/website/static/js/tests/fangorn.test.js b/website/static/js/tests/fangorn.test.js
index 10a18c56f1e..c34f3bd5c88 100644
--- a/website/static/js/tests/fangorn.test.js
+++ b/website/static/js/tests/fangorn.test.js
@@ -14,6 +14,8 @@ var Raven = require('raven-js');
window.contextVars = {
osfSupportEmail : 'fake-support@osf.io',
+ waterbutlerURL : 'https://files.osf.io/',
+ threshold : 0.9,
};
var language = require('js/osfLanguage').projectSettings;
@@ -423,6 +425,647 @@ describe('fangorn', () => {
assert.equal(Fangorn.showDeleteMultiple([folder, item]), true);
});
});
+
+ describe('shouldSkipSizeCheck', () => {
+ it('returns true for a move where source and destination provider match', () => {
+ var operation = {status: 'move'};
+ assert.equal(Fangorn.shouldSkipSizeCheck(operation, 'osfstorage', 'osfstorage'), true);
+ });
+
+ it('returns false for a move where source and destination provider differ', () => {
+ var operation = {status: 'move'};
+ assert.equal(Fangorn.shouldSkipSizeCheck(operation, 'osfstorage', 's3'), false);
+ });
+
+ it('returns false for a copy even when provider matches', () => {
+ var operation = {status: 'copy'};
+ assert.equal(Fangorn.shouldSkipSizeCheck(operation, 'osfstorage', 'osfstorage'), false);
+ });
+
+ it('returns false for a copy where provider also differs', () => {
+ var operation = {status: 'copy'};
+ assert.equal(Fangorn.shouldSkipSizeCheck(operation, 'osfstorage', 's3'), false);
+ });
+ });
+
+ describe('isSameUserQuota', () => {
+ it('returns true when user_guid and storage_type both match', () => {
+ var src = {user_guid: 'abc', storage_type: 'NII_STORAGE'};
+ var dest = {user_guid: 'abc', storage_type: 'NII_STORAGE'};
+ assert.equal(Fangorn.isSameUserQuota(src, dest), true);
+ });
+
+ it('returns false when user_guid differs', () => {
+ var src = {user_guid: 'abc', storage_type: 'NII_STORAGE'};
+ var dest = {user_guid: 'xyz', storage_type: 'NII_STORAGE'};
+ assert.equal(Fangorn.isSameUserQuota(src, dest), false);
+ });
+
+ it('returns false when storage_type differs', () => {
+ var src = {user_guid: 'abc', storage_type: 'NII_STORAGE'};
+ var dest = {user_guid: 'abc', storage_type: 'CUSTOM_STORAGE'};
+ assert.equal(Fangorn.isSameUserQuota(src, dest), false);
+ });
+
+ it('returns false when user_guid is undefined on either side', () => {
+ var src = {storage_type: 'NII_STORAGE'};
+ var dest = {user_guid: 'abc', storage_type: 'NII_STORAGE'};
+ assert.equal(Fangorn.isSameUserQuota(src, dest), false);
+ });
+
+ it('returns false when user_guid is null on both sides', () => {
+ var src = {user_guid: null, storage_type: 'NII_STORAGE'};
+ var dest = {user_guid: null, storage_type: 'NII_STORAGE'};
+ assert.equal(Fangorn.isSameUserQuota(src, dest), false);
+ });
+
+ it('returns false when srcQuota is falsy', () => {
+ var dest = {user_guid: 'abc', storage_type: 'NII_STORAGE'};
+ assert.equal(Fangorn.isSameUserQuota(null, dest), false);
+ });
+
+ it('returns false when destQuota is falsy', () => {
+ var src = {user_guid: 'abc', storage_type: 'NII_STORAGE'};
+ assert.equal(Fangorn.isSameUserQuota(src, null), false);
+ });
+ });
+
+ describe('quotaCheckExceeds', () => {
+ it('returns true when used + totalSize exceeds max', () => {
+ var destQuota = {used: 800, max: 1000};
+ assert.equal(Fangorn.quotaCheckExceeds(destQuota, 300, 0), true);
+ });
+
+ it('returns false when used + totalSize stays within max', () => {
+ var destQuota = {used: 800, max: 1000};
+ assert.equal(Fangorn.quotaCheckExceeds(destQuota, 100, 0), false);
+ });
+
+ it('returns false when used + totalSize lands exactly on max', () => {
+ var destQuota = {used: 800, max: 1000};
+ assert.equal(Fangorn.quotaCheckExceeds(destQuota, 200, 0), false);
+ });
+
+ it('subtracts replacedSize before comparing to max', () => {
+ var destQuota = {used: 800, max: 1000};
+ // Without replacedSize this would exceed (800 + 300 > 1000),
+ // but the 150 freed by the overwrite brings it back within max.
+ assert.equal(Fangorn.quotaCheckExceeds(destQuota, 300, 150), false);
+ });
+
+ it('treats a missing replacedSize as 0', () => {
+ var destQuota = {used: 800, max: 1000};
+ assert.equal(Fangorn.quotaCheckExceeds(destQuota, 300), true);
+ });
+ });
+
+ describe('getReplacedSize', () => {
+ // Fixture builder for treebeard-like nodes: getReplacedSize/hasUnloadedFolder
+ // read top-level `kind`/`load`, unlike the `getItem` helper above which only
+ // sets `data.kind`.
+ var makeNode = function(kind, name, opts) {
+ opts = opts || {};
+ return {
+ kind: kind,
+ data: {
+ name: name,
+ size: opts.size
+ },
+ children: opts.children || [],
+ load: opts.load !== undefined ? opts.load : true
+ };
+ };
+
+ it('returns 0 when conflict is not replace', () => {
+ var to = {children: []};
+ var from = makeNode('file', 'a.txt', {size: 10});
+ assert.equal(Fangorn.getReplacedSize(to, from, 'keep'), 0);
+ });
+
+ it('returns 0 when no existing item matches name and kind', () => {
+ var to = {children: [makeNode('file', 'other.txt', {size: 10})]};
+ var from = makeNode('file', 'a.txt', {size: 10});
+ assert.equal(Fangorn.getReplacedSize(to, from, 'replace'), 0);
+ });
+
+ it('does not match when name is the same but kind differs', () => {
+ var to = {children: [makeNode('folder', 'a', {})]};
+ var from = makeNode('file', 'a', {size: 10});
+ assert.equal(Fangorn.getReplacedSize(to, from, 'replace'), 0);
+ });
+
+ it('returns the existing file size when replacing a file', () => {
+ var existing = makeNode('file', 'a.txt', {size: 500});
+ var to = {children: [existing]};
+ var from = makeNode('file', 'a.txt', {size: 10});
+ assert.equal(Fangorn.getReplacedSize(to, from, 'replace'), 500);
+ });
+
+ it('returns 0 when the existing file has no size', () => {
+ var existing = makeNode('file', 'a.txt', {});
+ var to = {children: [existing]};
+ var from = makeNode('file', 'a.txt', {size: 10});
+ assert.equal(Fangorn.getReplacedSize(to, from, 'replace'), 0);
+ });
+
+ it('sums the sizes of all files in a fully loaded existing folder', () => {
+ var child1 = makeNode('file', 'x.txt', {size: 100});
+ var child2 = makeNode('file', 'y.txt', {size: 200});
+ var subfolder = makeNode('folder', 'sub', {children: [child1, child2], load: true});
+ var existing = makeNode('folder', 'a', {children: [subfolder], load: true});
+ var to = {children: [existing]};
+ var from = makeNode('folder', 'a', {});
+ assert.equal(Fangorn.getReplacedSize(to, from, 'replace'), 300);
+ });
+
+ it('returns null when the existing folder itself has not been lazy-loaded', () => {
+ var existing = makeNode('folder', 'a', {load: false});
+ var to = {children: [existing]};
+ var from = makeNode('folder', 'a', {});
+ assert.equal(Fangorn.getReplacedSize(to, from, 'replace'), null);
+ });
+
+ it('returns null when a nested subfolder has not been lazy-loaded', () => {
+ var unloadedSub = makeNode('folder', 'sub', {load: false});
+ var existing = makeNode('folder', 'a', {children: [unloadedSub], load: true});
+ var to = {children: [existing]};
+ var from = makeNode('folder', 'a', {});
+ assert.equal(Fangorn.getReplacedSize(to, from, 'replace'), null);
+ });
+ });
+ });
+
+ describe('doItemOp', () => {
+ var ajaxStub;
+ var growlStub;
+ var ravenStub;
+
+ beforeEach(() => {
+ growlStub = sinon.stub($osf, 'growl');
+ ravenStub = sinon.stub(Raven, 'captureMessage');
+ });
+
+ afterEach(() => {
+ if (ajaxStub) { ajaxStub.restore(); }
+ growlStub.restore();
+ ravenStub.restore();
+ });
+
+ // Fixture builder for a fake Treebeard node with just enough of the
+ // real API (move/parent/notify/add/removeSelf) for doItemOp to run.
+ function makeParent() {
+ return {
+ data: {
+ nodeUrl: 'https://osf.io/proj1/',
+ nodeApiUrl: 'https://osf.io/proj1/api/',
+ nodeId: 'proj1',
+ permissions: {edit: true},
+ provider: 'osfstorage'
+ },
+ sortChildren: sinon.stub()
+ };
+ }
+
+ function makeItem(kind, data, id, parentID) {
+ return {
+ kind: kind,
+ data: $.extend({provider: 'osfstorage'}, data),
+ children: [],
+ id: id,
+ parentID: parentID,
+ inProgress: false,
+ notify: {update: sinon.stub()},
+ move: sinon.stub(),
+ add: sinon.stub(),
+ removeSelf: sinon.stub(),
+ parent: sinon.stub().returns(makeParent())
+ };
+ }
+
+ // Fixture builder for a fake Treebeard.controller (`this` inside doItemOp).
+ function makeTb() {
+ var tb = {
+ modal: {dismiss: sinon.stub(), update: sinon.stub()},
+ pendingFileOps: [],
+ pendingReadyFiles: [],
+ moveStates: [],
+ uploadStates: [],
+ syncFileMoveCache: {osfstorage: {conflicts: [], ready: []}, s3compat: {conflicts: [], ready: []}},
+ clearMultiselect: sinon.stub(),
+ redraw: sinon.stub(),
+ select: sinon.stub().returns({hide: sinon.stub(), css: sinon.stub()}),
+ options: {
+ lazyLoadPreprocess: function(resp) { return {data: resp}; }
+ }
+ };
+ tb.createItem = sinon.stub().callsFake(function(data) {
+ return makeItem(data.kind, data);
+ });
+ tb.buildTree = sinon.stub().callsFake(function(data) {
+ return makeItem(data.kind, data);
+ });
+ return tb;
+ }
+
+ // A minimal fake jQuery Deferred supporting the .done()/.fail()/.always()
+ // chain doItemOp attaches to the final POST call.
+ function fakeDeferred(config) {
+ var obj = {
+ done: function(cb) {
+ if (config.status === 'done') { cb.apply(null, config.args || []); }
+ return obj;
+ },
+ fail: function(cb) {
+ if (config.status === 'fail') { cb.apply(null, config.args || []); }
+ return obj;
+ },
+ always: function(cb) { cb(); return obj; }
+ };
+ return obj;
+ }
+
+ function stubAjax(responder) {
+ ajaxStub = sinon.stub($, 'ajax').callsFake(responder);
+ }
+
+ function growlCallWithType(type) {
+ return growlStub.getCalls().some(function(call) { return call.args[2] === type; });
+ }
+
+ it('returns without calling the API when dropped back onto its own parent', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {nodeApiUrl: 'https://osf.io/to/'}, 5, undefined);
+ var from = makeItem('file', {name: 'a.txt', size: 10}, 3, 5);
+ ajaxStub = sinon.stub($, 'ajax');
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(ajaxStub.called, false);
+ });
+
+ it('rejects the move without calling the API when a file exceeds the destination max size', () => {
+ var tb = makeTb();
+ // Different providers: shouldSkipSizeCheck only shortcuts a same-provider move,
+ // so this keeps the client-side oversized check from being bypassed.
+ var to = makeItem('folder', {provider: 's3compat', nodeApiUrl: 'https://osf.io/to/', accept: {maxSize: 1}}, 5, 1);
+ var from = makeItem('file', {name: 'big.txt', size: 5000000}, 3, 1);
+ ajaxStub = sinon.stub($, 'ajax');
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(ajaxStub.called, false);
+ assert.equal(growlStub.called, true);
+ assert.equal(from.inProgress, false);
+ });
+
+ it('rejects the copy without calling the API when it would exceed quota', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {nodeApiUrl: 'https://osf.io/to/'}, 5, 1);
+ var from = makeItem('file', {name: 'a.txt', size: 100}, 3, 1);
+ stubAjax(function(opts) {
+ if (opts.method === 'GET') {
+ return {responseJSON: {used: 950, max: 1000, user_guid: 'u1', storage_type: 'NII_STORAGE'}};
+ }
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.COPY, to, from, undefined, undefined);
+ assert.equal(ajaxStub.callCount, 1);
+ assert.equal(from.notify.update.called, true);
+ assert.equal(from.inProgress, false);
+ });
+
+ it('skips the quota check and proceeds when source and destination share the same UserQuota record', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {nodeApiUrl: 'https://osf.io/to/', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {nodeApiUrl: 'https://osf.io/from/', name: 'a.txt', size: 999999, path: '/a.txt'}, 3, 1);
+ var sameQuota = {used: 10, max: 1000000000, user_guid: 'u1', storage_type: 'NII_STORAGE'};
+ stubAjax(function(opts) {
+ if (opts.method === 'GET') {
+ return {responseJSON: sameQuota};
+ }
+ if (opts.type === 'POST') {
+ return fakeDeferred({status: 'done', args: [
+ {name: 'a.txt', provider: 'osfstorage', path: '/dest/a.txt', kind: 'file', nodeUrl: 'https://osf.io/to/'},
+ 'success',
+ {status: 200}
+ ]});
+ }
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ // dest quota GET + src quota GET (different node) + the real POST
+ assert.equal(ajaxStub.callCount, 3);
+ assert.equal(tb.uploadStates.length, 1);
+ assert.equal(tb.uploadStates[0].success, true);
+ });
+
+ it('shows a quota usage alert on success once the destination crosses the warning threshold', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {nodeApiUrl: 'https://osf.io/to/', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {name: 'a.txt', size: 150}, 3, 1);
+ stubAjax(function(opts) {
+ if (opts.method === 'GET') {
+ // used(800) + totalSize(150) = 950 <= max(1000) -> not rejected,
+ // but 950 > max * threshold(0.9) = 900 -> alert should fire.
+ return {responseJSON: {used: 800, max: 1000, user_guid: 'u1', storage_type: 'NII_STORAGE'}};
+ }
+ if (opts.type === 'POST') {
+ return fakeDeferred({status: 'done', args: [
+ {name: 'a.txt', provider: 'osfstorage', path: '/dest/a.txt', kind: 'file', nodeUrl: 'https://osf.io/to/'},
+ 'success',
+ {status: 200}
+ ]});
+ }
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.COPY, to, from, undefined, undefined);
+ assert.equal(growlCallWithType('warning'), true);
+ });
+
+ it('creates a new item via tb.createItem for a COPY and posts a copy action', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', nodeApiUrl: 'https://osf.io/to/', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ var postedData;
+ stubAjax(function(opts) {
+ postedData = JSON.parse(opts.data);
+ return fakeDeferred({status: 'done', args: [
+ {name: 'a.txt', provider: 's3compat', path: '/dest/a.txt', kind: 'file', nodeUrl: 'https://osf.io/to/'},
+ 'success',
+ {status: 200}
+ ]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.COPY, to, from, undefined, undefined);
+ assert.equal(tb.createItem.called, true);
+ assert.equal(postedData.action, 'copy');
+ });
+
+ it('posts a rename action for a RENAME operation', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', path: '/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'old.txt'}, 3, 9);
+ var postedData;
+ stubAjax(function(opts) {
+ postedData = JSON.parse(opts.data);
+ return fakeDeferred({status: 'done', args: [
+ {name: 'new.txt', provider: 's3compat', path: '/new.txt', kind: 'file', nodeUrl: 'https://osf.io/proj1/'},
+ 'success',
+ {status: 200}
+ ]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.RENAME, to, from, 'new.txt', undefined);
+ assert.equal(postedData.action, 'rename');
+ assert.equal(postedData.rename, 'new.txt');
+ });
+
+ it('shows a pending growl and stops when the API responds 202', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ stubAjax(function() {
+ return fakeDeferred({status: 'done', args: [{}, 'success', {status: 202}]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(growlCallWithType('info'), true);
+ assert.equal(tb.uploadStates.length, 0);
+ });
+
+ it('shows an oversized-file growl and records a failure when the API rejects with 413', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ stubAjax(function() {
+ return fakeDeferred({status: 'fail', args: [
+ {status: 413, responseJSON: {oversized_files: [{name: 'big.txt', size: 5000000}], max_size: 1000000}},
+ 'error'
+ ]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(growlStub.called, true);
+ assert.equal(tb.uploadStates.length, 1);
+ assert.equal(tb.uploadStates[0].success, false);
+ });
+
+ it('shows the mapped MESSAGE_MAP text when the API rejects with 406 and a known message_key', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ stubAjax(function() {
+ return fakeDeferred({status: 'fail', args: [
+ {status: 406, responseJSON: {message: 'raw message', message_key: 'quota_exceeded'}},
+ 'error'
+ ]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(growlStub.called, true);
+ assert.equal(tb.uploadStates[0].success, false);
+ });
+
+ it('reports to Raven and shows a fallback message when the API fails with a generic 500', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ stubAjax(function() {
+ return fakeDeferred({status: 'fail', args: [{status: 500}, 'error']});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(ravenStub.called, true);
+ assert.equal(growlStub.called, true);
+ });
+
+ // The cases below close the gaps found in the coverage report
+ // (RDM-osf.io/coverage/html/js/fangorn.js.html) for _findOversizedFiles
+ // and doItemOp. See investigation/2026-09-03-fangorn-coverage-findoversizedfiles-doitemop.md.
+
+ it('rejects a folder move and reports oversized files found inside it', () => {
+ var tb = makeTb();
+ // Different providers: shouldSkipSizeCheck only shortcuts a same-provider move.
+ var to = makeItem('folder', {provider: 's3compat', nodeApiUrl: 'https://osf.io/to/', accept: {maxSize: 1}}, 5, 1);
+ var from = makeItem('folder', {name: 'myFolder'}, 3, 1);
+ var smallChild = makeItem('file', {name: 'small.txt', size: 100}, 10, 3);
+ var bigChild = makeItem('file', {name: 'big.txt', size: 5000000}, 11, 3);
+ from.children = [smallChild, bigChild];
+ ajaxStub = sinon.stub($, 'ajax');
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(ajaxStub.called, false);
+ assert.equal(growlStub.called, true);
+ assert.equal(from.inProgress, false);
+ });
+
+ it('sums child file sizes when moving a folder and rejects if quota would be exceeded', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {nodeApiUrl: 'https://osf.io/to/'}, 5, 1);
+ var from = makeItem('folder', {name: 'myFolder'}, 3, 1);
+ var child = makeItem('file', {name: 'a.txt', size: 200}, 10, 3);
+ from.children = [child];
+ stubAjax(function(opts) {
+ if (opts.method === 'GET') {
+ return {responseJSON: {used: 900, max: 1000, user_guid: 'u1', storage_type: 'NII_STORAGE'}};
+ }
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.COPY, to, from, undefined, undefined);
+ assert.equal(ajaxStub.callCount, 1);
+ assert.equal(from.notify.update.called, true);
+ assert.equal(from.inProgress, false);
+ });
+
+ // _rejectConflictItem isn't exported, so these two exercise it through its only
+ // real entry points (the oversized-file and quota pre-flight checks in doItemOp)
+ // to cover the object-identity pendingReadyFiles filter and its doSyncMove advance.
+ it('keeps other pending files queued when a pre-flight reject leaves the sync queue non-empty', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', nodeApiUrl: 'https://osf.io/to/', accept: {maxSize: 1}}, 5, 1);
+ var from = makeItem('file', {name: 'big.txt', size: 5000000}, 3, 1);
+ var other = makeItem('file', {name: 'other.txt'}, 77, 1);
+ tb.pendingReadyFiles = [from, other];
+ ajaxStub = sinon.stub($, 'ajax');
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, 'replace');
+ // Filtered by object identity, not id: "other" survives untouched.
+ assert.deepEqual(tb.pendingReadyFiles, [other]);
+ assert.equal(ajaxStub.called, false);
+ // Queue still has "other" pending, so doSyncMove must not have run yet.
+ assert.equal(tb.modal.dismiss.callCount, 1);
+ assert.equal(tb.modal.update.called, false);
+ });
+
+ it('advances the sync move queue via doSyncMove once a pre-flight reject empties pendingReadyFiles', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', nodeApiUrl: 'https://osf.io/to/', accept: {maxSize: 1}}, 5, 1);
+ var from = makeItem('file', {name: 'big.txt', size: 5000000}, 3, 1);
+ tb.pendingReadyFiles = [from];
+ ajaxStub = sinon.stub($, 'ajax');
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, 'replace');
+ assert.equal(tb.pendingReadyFiles.length, 0);
+ // Emptying the queue must trigger doSyncMove -> displayMoveStats, which shows
+ // the move-status modal because addFileStatus already recorded the rejection
+ // (displayMoveStats resets tb.moveStates back to [] once it builds the modal).
+ assert.equal(tb.modal.update.called, true);
+ });
+
+ it('shows a "conflicts left to resolve" modal when the sync queue has pending conflicts', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ tb.syncFileMoveCache.s3compat = {conflicts: [{item: from, folder: to}], ready: []};
+ stubAjax(function() {
+ return fakeDeferred({status: 'done', args: [
+ {name: 'a.txt', provider: 's3compat', path: '/dest/a.txt', kind: 'file', nodeUrl: 'https://osf.io/proj1/'},
+ 'success',
+ {status: 200}
+ ]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(tb.modal.update.called, true);
+ });
+
+ it('preserves the branch and checks the sync-ready queue when moving a github item', () => {
+ var tb = makeTb();
+ // github isn't in the default syncFileMoveCache fixture; add it so the
+ // notRenameOp conflicts-check doesn't throw on an undefined entry.
+ tb.syncFileMoveCache.github = {conflicts: [], ready: []};
+ var to = makeItem('folder', {provider: 'github', path: '/'}, 5, 1);
+ var from = makeItem('file', {provider: 'github', name: 'a.txt', branch: 'main'}, 3, 1);
+ var postedData;
+ stubAjax(function(opts) {
+ postedData = JSON.parse(opts.data);
+ return fakeDeferred({status: 'done', args: [
+ {name: 'a.txt', provider: 'github', path: '/a.txt', kind: 'file', nodeUrl: 'https://osf.io/proj1/'},
+ 'success',
+ {status: 200}
+ ]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(postedData.branch, 'main');
+ });
+
+ it('removes the existing item at destination when the server responds 200 with a name conflict', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', nodeApiUrl: 'https://osf.io/to/', path: '/dest/'}, 5, 1);
+ var existingChild = makeItem('file', {name: 'a.txt', provider: 's3compat'}, 99, 5);
+ to.children = [existingChild];
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ stubAjax(function() {
+ return fakeDeferred({status: 'done', args: [
+ {name: 'a.txt', provider: 's3compat', path: '/dest/a.txt', kind: 'file', nodeUrl: 'https://osf.io/to/'},
+ 'success',
+ {status: 200}
+ ]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(existingChild.removeSelf.called, true);
+ });
+
+ it('rebuilds the child subtree when the moved item is a folder with children in the response', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', nodeApiUrl: 'https://osf.io/to/', path: '/dest/'}, 5, 1);
+ var from = makeItem('folder', {provider: 's3compat', name: 'myFolder'}, 3, 1);
+ stubAjax(function() {
+ return fakeDeferred({status: 'done', args: [
+ {
+ name: 'myFolder', provider: 's3compat', path: '/dest/myFolder/', kind: 'folder',
+ nodeUrl: 'https://osf.io/to/',
+ children: [{name: 'child.txt', kind: 'file', provider: 's3compat'}]
+ },
+ 'success',
+ {status: 200}
+ ]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.equal(tb.buildTree.called, true);
+ assert.equal(from.add.called, true);
+ assert.equal(from.open, true);
+ assert.equal(from.load, true);
+ });
+
+ it('removes the newly created item when a COPY operation fails', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ stubAjax(function() {
+ return fakeDeferred({status: 'fail', args: [{status: 500}, 'error']});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.COPY, to, from, undefined, undefined);
+ assert.equal(tb.createItem.called, true);
+ var createdItem = tb.createItem.returnValues[0];
+ assert.equal(createdItem.removeSelf.called, true);
+ });
+
+ it('shows the responseJSON message for a non-413/406/500/503 failure', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ stubAjax(function() {
+ return fakeDeferred({status: 'fail', args: [
+ {status: 400, responseJSON: {message: 'custom failure message'}},
+ 'error'
+ ]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ var calledWithMessage = growlStub.getCalls().some(function(call) { return call.args[1] === 'custom failure message'; });
+ assert.equal(calledWithMessage, true);
+ });
+
+ it('uses textStatus as the error message on a 503 response', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ stubAjax(function() {
+ return fakeDeferred({status: 'fail', args: [{status: 503}, 'Service Unavailable']});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ var calledWithMessage = growlStub.getCalls().some(function(call) { return call.args[1] === 'Service Unavailable'; });
+ assert.equal(calledWithMessage, true);
+ });
+
+ it('removes the settled item from pendingReadyFiles once the operation completes', () => {
+ var tb = makeTb();
+ var to = makeItem('folder', {provider: 's3compat', path: '/dest/'}, 5, 1);
+ var from = makeItem('file', {provider: 's3compat', name: 'a.txt', size: 10}, 3, 1);
+ var other = makeItem('file', {name: 'other.txt'}, 77, 1);
+ tb.pendingReadyFiles = [from, other];
+ stubAjax(function() {
+ return fakeDeferred({status: 'done', args: [
+ {name: 'a.txt', provider: 's3compat', path: '/dest/a.txt', kind: 'file', nodeUrl: 'https://osf.io/proj1/'},
+ 'success',
+ {status: 200}
+ ]});
+ });
+ Fangorn.doItemOp.call(tb, Fangorn.OPERATIONS.MOVE, to, from, undefined, undefined);
+ assert.deepEqual(tb.pendingReadyFiles, [other]);
+ });
});
describe('FGToolbar IME Keydown Handling', function() {
diff --git a/website/templates/emails/file_operation_failed.html.mako b/website/templates/emails/file_operation_failed.html.mako
index 2c72fed9b16..e7ab9105d15 100644
--- a/website/templates/emails/file_operation_failed.html.mako
+++ b/website/templates/emails/file_operation_failed.html.mako
@@ -59,8 +59,30 @@
- An error has occurred, and the ${'folder' if source_path.endswith('/') else 'file'} from ${source_node.title} on the GakuNin RDM was not successfully ${'moved' if action == 'move' else 'copied'}.
- Please log in and try this action again. If the problem persists, please email ${osf_support_email}.
+ <%
+ ei = context.get('error_info') or {}
+ err_type = ei.get('type', '')
+ %>
+ % if err_type == 'quota_exceeded':
+ An error has occurred, and the ${'folder' if source_path.endswith('/') else 'file'} from ${source_node.title} on the GakuNin RDM was not successfully ${'moved' if action == 'move' else 'copied'} because you do not have enough available storage quota.
+ Please free up storage space or contact your administrator to increase your quota, then try again. If the problem persists, please email ${osf_support_email}.
+ % elif err_type == 'oversized':
+ <%
+ oversized_files = ei.get('oversized_files', [])
+ max_size_bytes = ei.get('max_size') or 0
+ max_size_mb = max_size_bytes // (1024 * 1024) if max_size_bytes else 0
+ %>
+ An error has occurred, and the ${'folder' if source_path.endswith('/') else 'file'} from ${source_node.title} on the GakuNin RDM was not successfully ${'moved' if action == 'move' else 'copied'} because the following file(s) exceed the maximum allowed file size${' of {} MB'.format(max_size_mb) if max_size_mb else ''}:
+
+ Please reduce the file size and try again. If the problem persists, please email ${osf_support_email}.
+ % else:
+ An error has occurred, and the ${'folder' if source_path.endswith('/') else 'file'} from ${source_node.title} on the GakuNin RDM was not successfully ${'moved' if action == 'move' else 'copied'}.
+ Please log in and try this action again. If the problem persists, please email ${osf_support_email}.
+ % endif
diff --git a/website/translations/en/LC_MESSAGES/js_messages.po b/website/translations/en/LC_MESSAGES/js_messages.po
index 7b54523cf44..32f8b61b92a 100644
--- a/website/translations/en/LC_MESSAGES/js_messages.po
+++ b/website/translations/en/LC_MESSAGES/js_messages.po
@@ -9475,3 +9475,15 @@ msgstr ""
"GakuNin RDM is not responsible for the service or for your use "
"thereof.\n"
"\n"
+
+msgid "File「%1$s」is too large (%2$s). Max file size is %3$s."
+msgstr ""
+
+msgid "You do not have enough available quota."
+msgstr ""
+
+msgid "Not enough quota to move/copy the file."
+msgstr ""
+
+msgid "Not enough quota to move/copy. The total size of the folder %1$s."
+msgstr ""
diff --git a/website/translations/ja/LC_MESSAGES/js_messages.po b/website/translations/ja/LC_MESSAGES/js_messages.po
index 8b8775c7c4f..18651932072 100644
--- a/website/translations/ja/LC_MESSAGES/js_messages.po
+++ b/website/translations/ja/LC_MESSAGES/js_messages.po
@@ -11021,3 +11021,15 @@ msgstr ""
"RDMプロジェクトは外部サービスに接続されます。このサービスを利用することで、それら外部サービスの利用規約に拘束されます。GakuNin "
"RDMは、それらサービスまたはユーザーによるその利用に対して責任を負いません。\n"
"\n"
+
+msgid "File「%1$s」is too large (%2$s). Max file size is %3$s."
+msgstr "ファイル「%1$s」は大きすぎます (%2$s)。最大ファイルサイズは %3$s です。"
+
+msgid "You do not have enough available quota."
+msgstr "利用可能なクォータが不足しています。"
+
+msgid "Not enough quota to move/copy the file."
+msgstr "ファイルを移動/コピーするのに十分なクォータがありません。"
+
+msgid "Not enough quota to move/copy. The total size of the folder %1$s."
+msgstr "移動/コピーするための空き容量が足りません。フォルダのサイズは%1$sです。"
diff --git a/website/translations/js_messages.pot b/website/translations/js_messages.pot
index f9bba444c1c..bd6c80d03c1 100644
--- a/website/translations/js_messages.pot
+++ b/website/translations/js_messages.pot
@@ -9314,3 +9314,15 @@ msgid ""
"${user} made bibliographic group ${mapcore_groups} a "
"non-bibliographic group on ${node}"
msgstr ""
+
+msgid "File「%1$s」is too large (%2$s). Max file size is %3$s."
+msgstr ""
+
+msgid "You do not have enough available quota."
+msgstr ""
+
+msgid "Not enough quota to move/copy the file."
+msgstr ""
+
+msgid "Not enough quota to move/copy. The total size of the folder %1$s."
+msgstr ""
diff --git a/website/util/quota.py b/website/util/quota.py
index 1990073a444..5c7ec557833 100644
--- a/website/util/quota.py
+++ b/website/util/quota.py
@@ -13,6 +13,7 @@
ProjectStorageType
)
from django.utils import timezone
+from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota
from osf.utils.requests import check_select_for_update
@@ -72,6 +73,7 @@ def update_user_used_quota(user, storage_type=UserQuota.NII_STORAGE, is_recalcul
# Get total file size of projects with specified storage_type
used = used_quota(user._id, storage_type)
+ max_quota = get_default_max_quota(user, storage_type)
try:
if check_select_for_update():
user_quota = UserQuota.objects.filter(
@@ -91,10 +93,11 @@ def update_user_used_quota(user, storage_type=UserQuota.NII_STORAGE, is_recalcul
UserQuota.objects.create(
user=user,
storage_type=storage_type,
- max_quota=api_settings.DEFAULT_MAX_QUOTA,
+ max_quota=max_quota,
used=used,
)
- except IntegrityError:
+ except IntegrityError as e:
+ logger.warning(u'IntegrityError while creating UserQuota: user={}, storage_type={}: {}'.format(user.id, storage_type, str(e)))
if is_recalculating_quota and storage_type == UserQuota.CUSTOM_STORAGE:
used_quota_for_nii_default_storage = used_quota(user._id, UserQuota.NII_STORAGE)
used_quota_for_nii_custom_storage = used_quota(user._id, UserQuota.CUSTOM_STORAGE)
@@ -128,11 +131,12 @@ def abbreviate_size(size):
return (size, abbr_dict[power])
def get_quota_info(user, storage_type=UserQuota.NII_STORAGE):
+ max_quota = get_default_max_quota(user, storage_type)
try:
user_quota = user.userquota_set.get(storage_type=storage_type)
return (user_quota.max_quota, user_quota.used)
except UserQuota.DoesNotExist:
- return (api_settings.DEFAULT_MAX_QUOTA, used_quota(user._id, storage_type))
+ return (max_quota, used_quota(user._id, storage_type))
def get_project_storage_type(node):
try:
@@ -142,6 +146,17 @@ def get_project_storage_type(node):
@file_signals.file_updated.connect
def update_used_quota(self, target, user, event_type, payload):
+ # FILE_MOVED and FILE_COPIED carry 'source'/'destination', not 'metadata'.
+ # Handle them first before the metadata_provider guard below.
+ if event_type in (FileLog.FILE_MOVED, FileLog.FILE_COPIED):
+ dest_provider = payload.get('destination', {}).get('provider')
+ if dest_provider == 'osfstorage':
+ return # Hooks already handled (create_child), SKIP double counting
+ src_provider = payload.get('source', {}).get('provider')
+ if src_provider == 'osfstorage' or src_provider in PROVIDERS:
+ _handle_move_copy(event_type, target, user, payload)
+ return
+
data = dict(payload.get('metadata')) if payload.get('metadata') else None
metadata_provider = data.get('provider') if payload.get('metadata') else None
if metadata_provider == 'osfstorage' or metadata_provider in PROVIDERS:
@@ -180,6 +195,9 @@ def update_used_quota(self, target, user, event_type, payload):
storage_type = get_project_storage_type(target)
if event_type == FileLog.FILE_ADDED:
+ metadata_provider = (payload.get('metadata') or {}).get('provider')
+ if metadata_provider == 'osfstorage':
+ return
file_added(target, payload, file_node, storage_type)
elif event_type == FileLog.FILE_REMOVED:
if metadata_provider in PROVIDERS:
@@ -214,7 +232,7 @@ def update_used_quota(self, target, user, event_type, payload):
else:
node_removed(target, user, payload, file_node, storage_type)
elif event_type == FileLog.FILE_UPDATED:
- file_modified(target, user, payload, file_node, storage_type)
+ file_modified(target, payload, file_node, storage_type)
else:
return
@@ -223,97 +241,53 @@ def file_added(target, payload, file_node, storage_type):
file_size = int(payload['metadata']['size'])
if file_size < 0:
return
- try:
- if check_select_for_update():
- user_quota = UserQuota.objects.filter(
- user=target.creator,
- storage_type=storage_type
- ).select_for_update().get()
- else:
- user_quota = UserQuota.objects.get(
- user=target.creator,
- storage_type=storage_type
- )
- user_quota.used += file_size
- user_quota.save()
- except UserQuota.DoesNotExist:
+ update_quota(target, file_size, storage_type, add=True)
+ FileInfo.objects.create(file=file_node, file_size=file_size)
+
+def node_removed(target, user, payload, file_node, storage_type):
+ if 'osf.trashed' not in file_node.type:
+ logging.error('FileNode is not trashed, cannot update used quota!')
+ return
+
+ total_removed = 0
+ for removed_file in get_node_file_list(file_node):
try:
- with transaction.atomic():
- UserQuota.objects.create(
- user=target.creator,
- storage_type=storage_type,
- max_quota=api_settings.DEFAULT_MAX_QUOTA,
- used=file_size
- )
- except IntegrityError:
if check_select_for_update():
- user_quota = UserQuota.objects.filter(
- user=target.creator,
- storage_type=storage_type
- ).select_for_update().get()
+ file_info = FileInfo.objects.filter(file=removed_file).select_for_update().get()
else:
- user_quota = UserQuota.objects.get(
- user=target.creator,
- storage_type=storage_type
- )
- user_quota.used += file_size
- user_quota.save()
-
- FileInfo.objects.create(file=file_node, file_size=file_size)
+ file_info = FileInfo.objects.get(file=removed_file)
+ except FileInfo.DoesNotExist:
+ logging.error('FileInfo not found, cannot update used quota!')
+ continue
-def node_removed(target, user, payload, file_node, storage_type):
- if check_select_for_update():
- user_quota = UserQuota.objects.filter(
- user=target.creator,
- storage_type=storage_type
- ).select_for_update().first()
- else:
- user_quota = UserQuota.objects.filter(
- user=target.creator,
- storage_type=storage_type
- ).first()
- if user_quota is not None:
- if 'osf.trashed' not in file_node.type:
- logging.error('FileNode is not trashed, cannot update used quota!')
- return
+ total_removed += file_info.file_size
+ file_info.file_size = 0
+ file_info.save()
- for removed_file in get_node_file_list(file_node):
- try:
- if check_select_for_update():
- file_info = FileInfo.objects.filter(file=removed_file).select_for_update().get()
- else:
- file_info = FileInfo.objects.get(file=removed_file)
- except FileInfo.DoesNotExist:
- logging.error('FileInfo not found, cannot update used quota!')
- continue
-
- user_quota.used -= file_info.file_size
- if user_quota.used < 0:
- user_quota.used = 0
- file_info.file_size = 0
- file_info.save()
- user_quota.save()
+ if total_removed > 0:
+ update_quota(target, total_removed, storage_type, add=False)
-def file_modified(target, user, payload, file_node, storage_type):
+def file_modified(target, payload, file_node, storage_type):
file_size = int(payload['metadata']['size'])
if file_size < 0:
return
-
+ max_quota = get_default_max_quota(target.creator, storage_type)
try:
with transaction.atomic():
if check_select_for_update():
user_quota, _ = UserQuota.objects.select_for_update().get_or_create(
user=target.creator,
storage_type=storage_type,
- defaults={'max_quota': api_settings.DEFAULT_MAX_QUOTA}
+ defaults={'max_quota': max_quota}
)
else:
user_quota, _ = UserQuota.objects.get_or_create(
user=target.creator,
storage_type=storage_type,
- defaults={'max_quota': api_settings.DEFAULT_MAX_QUOTA}
+ defaults={'max_quota': max_quota}
)
- except IntegrityError:
+ except IntegrityError as e:
+ logger.warning(u'IntegrityError while creating UserQuota in file_modified: user={}, storage_type={}: {}'.format(target.creator.id, storage_type, str(e)))
if check_select_for_update():
user_quota = UserQuota.objects.filter(user=target.creator, storage_type=storage_type).select_for_update().get()
else:
@@ -390,3 +364,154 @@ def get_node_file_list(file_node):
file_list.append(child_file_node)
return file_list
+
+def _handle_move_copy(event_type, target, user, payload):
+ """Resolve the destination file_node and dispatch to file_moved or file_copied.
+
+ For FILE_MOVED / FILE_COPIED events the relevant file information lives in
+ ``payload['destination']``, not in ``payload['metadata']``. Only extended-storage
+ providers (PROVIDERS) destinations are processed here.
+
+ Scenarios handled:
+ - osfstorage → extended storage (quota decreases for source node)
+ - same provider moves / copies (if it involves extended storage)
+
+ (Note: Moves TO osfstorage are handled entirely by OSF hooks, bypassing this flow).
+
+ :param str event_type: FileLog.FILE_MOVED or FileLog.FILE_COPIED
+ :param AbstractNode target: destination project node
+ :param OSFUser user: acting user
+ :param dict payload: full webhook payload
+ """
+
+ if event_type == FileLog.FILE_MOVED:
+ file_moved(target, payload)
+
+def file_moved(target, payload):
+ """Update per-user-per-storage used quota when moving file
+
+ :param Object target: Id of project
+ :param dict payload: The payload of the move operation
+ """
+ if isinstance(target, AbstractNode):
+ file_size = -1
+ dest_size = payload['destination'].get('size', None)
+ children = payload['destination'].get('children', None)
+ # Move file
+ if dest_size is not None:
+ file_size = int(dest_size)
+ # Move folder
+ elif children is not None:
+ file_size = get_file_size(payload['destination'])
+ if file_size < 0:
+ return
+
+ source_node_id = payload['source']['nid']
+ source_node = AbstractNode.objects.get(guids___id=source_node_id)
+ # Move from bulk-mount
+ if payload['source']['provider'] == 'osfstorage' \
+ and source_node.type != 'osf.quickfilesnode':
+ source_storage_type = get_project_storage_type(source_node)
+ update_quota(source_node, file_size, source_storage_type, add=False)
+
+def update_quota(target, file_size, storage_type, add=True):
+ """Add or subtract file_size from UserQuota.used for target.creator.
+
+ This is the single source of truth for UserQuota updates.
+ Both ``file_added`` and ``node_removed`` delegate to this function.
+
+ :param target: AbstractNode whose creator's quota is updated
+ :param int file_size: size in bytes to add or subtract
+ :param int storage_type: UserQuota.NII_STORAGE or CUSTOM_STORAGE
+ :param bool add: True to add (upload), False to subtract (delete)
+ """
+ try:
+ if check_select_for_update():
+ user_quota = UserQuota.objects.filter(
+ user=target.creator,
+ storage_type=storage_type
+ ).select_for_update().get()
+ else:
+ user_quota = UserQuota.objects.get(
+ user=target.creator,
+ storage_type=storage_type
+ )
+ if add:
+ user_quota.used += file_size
+ else:
+ user_quota.used -= file_size
+ if user_quota.used < 0:
+ user_quota.used = 0
+ user_quota.save()
+ except UserQuota.DoesNotExist:
+ if not add:
+ return
+ try:
+ with transaction.atomic():
+ UserQuota.objects.create(
+ user=target.creator,
+ storage_type=storage_type,
+ max_quota=api_settings.DEFAULT_MAX_QUOTA,
+ used=file_size
+ )
+ except IntegrityError:
+ if check_select_for_update():
+ user_quota = UserQuota.objects.filter(
+ user=target.creator,
+ storage_type=storage_type
+ ).select_for_update().get()
+ else:
+ user_quota = UserQuota.objects.get(
+ user=target.creator,
+ storage_type=storage_type
+ )
+ user_quota.used += file_size
+ user_quota.save()
+
+def get_file_size(children):
+ children = children.get('children', [])
+ size = 0
+ for child in children:
+ if child['kind'] == 'file':
+ try:
+ child_size = int(child['size'])
+ except (TypeError, ValueError):
+ child_size = None
+ if child_size is None or child_size < 0:
+ logger.warning(
+ 'child size is %r for %r; treating size as 0 for quota calculation',
+ child_size, child.get('name')
+ )
+ child_size = 0
+ size += int(child_size)
+ else:
+ size += get_file_size(child)
+ return size
+
+def get_default_max_quota(user, storage_type):
+ """
+ Get default max quota for a user based on their affiliated institution.
+
+ If the storage type is CUSTOM_STORAGE, retrieve the default quota
+ configured for the user's affiliated institution. Otherwise, or if no
+ institution-specific quota is configured, return the system default quota.
+ When the institution's region uses NII_STORAGE, always return the system default quota.
+
+ Args:
+ user (OSFUser): The user whose default quota is to be retrieved.
+ storage_type (str): The storage type associated with the user's quota.
+
+ Returns:
+ int: The default maximum quota (in GB) applicable to the user.
+ """
+ if storage_type == UserQuota.CUSTOM_STORAGE:
+ institution = user.representative_affiliated_institution
+ if institution and not Region.objects.filter(
+ _id=institution._id,
+ waterbutler_settings__storage__type=Region.NII_STORAGE
+ ).exists():
+ default_max_quota = InstitutionDefaultMaxQuota.get_quota_by_user(user.id)
+ if default_max_quota is not None:
+ return default_max_quota
+
+ return api_settings.DEFAULT_MAX_QUOTA