ticket #88 attempt 2
This commit is contained in:
parent
aa178a8c62
commit
85cf634e60
@ -1078,6 +1078,18 @@ def init_db():
|
|||||||
game_plots.create_column_by_example(column, example)
|
game_plots.create_column_by_example(column, example)
|
||||||
_index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"])
|
_index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"])
|
||||||
|
|
||||||
|
_drop_index(db, "idx_rate_limit_log_user_ts")
|
||||||
|
rate_limit_log = get_table("rate_limit_log")
|
||||||
|
for column, example in (
|
||||||
|
("uid", ""),
|
||||||
|
("user_uid", ""),
|
||||||
|
("action", ""),
|
||||||
|
("timestamp", ""),
|
||||||
|
):
|
||||||
|
if not rate_limit_log.has_column(column):
|
||||||
|
rate_limit_log.create_column_by_example(column, example)
|
||||||
|
_index(db, "rate_limit_log", "idx_rate_limit_log_user_ts", ["user_uid", "timestamp"])
|
||||||
|
|
||||||
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
|
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
|
||||||
_index(
|
_index(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
import time
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from devplacepy.database import db, get_table
|
||||||
from devplacepy.models import GameSlotForm
|
from devplacepy.models import GameSlotForm
|
||||||
from devplacepy.responses import json_error, respond, wants_json
|
from devplacepy.responses import json_error, respond, wants_json
|
||||||
from devplacepy.schemas import GameFarmViewOut
|
from devplacepy.schemas import GameFarmViewOut
|
||||||
@ -19,23 +20,31 @@ from devplacepy.utils import (
|
|||||||
|
|
||||||
from ._shared import game_seo, notify_farm, owner_by_username
|
from ._shared import game_seo, notify_farm, owner_by_username
|
||||||
|
|
||||||
_farm_action_rate: dict[str, list[float]] = {}
|
|
||||||
|
|
||||||
_MAX_ACTIONS_PER_MINUTE = 30
|
_MAX_ACTIONS_PER_MINUTE = 30
|
||||||
_RATE_WINDOW_SECONDS = 60
|
_RATE_WINDOW_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
def _check_farm_rate_limit(user_uid: str) -> None:
|
def _check_farm_rate_limit(user_uid: str) -> None:
|
||||||
now = time.time()
|
cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_RATE_WINDOW_SECONDS)).isoformat()
|
||||||
window_start = now - _RATE_WINDOW_SECONDS
|
result = list(
|
||||||
times = _farm_action_rate.setdefault(user_uid, [])
|
db.query(
|
||||||
times[:] = [t for t in times if t > window_start]
|
"SELECT COUNT(*) AS c FROM rate_limit_log"
|
||||||
if len(times) >= _MAX_ACTIONS_PER_MINUTE:
|
" WHERE user_uid = :uid AND timestamp >= :cutoff",
|
||||||
|
uid=user_uid,
|
||||||
|
cutoff=cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
count = result[0]["c"] if result else 0
|
||||||
|
if count >= _MAX_ACTIONS_PER_MINUTE:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
detail="Rate limit exceeded. Max 30 actions per minute.",
|
detail="Rate limit exceeded. Max 30 actions per minute.",
|
||||||
)
|
)
|
||||||
times.append(now)
|
get_table("rate_limit_log").insert({
|
||||||
|
"user_uid": user_uid,
|
||||||
|
"action": "",
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
import time
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
@ -13,7 +13,7 @@ from devplacepy.models import (
|
|||||||
GameQuestForm,
|
GameQuestForm,
|
||||||
GameSlotForm,
|
GameSlotForm,
|
||||||
)
|
)
|
||||||
from devplacepy.database import mark_notifications_read_by_target
|
from devplacepy.database import db, get_table, mark_notifications_read_by_target
|
||||||
from devplacepy.responses import json_error, respond, wants_json
|
from devplacepy.responses import json_error, respond, wants_json
|
||||||
from devplacepy.schemas import GameLeaderboardOut, GameStateOut
|
from devplacepy.schemas import GameLeaderboardOut, GameStateOut
|
||||||
from devplacepy.services.game import GameError, store
|
from devplacepy.services.game import GameError, store
|
||||||
@ -21,23 +21,31 @@ from devplacepy.utils import award_rewards, get_current_user, require_user, trac
|
|||||||
|
|
||||||
from ._shared import game_seo, notify_farm, state_payload
|
from ._shared import game_seo, notify_farm, state_payload
|
||||||
|
|
||||||
_action_rate: dict[str, list[float]] = {}
|
|
||||||
|
|
||||||
_MAX_ACTIONS_PER_MINUTE = 30
|
_MAX_ACTIONS_PER_MINUTE = 30
|
||||||
_RATE_WINDOW_SECONDS = 60
|
_RATE_WINDOW_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
def _check_rate_limit(user_uid: str) -> None:
|
def _check_rate_limit(user_uid: str) -> None:
|
||||||
now = time.time()
|
cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_RATE_WINDOW_SECONDS)).isoformat()
|
||||||
window_start = now - _RATE_WINDOW_SECONDS
|
result = list(
|
||||||
times = _action_rate.setdefault(user_uid, [])
|
db.query(
|
||||||
times[:] = [t for t in times if t > window_start]
|
"SELECT COUNT(*) AS c FROM rate_limit_log"
|
||||||
if len(times) >= _MAX_ACTIONS_PER_MINUTE:
|
" WHERE user_uid = :uid AND timestamp >= :cutoff",
|
||||||
|
uid=user_uid,
|
||||||
|
cutoff=cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
count = result[0]["c"] if result else 0
|
||||||
|
if count >= _MAX_ACTIONS_PER_MINUTE:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
detail="Rate limit exceeded. Max 30 actions per minute.",
|
detail="Rate limit exceeded. Max 30 actions per minute.",
|
||||||
)
|
)
|
||||||
times.append(now)
|
get_table("rate_limit_log").insert({
|
||||||
|
"user_uid": user_uid,
|
||||||
|
"action": "",
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|||||||
@ -60,9 +60,18 @@ CONFIRM_REQUIRED = {
|
|||||||
"gateway_model_delete",
|
"gateway_model_delete",
|
||||||
"email_account_delete",
|
"email_account_delete",
|
||||||
"email_delete_message",
|
"email_delete_message",
|
||||||
"game_plant",
|
"game_buy_plot",
|
||||||
"game_harvest",
|
"game_claim_quest",
|
||||||
|
"game_daily",
|
||||||
"game_fertilize",
|
"game_fertilize",
|
||||||
|
"game_harvest",
|
||||||
|
"game_plant",
|
||||||
|
"game_prestige",
|
||||||
|
"game_steal",
|
||||||
|
"game_upgrade_ci",
|
||||||
|
"game_upgrade_legacy",
|
||||||
|
"game_upgrade_perk",
|
||||||
|
"game_water",
|
||||||
}
|
}
|
||||||
|
|
||||||
CONDITIONAL_CONFIRM = {
|
CONDITIONAL_CONFIRM = {
|
||||||
|
|||||||
@ -177,3 +177,23 @@ def test_prestige_below_level_returns_400(app_server, seeded_db):
|
|||||||
_reset_farm(name)
|
_reset_farm(name)
|
||||||
response = session.post(f"{BASE_URL}/game/prestige", headers=JSON)
|
response = session.post(f"{BASE_URL}/game/prestige", headers=JSON)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_blocks_excess_requests(app_server, seeded_db):
|
||||||
|
session, name = _signup()
|
||||||
|
_reset_farm(name)
|
||||||
|
responses = []
|
||||||
|
for _ in range(31):
|
||||||
|
responses.append(
|
||||||
|
session.post(f"{BASE_URL}/game/buy-plot", headers=JSON)
|
||||||
|
)
|
||||||
|
success_count = sum(1 for r in responses[:30] if r.status_code != 429)
|
||||||
|
assert success_count >= 1, "expected at least one successful request"
|
||||||
|
assert responses[-1].status_code == 429, f"expected 429 on 31st request, got {responses[-1].status_code}"
|
||||||
|
assert "Rate limit exceeded" in responses[-1].json().get("detail", "")
|
||||||
|
# Verify the rate limit log stored entries for the test user
|
||||||
|
from devplacepy.database import get_table
|
||||||
|
user = get_table("users").find_one(username=name)
|
||||||
|
table = get_table("rate_limit_log")
|
||||||
|
entries = list(table.find(user_uid=user["uid"]))
|
||||||
|
assert len(entries) >= 2, f"expected rate log entries, got {len(entries)}"
|
||||||
|
|||||||
@ -212,3 +212,28 @@ def test_steal_cooldown_blocks_second_raid(app_server, seeded_db):
|
|||||||
f"{BASE_URL}/game/farm/{owner}/steal", data={"slot": 0}, headers=JSON
|
f"{BASE_URL}/game/farm/{owner}/steal", data={"slot": 0}, headers=JSON
|
||||||
)
|
)
|
||||||
assert second.status_code == 400
|
assert second.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_harvest_plant_cooldown_blocks_rapid_replant(app_server, seeded_db):
|
||||||
|
session, name = _signup()
|
||||||
|
_reset_farm(name)
|
||||||
|
# Plant and ripen a crop on slot 0
|
||||||
|
plant = session.post(
|
||||||
|
f"{BASE_URL}/game/plant", data={"slot": 0, "crop": "shell"}, headers=JSON
|
||||||
|
)
|
||||||
|
assert plant.status_code == 200
|
||||||
|
_ripen_owner_plot(name)
|
||||||
|
# Harvest it — this sets a 5-second cooldown on the plot
|
||||||
|
harvest = session.post(f"{BASE_URL}/game/harvest", data={"slot": 0}, headers=JSON)
|
||||||
|
assert harvest.status_code == 200
|
||||||
|
# Immediately replant on the same slot — must fail
|
||||||
|
replant = session.post(
|
||||||
|
f"{BASE_URL}/game/plant", data={"slot": 0, "crop": "shell"}, headers=JSON
|
||||||
|
)
|
||||||
|
assert replant.status_code == 400
|
||||||
|
assert "cooldown" in replant.text.lower()
|
||||||
|
# A different slot should still work
|
||||||
|
other = session.post(
|
||||||
|
f"{BASE_URL}/game/plant", data={"slot": 1, "crop": "shell"}, headers=JSON
|
||||||
|
)
|
||||||
|
assert other.status_code == 200
|
||||||
|
|||||||
@ -119,3 +119,23 @@ def test_game_actions_registered_with_correct_auth():
|
|||||||
def test_game_read_actions_are_read_only():
|
def test_game_read_actions_are_read_only():
|
||||||
for name in ("game_state", "game_leaderboard", "game_view_farm"):
|
for name in ("game_state", "game_leaderboard", "game_view_farm"):
|
||||||
assert BY_NAME[name].is_read_only is True
|
assert BY_NAME[name].is_read_only is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_game_post_actions_require_confirm():
|
||||||
|
mutating_game_actions = {
|
||||||
|
"game_plant",
|
||||||
|
"game_harvest",
|
||||||
|
"game_buy_plot",
|
||||||
|
"game_upgrade_ci",
|
||||||
|
"game_water",
|
||||||
|
"game_steal",
|
||||||
|
"game_fertilize",
|
||||||
|
"game_daily",
|
||||||
|
"game_upgrade_perk",
|
||||||
|
"game_claim_quest",
|
||||||
|
"game_prestige",
|
||||||
|
"game_upgrade_legacy",
|
||||||
|
}
|
||||||
|
for name in mutating_game_actions:
|
||||||
|
assert name in BY_NAME, f"{name} missing from catalog"
|
||||||
|
assert name in CONFIRM_REQUIRED, f"{name} missing from CONFIRM_REQUIRED"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user