Skip to content

test: mutation testing for Nostr event handling - #849

Open
ToRyVand wants to merge 6 commits into
MostroP2P:mainfrom
ToRyVand:fix/636-mutation-nostr-events
Open

test: mutation testing for Nostr event handling#849
ToRyVand wants to merge 6 commits into
MostroP2P:mainfrom
ToRyVand:fix/636-mutation-nostr-events

Conversation

@ToRyVand

@ToRyVand ToRyVand commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Context

Follow-up from #618. Implements mutation testing for Nostr event handling — the communication layer between Mostro and its clients. Closes #636.

What changed

  • Makefile / .github/workflows/mutation.yml: added a mutation-test target (CARGO_MUTANTS_JOBS=1 MOSTRO_TEST_LN_PORT=$${MOSTRO_TEST_LN_PORT:-18080} cargo mutants) so a single knob caps concurrency (avoids OOM on constrained machines) and lets the local LN test port be overridden when 8080 is already taken on the host. Single worker on purpose: workers share the host's TCP ports, and a worker losing the race for the LNURL listener fails that test for its own reasons — which mutation testing scores as a killed mutant, inflating the result.
  • src/lnurl.rs, src/lightning/invoice.rs: threaded MOSTRO_TEST_LN_PORT through the local test HTTP server/URL builder so cargo-mutants runs don't collide with something else already bound to 8080.
  • src/app.rs:
    • Extracted is_stale and missing_inner_signature out of accept_event with direct boundary tests.
    • Added accept_event_tests covering the full accept/reject paths (valid gift wrap, wrong kind, wrong receiver) plus the protocol-v2 spam-gate / PoW-first-contact branch (accepted when the bar is cleared, dropped when it isn't) — this branch had 3 surviving mutants with zero coverage.
  • src/nip33.rs: added tests for create_event's NIP-40 expiration-tag dedup check — a caller-supplied expiration tag must not be duplicated by the auto-expiration logic, which was the source of a surviving ||&& mutant. Also removed that check's TagKind::Custom("expiration") arm: nostr normalises the tag name at construction, so it was unreachable — and therefore an equivalent mutant no test could ever kill. The added tests pin the real path instead, including the exact Tag::custom shape order_to_tags emits.
  • src/spam_gate.rs: fixed install_global_then_second_install_is_rejected, which assumed it would always be the first test in the binary to install the process-wide SpamGate OnceLock — the new accept_event spam-gate tests expose that the assumption doesn't hold once another test races it there. Now robust to install order, still asserts a second install is always rejected.

Verification

  • 9/9 mutants confirmed killed via make mutation-test ARGS="--file src/app.rs --file src/nip33.rs -F 'in accept_event|in create_event'" — 0 missed, 0 timeout, 0 unviable. (7 in the two target functions, plus 2 in check_trade_index that cargo-mutants 27.1.0 admits because -F does not filter "delete field from struct expression" mutants.) Reads 9 rather than 10 because dropping the unreachable arm above also drops its ||&& mutant from the set — nothing became uncovered.
  • cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings: clean.
  • cargo test: 1059 passed, 2 ignored (with MOSTRO_TEST_LN_PORT pointed past a host process already holding 8080).

Acceptance Criteria (from #636)

  • Baseline mutation report for Nostr modules
  • Critical mutants in event validation killed (accept_event's spam-gate/PoW branch)
  • Critical mutants in NIP-33 replaceable-event logic killed (create_event)
  • Mutation score documented in PR

Summary by CodeRabbit

  • Bug Fixes

    • Strengthened handling and validation of incoming events, including stale, unsigned, malformed, or undecryptable messages.
    • Refined spam-gate behavior for direct messages and first-contact interactions.
    • Prevented duplicate expiration tags from being added to events.
  • Reliability

    • Improved event expiration handling.
    • Increased reliability of local service testing through configurable ports and more consistent mutation testing.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: adaab33c-01cc-4cdf-9f37-fbeae12dfeca

📥 Commits

Reviewing files that changed from the base of the PR and between e885508 and 541129f.

📒 Files selected for processing (5)
  • src/app.rs
  • src/lightning/invoice.rs
  • src/lnurl.rs
  • src/nip33.rs
  • src/spam_gate.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/spam_gate.rs
  • src/lightning/invoice.rs
  • src/lnurl.rs
  • src/app.rs

Walkthrough

Mutation testing now runs through a shared Makefile target with configurable test ports. Event acceptance checks use private helpers with expanded test coverage. Expiration-tag and global spam-gate behavior receive regression tests.

Changes

Nostr testing improvements

Layer / File(s) Summary
Mutation harness and configurable test port
.github/workflows/mutation.yml, Makefile, src/lightning/invoice.rs, src/lnurl.rs
Workflows invoke make mutation-test. The target configures mutation workers, forwards arguments, and sets the LN test port used by test servers and URL assertions.
Event acceptance helpers and coverage
src/app.rs
Staleness and missing-signature checks use private helpers. Unit and integration tests cover wrapped-event validation, decryption failure, kind checks, and v2 first-contact outcomes.
Tag and spam-gate regression coverage
src/nip33.rs, src/spam_gate.rs
Tests cover expiration-tag de-duplication and repeated global installation handling.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 54112

This change adds mutation-testing coverage and test-environment controls without any identified merge-blocking correctness or production risk; it is merge-ready after normal checks and review.

Suggested reviewers: arkanoider, grunch

Poem

A rabbit runs mutants through Make,
While test ports change for each test’s sake.
Events gain checks, tags stay true,
Spam gates guard their state anew.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: adding mutation testing for Nostr event handling.
Linked Issues check ✅ Passed The PR adds mutation testing, documents 9 of 9 mutants killed, and adds coverage for event validation, gift-wrap handling, routing, and replaceable events [#636].
Out of Scope Changes check ✅ Passed The changes support mutation testing or its required test stability, including configurable LN ports and SpamGate test-order handling.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Makefile`:
- Around line 69-71: Update the mutation-test target to avoid the Bash-only set
-o pipefail under Make’s default shell, and preserve caller configuration by not
unconditionally overwriting MOSTRO_TEST_LN_PORT. Also prevent parallel mutation
workers from sharing the same fixed port by guarding concurrency or assigning
distinct worker ports while retaining configurable overrides.

In `@src/nip33.rs`:
- Around line 1121-1143: Add a companion test alongside
create_event_does_not_duplicate_a_caller_supplied_expiration_tag that supplies a
custom "expiration" tag through new_order_event, then assert the resulting order
contains no auto-added standard TagKind::Expiration tag. Keep the existing
standard-tag test unchanged and verify the custom branch in create_event's
has_expiration_tag logic.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b2a6afb7-fe1d-4339-8c3c-1635a3e9717d

📥 Commits

Reviewing files that changed from the base of the PR and between 94e736a and 1c0e9a9.

📒 Files selected for processing (7)
  • .github/workflows/mutation.yml
  • Makefile
  • src/app.rs
  • src/lightning/invoice.rs
  • src/lnurl.rs
  • src/nip33.rs
  • src/spam_gate.rs

Comment thread Makefile Outdated
Comment thread src/nip33.rs
@ToRyVand

ToRyVand commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Both review comments addressed — one of them turned out to be the opposite of what it looked like

Pushed 3e05ed2 and e885508, kept separate since they are unrelated findings.

src/nip33.rs — the Custom("expiration") arm can't be tested, because it can't be reached

The suggestion was to add a companion test for the TagKind::Custom("expiration") arm. I wrote that test first and it failed on its own first assertion, which sent me to check the premise:

let t = Tag::custom(TagKind::Custom(Cow::Borrowed("expiration")), vec!["123456".to_string()]);
// kind = Expiration | is_expiration = true

nostr normalises the tag name at construction, so t.kind() can never return Custom("expiration"). The second arm of has_expiration_tag was unreachable — and that also makes it an equivalent mutant: deleting it changes nothing observable, so no test could ever have killed it. Adding a test there would have been writing an assertion that passes for the wrong reason.

Worth correcting one thing I had assumed too: this arm is not the production-load-bearing one. order_to_tags does build the tag as Tag::custom(TagKind::Custom("expiration"), ..), but by the time it reaches create_event it has already normalised to TagKind::Expiration — so production has always taken the first arm.

So I deleted the dead arm instead, and added a test that pins the real path end to end: the exact Tag::custom shape order_to_tags emits must normalise and suppress the auto-add, asserting the caller's 123456 is the only expiration tag on the event. Verified it actually bites by forcing has_expiration_tag = false — it goes red with left: ["1788381606", "123456"], the config auto-add stacked on top of the caller's tag.

Flagging this one as your call, since it is a production change inside a test: PR. I think it belongs here — an equivalent mutant is precisely the kind of thing a mutation-testing pass should surface, and leaving it in means leaving a mutant nobody can ever kill. But the smaller option is equally defensible: restore the arm with a comment saying it is unreachable, and keep this PR's diff purely additive. Say the word and I'll do that instead.

Either way the deletion is safe across every caller: create_event has five entry points, all passing tags built internally, and the only two that carry an expiration are order_to_tags (Tag::custom, normalises) and price/manager.rs:528 (Tag::expiration, already the first arm).

Makefile — two of the three points hold

set -o pipefail: no change. Makefile:1 is SHELL := $(shell which bash), which applies to every recipe, and six existing targets already rely on it. Verified the recipe runs under bash.

Caller's port was being overwritten: fixed. Now MOSTRO_TEST_LN_PORT=$${MOSTRO_TEST_LN_PORT:-18080}, so a busy 18080 can be worked around without editing the Makefile.

Parallel workers sharing the port: fixed, and this was the one that mattered. Each worker runs the full suite in its own temp dir but shares the host's TCP ports, so two workers collide on the LNURL listener. Under mutation testing that collision is not just noise — a test failing for its own reasons counts as a killed mutant, so it silently inflates the very score this target exists to measure. Dropped to CARGO_MUTANTS_JOBS=1. Slower, but the number means something.

That failure mode is easy to see locally: a plain cargo test on this machine gives 1058 passed / 1 failed with AddrInUse on 8080, and MOSTRO_TEST_LN_PORT=18080 cargo test gives 1059 passed / 0 failed.

On the mutation score

Since this touches create_event, I re-ran the measurement on the new head rather than carry the old number over:

$ make mutation-test ARGS="--file src/app.rs --file src/nip33.rs -F 'in accept_event|in create_event'"
Found 9 mutants to test
ok       Unmutated baseline in 140s build + 41s test
9 mutants tested in 12m: 9 caught

0 missed, 0 timeout, 0 unviable.

It reads 9/9 now, not 10/10, and the PR body should say so. Removing the unreachable arm removes its || — and with it the replace || with && mutant — from the set. Nothing became uncovered.

While tracing that: 2 of the original 10 were delete field .. in check_trade_index, which land in the run because cargo-mutants 27.1.0's -F doesn't filter "delete field from struct expression" mutants. They are caught, so they never distorted the pass/fail, but the headline was really 8 targeted + 2 incidental. It is 7 + 2 now.

PR body updated to match — score line, the mutation-test invocation it quoted, and the cargo test count.

Verification

  • cargo test: 1059 passed, 2 ignored (with MOSTRO_TEST_LN_PORT set past the host's busy 8080)
  • cargo fmt --check: clean
  • cargo clippy --all-targets --all-features -- -D warnings: clean

ToRyVand added a commit to ToRyVand/mostro that referenced this pull request Aug 4, 2026
`.cargo/mutants.toml` (added in 87b2b6f, this PR) set

    additional_cargo_test_args = ["--test-threads=4"]

cargo-mutants places those args before `cargo test`'s own `--`, so
cargo rejects the flag rather than forwarding it to libtest:

    *** cargo test --verbose --package=mostro@0.18.0 --test-threads=4
    error: unexpected argument '--test-threads' found
    *** result: Failure(1)
    ERROR cargo test failed in an unmutated tree, so no mutants were tested

The baseline never passed, so no mutant was ever tested — via the
Makefile target or the CI job, since cargo-mutants reads this file
regardless of how it is invoked. Intended as an OOM guard, it silently
disabled the thing it was guarding.

No config-file or CLI mechanism in cargo-mutants 27.1.0 forwards
arguments past that `--`, and `CARGO_MUTANTS_JOBS` is the cap that
actually binds. Removing the file restores the baseline: the suite now
runs to completion (1021 passed locally, the one failure being the
known hardcoded-8080 `AddrInUse` flake that PR MostroP2P#849 fixes).
Uncapped parallel jobs + per-test thread fan-out exhausted RAM and
crashed the machine during a local run. Cap via CARGO_MUTANTS_JOBS=2
(Makefile, verified with strace since .cargo/config.toml's [env]
does not propagate to third-party subcommands) and
--test-threads=4 (.cargo/mutants.toml). Both CI mutation jobs now
go through the same `make mutation-test` target.
cargo-mutants requires a fully green baseline before mutating anything,
and the lightning-address test path was hardcoded to 127.0.0.1:8080 in
both the test server and lnurl.rs's cfg!(test) URL builder. On a machine
already using 8080 that baseline never passes. MOSTRO_TEST_LN_PORT (env,
default 8080) lets `make mutation-test` point both sides at a free port
without changing default `cargo test` behavior.

Also drops --test-threads=4 from the mutation-test target: cargo-mutants
27.1.0 has no working way to forward libtest args (config key, CLI flag,
and trailing -- args all insert before cargo test's own --), so the flag
only broke the baseline. CARGO_MUTANTS_JOBS=2 remains the real OOM cap.
Issue MostroP2P#636 (mutation testing for Nostr event handling) flagged
accept_event()'s post-unwrap validation as uncovered: the replay-window
timestamp check and the identity/signature check had zero mutants
caught. Extracted both into pure functions (is_stale,
missing_inner_signature) so cargo-mutants' boundary mutants can be hit
directly, and added three accept_event-level tests built on a real
wrap_message_with-signed GiftWrap event (happy path, wrong kind, wrong
receiver) to cover the surrounding POW/kind/verify gates.

Mutation run on src/app.rs + src/nip33.rs (partial, 90/108 mutants
tested before this checkpoint): every accept_event mutant now caught
except the 3 in the is_v2 spam-gate branch, deliberately deferred since
exercising it needs the SpamGate global singleton initialized. Overall
score and the rest of app.rs's dispatcher/warning_msg mutants still
outstanding — see [[project_july_contribution_plan]] memory for the
resume point.
…mutants

Targets the remaining surviving mutants on the Nostr event-handling path
for issue MostroP2P#636:

- accept_event's protocol-v2 spam-gate / PoW-first-contact branch (3
  mutants around the `!gate.is_known(..) && !event.check_pow(..)` check)
  was never exercised by is_v2=true — add tests for the accepted and
  rejected first-contact cases.
- nip33::create_event's NIP-40 expiration-tag dedup check (`||` between
  the canonical and custom expiration tag kinds) was only exercised via
  the "no existing tag" paths — add a test that pre-supplies a real
  expiration tag and asserts it isn't duplicated.

spam_gate.rs: fix a latent test-order bug the new accept_event tests
exposed — install_global_then_second_install_is_rejected assumed it
would always be the first test in the binary to install the process-wide
SpamGate OnceLock, which broke once another test raced it there.

10/10 targeted mutants confirmed killed via a `-F`-filtered cargo-mutants
run scoped to accept_event/create_event.
`has_expiration_tag` tested two shapes, `TagKind::Expiration` and
`TagKind::Custom("expiration")`. The second is unreachable: nostr
normalises the tag name at construction, so `Tag::custom` built with
the name "expiration" — exactly what `order_to_tags` emits for every
order event — already arrives as `TagKind::Expiration`. Verified by
probing `Tag::kind()` directly before removing it.

Being unreachable, it was also an equivalent mutant: deleting the arm
changes nothing observable, so no test could ever kill it.

Add a test that pins the real production path end to end — a
custom-named "expiration" tag must normalise and suppress the auto-add
— so an sdk upgrade that stopped normalising goes red here instead of
silently double-stamping every order event.
Every cargo-mutants worker runs the full suite in its own temp dir but
shares the host's TCP ports, so two workers collide on the LNURL test's
fixed listener. That collision fails the test for its own reasons — and
under mutation testing a failing test counts as a killed mutant, so the
collision silently inflates the score this target exists to measure.

Run a single worker, and stop hard-coding the port over a caller's own
`MOSTRO_TEST_LN_PORT` so a busy 18080 can be worked around without
editing the Makefile.

Leaves `set -o pipefail` alone: `SHELL := $(shell which bash)` on line 1
already applies to every recipe, and six existing targets rely on it.
@ToRyVand
ToRyVand force-pushed the fix/636-mutation-nostr-events branch from e885508 to 541129f Compare August 14, 2026 14:09
@ToRyVand

Copy link
Copy Markdown
Contributor Author

Rebased onto main — and the nostr 0.45 bump turned this PR's own guard test into the thing that verified it

This had gone CONFLICTING. It's now rebased onto current main (541129f), no merge commit, same six commits.

The conflict itself was three small hunks, but resolving them surfaced the real issue: this branch was on nostr-sdk 0.44.1 and main is now on nostr + nostr-sdk 0.45.1. That's a breaking change, so the rebase is also a small 0.44 → 0.45 migration.

What the migration touched

TagKind is gone from the 0.45 API. The branch had matches!(t.kind(), TagKind::Expiration); main has t.kind() == "expiration". I kept main's form — not as a tiebreak, but because TagKind genuinely doesn't exist in 0.45 (it's only in 0.44.x). Same for the test helpers: Tag::custom(TagKind::Custom(Cow::Borrowed("expiration")), ..) is now just Tag::custom("expiration", ..), matching how admin_cancel.rs and admin_settle.rs already write custom tags on main.

nostr_sdk::secp256k1 no longer resolves, and sign_schnorr now takes AsRef<[u8]> rather than a secp256k1::Message. So app.rs goes from sign_schnorr(&nostr_sdk::secp256k1::Message::from_digest([7u8; 32])) to sign_schnorr([7u8; 32]).

extract_lnurl returns Url, not String (from the LNURL scheme validation on main). The port-override test now asserts extracted.to_string() against the formatted URL, keeping both main's type and this branch's MOSTRO_TEST_LN_PORT override.

The part worth flagging

Commit 45ca902 adds a_custom_named_expiration_tag_normalises_and_suppresses_the_auto_add, and the comment I wrote on it 11 days ago says:

If an sdk upgrade ever stopped normalising, the auto-add would start firing on top of the caller's tag and this test goes red.

The sdk upgrade arrived. The test is green against 0.45 — nostr still normalises a custom-named expiration tag to the canonical NIP-40 kind, so has_expiration_tag's single check is still correct and order events are not double-stamping expirations. That's the one behavioural question the 0.45 bump raised in this file, and it's now answered rather than assumed.

I rewrote that comment to describe the behaviour instead of the (now non-existent) TagKind variants, so it doesn't rot again.

Verification on the rebased tree

  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo test --bin mostrod1189 passed, 0 failed, 2 ignored

One note on that test run: on a first pass, lightning::invoice::tests::test_lnurl_validation_with_test_server failed with AddrInUse on port 8080 — which is precisely what commit 88679a0 in this PR exists to fix. Re-running with MOSTRO_TEST_LN_PORT=18080 gives a clean 1189/0. The failure was a live demonstration of the problem the commit addresses, not a regression.

Diff against main is unchanged in shape: 339 insertions, 13 deletions, same seven files.

ToRyVand added a commit to ToRyVand/mostro that referenced this pull request Aug 19, 2026
`.cargo/mutants.toml` (added in 87b2b6f, this PR) set

    additional_cargo_test_args = ["--test-threads=4"]

cargo-mutants places those args before `cargo test`'s own `--`, so
cargo rejects the flag rather than forwarding it to libtest:

    *** cargo test --verbose --package=mostro@0.18.0 --test-threads=4
    error: unexpected argument '--test-threads' found
    *** result: Failure(1)
    ERROR cargo test failed in an unmutated tree, so no mutants were tested

The baseline never passed, so no mutant was ever tested — via the
Makefile target or the CI job, since cargo-mutants reads this file
regardless of how it is invoked. Intended as an OOM guard, it silently
disabled the thing it was guarding.

No config-file or CLI mechanism in cargo-mutants 27.1.0 forwards
arguments past that `--`, and `CARGO_MUTANTS_JOBS` is the cap that
actually binds. Removing the file restores the baseline: the suite now
runs to completion (1021 passed locally, the one failure being the
known hardcoded-8080 `AddrInUse` flake that PR MostroP2P#849 fixes).
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.

test: mutation testing for Nostr event handling

1 participant