-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2023 Wire Swiss GmbH <opensource@wire.com>
--
-- This program is free software: you can redistribute it and/or modify it under
-- the terms of the GNU Affero General Public License as published by the Free
-- Software Foundation, either version 3 of the License, or (at your option) any
-- later version.
--
-- This program is distributed in the hope that it will be useful, but WITHOUT
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
-- details.
--
-- You should have received a copy of the GNU Affero General Public License along
-- with this program. If not, see <https://www.gnu.org/licenses/>.

module Test.MLS.One2One where

import API.Brig
import API.Galley
import Control.Concurrent.Async
import Control.Concurrent.MVar
import qualified Data.ByteString.Base64 as Base64
import qualified Data.ByteString.Char8 as B8
import qualified Data.Map as Map
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Text.Read as T
import MLS.Util
import Notifications
import SetupHelpers
import Test.Version
import Testlib.Prelude
import Testlib.VersionedFed

testGetMLSOne2OneLocalV5 :: (HasCallStack) => App ()
testGetMLSOne2OneLocalV5 = withVersion5 Version5 $ do
  [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain]
  let assertConvData conv = do
        conv %. "epoch" `shouldMatchInt` 0
        conv %. "cipher_suite" `shouldMatchInt` 1

  convId <-
    getMLSOne2OneConversationLegacy alice bob `bindResponse` \resp -> do
      conv <- getJSON 200 resp
      conv %. "type" `shouldMatchInt` 2
      shouldBeEmpty (conv %. "members.others")

      conv %. "members.self.conversation_role" `shouldMatch` "wire_member"
      conv %. "members.self.qualified_id" `shouldMatch` (alice %. "qualified_id")
      assertConvData conv

      conv %. "qualified_id"

  -- check that the conversation has the same ID on the other side
  conv2 <- getMLSOne2OneConversationLegacy bob alice >>= getJSON 200

  conv2 %. "type" `shouldMatchInt` 2
  conv2 %. "qualified_id" `shouldMatch` convId
  assertConvData conv2

testGetMLSOne2OneRemoteV5 :: (HasCallStack) => App ()
testGetMLSOne2OneRemoteV5 = withVersion5 Version5 $ do
  [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain]
  getMLSOne2OneConversationLegacy alice bob `bindResponse` \resp -> do
    resp.status `shouldMatchInt` 400
    resp.json %. "label" `shouldMatch` "mls-federated-one2one-not-supported"

  getMLSOne2OneConversationLegacy bob alice `bindResponse` \resp -> do
    resp.status `shouldMatchInt` 400
    resp.json %. "label" `shouldMatch` "mls-federated-one2one-not-supported"

testGetMLSOne2One :: (HasCallStack) => Domain -> App ()
testGetMLSOne2One bobDomain = do
  [alice, bob] <- createAndConnectUsers [OwnDomain, bobDomain]
  bobDomainStr <- asString bobDomain
  let assertConvData conv = do
        conv %. "epoch" `shouldMatchInt` 0
        assertFieldMissing conv "cipher_suite"

  mlsOne2OneConv <-
    getMLSOne2OneConversation alice bob `bindResponse` \resp -> do
      one2oneConv <- getJSON 200 resp
      convOwnerDomain <- asString $ one2oneConv %. "conversation.qualified_id.domain"
      let user = if convOwnerDomain == bobDomainStr then bob else alice
      ownerDomainPublicKeys <- getMLSPublicKeys user >>= getJSON 200

      one2oneConv %. "public_keys" `shouldMatch` ownerDomainPublicKeys

      conv <- one2oneConv %. "conversation"
      conv %. "type" `shouldMatchInt` 2
      shouldBeEmpty (conv %. "members.others")
      conv %. "members.self.conversation_role" `shouldMatch` "wire_member"
      conv %. "members.self.qualified_id" `shouldMatch` (alice %. "qualified_id")
      assertConvData conv

      pure one2oneConv

  -- check that the conversation has the same ID on the other side
  mlsOne2OneConv2 <- getMLSOne2OneConversation bob alice >>= getJSON 200

  conv2 <- mlsOne2OneConv2 %. "conversation"
  conv2 %. "type" `shouldMatchInt` 2
  conv2 %. "qualified_id" `shouldMatch` (mlsOne2OneConv %. "conversation.qualified_id")
  mlsOne2OneConv2 %. "public_keys" `shouldMatch` (mlsOne2OneConv %. "public_keys")
  assertConvData conv2

testMLSOne2OneOtherMember :: (HasCallStack) => One2OneScenario -> App ()
testMLSOne2OneOtherMember scenario = do
  alice <- randomUser OwnDomain def
  let otherDomain = one2OneScenarioUserDomain scenario
      convDomain = one2OneScenarioConvDomain scenario
  bob <- createMLSOne2OnePartner otherDomain alice convDomain
  one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200
  one2OneConvId <- objConvId $ one2OneConv %. "conversation"
  do
    convId <- one2OneConv %. "conversation.qualified_id"
    bobOne2OneConv <- getMLSOne2OneConversation bob alice >>= getJSON 200
    convId `shouldMatch` (bobOne2OneConv %. "conversation.qualified_id")

  [alice1, bob1] <- traverse (createMLSClient def) [alice, bob]
  void $ uploadNewKeyPackage def bob1
  resetOne2OneGroup def alice1 one2OneConv
  withWebSocket bob1 $ \ws -> do
    commit <- createAddCommit alice1 one2OneConvId [bob]
    void $ sendAndConsumeCommitBundle commit
    let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-welcome"
    n <- awaitMatch isMessage ws
    nPayload n %. "data" `shouldMatch` B8.unpack (Base64.encode (fold commit.welcome))

  -- Make sure the membership info is OK both for the MLS 1-to-1 endpoint and
  -- for the general conversation fetching endpoint.
  let assertOthers :: (HasCallStack, MakesValue other, MakesValue retrievedConv) => other -> retrievedConv -> App ()
      assertOthers other retrievedConv = do
        othersObj <- retrievedConv %. "members.others" & asList
        otherActual <- assertOne othersObj
        otherActual %. "qualified_id" `shouldMatch` (other %. "qualified_id")
  forM_ [(alice, bob), (bob, alice)] $ \(self, other) -> do
    getMLSOne2OneConversation self other `bindResponse` \resp -> do
      retrievedConv <- getJSON 200 resp >>= (%. "conversation")
      assertOthers other retrievedConv
    getConversation self (one2OneConv %. "conversation") `bindResponse` \resp -> do
      retrievedConv <- getJSON 200 resp
      assertOthers other retrievedConv

testMLSOne2OneRemoveClientLocalV5 :: App ()
testMLSOne2OneRemoveClientLocalV5 = withVersion5 Version5 $ do
  [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain]
  conv <- getMLSOne2OneConversationLegacy alice bob >>= getJSON 200

  [alice1, bob1] <- traverse (createMLSClient def) [alice, bob]
  void $ uploadNewKeyPackage def bob1
  convId <- objConvId conv
  createGroup def alice1 convId

  void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle

  withWebSocket alice $ \wsAlice -> do
    _ <- deleteClient bob bob1.client >>= getBody 200
    let predicate n = nPayload n %. "type" `isEqual` "conversation.mls-message-add"
    n <- awaitMatch predicate wsAlice
    shouldMatch (nPayload n %. "conversation") (objId conv)
    shouldMatch (nPayload n %. "from") (objId bob)

    mlsMsg <- asByteString (nPayload n %. "data")

    -- Checks that the remove proposal is consumable by alice
    void $ mlsCliConsume convId def alice1 mlsMsg

    parsedMsg <- showMessage def alice1 mlsMsg
    let leafIndexBob = 1
    -- msg `shouldMatch` "foo"
    parsedMsg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexBob
    parsedMsg %. "message.content.sender.External" `shouldMatchInt` 0

testGetMLSOne2OneUnconnected :: (HasCallStack) => Domain -> App ()
testGetMLSOne2OneUnconnected otherDomain = do
  [alice, bob] <- for [OwnDomain, otherDomain] $ \domain -> randomUser domain def

  bindResponse (getMLSOne2OneConversation alice bob) $ \resp ->
    resp.status `shouldMatchInt` 403

testMLSOne2OneBlocked :: (HasCallStack) => Domain -> App ()
testMLSOne2OneBlocked otherDomain = do
  [alice, bob] <- for [OwnDomain, otherDomain] $ flip randomUser def
  void $ postConnection bob alice >>= getBody 201
  void $ putConnection alice bob "blocked" >>= getBody 200
  void $ getMLSOne2OneConversation alice bob >>= getJSON 403
  void $ getMLSOne2OneConversation bob alice >>= getJSON 403

-- | Alice and Bob are initially connected, but then Alice blocks Bob.
testMLSOne2OneBlockedAfterConnected :: (HasCallStack) => One2OneScenario -> App ()
testMLSOne2OneBlockedAfterConnected scenario = do
  alice <- randomUser OwnDomain def
  let otherDomain = one2OneScenarioUserDomain scenario
      convDomain = one2OneScenarioConvDomain scenario
  bob <- createMLSOne2OnePartner otherDomain alice convDomain
  one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200
  one2OneConvId <- objConvId $ one2OneConv %. "conversation"
  convId <- one2OneConv %. "conversation.qualified_id"
  do
    bobConv <- getMLSOne2OneConversation bob alice >>= getJSON 200
    convId `shouldMatch` (bobConv %. "conversation.qualified_id")

  [alice1, bob1] <- traverse (createMLSClient def) [alice, bob]
  void $ uploadNewKeyPackage def bob1
  resetOne2OneGroup def alice1 one2OneConv
  commit <- createAddCommit alice1 one2OneConvId [bob]
  withWebSocket bob1 $ \ws -> do
    void $ sendAndConsumeCommitBundle commit
    let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-welcome"
    n <- awaitMatch isMessage ws
    nPayload n %. "data" `shouldMatch` B8.unpack (Base64.encode (fold commit.welcome))

  withWebSocket bob1 $ \ws -> do
    -- Alice blocks Bob
    void $ putConnection alice bob "blocked" >>= getBody 200
    -- There is also a proteus 1-to-1 conversation. Neither it nor the MLS
    -- 1-to-1 conversation should get any events.
    awaitAnyEvent 2 ws `shouldMatch` (Nothing :: Maybe Value)
    -- Alice is not in the MLS 1-to-1 conversation given that she has blocked
    -- Bob.
    void $ getMLSOne2OneConversation alice bob >>= getJSON 403

  mp <- createApplicationMessage one2OneConvId bob1 "hello, world, again"
  withWebSocket alice1 $ \ws -> do
    void $ postMLSMessage mp.sender mp.message >>= getJSON 201
    awaitAnyEvent 2 ws `shouldMatch` (Nothing :: Maybe Value)

-- | Alice and Bob are initially connected, then Alice blocks Bob, and finally
-- Alice unblocks Bob.
testMLSOne2OneUnblocked :: (HasCallStack) => One2OneScenario -> App ()
testMLSOne2OneUnblocked scenario = do
  alice <- randomUser OwnDomain def
  let otherDomain = one2OneScenarioUserDomain scenario
      convDomain = one2OneScenarioConvDomain scenario
  bob <- createMLSOne2OnePartner otherDomain alice convDomain
  one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200
  one2OneConvId <- objConvId $ one2OneConv %. "conversation"
  do
    convId <- one2OneConv %. "conversation.qualified_id"
    bobConv <- getMLSOne2OneConversation bob alice >>= getJSON 200
    convId `shouldMatch` (bobConv %. "conversation.qualified_id")

  [alice1, bob1] <- traverse (createMLSClient def) [alice, bob]
  void $ uploadNewKeyPackage def bob1
  resetOne2OneGroup def alice1 one2OneConv
  withWebSocket bob1 $ \ws -> do
    commit <- createAddCommit alice1 one2OneConvId [bob]
    void $ sendAndConsumeCommitBundle commit
    let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-welcome"
    n <- awaitMatch isMessage ws
    nPayload n %. "data" `shouldMatch` B8.unpack (Base64.encode (fold commit.welcome))

  -- Alice blocks Bob
  void $ putConnection alice bob "blocked" >>= getBody 200
  void $ getMLSOne2OneConversation alice bob >>= getJSON 403

  -- Reset the group membership in the test setup as only 'bob1' is left in
  -- reality, even though the test state believes 'alice1' is still part of the
  -- conversation.
  modifyMLSState $ \s ->
    s
      { convs =
          Map.adjust
            ( \conv ->
                conv
                  { members = Set.singleton bob1,
                    memberUsers = Set.singleton bob1.qualifiedUserId
                  }
            )
            one2OneConvId
            s.convs
      }

  -- Bob creates a new client and adds it to the one-to-one conversation just so
  -- that the epoch advances.
  bob2 <- createMLSClient def bob
  void $ uploadNewKeyPackage def bob2
  void $ createAddCommit bob1 one2OneConvId [bob] >>= sendAndConsumeCommitBundle

  -- Alice finally unblocks Bob
  void $ putConnection alice bob "accepted" >>= getBody 200
  void $ getMLSOne2OneConversation alice bob >>= getJSON 200

  -- Alice rejoins via an external commit
  void $ createExternalCommit one2OneConvId alice1 Nothing >>= sendAndConsumeCommitBundle

  -- Check that an application message can get to Bob
  withWebSockets [bob1, bob2] $ \wss -> do
    mp <- createApplicationMessage one2OneConvId alice1 "hello, I've always been here"
    void $ sendAndConsumeMessage mp
    let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-message-add"
    forM_ wss $ \ws -> do
      n <- awaitMatch isMessage ws
      nPayload n %. "data" `shouldMatch` B8.unpack (Base64.encode mp.message)

testGetMLSOne2OneSameTeam :: App ()
testGetMLSOne2OneSameTeam = do
  (alice, _, _) <- createTeam OwnDomain 1
  bob <- addUserToTeam alice
  void $ getMLSOne2OneConversation alice bob >>= getJSON 200

data One2OneScenario
  = -- | Both users are local
    One2OneScenarioLocal
  | -- | One user is remote, conversation is local
    One2OneScenarioLocalConv
  | -- | One user is remote, conversation is remote
    One2OneScenarioRemoteConv

instance TestCases One2OneScenario where
  mkTestCases =
    pure
      [ MkTestCase "[domain=own]" One2OneScenarioLocal,
        MkTestCase "[domain=other;conv=own]" One2OneScenarioLocalConv,
        MkTestCase "[domain=other;conv=other]" One2OneScenarioRemoteConv
      ]

one2OneScenarioUserDomain :: One2OneScenario -> Domain
one2OneScenarioUserDomain One2OneScenarioLocal = OwnDomain
one2OneScenarioUserDomain _ = OtherDomain

one2OneScenarioConvDomain :: One2OneScenario -> Domain
one2OneScenarioConvDomain One2OneScenarioLocal = OwnDomain
one2OneScenarioConvDomain One2OneScenarioLocalConv = OwnDomain
one2OneScenarioConvDomain One2OneScenarioRemoteConv = OtherDomain

testMLSOne2One :: (HasCallStack) => Ciphersuite -> One2OneScenario -> App ()
testMLSOne2One suite scenario = do
  alice <- randomUser OwnDomain def
  let otherDomain = one2OneScenarioUserDomain scenario
      convDomain = one2OneScenarioConvDomain scenario
  bob <- createMLSOne2OnePartner otherDomain alice convDomain
  [alice1, bob1] <- traverse (createMLSClient def {ciphersuites = [suite]}) [alice, bob]
  void $ uploadNewKeyPackage suite bob1

  one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200
  one2OneConvId <- objConvId $ one2OneConv %. "conversation"
  resetOne2OneGroup suite alice1 one2OneConv

  commit <- createAddCommit alice1 one2OneConvId [bob]
  withWebSocket bob1 $ \ws -> do
    void $ sendAndConsumeCommitBundle commit

    let isWelcome n = nPayload n %. "type" `isEqual` "conversation.mls-welcome"
    n <- awaitMatch isWelcome ws
    nPayload n %. "data" `shouldMatch` B8.unpack (Base64.encode (fold commit.welcome))

    void $ awaitMatch isMemberJoinNotif ws

  withWebSocket bob1 $ \ws -> do
    mp <- createApplicationMessage one2OneConvId alice1 "hello, world"
    void $ sendAndConsumeMessage mp
    let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-message-add"
    n <- awaitMatch isMessage ws
    nPayload n %. "data" `shouldMatch` B8.unpack (Base64.encode mp.message)

  -- Send another commit. This verifies that the backend has correctly updated
  -- the cipersuite of this conversation.
  void $ createPendingProposalCommit one2OneConvId alice1 >>= sendAndConsumeCommitBundle

  one2OneConv' <- getMLSOne2OneConversation alice bob >>= getJSON 200
  (suiteCode, _) <- assertOne $ T.hexadecimal (T.pack suite.code)
  one2OneConv' %. "conversation.cipher_suite" `shouldMatchInt` suiteCode

-- | This test verifies that one-to-one conversations are created inside the
-- commit lock. There used to be an issue where a conversation could be
-- partially created at the time of setting its ciphersuite, resulting in an
-- incomplete database entry that would prevent further uses of the
-- conversation.
testMLSGhostOne2OneConv :: App ()
testMLSGhostOne2OneConv = do
  [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain]
  [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob]
  traverse_ (uploadNewKeyPackage def) [bob1, bob2]
  one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200
  one2OneConvId <- objConvId $ one2OneConv %. "conversation"
  resetOne2OneGroup def alice1 one2OneConv

  doneVar <- liftIO $ newEmptyMVar
  let checkConversation =
        liftIO (tryReadMVar doneVar) >>= \case
          Nothing -> do
            bindResponse (getConversation alice (one2OneConv %. "conversation")) $ \resp ->
              resp.status `shouldMatchOneOf` [404 :: Int, 403, 200]

            checkConversation
          Just _ -> pure ()
  checkConversationIO <- appToIO checkConversation

  createCommit <-
    appToIO
      $ void
      $ createAddCommit alice1 one2OneConvId [bob]
      >>= sendAndConsumeCommitBundle

  liftIO $ withAsync checkConversationIO $ \a -> do
    createCommit
    liftIO $ putMVar doneVar ()
    wait a

-- Note [Federated 1:1 MLS Conversations]
-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-- 1:1 Conversations shouldn't work when there is no way for the creator to know
-- the MLS public keys of the backend which will host this conversation. In
-- federation API V2, this will always work and has been tested above. When one
-- of the backends doesn't support federation API v2, the 1:1 conversation can
-- still be created but only by the user whose backend hosts this conversation.

-- | See Note: [Federated 1:1 MLS Conversations]
-- To run locally this test requires federation-v1 docker containers to be up and running.
-- See `deploy/dockerephemeral/run.sh` and comment on `StaticFedDomain` in `Testlib/VersionedFed.hs` for more details.
testMLSFederationV1ConvOnOldBackend :: (HasCallStack) => FedDomain 1 -> App ()
testMLSFederationV1ConvOnOldBackend domain = do
  let cs = Ciphersuite "0x0001"
  alice <- randomUser OwnDomain def
  let createBob = do
        bobCandidate <- randomUser domain def
        connectUsers [alice, bobCandidate]
        getMLSOne2OneConversation alice bobCandidate `bindResponse` \resp -> do
          if resp.status == 533
            then pure bobCandidate
            else createBob

  bob <- createBob
  [alice1, bob1] <- traverse (createMLSClient def {ciphersuites = [cs]}) [alice, bob]
  void $ uploadNewKeyPackage cs alice1

  -- Alice cannot start this conversation because it would exist on Bob's
  -- backend and Alice cannot get the MLS public keys of that backend.
  getMLSOne2OneConversation alice bob `bindResponse` \resp -> do
    fedError <- getJSON 533 resp
    fedError %. "label" `shouldMatch` "federation-version-error"

  conv <- getMLSOne2OneConversationLegacy bob alice >>= getJSON 200
  convId <- objConvId conv
  keys <- getMLSPublicKeys bob >>= getJSON 200
  resetOne2OneGroupGeneric cs bob1 conv keys

  withWebSocket alice1 $ \wsAlice -> do
    commit <- createAddCommit bob1 convId [alice]
    void $ sendAndConsumeCommitBundle commit

    let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-welcome"
    n <- awaitMatch isMessage wsAlice
    nPayload n %. "data" `shouldMatch` B8.unpack (Base64.encode (fold commit.welcome))

  withWebSocket bob1 $ \wsBob -> do
    _ <- deleteClient alice alice1.client >>= getBody 200

    let predicate n = nPayload n %. "type" `isEqual` "conversation.mls-message-add"
    n <- awaitMatch predicate wsBob
    shouldMatch (nPayload n %. "conversation") (objId conv)
    shouldMatch (nPayload n %. "from") (objId alice)

    mlsMsg <- asByteString (nPayload n %. "data")

    -- Checks that the remove proposal is consumable by bob
    void $ mlsCliConsume convId cs bob1 mlsMsg

    parsedMsg <- showMessage cs bob1 mlsMsg
    let leafIndexAlice = 1
    parsedMsg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexAlice
    parsedMsg %. "message.content.sender.External" `shouldMatchInt` 0

-- | See Note: Federated 1:1 MLS Conversations
-- To run locally this test requires federation-v1 docker containers to be up and running.
-- See `deploy/dockerephemeral/run.sh` and comment on `StaticFedDomain` in `Testlib/VersionedFed.hs` for more details.
testMLSFederationV1ConvOnNewBackend :: (HasCallStack) => FedDomain 1 -> App ()
testMLSFederationV1ConvOnNewBackend domain = do
  let cs = Ciphersuite "0x0001"
  alice <- randomUser OwnDomain def
  let createBob = do
        bobCandidate <- randomUser domain def
        connectUsers [alice, bobCandidate]
        getMLSOne2OneConversation alice bobCandidate `bindResponse` \resp -> do
          if resp.status == 200
            then pure bobCandidate
            else createBob

  bob <- createBob
  [alice1, bob1] <- traverse (createMLSClient def {ciphersuites = [cs]}) [alice, bob]
  void $ uploadNewKeyPackage cs bob1

  -- Bob cannot start this conversation because it would exist on Alice's
  -- backend and Bob cannot get the MLS public keys of that backend.
  getMLSOne2OneConversationLegacy bob alice `bindResponse` \resp -> do
    fedError <- getJSON 533 resp
    fedError %. "label" `shouldMatch` "federation-remote-error"

  one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200
  one2OneConvId <- objConvId $ one2OneConv %. "conversation"
  conv <- one2OneConv %. "conversation"
  resetOne2OneGroup cs alice1 one2OneConv

  withWebSocket bob1 $ \wsBob -> do
    commit <- createAddCommit alice1 one2OneConvId [bob]
    void $ sendAndConsumeCommitBundle commit

    let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-welcome"
    n <- awaitMatch isMessage wsBob
    nPayload n %. "data" `shouldMatch` B8.unpack (Base64.encode (fold commit.welcome))

  withWebSocket alice1 $ \wsAlice -> do
    _ <- deleteClient bob bob1.client >>= getBody 200

    let predicate n = nPayload n %. "type" `isEqual` "conversation.mls-message-add"
    n <- awaitMatch predicate wsAlice
    shouldMatch (nPayload n %. "conversation") (objId conv)
    shouldMatch (nPayload n %. "from") (objId bob)

    mlsMsg <- asByteString (nPayload n %. "data")

    -- Checks that the remove proposal is consumable by bob
    void $ mlsCliConsume one2OneConvId cs alice1 mlsMsg

    parsedMsg <- showMessage cs alice1 mlsMsg
    let leafIndexBob = 1
    parsedMsg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexBob
    parsedMsg %. "message.content.sender.External" `shouldMatchInt` 0
