diff --git a/.gitignore b/.gitignore index ec6afcdea4ec7f7f9c46ca95a10a3b1ef393f605..f82852adf4dc0b2a50b95ffd691b04be57513447 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__/ .env .cache/ *.egg-info/ +.coverage +.pytest_cache/ diff --git a/Makefile b/Makefile index 9c9fdd5553ad8210824832662fd1c715ba53eb85..1004b1df8a96fec016cc07c582827b0c8dc88eb0 100644 --- a/Makefile +++ b/Makefile @@ -2,11 +2,14 @@ init-env: python3 -m venv .env install: - pip install -r requirements.txt + pip install --upgrade -r requirements.txt pip install -e . run: - SECRET_KEY=local FLASK_DEBUG=1 FLASK_APP=./openlobby/server.py flask run -p 8010 + DEBUG=1 python manage.py runserver 8010 + +migrate: + DEBUG=1 python manage.py migrate build: docker build -t openlobby/openlobby-server:latest . diff --git a/README.md b/README.md index 7bd58cfcf606dd5f74ff130a4af2234c97e9de56..cd0bddfd4bd26efcae83ea55387bab206fa5eaf4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ prepared Elasticsearch Docker container with Czech support at ## Configuration Configuration is done by environment variables: - - `SECRET_KEY` - long random secret string (required) + - `DEBUG` - Set to any value to turn on debug mode. Don't use in production! + - `SECRET_KEY` - long random secret string (required if not in debug mode) + - `DATABASE_DSN` - DSN of PostgreSQL database (default: `postgresql://db:db@localhost:5432/openlobby`) - `ELASTICSEARCH_DSN` - DSN of Elasticsearch cluster (default: `http://localhost:9200`) - `SITE_NAME` - site name for OpenID authentication (default: `Open Lobby`) - `ES_INDEX` - Elasticsearch index (default: `openlobby`) @@ -44,12 +46,13 @@ You need to have Python 3 installed. Clone this repository and run: 1. `make init-env` - prepares Python virtualenv in dir `.env` 2. `source .env/bin/activate` - activates virtualenv 3. `make install` - installs requirements and server in development mode -4. `make run` - runs development server on port `8010` +4. `make migrate` - runs database migrations +5. `make run` - runs development server on port `8010` Now you can use GraphQL API endpoint and GraphiQL web interface at `http://localhost:8010/graphql` -Next time you can just do steps 2 and 4. +Next time you can just do steps 2 and 5. Development server assumes that you have [openlobby/openlobby-es-czech](https://github.com/openlobby/openlobby-es-czech). diff --git a/manage.py b/manage.py new file mode 100755 index 0000000000000000000000000000000000000000..de2d50d9e35c30f66f7de58d9efb1b4f2faa96e0 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openlobby.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/openlobby/auth.py b/openlobby/auth.py deleted file mode 100644 index 48d2243a11495f6cf5563dcd755d71719ea7507c..0000000000000000000000000000000000000000 --- a/openlobby/auth.py +++ /dev/null @@ -1,67 +0,0 @@ -import json -import jwt -import re -import time -from flask import g, request -from flask_graphql import GraphQLView - -from .settings import ( - SECRET_KEY, - JWT_ALGORITHM, - LOGIN_ATTEMPT_EXPIRATION, - SESSION_EXPIRATION, -) - - -def get_login_attempt_expiration_time(): - return int(time.time() + LOGIN_ATTEMPT_EXPIRATION) - - -def get_session_expiration_time(): - return int(time.time() + SESSION_EXPIRATION) - - -def create_access_token(session_id, expiration): - payload = { - 'sub': session_id, - 'exp': expiration, - } - token = jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALGORITHM) - return token.decode('utf-8') - - -def parse_access_token(token): - payload = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALGORITHM]) - return payload['sub'] - - -def graphql_error_response(message, code=400): - error = {'message': message} - return json.dumps({'errors': [error]}), code, {'Content-Type': 'application/json'} - - -class AuthGraphQLView(GraphQLView): - """ - GraphQLView which sets session_id into 'g' if authorization token is - provided in Authorization header. - """ - - def dispatch_request(self): - session_id = None - auth_header = request.headers.get('Authorization') - if auth_header is not None: - m = re.match(r'Bearer (?P<token>.+)', auth_header) - if m: - token = m.group('token') - else: - return graphql_error_response('Wrong Authorization header. Expected: "Bearer <token>"') - - try: - session_id = parse_access_token(token) - except jwt.InvalidTokenError: - session_id = None - except Exception: - return graphql_error_response('Wrong Authorization token.', 401) - - g.session_id = session_id - return super(AuthGraphQLView, self).dispatch_request() diff --git a/openlobby/core/__init__.py b/openlobby/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openlobby/core/admin.py b/openlobby/core/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..731ffd5d4d9c5501f8052f597d4329b143562a83 --- /dev/null +++ b/openlobby/core/admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from .models import OpenIdClient, User + + +@admin.register(OpenIdClient) +class OpenIdClientAdmin(admin.ModelAdmin): + pass + + +@admin.register(User) +class MyUserAdmin(UserAdmin): + pass diff --git a/openlobby/core/api/__init__.py b/openlobby/core/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openlobby/core/api/mutations.py b/openlobby/core/api/mutations.py new file mode 100644 index 0000000000000000000000000000000000000000..51bfb8563e0bc2fd969dc19ce651b97ab997a1b0 --- /dev/null +++ b/openlobby/core/api/mutations.py @@ -0,0 +1,140 @@ +import graphene +from graphene import relay +from graphene.types.datetime import DateTime +from graphql_relay import from_global_id +from oic.oic import rndstr + +from ..models import OpenIdClient, LoginAttempt, Report +from ..openid import ( + discover_issuer, + init_client_for_shortcut, + register_client, + get_authorization_url, +) +from . import types +from .sanitizers import strip_all_tags + + +STATE_LENGTH = 48 + + +class Login(relay.ClientIDMutation): + + class Input: + openid_uid = graphene.String(required=True) + redirect_uri = graphene.String(required=True) + + authorization_url = graphene.String() + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + openid_uid = input['openid_uid'] + app_redirect_uri = input['redirect_uri'] + + # prepare OpenID client + issuer = discover_issuer(openid_uid) + try: + openid_client_obj = OpenIdClient.objects.get(issuer=issuer) + client = init_client_for_shortcut(openid_client_obj) + except OpenIdClient.DoesNotExist: + client = register_client(issuer) + openid_client_obj = OpenIdClient.objects.create( + name=issuer, + issuer=issuer, + client_id=client.client_id, + client_secret=client.client_secret, + ) + + # prepare login attempt details + state = rndstr(STATE_LENGTH) + + # save login attempt + LoginAttempt.objects.create(state=state, openid_client=openid_client_obj, + app_redirect_uri=app_redirect_uri, openid_uid=openid_uid) + + # get OpenID authorization url + authorization_url = get_authorization_url(client, state) + + return Login(authorization_url=authorization_url) + + +class LoginByShortcut(relay.ClientIDMutation): + + class Input: + shortcut_id = relay.GlobalID(required=True) + redirect_uri = graphene.String(required=True) + + authorization_url = graphene.String() + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + shortcut_id = input['shortcut_id'] + app_redirect_uri = input['redirect_uri'] + + # prepare OpenID client + type, id = from_global_id(shortcut_id) + openid_client_obj = OpenIdClient.objects.get(id=id) + client = init_client_for_shortcut(openid_client_obj) + + # prepare login attempt + state = rndstr(STATE_LENGTH) + + # save login attempt + LoginAttempt.objects.create(state=state, openid_client=openid_client_obj, + app_redirect_uri=app_redirect_uri) + + # get OpenID authorization url + authorization_url = get_authorization_url(client, state) + + return LoginByShortcut(authorization_url=authorization_url) + + +class Logout(relay.ClientIDMutation): + success = graphene.Boolean() + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + raise NotImplementedError() + return Logout(success=True) + + +class NewReport(relay.ClientIDMutation): + + class Input: + title = graphene.String(required=True) + body = graphene.String(required=True) + received_benefit = graphene.String() + provided_benefit = graphene.String() + our_participants = graphene.String() + other_participants = graphene.String() + date = DateTime(required=True) + + report = graphene.Field(types.Report) + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + if not info.context.user.is_authenticated: + raise Exception('User must be logged in to perform this mutation.') + + author = info.context.user + + report = Report.objects.create( + author=author, + date=input.get('date'), + title=strip_all_tags(input.get('title', '')), + body=strip_all_tags(input.get('body', '')), + received_benefit=strip_all_tags(input.get('received_benefit', '')), + provided_benefit=strip_all_tags(input.get('provided_benefit', '')), + our_participants=strip_all_tags(input.get('our_participants', '')), + other_participants=strip_all_tags(input.get('other_participants', '')), + ) + + return NewReport(report=types.Report.from_db(report)) + + +class Mutation: + login = Login.Field() + login_by_shortcut = LoginByShortcut.Field() + # TODO + # logout = Logout.Field() + new_report = NewReport.Field() diff --git a/openlobby/paginator.py b/openlobby/core/api/paginator.py similarity index 87% rename from openlobby/paginator.py rename to openlobby/core/api/paginator.py index d94f9e16a08225f94e251376173affab553093cd..ea7b05ae39935f072045bd4cb1ae31bbd8a12eff 100644 --- a/openlobby/paginator.py +++ b/openlobby/core/api/paginator.py @@ -5,6 +5,10 @@ from graphene.relay import PageInfo PER_PAGE = 10 +class MissingBeforeValueError(Exception): + pass + + def encode_cursor(num): return base64.b64encode(str(num).encode('utf-8')).decode('utf-8') @@ -27,8 +31,10 @@ class Paginator: slice_to = slice_from + first elif last is not None: - if before is not None: - slice_to = decode_cursor(before) - 1 + if before is None: + raise MissingBeforeValueError('Pagination "last" works only in combination with "before" argument.') + + slice_to = decode_cursor(before) - 1 slice_from = slice_to - last if slice_from < 0: slice_from = 0 diff --git a/openlobby/sanitizers.py b/openlobby/core/api/sanitizers.py similarity index 100% rename from openlobby/sanitizers.py rename to openlobby/core/api/sanitizers.py diff --git a/openlobby/core/api/schema.py b/openlobby/core/api/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..d206bdf7b124773d6cfd9e54ed0fc97cfd09f151 --- /dev/null +++ b/openlobby/core/api/schema.py @@ -0,0 +1,97 @@ +import graphene +from graphene import relay + +from . import types +from ..models import OpenIdClient +from .paginator import Paginator +from .sanitizers import extract_text +from .. import search +from ..models import User + + +class AuthorsConnection(relay.Connection): + total_count = graphene.Int() + + class Meta: + node = types.Author + + +class SearchReportsConnection(relay.Connection): + total_count = graphene.Int() + + class Meta: + node = types.Report + + +def _get_authors_cache(ids): + users = User.objects.filter(id__in=ids) + return {u.id: types.User.from_db(u) for u in users} + + +class Query: + highlight_help = ('Whether search matches should be marked with tag <mark>.' + ' Default: false') + + node = relay.Node.Field() + authors = relay.ConnectionField( + AuthorsConnection, + description='List of Authors. Returns first 10 nodes if pagination is not specified.', + ) + search_reports = relay.ConnectionField( + SearchReportsConnection, + description='Fulltext search in Reports. Returns first 10 nodes if pagination is not specified.', + query=graphene.String(description='Text to search for.'), + highlight=graphene.Boolean(default_value=False, description=highlight_help), + ) + viewer = graphene.Field(types.User, description='Active user viewing API.') + login_shortcuts = graphene.List( + types.LoginShortcut, + description='Shortcuts for login. Use with LoginByShortcut mutation.', + ) + + def resolve_authors(self, info, **kwargs): + paginator = Paginator(**kwargs) + + total = User.objects.filter(is_author=True).count() + authors = User.objects.filter(is_author=True)[paginator.slice_from:paginator.slice_to] + + page_info = paginator.get_page_info(total) + + edges = [] + for i, author in enumerate(authors): + cursor = paginator.get_edge_cursor(i + 1) + node = types.Author.from_db(author) + edges.append(AuthorsConnection.Edge(node=node, cursor=cursor)) + + return AuthorsConnection(page_info=page_info, edges=edges, total_count=total) + + def resolve_search_reports(self, info, **kwargs): + paginator = Paginator(**kwargs) + query = kwargs.get('query', '') + query = extract_text(query) + params = { + 'highlight': kwargs.get('highlight'), + } + response = search.query_reports(query, paginator, **params) + total = response.hits.total + page_info = paginator.get_page_info(total) + + edges = [] + if len(response) > 0: + authors = _get_authors_cache(ids=[r.author_id for r in response]) + for i, report in enumerate(response): + cursor = paginator.get_edge_cursor(i + 1) + node = types.Report.from_es(report, author=authors[report.author_id]) + edges.append(SearchReportsConnection.Edge(node=node, cursor=cursor)) + + return SearchReportsConnection(page_info=page_info, edges=edges, total_count=total) + + def resolve_viewer(self, info, **kwargs): + if info.context.user.is_authenticated: + return types.User.from_db(info.context.user) + else: + return None + + def resolve_login_shortcuts(self, info, **kwargs): + clients = OpenIdClient.objects.filter(is_shortcut=True) + return [types.LoginShortcut.from_db(c) for c in clients] diff --git a/openlobby/types.py b/openlobby/core/api/types.py similarity index 59% rename from openlobby/types.py rename to openlobby/core/api/types.py index bd4f9359b54a599adf616ddb6a27f20362ccd2f3..78640dfe3934d6e250ffc0b9bb7f7e903b4e7aaa 100644 --- a/openlobby/types.py +++ b/openlobby/core/api/types.py @@ -3,9 +3,10 @@ import graphene from graphene import relay from graphene.types.json import JSONString -from .documents import UserDoc, ReportDoc, OpenIdClientDoc +from ..documents import ReportDoc +from .. import models from .paginator import Paginator -from . import search +from .. import search def get_higlighted(hit, field): @@ -16,7 +17,7 @@ def get_higlighted(hit, field): class Report(graphene.ObjectType): - author = graphene.Field(lambda: User) + author = graphene.Field(lambda: Author) date = graphene.String() published = graphene.String() title = graphene.String() @@ -43,13 +44,29 @@ class Report(graphene.ObjectType): provided_benefit=get_higlighted(report, 'provided_benefit'), our_participants=get_higlighted(report, 'our_participants'), other_participants=get_higlighted(report, 'other_participants'), - extra=report.extra._d_, + extra=report.extra, + ) + + @classmethod + def from_db(cls, report): + return cls( + id=report.id, + author=report.author, + date=report.date, + published=report.published, + title=report.title, + body=report.body, + received_benefit=report.received_benefit, + provided_benefit=report.provided_benefit, + our_participants=report.our_participants, + other_participants=report.other_participants, + extra=report.extra, ) @classmethod def get_node(cls, info, id): try: - report = ReportDoc.get(id, using=info.context['es'], index=info.context['index']) + report = ReportDoc.get(id) except NotFoundError: return None @@ -66,36 +83,65 @@ class ReportConnection(relay.Connection): class User(graphene.ObjectType): - name = graphene.String() openid_uid = graphene.String() + first_name = graphene.String() + last_name = graphene.String() email = graphene.String() + is_author = graphene.Boolean() extra = JSONString() - reports = relay.ConnectionField(ReportConnection) class Meta: interfaces = (relay.Node, ) @classmethod - def from_es(cls, user): + def from_db(cls, user): return cls( - id=user.meta.id, - name=user.name, + id=user.id, openid_uid=user.openid_uid, + first_name=user.first_name, + last_name=user.last_name, email=user.email, - extra=user.extra._d_, + is_author=user.is_author, + extra=user.extra, + ) + + @classmethod + def get_node(cls, info, id): + if not info.context.user.is_authenticated: + return None + if int(id) != info.context.user.id: + return None + return cls.from_db(info.context.user) + + +class Author(graphene.ObjectType): + first_name = graphene.String() + last_name = graphene.String() + extra = JSONString() + reports = relay.ConnectionField(ReportConnection) + + class Meta: + interfaces = (relay.Node, ) + + @classmethod + def from_db(cls, user): + return cls( + id=user.id, + first_name=user.first_name, + last_name=user.last_name, + extra=user.extra, ) @classmethod def get_node(cls, info, id): try: - user = UserDoc.get(id, using=info.context['es'], index=info.context['index']) - except NotFoundError: + return cls.from_db(models.User.objects.get(id=id, is_author=True)) + except models.User.DoesNotExist: return None - return cls.from_es(user) def resolve_reports(self, info, **kwargs): paginator = Paginator(**kwargs) - response = search.reports_by_author(self.id, paginator, **info.context) + response = search.reports_by_author(self.id, paginator) total = response.hits.total page_info = paginator.get_page_info(total) @@ -115,16 +161,13 @@ class LoginShortcut(graphene.ObjectType): interfaces = (relay.Node, ) @classmethod - def from_es(cls, openid_client): - return cls( - id=openid_client.meta.id, - name=openid_client.name_x, - ) + def from_db(cls, openid_client): + return cls(id=openid_client.id, name=openid_client.name) @classmethod def get_node(cls, info, id): try: - openid_client = OpenIdClientDoc.get(id, using=info.context['es'], index=info.context['index']) - except NotFoundError: + client = models.OpenIdClient.objects.get(id=id) + return cls.from_db(client) + except models.OpenIdClient.DoesNotExist: return None - return cls.from_es(openid_client) diff --git a/openlobby/core/api/utils.py b/openlobby/core/api/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e2503c6f2b511527b797c270d6c758ecfbe2d7c7 --- /dev/null +++ b/openlobby/core/api/utils.py @@ -0,0 +1,6 @@ +from django.http import JsonResponse + + +def graphql_error_response(message, status_code=400): + error = {'message': message} + return JsonResponse({'errors': [error]}, status=status_code) diff --git a/openlobby/core/apps.py b/openlobby/core/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..26f78a8e673340121f68a92930e2830bc58d269d --- /dev/null +++ b/openlobby/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = 'core' diff --git a/openlobby/core/auth.py b/openlobby/core/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..b49b3a94ef675e132f8df1e008812b8a6ba7b714 --- /dev/null +++ b/openlobby/core/auth.py @@ -0,0 +1,19 @@ +from django.conf import settings +import jwt +import time + + +def create_access_token(username, expiration=None): + if expiration is None: + expiration = int(time.time() + settings.SESSION_EXPIRATION) + payload = { + 'sub': username, + 'exp': expiration, + } + token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + return token.decode('utf-8') + + +def parse_access_token(token): + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + return payload['sub'] diff --git a/openlobby/core/documents.py b/openlobby/core/documents.py new file mode 100644 index 0000000000000000000000000000000000000000..c4574a742838d579322db333777de2baf0d686d9 --- /dev/null +++ b/openlobby/core/documents.py @@ -0,0 +1,36 @@ +from django.conf import settings +from django_elasticsearch_dsl import DocType, Index, fields +import json + +from .models import Report + + +report = Index('report') + + +@report.doc_type +class ReportDoc(DocType): + author_id = fields.IntegerField() + title = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER) + body = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER) + received_benefit = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER) + provided_benefit = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER) + our_participants = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER) + other_participants = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER) + # there is no support for JSONField now, so we serialize it to keyword + extra_serialized = fields.KeywordField() + + def prepare_extra_serialized(self, instance): + return json.dumps(instance.extra) + + @property + def extra(self): + return json.loads(self.extra_serialized) + + class Meta: + model = Report + + fields = [ + 'date', + 'published', + ] diff --git a/openlobby/core/middleware.py b/openlobby/core/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..a6c1083c595d7ae6f7b7e0cba48a9561b455c8aa --- /dev/null +++ b/openlobby/core/middleware.py @@ -0,0 +1,33 @@ +import re + +from .api.utils import graphql_error_response +from .auth import parse_access_token +from .models import User + + +class TokenAuthMiddleware: + """Custom authentication middleware which using JWT token.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + auth_header = request.META.get('HTTP_AUTHORIZATION') + if auth_header is not None: + m = re.match(r'Bearer (?P<token>.+)', auth_header) + if m: + token = m.group('token') + else: + return graphql_error_response('Wrong Authorization header. Expected: "Bearer <token>"') + + try: + username = parse_access_token(token) + except Exception: + return graphql_error_response('Invalid Token.', 401) + + try: + request.user = User.objects.get(username=username) + except User.DoesNotExist: + pass + + return self.get_response(request) diff --git a/openlobby/core/migrations/0001_initial.py b/openlobby/core/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..6e16fd07305e7a325fdbe10617b20365eed5f2b3 --- /dev/null +++ b/openlobby/core/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# Generated by Django 2.0.1 on 2018-01-16 00:25 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0009_alter_user_last_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('openid_uid', models.CharField(db_index=True, max_length=255, unique=True)), + ('extras', django.contrib.postgres.fields.jsonb.JSONField()), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='LoginAttempt', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('state', models.CharField(db_index=True, max_length=50, unique=True)), + ('redirect_uri', models.CharField(max_length=255)), + ('expiration', models.IntegerField()), + ], + ), + migrations.CreateModel( + name='OpenIdClient', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('is_shortcut', models.BooleanField(default=False)), + ('client_id', models.CharField(max_length=255)), + ('client_secret', models.CharField(max_length=255)), + ('issuer', models.CharField(db_index=True, max_length=255, unique=True)), + ('authorization_endpoint', models.CharField(max_length=255)), + ('token_endpoint', models.CharField(max_length=255)), + ('userinfo_endpoint', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='Report', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateTimeField()), + ('published', models.DateTimeField()), + ('title', models.TextField()), + ('body', models.TextField()), + ('received_benefit', models.TextField()), + ('provided_benefit', models.TextField()), + ('our_participants', models.TextField()), + ('other_participants', models.TextField()), + ('extra', django.contrib.postgres.fields.jsonb.JSONField()), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='loginattempt', + name='openid_client', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.OpenIdClient'), + ), + ] diff --git a/openlobby/core/migrations/0002_auto_20180116_2349.py b/openlobby/core/migrations/0002_auto_20180116_2349.py new file mode 100644 index 0000000000000000000000000000000000000000..db82fcff43eea3210500cb4d4da010f13834d3fd --- /dev/null +++ b/openlobby/core/migrations/0002_auto_20180116_2349.py @@ -0,0 +1,49 @@ +# Generated by Django 2.0.1 on 2018-01-16 22:49 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='report', + name='extra', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='report', + name='other_participants', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='report', + name='our_participants', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='report', + name='provided_benefit', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='report', + name='received_benefit', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='report', + name='title', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='user', + name='extras', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), + ), + ] diff --git a/openlobby/core/migrations/0003_auto_20180117_0030.py b/openlobby/core/migrations/0003_auto_20180117_0030.py new file mode 100644 index 0000000000000000000000000000000000000000..b48c6ca7f0f47d8d7aabdf806ea40175902868fc --- /dev/null +++ b/openlobby/core/migrations/0003_auto_20180117_0030.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.1 on 2018-01-16 23:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_auto_20180116_2349'), + ] + + operations = [ + migrations.RenameField( + model_name='user', + old_name='extras', + new_name='extra', + ), + ] diff --git a/openlobby/core/migrations/0004_user_is_author.py b/openlobby/core/migrations/0004_user_is_author.py new file mode 100644 index 0000000000000000000000000000000000000000..9a8e43e40003605a4240ef6fb5313ea118c8936d --- /dev/null +++ b/openlobby/core/migrations/0004_user_is_author.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.1 on 2018-01-27 10:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_auto_20180117_0030'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_author', + field=models.BooleanField(default=False), + ), + ] diff --git a/openlobby/core/migrations/0005_auto_20180208_1210.py b/openlobby/core/migrations/0005_auto_20180208_1210.py new file mode 100644 index 0000000000000000000000000000000000000000..ce3b8ec06ee7c9a645d3b12b0eec675870fbef0c --- /dev/null +++ b/openlobby/core/migrations/0005_auto_20180208_1210.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.2 on 2018-02-08 11:10 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_user_is_author'), + ] + + operations = [ + migrations.RenameField( + model_name='loginattempt', + old_name='redirect_uri', + new_name='app_redirect_uri', + ), + migrations.AlterField( + model_name='report', + name='published', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/openlobby/core/migrations/0006_auto_20180208_1544.py b/openlobby/core/migrations/0006_auto_20180208_1544.py new file mode 100644 index 0000000000000000000000000000000000000000..90eb680791f7ec9859bce5fa141cc5c4d00b9387 --- /dev/null +++ b/openlobby/core/migrations/0006_auto_20180208_1544.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.2 on 2018-02-08 14:44 + +from django.db import migrations, models +import openlobby.core.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_auto_20180208_1210'), + ] + + operations = [ + migrations.AlterField( + model_name='loginattempt', + name='expiration', + field=models.IntegerField(default=openlobby.core.models.get_login_attempt_expiration), + ), + ] diff --git a/openlobby/core/migrations/0007_auto_20180209_1809.py b/openlobby/core/migrations/0007_auto_20180209_1809.py new file mode 100644 index 0000000000000000000000000000000000000000..898503bc22fcf9ae91e8e1198bcd8cdca320b1e3 --- /dev/null +++ b/openlobby/core/migrations/0007_auto_20180209_1809.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.2 on 2018-02-09 17:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_auto_20180208_1544'), + ] + + operations = [ + migrations.RemoveField( + model_name='openidclient', + name='authorization_endpoint', + ), + migrations.RemoveField( + model_name='openidclient', + name='token_endpoint', + ), + migrations.RemoveField( + model_name='openidclient', + name='userinfo_endpoint', + ), + ] diff --git a/openlobby/core/migrations/0008_loginattempt_openid_uid.py b/openlobby/core/migrations/0008_loginattempt_openid_uid.py new file mode 100644 index 0000000000000000000000000000000000000000..7243cf4b4e815bbdfe72527bc2c56de8a9abb51e --- /dev/null +++ b/openlobby/core/migrations/0008_loginattempt_openid_uid.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.2 on 2018-02-17 16:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_auto_20180209_1809'), + ] + + operations = [ + migrations.AddField( + model_name='loginattempt', + name='openid_uid', + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/openlobby/core/migrations/0009_auto_20180217_1850.py b/openlobby/core/migrations/0009_auto_20180217_1850.py new file mode 100644 index 0000000000000000000000000000000000000000..88afb4af1cd95ba957cdc1d231bbe994378c5fce --- /dev/null +++ b/openlobby/core/migrations/0009_auto_20180217_1850.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.2 on 2018-02-17 17:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_loginattempt_openid_uid'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='openid_uid', + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/openlobby/core/migrations/__init__.py b/openlobby/core/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openlobby/core/models.py b/openlobby/core/models.py new file mode 100644 index 0000000000000000000000000000000000000000..09314c9593a3efa365cd240adb4cd2ccce7cae7b --- /dev/null +++ b/openlobby/core/models.py @@ -0,0 +1,59 @@ +from django.conf import settings +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.contrib.postgres.fields import JSONField +from django.utils import timezone +import time + + +class User(AbstractUser): + """Custom user model. For simplicity we store OpenID 'sub' identifier in + username field. + """ + openid_uid = models.CharField(max_length=255, null=True) + extra = JSONField(null=True, blank=True) + is_author = models.BooleanField(default=False) + + +class OpenIdClient(models.Model): + """Stores connection parameters for OpenID Clients. Some may be used as + login shortcuts. + """ + name = models.CharField(max_length=255) + is_shortcut = models.BooleanField(default=False) + client_id = models.CharField(max_length=255) + client_secret = models.CharField(max_length=255) + issuer = models.CharField(max_length=255, unique=True, db_index=True) + + def __str__(self): + return self.name + + +def get_login_attempt_expiration(): + return int(time.time() + settings.LOGIN_ATTEMPT_EXPIRATION) + + +class LoginAttempt(models.Model): + """Temporary login attempt details which persists redirects.""" + openid_client = models.ForeignKey(OpenIdClient, on_delete=models.CASCADE) + state = models.CharField(max_length=50, unique=True, db_index=True) + app_redirect_uri = models.CharField(max_length=255) + openid_uid = models.CharField(max_length=255, null=True) + expiration = models.IntegerField(default=get_login_attempt_expiration) + + +class Report(models.Model): + author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + date = models.DateTimeField() + published = models.DateTimeField(default=timezone.now) + title = models.TextField(null=True, blank=True) + body = models.TextField() + received_benefit = models.TextField(null=True, blank=True) + provided_benefit = models.TextField(null=True, blank=True) + our_participants = models.TextField(null=True, blank=True) + other_participants = models.TextField(null=True, blank=True) + extra = JSONField(null=True, blank=True) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + User.objects.filter(id=self.author.id).update(is_author=True) diff --git a/openlobby/core/openid.py b/openlobby/core/openid.py new file mode 100644 index 0000000000000000000000000000000000000000..0ce9422a7cf012cd702d9178fd95cd7114d8a2aa --- /dev/null +++ b/openlobby/core/openid.py @@ -0,0 +1,67 @@ +from django.conf import settings +from oic.oic import Client +from oic.oic.message import ( + AuthorizationResponse, + RegistrationResponse, + ClaimsRequest, + Claims, +) +from oic.utils.authn.client import CLIENT_AUTHN_METHOD + + +def discover_issuer(openid_uid): + client = Client(client_authn_method=CLIENT_AUTHN_METHOD) + return client.discover(openid_uid) + + +def init_client_for_shortcut(openid_client_obj): + client = Client(client_authn_method=CLIENT_AUTHN_METHOD) + reg_info = { + 'client_id': openid_client_obj.client_id, + 'client_secret': openid_client_obj.client_secret, + 'redirect_uris': [settings.REDIRECT_URI], + } + client_reg = RegistrationResponse(**reg_info) + client.store_registration_info(client_reg) + client.provider_config(openid_client_obj.issuer) + return client + + +def register_client(issuer): + client = Client(client_authn_method=CLIENT_AUTHN_METHOD) + client.provider_config(issuer) + params = { + 'redirect_uris': [settings.REDIRECT_URI], + 'client_name': settings.SITE_NAME, + } + client.register(client.provider_info['registration_endpoint'], **params) + return client + + +def get_authorization_url(client, state): + args = { + 'client_id': client.client_id, + 'response_type': 'code', + 'scope': 'openid', + 'state': state, + 'redirect_uri': client.registration_response['redirect_uris'][0], + 'claims': ClaimsRequest(userinfo=Claims( + given_name={'essential': True}, + family_name={'essential': True}, + email={'essential': True}, + )), + } + + auth_req = client.construct_AuthorizationRequest(request_args=args) + return auth_req.request(client.provider_info['authorization_endpoint']) + + +def get_user_info(client, query_string): + """Processes query string from OpenID redirect and returns user info.""" + aresp = client.parse_response(AuthorizationResponse, info=query_string, + sformat='urlencoded') + + args = {'code': aresp['code'], 'redirect_uri': settings.REDIRECT_URI} + client.do_access_token_request(state=aresp['state'], request_args=args) + + return client.do_user_info_request(state=aresp['state']) diff --git a/openlobby/search.py b/openlobby/core/search.py similarity index 59% rename from openlobby/search.py rename to openlobby/core/search.py index d682b4cd35492dcf0aa0fe1053e7aee53249db09..27ef112755667cba816abd78dbf98d1842e75cc4 100644 --- a/openlobby/search.py +++ b/openlobby/core/search.py @@ -1,4 +1,4 @@ -from .documents import ReportDoc, OpenIdClientDoc +from .documents import ReportDoc HIGHLIGHT_PARAMS = { @@ -8,10 +8,10 @@ HIGHLIGHT_PARAMS = { } -def query_reports(query, paginator, *, es, index, highlight=False): +def query_reports(query, paginator, *, highlight=False): fields = ['title', 'body', 'received_benefit', 'provided_benefit', 'our_participants', 'other_participants'] - s = ReportDoc.search(using=es, index=index) + s = ReportDoc.search() if query != '': s = s.query('multi_match', query=query, fields=fields) if highlight: @@ -21,15 +21,9 @@ def query_reports(query, paginator, *, es, index, highlight=False): return s.execute() -def reports_by_author(author_id, paginator, *, es, index): - s = ReportDoc.search(using=es, index=index) +def reports_by_author(author_id, paginator): + s = ReportDoc.search() s = s.filter('term', author_id=author_id) s = s.sort('-published') s = s[paginator.slice_from:paginator.slice_to] return s.execute() - - -def login_shortcuts(*, es, index): - s = OpenIdClientDoc.search(using=es, index=index) - s = s.filter('term', is_shortcut=True) - return s.execute() diff --git a/openlobby/core/templates/core/index.html b/openlobby/core/templates/core/index.html new file mode 100644 index 0000000000000000000000000000000000000000..25a2631d8efe95cb3150315a930342f8bed9981a --- /dev/null +++ b/openlobby/core/templates/core/index.html @@ -0,0 +1,6 @@ +<html> + <body> + <h1>Open Lobby Server</h1> + <p>GraphQL API endpoint and GraphiQL is at: <a href="/graphql">/graphql</a></p> + </body> +</html> diff --git a/openlobby/core/views.py b/openlobby/core/views.py new file mode 100644 index 0000000000000000000000000000000000000000..5a270d2d443e5d88b6bde72df36ad28a919eeca9 --- /dev/null +++ b/openlobby/core/views.py @@ -0,0 +1,51 @@ +from django.shortcuts import redirect +from django.views import View +from django.views.generic.base import TemplateView +import time +import urllib.parse + +from .auth import create_access_token +from .models import LoginAttempt, User +from .openid import ( + init_client_for_shortcut, + get_user_info, +) + + +class IndexView(TemplateView): + template_name = 'core/index.html' + + +class LoginRedirectView(View): + + # TODO redirect to app_redirect_uri on fail + def get(self, request, **kwargs): + query_string = request.META['QUERY_STRING'] + + # get login attempt + state = urllib.parse.parse_qs(query_string)['state'][0] + la = LoginAttempt.objects.select_related('openid_client').get(state=state) + app_redirect_uri = la.app_redirect_uri + + if la.expiration < time.time(): + # TODO redirect to app_redirect_uri with fail + raise NotImplementedError + + # delete login attempt so it can be used just once + # TODO delete breaks it with LoginAttempt.DoesNotExist exception, why?! + # la.delete() + + client = init_client_for_shortcut(la.openid_client) + user_info = get_user_info(client, query_string) + + user, created = User.objects.get_or_create(username=user_info['sub'], defaults={ + 'username': user_info['sub'], + 'first_name': user_info['given_name'], + 'last_name': user_info['family_name'], + 'email': user_info['email'], + 'openid_uid': la.openid_uid, + }) + + token = create_access_token(user.username) + token_qs = urllib.parse.urlencode({'token': token}) + return redirect('{}?{}'.format(app_redirect_uri, token_qs)) diff --git a/openlobby/documents.py b/openlobby/documents.py deleted file mode 100644 index b07c66eef0718515cf8ca4b73f156ca46f4576f2..0000000000000000000000000000000000000000 --- a/openlobby/documents.py +++ /dev/null @@ -1,93 +0,0 @@ -from elasticsearch_dsl import DocType, Text, Date, Object, Keyword, Integer, Boolean -import time - -from .settings import ES_TEXT_ANALYZER - - -""" -Don't forget to add a new document to the list of all documents at the end of -the file. -""" - - -class UserDoc(DocType): - openid_uid = Keyword() - name = Text(analyzer=ES_TEXT_ANALYZER) - email = Keyword() - extra = Object() - - class Meta: - doc_type = 'user' - - @classmethod - def get_by_openid_uid(cls, openid_uid, *, es, index): - response = cls.search(using=es, index=index).query('match', openid_uid=openid_uid).execute() - return response.hits[0] if response.hits.total > 0 else None - - -class ReportDoc(DocType): - author_id = Keyword() - date = Date() - published = Date() - title = Text(analyzer=ES_TEXT_ANALYZER) - body = Text(analyzer=ES_TEXT_ANALYZER) - received_benefit = Text(analyzer=ES_TEXT_ANALYZER) - provided_benefit = Text(analyzer=ES_TEXT_ANALYZER) - our_participants = Text(analyzer=ES_TEXT_ANALYZER) - other_participants = Text(analyzer=ES_TEXT_ANALYZER) - extra = Object() - - class Meta: - doc_type = 'report' - - -class OpenIdClientDoc(DocType): - # TODO conflict in field type - name_x = Keyword() - is_shortcut = Boolean() - client_id = Keyword() - client_secret = Keyword() - issuer = Keyword() - authorization_endpoint = Keyword() - token_endpoint = Keyword() - userinfo_endpoint = Keyword() - - class Meta: - doc_type = 'open-id-client' - - -class LoginAttemptDoc(DocType): - openid_uid = Keyword() - redirect_uri = Keyword() - state = Keyword() - nonce = Keyword() - client_id = Keyword() - client_secret = Keyword() - expiration = Integer() # UTC timestamp - - class Meta: - doc_type = 'login-attempt' - - -class SessionDoc(DocType): - user_id = Keyword() - expiration = Integer() # UTC timestamp - - class Meta: - doc_type = 'session' - - @classmethod - def get_active(cls, session_id, *, es, index): - session = cls.get(session_id, using=es, index=index, ignore=404) - if session and session.expiration > time.time(): - return session - return None - - -all_documents = ( - UserDoc, - ReportDoc, - LoginAttemptDoc, - SessionDoc, - OpenIdClientDoc, -) diff --git a/openlobby/management.py b/openlobby/management.py deleted file mode 100644 index 828668e9e370f1e0a42b4c518c12baddc2b30bce..0000000000000000000000000000000000000000 --- a/openlobby/management.py +++ /dev/null @@ -1,120 +0,0 @@ -import re - -from .documents import all_documents - - -class IndexAlreadyExistsError(Exception): - pass - - -class AliasAlreadyExistsError(Exception): - pass - - -INDEX_SETTINGS = { - 'settings': { - 'analysis': { - 'filter': { - 'czech_stop': { - 'type': 'stop', - 'stopwords': '_czech_', - }, - 'czech_stemmer': { - 'type': 'stemmer', - 'language': 'czech', - }, - 'cs_CZ': { - 'type': 'hunspell', - 'locale': 'cs_CZ', - 'dedup': True, - }, - 'czech_synonym': { - 'type': 'synonym', - 'synonyms_path': 'analysis/cs_CZ/synonym.txt', - }, - }, - 'analyzer': { - 'czech': { - 'tokenizer': 'standard', - 'filter': [ - 'icu_folding', - 'lowercase', - 'czech_synonym', - 'czech_stop', - 'czech_stemmer', - 'cs_CZ', - ] - } - } - } - } -} - - -def bootstrap_es(es, index): - if es.indices.exists(index): - return - - print('Bootstrapping index and documents.') - init_alias(es, index) - - -def init_documents(es, index): - """Initializes all documents.""" - for doc in all_documents: - doc.init(index=index, using=es) - - -def get_new_index_version(index): - """Returns name of new version of index.""" - m = re.match(r'(.+)_v([\d]+)$', index) - if not m: - raise ValueError('Cannot get version from index name "{}"'.format(index)) - - alias = m.group(1) - version = m.group(2) - new_version = int(version) + 1 - return '{}_v{}'.format(alias, new_version) - - -def create_index(es, name): - """Creates index with INDEX_SETTINGS and all documents (mappings).""" - if es.indices.exists(name): - raise IndexAlreadyExistsError('Index "{}" already exists.'.format(name)) - - es.indices.create(name, body=INDEX_SETTINGS) - init_documents(es, name) - - -def init_alias(es, alias): - """Initializes alias for the first time. Creates index v1 for alias.""" - if es.indices.exists(alias): - raise AliasAlreadyExistsError('Alias "{}" already exists.'.format(alias)) - - index = '{}_v1'.format(alias) - create_index(es, index) - es.indices.put_alias(name=alias, index=index) - - -def reindex(es, alias): - """ - Reindexes index by alias into new version (with actual index settings and - mappings definitions) and switches alias. - """ - response = es.indices.get_alias(alias) - source = list(response.keys())[0] - - dest = get_new_index_version(source) - create_index(es, dest) - - es.reindex(body={ - 'source': {'index': source}, - 'dest': {'index': dest}, - }) - - es.indices.update_aliases(body={ - 'actions': [ - {'remove': {'index': source, 'alias': alias}}, - {'add': {'index': dest, 'alias': alias}}, - ], - }) diff --git a/openlobby/mutations.py b/openlobby/mutations.py deleted file mode 100644 index a6b6086901506650a21b455437f303a01ceea5f5..0000000000000000000000000000000000000000 --- a/openlobby/mutations.py +++ /dev/null @@ -1,251 +0,0 @@ -import arrow -from flask import g -import graphene -from graphene import relay -from graphene.types.datetime import DateTime -from graphql_relay import from_global_id -from oic.oic import rndstr -from oic.oic.message import AuthorizationResponse -import time -import urllib.parse - -from .auth import ( - get_login_attempt_expiration_time, - get_session_expiration_time, - create_access_token, -) -from .documents import ( - UserDoc, - LoginAttemptDoc, - SessionDoc, - ReportDoc, - OpenIdClientDoc, -) -from .openid import ( - init_client_for_uid, - init_client_for_shortcut, - register_client, - get_authorization_url, - set_registration_info, - do_access_token_request, -) -from .types import Report -from .utils import get_viewer -from .sanitizers import strip_all_tags - - -class Login(relay.ClientIDMutation): - - class Input: - openid_uid = graphene.String(required=True) - redirect_uri = graphene.String(required=True) - - authorization_url = graphene.String() - - @classmethod - def mutate_and_get_payload(cls, root, info, **input): - openid_uid = input['openid_uid'] - redirect_uri = input['redirect_uri'] - - # prepare OpenID client - client = init_client_for_uid(openid_uid) - client = register_client(client, redirect_uri) - - # prepare login attempt details - state = rndstr(32) - nonce = rndstr() - expiration = get_login_attempt_expiration_time() - - # save login attempt into ES - data = { - 'meta': {'id': client.client_id}, - 'state': state, - 'nonce': nonce, - 'client_id': client.client_id, - 'client_secret': client.client_secret, - 'openid_uid': openid_uid, - 'redirect_uri': redirect_uri, - 'expiration': expiration, - } - login_attempt = LoginAttemptDoc(**data) - login_attempt.save(using=info.context['es'], index=info.context['index']) - - # already registered user? - user = UserDoc.get_by_openid_uid(openid_uid, **info.context) - is_new_user = user is None - - # get OpenID authorization url - authorization_url = get_authorization_url(client, state, nonce, is_new_user) - - return Login(authorization_url=authorization_url) - - -class LoginByShortcut(relay.ClientIDMutation): - - class Input: - shortcut_id = relay.GlobalID(required=True) - redirect_uri = graphene.String(required=True) - - authorization_url = graphene.String() - - @classmethod - def mutate_and_get_payload(cls, root, info, **input): - shortcut_id = input['shortcut_id'] - redirect_uri = input['redirect_uri'] - - type, id = from_global_id(shortcut_id) - openid_client_data = OpenIdClientDoc.get(id, using=info.context['es'], - index=info.context['index']) - - # prepare OpenID client - client = init_client_for_shortcut(openid_client_data, redirect_uri) - - # TODO - """ - # prepare login attempt details - state = rndstr(32) - nonce = rndstr() - expiration = get_login_attempt_expiration_time() - - # save login attempt into ES - data = { - 'meta': {'id': client.client_id}, - 'state': state, - 'nonce': nonce, - 'client_id': client.client_id, - 'client_secret': client.client_secret, - 'openid_uid': openid_uid, - 'redirect_uri': redirect_uri, - 'expiration': expiration, - } - login_attempt = LoginAttemptDoc(**data) - login_attempt.save(using=info.context['es'], index=info.context['index']) - - # already registered user? - user = UserDoc.get_by_openid_uid(openid_uid, **info.context) - is_new_user = user is None - - # get OpenID authorization url - authorization_url = get_authorization_url(client, state, nonce, is_new_user) - """ - - authorization_url = 'http://localhost/foo' - return LoginByShortcut(authorization_url=authorization_url) - - -class LoginRedirect(relay.ClientIDMutation): - - class Input: - query_string = graphene.String(required=True) - - access_token = graphene.String() - - @classmethod - def mutate_and_get_payload(cls, root, info, **input): - query_string = input['query_string'] - - # get login attempt from ES - qs_data = urllib.parse.parse_qs(query_string) - la = LoginAttemptDoc.get(qs_data['client_id'], using=info.context['es'], - index=info.context['index']) - - # delete login attempt so it can be used just once - la.delete(using=info.context['es']) - - # check login attempt expiration - if la['expiration'] < time.time(): - raise Exception('Login attempt expired.') - - # reconstruct OpenID Client - client = init_client_for_uid(la['openid_uid']) - client = set_registration_info(client, la['client_id'], la['client_secret'], la['redirect_uri']) - - # process query string from OpenID redirect - aresp = client.parse_response(AuthorizationResponse, info=query_string, sformat='urlencoded') - code = aresp['code'] - state = aresp['state'] - - # check state - if state != la['state']: - raise ValueError('Wrong query_string.') - - # OpenID access token request - do_access_token_request(client, code, state) - - # OpenID user info request - user_info = client.do_user_info_request(state=state) - - # get or create User - user = UserDoc.get_by_openid_uid(la['openid_uid'], **info.context) - if user is None: - user = UserDoc(openid_uid=la['openid_uid'], name=user_info['name'], email=user_info['email']) - user.save(using=info.context['es'], index=info.context['index']) - - # create session - expiration = get_session_expiration_time() - session = SessionDoc(user_id=user.meta.id, expiration=expiration) - session.save(using=info.context['es'], index=info.context['index']) - - # create access token for session - token = create_access_token(session.meta.id, expiration) - - return LoginRedirect(access_token=token) - - -class Logout(relay.ClientIDMutation): - success = graphene.Boolean() - - @classmethod - def mutate_and_get_payload(cls, root, info, **input): - viewer = get_viewer(info) - if viewer is None: - raise Exception('User must be logged in to perform this mutation.') - - session_id = g.get('session_id') - session = SessionDoc.get(session_id, using=info.context['es'], index=info.context['index']) - session.delete(using=info.context['es'], index=info.context['index']) - - return Logout(success=True) - - -class NewReport(relay.ClientIDMutation): - - class Input: - title = graphene.String(required=True) - body = graphene.String(required=True) - received_benefit = graphene.String() - provided_benefit = graphene.String() - our_participants = graphene.String() - other_participants = graphene.String() - date = DateTime(required=True) - - report = graphene.Field(Report) - - @classmethod - def mutate_and_get_payload(cls, root, info, **input): - viewer = get_viewer(info) - if viewer is None: - raise Exception('User must be logged in to perform this mutation.') - - data = { - 'author_id': viewer.id, - 'published': arrow.utcnow().isoformat(), - 'title': strip_all_tags(input.get('title', '')), - 'body': strip_all_tags(input.get('body', '')), - 'received_benefit': strip_all_tags(input.get('received_benefit', '')), - 'provided_benefit': strip_all_tags(input.get('provided_benefit', '')), - 'our_participants': strip_all_tags(input.get('our_participants', '')), - 'other_participants': strip_all_tags(input.get('other_participants', '')), - 'date': input.get('date'), - } - report = ReportDoc(**data) - report.save(using=info.context['es'], index=info.context['index']) - return NewReport(report=Report.from_es(report, author=viewer)) - - -class Mutation(graphene.ObjectType): - login = Login.Field() - login_by_shortcut = LoginByShortcut.Field() - login_redirect = LoginRedirect.Field() - logout = Logout.Field() - new_report = NewReport.Field() diff --git a/openlobby/openid.py b/openlobby/openid.py deleted file mode 100644 index f336e0af71b6468e5136eb559cb7bace2744ae61..0000000000000000000000000000000000000000 --- a/openlobby/openid.py +++ /dev/null @@ -1,83 +0,0 @@ -from oic.oic import Client -from oic.oic.message import ( - ProviderConfigurationResponse, - RegistrationResponse, - ClaimsRequest, - Claims, -) -from oic.utils.authn.client import CLIENT_AUTHN_METHOD - -from .settings import SITE_NAME - - -def init_client_for_uid(openid_uid): - client = Client(client_authn_method=CLIENT_AUTHN_METHOD) - issuer = client.discover(openid_uid) - client.provider_config(issuer) - return client - - -def init_client_for_shortcut(data, redirect_uri): - client = Client(client_authn_method=CLIENT_AUTHN_METHOD) - set_registration_info(client, data['client_id'], data['client_secret'], redirect_uri) - info = { - 'issuer': data['issuer'], - 'authorization_endpoint': data['authorization_endpoint'], - 'token_endpoint': data['token_endpoint'], - 'userinfo_endpoint': data['userinfo_endpoint'], - } - client.provider_info = ProviderConfigurationResponse(**info) - return client - - -def register_client(client, redirect_uri): - params = { - 'redirect_uris': [redirect_uri], - 'client_name': SITE_NAME, - } - client.register(client.provider_info['registration_endpoint'], **params) - return client - - -def get_authorization_url(client, state, nonce, is_new_user=True): - args = { - 'client_id': client.client_id, - 'response_type': 'code', - 'scope': ['openid'], - 'nonce': nonce, - 'state': state, - 'redirect_uri': client.registration_response['redirect_uris'][0], - } - - if is_new_user: - args['claims'] = ClaimsRequest( - userinfo=Claims( - email={'essential': True}, - name={'essential': True}, - ) - ) - - auth_req = client.construct_AuthorizationRequest(request_args=args) - url = auth_req.request(client.provider_info['authorization_endpoint']) - return url - - -def set_registration_info(client, client_id, client_secret, redirect_uri): - info = { - 'client_id': client_id, - 'client_secret': client_secret, - 'redirect_uris': [redirect_uri], - } - client_reg = RegistrationResponse(**info) - client.store_registration_info(client_reg) - return client - - -def do_access_token_request(client, code, state): - args = { - 'code': code, - 'client_id': client.client_id, - 'client_secret': client.client_secret, - 'redirect_uri': client.registration_response['redirect_uris'][0], - } - client.do_access_token_request(state=state, request_args=args) diff --git a/openlobby/schema.py b/openlobby/schema.py index c76ed3b13f0049600574afd9c7404e2a1b1f3701..5772e2fd8a448384edf4b427c50b69aaa3c3ca41 100644 --- a/openlobby/schema.py +++ b/openlobby/schema.py @@ -1,73 +1,17 @@ import graphene -from graphene import relay -from .types import Report, User, LoginShortcut -from .documents import UserDoc -from .paginator import Paginator -from .mutations import Mutation -from .sanitizers import extract_text -from .utils import get_viewer -from . import search +from openlobby.core.api.mutations import Mutation as CoreMutation +from openlobby.core.api.schema import Query as CoreQuery +from openlobby.core.api.types import Author, User, Report, LoginShortcut -class SearchReportsConnection(relay.Connection): - total_count = graphene.Int() +class Query(CoreQuery, graphene.ObjectType): + pass - class Meta: - node = Report +class Mutation(CoreMutation, graphene.ObjectType): + pass -def get_authors(ids, *, es, index): - response = UserDoc.mget(ids, using=es, index=index) - return {a.meta.id: User.from_es(a) for a in response} - -class Query(graphene.ObjectType): - highlight_help = ('Whether search matches should be marked with tag <mark>.' - ' Default: false') - - node = relay.Node.Field() - search_reports = relay.ConnectionField( - SearchReportsConnection, - description='Fulltext search in Reports.', - query=graphene.String(description='Text to search for.'), - highlight=graphene.Boolean(default_value=False, description=highlight_help), - ) - viewer = graphene.Field(User, description='Active user viewing API.') - login_shortcuts = graphene.List( - LoginShortcut, - description='Shortcuts for login. Use with LoginByShortcut mutation.', - ) - - def resolve_search_reports(self, info, **kwargs): - paginator = Paginator(**kwargs) - query = kwargs.get('query', '') - query = extract_text(query) - params = { - 'es': info.context['es'], - 'index': info.context['index'], - 'highlight': kwargs.get('highlight'), - } - response = search.query_reports(query, paginator, **params) - total = response.hits.total - page_info = paginator.get_page_info(total) - - edges = [] - if len(response) > 0: - authors = get_authors(ids=[r.author_id for r in response], **info.context) - for i, report in enumerate(response): - cursor = paginator.get_edge_cursor(i + 1) - node = Report.from_es(report, author=authors[report.author_id]) - edges.append(SearchReportsConnection.Edge(node=node, cursor=cursor)) - - return SearchReportsConnection(page_info=page_info, edges=edges, total_count=total) - - def resolve_viewer(self, info, **kwargs): - return get_viewer(info) - - def resolve_login_shortcuts(self, info, **kwargs): - response = search.login_shortcuts(**info.context) - return [LoginShortcut.from_es(ls) for ls in response] - - -schema = graphene.Schema(query=Query, mutation=Mutation, types=[User, Report]) +schema = graphene.Schema(query=Query, mutation=Mutation, + types=[Author, User, Report, LoginShortcut]) diff --git a/openlobby/server.py b/openlobby/server.py deleted file mode 100644 index e35d9475071b2c223b820dbd1991a46a43f02c0a..0000000000000000000000000000000000000000 --- a/openlobby/server.py +++ /dev/null @@ -1,26 +0,0 @@ -import os -from flask import Flask -from elasticsearch import Elasticsearch - -from .auth import AuthGraphQLView -from .management import bootstrap_es -from .schema import schema -from .settings import ES_INDEX - - -app = Flask(__name__) - - -es_dsn = os.environ.get('ELASTICSEARCH_DSN', 'http://localhost:9200') -es_client = Elasticsearch(es_dsn) - -bootstrap_es(es_client, ES_INDEX) - - -@app.route('/') -def hello(): - return 'Open Lobby Server\n\nAPI is at: /graphql', 200, {'Content-Type': 'text/plain; charset=utf-8'} - - -app.add_url_rule('/graphql', view_func=AuthGraphQLView.as_view( - 'graphql', schema=schema, graphiql=True, context={'es': es_client, 'index': ES_INDEX})) diff --git a/openlobby/settings.py b/openlobby/settings.py index c1a8b8b7e0653c64d1b4c383bebefb0ef4b65770..dad0d61f52a23995c5bf42f891a44b1cfa485b6a 100644 --- a/openlobby/settings.py +++ b/openlobby/settings.py @@ -1,4 +1,145 @@ import os +import dsnparse + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = 'DEBUG' in os.environ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get('SECRET_KEY') +if not SECRET_KEY: + if DEBUG: + SECRET_KEY = 'not-secret-at-all' + else: + raise RuntimeError('Missing SECRET_KEY environment variable.') + +ALLOWED_HOSTS = ['*'] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.staticfiles', + 'graphene_django', + 'django_elasticsearch_dsl', + 'openlobby.core', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'openlobby.core.middleware.TokenAuthMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +] + +ROOT_URLCONF = 'openlobby.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'openlobby.wsgi.application' + +AUTH_USER_MODEL = 'core.User' + +DATABASE_DSN = os.environ.get('DATABASE_DSN', 'postgresql://db:db@localhost:5432/openlobby') +db = dsnparse.parse(DATABASE_DSN) +assert db.scheme == 'postgresql' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': db.paths[0], + 'USER': db.username, + 'PASSWORD': db.password, + 'HOST': db.host, + 'PORT': db.port, + } +} + +ELASTICSEARCH_DSL = { + 'default': { + 'hosts': os.environ.get('ELASTICSEARCH_DSN', 'http://localhost:9200'), + }, +} + +ELASTICSEARCH_DSL_INDEX_SETTINGS = { + 'number_of_shards': 1, + 'number_of_replicas': 0, +} + +# Password validation + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + + +# Internationalization + +LANGUAGE_CODE = 'cs' + +TIME_ZONE = 'Europe/Prague' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) + +STATIC_URL = '/static/' + + +LOGGING = { + 'version': 1, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': 'INFO' if DEBUG else 'WARNING', + }, + }, +} + + +GRAPHENE = { + 'SCHEMA': 'openlobby.schema.schema' +} + +############################################################################### +# Custom settings # Elasticsearch index ES_INDEX = os.environ.get('ES_INDEX', 'openlobby') @@ -6,11 +147,6 @@ ES_INDEX = os.environ.get('ES_INDEX', 'openlobby') # default analyzer for text fields ES_TEXT_ANALYZER = os.environ.get('ES_TEXT_ANALYZER', 'czech') -# secret key for signing tokens -SECRET_KEY = os.environ.get('SECRET_KEY') -if not SECRET_KEY: - raise RuntimeError('Missing SECRET_KEY environment variable.') - # signature algorithm JSON Web Tokens JWT_ALGORITHM = 'HS512' @@ -22,3 +158,6 @@ SESSION_EXPIRATION = 60 * 60 * 24 * 14 # name of the site used in OpenID authentication SITE_NAME = os.environ.get('SITE_NAME', 'Open Lobby') + +# redirect URI used in OpenID authentication +REDIRECT_URI = os.environ.get('REDIRECT_URI', 'http://localhost:8010/login-redirect') diff --git a/openlobby/urls.py b/openlobby/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..f9f5fed53ee512e7cc1ad4eeb090775ac5d0f9e3 --- /dev/null +++ b/openlobby/urls.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from django.urls import path +from django.views.decorators.csrf import csrf_exempt +from graphene_django.views import GraphQLView + +from openlobby.core.views import IndexView, LoginRedirectView + +urlpatterns = [ + path('', IndexView.as_view(), name='index'), + path('login-redirect', LoginRedirectView.as_view(), name='login-redirect'), + path('admin/', admin.site.urls), + path('graphql', csrf_exempt(GraphQLView.as_view(graphiql=True))), +] diff --git a/openlobby/utils.py b/openlobby/utils.py deleted file mode 100644 index e26871b964847a96b0fc2ad20d335613fc47c256..0000000000000000000000000000000000000000 --- a/openlobby/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -from flask import g - -from .documents import SessionDoc -from .types import User - - -def get_viewer(info): - """Resolves actual viewer and caches it into 'g'.""" - if not hasattr(g, 'viewer'): - session_id = g.get('session_id', None) - if session_id is None: - g.viewer = None - else: - session = SessionDoc.get_active(session_id, **info.context) - if session is None: - g.viewer = None - else: - g.viewer = User.get_node(info, session.user_id) - return g.viewer diff --git a/openlobby/wsgi.py b/openlobby/wsgi.py new file mode 100644 index 0000000000000000000000000000000000000000..5f399cc41e6446c3d2f303983b18fd88301d45c9 --- /dev/null +++ b/openlobby/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for openlobby project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openlobby.settings") + +application = get_wsgi_application() diff --git a/pytest.ini b/pytest.ini index e1b7a4018edbb066d5fde12841ba8022fcdae421..854a7af384b1a0a85b3288df7b656f7d733fe535 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,5 @@ [pytest] +DJANGO_SETTINGS_MODULE = openlobby.settings +python_files = tests.py test_*.py *_tests.py env = SECRET_KEY=justForTests diff --git a/requirements.txt b/requirements.txt index 13ed9ee43cec7473f77dbdc30fc658f2534af996..e0eb091d6f94f00c0c1d0716ba76059aef15d4c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,16 @@ +Django>=2,<2.1 graphene>=2.0,<3.0 -flask>=0.12,<0.13 -flask_graphQL>=1.4,<1.5 +graphene-django>=2.0,<3.0 elasticsearch-dsl>=5.3.0,<6.0.0 -pytest>=3.2.3,<3.3.0 +django-elasticsearch-dsl>=0.4.4,<0.5 +pytest>=3.3,<4.0 +pytest-django>=3.1.2,<3.2 pytest-env>=0.6.2,<0.7 oic>=0.12.0,<0.13 pyjwt>=1.5.3,<1.6 iso8601>=0.1.12,<0.2 -arrow>=0.10.0,<0.11 +arrow>=0.12.0,<0.13 bleach>=2.1.1,<2.2 snapshottest>=0.5.0,<0.6 +psycopg2>=2.7,<2.8 +dsnparse>=0.1.11,<0.2 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/conftest.py b/tests/conftest.py index 111f1cf97bf5866adfb09db3d667f17bb67093b7..6d9cc6b844b8a82264ff793dfe73c4f31c7593b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,32 +1,37 @@ -from elasticsearch import Elasticsearch -import os import pytest -import random -import string +from django_elasticsearch_dsl.test import ESTestCase -from openlobby.management import init_alias +class DummyTestCase: + def setUp(self): + pass + def tearDown(self): + pass -@pytest.fixture(scope='session') -def es(): - """Elasticsearch client.""" - es_dsn = os.environ.get('ELASTICSEARCH_DSN', 'http://localhost:9200') - es_client = Elasticsearch(es_dsn) - yield es_client - es_client.indices.delete('test_*') + +class TestCase(ESTestCase, DummyTestCase): + pass @pytest.fixture -def index_name(): - """Random test index name.""" - length = 10 - word = ''.join(random.choice(string.ascii_lowercase) for i in range(length)) - return 'test_{}'.format(word) +def django_es(): + """Setup and teardown of Elasticsearch test indices.""" + testCase = TestCase() + testCase.setUp() + yield + testCase.tearDown() -@pytest.fixture -def index(es, index_name): - """Initialized index.""" - init_alias(es, index_name) - return index_name +def pytest_addoption(parser): + parser.addoption('--issuer', action='store', + help='OpenID Provider (server) issuer URL. Provider must allow client registration.') + + +@pytest.fixture(scope='session') +def issuer(request): + """OpenID Provider issuer URL.""" + url = request.config.getoption('--issuer') + if url is None: + pytest.skip('Missing OpenID Provider URL.') + return url diff --git a/tests/dummy.py b/tests/dummy.py new file mode 100644 index 0000000000000000000000000000000000000000..dbdb7dcd6b16e1a5cda5ca745792d34787a6013a --- /dev/null +++ b/tests/dummy.py @@ -0,0 +1,84 @@ +import arrow + +from openlobby.core.models import Report, User + + +authors = [ + { + 'id': 1, + 'username': 'Wolf', + 'first_name': 'Winston', + 'last_name': 'Wolfe', + 'is_author': True, + 'extra': {'movies': 1}, + }, + { + 'id': 2, + 'username': 'sponge', + 'first_name': 'Spongebob', + 'last_name': 'Squarepants', + 'is_author': True, + }, + { + 'id': 3, + 'username': 'shaun', + 'first_name': 'Shaun', + 'last_name': 'Sheep', + 'is_author': True, + }, +] + +reports = [ + { + 'id': 1, + 'date': arrow.get(2018, 1, 1).datetime, + 'published': arrow.get(2018, 1, 2).datetime, + 'title': 'The Fellowship of the Ring', + 'body': 'Long story short: we got the Ring!', + 'received_benefit': 'The Ring', + 'provided_benefit': '', + 'our_participants': 'Frodo, Gandalf', + 'other_participants': 'Saruman', + }, + { + 'id': 2, + 'date': arrow.get(2018, 1, 5).datetime, + 'published': arrow.get(2018, 1, 10).datetime, + 'title': 'The Two Towers', + 'body': 'Another long story.', + 'received_benefit': 'Mithrill Jacket', + 'provided_benefit': '', + 'our_participants': 'Frodo, Gimli, Legolas', + 'other_participants': 'Saruman, Sauron', + 'extra': {'rings': 1}, + }, + { + 'id': 3, + 'date': arrow.get(2018, 1, 7).datetime, + 'published': arrow.get(2018, 1, 8).datetime, + 'title': 'The Return of the King', + 'body': 'Aragorn is the King. And we have lost the Ring.', + 'received_benefit': '', + 'provided_benefit': 'The Ring', + 'our_participants': 'Aragorn', + 'other_participants': 'Sauron', + }, +] + + +def prepare_reports(): + author1 = User.objects.create(**authors[0]) + author2 = User.objects.create(**authors[1]) + Report.objects.create(author=author1, **reports[0]) + Report.objects.create(author=author2, **reports[1]) + Report.objects.create(author=author1, **reports[2]) + + +def prepare_report(): + author = User.objects.create(**authors[0]) + Report.objects.create(author=author, **reports[0]) + + +def prepare_authors(): + for author in authors: + User.objects.create(**author) diff --git a/tests/mutations/__init__.py b/tests/mutations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/mutations/snapshots/__init__.py b/tests/mutations/snapshots/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/mutations/snapshots/snap_test_login.py b/tests/mutations/snapshots/snap_test_login.py new file mode 100644 index 0000000000000000000000000000000000000000..8a36af706ac9e7d00be5361a9589e8636245927c --- /dev/null +++ b/tests/mutations/snapshots/snap_test_login.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_login__known_openid_client 1'] = { + 'userinfo': { + 'email': { + 'essential': True + }, + 'family_name': { + 'essential': True + }, + 'given_name': { + 'essential': True + } + } +} + +snapshots['test_login__new_openid_client 1'] = { + 'userinfo': { + 'email': { + 'essential': True + }, + 'family_name': { + 'essential': True + }, + 'given_name': { + 'essential': True + } + } +} diff --git a/tests/mutations/snapshots/snap_test_login_by_shortcut.py b/tests/mutations/snapshots/snap_test_login_by_shortcut.py new file mode 100644 index 0000000000000000000000000000000000000000..6fe834f111a4cd7b14863c5eb635217f3b69679a --- /dev/null +++ b/tests/mutations/snapshots/snap_test_login_by_shortcut.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_login_by_shortcut 1'] = { + 'userinfo': { + 'email': { + 'essential': True + }, + 'family_name': { + 'essential': True + }, + 'given_name': { + 'essential': True + } + } +} diff --git a/tests/mutations/snapshots/snap_test_new_report.py b/tests/mutations/snapshots/snap_test_new_report.py new file mode 100644 index 0000000000000000000000000000000000000000..1f223bbe8031ead1b9af28180b1e2cf44b584070 --- /dev/null +++ b/tests/mutations/snapshots/snap_test_new_report.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_unauthorized 1'] = { + 'data': { + 'newReport': None + }, + 'errors': [ + { + 'locations': [ + { + 'column': 9, + 'line': 3 + } + ], + 'message': 'User must be logged in to perform this mutation.' + } + ] +} + +snapshots['test_full_report 1'] = { + 'data': { + 'newReport': { + 'report': { + 'author': { + 'extra': None, + 'firstName': 'Winston', + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe' + }, + 'body': 'I visited Tesla factory and talked with Elon Musk.', + 'date': '2018-01-01 00:00:00+00:00', + 'extra': None, + 'id': '__STRIPPED__', + 'otherParticipants': 'Elon Musk', + 'ourParticipants': 'me', + 'providedBenefit': 'nothing', + 'published': '__STRIPPED__', + 'receivedBenefit': 'Tesla Model S', + 'title': 'Free Tesla' + } + } + } +} diff --git a/tests/mutations/test_login.py b/tests/mutations/test_login.py new file mode 100644 index 0000000000000000000000000000000000000000..aa489ba73a9fb51f39dbf6069cfda63eef0db174 --- /dev/null +++ b/tests/mutations/test_login.py @@ -0,0 +1,85 @@ +import json +import pytest +import re +from urllib.parse import urlparse, urlunparse, parse_qs +from unittest.mock import patch + +from openlobby.core.models import OpenIdClient, LoginAttempt +from openlobby.core.openid import register_client + + +pytestmark = pytest.mark.django_db + + +def check_authorization_url(authorization_url, oid_client, state, snapshot): + url = urlparse(authorization_url) + url_without_query = urlunparse((url.scheme, url.netloc, url.path, '', '', '')) + assert url_without_query == '{}/protocol/openid-connect/auth'.format(oid_client.issuer) + + qs = parse_qs(url.query) + assert qs['client_id'][0] == oid_client.client_id + assert qs['response_type'][0] == 'code' + assert qs['scope'][0] == 'openid' + assert qs['redirect_uri'][0] == 'http://localhost:8010/login-redirect' + assert qs['state'][0] == state + snapshot.assert_match(json.loads(qs['claims'][0])) + + +def test_login__known_openid_client(issuer, client, snapshot): + oc = register_client(issuer) + oid_client = OpenIdClient.objects.create(name='Test', issuer=issuer, + client_id=oc.client_id, client_secret=oc.client_secret) + + app_redirect_uri = 'http://i.am.pirate' + openid_uid = 'wolf@openid.provider' + # Keycloak server used for tests does not support issuer discovery by UID, so we mock it + with patch('openlobby.core.api.mutations.discover_issuer', return_value=issuer) as mock: + res = client.post('/graphql', {'query': """ + mutation {{ + login (input: {{ openidUid: "{uid}", redirectUri: "{uri}" }}) {{ + authorizationUrl + }} + }} + """.format(uid=openid_uid, uri=app_redirect_uri)}) + mock.assert_called_once_with(openid_uid) + + response = res.json() + assert 'errors' not in response + authorization_url = response['data']['login']['authorizationUrl'] + + la = LoginAttempt.objects.get(openid_client__id=oid_client.id) + assert la.app_redirect_uri == app_redirect_uri + assert la.openid_uid == openid_uid + + check_authorization_url(authorization_url, oid_client, la.state, snapshot) + + +def test_login__new_openid_client(issuer, client, snapshot): + app_redirect_uri = 'http://i.am.pirate' + openid_uid = 'wolf@openid.provider' + # Keycloak server used for tests does not support issuer discovery by UID, so we mock it + with patch('openlobby.core.api.mutations.discover_issuer', return_value=issuer) as mock: + res = client.post('/graphql', {'query': """ + mutation {{ + login (input: {{ openidUid: "{uid}", redirectUri: "{uri}" }}) {{ + authorizationUrl + }} + }} + """.format(uid=openid_uid, uri=app_redirect_uri)}) + mock.assert_called_once_with(openid_uid) + + response = res.json() + assert 'errors' not in response + authorization_url = response['data']['login']['authorizationUrl'] + + oid_client = OpenIdClient.objects.get() + assert oid_client.name == issuer + assert oid_client.issuer == issuer + assert re.match(r'\w+', oid_client.client_id) + assert re.match(r'\w+', oid_client.client_secret) + + la = LoginAttempt.objects.get(openid_client__id=oid_client.id) + assert la.app_redirect_uri == app_redirect_uri + assert la.openid_uid == openid_uid + + check_authorization_url(authorization_url, oid_client, la.state, snapshot) diff --git a/tests/mutations/test_login_by_shortcut.py b/tests/mutations/test_login_by_shortcut.py new file mode 100644 index 0000000000000000000000000000000000000000..e26a320dce10051b87db1e2c9f0704845099cff7 --- /dev/null +++ b/tests/mutations/test_login_by_shortcut.py @@ -0,0 +1,33 @@ +from graphql_relay import to_global_id +import pytest + +from openlobby.core.models import OpenIdClient, LoginAttempt +from openlobby.core.openid import register_client + +from .test_login import check_authorization_url + +pytestmark = pytest.mark.django_db + + +def test_login_by_shortcut(issuer, client, snapshot): + oc = register_client(issuer) + oid_client = OpenIdClient.objects.create(name='Test', is_shortcut=True, + issuer=issuer, client_id=oc.client_id, client_secret=oc.client_secret) + + app_redirect_uri = 'http://i.am.pirate' + res = client.post('/graphql', {'query': """ + mutation {{ + loginByShortcut (input: {{ shortcutId: "{id}", redirectUri: "{uri}" }}) {{ + authorizationUrl + }} + }} + """.format(id=to_global_id('LoginShortcut', oid_client.id), uri=app_redirect_uri)}) + response = res.json() + assert 'errors' not in response + authorization_url = response['data']['loginByShortcut']['authorizationUrl'] + + la = LoginAttempt.objects.get(openid_client__id=oid_client.id) + assert la.app_redirect_uri == app_redirect_uri + assert la.openid_uid is None + + check_authorization_url(authorization_url, oid_client, la.state, snapshot) diff --git a/tests/mutations/test_new_report.py b/tests/mutations/test_new_report.py new file mode 100644 index 0000000000000000000000000000000000000000..ac64910ce937f59dd67f35ad2ffe376ce65aa0d9 --- /dev/null +++ b/tests/mutations/test_new_report.py @@ -0,0 +1,151 @@ +import pytest +import arrow +import json +import re + +from openlobby.core.auth import create_access_token +from openlobby.core.models import User, Report + + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')] + + +@pytest.fixture(autouse=True) +def setup(): + User.objects.create(id=1, is_author=True, username='wolfe', + first_name='Winston', last_name='Wolfe', email='winston@wolfe.com') + + +def call_api(client, query, input, username=None): + variables = json.dumps({'input': input}) + if username is None: + res = client.post('/graphql', {'query': query, 'variables': variables}) + else: + token = create_access_token(username) + auth_header = 'Bearer {}'.format(token) + res = client.post('/graphql', {'query': query, 'variables': variables}, + HTTP_AUTHORIZATION=auth_header) + return res.json() + + +def test_unauthorized(client, snapshot): + query = """ + mutation newReport ($input: NewReportInput!) { + newReport (input: $input) { + report { + id + } + } + } + """ + input = { + 'title': 'Short Story', + 'body': 'I told you!', + 'date': arrow.utcnow().isoformat(), + } + + response = call_api(client, query, input) + + snapshot.assert_match(response) + + +def test_full_report(client, snapshot): + query = """ + mutation newReport ($input: NewReportInput!) { + newReport (input: $input) { + report { + id + date + published + title + body + receivedBenefit + providedBenefit + ourParticipants + otherParticipants + extra + author { + id + firstName + lastName + extra + } + } + } + } + """ + date = arrow.get(2018, 1, 1) + title = 'Free Tesla' + body = 'I visited Tesla factory and talked with Elon Musk.' + received_benefit = 'Tesla Model S' + provided_benefit = 'nothing' + our_participants = 'me' + other_participants = 'Elon Musk' + input = { + 'title': title, + 'body': body, + 'receivedBenefit': received_benefit, + 'providedBenefit': provided_benefit, + 'ourParticipants': our_participants, + 'otherParticipants': other_participants, + 'date': date.isoformat(), + } + + response = call_api(client, query, input, 'wolfe') + + # published date is set on save, changing between test runs, so we strip it + # from snapshot + published = response['data']['newReport']['report']['published'] + response['data']['newReport']['report']['published'] = '__STRIPPED__' + + # There is a strange issue with tests, that report get's ID 2 when all tests + # are run. Even when the there is just one Report in database. I tried to + # debug it, no luck to solve. So I just strip ID from snapshot and check it. + id = response['data']['newReport']['report']['id'] + response['data']['newReport']['report']['id'] = '__STRIPPED__' + assert re.match(r'\w+', id) + + snapshot.assert_match(response) + + report = Report.objects.get() + assert report.author_id == 1 + assert report.date == date.datetime + assert report.published == arrow.get(published).datetime + assert report.title == title + assert report.body == body + assert report.received_benefit == received_benefit + assert report.provided_benefit == provided_benefit + assert report.our_participants == our_participants + assert report.other_participants == other_participants + assert report.extra is None + + +def test_input_sanitization(client): + query = """ + mutation newReport ($input: NewReportInput!) { + newReport (input: $input) { + report { + id + } + } + } + """ + input = { + 'title': '<s>No</s> tags', + 'body': 'some <a href="http://foo">link</a> <br>in body', + 'receivedBenefit': '<b>coffee</b>', + 'providedBenefit': '<li>tea', + 'ourParticipants': 'me, <u>myself</u>', + 'otherParticipants': '<strong>you!</strong>', + 'date': arrow.utcnow().isoformat(), + } + + call_api(client, query, input, 'wolfe') + + report = Report.objects.get() + assert report.title == 'No tags' + assert report.body == 'some link in body' + assert report.received_benefit == 'coffee' + assert report.provided_benefit == 'tea' + assert report.our_participants == 'me, myself' + assert report.other_participants == 'you!' diff --git a/tests/schema/__init__.py b/tests/schema/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/schema/snapshots/__init__.py b/tests/schema/snapshots/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/schema/snapshots/snap_test_authors.py b/tests/schema/snapshots/snap_test_authors.py new file mode 100644 index 0000000000000000000000000000000000000000..88acda2ab25a1e728a38899b4661f6fc0c4237e7 --- /dev/null +++ b/tests/schema/snapshots/snap_test_authors.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_all 1'] = { + 'data': { + 'authors': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe' + } + }, + { + 'cursor': 'Mg==', + 'node': { + 'extra': None, + 'firstName': 'Spongebob', + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants' + } + }, + { + 'cursor': 'Mw==', + 'node': { + 'extra': None, + 'firstName': 'Shaun', + 'id': 'QXV0aG9yOjM=', + 'lastName': 'Sheep' + } + } + ], + 'pageInfo': { + 'endCursor': 'Mw==', + 'hasNextPage': False, + 'hasPreviousPage': False, + 'startCursor': 'MQ==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_first 1'] = { + 'data': { + 'authors': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'firstName': 'Winston', + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe' + } + }, + { + 'cursor': 'Mg==', + 'node': { + 'firstName': 'Spongebob', + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants' + } + } + ], + 'pageInfo': { + 'endCursor': 'Mg==', + 'hasNextPage': True, + 'hasPreviousPage': False, + 'startCursor': 'MQ==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_first_after 1'] = { + 'data': { + 'authors': { + 'edges': [ + { + 'cursor': 'Mg==', + 'node': { + 'firstName': 'Spongebob', + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants' + } + } + ], + 'pageInfo': { + 'endCursor': 'Mg==', + 'hasNextPage': True, + 'hasPreviousPage': True, + 'startCursor': 'Mg==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_last 1'] = { + 'data': { + 'authors': None + }, + 'errors': [ + { + 'locations': [ + { + 'column': 9, + 'line': 3 + } + ], + 'message': 'Pagination "last" works only in combination with "before" argument.' + } + ] +} + +snapshots['test_last_before 1'] = { + 'data': { + 'authors': { + 'edges': [ + { + 'cursor': 'Mg==', + 'node': { + 'firstName': 'Spongebob', + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants' + } + } + ], + 'pageInfo': { + 'endCursor': 'Mg==', + 'hasNextPage': True, + 'hasPreviousPage': True, + 'startCursor': 'Mg==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_with_reports 1'] = { + 'data': { + 'authors': { + 'edges': [ + { + 'node': { + 'firstName': 'Winston', + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe', + 'reports': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'body': 'Long story short: we got the Ring!', + 'date': '2018-01-01 00:00:00+00:00', + 'extra': None, + 'id': 'UmVwb3J0OjE=', + 'otherParticipants': 'Saruman', + 'ourParticipants': 'Frodo, Gandalf', + 'providedBenefit': '', + 'published': '2018-01-02 00:00:00+00:00', + 'receivedBenefit': 'The Ring', + 'title': 'The Fellowship of the Ring' + } + } + ], + 'totalCount': 1 + } + } + } + ] + } + } +} diff --git a/tests/schema/snapshots/snap_test_login_shortcuts.py b/tests/schema/snapshots/snap_test_login_shortcuts.py new file mode 100644 index 0000000000000000000000000000000000000000..45cc81f6b986dea6fce412ac8a51a807bc57c7ad --- /dev/null +++ b/tests/schema/snapshots/snap_test_login_shortcuts.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_returns_only_shortcuts 1'] = { + 'data': { + 'loginShortcuts': [ + { + 'id': 'TG9naW5TaG9ydGN1dDoyMA==', + 'name': 'bar' + } + ] + } +} + +snapshots['test_none 1'] = { + 'data': { + 'loginShortcuts': [ + ] + } +} diff --git a/tests/schema/snapshots/snap_test_node.py b/tests/schema/snapshots/snap_test_node.py new file mode 100644 index 0000000000000000000000000000000000000000..d2b7936a3346d1c9f3281455a745a5833b6ae853 --- /dev/null +++ b/tests/schema/snapshots/snap_test_node.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_login_shortcut 1'] = { + 'data': { + 'node': { + 'id': 'TG9naW5TaG9ydGN1dDoxMA==', + 'name': 'foo' + } + } +} + +snapshots['test_author 1'] = { + 'data': { + 'node': { + 'extra': '{"x": 1}', + 'firstName': 'Winston', + 'id': 'QXV0aG9yOjU=', + 'lastName': 'Wolfe' + } + } +} + +snapshots['test_author__returns_only_if_is_author 1'] = { + 'data': { + 'node': None + } +} + +snapshots['test_report 1'] = { + 'data': { + 'node': { + 'author': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe' + }, + 'body': 'Long story short: we got the Ring!', + 'date': '2018-01-01 00:00:00+00:00', + 'extra': None, + 'id': 'UmVwb3J0OjE=', + 'otherParticipants': 'Saruman', + 'ourParticipants': 'Frodo, Gandalf', + 'providedBenefit': '', + 'published': '2018-01-02 00:00:00+00:00', + 'receivedBenefit': 'The Ring', + 'title': 'The Fellowship of the Ring' + } + } +} + +snapshots['test_user__unauthorized 1'] = { + 'data': { + 'node': None + } +} + +snapshots['test_user__not_a_viewer 1'] = { + 'data': { + 'node': None + } +} + +snapshots['test_user 1'] = { + 'data': { + 'node': { + 'extra': '{"e": "mc2"}', + 'firstName': 'Albert', + 'id': 'VXNlcjo4', + 'isAuthor': False, + 'lastName': 'Einstein', + 'openidUid': 'albert@einstein.id' + } + } +} diff --git a/tests/schema/snapshots/snap_test_search_reports.py b/tests/schema/snapshots/snap_test_search_reports.py new file mode 100644 index 0000000000000000000000000000000000000000..70f33adda263e829d0e417f46a2e563d2f827ee3 --- /dev/null +++ b/tests/schema/snapshots/snap_test_search_reports.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_all 1'] = { + 'data': { + 'searchReports': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'author': { + 'extra': None, + 'firstName': 'Spongebob', + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants' + }, + 'body': 'Another long story.', + 'date': '2018-01-05 00:00:00+00:00', + 'extra': '{"rings": 1}', + 'id': 'UmVwb3J0OjI=', + 'otherParticipants': 'Saruman, Sauron', + 'ourParticipants': 'Frodo, Gimli, Legolas', + 'providedBenefit': '', + 'published': '2018-01-10 00:00:00+00:00', + 'receivedBenefit': 'Mithrill Jacket', + 'title': 'The Two Towers' + } + }, + { + 'cursor': 'Mg==', + 'node': { + 'author': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe' + }, + 'body': 'Aragorn is the King. And we have lost the Ring.', + 'date': '2018-01-07 00:00:00+00:00', + 'extra': None, + 'id': 'UmVwb3J0OjM=', + 'otherParticipants': 'Sauron', + 'ourParticipants': 'Aragorn', + 'providedBenefit': 'The Ring', + 'published': '2018-01-08 00:00:00+00:00', + 'receivedBenefit': '', + 'title': 'The Return of the King' + } + }, + { + 'cursor': 'Mw==', + 'node': { + 'author': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe' + }, + 'body': 'Long story short: we got the Ring!', + 'date': '2018-01-01 00:00:00+00:00', + 'extra': None, + 'id': 'UmVwb3J0OjE=', + 'otherParticipants': 'Saruman', + 'ourParticipants': 'Frodo, Gandalf', + 'providedBenefit': '', + 'published': '2018-01-02 00:00:00+00:00', + 'receivedBenefit': 'The Ring', + 'title': 'The Fellowship of the Ring' + } + } + ], + 'pageInfo': { + 'endCursor': 'Mw==', + 'hasNextPage': False, + 'hasPreviousPage': False, + 'startCursor': 'MQ==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_query 1'] = { + 'data': { + 'searchReports': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'author': { + 'extra': None, + 'firstName': 'Spongebob', + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants' + }, + 'body': 'Another long story.', + 'date': '2018-01-05 00:00:00+00:00', + 'extra': '{"rings": 1}', + 'id': 'UmVwb3J0OjI=', + 'otherParticipants': 'Saruman, Sauron', + 'ourParticipants': 'Frodo, Gimli, Legolas', + 'providedBenefit': '', + 'published': '2018-01-10 00:00:00+00:00', + 'receivedBenefit': 'Mithrill Jacket', + 'title': 'The Two Towers' + } + } + ], + 'totalCount': 1 + } + } +} + +snapshots['test_highlight 1'] = { + 'data': { + 'searchReports': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'author': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe' + }, + 'body': 'Aragorn is the King. And we have lost the <mark>Ring</mark>.', + 'date': '2018-01-07 00:00:00+00:00', + 'extra': None, + 'id': 'UmVwb3J0OjM=', + 'otherParticipants': 'Sauron', + 'ourParticipants': 'Aragorn', + 'providedBenefit': 'The <mark>Ring</mark>', + 'published': '2018-01-08 00:00:00+00:00', + 'receivedBenefit': '', + 'title': 'The Return of the King' + } + }, + { + 'cursor': 'Mg==', + 'node': { + 'author': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe' + }, + 'body': 'Long story short: we got the <mark>Ring</mark>!', + 'date': '2018-01-01 00:00:00+00:00', + 'extra': None, + 'id': 'UmVwb3J0OjE=', + 'otherParticipants': 'Saruman', + 'ourParticipants': 'Frodo, Gandalf', + 'providedBenefit': '', + 'published': '2018-01-02 00:00:00+00:00', + 'receivedBenefit': 'The <mark>Ring</mark>', + 'title': 'The Fellowship of the <mark>Ring</mark>' + } + } + ], + 'totalCount': 2 + } + } +} + +snapshots['test_first 1'] = { + 'data': { + 'searchReports': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'id': 'UmVwb3J0OjI=', + 'title': 'The Two Towers' + } + } + ], + 'pageInfo': { + 'endCursor': 'MQ==', + 'hasNextPage': True, + 'hasPreviousPage': False, + 'startCursor': 'MQ==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_first_after 1'] = { + 'data': { + 'searchReports': { + 'edges': [ + { + 'cursor': 'Mg==', + 'node': { + 'id': 'UmVwb3J0OjM=', + 'title': 'The Return of the King' + } + } + ], + 'pageInfo': { + 'endCursor': 'Mg==', + 'hasNextPage': True, + 'hasPreviousPage': True, + 'startCursor': 'Mg==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_last 1'] = { + 'data': { + 'searchReports': None + }, + 'errors': [ + { + 'locations': [ + { + 'column': 9, + 'line': 3 + } + ], + 'message': 'Pagination "last" works only in combination with "before" argument.' + } + ] +} + +snapshots['test_last_before 1'] = { + 'data': { + 'searchReports': { + 'edges': [ + { + 'cursor': 'Mg==', + 'node': { + 'id': 'UmVwb3J0OjM=', + 'title': 'The Return of the King' + } + } + ], + 'pageInfo': { + 'endCursor': 'Mg==', + 'hasNextPage': True, + 'hasPreviousPage': True, + 'startCursor': 'Mg==' + }, + 'totalCount': 3 + } + } +} diff --git a/tests/schema/snapshots/snap_test_viewer.py b/tests/schema/snapshots/snap_test_viewer.py new file mode 100644 index 0000000000000000000000000000000000000000..947e074adf4a51eafab207cdbfb2dfc7672b131b --- /dev/null +++ b/tests/schema/snapshots/snap_test_viewer.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_unauthenticated 1'] = { + 'data': { + 'viewer': None + } +} + +snapshots['test_authenticated 1'] = { + 'data': { + 'viewer': { + 'email': 'winston@wolfe.com', + 'extra': '{"caliber": 45}', + 'firstName': 'Winston', + 'id': 'VXNlcjox', + 'isAuthor': True, + 'lastName': 'Wolfe', + 'openidUid': 'TheWolf' + } + } +} + +snapshots['test_wrong_header 1'] = { + 'errors': [ + { + 'message': 'Wrong Authorization header. Expected: "Bearer <token>"' + } + ] +} + +snapshots['test_wrong_token 1'] = { + 'errors': [ + { + 'message': 'Invalid Token.' + } + ] +} + +snapshots['test_unknown_user 1'] = { + 'data': { + 'viewer': None + } +} diff --git a/tests/schema/test_authors.py b/tests/schema/test_authors.py new file mode 100644 index 0000000000000000000000000000000000000000..e81d03e9e50f0705766b85951359fe7a707ff352 --- /dev/null +++ b/tests/schema/test_authors.py @@ -0,0 +1,176 @@ +import pytest + +from openlobby.core.models import User + +from ..dummy import prepare_authors, prepare_report + + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')] + + +def test_all(client, snapshot): + prepare_authors() + User.objects.create(id=4, is_author=False, username='x') + res = client.post('/graphql', {'query': """ + query { + authors { + totalCount + edges { + cursor + node { + id + firstName + lastName + extra + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_first(client, snapshot): + prepare_authors() + res = client.post('/graphql', {'query': """ + query { + authors (first: 2) { + totalCount + edges { + cursor + node { + id + firstName + lastName + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_first_after(client, snapshot): + prepare_authors() + res = client.post('/graphql', {'query': """ + query { + authors (first: 1, after: "MQ==") { + totalCount + edges { + cursor + node { + id + firstName + lastName + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_last(client, snapshot): + prepare_authors() + res = client.post('/graphql', {'query': """ + query { + authors (last: 2) { + totalCount + edges { + cursor + node { + id + firstName + lastName + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_last_before(client, snapshot): + prepare_authors() + res = client.post('/graphql', {'query': """ + query { + authors (last: 1, before: "Mw==") { + totalCount + edges { + cursor + node { + id + firstName + lastName + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_with_reports(client, snapshot): + prepare_report() + res = client.post('/graphql', {'query': """ + query { + authors { + edges { + node { + id + firstName + lastName + reports { + totalCount + edges { + cursor + node { + id + date + published + title + body + receivedBenefit + providedBenefit + ourParticipants + otherParticipants + extra + } + } + } + } + } + } + } + """}) + snapshot.assert_match(res.json()) diff --git a/tests/schema/test_login_shortcuts.py b/tests/schema/test_login_shortcuts.py new file mode 100644 index 0000000000000000000000000000000000000000..92f44fc4845c1429d155d2219bba100baf02166a --- /dev/null +++ b/tests/schema/test_login_shortcuts.py @@ -0,0 +1,33 @@ +import pytest + +from openlobby.core.models import OpenIdClient + + +pytestmark = pytest.mark.django_db + + +def test_returns_only_shortcuts(client, snapshot): + OpenIdClient.objects.create(id=10, name='foo', issuer='foo') + OpenIdClient.objects.create(id=20, name='bar', issuer='bar', is_shortcut=True) + res = client.post('/graphql', {'query': """ + query { + loginShortcuts { + id + name + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_none(client, snapshot): + OpenIdClient.objects.create(id=10, name='foo') + res = client.post('/graphql', {'query': """ + query { + loginShortcuts { + id + name + } + } + """}) + snapshot.assert_match(res.json()) diff --git a/tests/schema/test_node.py b/tests/schema/test_node.py new file mode 100644 index 0000000000000000000000000000000000000000..9b2adb7f818672f6fff5a3361e147ed1afa684b9 --- /dev/null +++ b/tests/schema/test_node.py @@ -0,0 +1,156 @@ +import pytest +from graphql_relay import to_global_id + +from openlobby.core.auth import create_access_token +from openlobby.core.models import OpenIdClient, User + +from ..dummy import prepare_report + + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')] + + +def test_login_shortcut(client, snapshot): + OpenIdClient.objects.create(id=10, name='foo', issuer='foo', is_shortcut=True) + res = client.post('/graphql', {'query': """ + query {{ + node (id:"{id}") {{ + ... on LoginShortcut {{ + id + name + }} + }} + }} + """.format(id=to_global_id('LoginShortcut', 10))}) + snapshot.assert_match(res.json()) + + +def test_author(client, snapshot): + User.objects.create( + id=5, + is_author=True, + openid_uid='TheWolf', + first_name='Winston', + last_name='Wolfe', + extra={'x': 1}, + ) + res = client.post('/graphql', {'query': """ + query {{ + node (id:"{id}") {{ + ... on Author {{ + id + firstName + lastName + extra + }} + }} + }} + """.format(id=to_global_id('Author', 5))}) + snapshot.assert_match(res.json()) + + +def test_author__returns_only_if_is_author(client, snapshot): + User.objects.create(id=7, is_author=False) + res = client.post('/graphql', {'query': """ + query {{ + node (id:"{id}") {{ + ... on Author {{ + id + }} + }} + }} + """.format(id=to_global_id('Author', 7))}) + snapshot.assert_match(res.json()) + + +def test_report(client, snapshot): + prepare_report() + res = client.post('/graphql', {'query': """ + query {{ + node (id:"{id}") {{ + ... on Report {{ + id + date + published + title + body + receivedBenefit + providedBenefit + ourParticipants + otherParticipants + extra + author {{ + id + firstName + lastName + extra + }} + }} + }} + }} + """.format(id=to_global_id('Report', 1))}) + snapshot.assert_match(res.json()) + + +def test_user__unauthorized(client, snapshot): + User.objects.create(id=8, username='albert', openid_uid='albert@einstein.id', + first_name='Albert', last_name='Einstein', extra={'e': 'mc2'}) + res = client.post('/graphql', {'query': """ + query {{ + node (id:"{id}") {{ + ... on User {{ + id + firstName + lastName + openidUid + isAuthor + extra + }} + }} + }} + """.format(id=to_global_id('User', 8))}) + snapshot.assert_match(res.json()) + + +def test_user__not_a_viewer(client, snapshot): + User.objects.create(id=8, username='albert', openid_uid='albert@einstein.id', + first_name='Albert', last_name='Einstein', extra={'e': 'mc2'}) + User.objects.create(id=2, username='isaac', openid_uid='isaac@newton.id', + first_name='Isaac', last_name='Newton', extra={'apple': 'hit'}) + auth_header = 'Bearer {}'.format(create_access_token('isaac')) + res = client.post('/graphql', {'query': """ + query {{ + node (id:"{id}") {{ + ... on User {{ + id + firstName + lastName + openidUid + isAuthor + extra + }} + }} + }} + """.format(id=to_global_id('User', 8))}, HTTP_AUTHORIZATION=auth_header) + snapshot.assert_match(res.json()) + + +def test_user(client, snapshot): + User.objects.create(id=8, username='albert', openid_uid='albert@einstein.id', + first_name='Albert', last_name='Einstein', extra={'e': 'mc2'}) + auth_header = 'Bearer {}'.format(create_access_token('albert')) + res = client.post('/graphql', {'query': """ + query {{ + node (id:"{id}") {{ + ... on User {{ + id + firstName + lastName + openidUid + isAuthor + extra + }} + }} + }} + """.format(id=to_global_id('User', 8))}, HTTP_AUTHORIZATION=auth_header) + snapshot.assert_match(res.json()) diff --git a/tests/schema/test_search_reports.py b/tests/schema/test_search_reports.py new file mode 100644 index 0000000000000000000000000000000000000000..a980aa37a4a31eb166716a9d9b1a486d61704be5 --- /dev/null +++ b/tests/schema/test_search_reports.py @@ -0,0 +1,211 @@ +import pytest + +from ..dummy import prepare_reports + + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')] + + +def test_all(client, snapshot): + prepare_reports() + res = client.post('/graphql', {'query': """ + query { + searchReports { + totalCount + edges { + cursor + node { + id + date + published + title + body + receivedBenefit + providedBenefit + ourParticipants + otherParticipants + extra + author { + id + firstName + lastName + extra + } + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_query(client, snapshot): + prepare_reports() + res = client.post('/graphql', {'query': """ + query { + searchReports (query: "towers") { + totalCount + edges { + cursor + node { + id + date + published + title + body + receivedBenefit + providedBenefit + ourParticipants + otherParticipants + extra + author { + id + firstName + lastName + extra + } + } + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_highlight(client, snapshot): + prepare_reports() + res = client.post('/graphql', {'query': """ + query { + searchReports (query: "Ring", highlight: true) { + totalCount + edges { + cursor + node { + id + date + published + title + body + receivedBenefit + providedBenefit + ourParticipants + otherParticipants + extra + author { + id + firstName + lastName + extra + } + } + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_first(client, snapshot): + prepare_reports() + res = client.post('/graphql', {'query': """ + query { + searchReports (first: 1) { + totalCount + edges { + cursor + node { + id + title + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_first_after(client, snapshot): + prepare_reports() + res = client.post('/graphql', {'query': """ + query { + searchReports (first: 1, after: "MQ==") { + totalCount + edges { + cursor + node { + id + title + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_last(client, snapshot): + prepare_reports() + res = client.post('/graphql', {'query': """ + query { + searchReports (last: 2) { + totalCount + edges { + cursor + node { + id + title + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_last_before(client, snapshot): + prepare_reports() + res = client.post('/graphql', {'query': """ + query { + searchReports (last: 1, before: "Mw==") { + totalCount + edges { + cursor + node { + id + title + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """}) + snapshot.assert_match(res.json()) diff --git a/tests/schema/test_viewer.py b/tests/schema/test_viewer.py new file mode 100644 index 0000000000000000000000000000000000000000..4ab66d2c54d60bf97fc47ea0d169bf855b85dd3b --- /dev/null +++ b/tests/schema/test_viewer.py @@ -0,0 +1,85 @@ +import pytest + +from openlobby.core.auth import create_access_token +from openlobby.core.models import User + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def setup(): + User.objects.create(id=1, is_author=True, username='wolfe', openid_uid='TheWolf', + first_name='Winston', last_name='Wolfe', email='winston@wolfe.com', + extra={'caliber': 45}) + + +def test_unauthenticated(client, snapshot): + res = client.post('/graphql', {'query': """ + query { + viewer { + id + } + } + """}) + snapshot.assert_match(res.json()) + + +def test_authenticated(client, snapshot): + token = create_access_token('wolfe') + auth_header = 'Bearer {}'.format(token) + res = client.post('/graphql', {'query': """ + query { + viewer { + id + firstName + lastName + email + openidUid + isAuthor + extra + } + } + """}, HTTP_AUTHORIZATION=auth_header) + snapshot.assert_match(res.json()) + + +# integration tests of wrong authentication + +def test_wrong_header(client, snapshot): + token = create_access_token('wolfe') + auth_header = 'WRONG {}'.format(token) + res = client.post('/graphql', {'query': """ + query { + viewer { + id + } + } + """}, HTTP_AUTHORIZATION=auth_header) + snapshot.assert_match(res.json()) + + +def test_wrong_token(client, snapshot): + token = create_access_token('wolfe') + auth_header = 'Bearer XXX{}'.format(token) + res = client.post('/graphql', {'query': """ + query { + viewer { + id + } + } + """}, HTTP_AUTHORIZATION=auth_header) + snapshot.assert_match(res.json()) + + +def test_unknown_user(client, snapshot): + token = create_access_token('unknown') + auth_header = 'Bearer {}'.format(token) + res = client.post('/graphql', {'query': """ + query { + viewer { + id + } + } + """}, HTTP_AUTHORIZATION=auth_header) + snapshot.assert_match(res.json()) diff --git a/tests/snapshots/snap_test_management.py b/tests/snapshots/snap_test_management.py index 28674d9096c3c1966df693b351fdd87c71015203..ff10ca76366130dd48abf3d4828fa3acb96a314b 100644 --- a/tests/snapshots/snap_test_management.py +++ b/tests/snapshots/snap_test_management.py @@ -44,59 +44,6 @@ snapshots['test_create_index__check_analysis_settings 1'] = { snapshots['test_create_index__check_mappings 1'] = { 'mappings': { - 'login-attempt': { - 'properties': { - 'client_id': { - 'type': 'keyword' - }, - 'client_secret': { - 'type': 'keyword' - }, - 'expiration': { - 'type': 'integer' - }, - 'nonce': { - 'type': 'keyword' - }, - 'openid_uid': { - 'type': 'keyword' - }, - 'redirect_uri': { - 'type': 'keyword' - }, - 'state': { - 'type': 'keyword' - } - } - }, - 'open-id-client': { - 'properties': { - 'authorization_endpoint': { - 'type': 'keyword' - }, - 'client_id': { - 'type': 'keyword' - }, - 'client_secret': { - 'type': 'keyword' - }, - 'is_shortcut': { - 'type': 'boolean' - }, - 'issuer': { - 'type': 'keyword' - }, - 'name_x': { - 'type': 'keyword' - }, - 'token_endpoint': { - 'type': 'keyword' - }, - 'userinfo_endpoint': { - 'type': 'keyword' - } - } - }, 'report': { 'properties': { 'author_id': { @@ -169,59 +116,6 @@ snapshots['test_create_index__check_mappings 1'] = { snapshots['test_init_alias 1'] = { 'mappings': { - 'login-attempt': { - 'properties': { - 'client_id': { - 'type': 'keyword' - }, - 'client_secret': { - 'type': 'keyword' - }, - 'expiration': { - 'type': 'integer' - }, - 'nonce': { - 'type': 'keyword' - }, - 'openid_uid': { - 'type': 'keyword' - }, - 'redirect_uri': { - 'type': 'keyword' - }, - 'state': { - 'type': 'keyword' - } - } - }, - 'open-id-client': { - 'properties': { - 'authorization_endpoint': { - 'type': 'keyword' - }, - 'client_id': { - 'type': 'keyword' - }, - 'client_secret': { - 'type': 'keyword' - }, - 'is_shortcut': { - 'type': 'boolean' - }, - 'issuer': { - 'type': 'keyword' - }, - 'name_x': { - 'type': 'keyword' - }, - 'token_endpoint': { - 'type': 'keyword' - }, - 'userinfo_endpoint': { - 'type': 'keyword' - } - } - }, 'report': { 'properties': { 'author_id': { @@ -294,59 +188,6 @@ snapshots['test_init_alias 1'] = { snapshots['test_reindex__check_new_index 1'] = { 'mappings': { - 'login-attempt': { - 'properties': { - 'client_id': { - 'type': 'keyword' - }, - 'client_secret': { - 'type': 'keyword' - }, - 'expiration': { - 'type': 'integer' - }, - 'nonce': { - 'type': 'keyword' - }, - 'openid_uid': { - 'type': 'keyword' - }, - 'redirect_uri': { - 'type': 'keyword' - }, - 'state': { - 'type': 'keyword' - } - } - }, - 'open-id-client': { - 'properties': { - 'authorization_endpoint': { - 'type': 'keyword' - }, - 'client_id': { - 'type': 'keyword' - }, - 'client_secret': { - 'type': 'keyword' - }, - 'is_shortcut': { - 'type': 'boolean' - }, - 'issuer': { - 'type': 'keyword' - }, - 'name_x': { - 'type': 'keyword' - }, - 'token_endpoint': { - 'type': 'keyword' - }, - 'userinfo_endpoint': { - 'type': 'keyword' - } - } - }, 'report': { 'properties': { 'author_id': { @@ -419,59 +260,6 @@ snapshots['test_reindex__check_new_index 1'] = { snapshots['test_init_documents 1'] = { 'mappings': { - 'login-attempt': { - 'properties': { - 'client_id': { - 'type': 'keyword' - }, - 'client_secret': { - 'type': 'keyword' - }, - 'expiration': { - 'type': 'integer' - }, - 'nonce': { - 'type': 'keyword' - }, - 'openid_uid': { - 'type': 'keyword' - }, - 'redirect_uri': { - 'type': 'keyword' - }, - 'state': { - 'type': 'keyword' - } - } - }, - 'open-id-client': { - 'properties': { - 'authorization_endpoint': { - 'type': 'keyword' - }, - 'client_id': { - 'type': 'keyword' - }, - 'client_secret': { - 'type': 'keyword' - }, - 'is_shortcut': { - 'type': 'boolean' - }, - 'issuer': { - 'type': 'keyword' - }, - 'name_x': { - 'type': 'keyword' - }, - 'token_endpoint': { - 'type': 'keyword' - }, - 'userinfo_endpoint': { - 'type': 'keyword' - } - } - }, 'report': { 'properties': { 'author_id': { diff --git a/tests/snapshots/snap_test_middleware.py b/tests/snapshots/snap_test_middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..2be80f56b2e39b8ef8bb4d16440d117633067ad3 --- /dev/null +++ b/tests/snapshots/snap_test_middleware.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_wrong_header 1'] = { + 'errors': [ + { + 'message': 'Wrong Authorization header. Expected: "Bearer <token>"' + } + ] +} + +snapshots['test_invalid_token 1'] = { + 'errors': [ + { + 'message': 'Invalid Token.' + } + ] +} diff --git a/tests/snapshots/snap_test_models.py b/tests/snapshots/snap_test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..61f5a0a38292a80edc46aa69a4e27d7342a62a3f --- /dev/null +++ b/tests/snapshots/snap_test_models.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot, GenericRepr + + +snapshots = Snapshot() + +snapshots['test_report_is_saved_in_elasticsearch 1'] = [ + GenericRepr("ReportDoc(index='report_ded_test', doc_type='report_doc', id='2')") +] diff --git a/tests/test_auth.py b/tests/test_auth.py index 51d1e97b109d8dd8c59cd0d1af07bacd0cfbb94f..580bcf807c08a23d34346ba608f186b3993d5429 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,54 +1,34 @@ +from django.conf import settings import time import jwt import pytest -from openlobby.settings import ( - SECRET_KEY, - JWT_ALGORITHM, - LOGIN_ATTEMPT_EXPIRATION, - SESSION_EXPIRATION, -) -from openlobby.auth import ( - get_login_attempt_expiration_time, - get_session_expiration_time, +from openlobby.core.auth import ( create_access_token, parse_access_token, ) -def test_get_login_attempt_expiration_time(): - expiration = get_login_attempt_expiration_time() - expected = int(time.time() + LOGIN_ATTEMPT_EXPIRATION) - assert expected <= expiration <= expected + 1 - - -def test_get_session_expiration_time(): - expiration = get_session_expiration_time() - expected = int(time.time() + SESSION_EXPIRATION) - assert expected <= expiration <= expected + 1 - - def test_create_access_token(): - session_id = 'idkfa' - expiration = int(time.time() + 10) - token = create_access_token(session_id, expiration) - payload = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALGORITHM]) + username = 'idkfa' + token = create_access_token(username) + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) assert isinstance(token, str) - assert payload['sub'] == session_id - assert payload['exp'] == expiration + assert payload['sub'] == username + expected_expiration = int(time.time() + settings.SESSION_EXPIRATION) + assert expected_expiration <= payload['exp'] <= expected_expiration + 1 def test_parse_access_token(): - session_id = 'iddqd' - expiration = int(time.time() + 10) - token = create_access_token(session_id, expiration) + username = 'iddqd' + token = create_access_token(username) result = parse_access_token(token) - assert result == session_id + assert result == username def test_parse_access_token__expired(): - session_id = 'idfa' + username = 'idfa' expiration = int(time.time() - 1) - token = create_access_token(session_id, expiration) + token = create_access_token(username, expiration) with pytest.raises(jwt.ExpiredSignatureError): parse_access_token(token) diff --git a/tests/test_documents.py b/tests/test_documents.py deleted file mode 100644 index 630dd9397315d2b85593f57ed34d6d30055c8850..0000000000000000000000000000000000000000 --- a/tests/test_documents.py +++ /dev/null @@ -1,40 +0,0 @@ -import time - -from openlobby.documents import SessionDoc, UserDoc - - -class TestSessionDoc: - - def test_get_active__does_not_exists(self, es, index): - session = SessionDoc.get_active('foo', es=es, index=index) - assert session is None - - def test_get_active__expired(self, es, index): - s = SessionDoc(user_id='foo', expiration=123456789) - s.save(using=es, index=index) - session = SessionDoc.get_active(s.meta.id, es=es, index=index) - assert session is None - - def test_get_active(self, es, index): - expiration = time.time() + 100 - s = SessionDoc(user_id='foo', expiration=expiration) - s.save(using=es, index=index, refresh='true') - session = SessionDoc.get_active(s.meta.id, es=es, index=index) - assert session is not None - assert session.user_id == 'foo' - assert session.expiration == expiration - - -class TestUserDoc: - - def test_get_by_openid_uid__does_not_exists(self, es, index): - user = UserDoc.get_by_openid_uid('foo', es=es, index=index) - assert user is None - - def test_get_by_openid_uid(self, es, index): - for uid in ['foo', 'bar', 'baz']: - u = UserDoc(openid_uid=uid) - u.save(using=es, index=index, refresh='true') - user = UserDoc.get_by_openid_uid('bar', es=es, index=index) - assert user is not None - assert user.openid_uid == 'bar' diff --git a/tests/test_management.py b/tests/test_management.py deleted file mode 100644 index ef1c7389c373ac633f472920e7105c26ebf90ea3..0000000000000000000000000000000000000000 --- a/tests/test_management.py +++ /dev/null @@ -1,103 +0,0 @@ -import pytest - -from openlobby.management import ( - AliasAlreadyExistsError, - IndexAlreadyExistsError, - create_index, - get_new_index_version, - init_alias, - init_documents, - reindex, -) -from openlobby.documents import UserDoc - - -def test_create_index(es, index_name): - create_index(es, index_name) - assert es.indices.exists(index_name) - - -def test_create_index__already_exists(es, index_name): - assert es.indices.create(index_name) - with pytest.raises(IndexAlreadyExistsError): - create_index(es, index_name) - - -def test_init_documents(es, index_name, snapshot): - assert es.indices.create(index_name) - init_documents(es, index_name) - mappings = es.indices.get_mapping(index_name) - snapshot.assert_match(mappings[index_name]) - - -def test_create_index__check_analysis_settings(es, index_name, snapshot): - create_index(es, index_name) - settings = es.indices.get_settings(index=index_name) - snapshot.assert_match(settings[index_name]['settings']['index']['analysis']) - - -def test_create_index__check_mappings(es, index_name, snapshot): - create_index(es, index_name) - mappings = es.indices.get_mapping(index_name) - snapshot.assert_match(mappings[index_name]) - - -@pytest.mark.parametrize('old, new', [ - ('foo_v1', 'foo_v2'), - ('bar_v9', 'bar_v10'), - ('two_words_v5', 'two_words_v6'), -]) -def test_get_new_index_version(old, new): - assert get_new_index_version(old) == new - - -def test_get_new_index_version__wrong_name(): - with pytest.raises(ValueError): - get_new_index_version('foo') - - -def test_init_alias(es, index_name, snapshot): - alias = index_name - init_alias(es, alias) - index = '{}_v1'.format(alias) - assert es.indices.exists(alias) - assert es.indices.exists(index) - mappings = es.indices.get_mapping(index) - snapshot.assert_match(mappings[index]) - - -def test_init_alias__already_exists(es, index_name): - alias = index_name - init_alias(es, alias) - with pytest.raises(AliasAlreadyExistsError): - init_alias(es, alias) - - -def test_reindex__check_new_index(es, index_name, snapshot): - alias = index_name - init_alias(es, alias) - reindex(es, alias) - new_index = '{}_v2'.format(alias) - assert es.indices.exists(new_index) - mappings = es.indices.get_mapping(new_index) - snapshot.assert_match(mappings[new_index]) - - -def test_reindex__check_alias(es, index_name): - alias = index_name - init_alias(es, alias) - reindex(es, alias) - new_index = '{}_v2'.format(alias) - assert es.indices.exists(alias) - res = es.indices.get_alias(name=alias) - assert res == {new_index: {'aliases': {alias: {}}}} - - -def test_reindex__with_some_data(es, index_name, snapshot): - alias = index_name - init_alias(es, alias) - user = UserDoc(name='The Black Knight') - user.save(using=es, index=alias, refresh='true') - reindex(es, alias) - new_user = UserDoc.get(id=user.meta.id, using=es, index=alias) - assert new_user.name == 'The Black Knight' diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..37dbd174bdf600ddee2fbb0b17ae53813e69c4a9 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,77 @@ +import pytest +import json +from unittest.mock import Mock + +from openlobby.core.auth import create_access_token +from openlobby.core.middleware import TokenAuthMiddleware +from openlobby.core.models import User + + +pytestmark = pytest.mark.django_db + + +def test_no_auth_header(): + request = Mock() + request.user = None + request.META.get.return_value = None + + middleware = TokenAuthMiddleware(lambda r: r) + response = middleware(request) + + request.META.get.assert_called_once_with('HTTP_AUTHORIZATION') + assert response == request + assert response.user is None + + +def test_authorized_user(): + user = User.objects.create(username='wolfe', first_name='Winston', + last_name='Wolfe', email='winston@wolfe.com') + request = Mock() + request.user = None + request.META.get.return_value = 'Bearer {}'.format(create_access_token('wolfe')) + + middleware = TokenAuthMiddleware(lambda r: r) + response = middleware(request) + + request.META.get.assert_called_once_with('HTTP_AUTHORIZATION') + assert response == request + assert response.user == user + + +def test_wrong_header(snapshot): + request = Mock() + request.user = None + request.META.get.return_value = 'WRONG {}'.format(create_access_token('unknown')) + + middleware = TokenAuthMiddleware(lambda r: r) + response = middleware(request) + + request.META.get.assert_called_once_with('HTTP_AUTHORIZATION') + assert response.status_code == 400 + snapshot.assert_match(json.loads(response.content)) + + +def test_invalid_token(snapshot): + request = Mock() + request.user = None + request.META.get.return_value = 'Bearer XXX{}'.format(create_access_token('unknown')) + + middleware = TokenAuthMiddleware(lambda r: r) + response = middleware(request) + + request.META.get.assert_called_once_with('HTTP_AUTHORIZATION') + assert response.status_code == 401 + snapshot.assert_match(json.loads(response.content)) + + +def test_unknown_user(snapshot): + request = Mock() + request.user = None + request.META.get.return_value = 'Bearer {}'.format(create_access_token('unknown')) + + middleware = TokenAuthMiddleware(lambda r: r) + response = middleware(request) + + request.META.get.assert_called_once_with('HTTP_AUTHORIZATION') + assert response == request + assert response.user is None diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..9ef9ae1bf86de445b916f4cc919edf6e38161f30 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,76 @@ +import pytest +import arrow +from django.conf import settings +from unittest.mock import patch + +from openlobby.core.models import Report, User, OpenIdClient, LoginAttempt +from openlobby.core.documents import ReportDoc + + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')] + + +def test_report__marks_user_as_author_on_save(): + author = User.objects.create(id=1, is_author=False) + date = arrow.get(2018, 1, 1).datetime + Report.objects.create(author=author, date=date, body='Lorem ipsum.') + user = User.objects.get(id=1) + assert user.is_author + + +def test_report__is_saved_in_elasticsearch(): + author = User.objects.create(id=6) + date = arrow.get(2018, 1, 1).datetime + published = arrow.get(2018, 1, 2).datetime + Report.objects.create( + id=3, + author=author, + date=date, + published=published, + title='It happened', + body='Lorem ipsum.', + received_benefit='coffee', + provided_benefit='tea', + our_participants='me', + other_participants='them', + extra={'a': 3}, + ) + docs = list(ReportDoc.search()) + assert len(docs) == 1 + doc = docs[0] + assert doc.meta.id == '3' + assert doc.author_id == 6 + assert doc.date == date + assert doc.published == published + assert doc.title == 'It happened' + assert doc.body == 'Lorem ipsum.' + assert doc.received_benefit == 'coffee' + assert doc.provided_benefit == 'tea' + assert doc.our_participants == 'me' + assert doc.other_participants == 'them' + assert doc.extra == {'a': 3} + + +def test_report__save_works_with_no_extra(): + author = User.objects.create(id=6) + date = arrow.get(2018, 1, 1).datetime + Report.objects.create( + id=7, + author=author, + date=date, + published=date, + body='Lorem ipsum.', + ) + docs = list(ReportDoc.search()) + assert len(docs) == 1 + doc = docs[0] + assert doc.meta.id == '7' + assert doc.extra is None + + +def test_login_attempt__default_expiration(): + client = OpenIdClient.objects.create(name='a', client_id='b', client_secret='c') + with patch('openlobby.core.models.time.time', return_value=10000): + attempt = LoginAttempt.objects.create(openid_client=client, state='foo', + app_redirect_uri='http://openlobby/app') + assert attempt.expiration == 10000 + settings.LOGIN_ATTEMPT_EXPIRATION diff --git a/tests/test_paginator.py b/tests/test_paginator.py index 590788fd47051349da10dfd359f0472afa39f254..8a8d08c8fb823235a518ef05ddf7c8c27c056743 100644 --- a/tests/test_paginator.py +++ b/tests/test_paginator.py @@ -1,6 +1,12 @@ import pytest -from openlobby.paginator import PER_PAGE, encode_cursor, decode_cursor, Paginator +from openlobby.core.api.paginator import ( + PER_PAGE, + encode_cursor, + decode_cursor, + Paginator, + MissingBeforeValueError, +) @pytest.mark.parametrize('num, cursor', [ @@ -41,10 +47,8 @@ def test_paginator__custom_per_page(): @pytest.mark.parametrize('kw, slice_from, slice_to', [ ({'first': 15}, 0, 15), ({'first': 5, 'after': encode_cursor(3)}, 3, 8), - ({'last': 3}, 7, 10), ({'last': 8, 'before': encode_cursor(20)}, 11, 19), # overflow of slice_from - ({'last': 15}, 0, 10), ({'last': 100, 'before': encode_cursor(42)}, 0, 41), # preffer first before last if both provided ({'first': 20, 'last': 4}, 0, 20), @@ -55,6 +59,11 @@ def test_paginator__input_combinations(kw, slice_from, slice_to): assert paginator.slice_to == slice_to +def test_paginator__last_without_before(): + with pytest.raises(MissingBeforeValueError): + Paginator(last=1) + + @pytest.mark.parametrize('kw, total, previous, next, start, end', [ ({}, 10, False, False, 1, 10), ({}, 15, False, True, 1, 10), @@ -66,10 +75,6 @@ def test_paginator__input_combinations(kw, slice_from, slice_to): ({'first': 3, 'after': encode_cursor(7)}, 20, True, True, 8, 10), ({'first': 3, 'after': encode_cursor(17)}, 20, True, False, 18, 20), ({'first': 5, 'after': encode_cursor(17)}, 20, True, False, 18, 20), - ({'last': 1}, 10, True, False, 10, 10), - ({'last': 5}, 10, True, False, 6, 10), - ({'last': 15}, 10, False, False, 1, 10), - ({'last': 10}, 10, False, False, 1, 10), ({'last': 4, 'before': encode_cursor(10)}, 20, True, True, 6, 9), ({'last': 4, 'before': encode_cursor(5)}, 20, False, True, 1, 4), ({'last': 4, 'before': encode_cursor(3)}, 20, False, True, 1, 2), @@ -89,7 +94,7 @@ def test_paginator__get_page_info(kw, total, previous, next, start, end): @pytest.mark.parametrize('kw, num, cursor', [ ({}, 3, encode_cursor(3)), ({'first': 6, 'after': encode_cursor(1)}, 3, encode_cursor(4)), - ({'last': 6}, 3, encode_cursor(7)), + ({'last': 6, 'before': encode_cursor(11)}, 3, encode_cursor(7)), ]) def test_paginator__get_edge_cursor(kw, num, cursor): paginator = Paginator(**kw) diff --git a/tests/test_sanitizers.py b/tests/test_sanitizers.py index 7ddf1cba7488af01b55a3109fc1577bb4537c4f7..74ed2722145b8b924253ebe6046baacd866eac90 100644 --- a/tests/test_sanitizers.py +++ b/tests/test_sanitizers.py @@ -1,6 +1,6 @@ import pytest -from openlobby.sanitizers import strip_all_tags, extract_text +from openlobby.core.api.sanitizers import strip_all_tags, extract_text @pytest.mark.parametrize('input, text', [ diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000000000000000000000000000000000000..a77314f3e9489b4725e3b570dd830cb570467400 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,64 @@ +import pytest + +from openlobby.core.api.paginator import Paginator, encode_cursor +from openlobby.core.search import query_reports, reports_by_author + +from .dummy import prepare_reports + + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')] + + +@pytest.mark.parametrize('query, expected_ids', [ + ('', [2, 3, 1]), + ('sauron', [2, 3]), + ('towers', [2]), + ('Aragorn Gandalf', [3, 1]), +]) +def test_query_reports(query, expected_ids): + prepare_reports() + paginator = Paginator() + response = query_reports(query, paginator) + assert expected_ids == [int(r.meta.id) for r in response] + + +def test_query_reports__highlight(): + prepare_reports() + paginator = Paginator() + query = 'King' + response = query_reports(query, paginator, highlight=True) + doc = response.hits[0] + assert '<mark>King</mark>' in doc.meta.highlight.title[0] + assert '<mark>King</mark>' in doc.meta.highlight.body[0] + + +@pytest.mark.parametrize('first, after, expected_ids', [ + (2, None, [2, 3]), + (2, encode_cursor(1), [3, 1]), +]) +def test_query_reports__pagination(first, after, expected_ids): + prepare_reports() + query = '' + paginator = Paginator(first=first, after=after) + response = query_reports(query, paginator) + assert expected_ids == [int(r.meta.id) for r in response] + + +def test_reports_by_author(): + prepare_reports() + author_id = 1 + paginator = Paginator() + response = reports_by_author(author_id, paginator) + assert [3, 1] == [int(r.meta.id) for r in response] + + +@pytest.mark.parametrize('first, after, expected_ids', [ + (1, None, [3]), + (1, encode_cursor(1), [1]), +]) +def test_reports_by_author__pagination(first, after, expected_ids): + prepare_reports() + author_id = 1 + paginator = Paginator(first=first, after=after) + response = reports_by_author(author_id, paginator) + assert expected_ids == [int(r.meta.id) for r in response] diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..b67547d90cc05e2d2e095b1910bfce1cbdc4d2a0 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,101 @@ +import pytest +from django.urls import reverse +from unittest.mock import patch +import urllib.parse + +from openlobby.core.auth import parse_access_token +from openlobby.core.models import User, OpenIdClient, LoginAttempt +from openlobby.core.openid import register_client + + +pytestmark = pytest.mark.django_db + + +def test_login_redirect__new_user(client, issuer): + state = 'IDDQD' + sub = 'IDKFA' + openid_uid = 'elon.musk@openid' + first_name = 'Elon' + last_name = 'Musk' + email = 'elon.musk@tesla.com' + app_redirect_uri = 'http://doom.is.legend' + + oc = register_client(issuer) + oid_client = OpenIdClient.objects.create(name='Test', issuer=issuer, + client_id=oc.client_id, client_secret=oc.client_secret) + LoginAttempt.objects.create(openid_client=oid_client, state=state, + app_redirect_uri=app_redirect_uri, openid_uid=openid_uid) + + # we are not testing real redirect url params, so we mock communication with + # OpenID Provider + user_info = { + 'sub': sub, + 'given_name': first_name, + 'family_name': last_name, + 'email': email, + } + with patch('openlobby.core.views.get_user_info', return_value=user_info) as mock: + response = client.get(reverse('login-redirect'), {'state': state}) + m_client, m_query_string = mock.call_args[0] + assert m_client.client_id == oc.client_id + assert m_query_string == 'state={}'.format(state) + + assert response.status_code == 302 + url, query_string = response.url.split('?') + qs = urllib.parse.parse_qs(query_string) + assert url == app_redirect_uri + assert sub == parse_access_token(qs['token'][0]) + + user = User.objects.get() + assert user.username == sub + assert user.first_name == first_name + assert user.last_name == last_name + assert user.email == email + assert user.openid_uid == openid_uid + + +def test_login_redirect__existing_user(client, issuer): + state = 'IDDQD' + sub = 'IDKFA' + openid_uid = 'elon.musk@openid' + first_name = 'Elon' + last_name = 'Musk' + email = 'elon.musk@tesla.com' + app_redirect_uri = 'http://doom.is.legend' + + oc = register_client(issuer) + oid_client = OpenIdClient.objects.create(name='Test', issuer=issuer, + client_id=oc.client_id, client_secret=oc.client_secret) + LoginAttempt.objects.create(openid_client=oid_client, state=state, + app_redirect_uri=app_redirect_uri, openid_uid=openid_uid) + User.objects.create(username=sub, first_name=first_name, last_name=last_name, + email=email, openid_uid=openid_uid) + + # we are not testing real redirect url params, so we mock communication with + # OpenID Provider + user_info = { + 'sub': sub, + # return different details + 'given_name': 'Elons', + 'family_name': 'Mustache', + 'email': 'elons.mustache@spacex.com', + } + with patch('openlobby.core.views.get_user_info', return_value=user_info) as mock: + response = client.get(reverse('login-redirect'), {'state': state}) + m_client, m_query_string = mock.call_args[0] + assert m_client.client_id == oc.client_id + assert m_query_string == 'state={}'.format(state) + + assert response.status_code == 302 + url, query_string = response.url.split('?') + qs = urllib.parse.parse_qs(query_string) + assert url == app_redirect_uri + assert sub == parse_access_token(qs['token'][0]) + + # check there is still just one user, who's details are not updated + user = User.objects.get() + assert user.username == sub + assert user.first_name == first_name + assert user.last_name == last_name + assert user.email == email + assert user.openid_uid == openid_uid