Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions backend/src/models/mission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,9 +327,13 @@ impl Mission {
87 | 88 | 89 | 90 => DoKind::Knowledge,
91 | 93 | 94 | 95 | 96 => DoKind::Knowledge,
97 | 98 | 99 => DoKind::Knowledge,
// 102/103 render a paste-value input on the frontend; server-side
// any non-empty reflection counts, same as knowledge missions.
100 | 101 | 102 | 103 | 104 => DoKind::Knowledge,
100 | 101 | 104 => DoKind::Knowledge,
// 102/103 render a paste-value input on the frontend: paste a
// docs link/sentence (102), or restate a "good first issue" in
// your own words (103). Split out of the Knowledge run above so
// verify_proof can require a minimum length instead of letting
// a one-character reflection clear the mission (issue #81).
102 | 103 => DoKind::PasteValue,
106 | 107 | 108 | 109 | 110 => DoKind::Knowledge,

// Action missions:
Expand Down Expand Up @@ -411,6 +415,19 @@ mod tests {
}
}

/// Missions 102/103 render a paste-value reflection input and must
/// enforce a minimum length (issue #81) — they can't fall back into the
/// neighbouring 100/101/104 `Knowledge` arm, which accepts any
/// non-empty string.
#[test]
fn paste_value_missions_are_not_shadowed_by_knowledge_arms() {
assert_eq!(Mission::do_kind(102), DoKind::PasteValue);
assert_eq!(Mission::do_kind(103), DoKind::PasteValue);
for n in [100u8, 101, 104] {
assert_eq!(Mission::do_kind(n), DoKind::Knowledge, "mission {n}");
}
}

/// The bar itself: every flight path carries at least one mission with a
/// real action, except Money Basics, which is deliberately exempt
/// because it is the conceptual opening path.
Expand Down Expand Up @@ -438,6 +455,15 @@ mod tests {
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DoKind {
Knowledge,
/// A free-text reflection (docs link, restated issue, etc.) that must
/// clear a minimum length so it isn't the same "any non-empty string
/// passes" bar as `Knowledge`. See `verify_proof`'s `PasteValue` arm.
///
/// Covers missions 102/103 only. Mission 106 also renders a paste-value
/// input on the frontend, but intentionally stays `Knowledge` here: it's a
/// numeric naira-quote input, not a reflection, so the minimum-length floor
/// doesn't apply.
PasteValue,
NostrIdentity,
Invoice,
Pay,
Expand Down
25 changes: 25 additions & 0 deletions backend/src/routes/missions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,15 @@ async fn complete_mission(
}))
}

/// Minimum character count for a `DoKind::PasteValue` reflection (missions
/// 102/103). Not tied to any protocol fact — it's a judgment call about how
/// much text rules out a "no" or a single pasted character while still
/// welcoming a short-but-real answer (a bare docs link, a one-line
/// restatement). Deliberately well under the frontend's per-mission
/// `maxLength` (300 for 102, 500 for 103), which caps length rather than
/// setting a floor.
const MIN_PASTE_VALUE_LEN: usize = 20;

/// Verify that a submitted proof matches what the server expects for the
/// given mission. Returns `BadRequest` on mismatch.
///
Expand All @@ -204,6 +213,22 @@ async fn verify_proof(
// The proof string gets recorded in mission_completions for audit.
DoKind::Knowledge => Ok(()),

// Missions 102/103: a paste-value reflection (a docs link/sentence,
// or a restated "good first issue"). We deliberately do not judge
// whether the reflection is *good* — we can't, and pretending to
// would be worse than the honest bar this replaces (issue #81).
// All we enforce is that it clears a minimum length, so a
// one-character answer can't pass it off as substance. The message
// is written as an invitation to say more, not a rejection.
DoKind::PasteValue => {
if proof.chars().count() < MIN_PASTE_VALUE_LEN {
return Err(AppError::BadRequest(format!(
"say a little more — at least {MIN_PASTE_VALUE_LEN} characters, so there's something real to reflect on"
)));
}
Ok(())
}

// Mission 14: Nostr identity created in browser; client submits the
// npub it generated. We don't validate the bech32 here beyond
// "non-empty"; the very fact that the participant possesses an npub
Expand Down
73 changes: 69 additions & 4 deletions backend/tests/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
//! * mission 11 (seed-words) requires 64-hex commitment
//! * mission 14 (nostr-identity) requires bech32 npub
//! * mission 42 (onchain-signet) requires 64-hex txid
//! * missions 102/103 (paste-value) require a minimum-length reflection
//! - bearer auth enforcement: wrong/missing token → 401
//! - facilitator endpoints require X-Facilitator-Key
//! - proof archive endpoint (/me/completions)
Expand Down Expand Up @@ -685,6 +686,62 @@ fn mission_42_requires_64_hex_txid_before_any_explorer_lookup() {
);
}

#[test]
fn paste_value_missions_require_more_than_a_word() {
// Issue #81: 102 and 103 render a paste-value reflection input and must
// enforce a minimum length, so a one-character (or one-word) answer
// can't clear the mission the way a Knowledge mission's "acknowledged"
// filler would.
let h = Harness::start();
let s = create_session(&h.base, "paste-value-test");
let j = join_session(&h.base, "kate", &s.id);

// Walk to mission 102: prerequisites are 100, 101.
for m in [100u8, 101] {
let r = complete(&h.base, &j.auth_token, m, "acknowledged");
assert_eq!(r.status(), 200, "mission {m} should succeed");
}

// Too short: rejected, and the message reads as an invitation to say
// more rather than a scolding "invalid" rejection.
let r = complete(&h.base, &j.auth_token, 102, "no");
assert_eq!(r.status(), 400);
let v: Value = r.json().unwrap();
let err = v["error"].as_str().unwrap();
assert!(
err.contains("more"),
"expected a nudge to write more, got: {err}"
);

// The knowledge-mission filler "acknowledged" is also too short here —
// it must not silently pass the way it does on a Knowledge mission.
let r = complete(&h.base, &j.auth_token, 102, "acknowledged");
assert_eq!(r.status(), 400);

// Long enough: passes and advances the per-tree pointer to 103.
let r = complete(
&h.base,
&j.auth_token,
102,
"https://bitcoindevkit.org/docs — the intro paragraph assumes I already know what a descriptor is.",
);
assert_eq!(r.status(), 200, "{}", r.text().unwrap());
let v: Value = r.json().unwrap();
assert_eq!(v["next_mission"], 103);

// Mission 103 enforces the same floor.
let r = complete(&h.base, &j.auth_token, 103, "idk");
assert_eq!(r.status(), 400);

let r = complete(
&h.base,
&j.auth_token,
103,
"The issue asks for reflection answers to need substance; it's done when 102 and 103 enforce a minimum length.",
);
assert_eq!(r.status(), 200, "{}", r.text().unwrap());
}

#[test]
fn proof_archive_lists_completions_in_order() {
let h = Harness::start();
Expand Down Expand Up @@ -1042,16 +1099,24 @@ fn daily_streak_extends_on_consecutive_days_and_resets_after_a_gap() {

#[test]
fn open_source_graduation_demands_a_parseable_github_proof() {
// Missions 100..=104 are reflection missions (any non-empty proof),
// but 105 must name a GitHub account and a github.com PR URL. A bad
// Missions 100/101/104 are plain reflection missions (any non-empty
// proof); 102/103 are paste-value missions and need a proof past the
// minimum-length floor (see paste_value_missions_require_more_than_a_word
// below). 105 must name a GitHub account and a github.com PR URL. A bad
// proof fails the shape check before any network call happens, so
// this test stays offline.
let h = Harness::start();
let s = create_session(&h.base, "oss-grad");
let j = join_session(&h.base, "grace", &s.id);

for m in [100, 101, 102, 103, 104] {
let r = complete(&h.base, &j.auth_token, m, "acknowledged");
// Order matters: the per-tree gate requires 100..104 in sequence.
for m in [100u8, 101, 102, 103, 104] {
let proof = if m == 102 || m == 103 {
"This is a long enough reflection to clear the paste-value floor."
} else {
"acknowledged"
};
let r = complete(&h.base, &j.auth_token, m, proof);
assert_eq!(r.status(), 200, "completing mission {m}");
}

Expand Down
Loading