TT Lab
はじめる
学ぶ 学習パス コース

テストツール実戦

アクセス制御の欠陥を見つけるテスト行列:設計原理

TT Labで続きを見る

一言でいうと

認証なし・権限なし・他人の所有・正常なリクエストを分けた、否定テストを作成します。

なぜ必要なのか

成功レスポンスだけを確認するアクセス制御のテストは、すべて緑でした。ルートでHeaderの注入を忘れたり、scopeの検査を消したりしても、テストは壊れませんでした。検証者は、許可するケースを並べるだけでなく、拒否する理由ごとに別々に観測する必要があります。

どう動くのか

各ステップで、ユーザー・ドキュメント・権限の組み合わせを自分で作ります。レスポンスのdictとステータスコードについてのアサーションを一緒に書き、元のscopesを変更してしまうコピーの欠陥も明らかにします。最後に、TestClientで200・401・403・404の行列を実際に実行します。

학생 테스트 → 정상 구현: 실제 시험 모두 통과
           └→ 계약 위반 구현: 해당 동작에서 실패
수집 실패·0개 실행·강제 종료 ≠ 결함 검출

契約を読んで失敗を予測するワークシート

以下は、実装を丸ごと暗記するための答案ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を破っています。変更後でも、正常なケースが成功することがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが表れるかを予想し、実装した後で、その予想と結果を比較します。

1. Bearerヘッダーを分離する(テスト)

提供されたservice.pyの次の公開契約をテストしてください: bearer(header)は、ちょうど'Bearer 'で始まり、その後に空白のないトークンが1つあるとき、トークンを返します。None・空のトークン・別のscheme・余分な空白はValueErrorです。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: ヘッダーを適当に複数の断片に分けると、空白のエラーを正常なトークンとして受け入れてしまうことがあります。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

header[6:]

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

2. プリンシパルをコピーして返す(テスト)

提供されたservice.pyの次の公開契約をテストしてください: principal(token, users)は、トークン辞書のユーザー{id, scopes}を返しますが、scopesのリストまでコピーします。未知のトークンはValueErrorです。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 返されたscopesを変更したときに、元のユーザーの権限まで変わると、リクエスト間で権限が混ざります。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

user["scopes"]

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

3. 権限を正確に比較する(テスト)

提供されたservice.pyの次の公開契約をテストしてください: require_scope(user, scope)は、scopesにscopeの文字列がちょうど含まれているときNone、含まれていなければPermissionErrorを送出します。read-allはreadではありません。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 部分文字列での比較は、より長い権限名を別の権限と取り違えます。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

if False:

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

4. 所有権を別に確認する(テスト)

提供されたservice.pyの次の公開契約をテストしてください: visible(user, document)は、documentがNoneではなく、ownerがuserのidとちょうど等しいときだけTrueです。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: リソースがない場合と他人の所有を、同じ判定にまとめます。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

True

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

5. レスポンスのフィールドを許可リストで選ぶ(テスト)

提供されたservice.pyの次の公開契約をテストしてください: public_document(document)は、idとtitleだけを持つ新しい辞書です。ownerやinternal_costは含みません。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 元のデータからフィールドを消さず、新しいレスポンスを組み立てます。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

("id", "title", "owner")

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

6. エラーをHTTPの契約に合わせる(テスト)

提供されたservice.pyの次の公開契約をテストしてください: authenticate(header, users)は、bearerとprincipalをつなぎます。ValueErrorはHTTPException(401)になり、headersのWWW-Authenticateの値はBearerです。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 認証の失敗とアプリケーションのエラーを、500の1つにまとめません。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

HTTPException(403,

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

7. 拒否の順序を固定する(テスト)

提供されたservice.pyの次の公開契約をテストしてください: read_document(user, documents, document_id)は、read scopeがなければHTTPException(403)、存在しない、または他人のドキュメントならHTTPException(404)、それ以外ならpublic_documentの結果です。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 認証の後でも、scopeと所有権はそれぞれ確認する必要があります。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

HTTPException(403, "not found")

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

8. 実際のリクエストで境界を閉じる(テスト)

提供されたservice.pyの次の公開契約をテストしてください: create_app(users, documents)は、GET /documents/{document_id}でAuthorizationヘッダーを受け取り、authenticateとread_documentを呼び出すFastAPIアプリを返します。200・401・403・404と、非公開フィールドの除去を、実際のリクエストで検証してください。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 関数が個別に正しくても、ルートで呼び出しを忘れると、アクセス制御は適用されません。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

authorization: str | None = None

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

現場での姿

固定トークンの辞書は学習用の入力です。本番の認証には、有効期限・署名・取り消し・安全な保管が追加で必要です。404を同じにしたからといって、応答時間やアクセスログを通じたすべての推測がなくなるわけでもありません。拒否されたリクエストが元のデータまで変更していないかも、一緒に検査します。提供された実装は読んでもかまいませんが、採点は別のコピーを使用します。ソースの文言の検査やファイルの修正で欠陥を回避せず、公開インターフェースの実行結果を検査してください。

次のラボですること

8つのステップが、1つの実行可能な成果物につながります。Bearerヘッダーを分離する(テスト) → プリンシパルをコピーして返す(テスト) → 権限を正確に比較する(テスト) → 所有権を別に確認する(テスト) → レスポンスのフィールドを許可リストで選ぶ(テスト) → エラーをHTTPの契約に合わせる(テスト) → 拒否の順序を固定する(テスト) → 実際のリクエストで境界を閉じる(テスト)。

各ステップでは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとは、わざと境界の比較や後始末のコードを変えて、どのテストが失敗するかを確認してください。前のテストが次のステップでも維持される理由を説明し、このラボが保証しない本番の条件を1つ書いてみてください。