diff --git a/tools/marmot-interop/headless/tests-extras.sh b/tools/marmot-interop/headless/tests-extras.sh index e155e4fb3..ef4deebcd 100644 --- a/tools/marmot-interop/headless/tests-extras.sh +++ b/tools/marmot-interop/headless/tests-extras.sh @@ -208,30 +208,110 @@ test_14_wn_removes_a() { banner "Test 14 — wn (admin) removes A; amy processes Remove" local id="14 wn removes amy" - # Known gap: wn (mdk-core/openmls) emits the filtered direct-path - # form of UpdatePath on Remove commits per RFC 9420 §7.7 — when the - # copath of a direct-path node has an empty resolution (every leaf - # under it is blank) the corresponding UpdatePathNode is omitted. - # Quartz's RatchetTree.applyUpdatePath currently requires the - # unfiltered node count (`pathNodes.size == directPath.size`) so - # every wn->amy Remove triggers - # "UpdatePath node count (N) doesn't match direct path length (N+k)". - # That's a pre-existing quartz conformance bug, out of scope for - # this branch; the harness carries the test so it starts passing - # the moment the filtered-path path is wired up. - record_result "$id" skip "pending filtered_direct_path support in applyUpdatePath" + # Exercises inbound filtered-direct-path per RFC 9420 §7.7: wn + # (mdk-core/openmls) strips UpdatePathNodes whose copath has empty + # resolution, and amy must accept the short path on receive. Quartz + # wires this on both sides as of commit 3279c246. + + local out mls_gid + out=$(wn_b --json groups create "Interop-14" "$C_NPUB" 2>>"$LOG_FILE") || { + record_result "$id" fail "wn create Interop-14 failed"; return + } + mls_gid=$(printf '%s' "$out" | jq_group_id) + [[ -n "$mls_gid" ]] || { record_result "$id" fail "no group_id from create"; return; } + wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true + + wn_b groups add-members "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || { + record_result "$id" fail "wn add-members A failed"; return + } + local a_gid + a_gid=$(amy_json marmot await group --name "Interop-14" --timeout 30 | jq -r '.group_id // empty') + [[ -n "$a_gid" ]] || { record_result "$id" fail "A never received Interop-14 invite"; return; } + + # B (admin) removes A. The resulting commit carries a filtered + # UpdatePath; amy must apply it without throwing on the node-count + # mismatch that used to happen pre-filter-support. + wn_b groups remove-members "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || { + record_result "$id" fail "wn remove-members A failed"; return + } + + # Amy should observe that she's no longer a member. `group show` returns + # a not_member error as soon as the Remove commit is applied locally. + local deadline=$(( $(date +%s) + 120 )) removed=0 + while [[ $(date +%s) -lt $deadline ]]; do + local show rc + show=$(amy_json marmot group show "$a_gid" 2>&1) + rc=$? + if [[ $rc -ne 0 ]] && printf '%s' "$show" | jq -e '.error == "not_member"' >/dev/null 2>&1; then + removed=1; break + fi + sleep 3 + done + if [[ "$removed" -eq 1 ]]; then + record_result "$id" pass + else + record_result "$id" fail "amy still considered herself a member after wn Remove" + fi } test_15_wn_member_leaves() { banner "Test 15 — wn_c leaves; amy + wn_b process SelfRemove" local id="15 wn_c leaves" - # Same filtered_direct_path gap as test 14: when wn_c leaves a - # 3-member group, wn_b folds the SelfRemove into a commit whose - # UpdatePath uses RFC 9420 §7.7 filtering, and amy's strict - # applyUpdatePath rejects it. Skip until quartz handles the - # filtered form on inbound. - record_result "$id" skip "pending filtered_direct_path support in applyUpdatePath" + # Same filtered-direct-path path as test 14, but the trigger is a + # SelfRemove proposal from C that wn_b folds into a commit. Amy stays + # a member here — verification is that the commit applies cleanly and + # the group continues to function afterwards. + + local out mls_gid + out=$(wn_b --json groups create "Interop-15" "$C_NPUB" 2>>"$LOG_FILE") || { + record_result "$id" fail "wn create Interop-15 failed"; return + } + mls_gid=$(printf '%s' "$out" | jq_group_id) + [[ -n "$mls_gid" ]] || { record_result "$id" fail "no group_id from create"; return; } + wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true + + wn_b groups add-members "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || { + record_result "$id" fail "wn add-members A failed"; return + } + local a_gid + a_gid=$(amy_json marmot await group --name "Interop-15" --timeout 30 | jq -r '.group_id // empty') + [[ -n "$a_gid" ]] || { record_result "$id" fail "A never received Interop-15 invite"; return; } + + # C self-removes. wn_b picks up the SelfRemove proposal and commits it. + wn_c groups leave "$mls_gid" >/dev/null 2>&1 || true + sleep 5 + # Nudge B to commit any outstanding proposals via a no-op rename round-trip. + wn_b groups rename "$mls_gid" "Interop-15 (C left)" >/dev/null 2>&1 || true + + # Wait for amy to see C gone AND stay a member herself. + local deadline=$(( $(date +%s) + 120 )) ok=0 + while [[ $(date +%s) -lt $deadline ]]; do + local show + show=$(amy_json marmot group show "$a_gid" 2>/dev/null) || { sleep 3; continue; } + local c_still + c_still=$(printf '%s' "$show" | jq --arg p "$C_HEX" '[.members[]? | select(.pubkey == $p)] | length') + local a_still + a_still=$(printf '%s' "$show" | jq --arg p "$A_HEX" '[.members[]? | select(.pubkey == $p)] | length') + if [[ "$c_still" == "0" && "$a_still" == "1" ]]; then + ok=1; break + fi + sleep 3 + done + if [[ "$ok" -ne 1 ]]; then + record_result "$id" fail "amy never saw a (C gone, A present) state"; return + fi + + # Post-commit round-trip: amy sends, B receives. Confirms encryption + # survived the filtered UpdatePath application on amy's side. + amy_json marmot message send "$a_gid" "after wn_c leave" >/dev/null || { + record_result "$id" fail "amy send failed after wn_c leave"; return + } + if wait_for_message B "$mls_gid" "after wn_c leave" 90; then + record_result "$id" pass + else + record_result "$id" fail "B didn't receive amy's post-leave message" + fi } test_16_wn_keypackage_rotation() {