はじめに
現在、ベーカリー向け売上管理Webアプリケーション
🍞 sales_data_app 🥖
を個人開発している tosane932 です。
直前まではGuest Demo公開後の総点検として、
JST
Dashboard期間
logout
Gemini timeout
PostgreSQL CI
並行処理
など、主に既存機能を安全に使い続けるための防御開発を進めていました。
前回の記事はこちらです。
今回は久しぶりに、
店舗で実際に使う新しい業務機能
として、
【🛒 材料発注リスト】
を追加しました。
ただし、いきなり画面を作ったわけではありません。
Change I
データ基盤
↓
Change J
CRUD + PostgreSQL並行処理
↓
Change K
Navigation / UI / UX基盤
という順番で進めました。
なぜ材料発注リストなのか
ベーカリーでは日々、
強力粉
バター
牛乳
卵
包装資材
など、購入・発注するものが発生します。
ただし今回作りたかったのは、
在庫管理
仕入先管理
原価計算
発注書
納品管理
まで含む大規模なシステムではありません。
イメージは、
□ 強力粉 2袋
□ バター 5個
□ 牛乳 1ケース
のような、
今日買うもの・注文するものを、スマホですぐ確認できるチェックリスト
です。
そのため、既存のProductやRecipeとはあえて結びつけず、
入力の速さと分かりやすさ
を優先しました。
Change I:まずデータ基盤を作る
PR:
最初に作ったのは画面ではなく、
DBモデル
Dataset境界
migration
Guest cleanup
rollback
PostgreSQL
です。
migration
DB構造の変更履歴を安全に適用する仕組みです。
rollback
途中で失敗したときに、変更を元へ戻す処理です。
追加したモデルは、
MaterialOrderItem
です。
| Column | 用途 |
|---|---|
id |
Primary Key |
dataset_id |
Dataset分離 |
name |
材料名 |
quantity_text |
数量・単位 |
memo |
メモ |
is_completed |
完了状態 |
created_at |
作成時刻 |
completed_at |
完了時刻 |
数量は、
2袋
5個
1ケース
500g
のような入力を優先し、
quantity_text
として文字列で保持します。
Dataset配下へ置く
sales_data_appでは、
Admin
Guest A
Guest B
をDatasetで分離しています。
MaterialOrderItemも同様に、
Dataset
└─ MaterialOrderItem
という構造にしました。
FKには、
ON DELETE CASCADE
を設定。
Guest cleanupでも、期限切れDataset配下の材料発注データを明示的に削除します。
例えば、
Guest A:期限切れ
Guest B:有効
Admin:有効
なら、
Guest A → 削除
Guest B → 保持
Admin → 保持
となることをpytestで確認しています。
cleanup途中でDB例外が起きた場合も、
rollback
によって、
Dataset
MaterialOrderItem
Product
DailySales
が途中まで消えた状態を残さないことを確認しました。
Alembic migration
新しいrevisionは、
d4f7a9c2e6b1
です。
SQLite・PostgreSQLともにmigration経路を確認し、既存データが維持されることも検証しました。
Change I終了時点:
437 passed
0 failed
0 skipped
Change J:CRUDと並行処理
PR:
次に、
GET /material-orders
POST /material-orders
POST /material-orders/<id>/completion
POST /material-orders/<id>/delete
を追加しました。
機能は、
一覧
追加
完了
未完了へ戻す
削除
です。
新機能はmaterial_orders.pyというBlueprintへ分離し、既存の認証とDataset resolverを外から渡しています。
Dataset越境を防ぐ
更新・削除対象は、
id = item_id
AND
dataset_id = current_dataset.id
を同一queryで絞り込みます。
そのためGuest AがGuest BのIDを知っていても、
完了
未完了
削除
はできません。
存在しないIDも、他DatasetのIDも同じ404にしています。
追加時のdataset_idも利用者入力から受け取らず、サーバー側で現在のDatasetを決定します。
IDOR
URLなどのIDを書き換えることで、他人のデータを操作できてしまう問題です。
CSRF / XSS / validation
状態変更はすべてPOSTです。
追加
完了
未完了
削除
について、
CSRF tokenなし
改ざんtoken
ではDBが変更されないことを確認しました。
利用者入力についてもJinja autoescapeを維持し、
|safe
Markup
innerHTML
は使っていません。
入力上限は、
| 項目 | 制限 |
|---|---|
| 材料名 | 必須・最大100文字 |
| 数量・単位 | 任意・最大30文字 |
| メモ | 任意・最大300文字 |
としています。
100件上限をPostgreSQLで守る
1 Datasetあたり、
未完了 + 完了済み
を合わせて最大100件です。
単純に、
COUNT
↓
INSERT
するだけでは、
99件の状態で2requestが同時に来たとき、
101件
になる可能性があります。
そこで、
Dataset row
↓
SELECT ... FOR UPDATE
↓
COUNT
↓
INSERT
↓
COMMIT
という順にしました。
row lock
同じデータを同時に書き換えないよう、一時的に順番待ちさせる仕組みです。
PostgreSQL 16で99件の状態から2requestを同時送信した結果、
成功:303
上限拒否:400
最終件数:100
となりました。
さらにpg_stat_activityで、
2本目が実際にrow lock待ちになったこと
まで確認しています。
GREEN後の監査で競合を発見
Change J終了時点では、
475 passed
でした。
しかし、
pytestがGREENでも、まだ壊せるかもしれない
という前提で総合監査を行ったところ、
Critical:0
High:0
Medium:1
となりました。
見つかったのは、
同一MaterialOrderItemへの並行操作
です。
例えば、
completion + completion
completion + reopen
completion + delete
が同時に発生した場合です。
MaterialOrderItem自体をlockする
修正後は、
MaterialOrderItem.query.filter_by(
id=item_id,
dataset_id=current_dataset.id,
).populate_existing().with_for_update().one_or_none()
としました。
ポイントは、
row lock
+
lock待ち後に最新状態を再読込
することです。
公式ドキュメント:
PostgreSQL 16 Explicit Locking
SQLAlchemy with_for_update()
SQLAlchemy populate_existing
実PostgreSQLで並行操作を確認
| 競合 | 最終結果 |
|---|---|
| completion + completion | 2本とも303、時刻更新は1回 |
| completion + reopen | 最終状態は未完了 |
| completion + delete | 最終状態は削除済み |
いずれも、
500なし
CHECK違反なし
transaction破損なし
でした。
Change J最終結果:
478 passed
0 failed
0 skipped
PostgreSQL integration:
13 passed
Change K:アプリ全体のNavigationを作る
PR:
材料発注を追加したことで、
商品登録
日次売上入力
Dashboard
材料発注
と機能が増えてきました。
トップページへリンクを増やし続けるより、
アプリ全体の移動方法を先に整える
ことにしました。
PCは固定sidebar
PCでは左側に固定sidebarを追加しました。
主な入口は、
商品登録
日次売上入力
Dashboard
店舗メモツール
です。
現在地は、
aria-current="page"
背景
太字
左側ライン
などを組み合わせ、
色だけに頼らず分かるようにしています。
Mobileはhamburger drawer
スマホでは、
☰ メニュー
から開くdrawer形式にしました。
Esc操作、focus制御、aria-expanded、aria-controlsなども含めて確認しています。
店舗メモツールという小さな器
材料発注を作る中で、
🛒 発注
📝 メモ
✅ タスク
を一つのまとまりとして扱うことにしました。
Mobileでは固定下部Navigationを置いています。
現在は、
発注:実装済み
メモ:準備中
タスク:準備中
です。
ここではメモやタスクまで一気に作らず、
次の機能を追加できる器だけ先に整えました。
材料追加FAB
材料発注には、
+
のFloating Action Buttonも追加しました。
押すと既存の材料名入力欄へfocusします。
スマホでは64pxの丸ボタンにし、600px以下では文字ラベルを隠しています。
Local Development Mode
Change Kをlocalhostで確認している途中、
503
Admin login rate limiting client IP is missing or invalid.
になりました。
productionではCloudflareの、
CF-Connecting-IP
だけを信頼するfail-closed設計ですが、localhostにはそのheaderがありません。
そこでproductionを緩めるのではなく、
LOCAL_DEVELOPMENT=true
を明示した場合だけ、
request.remote_addr
SESSION_COOKIE_SECURE=False
を利用するモードを追加しました。
LOCAL_DEVELOPMENT=trueは開発環境専用です。
productionでは未設定またはfalseとし、従来のfail-closedとSecure Cookieを維持します。
Change K終了時点
full pytest:
526 passed
13 skipped
PostgreSQL integration:
13 passed
GitHub Actions:
test:SUCCESS
postgres-integration:SUCCESS
通常のfull pytestでは、隔離PostgreSQL環境が必要なtestを分離しているため13件skipします。
PostgreSQL専用jobでは、その13件を実際に実行してGREENを確認しています。
526件のpytestは、本当に故障を検知できるのか?
ここで自分でも、
pytestが増えているだけでは?
と気になりました。
そこでPR #28だけを対象に、軽量なMutation Testingを行いました。
Mutation Testing
あえてコードを壊して、pytestが本当に故障を検知できるか確認する方法です。
正常なコードを1箇所ずつ意図的に壊し、
pytestがREDになる
→ KILLED
GREENのまま
→ SURVIVED
として確認します。
12種類の故障を入れる
| # | Mutation | 結果 |
|---|---|---|
| 1 |
1 / yes / trueeでもLocal Developmentを有効化 |
KILLED |
| 2 | 本番でCloudflare header欠損時にfallback | KILLED |
| 3 | 不正なCloudflare IPを許可 | KILLED |
| 4 | Local DevelopmentでもCloudflare headerを使用 | KILLED |
| 5 | 通常環境でもSecure Cookieを無効化 | KILLED |
| 6 | Dashboard linkを誤配線 | KILLED |
| 7 |
aria-currentを無効化 |
KILLED |
| 8 | Admin / Guest表示を反転 | KILLED |
| 9 | logoutをPOSTからGETへ変更 | KILLED |
| 10 | logoutからCSRF tokenを削除 | KILLED |
| 11 | Memo routeの認証を削除 | KILLED |
| 12 | FABのaria-labelを誤値へ変更 |
KILLED |
結果:
12 KILLED
0 SURVIVED
つまり今回確認した12種類については、
実際にコードを壊すと既存pytestがREDになりました。
Mutation後の最終確認
各Mutationは、
1つ壊す
↓
pytest RED確認
↓
元へ戻す
↓
次へ
という順で実施しました。
最終状態:
526 passed
13 skipped
git diff --check
問題なし
git status
clean
Mutation用コードは残っていません。
今回のMutation TestingはPR #28限定です。
アプリ全体の安全性や、すべてのコードパスを証明するものではありません。
確認できたのは、
PR #28の重要な12種類の故障を、既存pytestが実際に検知できた
というところまでです。
小さい機能でも、裏側は小さくない
利用者から見る材料発注リストは、
材料を書く
完了にする
削除する
くらいの小さな機能です。
しかし裏では、
Dataset分離
CSRF
IDOR
XSS
rollback
migration
Guest cleanup
100件上限
並行request
row lock
Navigation
Mutation Testing
まで考えることになりました。
利用者には、
そんなことまで?
と思われるくらいでちょうどいいのかもしれません。
簡単に見えるように、裏側で面倒なことを引き受ける
こともWebアプリ側の役割だと思っています。
次はChange L:ようやくメモ機能へ
Change Kで、
🛒 発注
📝 メモ
✅ タスク
という店舗メモツールの器ができました。
次は、
Change L:メモ機能
へ進みます。
一般的にはCRUD学習の序盤で作ることも多いメモアプリですが、
sales_data_appでは、
認証
CSRF
Dataset分離
Guest lifecycle
cleanup
migration
PostgreSQL
並行処理
CI
500件以上のpytest
などを先に整えたあとで、ようやく作ることになりました。
地盤と基礎を徹底的に固め、壊れないか何度も確認したあと、広い土地の真ん中にようやく小さなメモアプリを建て始めます。
ただし、高機能なメモアプリを作ること自体が目的ではありません。
売上管理をしている途中で、
明日は食パンを多めにしよう
○○業者へ価格確認
新商品の試作を忘れない
といったことを、
別のアプリへ移動せず、その場で残せる
ようにしたいと考えています。
機能数そのものではなく、
このシステムを使う価値を少しずつ高めること
を優先します。
売上入力のときだけ開くシステムではなく、
毎日の仕事のそばに置いてもらえるアプリ
へ育てていきたいと思っています。
おわりに
今回のChange I〜Kは、
Change I
データ基盤
↓
Change J
CRUD + PostgreSQL並行処理
↓
反証監査
同一row競合を修正
↓
Change K
Navigation / UI / UX
Local Development
↓
Mutation Testing
12 / 12 KILLED
という流れになりました。
最終結果:
full pytest
526 passed
13 skipped
PostgreSQL integration
13 passed
GitHub Actions
test:SUCCESS
postgres-integration:SUCCESS
Mutation Testing
12 KILLED
0 SURVIVED
関連リンク
GitHub
Change I
Change J
Change K
前回の記事




