diff --git a/CHANGELOG-draft.md b/CHANGELOG-draft.md index e8acfea30e..77c1171514 100644 --- a/CHANGELOG-draft.md +++ b/CHANGELOG-draft.md @@ -18,3 +18,5 @@ THIS FILE ACCUMULATES THE RELEASE NOTES FOR THE UPCOMING RELEASE. ## Internal changes ## Federation changes + +* Ensure clients only receive messages meant for them in remote convs (#1739) \ No newline at end of file diff --git a/libs/tasty-cannon/package.yaml b/libs/tasty-cannon/package.yaml index b325f84156..972dafbd11 100644 --- a/libs/tasty-cannon/package.yaml +++ b/libs/tasty-cannon/package.yaml @@ -12,6 +12,7 @@ dependencies: - aeson - async - base >=4.6 && <5 +- bilge - bytestring - bytestring-conversion - data-timeout diff --git a/libs/tasty-cannon/src/Test/Tasty/Cannon.hs b/libs/tasty-cannon/src/Test/Tasty/Cannon.hs index 66be04fbf2..94c1f68da0 100644 --- a/libs/tasty-cannon/src/Test/Tasty/Cannon.hs +++ b/libs/tasty-cannon/src/Test/Tasty/Cannon.hs @@ -26,16 +26,22 @@ module Test.Tasty.Cannon -- * WebSockets WebSocket, connect, + connectAsClient, close, bracket, + bracketAsClient, bracketN, + bracketAsClientN, -- ** Random Connection IDs connectR, + connectAsClientR, bracketR, + bracketAsClientR, bracketR2, bracketR3, bracketRN, + bracketAsClientRN, -- * Awaiting & Asserting on Notifications MatchTimeout (..), @@ -63,6 +69,7 @@ module Test.Tasty.Cannon ) where +import Bilge.Request (queryItem) import Control.Concurrent.Async import Control.Concurrent.Timeout hiding (threadDelay) import Control.Exception (asyncExceptionFromException, throwIO) @@ -96,10 +103,16 @@ data WebSocket = WebSocket } connect :: MonadIO m => Cannon -> UserId -> ConnId -> m WebSocket -connect can uid cid = liftIO $ do +connect can uid = connectAsMaybeClient can uid Nothing + +connectAsClient :: MonadIO m => Cannon -> UserId -> ClientId -> ConnId -> m WebSocket +connectAsClient can uid client = connectAsMaybeClient can uid (Just client) + +connectAsMaybeClient :: MonadIO m => Cannon -> UserId -> Maybe ClientId -> ConnId -> m WebSocket +connectAsMaybeClient can uid client conn = liftIO $ do nchan <- newTChanIO latch <- newEmptyMVar - wsapp <- run can uid cid (clientApp nchan latch) + wsapp <- run can uid client conn (clientApp nchan latch) return $ WebSocket nchan latch wsapp close :: MonadIO m => WebSocket -> m () @@ -114,7 +127,19 @@ bracket :: ConnId -> (WebSocket -> m a) -> m a -bracket can uid cid = Catch.bracket (connect can uid cid) close +bracket can uid conn = + Catch.bracket (connect can uid conn) close + +bracketAsClient :: + (MonadMask m, MonadIO m) => + Cannon -> + UserId -> + ClientId -> + ConnId -> + (WebSocket -> m a) -> + m a +bracketAsClient can uid client conn = + Catch.bracket (connectAsClient can uid client conn) close bracketN :: (MonadIO m, MonadMask m) => @@ -127,16 +152,35 @@ bracketN c us f = go [] us go wss [] = f (reverse wss) go wss ((x, y) : xs) = bracket c x y (\ws -> go (ws : wss) xs) +bracketAsClientN :: + (MonadMask m, MonadIO m) => + Cannon -> + [(UserId, ClientId, ConnId)] -> + ([WebSocket] -> m a) -> + m a +bracketAsClientN c us f = go [] us + where + go wss [] = f (reverse wss) + go wss ((x, y, z) : xs) = bracketAsClient c x y z (\ws -> go (ws : wss) xs) + -- Random Connection IDs connectR :: MonadIO m => Cannon -> UserId -> m WebSocket connectR can uid = randomConnId >>= connect can uid +connectAsClientR :: MonadIO m => Cannon -> UserId -> ClientId -> m WebSocket +connectAsClientR can uid clientId = randomConnId >>= connectAsClient can uid clientId + bracketR :: (MonadIO m, MonadMask m) => Cannon -> UserId -> (WebSocket -> m a) -> m a bracketR can usr f = do cid <- randomConnId bracket can usr cid f +bracketAsClientR :: (MonadIO m, MonadMask m) => Cannon -> UserId -> ClientId -> (WebSocket -> m a) -> m a +bracketAsClientR can usr clientId f = do + connId <- randomConnId + bracketAsClient can usr clientId connId f + bracketR2 :: (MonadIO m, MonadMask m) => Cannon -> @@ -174,6 +218,17 @@ bracketRN c us f = go [] us go wss [] = f (reverse wss) go wss (x : xs) = bracketR c x (\ws -> go (ws : wss) xs) +bracketAsClientRN :: + (MonadIO m, MonadMask m) => + Cannon -> + [(UserId, ClientId)] -> + ([WebSocket] -> m a) -> + m a +bracketAsClientRN can us f = go [] us + where + go wss [] = f (reverse wss) + go wss ((u, c) : xs) = bracketAsClientR can u c (\ws -> go (ws : wss) xs) + ----------------------------------------------------------------------------- -- Awaiting & Asserting on Notifications @@ -336,8 +391,8 @@ randomConnId = liftIO $ do -- | Start a client thread in 'Async' that opens a web socket to a Cannon, wait -- for the connection to register with Gundeck, and return the 'Async' thread. -run :: MonadIO m => Cannon -> UserId -> ConnId -> WS.ClientApp () -> m (Async ()) -run (($ Http.defaultRequest) -> ca) uid cid app = liftIO $ do +run :: MonadIO m => Cannon -> UserId -> Maybe ClientId -> ConnId -> WS.ClientApp () -> m (Async ()) +run cannon@(($ Http.defaultRequest) -> ca) uid client connId app = liftIO $ do latch <- newEmptyMVar wsapp <- async $ @@ -359,9 +414,10 @@ run (($ Http.defaultRequest) -> ca) uid cid app = liftIO $ do where caHost = C.unpack (Http.host ca) caPort = Http.port ca - caPath = "/await" ++ C.unpack (Http.queryString ca) + caPath = "/await" ++ C.unpack caQuery + caQuery = Http.queryString . cannon . maybe id (queryItem "client" . toByteString') client $ Http.defaultRequest caOpts = WS.defaultConnectionOptions - caHdrs = [("Z-User", toByteString' uid), ("Z-Connection", toByteString' cid)] + caHdrs = [("Z-User", toByteString' uid), ("Z-Connection", toByteString' connId)] numRetries = 30 waitForRegistry 0 = throwIO $ RegistrationTimeout numRetries waitForRegistry (n :: Int) = do @@ -369,7 +425,7 @@ run (($ Http.defaultRequest) -> ca) uid cid app = liftIO $ do let ca' = ca { method = "HEAD", - path = "/i/presences/" <> toByteString' uid <> "/" <> toByteString' cid + path = "/i/presences/" <> toByteString' uid <> "/" <> toByteString' connId } res <- httpLbs ca' man unless (responseStatus res == status200) $ do diff --git a/libs/tasty-cannon/tasty-cannon.cabal b/libs/tasty-cannon/tasty-cannon.cabal index 2e8fd861df..11de15b68e 100644 --- a/libs/tasty-cannon/tasty-cannon.cabal +++ b/libs/tasty-cannon/tasty-cannon.cabal @@ -4,7 +4,7 @@ cabal-version: 1.12 -- -- see: https://github.com/sol/hpack -- --- hash: b7fca22ffa51fd956424d50af91793bd16e3d1b5170e6ccc48b26bf821793358 +-- hash: 3e4d6b79f93c721b5df897b6653023feaa197910713bf3a2a759ea37ca05427f name: tasty-cannon version: 0.4.0 @@ -30,6 +30,7 @@ library aeson , async , base >=4.6 && <5 + , bilge , bytestring , bytestring-conversion , data-timeout diff --git a/services/galley/src/Galley/API/Federation.hs b/services/galley/src/Galley/API/Federation.hs index 5bb4c39dee..dadb5c8d2e 100644 --- a/services/galley/src/Galley/API/Federation.hs +++ b/services/galley/src/Galley/API/Federation.hs @@ -16,28 +16,37 @@ -- with this program. If not, see . module Galley.API.Federation where +import Control.Lens (itraversed, (<.>)) import Control.Monad.Catch (throwM) import Control.Monad.Except (runExceptT) +import Data.ByteString.Conversion (toByteString') import Data.Containers.ListUtils (nubOrd) import Data.Domain -import Data.Id (ConvId) +import Data.Id (ConvId, UserId) import Data.Json.Util (Base64ByteString (..)) import Data.List1 (list1) +import qualified Data.Map as Map +import Data.Map.Lens (toMapOf) import Data.Qualified (Qualified (..)) +import qualified Data.Set as Set import Data.Tagged import qualified Data.Text.Lazy as LT import Galley.API.Error (invalidPayload) import qualified Galley.API.Mapping as Mapping -import Galley.API.Message (UserType (..), postQualifiedOtrMessage) +import Galley.API.Message (MessageMetadata (..), UserType (..), postQualifiedOtrMessage, sendLocalMessages) import qualified Galley.API.Update as API import Galley.API.Util (fromRegisterConversation, pushConversationEvent, viewFederationDomain) import Galley.App (Galley) import qualified Galley.Data as Data +import Galley.Types.Conversations.Members (InternalMember (..), LocalMember) import Imports import Servant (ServerT) import Servant.API.Generic (ToServantApi) import Servant.Server.Generic (genericServerT) -import Wire.API.Conversation.Member (OtherMember (..), memId) +import qualified System.Logger.Class as Log +import qualified Wire.API.Conversation as Public +import Wire.API.Conversation.Member (OtherMember (..)) +import qualified Wire.API.Conversation.Role as Public import Wire.API.Event.Conversation import Wire.API.Federation.API.Galley ( ConversationMemberUpdate (..), @@ -52,6 +61,7 @@ import Wire.API.Federation.API.Galley ) import qualified Wire.API.Federation.API.Galley as FederationAPIGalley import Wire.API.ServantProto (FromProto (..)) +import Wire.API.User.Client (userClientMap) federationSitemap :: ServerT (ToServantApi FederationAPIGalley.Api) Galley federationSitemap = @@ -83,7 +93,7 @@ registerConversation rc = do (rcOrigUserId rc) (rcTime rc) (EdConversation c) - pushConversationEvent Nothing event [memId mem] [] + pushConversationEvent Nothing event [Public.memId mem] [] getConversations :: GetConversationsRequest -> Galley GetConversationsResponse getConversations (GetConversationsRequest qUid gcrConvIds) = do @@ -144,10 +154,49 @@ leaveConversation requestingDomain lc = do API.removeMemberFromLocalConv leaver Nothing (lcConvId lc) leaver -- FUTUREWORK: report errors to the originating backend +-- FUTUREWORK: error handling for missing / mismatched clients receiveMessage :: Domain -> RemoteMessage ConvId -> Galley () -receiveMessage domain = - API.postRemoteToLocal - . fmap (Tagged . (`Qualified` domain)) +receiveMessage domain rmUnqualified = do + let rm = fmap (Tagged . (`Qualified` domain)) rmUnqualified + let convId = unTagged $ rmConversation rm + msgMetadata = + MessageMetadata + { mmNativePush = rmPush rm, + mmTransient = rmTransient rm, + mmNativePriority = rmPriority rm, + mmData = rmData rm + } + recipientMap = userClientMap $ rmRecipients rm + msgs = toMapOf (itraversed <.> itraversed) recipientMap + (members, allMembers) <- Data.filterRemoteConvMembers (Map.keys recipientMap) convId + unless allMembers $ + Log.warn $ + Log.field "conversation" (toByteString' (qUnqualified convId)) + Log.~~ Log.field "domain" (toByteString' (qDomain convId)) + Log.~~ Log.msg + ( "Attempt to send remote message to local\ + \ users not in the conversation" :: + ByteString + ) + localMembers <- sequence $ Map.fromSet mkLocalMember (Set.fromList members) + void $ sendLocalMessages (rmTime rm) (rmSender rm) (rmSenderClient rm) Nothing convId localMembers msgMetadata msgs + where + -- FUTUREWORK: https://wearezeta.atlassian.net/browse/SQCORE-875 + mkLocalMember :: UserId -> Galley LocalMember + mkLocalMember m = + pure $ + InternalMember + { memId = m, + memService = Nothing, + memOtrMuted = False, + memOtrMutedStatus = Nothing, + memOtrMutedRef = Nothing, + memOtrArchived = False, + memOtrArchivedRef = Nothing, + memHidden = False, + memHiddenRef = Nothing, + memConvRoleName = Public.roleNameWireMember + } sendMessage :: Domain -> MessageSendRequest -> Galley MessageSendResponse sendMessage originDomain msr = do diff --git a/services/galley/src/Galley/API/Message.hs b/services/galley/src/Galley/API/Message.hs index 058142bce0..23ac50dafc 100644 --- a/services/galley/src/Galley/API/Message.hs +++ b/services/galley/src/Galley/API/Message.hs @@ -309,10 +309,10 @@ sendMessages :: Galley QualifiedUserClients sendMessages now sender senderClient mconn conv localMemberMap metadata messages = do localDomain <- viewFederationDomain - let messageMap = byDomain messages + let messageMap = byDomain $ fmap toBase64Text messages let send dom | localDomain == dom = - sendLocalMessages now sender senderClient mconn conv localMemberMap metadata + sendLocalMessages now sender senderClient mconn (Qualified conv localDomain) localMemberMap metadata | otherwise = sendRemoteMessages dom now sender senderClient conv metadata mkQualifiedUserClientsByDomain <$> Map.traverseWithKey send messageMap @@ -328,17 +328,17 @@ sendLocalMessages :: Qualified UserId -> ClientId -> Maybe ConnId -> - ConvId -> + Qualified ConvId -> Map UserId LocalMember -> MessageMetadata -> - Map (UserId, ClientId) ByteString -> + Map (UserId, ClientId) Text -> Galley (Set (UserId, ClientId)) sendLocalMessages now sender senderClient mconn conv localMemberMap metadata localMessages = do localDomain <- viewFederationDomain let events = localMessages & reindexed snd itraversed %@~ newMessageEvent - (Qualified conv localDomain) + conv sender senderClient (mmData metadata) @@ -356,18 +356,18 @@ sendRemoteMessages :: ClientId -> ConvId -> MessageMetadata -> - Map (UserId, ClientId) ByteString -> + Map (UserId, ClientId) Text -> Galley (Set (UserId, ClientId)) sendRemoteMessages domain now sender senderClient conv metadata messages = handle <=< runExceptT $ do let rcpts = foldr - (\((u, c), t) -> Map.insertWith (<>) u (Map.singleton c (toBase64Text t))) + (\((u, c), t) -> Map.insertWith (<>) u (Map.singleton c t)) mempty (Map.assocs messages) rm = FederatedGalley.RemoteMessage { FederatedGalley.rmTime = now, - FederatedGalley.rmData = Just (toBase64Text (mmData metadata)), + FederatedGalley.rmData = mmData metadata, FederatedGalley.rmSender = sender, FederatedGalley.rmSenderClient = senderClient, FederatedGalley.rmConversation = conv, @@ -420,21 +420,29 @@ newUserPush p = MessagePush {userPushes = pure p, botPushes = mempty} newBotPush :: BotMember -> Event -> MessagePush newBotPush b e = MessagePush {userPushes = mempty, botPushes = pure (b, e)} -runMessagePush :: ConvId -> MessagePush -> Galley () +runMessagePush :: Qualified ConvId -> MessagePush -> Galley () runMessagePush cnv mp = do pushSome (userPushes mp) - void . forkIO $ do - gone <- External.deliver (botPushes mp) - mapM_ (deleteBot cnv . botMemId) gone - -newMessageEvent :: Qualified ConvId -> Qualified UserId -> ClientId -> ByteString -> UTCTime -> ClientId -> ByteString -> Event -newMessageEvent convId sender senderClient dat time recieverClient cipherText = + pushToBots (botPushes mp) + where + pushToBots :: [(BotMember, Event)] -> Galley () + pushToBots pushes = do + localDomain <- viewFederationDomain + if localDomain /= qDomain cnv + then unless (null pushes) $ do + Log.warn $ Log.msg ("Ignoring messages for local bots in a remote conversation" :: ByteString) . Log.field "conversation" (show cnv) + else void . forkIO $ do + gone <- External.deliver pushes + mapM_ (deleteBot (qUnqualified cnv) . botMemId) gone + +newMessageEvent :: Qualified ConvId -> Qualified UserId -> ClientId -> Maybe Text -> UTCTime -> ClientId -> Text -> Event +newMessageEvent convId sender senderClient dat time receiverClient cipherText = Event OtrMessageAdd convId sender time . EdOtrMessage $ OtrMessage { otrSender = senderClient, - otrRecipient = recieverClient, - otrCiphertext = toBase64Text cipherText, - otrData = Just $ toBase64Text dat + otrRecipient = receiverClient, + otrCiphertext = cipherText, + otrData = dat } newMessagePush :: @@ -466,7 +474,7 @@ data MessageMetadata = MessageMetadata { mmNativePush :: Bool, mmTransient :: Bool, mmNativePriority :: Maybe Priority, - mmData :: ByteString + mmData :: Maybe Text } deriving (Eq, Ord, Show) @@ -476,7 +484,7 @@ qualifiedNewOtrMetadata msg = { mmNativePush = qualifiedNewOtrNativePush msg, mmTransient = qualifiedNewOtrTransient msg, mmNativePriority = qualifiedNewOtrNativePriority msg, - mmData = qualifiedNewOtrData msg + mmData = Just . toBase64Text $ qualifiedNewOtrData msg } -- unqualified diff --git a/services/galley/src/Galley/API/Update.hs b/services/galley/src/Galley/API/Update.hs index 2063333c1e..69dda9944a 100644 --- a/services/galley/src/Galley/API/Update.hs +++ b/services/galley/src/Galley/API/Update.hs @@ -49,7 +49,6 @@ module Galley.API.Update postOtrBroadcastH, postProtoOtrBroadcastH, isTypingH, - postRemoteToLocal, -- * External Services addServiceH, @@ -66,7 +65,6 @@ import Control.Lens import Control.Monad.Catch import Control.Monad.State import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE, withExceptT) -import Data.ByteString.Conversion (toByteString') import Data.Code import Data.Domain (Domain) import Data.Either.Extra (mapRight) @@ -110,7 +108,6 @@ import Network.HTTP.Types import Network.Wai import Network.Wai.Predicate hiding (and, failure, setStatus, _1, _2) import Network.Wai.Utilities -import qualified System.Logger.Class as Log import Wire.API.Conversation (InviteQualified (invQRoleName)) import qualified Wire.API.Conversation as Public import qualified Wire.API.Conversation.Code as Public @@ -124,7 +121,6 @@ import Wire.API.ErrorDescription ) import qualified Wire.API.ErrorDescription as Public import qualified Wire.API.Event.Conversation as Public -import Wire.API.Federation.API.Galley (RemoteMessage (..)) import qualified Wire.API.Federation.API.Galley as FederatedGalley import Wire.API.Federation.Error import qualified Wire.API.Message as Public @@ -283,8 +279,7 @@ uncheckedUpdateConversationAccess body usr zcon conv (currentAccess, targetAcces case removedUsers of [] -> return () x : xs -> do - -- FUTUREWORK: deal with remote members, too, see removeMembers (Jira - -- SQCORE-903) + -- FUTUREWORK: deal with remote members, too, see removeMembers (Jira SQCORE-903) e <- Data.removeLocalMembersFromLocalConv localDomain conv (Qualified usr localDomain) (x :| xs) -- push event to all clients, including zconn -- since updateConversationAccess generates a second (member removal) event here @@ -822,59 +817,6 @@ postNewOtrMessage utype usr con cnv val msg = do gone <- External.deliver toBots mapM_ (deleteBot cnv . botMemId) gone --- | Locally post a message originating from a remote conversation --- --- FUTUREWORK: error handling for missing / mismatched clients --- (https://wearezeta.atlassian.net/browse/SQCORE-894) -postRemoteToLocal :: RemoteMessage (Remote ConvId) -> Galley () -postRemoteToLocal rm = do - localDomain <- viewFederationDomain - let UserClientMap rcpts = rmRecipients rm - Tagged conv = rmConversation rm - -- FUTUREWORK(authorization) review whether filtering members is appropriate - -- at this stage - (members, allMembers) <- Data.filterRemoteConvMembers (Map.keys rcpts) conv - unless allMembers $ - Log.warn $ - Log.field "conversation" (toByteString' (qUnqualified conv)) - Log.~~ Log.field "domain" (toByteString' (qDomain conv)) - Log.~~ Log.msg - ( "Attempt to send remote message to local\ - \ users not in the conversation" :: - Text - ) - let rcpts' = do - m <- members - (c, t) <- maybe [] Map.assocs (rcpts ^? ix m) - pure (m, c, t) - let remoteToLocalPush (rcpt, rcptc, ciphertext) = - newPush1 - ListComplete - (guard (localDomain == qDomain (rmSender rm)) $> qUnqualified (rmSender rm)) - ( ConvEvent - ( Event - OtrMessageAdd - conv - (rmSender rm) - (rmTime rm) - ( EdOtrMessage - ( OtrMessage - { otrSender = rmSenderClient rm, - otrRecipient = rcptc, - otrCiphertext = ciphertext, - otrData = rmData rm - } - ) - ) - ) - ) - (singleton (userRecipient rcpt)) - -- FUTUREWORK: unify event creation logic after #1634 is merged - & pushNativePriority .~ rmPriority rm - & pushRoute .~ bool RouteDirect RouteAny (rmPush rm) - & pushTransient .~ rmTransient rm - pushSome (map remoteToLocalPush rcpts') - newMessage :: Qualified UserId -> Maybe ConnId -> diff --git a/services/galley/test/integration/API/Federation.hs b/services/galley/test/integration/API/Federation.hs index 9dcc456948..e11383260f 100644 --- a/services/galley/test/integration/API/Federation.hs +++ b/services/galley/test/integration/API/Federation.hs @@ -372,8 +372,9 @@ receiveMessage = do bob <- randomId conv <- randomId let fromc = newClientId 0 - alicec = newClientId 0 - evec = newClientId 0 + aliceC1 = newClientId 0 + aliceC2 = newClientId 1 + eveC = newClientId 0 bdom = Domain "bob.example.com" qconv = Qualified conv bdom qbob = Qualified bob bdom @@ -397,7 +398,7 @@ receiveMessage = do msg client = Map.fromList [(client, txt)] rcpts = UserClientMap $ - Map.fromList [(alice, msg alicec), (eve, msg evec)] + Map.fromListWith (<>) [(alice, msg aliceC1), (alice, msg aliceC2), (eve, msg eveC)] rm = FedGalley.RemoteMessage { FedGalley.rmTime = now, @@ -412,18 +413,33 @@ receiveMessage = do } -- send message to alice and check reception - WS.bracketR2 c alice eve $ \(wsA, wsE) -> do + WS.bracketAsClientRN c [(alice, aliceC1), (alice, aliceC2), (eve, eveC)] $ \[wsA1, wsA2, wsE] -> do FedGalley.receiveMessage fedGalleyClient bdom rm liftIO $ do - -- alice should receive the message - WS.assertMatch_ (5 # Second) wsA $ \n -> - do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= OtrMessageAdd - evtFrom e @?= qbob - evtData e @?= EdOtrMessage (OtrMessage fromc alicec txt Nothing) + -- alice should receive the message on her first client + WS.assertMatch_ (5 # Second) wsA1 $ \n -> do + let e = List1.head (WS.unpackPayload n) + ntfTransient n @?= False + evtConv e @?= qconv + evtType e @?= OtrMessageAdd + evtFrom e @?= qbob + evtData e @?= EdOtrMessage (OtrMessage fromc aliceC1 txt Nothing) + + -- alice should receive the message on her second client + WS.assertMatch_ (5 # Second) wsA2 $ \n -> do + let e = List1.head (WS.unpackPayload n) + ntfTransient n @?= False + evtConv e @?= qconv + evtType e @?= OtrMessageAdd + evtFrom e @?= qbob + evtData e @?= EdOtrMessage (OtrMessage fromc aliceC2 txt Nothing) + + -- These should be the only events for each device of alice. This verifies + -- that targetted delivery to the clients was used so that client 2 does + -- not receive the message encrypted for client 1 and vice versa. + WS.assertNoEvent (1 # Second) [wsA1] + WS.assertNoEvent (1 # Second) [wsA2] + -- eve should not receive the message WS.assertNoEvent (1 # Second) [wsE]