trents_blog/docs/posts/test-qr-svg-django.md

369 lines
9.9 KiB
Markdown
Raw Normal View History

2021-04-19 19:23:27 -07:00
---
title: "Test QRCODE Svg in Django"
date: 2021-04-19
draft: false
tags: ["django", "python", "testing", "pillow", "beautifulsoup", "opencv", "cairosvg", "pyotp"]
authors: ["trent"]
---
date: 2021-04-19
## **Introduction**
I worked out a solution in django-testing, for testing a view that renders a qrcode
as an svg as an inline svg xml string.
In case you are not familiar with
[svg](https://en.wikipedia.org/wiki/Scalable_Vector_Graphics){target=_blank},
scalable vector graphics, it is an image that is
completely rendered from a
[string](https://en.wikipedia.org/wiki/String_(computer_science)){target=_blank}
of text, or more accurately [XML](https://en.wikipedia.org/wiki/XML){target=_blank}
text, unlike [PNG](https://en.wikipedia.org/wiki/Portable_Network_Graphics){target=_blank}
or [JPG](https://en.wikipedia.org/wiki/JPEG){target=_blank}, which are
[binary file](https://en.wikipedia.org/wiki/Binary_file){target=_blank}s.
### Python Libraries Used
* [BeautifulSoup](https://pypi.org/project/beautifulsoup4/){target=_blank}
to consume html
* [CairoSVG](https://pypi.org/project/CairoSVG/){target=_blank}
to convert svg to png
* [python-pillow](https://pypi.org/project/Pillow/){target=_blank}
to convert transparent png background to white
* [opencv-python](https://pypi.org/project/opencv-python/){target=_blank}
to extract data from qrcode
* [python-pyotp](https://pypi.org/project/pyotp/){target=_blank}
## **Form ScreenShot**
This is the form we will be testing.
In real life you would confirm that you want to use
[two-factor-authentication](https://en.wikipedia.org/wiki/Multi-factor_authentication){target=_blank}
by scanning the qrcode with an
[authentication app](https://play.google.com/store/apps/details?id=org.shadowice.flocke.andotp){target=_blank}
on your smartphone, and then enter the
[time-based one-time password](https://en.wikipedia.org/wiki/Time-based_One-Time_Password){target=_blank}.
totp codes are derived from two things and two things only. A secret key and a time such as the current time.
For instance, an authentication application can tell you what your totp code is, but of course
in this testing scenario we use
[python-pyotp](https://pypi.org/project/pyotp/){target=_blank}, after extracting
the secret key from the qrcode.
<div align="center">
<em>enable totp confirmation form</em>
</div>
!["ScreenShot of a confirmation form for enabling totp (in a django app)"](
../../photos/IMG_screenshot_enable_totp_confirmation_form.jpg){: .center }
## **Import Python Libraries**
```python
# tp/accounts/tests/test_enable_totp_view.py
# python manage.py test accounts.tests.test_enable_totp_view
from django.test import TestCase
from django.contrib.auth.models import User
from accounts.models import Account
from django.urls import reverse
from bs4 import BeautifulSoup
import cv2
from cairosvg import svg2png
from PIL import Image
import pyotp
import pathlib
```
The Account model has a one-to-one relationship with the Django built-in
User model. This is where we keep track of the totp secret key and
boolean value for having totp authentication enabled.
## **setUp TestCase**
```python
...
import pathlib
class TestEnableTOTPViewTestCase(TestCase):
def setUp(self):
user_a = User.objects.create(
username='user_a')
user_a.set_password('password_user_a')
user_a.save()
Account.objects.create(user=user_a)
```
We create a test user and save that in the test database.
## **GET Form**
```python
class TestEnableTOTPViewTestCase(TestCase):
...
def test_enable_totp_view(self):
self.client.login(
username='user_a',
password='password_user_a'
)
get_response = self.client.get(
reverse('accounts:enable_totp')
)
self.assertEquals(
get_response.status_code, 200
)
self.assertTemplateUsed(
get_response, 'accounts/totp_form.html'
)
self.assertEquals(
get_response.request['PATH_INFO'],
'/accounts/enable-totp/'
)
```
The TestCase requires two requests. In the first request we GET the form
so that we can consume the qrcode.
## **Consume SVG with BeautifulSoup**
```python
class TestEnableTOTPViewTestCase(TestCase):
...
def test_enable_totp_view(self):
...
soup = BeautifulSoup(
get_response.content, features="lxml"
)
svg_container = soup.find(
"div", {"id": "svgcontainer"}
)
self.assertIsNotNone(svg_container)
scsvg = svg_container.findChild(
"svg", recursive=False
)
self.assertIsNotNone(scsvg)
with open('qr.svg', 'w') as f:
x_string = "<?xml version='1.0'"
x_string += " encoding='utf-8'?>\n"
f.write(x_string + str(scsvg))
```
The inline xml for the svg comes to us as a child of a
`div` with an `id` of *svgcontainer*. We capture the xml of the svg in the variable
`scsvg`, and then write it out to disc.
## **svg2png**
```python
class TestEnableTOTPViewTestCase(TestCase):
...
def test_enable_totp_view(self):
...
svg2png(
url='qr.svg', write_to='qr.png',
scale=8
)
```
With svg2png from CairoSVG, we convert the svg to png format. Opencv seems
unable to consume the qrcode unless you scale it up.
## **Add White Background**
```python
class TestEnableTOTPViewTestCase(TestCase):
...
def test_enable_totp_view(self):
...
t_image = Image.open('qr.png')
t_image.load()
background = Image.new(
"RGB", t_image.size,
(255, 255, 255)
)
background.paste(
t_image,
mask=t_image.split()[3]
)
background.save(
'qr.jpg', "JPEG",
quality=100
)
```
We use Image from python-pillow to change the background from transparent
to white. Opencv seems unable to consume the qrcode when it has a transparent
background.
## **Extract Data From QRCODE**
```python
class TestEnableTOTPViewTestCase(TestCase):
...
def test_enable_totp_view(self):
...
image = cv2.imread('qr.jpg')
qr_det = cv2.QRCodeDetector()
qrdata = qr_det.detectAndDecode(image)
totp_code = pyotp.TOTP(
pyotp.parse_uri(qrdata[0]).secret
).now()
```
* `qrdata[0]` will be the *otpauth_uri*.
* `pyotp.parse_uri(qrdata[0]).secret` is the secret key.
* `totp_code` is the one-time password.
## **POST the `totp_code`**
```python
class TestEnableTOTPViewTestCase(TestCase):
...
def test_enable_totp_view(self):
...
totp_code = pyotp.TOTP(
pyotp.parse_uri(qrdata[0]).secret
).now()
response = self.client.post(
reverse('accounts:enable_totp'),
{'totp_code': totp_code},
follow=True
)
self.assertEquals(
response.status_code, 200
)
self.assertTemplateUsed(
response, 'base_form.html'
)
self.assertEquals(
response.request['PATH_INFO'],
'/accounts/edit-profile/'
)
user_a = User.objects.get(
username='user_a'
)
self.assertTrue(user_a.account.use_totp)
self.assertEquals(
len(user_a.account.totp_key), 16
)
pathlib.Path('qr.svg').unlink()
pathlib.Path('qr.png').unlink()
pathlib.Path('qr.jpg').unlink()
```
Post the `totp_code` back to the form, and then verify
that the database is updated, and then delete the image
files.
## **Complete TestCase**
```python
# tp/accounts/tests/test_enable_totp_view.py
# python manage.py test accounts.tests.test_enable_totp_view
from django.test import TestCase
from django.contrib.auth.models import User
from accounts.models import Account
from django.urls import reverse
from bs4 import BeautifulSoup
import cv2
from cairosvg import svg2png
from PIL import Image
import pyotp
class TestEnableTOTPViewTestCase(TestCase):
# setUP TestCase
def setUp(self):
user_a = User.objects.create(
username='user_a')
user_a.set_password('password_user_a')
user_a.save()
Account.objects.create(user=user_a)
def test_enable_totp_view(self):
self.client.login(
username='user_a',
password='password_user_a'
)
# GET Form
get_response = self.client.get(
reverse('accounts:enable_totp')
)
self.assertEquals(
get_response.status_code, 200
)
self.assertTemplateUsed(
get_response, 'accounts/totp_form.html'
)
self.assertEquals(
get_response.request['PATH_INFO'],
'/accounts/enable-totp/'
)
# Consume SVG with BeautifulSoup
soup = BeautifulSoup(
get_response.content, features="lxml"
)
svg_container = soup.find(
"div", {"id": "svgcontainer"}
)
self.assertIsNotNone(svg_container)
scsvg = svg_container.findChild(
"svg", recursive=False
)
self.assertIsNotNone(scsvg)
with open('qr.svg', 'w') as f:
x_string = "<?xml version='1.0'"
x_string += " encoding='utf-8'?>\n"
f.write(x_string + str(scsvg))
# svg2png
svg2png(
url='qr.svg', write_to='qr.png',
scale=8
)
# add white background
t_image = Image.open('qr.png')
t_image.load()
background = Image.new(
"RGB", t_image.size,
(255, 255, 255)
)
background.paste(
t_image,
mask=t_image.split()[3]
)
background.save(
'qr.jpg', "JPEG",
quality=100
)
# extract data from qrcode
image = cv2.imread('qr.jpg')
qr_det = cv2.QRCodeDetector()
qrdata = qr_det.detectAndDecode(image)
totp_code = pyotp.TOTP(
pyotp.parse_uri(qrdata[0]).secret
).now()
totp_code = pyotp.TOTP(
pyotp.parse_uri(qrdata[0]).secret
).now()
# POST the `totp_code`
response = self.client.post(
reverse('accounts:enable_totp'),
{'totp_code': totp_code},
follow=True
)
self.assertEquals(
response.status_code, 200
)
self.assertTemplateUsed(
response, 'base_form.html'
)
self.assertEquals(
response.request['PATH_INFO'],
'/accounts/edit-profile/'
)
user_a = User.objects.get(
username='user_a'
)
self.assertTrue(user_a.account.use_totp)
self.assertEquals(
len(user_a.account.totp_key), 16
)
pathlib.Path('qr.svg').unlink()
pathlib.Path('qr.png').unlink()
pathlib.Path('qr.jpg').unlink()
```