Skip to content

Commit a5b2e3b

Browse files
committed
test: kill the mutants
1 parent 62ed62c commit a5b2e3b

26 files changed

Lines changed: 1423 additions & 34 deletions

.cargo/mutants.toml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ exclude_re = [
6161
# Secret::drop zeroizes memory for security. Testing this properly requires unsafe memory
6262
# inspection. The security guarantee comes from using the zeroize crate correctly.
6363
"crates/cashu/src/secret.rs.*impl Drop for Secret.*drop",
64+
"crates/cashu/src/nuts/nut01/secret_key.rs:.*impl Drop for SecretKey.*drop",
65+
66+
# SecretKeyVisitor/SecretVisitor::expecting are serde diagnostics only.
67+
"crates/cashu/src/nuts/nut01/secret_key.rs:.*SecretKeyVisitor.*expecting",
68+
"crates/cashu/src/nuts/nut10/secret.rs:.*SecretVisitor.*expecting",
69+
70+
# Inline benchmark harness is not runtime behavior.
71+
"crates/cashu/src/nuts/nut01/public_key.rs:.*benches::public_key_from_hex",
72+
73+
# TlvReader constant-success mutations with type 0 never advance through unknown TLVs
74+
# and cause non-progress in decode loops.
75+
"crates/cashu/src/nuts/nut26/encoding.rs:.*TlvReader.*read_tlv.*Ok\\(Some\\(\\(0, vec!",
76+
"crates/cashu/src/nuts/nut26/encoding.rs:.*TlvReader.*read_tlv.*replace \\+= with -=",
77+
"crates/cashu/src/nuts/nut26/encoding.rs:.*replace \\+= with -= in TlvReader.*read_tlv",
6478

6579
# serialize_to_cbor_diag is only used for CLI diagnostic output (decode_token, decode_request).
6680
# Not part of any critical path - purely human-readable debugging output.
@@ -99,6 +113,48 @@ exclude_re = [
99113
"crates/cashu/src/nuts/nut00/mod.rs:.*PreMintSecrets::iter",
100114
"crates/cashu/src/nuts/nut00/mod.rs:.*PreMintSecrets::is_empty",
101115

116+
# msg_to_sign preallocation capacity does not affect the signed bytes.
117+
"crates/cashu/src/nuts/nut20.rs:39:.*MintRequest<.*>::msg_to_sign",
118+
"crates/cashu/src/nuts/nut29.rs:88:.*BatchMintRequest<.*>::msg_to_sign",
119+
120+
# Equivalent max update: assigning the same value when sigs == sigs_required
121+
# leaves enforce_sig_flag unchanged.
122+
"crates/cashu/src/nuts/nut11/mod.rs:.*replace > with >= in enforce_sig_flag",
123+
124+
# Feature-gated behind wallet+nostr; the default cashu mutation profile does
125+
# not compile or run these tests. The weekly NUT-27 Nostr job and
126+
# `just mutants-cashu-nut27-nostr` cover this file with --no-config so this
127+
# default-profile exclude does not apply there.
128+
"crates/cashu/src/nuts/nut27.rs:.*",
129+
130+
# Equivalent bit operation in hex decoding: high << 4 and low nibble never
131+
# overlap, so OR and XOR produce identical bytes.
132+
"crates/cashu/src/util/hex.rs:.*replace \\| with \\^ in decode",
133+
134+
# These builder constructors intentionally return Default::default().
135+
"crates/cashu/src/nuts/nut18/payment_request.rs:.*PaymentRequest::builder",
136+
"crates/cashu/src/nuts/nut18/transport.rs:.*Transport::builder",
137+
138+
# Length guard forced true is equivalent: non-2 array lengths still fail
139+
# either missing response lookup or tuple deserialization.
140+
"crates/cashu/src/nuts/nut17/mod.rs:.*items\\.len\\(\\) == 2 with true",
141+
142+
# Target-before-kind transport data is ignored and remains invalid later
143+
# because no valid target is available for the decoded kind.
144+
"crates/cashu/src/nuts/nut26/encoding.rs:.*delete match arm None in PaymentRequest::decode_transport",
145+
146+
# Equivalent Nostr NIP tag encoding: the specialized "n" branch and the
147+
# generic tag branch both encode the full tag tuple identically.
148+
"crates/cashu/src/nuts/nut26/encoding.rs:499:.*replace >= with < in PaymentRequest::encode_transport",
149+
150+
# Equivalent loop boundary: pos == data.len() enters then immediately breaks
151+
# on the inner type+length guard, same as exiting the loop.
152+
"crates/cashu/src/nuts/nut26/encoding.rs:.*replace < with <= in PaymentRequest::decode_nprofile",
153+
154+
# get_pubkeys_and_required_sigs does not currently surface reachable NUT14
155+
# errors through verify_htlc; malformed preimages are checked later.
156+
"crates/cashu/src/nuts/nut14/mod.rs:139:.*delete match arm super::nut10::Error::NUT14",
157+
102158
# Keys/MintKeys trivial delegation - Deref/DerefMut/keys() just return &self.0
103159
"crates/cashu/src/nuts/nut01/mod.rs:.*impl Deref for Keys.*deref",
104160
"crates/cashu/src/nuts/nut01/mod.rs:.*Keys::keys",

.github/workflows/mutation-testing-weekly.yml

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,49 +31,95 @@ jobs:
3131
run: cargo mutants --package cashu --in-place --no-shuffle
3232
continue-on-error: true
3333

34-
- name: Upload mutation results
34+
- name: Upload cashu mutation results
3535
uses: actions/upload-artifact@v7
3636
if: always()
3737
with:
3838
name: mutants.out
3939
path: mutants.out
4040
retention-days: 90
4141

42+
- name: Run mutation tests on cashu NUT-27 with Nostr features
43+
run: cargo mutants --package cashu --no-config --file crates/cashu/src/nuts/nut27.rs --features wallet,nostr --in-place --no-shuffle --output mutants-nut27-nostr.out
44+
continue-on-error: true
45+
46+
- name: Upload cashu NUT-27 Nostr mutation results
47+
uses: actions/upload-artifact@v7
48+
if: always()
49+
with:
50+
name: mutants-nut27-nostr.out
51+
path: mutants-nut27-nostr.out
52+
retention-days: 90
53+
4254
- name: Check for missed mutants and create issue
4355
if: always()
4456
run: |
45-
if [ -s mutants.out/missed.txt ]; then
57+
DEFAULT_MISSED_COUNT=0
58+
DEFAULT_CAUGHT_COUNT=0
59+
NUT27_MISSED_COUNT=0
60+
NUT27_CAUGHT_COUNT=0
61+
62+
if [ -f mutants.out/missed.txt ]; then
63+
DEFAULT_MISSED_COUNT=$(wc -l < mutants.out/missed.txt)
64+
fi
65+
66+
if [ -f mutants.out/caught.txt ]; then
67+
DEFAULT_CAUGHT_COUNT=$(wc -l < mutants.out/caught.txt)
68+
fi
69+
70+
if [ -f mutants-nut27-nostr.out/missed.txt ]; then
71+
NUT27_MISSED_COUNT=$(wc -l < mutants-nut27-nostr.out/missed.txt)
72+
fi
73+
74+
if [ -f mutants-nut27-nostr.out/caught.txt ]; then
75+
NUT27_CAUGHT_COUNT=$(wc -l < mutants-nut27-nostr.out/caught.txt)
76+
fi
77+
78+
TOTAL_MISSED=$((DEFAULT_MISSED_COUNT + NUT27_MISSED_COUNT))
79+
80+
if [ "$TOTAL_MISSED" -gt 0 ]; then
4681
echo "Missed mutants found"
4782
MUTANTS_VERSION=$(cargo mutants --version)
48-
MISSED_COUNT=$(wc -l < mutants.out/missed.txt)
49-
CAUGHT_COUNT=$(wc -l < mutants.out/caught.txt 2>/dev/null || echo "0")
5083
5184
gh issue create \
5285
--title "🧬 Weekly Mutation Testing Report - $(date +%Y-%m-%d)" \
5386
--label "mutation-testing,weekly-report" \
5487
--body "$(cat <<EOF
5588
## Mutation Testing Results
5689
57-
- ✅ Caught: ${CAUGHT_COUNT}
58-
- ❌ Missed: ${MISSED_COUNT}
90+
### Default cashu profile
91+
92+
- ✅ Caught: ${DEFAULT_CAUGHT_COUNT}
93+
- ❌ Missed: ${DEFAULT_MISSED_COUNT}
94+
95+
### NUT-27 Nostr profile
96+
97+
- ✅ Caught: ${NUT27_CAUGHT_COUNT}
98+
- ❌ Missed: ${NUT27_MISSED_COUNT}
99+
100+
### Top 10 Missed Mutations - Default cashu profile
101+
102+
\`\`\`
103+
$(if [ -s mutants.out/missed.txt ]; then head -n 10 mutants.out/missed.txt; else echo "No missed mutants"; fi)
104+
\`\`\`
59105
60-
### Top 10 Missed Mutations
106+
### Top 10 Missed Mutations - NUT-27 Nostr profile
61107
62108
\`\`\`
63-
$(head -n 10 mutants.out/missed.txt)
109+
$(if [ -s mutants-nut27-nostr.out/missed.txt ]; then head -n 10 mutants-nut27-nostr.out/missed.txt; else echo "No missed mutants"; fi)
64110
\`\`\`
65111
66112
### Action Items
67113
68114
1. Review the missed mutations above
69115
2. Add tests to catch these mutations
70-
3. For the complete list, check the [mutants.out artifact](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
116+
3. For the complete list, check the [mutants.out artifact](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) and [mutants-nut27-nostr.out artifact](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
71117
72118
**cargo-mutants version:** ${MUTANTS_VERSION}
73119
74120
---
75121
76-
💡 **Tip:** Use \`just mutants-quick\` to test only your changes before pushing!
122+
💡 **Tip:** Use \`just mutants-quick\` to test only your changes before pushing, or \`just mutants-cashu-nut27-nostr\` for the NUT-27 Nostr profile.
77123
EOF
78124
)"
79125
else

crates/cashu/src/nuts/auth/nut21.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,19 @@ mod tests {
601601
assert_eq!(non_matching_path.match_specificity(&request), None);
602602
}
603603

604+
#[test]
605+
fn test_route_path_specificity_rejects_nonmatching_wildcard_and_accepts_static_exact() {
606+
assert_eq!(
607+
RoutePath::Wildcard("/v1/mint/".to_string())
608+
.match_specificity(&RoutePath::Melt("bolt11".to_string())),
609+
None
610+
);
611+
assert_eq!(
612+
RoutePath::Swap.match_specificity(&RoutePath::Swap),
613+
Some(usize::MAX)
614+
);
615+
}
616+
604617
#[test]
605618
fn test_payment_method_routes_match_case_insensitively() {
606619
let cases = [
@@ -627,6 +640,36 @@ mod tests {
627640
}
628641
}
629642

643+
#[test]
644+
fn test_payment_method_routes_reject_wrong_route_or_method() {
645+
let cases = [
646+
(
647+
RoutePath::MintQuote("bolt11".to_string()),
648+
RoutePath::MintQuote("bolt12".to_string()),
649+
),
650+
(
651+
RoutePath::Mint("bolt11".to_string()),
652+
RoutePath::Mint("bolt12".to_string()),
653+
),
654+
(
655+
RoutePath::MeltQuote("bolt11".to_string()),
656+
RoutePath::MeltQuote("bolt12".to_string()),
657+
),
658+
(
659+
RoutePath::Melt("bolt11".to_string()),
660+
RoutePath::Melt("bolt12".to_string()),
661+
),
662+
(
663+
RoutePath::Mint("bolt11".to_string()),
664+
RoutePath::Melt("bolt11".to_string()),
665+
),
666+
];
667+
668+
for (configured, request) in cases {
669+
assert_eq!(configured.match_specificity(&request), None);
670+
}
671+
}
672+
630673
#[test]
631674
fn test_protected_endpoint_matches_case_insensitive_payment_method() {
632675
let configured =

crates/cashu/src/nuts/auth/nut22.rs

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -270,29 +270,27 @@ impl MintAuthRequest {
270270
#[cfg(test)]
271271
mod tests {
272272
use std::collections::HashSet;
273+
use std::str::FromStr;
273274

274275
use super::super::nut21::{Method, RoutePath};
275276
use super::*;
276277
use crate::nut00::KnownMethod;
277-
use crate::PaymentMethod;
278+
use crate::{Amount, PaymentMethod, SecretKey};
278279

279-
#[test]
280-
fn test_blind_auth_token_padding() {
281-
use std::str::FromStr;
282-
283-
use crate::SecretKey;
284-
285-
// Build a valid BlindAuthToken programmatically
280+
fn test_auth_proof() -> AuthProof {
286281
let secret_key = SecretKey::generate();
287-
let public_key = secret_key.public_key();
288-
let secret = Secret::generate();
289-
let auth_proof = AuthProof {
282+
283+
AuthProof {
290284
keyset_id: Id::from_bytes(&[0, 1, 2, 3, 4, 5, 6, 7]).expect("valid id"),
291-
secret,
292-
c: public_key,
285+
secret: Secret::generate(),
286+
c: secret_key.public_key(),
293287
dleq: None,
294-
};
295-
let token = BlindAuthToken::new(auth_proof);
288+
}
289+
}
290+
291+
#[test]
292+
fn test_blind_auth_token_padding() {
293+
let token = BlindAuthToken::new(test_auth_proof());
296294

297295
// Serialize (Display impl produces padded base64)
298296
let token_str = token.to_string();
@@ -310,6 +308,40 @@ mod tests {
310308
assert_eq!(token, parsed_no_pad);
311309
}
312310

311+
#[test]
312+
fn test_auth_token_display_and_header_key_preserve_type() {
313+
let blind_auth = BlindAuthToken::new(test_auth_proof());
314+
let blind_auth_string = blind_auth.to_string();
315+
316+
assert_eq!(
317+
AuthToken::ClearAuth("clear-token".to_string()).to_string(),
318+
"clear-token"
319+
);
320+
assert_eq!(
321+
AuthToken::ClearAuth(String::new()).header_key(),
322+
"Clear-auth"
323+
);
324+
assert_eq!(
325+
AuthToken::BlindAuth(blind_auth.clone()).to_string(),
326+
blind_auth_string
327+
);
328+
assert_eq!(AuthToken::BlindAuth(blind_auth).header_key(), "Blind-auth");
329+
}
330+
331+
#[test]
332+
fn test_mint_auth_request_amount_counts_outputs() {
333+
let keyset_id = Id::from_bytes(&[0, 1, 2, 3, 4, 5, 6, 7]).expect("valid id");
334+
let blinded_secret = SecretKey::generate().public_key();
335+
let request = MintAuthRequest {
336+
outputs: vec![
337+
BlindedMessage::new(Amount::ONE, keyset_id, blinded_secret),
338+
BlindedMessage::new(Amount::ONE, keyset_id, blinded_secret),
339+
],
340+
};
341+
342+
assert_eq!(request.amount(), 2);
343+
}
344+
313345
#[test]
314346
fn test_settings_deserialize_direct_paths() {
315347
let json = r#"{

0 commit comments

Comments
 (0)