Files
TJWaterServerBinary/tests/unit/test_wndb_query_safety.py
T
jiang 9b095c7439 refactor(db)!: clean up business SQL access
- make realtime replacement and analysis result writes transactional\n- consolidate SCADA repositories and remove process-global project state\n- validate SCADA batches and use indexed GIS-backed business queries\n\nBREAKING CHANGE: remove the public analysis result writer and the pipeline-health network_name query parameter.
2026-08-28 11:37:36 +08:00

139 lines
4.8 KiB
Python

import ast
from pathlib import Path
from app.infra.db.postgresql import scada
from app.native.wndb.core.database import ChangeSet, sql_literal
from app.native.wndb.gis import coordinates
from app.native.wndb.model import controls, junctions, patterns
def test_get_junction_binds_untrusted_identifier(monkeypatch) -> None:
calls: list[tuple[str, str, tuple[str, ...]]] = []
malicious_id = "J-1'; DELETE FROM junctions; --"
def fake_try_read(name, statement, params):
calls.append((name, statement, params))
return None
monkeypatch.setattr(junctions, "try_read", fake_try_read)
assert junctions.get_junction("project_a", malicious_id) == {}
assert len(calls) == 1
name, statement, params = calls[0]
assert name == "project_a"
assert "network.junctions" in statement
assert "WHERE n.id = %s" in statement
assert malicious_id not in statement
assert params == (malicious_id,)
def test_get_all_junctions_reads_materialized_view(monkeypatch) -> None:
statements: list[str] = []
def fake_read_all(_name, statement):
statements.append(statement)
return []
monkeypatch.setattr(junctions, "read_all", fake_read_all)
monkeypatch.setattr(junctions, "get_all_node_links", lambda _name: {})
assert junctions.get_all_junctions("project_a") == []
assert "FROM gis.junctions" in statements[0]
def test_get_all_scada_info_reads_materialized_view(monkeypatch) -> None:
statements: list[str] = []
def fake_read_all(_name, statement):
statements.append(statement)
return []
monkeypatch.setattr(scada, "read_all", fake_read_all)
assert scada.get_all_scada_info("project_a") == []
assert "FROM gis.scada_devices" in statements[0]
def test_get_missing_node_coord_does_not_write_default_geometry(monkeypatch) -> None:
calls: list[tuple[str, str, tuple[str, ...]]] = []
def fake_try_read(name, statement, params):
calls.append((name, statement, params))
return None
monkeypatch.setattr(coordinates, "try_read", fake_try_read)
assert coordinates.get_node_coord("project_a", "J-1") == {"x": 0.0, "y": 0.0}
assert calls == [
(
"project_a",
"select st_astext(geom) as coord_geom "
"from gis.node_geometries where node_id = %s",
("J-1",),
)
]
def test_sql_literal_keeps_attacker_text_inside_one_postgres_literal() -> None:
malicious = "x'); DROP SCHEMA network CASCADE; --"
assert sql_literal("O'Brien") == "'O''Brien'"
assert sql_literal(malicious) == "'x''); DROP SCHEMA network CASCADE; --'"
assert sql_literal(None) == "NULL"
def test_pattern_command_quotes_malicious_identifier() -> None:
malicious = "x'); DROP SCHEMA network CASCADE; --"
change = ChangeSet({"id": malicious, "factors": [1.0]})
command = patterns._add_pattern("project_a", change).sql
assert f"values ({sql_literal(malicious)})" in command
assert "values ('x'); DROP SCHEMA" not in command
def test_inp_control_quotes_embedded_apostrophe() -> None:
command = controls.inp_in_control("LINK P-1 STATUS 'OPEN'; DELETE")
assert "''OPEN''" in command
assert "STATUS 'OPEN'; DELETE')" not in command
def test_wndb_sql_fstrings_do_not_quote_formatted_values_directly() -> None:
root = Path(__file__).resolve().parents[2] / "app" / "native" / "wndb"
violations: list[str] = []
for path in root.rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not isinstance(node, ast.JoinedStr):
continue
static_text = "".join(
value.value
for value in node.values
if isinstance(value, ast.Constant) and isinstance(value.value, str)
).lower()
if not any(
keyword in static_text
for keyword in ("select ", "insert into", "update ", "delete from")
):
continue
for index, value in enumerate(node.values):
if not isinstance(value, ast.FormattedValue):
continue
before = node.values[index - 1] if index else None
after = node.values[index + 1] if index + 1 < len(node.values) else None
left_quote = (
isinstance(before, ast.Constant)
and isinstance(before.value, str)
and before.value.endswith("'")
)
right_quote = (
isinstance(after, ast.Constant)
and isinstance(after.value, str)
and after.value.startswith("'")
)
if left_quote and right_quote:
violations.append(f"{path.name}:{node.lineno}")
assert violations == []