Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

概要

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

目次

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

ソースコード

DjangoテストフレームワークでCSRFトークンエラーを検出する

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

sampleアプリケーション

モデル sample/models.py

単純にメッセージだけを保持するモデルです。

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を用いて、メッセージ用のフォームを定義します。

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を用いたビュークラスを定義します。

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 }}を外しています。

<!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を定義します。

from django.urls import path
from . import views

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

config(プロジェクト設定)

プロジェクト設定 config/settings.py

以下の設定を追加します。

  • 作成したsampleアプリケーションをインストールする
  • HTMLテンプレートをプロジェクト直下のtemplatesディレクトリから読み込む
  • 言語設定を「日本語」に設定する
  • タイムゾーン設定を「東京」に設定する
(省略)
# 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設定を読み込み、トップページでアクセスできるように設定しています。

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
SimpleTestCase:django.test.testcases | Django ドキュメント | Django

tests/sample/test_view.py

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

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

test/e2e/test_index.py
スクリーンショット 2019-08-20 18.09.33.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.Clientenforce_csrf_checks引数はデフォルトでFalseであるため、django.test.testcases.SimpleTestCase_pre_setup()メソッドで生成されるself.clientオブジェクトは常にCSRFトークンチェック無効になっている
      • このため、テストクラス内で_pre_setup()メソッドsetUp()メソッドをオーバーライドし、self.clientenforce_csrf_checks=Trueであるクライアントオブジェクトで上書きする必要が生じる
  • LiveServerTestCaseとSelenium WebDriverを用いたユニットテストで、CSRFトークンエラーを検知することは可能
    • Selenium WebDriverではHTTPステータスコードを取得することはできないため、画面のタイトル等を用いて403エラーを検知する必要がある

もう一歩先へ

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

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

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:上記のCsrfErrorDetectionClientclient_classに設定したビューのテスト
y_nishimura
IT業界の隅っこにいる人。今はPythonとか勉強中。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした