From 1e181eb305ac210640b9374bd6a994e5fc3ad478 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 21:28:56 +0000 Subject: [PATCH] fix(marmot-interop): drive Test 06 removal from the admin (B), not A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test 06 was asking the operator to remove C from Interop-05 via the Amethyst UI — but Interop-05 was created by B (wn), so B is the sole admin and A is a plain member. MIP-03's authorization gate (`enforceAuthorizedProposalSet` in `MlsGroup.kt:1842`) rejects any Remove proposal from a non-admin, so the UI correctly fires "not an admin" and the test fails against its own author's assumption — the test was misaligned with the admin model, not the code. Flip the flow: B (admin) issues the removal via `wn groups remove-members `, and A / C observe the effect. That still exercises the same intended invariants: - B + A remain in the group, commit + epoch advance propagate. - A's Amethyst UI shows C gone (visible confirm). - A sends "after removing C" → B decrypts (expected). - C cannot decrypt the new message (forward secrecy at the new epoch — C's retained keys only open the pre-remove epoch). The "Amethyst admin issues remove" path is already separately covered by Test 02 (A creates the group → A is admin → A removes) and Test 08 (promote A, then A issues an admin-only action). Test 06's focus is forward secrecy after remove, not who may initiate — so having B drive it is the spec-correct shape. --- tools/marmot-interop/marmot-interop.sh | 48 ++++++++++++++++++++------ 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/tools/marmot-interop/marmot-interop.sh b/tools/marmot-interop/marmot-interop.sh index 80579bc0d..5ddc1e523 100755 --- a/tools/marmot-interop/marmot-interop.sh +++ b/tools/marmot-interop/marmot-interop.sh @@ -847,33 +847,61 @@ test_06_member_removal() { skip_msg "Test 06 requires Test 05 state"; record_result "06 member removal" skip "no GROUP_05"; return fi - prompt_human "In Amethyst, open group 'Interop-05' -> Group Info -> Members. - Tap C ($C_NPUB) -> Remove. - Confirm the removal." + # Interop-05 was created by B (wn), so B is the sole admin. Per + # MIP-03 `enforceAuthorizedProposalSet`, non-admin members may only + # commit a self-Update or a SelfRemove-only proposal set — a Remove + # proposal from A (a plain member here) is rejected before publish + # with "non-admin members may only commit …". The previous revision + # of this test asked the operator to remove C from Amethyst's UI, + # which is correct per spec to REJECT — so the test itself was + # misaligned with the admin model. Drive the removal from B (the + # admin) instead, and observe its effect on A + C. + step "B (admin) removes C from Interop-05" + local remove_out + if ! remove_out=$(wn_b groups remove-members "$gid" "$C_NPUB" 2>&1); then + fail_msg "wn_b groups remove-members failed: $remove_out" + printf 'wn_b groups remove-members: %s\n' "$remove_out" >>"$LOG_FILE" + record_result "06 member removal" fail "wn remove returned nonzero" + return + fi + printf 'wn_b groups remove-members: %s\n' "$remove_out" >>"$LOG_FILE" - step "verifying C is removed (60s poll)" - local deadline=$(( $(date +%s) + 60 )) removed=0 + step "verifying C is removed from B's + C's view (60s poll)" + local deadline=$(( $(date +%s) + 60 )) removed_b=0 removed_c=0 while [[ $(date +%s) -lt $deadline ]]; do + if ! wn_b --json groups members "$gid" 2>/dev/null \ + | jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then + removed_b=1 + fi if ! wn_c --json groups members "$gid" 2>/dev/null \ | jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then - removed=1; break + removed_c=1 fi + [[ "$removed_b" -eq 1 ]] && break sleep 3 done + if [[ "$removed_b" -ne 1 ]]; then + warn "B still shows C as a member after remove — commit may not have applied" + fi + info "post-remove: B sees C gone=$removed_b, C sees self gone=$removed_c" - if [[ "$removed" -ne 1 ]]; then - warn "C still appears to be a member; proceeding anyway" + prompt_human "In Amethyst, open 'Interop-05' -> Group Info and confirm C is no longer listed." + if ! confirm "Does Amethyst's member list now show only you and B (not C)?"; then + record_result "06 member removal" fail "Amethyst still shows C as a member" + return fi prompt_human "In Amethyst, send: 'after removing C'" if wait_for_message B "$gid" "after removing C" 30; then - info "B still receives messages (expected)" + info "B still receives messages (expected — B + A are the remaining members)" else fail_msg "B stopped receiving messages (unexpected)" record_result "06 member removal" fail "B lost access"; return fi - # C should NOT be able to decrypt the new message + # C should NOT be able to decrypt the new message — forward secrecy + # at the post-remove epoch means C's retained keys cannot open any + # kind:445 sealed under the new epoch's exporter. sleep 5 if wait_for_message C "$gid" "after removing C" 10; then fail_msg "C still decrypted a post-removal message — forward secrecy broken"