From 94a3d5dc103e37b41a02b5c1c611621e91ba4b75 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Mon, 21 Jan 2019 23:04:00 +0100 Subject: [PATCH] API is now pretty much tested. --- .gitignore | 4 + src/api/handlers.py | 5 + .../tests/res/responses/books-grandchild.json | 16 +- src/api/tests/res/responses/ebooks.json | 10 +- src/api/tests/tests.py | 284 ++++++++++++++++-- src/catalogue/fixtures/test-books.yaml | 14 +- 6 files changed, 286 insertions(+), 47 deletions(-) diff --git a/.gitignore b/.gitignore index a1c1ad15a..cf605b2b4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,10 @@ thumbs.db # PyCharm .idea +# Emacs +\#*\# +.\#* + # Tags file TAGS diff --git a/src/api/handlers.py b/src/api/handlers.py index eb18e0502..cd24561f4 100644 --- a/src/api/handlers.py +++ b/src/api/handlers.py @@ -63,6 +63,7 @@ def read_tags(tags, request, allowed): def process(category, slug): if category == 'book': + # FIXME: Unused? try: books.append(Book.objects.get(slug=slug)) except Book.DoesNotExist: @@ -221,6 +222,7 @@ class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails): model = Book fields = book_list_fields + # FIXME: Unused? @classmethod def genres(cls, book): """ Returns all media for a book. """ @@ -239,6 +241,7 @@ class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails): are returned. """ if pk is not None: + # FIXME: Unused? try: return Book.objects.get(pk=pk) except Book.DoesNotExist: @@ -606,6 +609,7 @@ class TagsHandler(BaseHandler, TagDetails): def read(self, request, category=None, pk=None): """ Lists all tags in the category (eg. all themes). """ if pk is not None: + # FIXME: Unused? try: return Tag.objects.exclude(category='set').get(pk=pk) except Book.DoesNotExist: @@ -759,6 +763,7 @@ class UserDataHandler(BaseHandler): class UserShelfHandler(BookDetailHandler): fields = book_list_fields + ['liked'] + # FIXME: Unused? def parse_bool(self, s): if s in ('true', 'false'): return s == 'true' diff --git a/src/api/tests/res/responses/books-grandchild.json b/src/api/tests/res/responses/books-grandchild.json index faf8c783e..5d407b823 100644 --- a/src/api/tests/res/responses/books-grandchild.json +++ b/src/api/tests/res/responses/books-grandchild.json @@ -4,9 +4,9 @@ "html": "Fragment", "title": "Parent, Child" }, - "txt": "https://example.com/media/txt/grandchild.txt", + "txt": "https://example.com/katalog/pobierz/grandchild.txt", "children": [], - "xml": "", + "xml": "https://example.com/katalog/pobierz/grandchild.xml", "genres": [ { "url": "https://example.com/katalog/gatunek/sonet/", @@ -17,9 +17,9 @@ ], "title": "Grandchild", "media": [], - "html": "https://example.com/media/html/grandchild.html", - "preview": false, - "fb2": "https://example.com/media/fb2/grandchild.fb2", + "html": "https://example.com/katalog/pobierz/grandchild.html", + "preview": true, + "fb2": "https://example.com/katalog/pobierz/grandchild.fb2", "kinds": [], "parent": { "kind": "", @@ -42,11 +42,11 @@ "simple_cover": "", "authors": [], "audio_length": "", - "epub": "", + "epub": "https://example.com/katalog/pobierz/grandchild.epub", "cover_thumb": "", - "mobi": "", + "mobi": "https://example.com/katalog/pobierz/grandchild.mobi", "url": "https://example.com/katalog/lektura/grandchild/", "cover": "", - "pdf": "", + "pdf": "https://example.com/katalog/pobierz/grandchild.pdf", "simple_thumb": "" } diff --git a/src/api/tests/res/responses/ebooks.json b/src/api/tests/res/responses/ebooks.json index 171c153a9..9c4659a1b 100644 --- a/src/api/tests/res/responses/ebooks.json +++ b/src/api/tests/res/responses/ebooks.json @@ -12,16 +12,16 @@ "epub": "" }, { - "fb2": "https://example.com/media/fb2/grandchild.fb2", - "mobi": "", + "fb2": "https://example.com/katalog/pobierz/grandchild.fb2", + "mobi": "https://example.com/katalog/pobierz/grandchild.mobi", "title": "Grandchild", "author": "", "cover": "", "href": "https://example.com/api/books/grandchild/", - "pdf": "", - "txt": "https://example.com/media/txt/grandchild.txt", + "pdf": "https://example.com/katalog/pobierz/grandchild.pdf", + "txt": "https://example.com/katalog/pobierz/grandchild.txt", "slug": "grandchild", - "epub": "" + "epub": "https://example.com/katalog/pobierz/grandchild.epub" }, { "fb2": "", diff --git a/src/api/tests/tests.py b/src/api/tests/tests.py index 5486b5f53..ccee689f9 100644 --- a/src/api/tests/tests.py +++ b/src/api/tests/tests.py @@ -2,12 +2,22 @@ # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. # +from base64 import b64encode from os import path +import hashlib +import hmac import json +from StringIO import StringIO +from time import time +from urllib import quote, urlencode +from urlparse import parse_qs +from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.test.utils import override_settings +from mock import patch +from piston.models import Consumer, Token from catalogue.models import Book, Tag from picture.forms import PictureImportForm @@ -31,7 +41,8 @@ class ApiTest(TestCase): def assert_json_response(self, url, name): data = self.load_json(url) - with open(path.join(path.dirname(__file__), 'res', 'responses', name)) as f: + filename = path.join(path.dirname(__file__), 'res', 'responses', name) + with open(filename) as f: good_data = json.load(f) self.assertEqual(data, good_data, json.dumps(data, indent=4)) @@ -45,7 +56,8 @@ class BookTests(ApiTest): def setUp(self): self.tag = Tag.objects.create(category='author', slug='joe') self.book = Book.objects.create(title='A Book', slug='a-book') - self.book_tagged = Book.objects.create(title='Tagged Book', slug='tagged-book') + self.book_tagged = Book.objects.create( + title='Tagged Book', slug='tagged-book') self.book_tagged.tags = [self.tag] self.book_tagged.save() @@ -69,7 +81,8 @@ class BookTests(ApiTest): class TagTests(ApiTest): def setUp(self): - self.tag = Tag.objects.create(category='author', slug='joe', name='Joe') + self.tag = Tag.objects.create( + category='author', slug='joe', name='Joe') self.book = Book.objects.create(title='A Book', slug='a-book') self.book.tags = [self.tag] self.book.save() @@ -89,9 +102,15 @@ class PictureTests(ApiTest): def test_publish(self): slug = "kandinsky-composition-viii" xml = SimpleUploadedFile( - 'composition8.xml', open(path.join(picture.tests.__path__[0], "files", slug + ".xml")).read()) + 'composition8.xml', + open(path.join( + picture.tests.__path__[0], "files", slug + ".xml" + )).read()) img = SimpleUploadedFile( - 'kompozycja-8.png', open(path.join(picture.tests.__path__[0], "files", slug + ".png")).read()) + 'kompozycja-8.png', + open(path.join( + picture.tests.__path__[0], "files", slug + ".png" + )).read()) import_form = PictureImportForm({}, { 'picture_xml_file': xml, @@ -109,8 +128,8 @@ class BooksTests(ApiTest): fixtures = ['test-books.yaml'] def test_books(self): - self.assert_json_response('/api/books/', 'books.json') - self.assert_json_response('/api/books/?new_api=true', 'books.json') + self.assert_json_response('/api/books/', 'books.json') + self.assert_json_response('/api/books/?new_api=true', 'books.json') self.assert_slugs('/api/audiobooks/', ['parent']) self.assert_slugs('/api/daisy/', ['parent']) @@ -120,18 +139,25 @@ class BooksTests(ApiTest): # Book paging. self.assert_slugs('/api/books/after/grandchild/count/1/', ['parent']) - self.assert_slugs('/api/books/?new_api=true&after=$grandchild$3&count=1', ['parent']) + self.assert_slugs( + '/api/books/?new_api=true&after=$grandchild$3&count=1', ['parent']) # By tag. - self.assert_slugs('/api/authors/john-doe/books/', ['parent']) - self.assert_slugs('/api/genres/sonet/books/?authors=john-doe', ['parent']) + self.assert_slugs('/api/authors/john-doe/books/', ['parent']) + self.assert_slugs( + '/api/genres/sonet/books/?authors=john-doe', + ['parent']) # It is probably a mistake that this doesn't filter: - self.assert_slugs('/api/books/?authors=john-doe', ['child', 'grandchild', 'parent']) + self.assert_slugs( + '/api/books/?authors=john-doe', + ['child', 'grandchild', 'parent']) - # Parent books by tag. + # Parent books by tag. # Notice this contains a grandchild, if a child doesn't have the tag. # This probably isn't really intended behavior and should be redefined. - self.assert_slugs('/api/genres/sonet/parent_books/', ['grandchild', 'parent']) + self.assert_slugs( + '/api/genres/sonet/parent_books/', + ['grandchild', 'parent']) def test_ebooks(self): self.assert_json_response('/api/ebooks/', 'ebooks.json') @@ -139,18 +165,25 @@ class BooksTests(ApiTest): def test_filter_books(self): self.assert_json_response('/api/filter-books/', 'filter-books.json') self.assert_slugs( - '/api/filter-books/?lektura=false&preview=false', + '/api/filter-books/?lektura=false', ['child', 'grandchild', 'parent']) self.assert_slugs( '/api/filter-books/?lektura=true', []) - Book.objects.filter(slug='child').update(preview=True) - self.assert_slugs('/api/filter-books/?preview=true', ['child']) - self.assert_slugs('/api/filter-books/?preview=false', ['grandchild', 'parent']) + self.assert_slugs( + '/api/filter-books/?preview=true', + ['grandchild']) + self.assert_slugs( + '/api/filter-books/?preview=false', + ['child', 'parent']) - self.assert_slugs('/api/filter-books/?audiobook=true', ['parent']) - self.assert_slugs('/api/filter-books/?audiobook=false', ['child', 'grandchild']) + self.assert_slugs( + '/api/filter-books/?audiobook=true', + ['parent']) + self.assert_slugs( + '/api/filter-books/?audiobook=false', + ['child', 'grandchild']) self.assert_slugs('/api/filter-books/?genres=wiersz', ['child']) @@ -158,25 +191,33 @@ class BooksTests(ApiTest): def test_collections(self): self.assert_json_response('/api/collections/', 'collections.json') - self.assert_json_response('/api/collections/a-collection/', 'collection.json') + self.assert_json_response( + '/api/collections/a-collection/', + 'collection.json') def test_book(self): - self.assert_json_response('/api/books/parent/', 'books-parent.json') - self.assert_json_response('/api/books/child/', 'books-child.json') - self.assert_json_response('/api/books/grandchild/', 'books-grandchild.json') + self.assert_json_response('/api/books/parent/', 'books-parent.json') + self.assert_json_response('/api/books/child/', 'books-child.json') + self.assert_json_response( + '/api/books/grandchild/', + 'books-grandchild.json') def test_tags(self): - # List of tags by category. - self.assert_json_response('/api/genres/', 'tags.json') + # List of tags by category. + self.assert_json_response('/api/genres/', 'tags.json') def test_fragments(self): # This is not supported, though it probably should be. - #self.assert_json_response('/api/books/child/fragments/', 'fragments.json') + # self.assert_json_response( + # '/api/books/child/fragments/', + # 'fragments.json') - self.assert_json_response('/api/genres/wiersz/fragments/', 'fragments.json') - self.assert_json_response('/api/genres/wiersz/fragments/', 'fragments.json') - - self.assert_json_response('/api/books/child/fragments/an-anchor/', 'fragment.json') + self.assert_json_response( + '/api/genres/wiersz/fragments/', + 'fragments.json') + self.assert_json_response( + '/api/books/child/fragments/an-anchor/', + 'fragment.json') class BlogTests(ApiTest): @@ -189,3 +230,186 @@ class PreviewTests(ApiTest): self.assert_json_response('/api/preview/', 'preview.json') +class OAuth1Tests(ApiTest): + @classmethod + def setUpClass(cls): + cls.user = User.objects.create(username='test') + cls.consumer_secret = 'len(quote(consumer secret))>=32' + Consumer.objects.create( + key='client', + secret=cls.consumer_secret + ) + + @classmethod + def tearDownClass(cls): + User.objects.all().delete() + + def test_create_token(self): + base_query = ("oauth_consumer_key=client&oauth_nonce=123&" + "oauth_signature_method=HMAC-SHA1&oauth_timestamp={}&" + "oauth_version=1.0".format(int(time()))) + raw = '&'.join([ + 'GET', + quote('http://testserver/api/oauth/request_token/', safe=''), + quote(base_query, safe='') + ]) + h = hmac.new( + quote(self.consumer_secret) + '&', raw, hashlib.sha1 + ).digest() + h = b64encode(h).rstrip('\n') + sign = quote(h) + query = "{}&oauth_signature={}".format(base_query, sign) + response = self.client.get('/api/oauth/request_token/?' + query) + request_token = parse_qs(response.content) + + Token.objects.filter( + key=request_token['oauth_token'][0], token_type=Token.REQUEST + ).update(user=self.user, is_approved=True) + + base_query = ("oauth_consumer_key=client&oauth_nonce=123&" + "oauth_signature_method=HMAC-SHA1&oauth_timestamp={}&" + "oauth_token={}&oauth_version=1.0".format( + int(time()), request_token['oauth_token'][0])) + raw = '&'.join([ + 'GET', + quote('http://testserver/api/oauth/access_token/', safe=''), + quote(base_query, safe='') + ]) + h = hmac.new( + quote(self.consumer_secret) + '&' + + quote(request_token['oauth_token_secret'][0], safe=''), + raw, + hashlib.sha1 + ).digest() + h = b64encode(h).rstrip('\n') + sign = quote(h) + query = u"{}&oauth_signature={}".format(base_query, sign) + response = self.client.get(u'/api/oauth/access_token/?' + query) + access_token = parse_qs(response.content) + + self.assertTrue( + Token.objects.filter( + key=access_token['oauth_token'][0], + token_type=Token.ACCESS, + user=self.user + ).exists()) + + +class AuthorizedTests(ApiTest): + fixtures = ['test-books.yaml'] + + @classmethod + def setUpClass(cls): + super(AuthorizedTests, cls).setUpClass() + cls.user = User.objects.create(username='test') + cls.consumer = Consumer.objects.create( + key='client', secret='12345678901234567890123456789012') + cls.token = Token.objects.create( + key='123456789012345678', + secret='12345678901234567890123456789012', + user=cls.user, + consumer=cls.consumer, + token_type=Token.ACCESS, + timestamp=time()) + cls.key = cls.consumer.secret + '&' + cls.token.secret + + @classmethod + def tearDownClass(cls): + cls.user.delete() + cls.consumer.delete() + super(AuthorizedTests, cls).tearDownClass() + + def signed(self, url, method='GET', params=None): + auth_params = { + "oauth_consumer_key": self.consumer.key, + "oauth_nonce": "%f" % time(), + "oauth_signature_method": "HMAC-SHA1", + "oauth_timestamp": int(time()), + "oauth_token": self.token.key, + "oauth_version": "1.0", + } + + sign_params = {} + if params: + sign_params.update(params) + sign_params.update(auth_params) + raw = "&".join([ + method.upper(), + quote('http://testserver' + url, safe=''), + quote("&".join( + quote(str(k)) + "=" + quote(str(v)) + for (k, v) in sorted(sign_params.items()))) + ]) + auth_params["oauth_signature"] = quote(b64encode(hmac.new( + self.key, raw, hashlib.sha1).digest()).rstrip('\n')) + auth = 'OAuth realm="API", ' + ', '.join( + '{}="{}"'.format(k, v) for (k, v) in auth_params.items()) + + if params: + url = url + '?' + urlencode(params) + return getattr(self.client, method.lower())( + url, + HTTP_AUTHORIZATION=auth + ) + + def signed_json(self, url, method='GET', params=None): + return json.loads(self.signed(url, method, params).content) + + def test_books(self): + self.assertEqual( + self.signed_json('/api/like/parent/'), + {"likes": False} + ) + self.signed('/api/like/parent/', 'POST') + self.assertEqual( + self.signed_json('/api/like/parent/'), + {"likes": True} + ) + # There are several endpoints where 'liked' appears. + self.assertTrue(self.signed_json('/api/parent_books/')[0]['liked']) + self.assertTrue(self.signed_json( + '/api/filter-books/', params={"search": "parent"})[0]['liked']) + # Liked books go on shelf. + self.assertEqual( + [x['slug'] for x in self.signed_json('/api/shelf/likes/')], + ['parent']) + + self.signed('/api/like/parent/', 'POST', {"action": "unlike"}) + self.assertEqual( + self.signed_json('/api/like/parent/'), + {"likes": False} + ) + self.assertFalse(self.signed_json('/api/parent_books/')[0]['liked']) + + def test_reading(self): + self.assertEqual( + self.signed_json('/api/reading/parent/'), + {"state": "not_started"} + ) + self.signed('/api/reading/parent/reading/', 'post') + self.assertEqual( + self.signed_json('/api/reading/parent/'), + {"state": "reading"} + ) + self.assertEqual( + [x['slug'] for x in self.signed_json('/api/shelf/reading/')], + ['parent']) + + def test_subscription(self): + self.assert_slugs('/api/preview/', ['grandchild']) + self.assertEqual( + self.signed_json('/api/username/'), + {"username": "test", "premium": False}) + self.assertEqual( + self.signed('/api/epub/grandchild/').status_code, + 401) # Not 403 because Piston. + + with patch('api.handlers.user_is_subscribed', return_value=True): + self.assertEqual( + self.signed_json('/api/username/'), + {"username": "test", "premium": True}) + with patch('django.core.files.storage.Storage.open', + return_value=StringIO("")): + self.assertEqual( + self.signed('/api/epub/grandchild/').content, + "") diff --git a/src/catalogue/fixtures/test-books.yaml b/src/catalogue/fixtures/test-books.yaml index ad7636863..88dfccbdb 100644 --- a/src/catalogue/fixtures/test-books.yaml +++ b/src/catalogue/fixtures/test-books.yaml @@ -1,3 +1,4 @@ + - model: catalogue.book pk: 1 fields: @@ -30,11 +31,16 @@ fields: slug: grandchild title: Grandchild + preview: true sort_key: grandchild parent: 2 - txt_file: txt/grandchild.txt - html_file: html/grandchild.html - fb2_file: fb2/grandchild.fb2 + xml_file: secret/grandchild.xml + txt_file: secret/grandchild.txt + html_file: secret/grandchild.html + epub_file: secret/grandchild.epub + mobi_file: secret/grandchild.mobi + pdf_file: secret/grandchild.pdf + fb2_file: secret/grandchild.fb2 created_at: "1970-01-01 0:0Z" changed_at: "1970-01-01 0:0Z" @@ -133,7 +139,7 @@ uploaded_at: "1970-01-03 0:0Z" - model: catalogue.fragment - id: 1 + pk: 1 fields: short_text: "Fragment" text: "A fragment" -- 2.20.1