A UI Freeze Without a Server Check Is Not a Freeze
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
The UI froze inputs when a slot was live or bought out, while the server checked only live, so a direct POST could rewrite targeting right after a buyout with nothing booked yet.
The short version
The condition that freezes an input and the condition on which the update path rejects a request drift apart easily when they live in separate implementations, however much they are meant to be looking at the same two states (live, bought out). Here the server had only the one condition, “live”, and exactly one state — “bought out but not yet running” — walked past validation.
Less a symptom than a blind spot
Nothing was harmed in production. Twelve minutes after the commit adding the ad-slot edit form, review flagged the mismatch and it was fixed the same day. It is still worth writing up, because both implementations worked correctly when examined on their own.
- Open the edit screen and, for a live slot or one bought out by an organisation, the targeting inputs (scope, monitors, schools) are properly frozen
- Submit normally from the form to the update path and a live slot is correctly rejected with “targeting cannot be changed”
- However carefully you verify through the UI, those two test cases show nothing wrong
The anomaly only appears in a third state: right after an organisation buys out a slot, with not a single booking underneath it yet. In that state the inputs on screen are correctly frozen, and POSTing directly to the update endpoint, bypassing the form, rewrote the targeting.
Why
The UI’s freeze condition was this, consistently, from the first commit adding the edit form.
// A sold slot cannot have its targeting changed (same while an organisation holds it —
// the premise of distributing to its members would change).
const targetingLocked = isEdit && ((loop?.used ?? 0) > 0 || !!loop?.boughtOutById);
Either “there is a live occupancy” or “an organisation buyout is attached” being true means frozen.
The server-side update (updateLoop), before the fix, looked only at the live occupancy count.
const occupied = await activeOccupantCount(admin, id);
// ...
if (occupied > 0 && targetingChanged)
return { ok: false, error: "この枠は既に売れているため、配信先は変更できません。" };
activeOccupantCount counts the placements table with availability in ("商談中", "申込済", "掲載中"), and returns 0 right after an organisation buys out a slot with no member bookings yet. That state has a value in boughtOutById (bought_out_by_company_id) while occupied is 0 — a combination that fails only the server’s condition.
The UI’s disabled state is presentation control on the browser side. Even with the form’s button unclickable, a request POSTing directly to the update endpoint goes through unless the server validates the same condition. The cause is that UI and server implemented the same two conditions separately, with no mechanical guarantee that they agreed.
Fixing it
We brought the server’s decision in line with the UI’s two conditions.
// An organisation (reseller) holding it counts the same. Even with no member bookings yet,
// the organisation is operating on the premise that it secured this targeting, so do not
// decide from the presence of occupying placements alone.
const targetingFrozen =
(await activeOccupantCount(admin, id)) > 0 || !!current.bought_out_by_company_id;
bought_out_by_company_id was added to the select for current (the present values read from the DB), turning it into an OR with the live occupancy count. This targetingFrozen is used not only for rejecting a targeting change but also for deciding whether school synchronisation is permitted (managesSchools && !targetingFrozen), which before the fix also ran on the same single occupied === 0 condition. Collecting the freeze condition into one place (targetingFrozen) also removed the room for those two to drift apart separately.
Why it wasn’t noticed (though it was, quickly)
Verification of the edit screen is only ever done through the UI. As long as the UI freezes correctly, the check “I tried to change a live slot’s targeting and was stopped” succeeds every time. The UI’s control being correct and the server independently reproducing that control need separate verification — a shape that has come up repeatedly on this blog — but this time the two implementations that ought to be doing the same thing, “the display condition” and “the validation condition”, had grown up in separate files.
Unless the design of writing the freeze condition in two places, UI and server, is abandoned entirely, this kind of drift recurs in another form. We collected the condition into a single targetingFrozen variable and removed the duplication inside the server, but it remains a separate implementation from the UI’s targetingLocked.
よくある質問
Q1Did the UI and server drift because one of them was left unfixed?
No. The UI had two conditions from the beginning. The cause is that the server-side update consistently implemented only one from the moment the edit form was added. UI and server wrote the same conditions separately, in separate places, with nothing guaranteeing they agreed.
Q2What exactly is a forged POST here?
Sending a request straight to the update endpoint, bypassing the form on screen, with browser devtools or curl. A disabled button or checkbox is only presentation control on the browser side; unless the server validates the same condition, the request goes through.
Q3Did this lead to real damage?
It did not. Twelve minutes after the commit adding the edit form, review flagged the mismatch and it was fixed the same day. This is not an incident report but the point that when UI display control and server validation are written separately, both look correct and the drift is hard to see.
Q4Can a test detect this kind of drift?
Testing the UI's freeze condition and the server's freeze condition independently cannot verify whether they agree. Only a test passing the same input (combinations of the live flag and buyout state) to both decision functions and asserting the results match will detect the drift itself.
この記事の根拠
- TypeScriptファイル 96〜135行目コミット d92b633
- TypeScriptファイル 270〜400行目コミット d92b633
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。