Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ updates:
target-branch: "dev"
# Labels on pull requests for version updates only
labels:
- "pip dependencies"
- "pip dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
target-branch: "dev"
44 changes: 22 additions & 22 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
base32_crockford==0.3.0
bleach==6.4.0
boto3
Django==5.2.17
django-cors-headers==4.9.0
django-prometheus==2.4.1
djangorestframework==3.18.1
django-ses==4.8.0
elasticsearch==7.10.1
elasticsearch_dsl==7.4.1
geonamescache==1.3.0
requests==2.32.4
requests-aws4auth==0.9
iso639-lang
jsonschema==3.2.0
launchdarkly-server-sdk==7.6.1
mock==3.0.5
base32_crockford==0.3.0
elasticsearch==7.10.1
djangorestframework==3.16.1
setuptools>=40,<81
django-prometheus==2.4.1
mysqlclient==2.2.7
numpy==1.26.4
pandas==2.2.3
pycountry==22.3.5
python-dotenv==1.2.3
python-magic
rapidfuzz==3.6.1
requests==2.33.0
requests-aws4auth==0.9
sentry-sdk==1.45.1
python-dotenv==0.10.3
django-cors-headers==4.9.0
unidecode==1.1.1
setuptools>=40,<81
statsmodels==0.14.4
boto3
pandas==2.2.3
numpy==1.26.4
titlecase==2.3
unidecode==1.1.1
update_address @ git+https://github.com/ror-community/update_address.git
launchdarkly-server-sdk==7.6.1
jsonschema==3.2.0
python-magic
iso639-lang
rapidfuzz==3.6.1
mysqlclient==2.2.7
bleach==6.0.0
pycountry==22.3.5
django-ses==4.8.0
19 changes: 16 additions & 3 deletions rorapi/common/create_update.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import copy
import functools
import json
import os
from datetime import datetime

from rorapi.common.record_utils import *
import update_address as ua
from rorapi.v2.record_constants import *
Expand All @@ -8,7 +12,16 @@
)
from rorapi.management.commands.generaterorid import check_ror_id

V2_SCHEMA = get_file_from_url("https://raw.githubusercontent.com/ror-community/ror-schema/refs/heads/master/ror_schema_v2_1.json")
VENDORED_SCHEMA_PATH = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "v2", "ror_schema_v2_1.json"
)


@functools.cache
def get_v2_schema():
"""Load the vendored v2.1 schema on first write; cache in-process."""
with open(VENDORED_SCHEMA_PATH) as f:
return json.load(f)


def update_record(json_input, existing_record):
Expand Down Expand Up @@ -79,7 +92,7 @@ def new_record_from_json(json_input, version):
new_ror_id = check_ror_id()
print("new ror id: " + new_ror_id)
new_record['id'] = new_ror_id
error, valid_data = validate_record(sort_list_fields(new_record), V2_SCHEMA)
error, valid_data = validate_record(sort_list_fields(new_record), get_v2_schema())
return error, valid_data


Expand All @@ -92,5 +105,5 @@ def update_record_from_json(new_json, existing_org):
error, updated_locations = update_locations(updated_record['locations'])
if not error:
updated_record['locations'] = updated_locations
error, valid_data = validate_record(sort_list_fields(updated_record), V2_SCHEMA)
error, valid_data = validate_record(sort_list_fields(updated_record), get_v2_schema())
return error, valid_data
7 changes: 6 additions & 1 deletion rorapi/common/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from titlecase import titlecase
from collections import defaultdict

from elasticsearch.exceptions import RequestError

from rorapi.common.models import Errors
from rorapi.common.matching import match_affiliation
from rorapi.v2.models import (
Expand Down Expand Up @@ -270,7 +272,10 @@ def search_organizations(params):
if error is not None:
return error, None
search = build_search_query(params)
return None, ListResultV2(search.execute())
try:
return None, ListResultV2(search.execute())
except RequestError as e:
return Errors([str(e)]), None


def retrieve_organization(ror_id):
Expand Down
28 changes: 17 additions & 11 deletions rorapi/common/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

from rorapi.common.queries import search_organizations, retrieve_organization, get_ror_id
from urllib.parse import urlencode
import hmac
import os
import update_address as ua
from rorapi.management.commands.generaterorid import check_ror_id
Expand Down Expand Up @@ -122,18 +123,23 @@ class OurTokenPermission(BasePermission):
"""

def has_permission(self, request, view):
has_permission = False
if request.method == 'GET':
has_permission = True
else:
header_token = request.headers.get("Token", None)
header_user = request.headers.get("Route-User", None)
user = os.environ.get("ROUTE_USER")
token = os.environ.get("TOKEN")
if header_token == token and header_user == user:
has_permission = True

return has_permission
return True

header_token = request.headers.get("Token")
header_user = request.headers.get("Route-User")
user = os.environ.get("ROUTE_USER")
token = os.environ.get("TOKEN")

# Deny when env credentials are unset/empty or either header is missing.
# Avoid == on None (previously None==None granted write access).
if not token or not user or not header_token or not header_user:
return False

return (
hmac.compare_digest(header_token, token)
and hmac.compare_digest(header_user, user)
)


class OrganizationViewSet(viewsets.ViewSet):
Expand Down
39 changes: 39 additions & 0 deletions rorapi/tests/tests_unit/tests_lazy_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import importlib
import json
import os

from django.test import SimpleTestCase

from rorapi.common import create_update


class LazySchemaTests(SimpleTestCase):
def setUp(self):
create_update.get_v2_schema.cache_clear()

def tearDown(self):
create_update.get_v2_schema.cache_clear()

def test_import_does_not_load_schema(self):
importlib.reload(create_update)
self.assertEqual(create_update.get_v2_schema.cache_info().hits, 0)
self.assertEqual(create_update.get_v2_schema.cache_info().misses, 0)

def test_loads_vendored_schema_on_first_use(self):
with open(create_update.VENDORED_SCHEMA_PATH) as f:
expected = json.load(f)
schema = create_update.get_v2_schema()
self.assertEqual(schema, expected)
self.assertEqual(schema["$id"], "http://ror.org/schemas/v2.0/organization")
# Second call uses the in-process cache (same object).
self.assertIs(create_update.get_v2_schema(), schema)
info = create_update.get_v2_schema.cache_info()
self.assertEqual(info.hits, 1)
self.assertEqual(info.misses, 1)

def test_vendored_schema_file_exists(self):
self.assertTrue(os.path.isfile(create_update.VENDORED_SCHEMA_PATH))
with open(create_update.VENDORED_SCHEMA_PATH) as f:
schema = json.load(f)
self.assertIn("required", schema)
self.assertIn("properties", schema)
16 changes: 16 additions & 0 deletions rorapi/tests/tests_unit/tests_queries_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,22 @@ def test_malformed_search_organizations(self, search_mock):
search_mock.assert_not_called()
self.assertEqual(len(error.errors), 6)

@mock.patch('elasticsearch_dsl.Search.execute')
def test_search_organizations_request_error(self, search_mock):
from elasticsearch.exceptions import RequestError

search_mock.side_effect = RequestError(
400,
'parsing_exception',
{'error': {'type': 'parsing_exception', 'reason': 'bad query'}},
)

error, organizations = search_organizations({'query': 'query'})
self.assertIsNone(organizations)
search_mock.assert_called_once()
self.assertEqual(len(error.errors), 1)
self.assertTrue('parsing_exception' in error.errors[0])


class RetrieveOrganizationsTestCase(SimpleTestCase):

Expand Down
99 changes: 99 additions & 0 deletions rorapi/tests/tests_unit/tests_token_permission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import os
from unittest import mock

from django.test import SimpleTestCase
from rest_framework.test import APIRequestFactory

from rorapi.common.views import OurTokenPermission

factory = APIRequestFactory()


class OurTokenPermissionTestCase(SimpleTestCase):
def setUp(self):
self.permission = OurTokenPermission()

def test_get_always_allowed(self):
request = factory.get('/v2/organizations')
self.assertTrue(self.permission.has_permission(request, None))

@mock.patch.dict(os.environ, {}, clear=True)
def test_post_denied_when_env_unset(self):
request = factory.post(
'/v2/organizations',
HTTP_TOKEN='token-value',
HTTP_ROUTE_USER='route-user',
)
self.assertFalse(self.permission.has_permission(request, None))

@mock.patch.dict(
os.environ,
{"TOKEN": "token-value", "ROUTE_USER": "route-user"},
clear=False,
)
def test_post_denied_when_headers_missing(self):
request = factory.post('/v2/organizations')
self.assertFalse(self.permission.has_permission(request, None))

@mock.patch.dict(
os.environ,
{"TOKEN": "token-value", "ROUTE_USER": "route-user"},
clear=False,
)
def test_post_denied_when_token_header_missing(self):
request = factory.post(
'/v2/organizations',
HTTP_ROUTE_USER='route-user',
)
self.assertFalse(self.permission.has_permission(request, None))

@mock.patch.dict(
os.environ,
{"TOKEN": "token-value", "ROUTE_USER": "route-user"},
clear=False,
)
def test_post_denied_when_route_user_header_missing(self):
request = factory.post(
'/v2/organizations',
HTTP_TOKEN='token-value',
)
self.assertFalse(self.permission.has_permission(request, None))

@mock.patch.dict(
os.environ,
{"TOKEN": "token-value", "ROUTE_USER": "route-user"},
clear=False,
)
def test_post_denied_on_mismatch(self):
request = factory.post(
'/v2/organizations',
HTTP_TOKEN='wrong-token',
HTTP_ROUTE_USER='route-user',
)
self.assertFalse(self.permission.has_permission(request, None))

request = factory.post(
'/v2/organizations',
HTTP_TOKEN='token-value',
HTTP_ROUTE_USER='wrong-user',
)
self.assertFalse(self.permission.has_permission(request, None))

@mock.patch.dict(
os.environ,
{"TOKEN": "token-value", "ROUTE_USER": "route-user"},
clear=False,
)
def test_post_allowed_on_match(self):
request = factory.post(
'/v2/organizations',
HTTP_TOKEN='token-value',
HTTP_ROUTE_USER='route-user',
)
self.assertTrue(self.permission.has_permission(request, None))

@mock.patch.dict(os.environ, {}, clear=True)
def test_post_denied_when_env_and_headers_both_missing(self):
"""Regression: None == None previously granted write access."""
request = factory.post('/v2/organizations')
self.assertFalse(self.permission.has_permission(request, None))
Loading
Loading