【Python】【Django】CSRFトークンエラーの検知テスト

# 概要
Djangoでテンプレートのフォームを手作業で作ると時々やらかすミス、「CSRFトークン用タグのつけ忘れ」。
`runserver`でテスト環境を動かすことで検知することが多いですが、ユニットテストではどこで検知できるかを確かめてみました。

# 目次
1. [ソースコード](#ソースコード)
    1. [sampleアプリケーション](#sampleアプリケーション)
    2. [config（プロジェクト設定）](#configプロジェクト設定)
    3. [テスト](#テスト)
2. [テストの実行結果](#テストの実行結果)
3. [得られた知見](#得られた知見)
4. [もう一歩先へ](#もう一歩先へ)

# ソースコード
[DjangoテストフレームワークでCSRFトークンエラーを検出する](https://github.com/yui728/django-csrferror-test-notify)

- [config](#configプロジェクト設定)
    - プロジェクト設定
- [sample](#sampleアプリケーション)
    - フォームで入力したメッセージを保存するだけのサンプルアプリケーション
- [templates](#htmlテンプレート-templatessampleindexpy)
    - HTMLテンプレート
- [tests](#テスト)
    - ユニットテスト
    - sample：`django.test.testcases.TestCase`を用いたテスト
    - e2e：`django.test.testcases.LiveServerTestCase`とSelenium WebDriverを用いたテスト

## sampleアプリケーション
### モデル `sample/models.py`
単純にメッセージだけを保持するモデルです。

~~~python
from django.db import models

# Create your models here.
class Sample(models.Model):
    message = models.CharField(verbose_name='メッセージ', max_length=255)

    """サンプルアプリケーションモデル"""
    class Meta:
        # テーブル名
        db_table = 'sample'
~~~

### フォーム `sample/fomrms.py`
`sample/models.py`で定義した`Sample`モデルと`django.models.ModelForm`を用いて、メッセージ用のフォームを定義します。

~~~python
from django import forms
from .models import Sample

class SampleForm(forms.ModelForm):
    """サンプルフォーム"""
    class Meta:
        model = Sample
        fields = ('message',)
        widgets = {
            'message': forms.Textarea(attrs={'placeholder': 'メッセージ'})
        }
~~~

### ビュー `sample/views.py`
`sample/forms.py`で定義した`SampleForm`を用いたビュークラスを定義します。

~~~python
from django.shortcuts import render,redirect
from .forms import SampleForm
from django.urls import reverse
from django.views import View

class SampleFormView(View):

    # Create your views here.
    def get(self, request, *args, **kwargs):
        context = {
            'form': SampleForm()
        }
        return render(request, 'sample/index.html', context)

    def post(self, request, *args, **kwargs):
        form = SampleForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect(reverse('sample:index'))

        context = {
            'form': form
        }
        return render(request, 'sample/index.html', context)

sampleFromView = SampleFormView.as_view()
~~~

### HTMLテンプレート `templates/sample/index.py`
単純に入力フォームと「送信」サブミットボタンを表示するだけです。
今回はCSRFトークンのエラーをテストするため、意図的に`{{ csrf_token }}`を外しています。

~~~HTML
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>Form Sample</title>
    </head>
    <body>
        <form method="POST" action="{% url 'sample:index' %}">
            {% for field in form %}
            <label>{{ field.label_tag }}</label>
            {{ field }}
            {% endfor %}
            <input type="submit" value="送信" />
        </form>
    </body>
</html>
~~~

### アプリ内のURL設定 `sample/urls.py`
作成したビュークラスにアクセスするURLを定義します。

~~~python
from django.urls import path
from . import views

app_name='sample'
urlpatterns = [
    path('', views.sampleFromView, name="index")
]
~~~

## config（プロジェクト設定）
### プロジェクト設定 `config/settings.py`
以下の設定を追加します。

- 作成した`sample`アプリケーションをインストールする
- HTMLテンプレートをプロジェクト直下の`templates`ディレクトリから読み込む
- 言語設定を「日本語」に設定する
- タイムゾーン設定を「東京」に設定する

~~~python
（省略）
# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'sample',
]

……

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            os.path.join(BASE_DIR, 'templates')
        ],
        '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',
            ],
        },
    },
]

……

# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/

LANGUAGE_CODE = 'ja-JP'

TIME_ZONE = 'Asia/Tokyo'

（省略）
~~~

### プロジェクトのURL設定 `config/urls.py`
`sample`プロジェクトで作成したURL設定を読み込み、トップページでアクセスできるように設定しています。

~~~python
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('sample.urls')),
]
~~~

## テスト
### sample
`django.test.testcases.Testcase`を用いたテストを実施します。
`django.test.testcases.Testcase`に用意された`django.test.Client`クラスのオブジェクト`self.client`では、CSRFトークンのチェックが行われないため、clientをCSRFトークンのチェックを行うように再設定し、サンプルアプリケーションにGET、POSTを行い、GET時に指定したテンプレートの表示、POST時にHTTPステータスコードが`403`になることを確認します。

参考ページ：
[リクエストの作成：テストツール | Django ドキュメント | Django](https://docs.djangoproject.com/ja/2.2/topics/testing/tools/#django.test.Client)
[SimpleTestCase：django.test.testcases | Django ドキュメント | Django](https://docs.djangoproject.com/ja/2.2/_modules/django/test/testcases/#SimpleTestCase)

#### `tests/sample/test_view.py`

~~~python
from django.test.testcases import TestCase
from django.urls import reverse
from django.test.client import Client

class SampleViewTest(TestCase):

    # def _pre_setup(self):
    #     super()._pre_setup()
    #     self.client = Client(enforce_csrf_checks=True)

    def setUp(self):
        super().setUp()
        self.client = Client(enforce_csrf_checks=True)

    def test_get_index_01(self):
        response = self.client.get(reverse('sample:index'))
        self.assertTemplateUsed(response, 'sample/index.html')

    def test_post_index_01(self):
        response = self.client.post(reverse('sample:index'), data={})
        # If csrf_token was template given.
        # self.assertTemplateUsed(response, 'sample/index.html')
        # If csrf_token was't template given.
        self.assertEquals(403, response.status_code)

    def test_post_index_02(self):
        response = self.client.post(reverse('sample:index'), data={'message': 'Test Message'})
        # If csrf_token was template given.
        # self.assertRedirects(response, reverse('sample:index'))
        # If csrf_token was't template given.
        self.assertEquals(403, response.status_code)
~~~

### e2e
`django.test.testcases.LiveServerTestCase`とSelenium WebDriverを用いたテストを実施します。

#### `tests/e2e/test_index.py`

~~~python
from django.test.testcases import LiveServerTestCase
import chromedriver_binary
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

class LiveServerIndexTest(LiveServerTestCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        options = Options()
        options.add_argument('--headless')
        cls.selenium = webdriver.Chrome(options=options)
        cls.selenium.implicitly_wait(10)

    def test_index_01(self):
        self.selenium.get('%s%s' % (self.live_server_url, '/'))
        self.assertTemplateUsed('sample/index.html')
        self.assertEquals('Form Sample', self.selenium.title)

    def test_index_02(self):
        self.selenium.get('%s%s' % (self.live_server_url, '/'))
        message_elem = self.selenium.find_element_by_css_selector('form textarea[name="message"]')
        message_elem.send_keys("Test Message")
        submit_elem = self.selenium.find_element_by_css_selector('form input[type="submit"]')
        submit_elem.click()
        WebDriverWait(self.selenium, 15).until(EC.visibility_of_all_elements_located)
        
        # assert Submit Success
        # self.assertEquals('Form Sample', self.selenium.title)

        # assert Submit 403 Error(CSRF Token Error)
        self.assertTrue('403' in self.selenium.title)

    @classmethod
    def tearDownClass(cls):
        cls.selenium.quit()
        super().tearDownClass()
~~~

## テストの実行結果
`tests/sample/test_views.py`
![スクリーンショット 2019-08-20 18.08.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27600/a108bb6f-e50c-9b2e-d3cb-a30239ec13a8.png)

`test/e2e/test_index.py`
![スクリーンショット 2019-08-20 18.09.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27600/bb8ef250-b98c-f801-e8c2-4dd53f44d247.png)

## 得られた知見
- `django.test.testcases.TestCase`を用いたユニットテストで、CSRFトークンエラーを検知することは可能
    - ただし、~~`_pre_setup()`メソッド~~、`setUp()`メソッドをオーバーライドし、`self.client`をCSRFトークンチェック有効（`enforce_csrf_checks=True`）のクライアントに上書きする必要がある
     - `self.client`自体は`django.test.testcases.TestCase`クラスの親クラスである`django.test.testcases.SimpleTestCase`で定義されている
     - `django.test.Client`の`enforce_csrf_checks`引数はデフォルトで`False`であるため、`django.test.testcases.SimpleTestCase`の`_pre_setup()`メソッドで生成される`self.client`オブジェクトは常にCSRFトークンチェック無効になっている  
     - このため、テストクラス内で~~`_pre_setup()`メソッド~~`setUp()`メソッドをオーバーライドし、`self.client`を`enforce_csrf_checks=True`であるクライアントオブジェクトで上書きする必要が生じる
- LiveServerTestCaseとSelenium WebDriverを用いたユニットテストで、CSRFトークンエラーを検知することは可能
    - Selenium WebDriverではHTTPステータスコードを取得することはできないため、画面のタイトル等を用いて403エラーを検知する必要がある

## もう一歩先へ

ユニットテストでのCSRFトークンエラーの検知を汎用化するには、おおよそ以下の方向性があるかと思います。

- `django.test.testcases.TestCase`を拡張したベースクラスを使用する
    - このクラスからテストクラスを作成すれば、無条件でCSRFトークンエラーを検出できるようにする
    - ベースのテストクラスへの拡張性はあるが、クライアントそのものへの拡張性は低い
- テストクラスで用いるクライアントをCSRFトークンエラー検知有効なクライアントに設定する
    - テストクラスの`client_class`にこのクライアントを設定すればCSRFトークンエラー検知有効なクライントを使用するようにする
    - CSRFトークンエラーを検知したいテストクラスが複数ある場合は、すべてのクラスの`client_class`にこのクラスを設定する必要がある
- 上記２つの合せ技
    - クライアント、ベースのテストクラス双方に拡張性を持つことができる
    - テストクラス周りのベース部分が複雑化する可能性がある

### `django.test.testcases.TestCase`を拡張したベースクラスを使用する `tests/sample/test_views_extend_class.py`

- `CsrfErrorDetactionTestCase`:
`setUp()`メソッドでself.clientを`enforce_csrf_checks=True`を設定した`django.test.Client`のインスタンスに上書きする`django.test.testcases.TestCase`の拡張クラス
- `SampleViewTest`:上記の`CsrfErrorDetactionTestCase`をベースに作成したビューのテスト


### テストクラスで用いるクライアントをCSRFトークンエラー検知有効なクライアントに設定する `tests/sample/test_views_extend_client.py`

- `CsrfErrorDetectionClient`:`enforce_csrf_checks`をデフォルトで`True`に設定する`django.test.Client`
- `SampleViewTest`:上記の`CsrfErrorDetectionClient`を`client_class`に設定したビューのテスト