追記
この記事は、pytestを3件から9件へ強化した「第1段階」時点の記録です。
当時、AI返答表示には
createTextNode()と<br>を組み合わせた方式を使用していました。
その後、コメントでご提案いただいたinnerTextを実際に検証し、今回の用途ではHTMLとして解釈されない状態と改行表示を維持しながら、より簡潔に実装できることを確認したため、現在はinnerTextを利用しています。XSS回帰テストについても、現在の実装に合わせて
innerTextが維持され、innerHTMLなどの危険なHTML sinkへ戻っていないことを確認する形へ更新しています。そのため、本文中の
createTextNode()と<br>に関する記述は、第1段階実施時点の記録としてお読みください。
pytest強化シリーズ
はじめに
個人開発中のFlaskアプリで、Codexを使いながらセキュリティや保守性の見直しを進めています。
これまで、
- AI返答表示のXSS対策
- 動的ランキング表示の保存型XSS対策
- 空DBから初期構築できないマイグレーション問題
などを修正してきました。
ただ、修正を進める中で一つ気になったことがありました。
現在のpytestがあまりにも簡単ではないか?
という点です。
当時のpytestは3件だけで、すべてbuild_sales_prompt()という1つの関数しか確認していませんでした。
そこで今回は、pytestを単なる「動作確認」ではなく、
過去に発生した不具合やヒヤリハットを、二度と同じ状態へ戻さないための「事故防止台帳」
として育てることにしました。
今回はその第1段階として、
- GitHub Actionsのpytest全件収集化
- XSS回帰テスト
- Jinja autoescape回帰テスト
- プロンプト契約テスト
- Geminiへのプロンプト接続テスト
を追加・強化しました。
改善前のpytest
改善前に存在していたpytestは、test_prompts.pyの3件だけでした。
主に確認していたのは、
- 販売データがプロンプトへ含まれている
- 「提案を3点挙げて」という文言がある
- 戻り値が文字列である
といった内容です。
一見するとテストがあるように見えます。
しかしCodexに静的調査を依頼すると、かなり弱いことが分かりました。
例えば、極端に言えば次のような実装でも通る可能性があります。
return sales_summary + " 提案を3点挙げて 箇条書き3点"
これでも、
- 入力データが含まれている
- 固定文言が含まれている
- 戻り値が文字列
という条件は満たしてしまいます。
本来プロンプトに含まれている、
- AIの役割
- 分析観点
- 提案数
- 回答形式
- 文章量
などが消えていても、テストは成功します。
つまり、
テストが通る = 本来の仕様が守られている
とは限らない状態でした。
さらに見つかったCI側の問題
調査中、GitHub Actionsにも問題が見つかりました。
当時のCIでは、
pytest test_prompts.py -v
を実行していました。
つまり、今後例えば、
test_security.py
test_sales.py
test_api.py
を追加しても、GitHub Actionsはそれらを実行しません。
ローカルではテストしていても、CIでは素通りしてしまいます。
これはpytestを「事故防止台帳」にするうえでかなり大きな問題です。
そこで、
pytest -v
へ変更しました。
これにより、pytestの通常のテスト収集ルールに従って、新しく追加したテストファイルもCIで実行されるようにしました。
変更自体は1行だけです。
- pytest test_prompts.py -v
+ pytest -v
しかし、今後のテスト運用を考えると重要な変更でした。
今回の方針
今回の目的は、
テスト件数を増やすこと
ではありません。
過去に実際に起きた不具合や、修正した脆弱性が、
将来同じ状態へ戻った時点でpytestが失敗すること
を目標にしました。
そのため、今回は本体機能を変更せず、
すでに修正済みで「正しい状態」が分かっているものを回帰テストとして固定しました。
第1段階で追加・強化したテスト
最終的にpytestは3件から9件になりました。
1. 販売データが正しい区画に入っているか
test_build_sales_prompt_places_sales_data_in_its_section
単に販売データが文字列内のどこかに存在するだけではなく、
本来の販売データ区画へ配置されていること
を確認します。
これにより、プロンプト構造が壊れても以前のように見逃しにくくなります。
2. AIの役割・分析観点を守る
test_build_sales_prompt_preserves_role_and_analysis_contract
プロンプト内に必要な、
- AIの役割
- 分析に必要な視点
などが残っていることを確認します。
以前は一部の固定文言だけ残っていれば通る可能性がありましたが、
今回からはプロンプトの「役割」や「分析観点」も守るようにしました。
3. 出力条件を守る
test_build_sales_prompt_preserves_output_contract
Geminiへ求めている、
- 提案数
- 箇条書き
- 文章量
- 簡潔さ
などの回答条件が維持されていることを確認します。
全文完全一致にはしていません。
少し文章を調整しただけでテストが壊れるような、
過度に脆いテスト
にしないためです。
重要な契約単位で確認しています。
Geminiへ本当にプロンプトが渡っているか
今回追加した中で、個人的に重要だと感じたのがこちらです。
test_generate_ai_advice_sends_complete_sales_prompt
以前は、
build_sales_prompt()
単体しか確認していませんでした。
しかし、関数単体が正常でも、
実際のGemini呼び出しでその関数によって組み立てた内容が使われていなければ意味がありません。
そこで今回は、
-
build_sales_prompt()で作った内容がGeminiへ渡る - 使用モデルが設定値と一致する
- Geminiの返答テキストが戻る
ことまで確認しました。
Gemini APIはモックして外部通信しない
pytestを実行するたびに実際のGemini APIへ接続するのは避けたいです。
そこで、
unittest.mock.Mock
を使用してGemini Clientをモックしました。
テスト用のダミーAPIキーを設定し、
genai.Client
をMockへ差し替えています。
返答も、
SimpleNamespace(
text="モックされたAIアドバイス"
)
のように固定しています。
これにより、
- 実APIキー不要
- 外部通信なし
- Gemini APIを実際に呼び出さない
- ネットワーク状態に左右されない
テストになりました。
XSSの再発防止テスト
今回特に重視したのが、
最近修正したXSS対策をpytestへ残すこと
です。
追加したテストは以下です。
test_dynamic_ranking_product_name_uses_text_dom_api
test_dashboard_ai_responses_use_text_dom_api
test_input_ai_response_uses_text_dom_api
動的ランキングの保存型XSS
以前、商品名を動的ランキングへ表示する際に、
innerHTML
を使用していた箇所がありました。
修正後はDOM APIとtextContentを利用する形へ変更しています。
今回のpytestでは、
商品名を表示する経路が将来再び危険なHTML sinkへ戻らないように確認します。
AI返答表示のXSS
AI経営アドバイスとAIあいさつでも、
以前はinnerHTMLを使用していました。
第1段階実施時点では、
createTextNode()<br>- DOM API
を使用し、AI返答そのものをHTMLとして解釈させない表示方式へ変更していました。
pytestでは、その表示経路が将来innerHTMLなどへ戻らないことを確認するsource guardを追加しました。
なお、記事公開後の追加検証を経て、現在の実装ではinnerTextを利用しています。
現在の回帰テストも実装に合わせて更新し、innerTextによる表示が維持されていることと、対象経路へinnerHTML・outerHTML・insertAdjacentHTMLなどが戻っていないことを確認する形になっています。
「innerHTMLが1文字でもあったら失敗」にはしなかった
ここは少し意識しました。
例えば、
ファイル内にinnerHTMLという文字列が存在したら失敗
というテストは簡単に書けます。
しかし、それでは将来、
安全上問題のない別用途でinnerHTMLを使う必要が出た場合でもテストが失敗します。
つまり、
過度に広すぎる禁止ルール
になります。
そこで今回は、
- 商品ランキング
- AI経営アドバイス
- AIあいさつ
という、
実際に過去問題になった外部文字列の表示経路
だけを対象にしました。
pytestの目的は、
「innerHTMLという言葉を禁止すること」
ではなく、
過去と同じ危険な状態へ戻ることを防ぐこと
だからです。
Jinjaのautoescapeも回帰テスト化
AI返答の初期表示では以前、
| safe
を使用していました。
修正後は削除し、Jinja本来のautoescapeを利用しています。
そこで、
test_dashboard_initial_ai_binding_does_not_disable_autoescape
を追加しました。
これはテンプレートソースを確認し、
-
{{ ai_advice }}が使われていること -
| safeが戻っていないこと
をチェックするsource guardです。
これにより、将来また初期AI表示へsafe指定が戻る事故を検知しやすくしました。
実際にHTML風文字列もJinjaで描画
さらに、
test_dashboard_initial_ai_advice_autoescapes_html_like_text
も追加しました。
例えば、
<b>テスト</b>
<img src=x>
のような文字列をJinjaへ渡し、
それが実際のb要素やimg要素として生成されず、
エスケープされた文字列として扱われること
を確認します。
こちらはテンプレートソースを見るだけではなく、
実際にJinjaでテンプレートを描画し、BeautifulSoupで生成されたHTMLを確認するテストです。
pytest結果
まず、
pytest --collect-only -q
を実行しました。
第1段階実施時点の結果は、
9 tests collected in 8.23s
となり、新しく追加したテストもすべて収集されました。
次に、
pytest -v
を実行しました。
結果は、
9 passed in 6.37s
でした。
- 成功:9件
- 失敗:0件
- スキップ:0件
すべて成功しました。
この9件は、あくまで第1段階実施時点のテスト数です。
その後も、実際に見つかった不具合や守りたい仕様を回帰テストとして追加し、pytestの検証範囲を広げています。
第1段階ではDBもDockerも使っていない
第1段階では軽量な回帰テストに限定しました。
そのため、
- PostgreSQL
- Docker
- 実Gemini API
は使用していません。
DB操作もありません。
今回は、
高速かつ外部環境に依存しないテスト
を先に整備しました。
変更ファイル
第1段階で変更したのは4ファイルです。
.github/workflows/test.yml
test_prompts.py
test_ai_integration.py
test_xss_regressions.py
差分は、
4 files changed, 195 insertions(+), 13 deletions(-)
でした。
本体コード、
app.py
prompts.py
models.py
templates/
migrations/
などは、このコミットでは変更していません。
既存の実装を変えるのではなく、
すでに修正した状態をテストで固定する
ことに集中しました。
commit
最終確認後、以下のコミットを作成しました。
01d76e8 test: strengthen regression coverage
origin/mainへpushしています。
今回学んだこと
1. テスト件数が多いことより、「何を守るか」が重要
以前はpytestが3件ありました。
しかし3件とも、
ほぼ同じ1関数しか見ていませんでした。
テスト件数だけを見れば、
3件 → 9件
ですが、今回重要なのは数字ではありません。
守れる範囲が、
プロンプトの簡単な文字列確認
から、
XSS
Jinja autoescape
プロンプト契約
Geminiへの接続
CI全件収集
まで広がったことです。
2. 「HTTP 200ならOK」では弱い
今回の調査で、
単に、
HTTP 200
を見るだけでは弱いことも改めて分かりました。
レスポンスが成功していても、
- DBが意図した状態になっていない
- HTMLとして危険に表示されている
- 重複レコードが増えている
といった問題が残る可能性があります。
今後は、
レスポンス結果だけではなく、その後の状態まで確認する
テストを増やしていく必要があると考えました。
3. pytestは「事故防止台帳」にできる
今回の改善で一番大きかったのは、
pytestに対する見方が変わったことです。
以前は、
正常に動いているか確認するもの
という感覚が強くありました。
しかし今は、
過去に起きた事故・不具合・ヒヤリハットを記録し、同じ状態へ戻ったら自動で止める仕組み
として考えています。
例えば、
XSSを発見
↓
修正
↓
回帰テストを追加
↓
将来同じ危険な状態へ戻る
↓
pytest失敗
↓
CIが赤くなる
という流れです。
これなら人間が過去の修正内容をすべて覚えていなくても、
pytestが確認項目として残してくれます。
「二度と同じことを起こさない」ではなく「同じ状態にしない」
普段の仕事でも、
事故やミスが起きたあと、
次から気をつけます
だけでは再発防止になりません。
それだけなら、人の注意力に再び頼ることになります。
重要なのは、
二度と同じ危険な状況・状態にならないためにはどうするか
だと考えています。
システム開発でも同じでした。
例えばXSSなら、
危険な文字列を入力しないようにする
ではなく、
危険なHTML風文字列が来ても
HTMLとして実行されない状態にする
必要があります。
そして今回、
さらにその先として、
安全な実装が将来壊れたらpytestで止める
ところまで仕組みに残しました。
第1段階実施時点で考えていた次の改善
この記事を書いた第1段階の時点では、
外部APIやDBを必要としない軽量な回帰テストを中心に強化しました。
一方で、まだ守れていない領域として、
- PostgreSQLの空DBマイグレーション
- 既存DBへのマイグレーション
- 認証・認可
- CSRF
- 不正な売上入力
- 商品の論理削除と履歴保持
- 同日売上の重複防止
- Gemini APIの429・503・一般例外
- APIレスポンス
- ダッシュボード集計
などが残っていると考えていました。
その後、これらを一度に変更するのではなく、
危険な状態を1つ決める → 先にテストを書く → 修正する → 全テストを確認する
という小さな単位で、段階的に検証範囲を広げています。
特にDBを扱うテストでは、
HTTPレスポンスだけではなく、
不正入力や保存失敗のあとにDBがどの状態になっているか
まで確認することを重視するようになりました。
なお、認証・認可・CSRFなど、別段階で扱う予定の領域もあります。
おわりに
今回の改善で、
pytestは3件から9件になりました。
ただし、目的はテスト数を増やすことではありません。
これまで見つけた小さな不具合や脆弱性を、
一つずつ再発防止策として残していくこと
が目的です。
小さな事故やヒヤリハットを見つけたら、
発見
↓
原因確認
↓
修正
↓
pytestへ再発防止ルールを追加
↓
CIで自動確認
という流れを積み重ねていきます。
pytestを、
「正常確認の道具」から「事故防止台帳」へ。
この記事はその第1段階の記録ですが、
その後もこの考え方をベースに、テストで守る範囲を少しずつ広げています。
また、AI返答表示についても、記事公開後のコメントをきっかけにinnerTextという別の実装方法を検証しました。
提案者が人であってもAIであっても、
そのまま採用するのではなく、自分の環境で検証し、現在の仕様に合うか確認してから採用する
という姿勢も、今後の開発で続けていきたいと思います。
