View
@@ -5,64 +5,8 @@
import unittest
import urllib
from pytest import raises
from pyramid import security
from h.api import models
class TestAnnotationPermissions(unittest.TestCase):
def test_principal(self):
annotation = models.Annotation()
annotation['permissions'] = {
'read': ['saoirse'],
}
actual = annotation.__acl__()
expect = [(security.Allow, 'saoirse', 'read')]
assert actual == expect
def test_admin_party(self):
annotation = models.Annotation()
actual = annotation.__acl__()
expect = [(security.Allow, security.Everyone, security.ALL_PERMISSIONS)]
assert actual == expect
def test_deny_system_role(self):
annotation = models.Annotation()
annotation['permissions'] = {
'read': [security.Everyone],
}
with raises(ValueError):
annotation.__acl__()
def test_group(self):
annotation = models.Annotation()
annotation['permissions'] = {
'read': ['group:lulapalooza'],
}
actual = annotation.__acl__()
expect = [(security.Allow, 'group:lulapalooza', 'read')]
assert actual == expect
def test_group_world(self):
annotation = models.Annotation()
annotation['permissions'] = {
'read': ['group:__world__'],
}
actual = annotation.__acl__()
expect = [(security.Allow, security.Everyone, 'read')]
assert actual == expect
def test_group_authenticated(self):
annotation = models.Annotation()
annotation['permissions'] = {
'read': ['group:__authenticated__'],
}
actual = annotation.__acl__()
expect = [(security.Allow, security.Authenticated, 'read')]
assert actual == expect
analysis = models.Annotation.__analysis__
View
@@ -1,12 +1,70 @@
import mock
from pytest import raises
from pyramid import security
from h.api import resources
class TestAnnotations(object):
class TestAnnotationPermissions(object):
def test__acl__when_request_has_no_json_body(self):
def test_principal(self):
resource = resources.Annotation()
resource.__name__ = 'foo'
annotation = resource.model
annotation['permissions'] = {
'read': ['saoirse'],
}
actual = resource.__acl__()
expect = [(security.Allow, 'saoirse', 'read')]
assert actual == expect
def test_deny_system_role(self):
resource = resources.Annotation()
resource.__name__ = 'foo'
annotation = resource.model
annotation['permissions'] = {
'read': [security.Everyone],
}
with raises(ValueError):
resource.__acl__()
def test_group(self):
resource = resources.Annotation()
resource.__name__ = 'foo'
annotation = resource.model
annotation['permissions'] = {
'read': ['group:lulapalooza'],
}
actual = resource.__acl__()
expect = [(security.Allow, 'group:lulapalooza', 'read')]
assert actual == expect
def test_group_world(self):
resource = resources.Annotation()
resource.__name__ = 'foo'
annotation = resource.model
annotation['permissions'] = {
'read': ['group:__world__'],
}
actual = resource.__acl__()
expect = [(security.Allow, security.Everyone, 'read')]
assert actual == expect
def test_group_authenticated(self):
resource = resources.Annotation()
resource.__name__ = 'foo'
annotation = resource.model
annotation['permissions'] = {
'read': ['group:__authenticated__'],
}
actual = resource.__acl__()
expect = [(security.Allow, security.Authenticated, 'read')]
assert actual == expect
class TestAnnotationsPermissions(object):
def test_when_request_has_no_json_body(self):
request = mock.Mock()
# Make request.json_body raise ValueError.
type(request).json_body = mock.PropertyMock(side_effect=ValueError)
@@ -15,7 +73,7 @@ def test__acl__when_request_has_no_json_body(self):
assert annotations.__acl__() == [
(security.Deny, security.Everyone, 'create')]
def test__acl__when_request_contains_no_group(self):
def test_when_request_contains_no_group(self):
request = mock.Mock()
request.json_body = {}
annotations = resources.Annotations(request)
@@ -24,7 +82,7 @@ def test__acl__when_request_contains_no_group(self):
(security.Allow, 'group:__world__', 'create'),
(security.Deny, security.Everyone, 'create')]
def test__acl__when_request_has_a_group(self):
def test_when_request_has_a_group(self):
request = mock.Mock()
request.json_body = {'group': 'xyzabc'}
annotations = resources.Annotations(request)
View
@@ -4,19 +4,18 @@
import mock
import pytest
from pyramid import testing
from pyramid import httpexceptions
from h import api
from h.api import views
def _mock_annotation(**kwargs):
"""Return a mock h.api.models.Annotation object."""
"""Return a mock h.api.resources.Annotation object."""
annotation = mock.MagicMock()
annotation.__getitem__.side_effect = kwargs.__getitem__
annotation.__setitem__.side_effect = kwargs.__setitem__
annotation.get.side_effect = kwargs.get
annotation.__contains__.side_effect = kwargs.__contains__
annotation.model = model = mock.MagicMock()
model.__getitem__.side_effect = kwargs.__getitem__
model.__setitem__.side_effect = kwargs.__setitem__
model.get.side_effect = kwargs.get
model.__contains__.side_effect = kwargs.__contains__
return annotation
@@ -313,7 +312,7 @@ def test_read_event(AnnotationEvent):
views.read(annotation, request)
AnnotationEvent.assert_called_once_with(request, annotation, 'read')
AnnotationEvent.assert_called_once_with(request, annotation.model, 'read')
request.registry.notify.assert_called_once_with(event)
@@ -324,7 +323,7 @@ def test_read_calls_render(search_lib):
views.read(context=annotation,
request=mock.Mock(effective_principals=[]))
search_lib.render.assert_called_once_with(annotation)
search_lib.render.assert_called_once_with(annotation.model)
@read_fixtures
@@ -382,7 +381,7 @@ def test_update_passes_annotation_to_update_annotation(logic):
views.update(annotation, mock.Mock())
assert logic.update_annotation.call_args[0][0] == annotation
assert logic.update_annotation.call_args[0][0] == annotation.model
@update_fixtures
@@ -419,7 +418,8 @@ def test_update_event(AnnotationEvent):
annotation = mock.Mock()
event = AnnotationEvent.return_value
views.update(annotation, request)
AnnotationEvent.assert_called_once_with(request, annotation, 'update')
AnnotationEvent.assert_called_once_with(request, annotation.model,
'update')
request.registry.notify.assert_called_once_with(event)
@@ -429,7 +429,7 @@ def test_update_calls_render(search_lib):
views.update(annotation, mock.Mock())
search_lib.render.assert_called_once_with(annotation)
search_lib.render.assert_called_once_with(annotation.model)
@update_fixtures
@@ -449,7 +449,7 @@ def test_delete_calls_delete_annotation(logic):
views.delete(annotation, request)
logic.delete_annotation.assert_called_once_with(annotation)
logic.delete_annotation.assert_called_once_with(annotation.model)
@delete_fixtures
@@ -460,7 +460,7 @@ def test_delete_event(AnnotationEvent):
views.delete(annotation, request)
AnnotationEvent.assert_called_once_with(request, annotation, 'delete')
AnnotationEvent.assert_called_once_with(request, annotation.model, 'delete')
request.registry.notify.assert_called_once_with(event)
@@ -471,7 +471,7 @@ def test_delete_returns_id():
response_data = views.delete(
annotation, mock.Mock(effective_principals=['group:test-group']))
assert response_data['id'] == annotation['id']
assert response_data['id'] == annotation.model['id']
@delete_fixtures
View
@@ -10,11 +10,11 @@
from h.api import cors
from h.api.auth import get_user
from h.api.events import AnnotationEvent
from h.api.models import Annotation
from h.api.resources import Root
from h.api.resources import Annotations
from h.api import search as search_lib
from h.api import logic
from h.api.resources import Annotation
from h.api.resources import Annotations
from h.api.resources import Root
log = logging.getLogger(__name__)
@@ -30,15 +30,21 @@
allow_methods=('HEAD', 'GET', 'POST', 'PUT', 'DELETE'))
def api_config(**kwargs):
"""Extend Pyramid's @view_config decorator with modified defaults."""
config = {
'accept': 'application/json',
'decorator': cors_policy,
'renderer': 'json',
}
config.update(kwargs)
return view_config(**config)
def api_config(**settings):
"""
A view configuration decorator with defaults.
JSON in and out. CORS with tokens and client id but no cookie.
"""
settings.setdefault('decorator', cors_policy)
return json_view(**settings)
def json_view(**settings):
"""A view configuration decorator with JSON defaults."""
settings.setdefault('accept', 'application/json')
settings.setdefault('renderer', 'json')
return view_config(**settings)
@api_config(context=Root)
@@ -93,7 +99,7 @@ def search(request):
)
@api_config(context=Root, name='access_token')
@api_config(route_name='access_token')
def access_token(request):
"""The OAuth 2 access token view."""
return request.create_token_response()
@@ -106,7 +112,7 @@ def access_token(request):
# currently accessible off-origin. Given that this method of authenticating to
# the API is not intended to remain, this seems like a limitation we do not
# need to lift any time soon.
@api_config(context=Root, name='token', renderer='string')
@api_config(route_name='token', renderer='string')
def annotator_token(request):
"""The Annotator Auth token view."""
request.grant_type = 'client_credentials'
@@ -156,23 +162,21 @@ def create(request):
return search_lib.render(annotation)
@api_config(containment=Root, context=Annotation,
request_method='GET', permission='read')
@api_config(context=Annotation, request_method='GET', permission='read')
def read(context, request):
"""Return the annotation (simply how it was stored in the database)."""
annotation = context
annotation = context.model
# Notify any subscribers
_publish_annotation_event(request, annotation, 'read')
return search_lib.render(annotation)
@api_config(containment=Root, context=Annotation,
request_method='PUT', permission='update')
@api_config(context=Annotation, request_method='PUT', permission='update')
def update(context, request):
"""Update the fields we received and store the updated version."""
annotation = context
annotation = context.model
# Read the new fields for the annotation
try:
@@ -183,7 +187,7 @@ def update(context, request):
status_code=400) # Client Error: Bad Request
# Check user's permissions
has_admin_permission = request.has_permission('admin', annotation)
has_admin_permission = request.has_permission('admin', context)
# Update and store the annotation
try:
@@ -201,11 +205,10 @@ def update(context, request):
return search_lib.render(annotation)
@api_config(containment=Root, context=Annotation,
request_method='DELETE', permission='delete')
@api_config(context=Annotation, request_method='DELETE', permission='delete')
def delete(context, request):
"""Delete the annotation permanently."""
annotation = context
annotation = context.model
logic.delete_annotation(annotation)
View
@@ -52,7 +52,7 @@ def build_extension_common(env, bundle_app=False):
if bundle_app:
app_uri = request.webassets_env.url + '/app.html'
else:
app_uri = request.resource_url(request.root, 'app.html')
app_uri = request.route_url('widget')
value = {'app_uri': app_uri}
data = render('h:templates/embed.js.jinja2', value, request=request)
fp.write(data)
View
@@ -1,72 +1,47 @@
# -*- coding: utf-8 -*-
from pyramid import security
from pyramid.security import Allow
from .api import create_root as create_api
from .models import Annotation
class Resource(dict):
"""
Resource is an entry in the traversal tree.
"""
__name__ = None
__parent__ = None
def add(self, name, obj):
"""
Adds obj as a child resource with the given name. Automatically sets
the __name__ and __parent__ properties of the child resource object.
"""
obj.__name__ = name
obj.__parent__ = self
self[name] = obj
from h.api import resources as api
from h.api.resources import Resource
class UserStreamFactory(Resource):
def __getitem__(self, key):
return Stream(stream_type='user', stream_key=key)
query = {'q': 'user:{}'.format(key)}
return Search(query=query)
class TagStreamFactory(Resource):
def __getitem__(self, key):
return Stream(stream_type='tag', stream_key=key)
query = {'q': 'tag:{}'.format(key)}
return Search(query=query)
class AnnotationFactory(Resource):
def __getitem__(self, key):
annotation = Annotation.fetch(key)
if annotation is None:
raise KeyError(key)
annotation.__name__ = key
annotation.__parent__ = self
return annotation
class Annotation(api.Annotation):
pass
class Application(Resource):
pass
class Annotations(api.Annotations):
def factory(self, key):
return Annotation(id=key)
class Stream(Resource):
pass
class Root(Resource):
__acl__ = [
(security.Allow, security.Authenticated, 'authenticated'),
(security.Allow, 'group:__admin__', 'admin'),
]
__acl__ = [(Allow, 'group:__admin__', 'admin')]
def create_root(request):
"""
Returns a new traversal tree root.
"""
r = Root()
r.add('api', create_api(request))
r.add('app', Application())
r.add('stream', Stream())
r.add('a', AnnotationFactory())
r.add('api', api.create_root(request))
r.add('a', Annotations(request))
r.add('t', TagStreamFactory())
r.add('u', UserStreamFactory())
return r
View
@@ -6,27 +6,27 @@
import unittest
import mock
import pyramid
from pyramid import testing
import pytest
from h import views
from . import factories
class TestAnnotationView(unittest.TestCase):
def test_og_document(self):
context = {'id': '123', 'user': 'foo'}
context['document'] = {'title': 'WikiHow — How to Make a ☆Starmap☆'}
annotation = {'id': '123', 'user': 'foo'}
annotation['document'] = {'title': 'WikiHow — How to Make a ☆Starmap☆'}
context = mock.MagicMock(model=annotation)
request = testing.DummyRequest()
result = views.annotation(context, request)
assert isinstance(result, dict)
test = lambda d: 'foo' in d['content'] and 'Starmap' in d['content']
assert any(test(d) for d in result['meta_attrs'])
def test_og_no_document(self):
context = {'id': '123', 'user': 'foo'}
annotation = {'id': '123', 'user': 'foo'}
context = mock.MagicMock(model=annotation)
request = testing.DummyRequest()
result = views.annotation(context, request)
assert isinstance(result, dict)
@@ -43,7 +43,7 @@ def test_blocklist(self):
blocklist = {"foo": "bar"}
request.registry.settings = {'h.blocklist': blocklist}
data = views.js({}, request)
data = views.embed({}, request)
assert data['blocklist'] == json.dumps(blocklist)
View
@@ -8,10 +8,10 @@
from pyramid.view import view_config
from pyramid import i18n
from . import session
from .models import Annotation
from .resources import Application, Stream
from . import util
from h import session
from h.api.views import json_view
from h.resources import Annotation
from h.resources import Stream
log = logging.getLogger(__name__)
@@ -28,7 +28,7 @@ def error(context, request):
return {}
@view_config(context=Exception, accept='application/json', renderer='json')
@json_view(context=Exception)
def json_error(context, request):
""""Return a JSON-formatted error message."""
log.exception('%s: %s', type(context).__name__, str(context))
@@ -47,16 +47,18 @@ def json_error(context, request):
renderer='h:templates/app.html.jinja2',
)
def annotation(context, request):
if 'title' in context.get('document', {}):
annotation = context.model
if 'title' in annotation.get('document', {}):
title = 'Annotation by {user} on {title}'.format(
user=context['user'].replace('acct:', ''),
title=context['document']['title'])
user=annotation['user'].replace('acct:', ''),
title=annotation['document']['title'])
else:
title = 'Annotation by {user}'.format(
user=context['user'].replace('acct:', ''))
user=annotation['user'].replace('acct:', ''))
alternate = request.resource_url(request.root, 'api', 'annotations',
context['id'])
annotation['id'])
return {
'meta_attrs': (
@@ -73,15 +75,15 @@ def annotation(context, request):
}
@view_config(name='embed.js', renderer='h:templates/embed.js.jinja2')
def js(context, request):
@view_config(route_name='embed', renderer='h:templates/embed.js.jinja2')
def embed(context, request):
request.response.content_type = b'text/javascript'
return {
'blocklist': json.dumps(request.registry.settings['h.blocklist'])
}
@view_config(name='app.html', renderer='h:templates/app.html.jinja2')
@view_config(route_name='widget', renderer='h:templates/app.html.jinja2')
def widget(context, request):
return {}
@@ -98,45 +100,29 @@ def help_page(context, request):
}
@view_config(accept='application/json', context=Application, http_cache=0,
renderer='json')
@json_view(route_name='session', http_cache=0)
def session_view(request):
flash = session.pop_flash(request)
model = session.model(request)
return dict(status='okay', flash=flash, model=model)
@view_config(context=Stream, renderer='h:templates/app.html.jinja2')
@view_config(context=Stream)
def stream_redirect(context, request):
location = request.route_url('stream', _query=context['query'])
raise httpexceptions.HTTPFound(location=location)
@view_config(route_name='stream', renderer='h:templates/app.html.jinja2')
def stream(context, request):
stream_type = context.get('stream_type')
stream_key = context.get('stream_key')
query = None
if stream_type == 'user':
parts = util.split_user(stream_key)
if parts is not None and parts[1] == request.domain:
query = {'q': 'user:{}'.format(parts[0])}
else:
query = {'q': 'user:{}'.format(stream_key)}
elif stream_type == 'tag':
query = {'q': 'tag:{}'.format(stream_key)}
if query is not None:
location = request.resource_url(context, 'stream', query=query)
return httpexceptions.HTTPFound(location=location)
else:
context["link_tags"] = [
{
"rel": "alternate", "href": request.route_url("stream_atom"),
"type": "application/atom+xml"
},
{
"rel": "alternate", "href": request.route_url("stream_rss"),
"type": "application/rss+xml"
}
atom = request.route_url('stream_atom')
rss = request.route_url('stream_rss')
return {
'link_tags': [
{'rel': 'alternate', 'href': atom, 'type': 'application/atom+xml'},
{'rel': 'alternate', 'href': rss, 'type': 'application/rss+xml'},
]
return context
}
@forbidden_view_config(renderer='h:templates/notfound.html.jinja2')
@@ -176,8 +162,14 @@ def includeme(config):
config.include('h.assets')
config.add_route('index', '/')
config.add_route('embed', '/embed.js')
config.add_route('widget', '/app.html')
config.add_route('help', '/docs/help')
config.add_route('onboarding', '/welcome')
config.add_route('session', '/app')
config.add_route('stream', '/stream')
_validate_blocklist(config)