⏱️ この記事を3行でまとめると
① 🔐 Guestを業務routeへ通す前に、Product・DailySales・Dashboard・AI・seedをDataset単位へ分離し、Admin / Guest間の越境を先に潰しました。
② 🧪 商品更新・売上POST・同名商品の集計・Geminiへ渡すpromptなど、実際に起こり得る越境をREDで再現してから修正し、pytestは181→195件へ。
③ 🔎 全GREEN後もproduction code全体を再監査し、今回の範囲でDatasetスコープ漏れ0件を確認してから、第4段階のGuest開放へ進む判断をしました。
はじめに
本業でトラックドライバーをしながら、Python / Flaskを中心にWebアプリケーションを個人開発しています。
現在育てているのが、ベーカリー向け売上管理システム sales_data_app です。
商品登録、日々の売上入力、Dashboardでの集計・ランキング、AIによる売上分析などを行えるシステムとして開発しています。
そして現在は、将来このシステムを第三者にも実際に触ってもらえるようにするため、ゲストデモの実装を段階的に進めています。
ただし、第3段階終了時点でもGuestを商品登録・売上入力・Dashboardなどの業務画面へはまだ通していません。
📌 この記事での現在地
この記事は「ゲストデモが完成した話」ではありません。
現在は、
Guestを業務画面へ通す前に
↓
Admin / Guestごとのデータ境界を固める
という段階です。
セキュリティを確認してからGuest向けの入口を開放する方針で進めています。
先にやったのは、
Guestを業務画面へ通しても、管理者や他Guestのデータへ越境できない土台を作ること
です。
ここでいう「越境」は、本来入ってはいけない別のデータ領域へ入り込んでしまうことです。
例えば、
Admin Dataset
────────────── 境界
Guest A Dataset
────────────── 境界
Guest B Dataset
という区画があるのに、
Guestの商品がAdmin画面に出る
Guestの売上がAdmin集計に混ざる
別Datasetの商品を書き換えられる
といったことが起きれば、データが境界を越えてしまっています。
工事中の建物で、内部の安全確認が終わる前に一般利用者を入れないのと同じように、
Guest向けの入口も、安全性を確認してから開放する
方針にしています。
Dataset は今回のシステムでは、
AdminやGuestごとに分けたデータの区画
として使っています。
そして route は、簡単に言えば、
商品登録画面
売上入力画面
Dashboard
API
など、Webアプリ内の各入口・処理経路のことです。
ゲストデモでは、最終的に第三者が実際にシステムへ触れることになります。
そのため、
「Guest用のDatasetを作れるようになった」
だけでは不十分です。
例えば、こんな状態が残っていたら危険です。
- Guestの商品が管理者画面へ混ざる
- 他Datasetの商品IDを直接送ると更新できる
- 管理者の通常操作でGuest商品まで論理削除される
- 他Datasetの売上がランキングへ混ざる
- 同名商品の数量がDatasetを越えて合算される
- 他Datasetの売上がGeminiへ渡すデータに混ざる
- Guestデータが存在するだけでAdmin用seedが止まる
第3段階では、このデータ越境を中心に確認しました。
開始時点は、
181 passed
最終結果は、
195 passed in 22.91s
です。
第3段階で追加したpytestは14件です。
ただし、今回も件数を増やすこと自体が目的ではありません。
テーマはこれまでと同じです。
「このGREENは、どの事故を防いでいるのか?」
💡 RED / GREENって何?
この記事では、
RED
= 意図した問題をpytestで再現し、
テストが失敗している状態
GREEN
= 問題を修正したあと、
テストが通った状態
という意味で使っています。
最初からGREENを見るだけではなく、修正前に本当に問題が起きることをREDで確認するのが今回の進め方です。
今回がシリーズの最初の記事になった方も、
何を作ろうとしているのか
↓
どんな問題が見つかったのか
↓
なぜ危険なのか
↓
どう直したのか
↓
pytestで何を確認したのか
を追えるよう、専門用語もできるだけその場で補足していきます。
目次
📌 この記事はかなり長めです。
第3段階で実際に確認したRED → GREENを残しているため、コードや検証結果も多めに掲載しています。
初めて読む方は上から順番に、以前の続きから読む方や気になる部分だけ確認したい方は、以下のリンクから飛べます。
- 第3段階でやることとまだやらないこと
- 第3段階の流れ
- Datasetを見ていない箇所を棚卸し
- 1 Product境界
- Productをcurrent Datasetへ限定する
- 2 DailySales境界
- Dataset条件だけでなく処理順序も変える
- DailySalesにdataset_idは追加しなかった
- 3 Dashboard API AIの境界
- 集計helperそのものをDataset必須にする
- 4 seedのDataset境界
- GREENになったあともう一度全部見る
- 最後にもう一度pytest
- 195件のpytestは何を確認しているのか
- 事故防止台帳としてどこまで育ったのか
- トラックドライバーとしての安全意識を開発にも持ち込む
- 第3段階で追加した14件
- 第3段階を終えて
- 次は第4段階
💡 目次へ戻りたいとき
目次のリンクから各見出しへ移動したあと、もう一度目次へ戻りたい場合は、ブラウザの「戻る」ボタンを使うと早いです。
マウスに「前のページへ戻る」ボタンがある場合は、それを使うとさらにスムーズです。
スマホ・タブレットならスワイプで目次に戻れます。
第3段階でやることとまだやらないこと
今回の対象は、業務データのDataset分離です。
構造を簡単にすると、こうなります。
Admin
└─ Admin Dataset
├─ Product
└─ DailySales
Guest A
└─ Guest A Dataset
├─ Product
└─ DailySales
Guest B
└─ Guest B Dataset
├─ Product
└─ DailySales
つまり、
Adminの商品・売上
Guest Aの商品・売上
Guest Bの商品・売上
を、それぞれ別の区画として扱います。
第2段階までに、
- Admin / Guest identity
- GuestごとのDataset作成
require_current_dataset()- 外部からDataset IDを指定できない仕組み
- GuestからAdmin専用routeへのアクセス拒否
まで実装していました。
ここでいう identity は、
現在アクセスしているのがAdminなのかGuestなのかを識別するための情報
です。
そして、
require_current_dataset()
は、
今ログインしている利用者が使うべきDatasetを、サーバー側で判断する
ための処理です。
💡 Datasetを「部屋」と考えると分かりやすい
イメージとしては、
Admin専用の部屋
Guest A専用の部屋
Guest B専用の部屋
がそれぞれ存在する状態です。
大事なのは、ログインしている人に対応する部屋をサーバー側で決めることです。
利用者自身に、
「今日はGuest Bの部屋を使います」
と自由に指定させる設計にはしません。
今回の第3段階では、
Product
DailySales
Dashboard
Dashboard API
AIへ渡す売上データ
seed
をDataset単位で分離します。
一方、まだ以下には進みません。
Guestの業務route開放
Guest A / Guest Bの実リクエストでの分離確認
Guestの有効期限
30分無操作判定
cleanup
AI利用回数制限
Guestを先に通してから境界を作るのではなく、
門を閉じたまま、内部の仕切りを先に作る
方針にしました。
第3段階の流れ
結果だけ並べると、こうなりました。
開始
181 passed
│
├─ Product境界テスト +3
│ 3 failed, 181 passed 🔴
│ ↓
│ 修正
│ ↓
│ 184 passed 🟢
│
├─ DailySales境界テスト +4
│ 4 failed, 184 passed 🔴
│ ↓
│ 修正
│ ↓
│ 188 passed 🟢
│
├─ Dashboard / API / AI境界テスト +4
│ 4 failed, 188 passed 🔴
│ ↓
│ 修正
│ ↓
│ 192 passed 🟢
│
├─ seed境界テスト +3
│ 2 failed, 193 passed 🔴
│ ↓
│ 修正
│ ↓
│ 195 passed 🟢
│
└─ production code全体を再監査
↓
第3段階完了可能
↓
195 passed 🟢
今回も、
テストを書く
↓
REDになる
↓
なぜ失敗したか確認する
↓
修正する
↓
GREENを確認する
という順番で進めました。
最初から修正してGREENを見るだけではなく、
修正前に本当に事故が起きることをREDで確認する
ようにしています。
例えば、
「たぶんGuest商品は混ざらない」
ではなく、
実際にGuest商品を作る
↓
Admin側からアクセスする
↓
本当に混ざる
↓
RED
まで確認してから直します。
Datasetを見ていない箇所を棚卸し
いきなりコードを修正せず、まずproduction code内のDBアクセスを調査しました。
production code は、テスト専用コードではなく、
実際にアプリ本体で動くコード
くらいの意味です。
すると、Dataset条件のないqueryが複数残っていました。
query はここでは、
データベースから「どのデータを取ってくるか」を指定する処理
くらいの意味です。
例えば商品一覧です。
Product.query.filter_by(
year=year,
month=month,
is_active=True
).all()
この条件が見ているのは、
year
month
is_active
だけです。
肝心の、
dataset_id
を見ていません。
Guest Datasetに商品が増えると、
Admin商品
Guest A商品
Guest B商品
が同じ検索対象へ入る可能性があります。
さらに注意が必要だったのが、ブラウザから送られた商品IDです。
db.session.get(Product, parsed_product_id)
Product IDだけで取得しているため、
別Datasetの商品IDを送る
↓
商品自体は存在する
↓
取得できる
↓
更新処理へ進める
という構造でした。
この時点ではGuest向け業務routeを admin_required で閉じているため、Guest本人がこの経路を利用できる状態ではありません。
つまり、
Guestが自由に商品更新できる
状態だったわけではありません。
しかし、Guest DatasetのデータがDBに存在するだけで、
Admin側へGuestデータが混入する問題
は再現できます。
⚠️ 「Guestがまだ入れないから大丈夫」ではない
この段階ではGuest向けroute自体は閉じています。
それでも、DB内にGuestデータが存在するだけでAdmin側の表示・更新・集計へ混ざる可能性がありました。
そのため、
Guestを開放してから問題を探す
のではなく、
Guestを開放する前に
内部の境界を確認する
順番にしています。
そこで、Guest routeを開放する前にDataset境界を固めます。
1 Product境界
最初は商品データです。
追加したテストは3件です。
Adminの商品画面にGuest商品を表示しない
AdminがGuest商品のIDをPOSTしても更新できない
Adminの商品保存でGuest商品を論理削除しない
論理削除 は、DBからデータそのものを消すのではなく、
is_active = False
のようにして、
削除済み・無効なデータとして扱う
方法です。
追加直後は、
3 failed, 181 passed
でした。
3件とも想定した理由でREDになりました。
RED 1:Guest商品がAdmin画面に表示された
商品一覧queryにDataset条件がなかったためです。
同じ年月であれば、Admin商品とGuest商品が同じ一覧へ入りました。
本来は、
Adminでログイン
↓
Admin Datasetの商品だけ取得
でなければいけません。
しかし実際には、
同じ年月の商品を取得
↓
Datasetは見ていない
↓
Guest商品も混ざる
となっていました。
RED 2:Guest商品を書き換えられた
Guest Datasetの商品IDをAdminの商品更新POSTへ送ると、
ゲスト商品
↓
越境更新商品
へ実際に変更されました。
原因は、
db.session.get(Product, parsed_product_id)
で、IDしか確認していなかったことです。
Product IDは整数主キーです。
そのため、
「IDを知られなければ大丈夫」
を認可として使うことはできません。
必要なのは、
そのIDの商品は存在するか
だけではなく、
そのIDの商品は
「現在のDataset」に存在するか
という確認です。
🔐 IDが存在することと、操作してよいことは別
例えば、
Product ID = 15
という商品がDBに存在していても、
その商品が現在のDatasetに所属しているか
まで確認しなければ認可にはなりません。
今回の修正で特に重要だったポイントです。
RED 3:普通の商品保存だけでGuest商品が論理削除された
これは特に注意が必要でした。
商品マスタ更新時、
existing_products = Product.query.filter_by(
year=year,
month=month
).all()
としていたため、同じ年月の別Dataset商品まで取得していました。
その後、
if product.id not in submitted_ids:
product.is_active = False
としていました。
この処理を日本語にすると、
今回送られてこなかった商品は、削除されたものとして無効化する
という処理です。
しかしDatasetを絞っていなかったため、
Adminが自分の商品マスタを保存
↓
同じ年月のGuest商品もexisting_productsへ入る
↓
Adminのsubmitted_idsには当然入っていない
↓
Guest商品 is_active=False
となります。
攻撃的な操作ではありません。
Adminが普通に使っただけで、別Datasetの商品を壊す可能性がありました。
こういう事故は、正常な画面操作だけを確認していると見逃しやすい部分だと思います。
Productをcurrent Datasetへ限定する
商品処理では、
current_dataset = require_current_dataset()
で、現在の認証identityに対応するDatasetを取得します。
ここでいう current Dataset は、
現在操作している利用者が使うべきDataset
です。
重要なのは、Dataset IDをブラウザから受け取らないことです。
例えば、
dataset_id=3
のような値を利用者に自由に送らせ、それをそのまま信用すると、
別のDataset IDを指定する
という経路が生まれてしまいます。
そこで、
ログイン中のidentity
↓
サーバー側でDatasetを決定
します。
商品ID検索も、
id=parsed_product_id
dataset_id=current_dataset.id
の両方で確認するようにしました。
つまり、
「その商品IDは存在するか?」
ではなく、
「現在のDatasetに、その商品IDは存在するか?」
を確認します。
別Datasetの商品IDなら、通常の存在しない商品と同じ扱いで拒否します。
「別Datasetにその商品が存在する」という情報も返しません。
新規Productについても、
dataset=current_dataset
へ所属させる構造にしました。
商品一覧、登録済み月、更新対象、論理削除対象もすべてcurrent Datasetへ限定します。
結果、
184 passed
となりました。
2 DailySales境界
次は売上入力です。
追加したテストは4件です。
Guest商品をAdminの売上入力画面へ出さない
Guest商品の当日売上をAdmin画面へ出さない
Guest商品IDで売上を更新できない
Admin商品とGuest商品を混ぜたPOSTを全体拒否する
追加直後は、
4 failed, 184 passed
でした。
RED:Guest商品の売上を実際に変更できた
テストではGuest商品の既存売上が、
987654 → 99
へ更新されました。
売上POSTでも、商品IDだけでProductを取得していたためです。
つまりProduct側で見つかった問題と同じように、
商品IDは存在する
↓
でも「誰のDatasetの商品か」は確認していない
という状態でした。
RED:mixed Dataset POSTでは両方更新された
さらに、
Admin商品
Guest商品
を同じPOSTに含めるテストを追加しました。
POST は、Web画面からサーバーへ、
この商品を更新したい
この数量を保存したい
といったデータを送るリクエストです。
結果は、
Admin売上 11 → 55
Guest売上 987654 → 77
でした。
ここで分かったのは、
「途中まで保存してGuest商品で失敗した」
のではないことです。
Guest商品自体を不正と判定していなかったため、
Admin商品 → OK
Guest商品 → これもOK
↓
両方更新
↓
commit
となっていました。
commit は、簡単に言えば、
ここまでのDB変更を正式に保存する
処理です。
Dataset条件だけでなく処理順序も変える
売上POSTでは、Dataset条件を付けるだけでなく、
書き込みを始める前に全商品を検証する
構造にしました。
POST受信
↓
current Dataset解決
↓
入力形式を検証
↓
全Productの存在確認
↓
全ProductのDataset所属確認
↓
年月・有効状態などを確認
↓
全部OK
↓
DailySales更新・作成
↓
commit
1件でも別Datasetの商品が混ざっていれば、
書き込み開始前にリクエスト全体を拒否
します。
そのため、
Admin商品
Admin商品
Guest商品 ← NG
Admin商品
のようなPOSTでも、最初のAdmin商品だけが先に更新されることはありません。
mixed Dataset POSTをatomic(全件成功か、全件拒否)に扱う
ことをpytestで確認しました。
💡 atomicを簡単に言うと
例えば4件の売上をまとめて送った場合、
1件目 OK
2件目 OK
3件目 NG
4件目 OK
だったとしても、
OKだった3件だけ保存
とはしません。
4件すべて保存しない
とします。
途中まで処理された中途半端な状態を残さないためです。
結果、
188 passed
となりました。
DailySalesにdataset_idは追加しなかった
今回、DailySales 自身には dataset_id を追加していません。
構造は、
Dataset
↓
Product
↓
DailySales
です。
DailySalesがどのDatasetへ属するかは、Productを経由すれば判断できます。
そのため、
DailySales
JOIN Product
WHERE Product.dataset_id = current_dataset.id
としてスコープします。
JOIN は、ここでは、
DailySalesとProductを関連付けて検索する
という意味です。
例えばDailySales自身にDataset情報がなくても、
この売上はProduct 10の商品
↓
Product 10はAdmin Datasetの商品
↓
この売上もAdmin Datasetのもの
と判断できます。
同じ所属情報をDailySalesにも重複して持たせるのではなく、
Productを境界としてDataset所属を保証する
設計を維持しました。
3 Dashboard API AIの境界
次は売上集計です。
ここでは、画面を眺めるだけでは気づきにくい越境が見つかりました。
例えば、
Admin Dataset
クロワッサン 10個
Guest Dataset
クロワッサン 90個
が存在するとします。
Dataset条件なしで Product.name ごとにGROUP BYすると、
クロワッサン 100個
になります。
GROUP BY は、
同じ商品名などをひとまとめにして集計するSQLの処理
です。
Guest固有の商品名が表示されるわけではありません。
画面にはただ、
クロワッサン 100個
と出ます。
一見すると普通の集計結果です。
しかし実際には、
Admin 10個
+
Guest 90個
=
100個
です。
つまり、
別Datasetの数字だけが静かにAdminの集計へ混ざる
状態でした。
⚠️ 「Guestの商品名が見えないから安全」とは限らない
今回見つかったのは、商品名そのものではなく、
数量だけが別Datasetと合算される
という問題です。
画面上では自然な数字に見えるため、目視確認だけでは気づきにくい越境でした。
RED:同名商品が100個へ合算された
pytestで実際に、
Admin クロワッサン: 10個
Guest クロワッサン: 90個
を作りました。
結果は、
クロワッサン: 100個
でした。
Datasetを絞る前に商品名単位で集計していたためです。
これは、
Guestの商品名が画面に出た
よりも見つけにくい問題でした。
同じ商品名であれば、
数字だけが混ざって見える
からです。
RED:GuestデータがGeminiへ渡すpromptに混入した
さらにAI連携も確認しました。
ここでは実際のGemini API通信は行っていません。
mock を使い、
generate_content(contents=...)
へ渡そうとした最終promptを確認しました。
mock は、本物の外部APIを呼ばずに、
もし呼び出したら何を渡す予定だったか
をテストするための代役です。
🤖 実際のGemini APIには送信していません
今回確認したのは、
Geminiへ送信する直前のprompt
です。
mockを使って、
この状態でAPIを呼んだら
何が送られる予定だったか
を確認しています。
Dataset境界を追加する前は、
AIゲスト機密商品: 876543個
AI共通クロワッサン: 100個
AI管理者限定商品: 12個
というデータがpromptに含まれていました。
Guest固有の商品だけでなく、
Admin 10個
Guest 90個
の同名商品まで、
100個
としてGeminiへ渡そうとしていました。
実際の通信はしていませんが、
このまま本番で呼べば、Guestデータを含んだpromptを送る可能性がある
状態です。
問題の中心は、
_get_sales_from_db()
でした。
Dashboard、Dashboard API、AI adviceがすべてこの集計helperを使っていました。
helper は、複数の処理から共通して呼び出すためにまとめた処理です。
集計helperそのものをDataset必須にする
_get_sales_from_db()を、
_get_sales_from_db(
current_dataset,
target_year=None,
target_month=None,
)
へ変更しました。
current_datasetにはデフォルト値を設けていません。
つまり、
_get_sales_from_db()
と呼んで、うっかり全Datasetを集計する使い方自体ができません。
呼び出す側が必ず、
どのDatasetを集計するのか
を渡す必要があります。
SQL queryにも、
Product.dataset_id == current_dataset.id
を追加します。
順番は、
Product JOIN DailySales
↓
current Datasetへ限定
↓
年月条件
↓
Product.nameでGROUP BY
です。
大事なのは、
全Datasetを取得
↓
PythonでGuestを除外
ではありません。
DB queryの時点で
current Datasetだけを取得
します。
そもそも不要なデータを取得しないようにします。
💡 あとから除外するのではなく、最初から取らない
今回の方針は、
全Datasetを取得
↓
あとからGuestを削除
ではありません。
DBへ問い合わせる時点で
current Datasetだけを指定
します。
不要なDatasetのデータを、最初から検索対象へ入れないようにしています。
これで、
current Dataset
↓
_get_sales_from_db()
↓
├─ Dashboard
├─ Dashboard API
└─ AI advice
の3経路を、同じ境界で守れます。
先ほどの例も、
Admin クロワッサン: 10個
Guest クロワッサン: 90個
なら、
Admin集計
クロワッサン: 10個
となります。
結果、
192 passed
になりました。
4 seedのDataset境界
最後にデモデータ投入処理を確認しました。
seed は、
動作確認などに使う初期データをDBへ入れる処理
です。
元の処理では、
Product.query.first()
DailySales.query.first()
によって、DB全体にデータがあるか確認していました。
そのため、
Admin Dataset
データなし
Guest Dataset
データあり
という状態でも、
Demo seed skipped: database already contains data.
となりました。
Admin Datasetは空なのにseedが停止します。
ここでは、Guestデータ自体が変更・削除されていたわけではありません。
問題は、
Guestデータの存在が、Admin Datasetの処理判断へ影響していた
ことです。
データそのものを壊さなくても、
本来関係のないDatasetの存在によって
別Datasetの処理結果が変わる
なら、これも境界が十分ではありません。
seedの存在確認もAdmin Datasetだけを見る
Productの存在確認は、
Product.query.filter_by(
dataset_id=admin_dataset.id
).first()
へ変更しました。
DailySales自身には dataset_id がないため、
DailySales
JOIN Product
WHERE Product.dataset_id = admin_dataset.id
として確認します。
これにより、
Guest Datasetにデータあり
↓
Admin Datasetにはデータなし
↓
Admin seed実行
が可能になります。
同時にpytestで、
Guest Product
Guest DailySales
について、
- 名前
- 価格
- 有効状態
- Dataset ID
- 数量
- 日付
- Product ID
- 件数
がseed実行前後で変化しないことも確認しました。
結果、
195 passed
となりました。
GREENになったあともう一度全部見る
ここで第3段階を終了することもできました。
しかし最後に、もう一度production code全体を監査しました。
各テストがGREENになっていても、
今回テストを書かなかった別の場所に
同じようなDataset漏れが残っているかもしれない
からです。
1回目は、
REDで問題を再現
↓
Codexで修正
↓
pytestでGREEN
です。
2回目は、Codexに修正後のproduction code全体をもう一度横断して監査させ、
Product.query
DailySales.query
ProductとのJOIN
product_idの直接取得
Dashboard集計
Dashboard API
Gemini入力
seed
にDatasetスコープ漏れが残っていないか確認しました。
スコープ はここでは、
どのDatasetのデータまで処理対象にするかという範囲
という意味です。
各機能のRED → GREENだけで終わらせず、
最後に全体をもう一度見る
というダブルチェックです。
🔎 GREENだから即終了、にはしなかった
pytestがすべてGREENでも、
まだテストを書いていない場所に
同じ問題が残っているかもしれない
という可能性はあります。
そのため今回は、GREEN確認後にCodexへproduction code全体の再監査も行わせました。
出口監査の結果
監査ではProductアクセスをすべて分類しました。
A:current Datasetへ限定済み
B:Admin Datasetへ限定済み
C:Dataset境界が不要な正当な処理
D:スコープ漏れ
結果、今回の監査範囲では、
D:スコープ漏れ
0件
でした。
特に、
db.session.get(Product, ...)
のような、
商品IDだけでProductを取得する、実際のアプリ動作中のproduction経路
が残っていないことも確認しました。
DailySalesについても、
Dataset条件付きJOIN
または、
current Dataset所属を確認済みのProduct
だけを経由していました。
外部から渡された dataset_id を認可の根拠として信用する処理もありません。
Dataset解決に失敗した場合、
仕方がないので全Datasetを取得
といったfallbackもありません。
つまり、
Datasetが分からない
↓
安全のため処理を止める
方向です。
これは fail-closed と呼ばれる考え方に近く、
判断できないときに広く許可するのではなく、安全側へ閉じる
ようにします。
🛡️ fail-closed
簡単に言えば、
よく分からない
↓
とりあえず通す
ではなく、
よく分からない
↓
安全のため止める
という考え方です。
Codexによる今回の出口監査の判定は、
A. 第3段階完了可能
でした。
最後にもう一度pytest
出口監査後、もう一度全pytestを実行しました。
pytest -q
結果は、
195 passed in 22.91s
でした。
195件のpytestは何を確認しているのか
ここまでpytestが増えると、
「195件も作って、何をテストしているの?」
という疑問が出ても不思議ではありません。
自分自身、テスト件数だけが一人歩きする状態にはしたくありません。
そこで第3段階終了時点で、
pytest --collect-only -q
を実行し、195件が何を確認しているのか改めて一覧化しました。
--collect-only を使うと、pytestを実行せず、
どんなテストが登録されているのか
を一覧で確認できます。
📚 この表の用語を全部理解する必要はありません
以下には、
CSRF
migration
rollback
fingerprint
cascade
など、いくつか専門用語も出てきます。
ここで見てほしいのは、
「195件もある」という数字ではなく、どんな種類の事故を確認しているのか
という部分です。
知らない用語があっても、そのまま読み進めて大丈夫です。
現在の内訳はこうなっています。
| 分類 | 件数 | 主に確認していること |
|---|---|---|
| AI連携 | 6 | Gemini入力、Dataset分離、429/503等のfallback |
| Admin認証 | 8 | ログイン、session復元、fingerprint、未認証POST |
| route認可 | 21 | 匿名・非Admin拒否、Admin許可、AI実行前認可 |
| CSRF | 9 | token有無、不正token、拒否時のDB不変 |
| Dashboard / 集計 | 13 | 期間集計、グラフ整合、Dataset分離、不正query |
| Dataset認可 | 17 | Admin/Guest Dataset解決、外部ID拒否、fail-closed |
| Dataset migration | 4 | backfill、NOT NULL化、upgrade/downgrade |
| Dataset / Product構造 | 6 | Dataset所属、分離、FK、cascade |
| Dataset制約 | 9 | kind、system_key、期限、重複Admin防止 |
| Guest identity | 24 | Guest復元、Admin昇格防止、不正identity拒否 |
| Guest session作成 | 12 | Dataset発行、login順序、rollback、identity分離 |
| migration全体 | 1 | 空DBからheadまで完走 |
| 商品登録・更新 | 27 | CRUD、論理削除、rollback、不正入力、データ越境防止 |
| AI prompt契約 | 3 | 売上挿入位置、AI役割、出力契約 |
| 売上入力 | 22 | 更新・追加、rollback、入力検証、atomic性、データ越境防止 |
| seed | 6 | 投入、重複防止、Admin Dataset、Guest独立性 |
| XSS回帰 | 7 |
innerHTML禁止、text API、Jinja autoescape |
| 合計 | 195 |
195件を1件ずつ数字として覚えるのではなく、
どんな事故を防ぐためのテスト群なのか
を分類して把握するようにしています。
例えば、
認証の事故
認可の事故
DB更新の事故
データ越境
AIへのデータ混入
XSSの再発
migrationによる破損
のように見ると、
「195」という数字より、何を守っているpytestなのかが分かりやすくなります。
事故防止台帳としてどこまで育ったのか
最初のpytestは、正常に動くことを確認するための数件から始まりました。
例えば、
正しく入力したら正しく保存できる
画面が正常に表示される
といった確認です。
しかし現在は、
正しく入力したら正しく動く
だけを確認しているわけではありません。
例えば、
認証が切れたらどうなるか
権限のない利用者がアクセスしたらどうなるか
不正なIDを直接送ったらどうなるか
途中でDB保存に失敗したらどうなるか
別Datasetの商品が混ざったらどうなるか
同名商品が別Datasetに存在したらどうなるか
AIへ渡してはいけないデータが混ざらないか
migrationで既存データを壊さないか
XSS対策を将来の修正で戻してしまわないか
まで確認するようになりました。
つまり現在のpytestは、
「正常に動くことを確認するテスト」だけではなく、
「過去に見つけた事故・想定した事故・再発させたくない事故を記録する台帳」
に近づいています。
今回の第3段階でも、
商品閲覧の越境
商品更新の越境
論理削除の越境
売上更新の越境
mixed Dataset POST
同名商品の越境合算
Dashboard APIへの混入
Geminiへ渡すデータへの混入
seed判定へのGuestデータ混入
をREDとして実際に再現しました。
そして、
RED
↓
原因確認
↓
修正
↓
GREEN
↓
production code全体を再監査
という形で残しました。
一度見つかった問題を、
直したから忘れる
のではなく、
pytestとして残す
↓
将来同じ問題が戻ればREDになる
状態にします。
これが、自分がpytestを**「事故防止台帳」**と呼んでいる理由です。
📝 195 passedはゴールではない
pytestが195件すべてGREENになったから、
もう事故は起きない
これ以上テストは必要ない
という意味ではありません。
システムへ新しい機能を追加すれば、これまで存在しなかった事故候補も増えます。
そのたびに必要な項目を事故防止台帳へ追加していく、という考え方です。
今後Guest向けrouteを開けば、
Guest A → Guest B
Guest A → Admin
期限切れGuest
cleanup
AI利用回数制限
など、新しい事故候補も出てきます。
そのたびに、必要な確認を追加していく。
自分にとっての「事故防止台帳」は、
完成して閉じるものではなく、システムの変化に合わせて更新していく台帳
という位置づけです。
トラックドライバーとしての安全意識を開発にも持ち込む
本業ではトラックドライバーとして、安全運転でよく意識している考え方があります。
トラックを運転していると、
事故が起きてから考えるのでは遅い
場面が多くあります。
だから普段から、
「事故が起きないように、先に危険を想定する」
ことを意識しています。
その考え方は、個人開発を続ける中でも少しずつ自分の開発スタイルに入ってきました。
例えば、
- 「かもしれない運転」
- ABCルール(当たり前のことを、馬鹿にせず、ちゃんとやる)
- 死角を確認し、すぐ動かず指差し確認する
- ゆとりを持った運転
です。
もちろん、交通安全の考え方をそのまま技術用語へ置き換えているわけではありません。
ただ、根っこの考え方には共通する部分があると感じています。
「かもしれない運転」
運転では、
歩行者が出てくるかもしれない
自転車が死角にいるかもしれない
前の車が急停止するかもしれない
と、まだ起きていない危険を先に考えます。
開発でも、
別Datasetへ越境するかもしれない
GREENでも条件を見落としているかもしれない
商品IDを直接送られるかもしれない
同名商品なら数字だけ混ざるかもしれない
と先に疑う。
今回のDataset境界テストも、この考え方にかなり近いです。
「当たり前のことを、馬鹿にせず、ちゃんとやる」
自分の職場では、
ABCルール
という言葉があります。
A:当たり前のことを
B:馬鹿にせず
C:ちゃんとやる
です。
システム開発でも、
認証
認可
CSRF
入力検証
rollback
Dataset確認
などは、派手な機能ではありません。
ユーザーから見れば、
普通にログインできる
普通に商品を保存できる
普通に自分のデータだけ見える
という「当たり前」の部分です。
でも、その当たり前を支えている処理を省略すると事故につながります。
だから、
動いていて当たり前に見える部分ほど、馬鹿にせず確認する
ようにしています。
「死角を確認する」
大型車には、どうしても見えにくい死角があります。
左折するときも、
ミラーを見る
↓
周囲を見る
↓
死角を意識する
↓
すぐ曲がらず確認する
ことが重要です。
システムでも、正常な画面だけ見ていると気づきにくい死角があります。
今回で言えば、
商品IDの直接指定
mixed POST
同名商品の越境合算
AIへ渡す直前のprompt
seedの存在確認
などです。
例えば、
DashboardにGuest商品名が出ていない
だけなら問題なさそうに見えても、
Admin 10個
Guest 90個
↓
100個
と数字だけ混ざっている可能性があります。
見えている画面だけではなく、
その裏側にある死角も見る
ようにしています。
「ゆとりを持つ」
Guestデモは、早く形にすることだけを考えれば、先に画面やGuest向け入口を作ることもできます。
しかし今回は、
Guestを識別する仕組みを作る
↓
Guest用Datasetを作る
↓
Dataset境界を固める
↓
REDで越境を再現する
↓
修正する
↓
GREEN
↓
production code全体を再監査
↓
それからGuestを通す
という順番にしています。
見た目だけなら、
「体験版を追加するだけなのに、ずいぶん時間をかけている」
ように見えるかもしれません。
でも、実際に第三者へ操作してもらうなら、
ゲートを開けたあとで境界の穴に気づくより、閉じているうちに確認した方がいい
と考えています。
これも、自分の中では「ゆとりを持った運転」に近い感覚です。
急いで先へ進むより、
確認できるところで確認する
ことを優先します。
「気をつける」だけではなく、仕組みで守る
運送の仕事でも、安全はドライバー個人の注意力だけで成立するものではありません。
例えば、
点呼
運行記録
勤務時間の管理
車両点検
確認手順
などがあります。
どれだけ本人が、
「今日は気をつけて運転しよう」
と思っていても、それだけに安全を任せるのではなく、
事故が起こりにくいように、確認や記録を仕組みとして残す
ことが重要だと感じています。
これはシステム開発でも同じだと思っています。
例えば、
別Datasetの商品を触らないよう気をつける
だけではなく、
別Datasetの商品IDならコード側で拒否する
ようにする。
不正なデータを保存しないよう気をつける
ではなく、
全件検証が終わるまでDBへ書き込まない
ようにする。
同じバグをもう起こさないよう覚えておく
だけではなく、
pytestへ残し、戻ったらREDになる
ようにする。
つまり、
人が毎回完璧に注意することを期待するのではなく、事故が起きにくい仕組みを作る
という考え方です。
今回のDataset境界も、
越境しないよう注意する
のではなく、
越境しようとしても通らない
状態を目指しています。
そしてpytestは、
その安全装置が
将来の変更でも残っているか
を確認する役割も持っています。
🚛 自分の中で共通している考え方
運転
事故が起きないよう先に危険を考える
開発
不具合が起きないよう先に失敗条件を考える
分野は違っても、
「起きてから対応するだけではなく、起きる前に防ぐ仕組みを作る」
という考え方は共通していると感じています。
トラックドライバーとして普段から意識しているこうした考え方を、
「先に危険を想定する」
「当たり前の確認を省略しない」
「見えにくい死角を疑う」
「GREENでももう一度見る」
「注意力だけに頼らず、仕組みで守る」
という形で、システム開発にも持ち込んでいます。
そして、その確認結果を残していく場所が、
自分にとってのpytestという**「事故防止台帳」**です。
第3段階で追加した14件
今回、第3段階で新しく増えたのは14件です。
大きく分けると、
Product境界 +3
DailySales境界 +4
Dashboard / API / AI境界 +4
seed Dataset独立性 +3
──────────────────────────────
合計 +14
です。
181 passed
↓
195 passed
という数字だけを見ると、
pytestが14件増えた
だけに見えます。
でも実際には、
商品閲覧の越境
商品更新の越境
論理削除の越境
売上更新の越境
mixed POST
集計値の混入
同名商品の越境合算
Dashboard APIへの混入
Geminiへ渡すデータへの混入
seed判定へのGuestデータ混入
などをREDとして実際に再現し、その後GREENへ変えた14件です。
自分としてはこちらの中身の方を重視しています。
pytestが14件増えた
ではなく、
新しく14件の「事故防止項目」が台帳へ追加された
という感覚に近いです。
第3段階を終えて
第3段階では、まだGuestを業務routeへ通していません。
先に業務データのDataset境界と、修正後の全体再監査まで確認しました。
工事中の建物に例えるなら、
一般利用者を入れる前に、
内部の区画・鍵・通路を先に確認した
という状態です。
最終結果は、
195 passed in 22.91s
でした。
これで、
ゲストデモ実装 第3段階「業務データのDataset分離」
は完了としました。
まだゲストデモそのものを公開したわけではありません。
今は、
ゲートを開ける前の安全確認
を進めている段階です。
✅ 第3段階で確認できたところ
Product ✅
DailySales ✅
Dashboard ✅
Dashboard API ✅
Gemini入力 ✅
seed Dataset独立性 ✅
全体再監査 ✅
ただし、Guestを実際の業務routeへ通した状態でのAdmin / Guest A / Guest Bの分離確認は、次の第4段階で行います。
次は第4段階
次の第4段階では、Guestを限定的に業務routeへ通すところから進める予定です。
ここからは、
Admin
Guest A
Guest B
が、実際のrequest経路でも互いのデータへ越境できないかを確認していきます。
特に、
Guest A → Guest B
Guest A → Admin
Guest B → Guest A
Admin → Guest
の境界を、実際のrouteレベルで確認する必要があります。
第3段階までは、
内部のDataset境界を作った
段階です。
第4段階では、
実際にGuestを通したときにも
その境界が守られるか
を確認していきます。
さらに今後、
- Guest期限切れ
- 30分無操作
last_activity_at- cleanup
- Guestデータだけの安全な削除
- AI利用回数制限
なども残っています。
まだゲストデモそのものが完成したわけではありません。
セキュリティを固めてからゲートを開ける。
引き続き、
安全第一⛑️
で進めます。
👤ゲストデモモード搭載シリーズ
第1段階
既存AdminデータをDatasetへ移行し、Guest領域を作る前の土台を整えました。
第2段階①
Guestを入れる前にAdminを守り、Guest identityとDataset認可の土台を作りました。
第2段階②
Guest用Datasetをサーバー側で安全に発行し、Guest identityと結びつけたうえで、181件GREENでも最後に安全条件を再監査しました。
第3段階(※この記事です)
Guestを業務routeへ通す前に、Product・DailySales・Dashboard・API・AI・seedのDataset境界を固め、pytestを181件から195件へ強化しました。
第4段階
正規Guestを実際の業務routeへ通し、商品・売上・Dashboard・AIまで実request経路でDataset越境が起きないことを確認しました。
✅️pytest強化シリーズ
