pytest強化シリーズ
はじめに
第3段階は、認証・CSRF・アクセス制御まで扱ったことで、かなりボリュームの大きい記事になりました。
そのままだとスクロール量がかなり多くなるため、読みやすいように一部の詳細内容を<details>タグで折りたたんでいます。
本文だけでも流れは追えるようにしているので、気になるところだけ開いて読んでもらえればと思います。
私は現在、本業でトラックドライバーとして働きながら、Pythonを中心にWebアプリケーション開発を独学しています。
個人開発中のFlaskアプリでは、pytestを単なる「動作確認」ではなく、
一度見つけた事故やヒヤリハットを、次から自動で止めるための事故防止台帳
として少しずつ育てています。
これまで、
- 第1段階:3件 → 9件
- 第2段階:9件 → 51件
まで強化してきました。
今回の第3段階では、
認証・CSRF・アクセス制御
を中心に見直し、
51 passed
↓
69 passed
まで回帰テストを増やしました。
ただし、今回もテスト数そのものを増やすことが目的ではありません。
第3段階で確認したかったのは、
誰でもデータを変更できないか
ログインしていても、
外部サイトなどから意図しないPOSTを送られないか
業務画面やAPIへ匿名で入れないか
匿名ユーザーからAI APIを実行されないか
といった部分です。
今回もCodexと一緒に、
調査
↓
RED
↓
最小修正
↓
GREEN
という順番で進めました。
第3段階開始時は51件
第2段階終了時点では、
51 passed
でした。
すでに、
- 売上入力validation
- 商品登録validation
- 不正な商品ID
- 月違いの商品
- 販売終了商品
- DB一意制約
- commit失敗時のrollback
- 論理削除と売上履歴保持
- dashboard集計
などはpytestで確認していました。
一方で、認証まわりを調査すると、
認証
CSRF
アクセス制御
はまだ十分に実装されていませんでした。
そこで第3段階では、
認証
↓
CSRF
↓
業務画面・APIのアクセス制御
の順番で進めることにしました。
まずは修正せず、現在状態を調査した
最初にCodexへ依頼したのは実装ではありません。
現在の認証状態を調査するだけ
にしました。
調査時点では、
- ログイン画面なし
- Userモデルなし
- Flask-Loginなし
- CSRF tokenなし
- 業務画面/APIも匿名アクセス可能
という状態でした。
特に状態を変更するPOSTでは、
POST /
POST /input
へ未認証でも到達できました。
つまり、
ログインしていない利用者でも、商品や売上データの変更処理へ到達できる状態
でした。
ここでいきなり修正せず、
まずその危険な状態をpytestで表すことにしました。
認証:未認証POSTをREDにする
最初に追加したのは、
未認証の商品POSTを拒否する
未認証の売上POSTを拒否する
というテストです。
期待する将来仕様は、
anonymous
↓
POST
↓
302
↓
/login
↓
DB変更なし
です。
しかし実装前は、
期待:302
実際:200
となり、
実際にDB変更処理まで到達しました。
狙いどおりREDです。
🧪 認証REDテストから実装までの詳細を見る
ログイン仕様も先にREDで決めた
次に、
-
GET /loginが表示できる - 正しい認証情報ならログインできる
- 間違ったpasswordではログインできない
- ログイン状態が同じclientで維持される
- 未認証POSTは拒否される
という仕様をテストへ追加しました。
この時点ではログイン機能そのものが存在しないため、
5 failed
になりました。
ここで初めて認証実装へ進みました。
単一管理者方式を採用した
今回のアプリでは、いきなり複数ユーザー管理までは広げませんでした。
採用したのは、
単一管理者ログイン
です。
認証にはFlask-Loginを使用しました。
認証情報は、
SECRET_KEY
ADMIN_USERNAME
ADMIN_PASSWORD_HASH
を環境変数から取得します。
本番用の平文passwordをコードへ書くのではなく、
check_password_hash()
でpassword hashを検証する形にしました。
この段階では、
- User DBモデル
- 一般ユーザー
- role
- tenant
- 店舗別権限
などは追加していません。
今回の仕様では、
認証済み利用者
=
単一管理者
として扱っています。
そのため今回の第3段階では、複数のroleによる詳細な認可ではなく、
「認証済み管理者だけが業務機能へアクセスできる」
というアクセス制御までを対象にしました。
認証実装後は56件GREEN
単一管理者認証を実装すると、
pytest test_auth.py -v
→ 5 passed
pytest -v
→ 56 passed
となりました。
ここで一度、
feat: add single-admin authentication
としてローカルcommitを作りました。
認証だけがGREENになった地点を残し、
そのままCSRFまで一気に進めないようにしました。
次はCSRFを調べた
認証を追加すると、
「ログイン済みだから安全」
とは限りません。
Session Cookieを使った認証では、
ログイン状態を利用して、利用者が意図していないPOSTを送られる
可能性も考える必要があります。
そこで次はCSRF対策へ進みました。
CSRFも実装前にREDを作った
今回作ったCSRFテストは6件です。
実装前は、
6 failed, 56 passed
でした。
フォームにはtokenが存在せず、
認証済み状態ならtokenなしの商品・売上POSTも通りました。
さらにtokenなしログインでも、
正しいusernameとpasswordならログインできていました。
🧪 CSRFの6件のREDテストと実装内容を見る
追加したCSRFテスト
ログインフォームにCSRF tokenがある
商品フォームにCSRF tokenがある
売上フォームにCSRF tokenがある
tokenなしログインPOSTを拒否する
tokenなし商品POSTを拒否する
tokenなし売上POSTを拒否する
tokenなしでもDB変更まで進んでいた
CSRF実装前の商品POSTでは、
status=200
products_unchanged=False
となりました。
つまり、
CSRF tokenがなくてもProduct変更処理へ到達していた
ということです。
売上POSTでも、
status=200
sales_unchanged=False
となり、
DailySalesが変更されました。
単に、
CSRF対策がない
というコード上の指摘だけではなく、
現在の状態では実際にどこまで処理が進むのか
をREDテストで確認しました。
Flask-WTFでCSRFProtectを追加
CSRF対策にはFlask-WTFを使いました。
CSRFProtect(app)
で保護を有効化し、
3つのPOSTフォームへ、
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
を追加しました。
対象は、
/login
/
/input
です。
CSRF exemptは追加せず、
テスト環境でもCSRF保護を無効化していません。
テスト側も「正規のPOST」に変更した
CSRFProtectを有効にすると、
既存の正常系テストもtokenなしでは400になります。
しかし、
商品validationを確認したいテスト
売上rollbackを確認したいテスト
までCSRFで止まってしまうと、本来確認したい業務処理へ到達できません。
そこでテスト側も、
GET
↓
csrf_token取得
↓
有効token付きPOST
という形へ変更しました。
CSRF tokenの取得には、テスト用fixtureを使っています。
認証済みclientも、
Sessionへ直接ログイン状態を書き込むのではなく、
GET /login
↓
CSRF token取得
↓
username
password
csrf_token
↓
POST /login
↓
認証済みclient
という実際のログインに近い流れで作っています。
CSRF実装後は62件GREEN
実装後は、
pytest test_csrf.py -v
→ 6 passed
pytest test_auth.py -v
→ 5 passed
pytest -v
→ 62 passed
となりました。
tokenなしの商品・売上POSTでは、
CSRFProtectによって400で拒否
↓
DB変更なし
となることを確認しています。
商品POSTではProductとDailySales、
売上POSTではDailySalesについて、
POST前後のsnapshotが変わらないことをテストしました。
tokenなしログインについても、
400
↓
認証状態が作られない
ことを確認しています。
ここでも一度、
feat: add CSRF protection
としてcommitしました。
認証とCSRFを入れても、まだ匿名で業務画面へ入れた
認証とCSRFを入れたあと、
次はGETルートとAPIを調査しました。
対象にしたのは、
/login
/
/input
/dashboard
/api/dashboard-data
/api/ai-advice
/api/greeting
です。
調査時点では、すべて匿名アクセスで、
HTTP 200
となりました。
/loginは公開で問題ありません。
しかし、それ以外の業務画面やAPIも匿名で利用できる状態でした。
匿名でもAI APIまで実行できた
特に気になったのは、
/api/ai-advice
/api/greeting
です。
調査時には実Gemini APIへ接続せず、
Gemini Clientをモックしました。
その結果、匿名アクセスでも、
Gemini Client
→ 1回呼び出し
となりました。
つまり条件が揃えば、
ログインしていない利用者でもAI処理へ到達できる
状態でした。
アクセス制御もREDから始めた
そこで新しく、
test_authorization.py
を追加しました。
ファイル名にはauthorizationを使っていますが、
今回確認しているのはroleごとの詳細な認可ではなく、
匿名ユーザーから業務画面・APIを保護するアクセス制御
です。
今回の将来仕様は、
GET /login
→ anonymous 200
それ以外の業務画面/API
→ anonymous 302
→ /login
としました。
最初の結果は、
1 passed, 6 failed
でした。
公開する/loginだけGREEN。
残り6ルートはすべて匿名へ200を返していたためREDです。
🔐 アクセス制御の実装と既存テスト調整の詳細を見る
「既存テスト変更が必要」でCodexが停止した
ここで少し印象に残った出来事がありました。
アクセス制御を実装しようとしたところ、
Codexが、
既存テストの変更が必要になる
と判断して実装前に停止しました。
例えば、
test_dashboard.py
は匿名clientでAPIの集計結果を確認していました。
しかしAPIを認証必須にすると、
匿名clientでは302になり、
集計処理そのものへ到達できません。
同じようにCSRFテストにも、
匿名状態で業務画面からtokenを取得している箇所がありました。
そこで、
test_authorization.py
→ 匿名で入れないことを確認
test_dashboard.py
→ 認証済みなら正しく集計できることを確認
test_csrf.py
→ CSRFそのものを確認
test_auth.py
→ 認証そのものを確認
と、テストの役割を整理しました。
Codexには、
想定外の変更が必要なら勝手に進めず、一度報告する
というルールを入れていました。
今回はそのルールどおり、
既存テストを勝手に変更せず停止したため、
変更理由を確認してから最小限の修正を許可しました。
login_requiredで6ルートを保護した
最終的に、
/
/input
/dashboard
/api/dashboard-data
/api/ai-advice
/api/greeting
へlogin_requiredを追加しました。
実装後は、
GET /login
→ anonymous 200
その他6ルート
→ anonymous 302
→ /login
となりました。
AI APIについても、
匿名アクセス時には、
Gemini Client
→ 0回
となることを回帰テストで確認しています。
つまり、匿名ユーザーはAI処理本体へ到達しません。
第3段階の最終結果は69件GREEN
最終的に、
51 passed
↓
56 passed
↓
62 passed
↓
69 passed
となりました。
第3段階で直接追加した、
認証 5件
CSRF 6件
アクセス制御 7件
の合計18件が増え、
51
↓
69
となりました。
✅ pytestの実行結果と各テストの役割を見る
最終pytest結果
pytest test_authorization.py -v
→ 7 passed
pytest test_auth.py -v
→ 5 passed
pytest test_csrf.py -v
→ 6 passed
pytest test_dashboard.py -v
→ 4 passed
pytest -v
→ 69 passed
テスト数より「担当」が分かれてきた
第3段階で特に感じたのは、
単にテスト数が増えたというより、
テストごとの担当がかなり分かれてきた
ことです。
test_auth.py
→ ログイン・未認証POST拒否
test_csrf.py
→ CSRF tokenとtokenなしPOST拒否
test_authorization.py
→ 匿名から業務画面/APIへ入れないこと
test_dashboard.py
→ 認証後の集計処理
test_products.py
→ 認証・CSRF通過後の商品処理
test_sales.py
→ 認証・CSRF通過後の売上処理
以前は、
とりあえずレスポンスが返る
くらいの確認だったpytestが、
少しずつ、
「どの事故を、どのテストが止めるのか」
分かる状態になってきました。
feature branchとPull Requestも使った
今回の第3段階では、
feature/auth-hardening
というfeature branchで作業しました。
ローカルで69件GREENを確認したあと、
feature/auth-hardening
↓
GitHubへpush
↓
Pull Request
↓
GitHub Actions
↓
mainへMerge
という流れを通しました。
これまでローカルでのpytestとGitHub Actionsは使っていましたが、
今回はfeature branchからPull Requestを作り、CIの成功を確認してからmainへ入れるところまで進めました。
🚚 commit・Pull Request・GitHub Actionsの詳細を見る
commitも工程ごとに分けた
feat: add single-admin authentication
feat: add CSRF protection
feat: protect authenticated routes
と分けました。
Pull Requestでは、
- baseが
main - compareが
feature/auth-hardening - 3commit
- 変更差分
- conflictの有無
- GitHub Actionsの結果
を確認してからMergeしました。
GitHub Actionsでも69件GREEN
Pull Request作成後、
GitHub ActionsのRun Testsが実行されました。
CI上でも、
collected 69 items
となり、
最終結果は、
69 passed
でした。
GitHub Actionsの結果も、
Success
となったことを確認してからmainへMergeしました。
つまり今回の第3段階では、
ローカルpytest
69 passed
↓
feature branchへpush
↓
Pull Request
↓
GitHub Actions
69 passed
↓
mainへMerge
という流れまで確認できました。
Merge後のmainでも69件確認
Pull RequestをMergeしたあと、
ローカル環境もmainへ戻し、
origin/mainと同期しました。
Local mainとorigin/mainのHEADが一致していることを確認したあと、
もう一度、
pytest -v
を実行しました。
結果は、
69 passed
でした。
作業ツリーもクリーンな状態です。
ここまで確認して、第3段階を完了扱いにしました。
私の中ではPRも「出荷判定」
以前からpytestを、
出庫前点検
GitHub Actionsを、
出荷ゲートでの自動点検
のように考えていました。
今回Pull Requestを使ったことで、
その間にもう一つ、
作業用branch
↓
Pull Request
↓
CI
↓
main
という確認工程が入りました。
トラックの仕事に例えるなら、
作業場で点検
↓
出荷判定
↓
自動検査
↓
本線へ流す
ようなイメージです。
ローカルで動いたからすぐmainへ入れるのではなく、
一度別の場所で差分とテスト結果を確認してからmainへ入れる
という流れが、実際に使ってみて少し分かりました。
第3段階終了時点
第3段階終了時点では、
単一管理者認証
✅
CSRF
✅
業務画面/APIの匿名アクセス拒否
✅
匿名AI API実行防止
✅
pytest
69 passed
GitHub Actions
69 passed / Success
Pull Request
✅
mainへMerge
✅
Merge後のmain
69 passed
となりました。
今回決めた範囲では、
第3段階完了
です。
あえて今回やらなかったこと
第3段階の最終点検では、
「見つけたけれど、今回は直さない」
と決めた項目も残りました。
ここまで全部手を広げると第3段階のゴールが曖昧になるため、
今回の範囲は、
単一管理者認証
CSRF
業務画面/APIの匿名アクセス拒否
までとしました。
📋 今回あえて後回しにした改善候補を見る
最終点検で確認した改善候補は、
logout
設定不足時のfail-closed専用pytest
不正CSRF token専用テスト
Session Cookie設定
ログイン試行rate limit
APIの401 JSON化
認証チェック重複の整理
などです。
logoutはまだ実装していない
現在は、
logout route
logout_user()
ログアウトボタン
はまだありません。
匿名アクセス防止そのものは成立していますが、
Session Cookieを利用する認証方式なので、
管理者が任意のタイミングでSessionを終了できるlogoutは、
今後追加したい機能です。
特に共有端末で運用する場合には、
本番公開前に必要性を改めて判断する予定です。
roleによる詳細な認可はまだない
今回のアプリには、
- User DBモデル
- 一般ユーザー
- role
- tenant
- 店舗ID
- 店舗別アクセス制御
はありません。
現在ログインできるのは、
環境変数で設定した単一管理者だけです。
そのため今回の第3段階では、
認証済み利用者
=
管理者
という前提で、
認証済み管理者だけが業務機能へアクセスできる状態
までを完了条件としました。
将来複数ユーザーを導入する場合は、
roleや店舗単位の権限設計を別途考える必要があります。
本番運用前には環境変数が必要
今回の認証機能では、
SECRET_KEY
ADMIN_USERNAME
ADMIN_PASSWORD_HASH
を環境変数から取得しています。
そのため、本番環境でログイン機能を利用するには、
これらを正しく設定する必要があります。
特に、
ADMIN_PASSWORD_HASH
には平文passwordではなく、
Werkzeug互換のpassword hashを設定する前提です。
今回の第3段階では、
Render側の本番環境設定までは変更していません。
まとめ
pytest強化の第3段階では、
51 passed
↓
69 passed
となりました。
今回追加した中心部分は、
- 単一管理者認証
- CSRF保護
- 業務画面/APIのアクセス制御
- 匿名AI API実行防止
- 認証・CSRF・アクセス制御の回帰テスト
です。
第1段階では、
3件しかなかったpytestを回帰テストとして育て始める
ところから始まりました。
第2段階では、
DB更新やrollback、論理削除、集計処理
まで広がりました。
そして第3段階では、
「正しいデータを保存できるか」だけでなく、「誰がその処理へ到達できるか」
までテスト対象が広がりました。
さらに今回は、
feature branch
↓
Pull Request
↓
GitHub Actions
↓
mainへMerge
という流れも初めて実際に使いました。
まだ改善できる部分はあります。
それでも、一つ問題を見つけるたびに、
調査
↓
RED
↓
最小修正
↓
GREEN
↓
commit
↓
次の問題
と進めることで、
pytestが少しずつ、
事故が起きたあとに確認するものではなく、事故を入口で止める仕組み
になってきたと感じています。
次は第4段階へ進む予定です。
pytest強化シリーズ
