fix(511-B /calc 경로 막기): 계산 창구가 브라우저 파일 경로를 안 씀 — 단계 격자 = {model_id · smooth · design} → 서버가 그 프로젝트 storage 안 DTM 격자 경로로 갈음(dtm_grid_npz · read_surface_grid 와 한 벌 · storage 밖 = 거절) · 경로 칸(stages.grid.path · stations.grid) = 400 · 창구 묶음 안 파일 읽기 = gridAt 하나 · 시험 test_511_b_calc_path_guard — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 14:38:16 +09:00
co-authored by Claude Opus 5.5
parent 578b48f7ab
commit a7e2da2b9e
3 changed files with 224 additions and 21 deletions
@@ -0,0 +1,157 @@
"""PLAN 511-B (요구 320-4) — 계산 창구 `/calc` 가 브라우저 파일 경로를 안 씀.
못박는 것
- 단계 격자 경로 칸(`stages.grid.path`) · 측점 격자 칸(`stations.grid`) = 400 · Node 안 부름.
- 격자 = `{model_id, smooth, design}` → 서버가 그 프로젝트 storage 안 DTM 격자 경로로 갈음.
- `dtm_grid_npz` — 다른 프로젝트 모델 · storage 밖 상대 경로(../) · 절대 경로 = LookupError.
- 공유 DB 안 씀(가짜 풀 · 가짜 연결) · 실제 storage 안 씀(tmp_path).
./venv/Scripts/python.exe -m pytest resources/tester/test_511_b_calc_path_guard.py -q
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Router_Terrain as terrain # noqa: E402
from B06_Section import B06_Section_Router_Calc as calc_router # noqa: E402
from common_util import common_util_auth as auth # noqa: E402
from common_util import common_util_surface_sampler as sampler # noqa: E402
PROJECT = "11111111-2222-3333-4444-555555555555"
OTHER = "99999999-2222-3333-4444-555555555555"
class _Cursor:
rows: dict = {}
async def execute(self, sql, args=None):
self.args = args
async def fetchone(self):
if self.args == (PROJECT,):
return (1,) # 권한 — 회사 1
return self.rows.get(self.args)
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
class _Conn(_Cursor):
def cursor(self):
return _Cursor()
async def __aenter__(self):
return self
class _Pool:
def acquire(self):
return _Conn()
@pytest.fixture
def storage(tmp_path, monkeypatch):
"""프로젝트 둘의 storage(tmp) — 모델 행 = (id, project) → (종류, 상대 경로)."""
roots = {PROJECT: tmp_path / "p1", OTHER: tmp_path / "p2"}
for root in roots.values():
(root / "models").mkdir(parents=True)
(roots[PROJECT] / "models" / "dtm_las.npz").write_bytes(b"x")
(roots[OTHER] / "models" / "dtm_las.npz").write_bytes(b"x")
async def stored(_conn, project_id):
return str(project_id)
monkeypatch.setattr(terrain, "get_project_storage_relative_path", stored)
monkeypatch.setattr(terrain, "resolve_stored_project_path", lambda pid: str(roots[pid]))
_Cursor.rows = {
(7, PROJECT): ("dtm", "models/dtm_las.npz"),
(8, OTHER): ("dtm", "models/dtm_las.npz"),
(9, PROJECT): ("dtm", "../p2/models/dtm_las.npz"),
(10, PROJECT): ("dtm", str(roots[OTHER] / "models" / "dtm_las.npz")),
}
return roots
def _npz(model_id: int, project: str = PROJECT) -> Path:
from uuid import UUID
return asyncio.run(terrain.dtm_grid_npz(_Conn(), UUID(project), model_id, False))
def test_dtm_grid_npz_storage_안만(storage):
assert _npz(7) == (storage[PROJECT] / "models" / "dtm_las.npz").resolve()
for model_id in (8, 9, 10): # 다른 프로젝트 모델 · ../ · 절대 경로
with pytest.raises(LookupError):
_npz(model_id)
@pytest.fixture
def client(monkeypatch, storage):
monkeypatch.setattr(auth, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(calc_router, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(sampler, "dtm_grid_file", lambda npz: Path(npz).with_suffix(".bin"))
seen: list = []
def fake_run(run, inputs):
seen.append(inputs)
return {"results": {}, "ms": 0}
monkeypatch.setattr(calc_router, "run_calc_served", fake_run)
app = FastAPI()
app.include_router(
calc_router.router,
dependencies=[Depends(auth.verify_session), Depends(auth.require_project_access)],
)
app.dependency_overrides[auth.verify_session] = lambda: {
"role": "USER",
"company_id": 1,
"user_id": 7,
}
return TestClient(app), seen
def _post(http, inputs, run=("stage.route",)):
return http.post(f"/api/projects/{PROJECT}/calc", json={"run": list(run), "inputs": inputs})
def test_경로_칸_거절(client, storage):
http, seen = client
other = str(storage[OTHER] / "models" / "dtm_las.bin")
bad = [
{"stages": {"grid": {"path": other, "design": "las"}}},
{"stages": {"grid": {"path": "../../p2/models/dtm_las.bin"}}},
{"stages": {"grid": {"model_id": 7, "path": other}}},
{"stages": {"grid": "C:/Windows/win.ini"}},
{"stages": {"grid": {"model_id": "7"}}},
{"stages": {"grid": {"model_id": 7, "design": "../x"}}},
{"stations": {"grid": other, "line": []}},
]
for inputs in bad:
res = _post(http, inputs, ("stations.list",) if "stations" in inputs else ("stage.route",))
assert res.status_code == 400, inputs
for model_id in (8, 9, 10): # 다른 프로젝트 · ../ · 절대 경로 모델 = 거절
assert _post(http, {"stages": {"grid": {"model_id": model_id}}}).status_code == 400
assert seen == [] # Node 안 부름
def test_격자_서버가_정함(client, storage):
http, seen = client
res = _post(http, {"stages": {"grid": {"model_id": 7, "smooth": False, "design": "sheet"}}})
assert res.status_code == 200, res.text
want = (storage[PROJECT] / "models" / "dtm_las.bin").resolve()
assert seen[-1]["stages"]["grid"] == {"path": str(want), "design": "sheet"}
assert _post(http, {"stages": {"grid": None}}).status_code == 200 # 격자 없음 = 그대로