xmtp_mls: integration test for QR-invite end-to-end flow#3677
xmtp_mls: integration test for QR-invite end-to-end flow#3677tylerhawkes wants to merge 10 commits into
Conversation
|
Claude finished @tylerhawkes's task —— View job Code Review — 2026-05-27 18:34 UTC
Overall AssessmentThis is a well-structured test file with comprehensive coverage of the QR-invite flow. The tests are clear, well-documented, and follow the repository's testing conventions. Below are specific observations: Strengths
Issues & Suggestions1. Inconsistent error handling in negative tests (lines 348-417, 423-484)In let _ = bo.join_group_by_external_invite(...).await;
let _ = group.sync().await;
let _ = charlie_group.sync().await;Issue: Discarding Suggestion: Either:
2. Hardcoded sleep duration (line 185)xmtp_common::time::sleep(std::time::Duration::from_millis(500)).await;Issue: 500ms is arbitrary and may cause flakiness on slower CI systems or create unnecessarily slow tests on fast systems. Suggestions:
3. Redundant test setup duplicationAll happy-path tests (lines 126-231, 238-342, 491-572, 579-700) repeat the same setup pattern: group.sync().await?;
group.enable_proposals(EnableProposalsOptions::test_default()).await?;
group.set_allow_external_commit(true).await?;
group.sync().await?;Suggestion: Extract this to a helper function like: async fn setup_group_for_qr_invite(group: &TestMlsGroup) -> Result<(), GroupError> {
group.sync().await?;
group.enable_proposals(EnableProposalsOptions::test_default()).await?;
group.set_allow_external_commit(true).await?;
group.sync().await
}This would reduce duplication and make it easier to update the setup process if requirements change. 4.
|
| for mutation in &delta.mutations { | ||
| let key = match mutation { | ||
| TlsMapMutation::Insert { key, .. } | ||
| | TlsMapMutation::Update { key, .. } | ||
| | TlsMapMutation::Delete { key } => key, | ||
| }; | ||
| if key != &joiner_id { | ||
| return Err(CommitValidationError::ExternalCommitAppDataUpdateOutOfScope); | ||
| } | ||
| } |
There was a problem hiding this comment.
🟠 High groups/validated_commit.rs:2692
enforce_app_data_update_scope accepts TlsMapMutation::Delete when the key matches joiner_inbox_id, but this allows a joiner to submit a delta that only deletes their own entry. The joiner then ends up in the MLS group without a GROUP_MEMBERSHIP entry, violating the invariant that external joins must register the joiner. Since resync external commits are unsupported (per ResyncExternalCommitNotSupported), the joiner has no legitimate pre-existing entry to update or delete — the check should reject Delete and Update mutations.
for mutation in &delta.mutations {
let key = match mutation {
- TlsMapMutation::Insert { key, .. }
- | TlsMapMutation::Update { key, .. }
- | TlsMapMutation::Delete { key } => key,
+ TlsMapMutation::Insert { key, .. } => key,
+ TlsMapMutation::Update { .. } | TlsMapMutation::Delete { .. } => {
+ return Err(CommitValidationError::ExternalCommitAppDataUpdateOutOfScope);
+ }
};
if key != &joiner_id {
return Err(CommitValidationError::ExternalCommitAppDataUpdateOutOfScope);
}
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file crates/xmtp_mls/src/groups/validated_commit.rs around lines 2692-2701:
`enforce_app_data_update_scope` accepts `TlsMapMutation::Delete` when the key matches `joiner_inbox_id`, but this allows a joiner to submit a delta that only deletes their own entry. The joiner then ends up in the MLS group without a `GROUP_MEMBERSHIP` entry, violating the invariant that external joins must register the joiner. Since resync external commits are unsupported (per `ResyncExternalCommitNotSupported`), the joiner has no legitimate pre-existing entry to update or delete — the check should reject `Delete` and `Update` mutations.
Evidence trail:
crates/xmtp_mls/src/groups/validated_commit.rs lines 2640-2703 (function `enforce_app_data_update_scope`): line 2641 documents intent as Insert-only, but lines 2692-2701 accept all mutation types; line 2582 shows `ResyncExternalCommitNotSupported`; lines 2743-2748 (`well_formed_membership_update_for`) shows only Insert tested; crates/xmtp_mls_common/src/tls_map.rs lines 486-515 (`apply_delta`) shows Delete on non-existent key returns error but [Insert, Delete] compound succeeds.
ApprovabilityVerdict: Approved This PR adds integration tests for the QR-invite flow with no production code changes. The author owns all modified test files, and the unresolved review comments reference files outside this PR's scope. No code changes detected at You can customize Macroscope's approvability policy. Learn more. |
1557cdd to
7c44f08
Compare
| /// group to be migrated to AppData. The component's | ||
| /// `permissions.update_policy` (super-admin-only by default) gates | ||
| /// who can flip the bits. | ||
| pub async fn set_external_commit_policy( |
There was a problem hiding this comment.
🟡 Medium groups/mod.rs:2016
set_external_commit_policy and set_allow_external_commit do not check if the conversation is a DM before modifying policy, while all other metadata update methods (update_group_name, update_app_data, etc.) return MetadataPermissionsError::DmGroupMetadataForbidden for DMs. Enabling external commits on a DM is semantically meaningless since DMs are fixed 2-person conversations that don't support QR-invite member addition. This inconsistency could lead to confusing downstream errors or undefined behavior.
Consider adding the same DM check used by other metadata update methods.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file crates/xmtp_mls/src/groups/mod.rs around line 2016:
`set_external_commit_policy` and `set_allow_external_commit` do not check if the conversation is a DM before modifying policy, while all other metadata update methods (`update_group_name`, `update_app_data`, etc.) return `MetadataPermissionsError::DmGroupMetadataForbidden` for DMs. Enabling external commits on a DM is semantically meaningless since DMs are fixed 2-person conversations that don't support QR-invite member addition. This inconsistency could lead to confusing downstream errors or undefined behavior.
Consider adding the same DM check used by other metadata update methods.
Evidence trail:
crates/xmtp_mls/src/groups/mod.rs lines 2016-2047 (set_external_commit_policy, no DM check), lines 2052-2076 (set_allow_external_commit, delegates to set_external_commit_policy). Compare with DM checks at: line 1901 (update_group_name), line 1927 (update_app_data), line 1994 (update_commit_log_signer), line 2101 (update_permission_policy), line 2159 (update_group_description), line 2199 (update_group_image_url_square). All found via git_grep for 'DmGroupMetadataForbidden' in crates/xmtp_mls/src/groups/mod.rs at REVIEWED_COMMIT.
| return Err(GroupMessageProcessingError::CommitValidation( | ||
| CommitValidationError::ActorNotMember, | ||
| )); |
There was a problem hiding this comment.
🟠 High groups/mls_sync.rs:1217
When an unsupported sender type (e.g., Sender::External, Sender::NewMemberProposal, Sender::Preconfigured) is received, the function returns CommitValidationError::ActorNotMember without calling self.maybe_update_cursor(). This is inconsistent with every other error path in this function, which updates the cursor before returning. Without the cursor update, the same invalid message will be re-processed on every sync, causing an infinite retry loop.
- return Err(GroupMessageProcessingError::CommitValidation(
- CommitValidationError::ActorNotMember,
- ));
+ self.maybe_update_cursor(&self.context.db(), envelope)?;
+ return Err(GroupMessageProcessingError::CommitValidation(
+ CommitValidationError::ActorNotMember,
+ ));🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file crates/xmtp_mls/src/groups/mls_sync.rs around lines 1217-1219:
When an unsupported sender type (e.g., `Sender::External`, `Sender::NewMemberProposal`, `Sender::Preconfigured`) is received, the function returns `CommitValidationError::ActorNotMember` without calling `self.maybe_update_cursor()`. This is inconsistent with every other error path in this function, which updates the cursor before returning. Without the cursor update, the same invalid message will be re-processed on every sync, causing an infinite retry loop.
Evidence trail:
crates/xmtp_mls/src/groups/mls_sync.rs lines 1210-1220 (the `other` branch returning without cursor update), lines 1132-1195 (other error paths calling maybe_update_cursor before return), lines 1223-1235 (post-result cursor update handler bypassed by direct return), lines 2499-2517 (maybe_update_cursor implementation), crates/xmtp_mls/src/groups/validated_commit.rs lines 213-220 (is_retryable returns false for ActorNotMember)
7c44f08 to
a65eb44
Compare
e9e80a9 to
66f7743
Compare
Dismissing prior approval to re-evaluate a65eb44
… generic AppDataUpdate intent Defense-in-depth master switch + per-component declarative external committer authorization for the QR-invite flow (MLS External Commits, RFC 9420 §12.4.3.2). - ComponentId::EXTERNAL_COMMIT_POLICY = 0x800C in xmtp_mls_common. - Type-dispatch entry: ComponentType::Bytes for the new id. - crates/xmtp_mls/src/groups/external_commit_policy.rs (new file): - load_external_commit_policy(group) -> Option<ExternalCommitPolicyV1> - is_external_commit_allowed(group) -> bool - external_committer_permissions_for(group, id) -> Option<ComponentPermissions> - MlsGroup::set_external_commit_policy(policy) — granular setter writes the full ExternalCommitPolicyV1 via the generic AppDataUpdate intent (AppDataUpdateOp::Replace, since the component is full-replace Bytes-typed). - MlsGroup::set_allow_external_commit(bool) — sugar wrapper. Depends on L-0 (generic IntentKind::AppDataUpdate) for the intent machinery. No new IntentKind variant added — the generic path covers this and every future full-replace component setter. No bootstrap synth: absent EXTERNAL_COMMIT_POLICY = disabled default. First setter call creates the entry via AppDataUpdateOperation::Update upsert semantics. Zero risk of cross-client serialization drift.
… + encrypted GroupInfo)
f0d63d4 to
a6ec61d
Compare
a65eb44 to
9874f1f
Compare
a6ec61d to
b5b9a53
Compare
b5b9a53 to
3056397
Compare
Summary
End-to-end integration test for the QR-invite atomic external commit flow.
crates/xmtp_mls/src/groups/tests/test_external_invite_e2e.rsexercises:set_allow_external_commit(false).Uses a minimal in-test "service" stub (HashMap keyed by
group_id_hash) to simulate the application's transport layer.Stack
This PR is a merge of two branches:
tyler/qr-invite-b2-napi-join(bindings/node: add Client.joinGroupByExternalInvite NAPI binding #3676) — pulls in L-9/L-10/L-11.tyler/qr-invite-l8-ingestion(xmtp_mls: route Sender::NewMemberCommit through from_external_commit + envelope-timestamp plumbing #3668) — pulls in L-6/L-7/L-8 (validator + ingestion routing).Both branches reach back through L-4 / L-1 / L-0.
Test plan
Note
Add integration tests for QR-invite end-to-end flow in
xmtp_mlsAdds a new test file
test_external_invite_e2e.rscovering the full QR-invite join flow via external commits.InviteBlobStoreprovides an in-memory stub for the invite-blob service, keyed byexternal_group_idfromExternalInvitePayloadqr_invite_happy_path_single_installationtests the single-installation join path including sync and post-join message decryptionqr_invite_happy_path_multi_installationtests atomic join of two installations via external commit with HPKE-wrapped Welcome processingqr_invite_rejected_when_policy_disallows) and stale-epoch rejection (qr_invite_rejected_stale_epoch)qr_invite_chain_self_refresh) and inbox-scoped membership updates (qr_invite_membership_update_is_inbox_scoped)Macroscope summarized 9874f1f.