Skip to content

fix: use bound parameters in pg_notify to survive dollar-tokens in payloads#123

Merged
GeiserX merged 2 commits intoGeiserX:mainfrom
tondeaf:fix/notify-dollar-token-bind-param
Apr 25, 2026
Merged

fix: use bound parameters in pg_notify to survive dollar-tokens in payloads#123
GeiserX merged 2 commits intoGeiserX:mainfrom
tondeaf:fix/notify-dollar-token-bind-param

Conversation

@tondeaf
Copy link
Copy Markdown
Contributor

@tondeaf tondeaf commented Apr 18, 2026

Summary

  • Switch _notify_postgres from f-string-built SQL to pg_notify(:channel, :payload) with SQLAlchemy bound parameters, so message bodies that happen to contain $N / $<letter> tokens stop tripping asyncpg's positional-placeholder parser and breaking realtime fanout.
  • Regression tests consolidated into TestRealtimeNotifierPostgres covering payload-not-in-SQL, dollar-token end-to-end (no warning), single-quote payload (replaces the old manual .replace("'", "''") escaping), and a tightened pg_notify-shape assertion to replace the stale execute-was-called test.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that causes existing functionality to change)
  • Documentation update
  • Infrastructure/CI change

Database Changes

  • Schema changes (Alembic migration required)
  • Data migration script added in scripts/
  • No database changes

Data Consistency Checklist

  • All chat_id values use marked format (via _get_marked_id()) — N/A, no chat_id construction in this path
  • All datetime values pass through _strip_tz() before DB operations — N/A, no datetime handling in this path
  • INSERT and UPDATE operations handle the same fields identically — N/A, change is in NOTIFY plumbing, not in row writes

Testing

  • Tests pass locally (python -m pytest tests/ -v)
  • Linting passes (ruff check .)
  • Formatting passes (ruff format --check .)
  • Manually tested in development environment

Security Checklist

  • No secrets or credentials committed
  • User input properly validated/sanitized — payload now flows as a bound parameter, eliminating the manual single-quote escaping path entirely
  • Authentication/authorization properly checked — change is below the auth boundary; no surface change

Deployment Notes

  • Cuts over on the next service restart; no migration. The fix is purely safer SQL plumbing for an existing call.

The bug

Realtime LISTEN/NOTIFY fanout to the viewer silently breaks for any chat when a single message body contains a $N or $<letter> token (e.g. "sshhhh $1 will take LONGER!" or "paid $D today"). The viewer stops receiving updates for that chat until the service is restarted. The only visible symptom is a vague "Failed to send realtime notification" warning in the log.

Root cause

_notify_postgres in src/realtime.py f-string-interpolates the JSON payload directly into the SQL string passed to text():

sql = f"NOTIFY telegram_updates, '{json.dumps(payload)}'"
await session.execute(text(sql))

asyncpg scans the raw SQL for $N positional placeholders before dispatching to Postgres, so any message text containing tokens that look like parameters trips its parser:

asyncpg.exceptions.PostgresSyntaxError: there is no parameter $1

The fix

Use pg_notify(:channel, :payload) with SQLAlchemy bound parameters. Payload becomes opaque string data to the driver — no SQL-level quoting, and the placeholder scanner never sees the payload content.

stmt = text("SELECT pg_notify(:channel, :payload)")
await session.execute(stmt, {"channel": channel, "payload": payload})

Regression tests live alongside the existing TestRealtimeNotifierPostgres class:

  • Payload containing $1/$D tokens is never embedded in the final SQL.
  • End-to-end: .notify() with those tokens does not emit the "Failed to send realtime notification" warning.
  • Single-quote payload round-trips cleanly (covers the removal of the old .replace("'", "''") path).
  • The pg_notify SQL shape and channel/payload bound-param keys are explicitly asserted (replacing the stale execute-was-called test).

Test plan

  • pytest tests/test_realtime.py::TestRealtimeNotifierPostgres — 5/5 pass; full tests/test_realtime.py 52/52 pass
  • Manually verified in production: the same message body that previously killed realtime updates now round-trips cleanly to the viewer.

…yloads

``_notify_postgres`` in ``src/realtime.py`` was f-string-interpolating the
JSON payload directly into the SQL string passed to ``text()``:

    NOTIFY telegram_updates, '<json>'

asyncpg scans the raw SQL for ``$N`` positional placeholders before
dispatching to Postgres, so any message body containing tokens like
``$1`` or ``$D`` tripped the driver's parameter parser and the
LISTEN/NOTIFY fanout to the viewer UI stopped working. Real example
from production: a chat message containing "sshhhh $1 will take LONGER!
$O" silently killed realtime updates for that chat until restart, with
only a generic warning in the log.

Fix: use ``pg_notify(:channel, :payload)`` with SQLAlchemy bound
parameters. Payload is opaque string data to the driver — no SQL-level
escaping needed, and the driver doesn't scan it for placeholders.

Regression tests in ``tests/test_realtime_notify.py``:
- Payload with ``$1``/``$D`` tokens is never embedded in the SQL string.
- ``.notify()`` with dollar-tokens does not emit the
  "Failed to send realtime notification" warning.
@tondeaf tondeaf requested a review from GeiserX as a code owner April 18, 2026 21:51
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 18, 2026

📝 Walkthrough

Walkthrough

Modified PostgreSQL notification in RealtimeNotifier._notify_postgres to use bound parameters with pg_notify() instead of embedding JSON directly in SQL strings. Eliminated manual escaping and added regression tests verifying dollar tokens in payloads don't interpolate into SQL.

Changes

Cohort / File(s) Summary
PostgreSQL notification refactoring
src/realtime.py
Updated _notify_postgres method to send notifications via SELECT pg_notify(:channel, :payload) with bound parameters instead of raw SQL string concatenation. Removed manual quote escaping; payload remains JSON-serialized.
Regression test coverage
tests/test_realtime_notify.py
New test module with helper function to construct mocked RealtimeNotifier instances. Two async tests verify bound parameters work correctly: one confirms dollar tokens ($1, $D) don't interpolate into SQL statements, the other ensures no warnings are logged when payloads contain such tokens.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and accurately describes the main change: using bound parameters in pg_notify to fix the dollar-token payload bug.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed PR description is comprehensive and follows template structure with all major sections completed: Summary, Type of Change, Database Changes, Data Consistency, Testing, Security, and Deployment Notes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@GeiserX
Copy link
Copy Markdown
Owner

GeiserX commented Apr 18, 2026

Hey @tondeaf, great catch on this one! The $N placeholder parsing by asyncpg is a really subtle bug — using pg_notify with bound parameters is exactly the right fix. The expanded docstring explaining the why is appreciated too.

Here's the review:

Security (APPROVE)

This is actually a security improvement. The old code did text(f"NOTIFY telegram_updates, '{payload_json}'") with manual .replace("'", "''") — while the single-quote escaping was technically correct for PostgreSQL string literals, it was fragile and had the asyncpg $N parsing issue. The new pg_notify(:channel, :payload) with bound params eliminates the entire class of problems. No injection surface remains.

Code Quality (1 HIGH, 1 MEDIUM, 1 LOW)

HIGH — Existing test test_notify_postgres_executes_notify_command has a stale docstring and doesn't validate the new contract
The existing test at tests/test_realtime.py:283 says """_notify_postgres sends NOTIFY via SQL.""" — but after this PR, it sends SELECT pg_notify(...). The test only asserts execute.assert_called_once() and commit.assert_called_once(), not the SQL structure. Please either:

  • Update the docstring and add an assertion that the first arg contains pg_notify and the second arg is a dict with channel/payload keys, or
  • Merge the new tests into the existing TestRealtimeNotifierPostgres class.

MEDIUM — Duplicate mock setup
_make_notifier_with_fake_session() in the new file duplicates the setup already in tests/test_realtime.py:285-295. Consider extracting a shared fixture or merging the new tests into the existing class.

LOW — Opaque ticket ID in docstring
The test file references (yrru-ks3d) which is meaningless to outside contributors. Replace with the PR/issue number.

Test Adequacy (2 must-fix, 2 should-fix)

Must-fix:

  1. Add commit() assertion to test_notify_does_not_interpolate_payload_into_sql. The session.commit() call is critical (NOTIFY fires on commit in PostgreSQL) but neither test verifies it:

    assert session.commit.await_count == 1
  2. Remove @pytest.mark.asyncio decorators — the project uses asyncio_mode = "auto" in pyproject.toml, so they're redundant. Minor but keeps consistency.

Should-fix:
3. Add a single-quote payload test — the old code explicitly handled single quotes with .replace("'", "''"). The new code drops that escaping. A test proving single quotes work with bound params documents this intentional change.

  1. Simplify mock setup — remove the explicit __aenter__/__aexit__ assignments (lines 21-22). AsyncMock already provides them.

CodeRabbit

CodeRabbit was rate-limited on this PR. I'll retrigger it once the cooldown expires.

Thanks again for the contribution! 🙏

@GeiserX
Copy link
Copy Markdown
Owner

GeiserX commented Apr 18, 2026

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 18, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
tests/test_realtime_notify.py (2)

40-59: Add coverage for single quotes in payloads.

The old path manually escaped '; the new path relies entirely on bound params. Add a regression case with text like don't break to ensure quote-heavy payloads still execute without SQL embedding.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_realtime_notify.py` around lines 40 - 59, Add a regression case to
tests/test_realtime_notify.py that sends a message containing single quotes
(e.g., "don't break") through notifier.notify (NotificationType.NEW_MESSAGE) and
then assert that session.execute was called once and that the SQL (from
session.execute.await_args.args[0] / stmt) does NOT contain the raw payload or
any $-tokens and DOES contain "pg_notify", while the bound params (from
call_args.args[1] or call_args.kwargs) include the full payload with the single
quote intact (params["payload"] contains "don't break"); this verifies
notifier.notify relies on bound parameters (not SQL embedding) and handles
quote-heavy payloads safely.

19-36: Align the new tests with the repository test style and add signatures.

These new functions miss return annotations, and the module-level async pytest style conflicts with the project’s preferred unittest pattern. Also, if the project uses asyncio auto mode, the explicit @pytest.mark.asyncio decorators can be dropped.

As per coding guidelines, **/*.py: “Use type hints for function signatures”; **/test_*.py: “Use unittest.TestCase with MagicMock/AsyncMock pattern for consistency with existing tests”.

Also applies to: 62-63

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_realtime_notify.py` around lines 19 - 36, Convert the new
pytest-style functions into the project's unittest style: move
_make_notifier_with_fake_session() and the async test
test_notify_does_not_interpolate_payload_into_sql() into a unittest.TestCase
subclass as methods (e.g., class
TestRealtimeNotifier(unittest.IsolatedAsyncioTestCase) if async is needed), add
explicit type hints for return values (e.g., -> Tuple[RealtimeNotifier,
AsyncMock]) on _make_notifier_with_fake_session, and make the test an async test
method on the TestCase instead of using `@pytest.mark.asyncio`; also remove the
module-level pytest async decorator if the test runner uses asyncio auto mode
and ensure you keep using MagicMock/AsyncMock for session_factory and db_manager
to match existing patterns.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/test_realtime_notify.py`:
- Line 1: Replace the opaque tracker ID in the module docstring of
tests/test_realtime_notify.py (the first-line string containing "(yrru-ks3d)")
with the actual PR or issue identifier (e.g., "PR `#123`" or "issue `#456`" or a
short issue URL) so future readers can locate the discussion; update only the
docstring text at the top of this file (the module-level string) to include the
actionable reference.
- Around line 46-59: The test currently only asserts session.execute was
awaited, which can pass without a transaction commit; update the test for
_notify_postgres() to also assert a commit was performed: check
session.commit.await_count (or the mock transaction object's commit await_count)
is 1 and optionally verify the commit call occurs after the session.execute call
by inspecting session.commit.await_args_list or the combined await call order;
ensure you reference the existing session.execute and session.commit mocks (and
_notify_postgres() behavior) when adding these assertions.

---

Nitpick comments:
In `@tests/test_realtime_notify.py`:
- Around line 40-59: Add a regression case to tests/test_realtime_notify.py that
sends a message containing single quotes (e.g., "don't break") through
notifier.notify (NotificationType.NEW_MESSAGE) and then assert that
session.execute was called once and that the SQL (from
session.execute.await_args.args[0] / stmt) does NOT contain the raw payload or
any $-tokens and DOES contain "pg_notify", while the bound params (from
call_args.args[1] or call_args.kwargs) include the full payload with the single
quote intact (params["payload"] contains "don't break"); this verifies
notifier.notify relies on bound parameters (not SQL embedding) and handles
quote-heavy payloads safely.
- Around line 19-36: Convert the new pytest-style functions into the project's
unittest style: move _make_notifier_with_fake_session() and the async test
test_notify_does_not_interpolate_payload_into_sql() into a unittest.TestCase
subclass as methods (e.g., class
TestRealtimeNotifier(unittest.IsolatedAsyncioTestCase) if async is needed), add
explicit type hints for return values (e.g., -> Tuple[RealtimeNotifier,
AsyncMock]) on _make_notifier_with_fake_session, and make the test an async test
method on the TestCase instead of using `@pytest.mark.asyncio`; also remove the
module-level pytest async decorator if the test runner uses asyncio auto mode
and ensure you keep using MagicMock/AsyncMock for session_factory and db_manager
to match existing patterns.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9695f5e5-94f5-4b08-b750-db3c91916896

📥 Commits

Reviewing files that changed from the base of the PR and between d76873a and ebbad8f.

📒 Files selected for processing (2)
  • src/realtime.py
  • tests/test_realtime_notify.py

Comment thread tests/test_realtime_notify.py Outdated
@@ -0,0 +1,80 @@
"""Regression tests for the NOTIFY bind-parameter bug (yrru-ks3d).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace the opaque tracker ID with the PR or issue number.

(yrru-ks3d) is not actionable for future readers; use PR #123`` or the linked issue instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_realtime_notify.py` at line 1, Replace the opaque tracker ID in
the module docstring of tests/test_realtime_notify.py (the first-line string
containing "(yrru-ks3d)") with the actual PR or issue identifier (e.g., "PR
`#123`" or "issue `#456`" or a short issue URL) so future readers can locate the
discussion; update only the docstring text at the top of this file (the
module-level string) to include the actionable reference.

Comment thread tests/test_realtime_notify.py Outdated
Comment on lines +46 to +59
assert session.execute.await_count == 1
call_args = session.execute.await_args
stmt = call_args.args[0]
sql = str(stmt)

# The payload (and any $-tokens it contains) must not be in the SQL.
assert "$1 break" not in sql
assert "$D" not in sql
# The SQL must use pg_notify with bound parameter names.
assert "pg_notify" in sql.lower()

params = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs
assert params["channel"] == "telegram_updates"
assert "$1 break $D asyncpg" in params["payload"]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Assert the commit, not just the execute.

PostgreSQL NOTIFY is delivered on transaction commit, so this regression test can pass even if _notify_postgres() stops committing.

Proposed test assertion
     assert session.execute.await_count == 1
+    assert session.commit.await_count == 1
     call_args = session.execute.await_args
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert session.execute.await_count == 1
call_args = session.execute.await_args
stmt = call_args.args[0]
sql = str(stmt)
# The payload (and any $-tokens it contains) must not be in the SQL.
assert "$1 break" not in sql
assert "$D" not in sql
# The SQL must use pg_notify with bound parameter names.
assert "pg_notify" in sql.lower()
params = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs
assert params["channel"] == "telegram_updates"
assert "$1 break $D asyncpg" in params["payload"]
assert session.execute.await_count == 1
assert session.commit.await_count == 1
call_args = session.execute.await_args
stmt = call_args.args[0]
sql = str(stmt)
# The payload (and any $-tokens it contains) must not be in the SQL.
assert "$1 break" not in sql
assert "$D" not in sql
# The SQL must use pg_notify with bound parameter names.
assert "pg_notify" in sql.lower()
params = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs
assert params["channel"] == "telegram_updates"
assert "$1 break $D asyncpg" in params["payload"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_realtime_notify.py` around lines 46 - 59, The test currently only
asserts session.execute was awaited, which can pass without a transaction
commit; update the test for _notify_postgres() to also assert a commit was
performed: check session.commit.await_count (or the mock transaction object's
commit await_count) is 1 and optionally verify the commit call occurs after the
session.execute call by inspecting session.commit.await_args_list or the
combined await call order; ensure you reference the existing session.execute and
session.commit mocks (and _notify_postgres() behavior) when adding these
assertions.

…rPostgres

Address PR GeiserX#123 review feedback:

- Merged the dollar-token / bind-param regression tests from the
  separate test_realtime_notify.py into the existing
  TestRealtimeNotifierPostgres class to eliminate duplicated mock setup.
- Replaced the stale test_notify_postgres_executes_notify_command
  (which only asserted that some SQL ran) with
  test_notify_postgres_uses_pg_notify_with_bound_params, which validates
  the new contract: SELECT pg_notify in the SQL plus channel/payload
  bound params.
- Added test_notify_handles_single_quotes_in_payload so the removal of
  the old manual .replace("'", "''") escaping is documented.
- Added explicit session.commit.await_count assertions on the dollar-token
  regression test; NOTIFY only delivers on commit so this matters.
- Dropped redundant @pytest.mark.asyncio decorators (the project uses
  asyncio_mode = "auto" in pyproject.toml).
- Removed the opaque (yrru-ks3d) ticket reference; the docstring now
  cites PR GeiserX#123 instead.
- Kept the explicit session.__aenter__.return_value = session: dropping
  it (as the review suggested) breaks the tests, because AsyncMock's
  default __aenter__ returns a fresh child mock rather than the same
  object, so `async with session as s:` would bind s to a different mock
  and the assertions would miss the call. Documented this in the helper's
  docstring.

Deleted tests/test_realtime_notify.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tondeaf
Copy link
Copy Markdown
Contributor Author

tondeaf commented Apr 25, 2026

Addressed your feedback in the latest commit. Summary:

HIGH — stale test_notify_postgres_executes_notify_command

  • Replaced with test_notify_postgres_uses_pg_notify_with_bound_params, which validates the new contract: SQL contains pg_notify, params dict has channel/payload keys.

MEDIUM — duplicate mock setup

  • Merged everything from tests/test_realtime_notify.py into the existing TestRealtimeNotifierPostgres class via a static _make_notifier_with_fake_session helper. Deleted the standalone file.

LOW — opaque ticket id

Must-fix #1commit() assertion

  • Added session.commit.await_count == 1 to the dollar-token regression test (and to the new pg_notify shape test).

Must-fix #2@pytest.mark.asyncio decorators

  • Dropped (project uses asyncio_mode = "auto").

Should-fix #3 — single-quote payload test

  • Added test_notify_handles_single_quotes_in_payload.

Should-fix #4 — explicit __aenter__/__aexit__ setup

  • One small pushback: dropping the line entirely breaks the tests. AsyncMock() provides the protocol, but __aenter__() returns a fresh child mock by default rather than self, so async with session as s: binds s to a different mock and the assertions miss the real call. I kept session.__aenter__.return_value = session and documented why in the helper docstring. The __aexit__ setup is genuinely unneeded and was removed.

Ready for another look.

@GeiserX GeiserX merged commit 1a8b195 into GeiserX:main Apr 25, 2026
2 checks passed
@GeiserX
Copy link
Copy Markdown
Owner

GeiserX commented Apr 25, 2026

Thanks for the contribution, @tondeaf! Merged and shipped a follow-up in #131 (v7.6.3) addressing edge cases found during review:

  • Edit notifications now truncated too — your fix exposed a pre-existing gap: data["new_text"] (edit path) bypassed the 500-char truncation, so a 4096-char emoji edit could produce a 16KB payload exceeding PostgreSQL's 8KB NOTIFY limit. Now handled via shared _truncate_notify_data().
  • Timing-safe push secret comparison/internal/push bearer token check switched from != to secrets.compare_digest().
  • Stable test assertionsstr(stmt)stmt.text for SQLAlchemy TextClause.

Released as v7.6.3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants