-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2022 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 Federation.End2end where

import API.MLS.Util
import API.User.Util
import Bilge
import Bilge.Assert ((!!!), (<!!), (===))
import Brig.Options qualified as BrigOpts
import Control.Arrow ((&&&))
import Control.Lens hiding ((#))
import Data.Aeson qualified as Aeson
import Data.ByteString.Conversion (toByteString')
import Data.Default
import Data.Domain
import Data.Id
import Data.Json.Util (toBase64Text)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map qualified as Map
import Data.ProtoLens qualified as Protolens
import Data.Qualified
import Data.Range (checked)
import Data.Set qualified as Set
import Federation.Util
import Imports
import System.IO.Temp
import System.Logger qualified as Log
import Test.Tasty
import Test.Tasty.Cannon (TimeoutUnit (..), (#))
import Test.Tasty.Cannon qualified as WS
import Test.Tasty.HUnit
import Util
import Util.Options (Endpoint)
import Wire.API.Asset
import Wire.API.Conversation
import Wire.API.Conversation.Role
import Wire.API.Conversation.Typing
import Wire.API.Event.Conversation
import Wire.API.Event.LeaveReason
import Wire.API.Internal.Notification
import Wire.API.MLS.KeyPackage
import Wire.API.Message
import Wire.API.Routes.MultiTablePaging
import Wire.API.User hiding (assetKey)
import Wire.API.User.Client
import Wire.API.User.Client.Prekey

-- NOTE: These federation tests require deploying two sets of (some) services
-- This might be best left to a kubernetes setup.
--
-- While individual functions can and should be tested in a more unit-testy way,
-- these more end-to-end integration test serve as a way to test the overall
-- network flow
--
-- FUTUREWORK(federation): Add tests for these scenarios (but not here in end2end, but in unit/1-backend-integration tests):
-- - Remote discovery fails
-- - Remote discovery succeeds but server doesn't exist
-- - Remote federator fails to respond in many ways (protocol error, timeout, etc.)
-- - SRV record has two servers but higher priority one always fails
--
-- See https://wearezeta.atlassian.net/browse/SQCORE-914
spec ::
  BrigOpts.Opts ->
  Manager ->
  Brig ->
  Galley ->
  CargoHold ->
  Cannon ->
  Endpoint ->
  Brig ->
  Galley ->
  CargoHold ->
  Cannon ->
  IO TestTree
spec _brigOpts mg brig galley cargohold cannon _federator brigTwo galleyTwo cargoholdTwo cannonTwo =
  pure $
    testGroup
      "federation-end2end-user"
      [ test mg "get users by ids on multiple backends" $ testGetUsersById brig brigTwo,
        test mg "claim client prekey" $ testClaimPrekeySuccess brig brigTwo,
        test mg "claim prekey bundle" $ testClaimPrekeyBundleSuccess brig brigTwo,
        test mg "claim multi-prekey bundle" $ testClaimMultiPrekeyBundleSuccess brig brigTwo,
        test mg "list user clients" $ testListUserClients brig brigTwo,
        test mg "list own conversations" $ testListConversations brig brigTwo galley galleyTwo,
        test mg "remove remote user from a local conversation" $ testRemoveRemoteUserFromLocalConv brig galley brigTwo galleyTwo,
        test mg "leave a remote conversation" $ leaveRemoteConversation brig galley brigTwo galleyTwo,
        test mg "include remote users to new conversation" $ testRemoteUsersInNewConv brig galley brigTwo galleyTwo,
        test mg "send a message to a remote user" $ testSendMessage brig brigTwo galleyTwo cannon,
        test mg "send a message in a remote conversation" $ testSendMessageToRemoteConv brig brigTwo galley galleyTwo cannon,
        test mg "delete user connected to remotes and in conversation with remotes" $ testDeleteUser brig brigTwo galley galleyTwo cannon,
        test mg "download remote asset" $ testRemoteAsset brig brigTwo cargohold cargoholdTwo,
        test mg "claim remote key packages" $ claimRemoteKeyPackages brig brigTwo,
        test mg "remote typing indicator" $
          testRemoteTypingIndicator brig brigTwo galley galleyTwo cannon cannonTwo
      ]

-- | Path covered by this test:
--
-- +------+         +---------+        +---------+          +------+
-- | brig |  http2  |federator| http2  |federator|   http   | brig |
-- |      +-------->+         +------->+         +--------->+      |
-- +------+         +-+-------+        +---------+          +------+
testGetUsersById :: Brig -> Brig -> Http ()
testGetUsersById brig1 brig2 = do
  users <- traverse randomUser [brig1, brig2]
  let self = Imports.head users
      q = ListUsersByIds (map userQualifiedId users)
      expected = sort (map userQualifiedId users)
  post
    ( apiVersion "v3"
        . brig1
        . path "list-users"
        . zUser (userId self)
        . json q
        . contentJson
        . acceptJson
        . expect2xx
    )
    !!! do
      const 200 === statusCode
      const (Just expected)
        === fmap (sort . map profileQualifiedId)
          . responseJsonMaybe

testClaimPrekeySuccess :: Brig -> Brig -> Http ()
testClaimPrekeySuccess brig1 brig2 = do
  self <- randomUser brig1
  user <- randomUser brig2
  let new = defNewClient TemporaryClientType (take 1 somePrekeys) (Imports.head someLastPrekeys)
  c :: Client <- responseJsonError =<< addClient brig2 (userId user) new
  let cpk = ClientPrekey c.clientId (Imports.head somePrekeys)
  let quser = userQualifiedId user
  get
    ( brig1
        . zUser (userId self)
        . paths
          [ "users",
            toByteString' (qDomain quser),
            toByteString' (qUnqualified quser),
            "prekeys",
            toByteString' c.clientId
          ]
    )
    !!! do
      const 200 === statusCode
      const (Just cpk) === responseJsonMaybe

testClaimPrekeyBundleSuccess :: Brig -> Brig -> Http ()
testClaimPrekeyBundleSuccess brig1 brig2 = do
  qself <- userQualifiedId <$> randomUser brig1
  let prekeys = take 5 (zip somePrekeys someLastPrekeys)
  (quser, clients) <- generateClientPrekeys brig2 prekeys
  let sortClients = sortBy (compare `on` prekeyClient)
  get
    ( brig1
        . zUser (qUnqualified qself)
        . paths
          [ "users",
            toByteString' (qDomain quser),
            toByteString' (qUnqualified quser),
            "prekeys"
          ]
        . expect2xx
    )
    !!! do
      const 200 === statusCode
      const (Just (sortClients clients))
        === fmap (sortClients . prekeyClients) . responseJsonMaybe

testClaimMultiPrekeyBundleSuccess :: Brig -> Brig -> Http ()
testClaimMultiPrekeyBundleSuccess brig1 brig2 = do
  let prekeys = zip somePrekeys someLastPrekeys
      (prekeys1, prekeys') = splitAt 5 prekeys
      prekeys2 = take 4 prekeys'
      mkClients = Set.fromList . map prekeyClient
      mkClientMap :: [ClientPrekey] -> Map ClientId (Maybe UncheckedPrekeyBundle)
      mkClientMap = Map.fromList . map (prekeyClient &&& Just . prekeyData)
      qmap :: (Ord a) => [(Qualified a, b)] -> Map Domain (Map a b)
      qmap = fmap Map.fromList . indexQualified . map (sequenceAOf _1)
  c1 <- generateClientPrekeys brig1 prekeys1
  c2 <- generateClientPrekeys brig2 prekeys2
  let uc =
        QualifiedUserClients . qmap $
          [mkClients <$> c1, mkClients <$> c2]
      ucm =
        mkQualifiedUserClientPrekeyMap . fmap mkUserClientPrekeyMap . qmap $
          [mkClientMap <$> c1, mkClientMap <$> c2]
  post
    ( apiVersion "v3"
        . brig1
        . zUser (qUnqualified (fst c1))
        . paths ["users", "list-prekeys"]
        . body (RequestBodyLBS (Aeson.encode uc))
        . contentJson
        . acceptJson
        . expect2xx
    )
    !!! do
      const 200 === statusCode
      const (Just ucm) === responseJsonMaybe

testRemoveRemoteUserFromLocalConv :: Brig -> Galley -> Brig -> Galley -> Http ()
testRemoveRemoteUserFromLocalConv brig1 galley1 brig2 galley2 = do
  alice <- randomUser brig1
  bob <- randomUser brig2
  let aliceId = userQualifiedId alice
  let bobId = userQualifiedId bob

  connectUsersEnd2End brig1 brig2 aliceId bobId

  convId <-
    fmap (.qualifiedId) . responseJsonError @_ @(Conversation GroupConvType)
      =<< createConversation galley1 (userId alice) [bobId]
        <!! const 201 === statusCode

  aliceConvBeforeDelete :: OwnConversation GroupConvType <- responseJsonUnsafe <$> getConversationQualified galley1 (userId alice) convId
  liftIO $ map omQualifiedId (cmOthers (cnvMembers aliceConvBeforeDelete)) @?= [bobId]

  bobConvBeforeDelete :: OwnConversation GroupConvType <- responseJsonUnsafe <$> getConversationQualified galley2 (userId bob) convId
  liftIO $ map omQualifiedId (cmOthers (cnvMembers bobConvBeforeDelete)) @?= [aliceId]

  -- Alice kicks Bob out of the conversation
  delete
    ( galley1
        . paths
          [ "conversations",
            (toByteString' . qDomain) convId,
            (toByteString' . qUnqualified) convId,
            "members",
            (toByteString' . qDomain) bobId,
            (toByteString' . qUnqualified) bobId
          ]
        . zUser (userId alice)
        . zConn "conn"
    )
    !!! const 200 === statusCode

  aliceConvAfterDelete :: OwnConversation GroupConvType <- responseJsonUnsafe <$> getConversationQualified galley1 (userId alice) convId
  liftIO $ map omQualifiedId (cmOthers (cnvMembers aliceConvAfterDelete)) @?= []

  getConversationQualified galley2 (userId bob) convId
    !!! const 404 === statusCode

leaveRemoteConversation :: Brig -> Galley -> Brig -> Galley -> Http ()
leaveRemoteConversation brig1 galley1 brig2 galley2 = do
  alice <- randomUser brig1
  bob <- randomUser brig2
  let aliceId = userQualifiedId alice
  let bobId = userQualifiedId bob

  connectUsersEnd2End brig1 brig2 aliceId bobId

  convId <-
    fmap (.qualifiedId) . responseJsonError @_ @(Conversation GroupConvType)
      =<< createConversation galley1 (userId alice) [bobId]
        <!! const 201 === statusCode

  aliceConvBeforeDelete :: OwnConversation GroupConvType <- responseJsonUnsafe <$> getConversationQualified galley1 (userId alice) convId
  liftIO $ map omQualifiedId (cmOthers (cnvMembers aliceConvBeforeDelete)) @?= [bobId]

  bobConvBeforeDelete :: OwnConversation GroupConvType <- responseJsonUnsafe <$> getConversationQualified galley2 (userId bob) convId
  liftIO $ map omQualifiedId (cmOthers (cnvMembers bobConvBeforeDelete)) @?= [aliceId]

  -- Bob leaves the conversation
  delete
    ( galley2
        . paths
          [ "conversations",
            (toByteString' . qDomain) convId,
            (toByteString' . qUnqualified) convId,
            "members",
            (toByteString' . qDomain) bobId,
            (toByteString' . qUnqualified) bobId
          ]
        . zUser (userId bob)
        . zConn "conn"
    )
    !!! const 200 === statusCode

  aliceConvAfterDelete :: OwnConversation GroupConvType <- responseJsonUnsafe <$> getConversationQualified galley1 (userId alice) convId
  liftIO $ map omQualifiedId (cmOthers (cnvMembers aliceConvAfterDelete)) @?= []

  getConversationQualified galley2 (userId bob) convId
    !!! const 404 === statusCode

-- | This creates a new conversation with a remote user. The test checks that
-- Galleys on both ends of the federation see the same conversation members.
testRemoteUsersInNewConv :: Brig -> Galley -> Brig -> Galley -> Http ()
testRemoteUsersInNewConv brig1 galley1 brig2 galley2 = do
  alice <- randomUser brig1
  bob <- randomUser brig2

  connectUsersEnd2End brig1 brig2 (userQualifiedId alice) (userQualifiedId bob)
  convId <-
    fmap (.qualifiedId) . responseJsonError @_ @(Conversation GroupConvType)
      =<< createConversation galley1 (userId alice) [userQualifiedId bob]
        <!! const 201 === statusCode

  -- test GET /conversations/:backend1Domain/:cnv
  testQualifiedGetConversation galley1 "galley1" alice bob convId
  testQualifiedGetConversation galley2 "galley2" bob alice convId

-- | Test a scenario of a two-user conversation.
testQualifiedGetConversation ::
  -- | A Galley to get information from
  Galley ->
  -- | A message to display during response parsing
  String ->
  -- | The user making the request
  User ->
  -- | The other user in the conversation
  User ->
  -- | A qualified conversation ID
  Qualified ConvId ->
  Http ()
testQualifiedGetConversation galley msg alice bob qconvId = do
  res <- getConvQualified galley (userId alice) qconvId <!! (const 200 === statusCode)
  let conv = responseJsonUnsafeWithMsg @(OwnConversation GroupConvType) (msg <> " - get /conversations/domain/cnvId") res
      actual = cmOthers $ cnvMembers conv
      expected = [OtherMember (userQualifiedId bob) Nothing roleNameWireAdmin]
  liftIO $ actual @?= expected

testListUserClients :: Brig -> Brig -> Http ()
testListUserClients brig1 brig2 = do
  alice <- randomUser brig1
  bob <- randomUser brig2
  let prekeys = take 3 (zip somePrekeys someLastPrekeys)
  let mkClient (pk, lpk) = defNewClient PermanentClientType [pk] lpk
      nclients = map mkClient prekeys
  clients <- traverse (responseJsonError <=< addClient brig2 (userId bob)) nclients
  resp <- getUserClientsQualified brig1 (userId alice) (qDomain (userQualifiedId bob)) (userId bob)
  let expected = map toPubClient clients
      actual = responseJsonUnsafe resp
  liftIO $ Set.fromList actual @?= Set.fromList expected

testListConversations :: Brig -> Brig -> Galley -> Galley -> Http ()
testListConversations brig1 brig2 galley1 galley2 = do
  alice <- randomUser brig1
  bob <- randomUser brig2

  connectUsersEnd2End brig1 brig2 (userQualifiedId alice) (userQualifiedId bob)

  -- create two group conversations with alice & bob, on each of domain1, domain2
  cnv1 <-
    responseJsonError @_ @(Conversation GroupConvType)
      =<< createConversation galley1 (userId alice) [userQualifiedId bob]
        <!! const 201 === statusCode
  cnv2 <-
    responseJsonError @_ @(Conversation GroupConvType)
      =<< createConversation galley2 (userId bob) [userQualifiedId alice]
        <!! const 201 === statusCode

  --  Expect both group conversations containing alice and bob
  --  to pop up for alice (on galley1)
  --  when she request her own conversations ( GET /conversations )
  debug "list all convs galley 1..."
  -- From Alice's point of view
  -- both conversations should show her as the self member and bob as Othermember.
  let expected = cnv1
  rs <- listConvIdsFirstPage galley1 (userId alice) <!! (const 200 === statusCode)
  (page :: ConvIdsPage) <- responseJsonError rs
  liftIO $ assertBool "conversations should fit in a single page" (not (mtpHasMore page))
  cids <- liftIO $ case checked (mtpResults page) of
    Nothing -> assertFailure "too many conversations"
    Just r -> pure r
  (cs :: [OwnConversation GroupConvType]) <-
    (fmap crFound . responseJsonError)
      =<< listConvs galley1 (userId alice) cids <!! (const 200 === statusCode)
  let c1 = find ((== cnv1.qualifiedId) . cnvQualifiedId) cs
  let c2 = find ((== cnv2.qualifiedId) . cnvQualifiedId) cs
  liftIO . forM_ [c1, c2] $ \mActual -> do
    case mActual of
      Nothing -> assertFailure "An expected conversation was not found"
      Just actual -> do
        let actualMemIds = Set.fromList $ actual.cnvMembers.cmSelf.memId : map (.omQualifiedId) actual.cnvMembers.cmOthers
            expectedMemIds = Set.fromList $ (maybe [] (\self -> [self.memId]) expected.members.self) <> map (.omQualifiedId) expected.members.others
        assertEqual "member mismatch" actualMemIds expectedMemIds

debug :: Text -> Http ()
debug text = do
  l <- Log.new Log.defSettings
  liftIO $ Log.warn l (Log.msg text)

-- bob creates a conversation on domain 2 with alice on domain 1, then sends a
-- message to alice
testSendMessage :: Brig -> Brig -> Galley -> Cannon -> Http ()
testSendMessage brig1 brig2 galley2 cannon1 = do
  -- create alice user and client on domain 1
  alice <- randomUser brig1
  aliceClient :: Client <-
    responseJsonUnsafe
      <$> addClient
        brig1
        (userId alice)
        (defNewClient PermanentClientType [] (Imports.head someLastPrekeys))

  -- create bob user and client on domain 2
  bob <- randomUser brig2
  bobClient :: Client <-
    responseJsonUnsafe
      <$> addClient
        brig2
        (userId bob)
        (defNewClient PermanentClientType [] (someLastPrekeys !! 1))

  connectUsersEnd2End brig1 brig2 (userQualifiedId alice) (userQualifiedId bob)

  -- create conversation on domain 2
  convId <-
    fmap (qUnqualified . (.qualifiedId)) . responseJsonError @_ @(Conversation GroupConvType)
      =<< createConversation galley2 (userId bob) [userQualifiedId alice]
        <!! const 201 === statusCode

  -- send a message from bob at domain 2 to alice at domain 1
  let qconvId = Qualified convId (qDomain (userQualifiedId bob))
      msgText = "🕊️"
      rcpts = [(userQualifiedId alice, aliceClient.clientId, msgText)]
      msg = mkQualifiedOtrPayload bobClient.clientId rcpts "" MismatchReportAll

  WS.bracketR cannon1 (userId alice) $ \wsAlice -> do
    post
      ( galley2
          . paths
            [ "conversations",
              toByteString' (qDomain qconvId),
              toByteString' convId,
              "proteus",
              "messages"
            ]
          . zUser (userId bob)
          . zConn "conn"
          . header "Z-Type" "access"
          . contentProtobuf
          . acceptJson
          . bytes (Protolens.encodeMessage msg)
      )
      !!! const 201 === statusCode

    -- verify that alice received the message
    WS.assertMatch_ (5 # Second) wsAlice $ \n -> do
      let e = NonEmpty.head (WS.unpackPayload n)
      ntfTransient n @?= False
      evtConv e @?= qconvId
      evtType e @?= OtrMessageAdd
      evtFrom e @?= EventFromUser (userQualifiedId bob)
      evtData e
        @?= EdOtrMessage
          (OtrMessage bobClient.clientId aliceClient.clientId (toBase64Text msgText) (Just ""))

-- alice creates a conversation on domain 1 with bob on domain 2, then bob
-- sends a message to alice
testSendMessageToRemoteConv :: Brig -> Brig -> Galley -> Galley -> Cannon -> Http ()
testSendMessageToRemoteConv brig1 brig2 galley1 galley2 cannon1 = do
  -- create alice user and client on domain 1
  alice <- randomUser brig1
  aliceClient :: Client <-
    responseJsonError
      =<< addClient brig1 (userId alice) (defNewClient PermanentClientType [] (Imports.head someLastPrekeys))
        <!! const 201 === statusCode

  -- create bob user and client on domain 2
  bob <- randomUser brig2
  bobClient :: Client <-
    responseJsonError
      =<< addClient brig2 (userId bob) (defNewClient PermanentClientType [] (someLastPrekeys !! 1))
        <!! const 201 === statusCode

  connectUsersEnd2End brig1 brig2 (userQualifiedId alice) (userQualifiedId bob)

  -- create conversation on domain 1
  convId <-
    fmap (qUnqualified . (.qualifiedId)) . responseJsonError @_ @(Conversation GroupConvType)
      =<< createConversation galley1 (userId alice) [userQualifiedId bob]
        <!! const 201 === statusCode

  -- send a message from bob at domain 2 to alice at domain 1
  let qconvId = Qualified convId (qDomain (userQualifiedId alice))
      msgText = "🕊️"
      rcpts = [(userQualifiedId alice, aliceClient.clientId, msgText)]
      msg = mkQualifiedOtrPayload bobClient.clientId rcpts "" MismatchReportAll

  WS.bracketR cannon1 (userId alice) $ \wsAlice -> do
    post
      ( galley2
          . paths
            [ "conversations",
              toByteString' (qDomain qconvId),
              toByteString' convId,
              "proteus",
              "messages"
            ]
          . zUser (userId bob)
          . zConn "conn"
          . header "Z-Type" "access"
          . contentProtobuf
          . acceptJson
          . bytes (Protolens.encodeMessage msg)
      )
      !!! const 201 === statusCode

    -- verify that alice received the message
    WS.assertMatch_ (5 # Second) wsAlice $ \n -> do
      let e = NonEmpty.head (WS.unpackPayload n)
      ntfTransient n @?= False
      evtConv e @?= qconvId
      evtType e @?= OtrMessageAdd
      evtFrom e @?= EventFromUser (userQualifiedId bob)
      evtData e
        @?= EdOtrMessage
          (OtrMessage bobClient.clientId aliceClient.clientId (toBase64Text msgText) (Just ""))

testDeleteUser :: Brig -> Brig -> Galley -> Galley -> Cannon -> Http ()
testDeleteUser brig1 brig2 galley1 galley2 cannon1 = do
  alice <- userQualifiedId <$> randomUser brig1
  bobDel <- userQualifiedId <$> randomUser brig2

  connectUsersEnd2End brig1 brig2 alice bobDel

  conv1 <-
    fmap (.qualifiedId) . responseJsonError @_ @(Conversation GroupConvType)
      =<< createConversation galley1 (qUnqualified alice) [bobDel]
        <!! const 201 === statusCode

  conv2 <-
    fmap (.qualifiedId) . responseJsonError @_ @(Conversation GroupConvType)
      =<< createConversation galley2 (qUnqualified bobDel) [alice]
        <!! const 201 === statusCode

  WS.bracketR cannon1 (qUnqualified alice) $ \wsAlice -> do
    deleteUser (qUnqualified bobDel) (Just defPassword) brig2 !!! const 200 === statusCode
    WS.assertMatch_ (5 # Second) wsAlice $ matchDeleteUserNotification bobDel
    WS.assertMatch_ (5 # Second) wsAlice $ matchConvLeaveNotification conv1 bobDel [bobDel] EdReasonLeft
    WS.assertMatch_ (5 # Second) wsAlice $ matchConvLeaveNotification conv2 bobDel [bobDel] EdReasonLeft

testRemoteAsset :: Brig -> Brig -> CargoHold -> CargoHold -> Http ()
testRemoteAsset brig1 brig2 ch1 ch2 = do
  alice <- userQualifiedId <$> randomUser brig1
  bob <- userQualifiedId <$> randomUser brig2

  let sts = defAssetSettings & setAssetPublic .~ True
  ast <- responseJsonError =<< uploadAsset ch2 (qUnqualified bob) sts "hello world"
  let qkey = view assetKey ast

  downloadAsset ch1 (qUnqualified alice) qkey
    !!! do
      const 200 === statusCode
      const (Just "hello world") === responseBody

claimRemoteKeyPackages :: Brig -> Brig -> Http ()
claimRemoteKeyPackages brig1 brig2 = do
  alice <- userQualifiedId <$> randomUser brig1

  bob <- userQualifiedId <$> randomUser brig2
  bobClients :: [Client] <- for (take 3 someLastPrekeys) $ \lpk -> do
    let new = defNewClient PermanentClientType [] lpk
    responseJsonError =<< addClient brig2 (qUnqualified bob) new

  withSystemTempDirectory "mls" $ \tmp ->
    for_ bobClients $ \c ->
      uploadKeyPackages brig2 tmp def bob c.clientId 5

  bundle :: KeyPackageBundle <-
    responseJsonError
      =<< post
        ( brig1
            . paths ["mls", "key-packages", "claim", toByteString' (qDomain bob), toByteString' (qUnqualified bob)]
            . queryItem "ciphersuite" "0x0001"
            . zUser (qUnqualified alice)
        )
        <!! const 200 === statusCode

  liftIO $
    Set.map (\e -> (e.user, e.client)) bundle.entries
      @?= Set.fromList [(bob, c.clientId) | c <- bobClients]

testRemoteTypingIndicator ::
  (HasCallStack) =>
  Brig ->
  Brig ->
  Galley ->
  Galley ->
  Cannon ->
  Cannon ->
  Http ()
testRemoteTypingIndicator brig1 brig2 galley1 galley2 cannon1 cannon2 = do
  alice <- randomUser brig1
  bob <- randomUser brig2

  connectUsersEnd2End brig1 brig2 (userQualifiedId alice) (userQualifiedId bob)

  cnv <-
    responseJsonError @_ @(Conversation GroupConvType)
      =<< createConversation galley1 (userId alice) [userQualifiedId bob]
        <!! const 201 === statusCode
  let isTyping g u s =
        post
          ( g
              . paths
                [ "conversations",
                  toByteString' (qDomain cnv.qualifiedId),
                  toByteString' (qUnqualified cnv.qualifiedId),
                  "typing"
                ]
              . zUser (userId u)
              . zConn "conn"
              . json s
          )
          !!! const 200 === statusCode
  let checkEvent ws u s =
        WS.assertMatch_ (8 # Second) ws $ \n -> do
          let e = NonEmpty.head (WS.unpackPayload n)
          ntfTransient n @?= True
          evtConv e @?= cnv.qualifiedId
          evtType e @?= Typing
          evtFrom e @?= EventFromUser (userQualifiedId u)
          evtData e @?= EdTyping s

  -- -- alice is typing, bob gets events
  WS.bracketR cannon2 (userId bob) $ \wsBob -> do
    isTyping galley1 alice StartedTyping
    checkEvent wsBob alice StartedTyping
    isTyping galley1 alice StoppedTyping
    checkEvent wsBob alice StoppedTyping

  -- bob is typing, alice gets events
  WS.bracketR cannon1 (userId alice) $ \wsAlice -> do
    isTyping galley2 bob StartedTyping
    checkEvent wsAlice bob StartedTyping
    isTyping galley2 bob StoppedTyping
    checkEvent wsAlice bob StoppedTyping
