|
4 | 4 |
|
5 | 5 | import pytest |
6 | 6 |
|
7 | | -from app.db.readonly import assert_select_only |
| 7 | +import app.db.readonly as readonly_module |
| 8 | +from app.db.readonly import ( |
| 9 | + assert_select_only, |
| 10 | + fetch_all, |
| 11 | + fetch_one, |
| 12 | + readonly_connection, |
| 13 | +) |
8 | 14 |
|
9 | 15 |
|
10 | 16 | def test_assert_select_only_allows_select(): |
11 | 17 | assert_select_only("SELECT 1") |
12 | 18 |
|
13 | 19 |
|
14 | 20 | def test_assert_select_only_allows_with_and_leading_comments(): |
15 | | - sql = textwrap.dedent( |
16 | | - """ |
| 21 | + sql = textwrap.dedent(""" |
17 | 22 | -- leading comment |
18 | 23 | /* block comment */ |
19 | 24 | WITH x AS (SELECT 1 AS id) |
20 | 25 | SELECT id FROM x |
21 | | - """ |
22 | | - ) |
| 26 | + """) |
23 | 27 | assert_select_only(sql) |
24 | 28 |
|
25 | 29 |
|
@@ -52,3 +56,86 @@ def test_assert_select_only_rejects_writes(): |
52 | 56 | def test_assert_select_only_rejects_dangerous_and_multi_statement_sql(sql: str): |
53 | 57 | with pytest.raises(ValueError, match="single read-only SELECT or WITH query"): |
54 | 58 | assert_select_only(sql) |
| 59 | + |
| 60 | + |
| 61 | +def test_readonly_helpers_use_read_only_transaction_and_params( |
| 62 | + monkeypatch: pytest.MonkeyPatch, |
| 63 | +) -> None: |
| 64 | + calls: list[tuple[str, object]] = [] |
| 65 | + |
| 66 | + class _FakeTransaction: |
| 67 | + def __enter__(self) -> None: |
| 68 | + calls.append(("transaction_enter", True)) |
| 69 | + |
| 70 | + def __exit__(self, exc_type, exc, tb) -> bool: |
| 71 | + calls.append(("transaction_exit", exc_type)) |
| 72 | + return False |
| 73 | + |
| 74 | + class _FakeCursor: |
| 75 | + def __enter__(self) -> "_FakeCursor": |
| 76 | + calls.append(("cursor_enter", None)) |
| 77 | + return self |
| 78 | + |
| 79 | + def __exit__(self, exc_type, exc, tb) -> bool: |
| 80 | + calls.append(("cursor_exit", exc_type)) |
| 81 | + return False |
| 82 | + |
| 83 | + def execute(self, sql: str, params: tuple[object, ...]) -> None: |
| 84 | + calls.append(("execute", (sql, params))) |
| 85 | + |
| 86 | + def fetchall(self) -> list[dict[str, int]]: |
| 87 | + return [{"id": 1}, {"id": 2}] |
| 88 | + |
| 89 | + class _FakeConnection: |
| 90 | + def __enter__(self) -> "_FakeConnection": |
| 91 | + calls.append(("connect_enter", None)) |
| 92 | + return self |
| 93 | + |
| 94 | + def __exit__(self, exc_type, exc, tb) -> bool: |
| 95 | + calls.append(("connect_exit", exc_type)) |
| 96 | + return False |
| 97 | + |
| 98 | + def transaction(self, read_only: bool = False) -> _FakeTransaction: |
| 99 | + calls.append(("transaction", read_only)) |
| 100 | + return _FakeTransaction() |
| 101 | + |
| 102 | + def cursor(self) -> _FakeCursor: |
| 103 | + return _FakeCursor() |
| 104 | + |
| 105 | + monkeypatch.setenv("DATABASE_URL", "postgresql://example") |
| 106 | + monkeypatch.setattr( |
| 107 | + readonly_module.psycopg, |
| 108 | + "connect", |
| 109 | + lambda *args, **kwargs: _FakeConnection(), |
| 110 | + ) |
| 111 | + |
| 112 | + with readonly_connection() as conn: |
| 113 | + assert isinstance(conn, _FakeConnection) |
| 114 | + |
| 115 | + rows = fetch_all(" SELECT * FROM memories WHERE id = %s", (7,)) |
| 116 | + assert rows == [{"id": 1}, {"id": 2}] |
| 117 | + |
| 118 | + first = fetch_one( |
| 119 | + "WITH x AS (SELECT 1 AS id) SELECT id FROM x WHERE id = %s", |
| 120 | + (1,), |
| 121 | + ) |
| 122 | + assert first == {"id": 1} |
| 123 | + |
| 124 | + assert ("transaction", True) in calls |
| 125 | + assert ( |
| 126 | + "execute", |
| 127 | + (" SELECT * FROM memories WHERE id = %s", (7,)), |
| 128 | + ) in calls |
| 129 | + assert ( |
| 130 | + "execute", |
| 131 | + ("WITH x AS (SELECT 1 AS id) SELECT id FROM x WHERE id = %s", (1,)), |
| 132 | + ) in calls |
| 133 | + |
| 134 | + |
| 135 | +def test_readonly_connection_requires_database_url( |
| 136 | + monkeypatch: pytest.MonkeyPatch, |
| 137 | +) -> None: |
| 138 | + monkeypatch.delenv("DATABASE_URL", raising=False) |
| 139 | + with pytest.raises(RuntimeError, match="DATABASE_URL is not configured"): |
| 140 | + with readonly_connection(): |
| 141 | + pass |
0 commit comments