diff --git a/docs/protocol/channels-overview.md b/docs/protocol/channels-overview.md index 21f29abd82..9479a4c54b 100644 --- a/docs/protocol/channels-overview.md +++ b/docs/protocol/channels-overview.md @@ -182,7 +182,7 @@ The low-level protocol supports multiple owners from the initial release. The ap - **Subscribers** connect to relays and receive content. They cannot send messages by default, but can be given posting rights. -Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol. +Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol. The owner-signed roster tracks the promoted set - members, moderators, and admins; subscribers are observers until an owner promotes them. For protocol-level detail - wire formats, message types, signing and verification mechanics, delivery pipeline - see [SimpleX Channels Protocol](./channels-protocol.md). @@ -236,7 +236,7 @@ This threat model assumes the [SimpleX network threat model](https://github.com/ - Selectively target specific subscribers while delivering correctly to others. - Ignore the "message from channel" directive, revealing which owner sent a message. Detectable out-of-band. - Fabricate or hide subscriber connections, inflating or deflating counts. Detectable if subscribers are connected to other relays. -- Replay a previously valid roster - the owner-signed header plus its blob - to a *new* joiner, re-introducing a member whose privileged role was later revoked (or masking a later demotion). The owner signature binds the channel entity ID and the roster version, and the header's digest binds the blob to that header, so cross-channel and cross-version substitution remain blocked; but a same-group replay to a joiner that has not yet seen a newer version is not prevented. Existing members are protected by the monotonic roster version check - they reject any roster not strictly newer than the one already applied, so the replay reaches only members with no prior roster state. +- Replay a previously valid roster - the owner-signed header plus its blob - to a *new* joiner, re-introducing a member who was later removed or demoted (the roster now carries plain members, not only moderators and admins). The owner signature binds the channel entity ID and the roster version, and the header's digest binds the blob to that header, so cross-channel and cross-version substitution remain blocked; but a same-group replay to a joiner that has not yet seen a newer version is not prevented. Existing members are protected by the monotonic roster version check - they reject any roster not strictly newer than the one already applied, so the replay reaches only a joiner with no prior roster state, and only until that joiner receives the current roster from another relay it connects to. *cannot:* diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 7786b65214..b545e68d76 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2739,7 +2739,7 @@ processChatCommand cxt nm = \case when (useRelays' gInfo && (isRosterRole newRole || anyPrivilegedTarget) && memberRole' (membership gInfo) /= GROwner) $ throwCmdError "only the group owner can change moderator and admin roles" when (useRelays' gInfo && isRosterRole newRole && finalPrivilegedCount > maxGroupRosterSize) $ - throwCmdError $ "the number of moderators and admins would exceed the limit of " <> show maxGroupRosterSize + throwCmdError $ "the number of members, moderators and admins would exceed the limit of " <> show maxGroupRosterSize (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g currentMems unless (null acis) $ toView $ CEvtNewChatItems user acis @@ -2865,7 +2865,7 @@ processChatCommand cxt nm = \case when (memCount > 1 && anyAdmin) $ throwCmdError "can't remove multiple members when admins selected" assertUserGroupRole gInfo $ max GRAdmin maxRole when (useRelays' gInfo && anyPrivilegedRemoved && memberRole' (membership gInfo) /= GROwner) $ - throwCmdError "only the group owner can remove moderators and admins" + throwCmdError "only the group owner can remove members, moderators and admins" (errs1, deleted1) <- deleteInvitedMems user invitedMems let recipients = filter memberCurrent members (errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing recipients currentMems diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 2205da567f..eeaf03e22d 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1239,7 +1239,7 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes -- Roles carried by the roster; owners are on the link, not the roster. isRosterRole :: GroupMemberRole -> Bool -isRosterRole r = r == GRModerator || r == GRAdmin +isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin -- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first. -- Runs on the parsed roster blob. @@ -1256,8 +1256,8 @@ validateGroupRoster entries = buildGroupRoster :: [GroupMember] -> [RosterMember] buildGroupRoster mods = mapMaybe rosterMember mods where - rosterMember m@GroupMember {memberId, memberPubKey, memberRole} - | isRosterRole memberRole = (\k -> RosterMember {memberId, name = memberShortenedName m, key = MemberKey k, role = memberRole}) <$> memberPubKey + rosterMember GroupMember {memberId, memberPubKey, memberRole} + | isRosterRole memberRole = (\k -> RosterMember {memberId, key = MemberKey k, role = memberRole, privileges = 0}) <$> memberPubKey | otherwise = Nothing sendHistory :: User -> GroupInfo -> GroupMember -> CM () @@ -2212,7 +2212,7 @@ bumpAndBroadcastRoster user gInfo = do let rosterVer = maybe (VersionRoster 0) (\(VersionRoster n) -> VersionRoster (n + 1)) (rosterVersion gInfo) (relays, mods) <- withStore' $ \db -> do relays <- getGroupRelayMembers db cxt user gInfo - mods <- getGroupRosterMembers db cxt user gInfo + mods <- (++) <$> getGroupRosterMembers db cxt user gInfo <*> getGroupOnlyMembers db cxt user gInfo setGroupRosterVersion db gInfo rosterVer pure (relays, mods) forM_ (L.nonEmpty relays) $ \relays' -> @@ -2223,7 +2223,7 @@ sendGroupRosterToRelay :: User -> GroupInfo -> GroupMember -> CM () sendGroupRosterToRelay user gInfo relayMember = forM_ (rosterVersion gInfo) $ \rosterVer -> do cxt <- chatStoreCxt - mods <- withStore' $ \db -> getGroupRosterMembers db cxt user gInfo + mods <- withStore' $ \db -> (++) <$> getGroupRosterMembers db cxt user gInfo <*> getGroupOnlyMembers db cxt user gInfo sendRoster user gInfo [relayMember] rosterVer (buildGroupRoster mods) -- Row-less send (no files/snd_files rows, so no send-side cleanup); redelivery is the agent's. diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e94b10045c..682a3bc75d 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3209,7 +3209,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" $> Nothing | useRelays' gInfo && (isRosterRole memRole || isRosterRole fromRole) && senderRole /= GROwner = - messageError "x.grp.mem.role: only the owner can change moderator/admin roles in relay groups" $> Nothing + messageError "x.grp.mem.role: only the owner can change member, moderator and admin roles in relay groups" $> Nothing | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m @@ -3224,6 +3224,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = xGrpRoster gInfo author GroupRoster {version = newVer, fileInv = InlineFileInvitation {fileSize, fileDigest}} verifiedMsg sharedMsgId_ brokerTs -- only an owner may sign a roster; otherwise a relay could route it as a member whose key it controls | memberRole' author /= GROwner = messageError "x.grp.roster: not signed by an owner" $> Nothing + | fileSize > maxGroupRosterBytes = messageError "x.grp.roster: roster blob size exceeds limit" $> Nothing | otherwise = case verifiedMsg of -- unreachable: XGrpRoster is in requiresSignature, so withVerifiedMsg rejected unsigned VMUnsigned _ -> pure Nothing @@ -3307,7 +3308,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = members <- withStore' $ \db -> getGroupMembers db cxt user gInfo serveRoster user gInfo (filter rosterRecipient members) `catchAllErrors` eToView where - rosterRecipient m@GroupMember {activeConn} = memberCurrent m && isJust activeConn && not (isRelay m) && memberRole' m /= GROwner + rosterRecipient m = memberCurrent m && not (isRelay m) && memberRole' m /= GROwner && isJust (readyMemberConn m) -- TOFU apply: pin each member's key on first use, then update roles. processRosterEntries :: DB.Connection -> GroupInfo -> GroupMemberRole -> [RosterMember] -> ExceptT StoreError IO ([MemberId], [(GroupMember, GroupMemberRole)]) @@ -3315,18 +3316,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = let rosterIds = map (\RosterMember {memberId} -> memberId) entries acc <- foldrM applyRosterEntry ([], []) entries -- absent privileged members revert to the joiner default - currentPriv <- liftIO $ getGroupRosterMembers db cxt user gInfo + currentPriv <- liftIO $ (++) <$> getGroupRosterMembers db cxt user gInfo <*> getGroupOnlyMembers db cxt user gInfo liftIO $ forM_ currentPriv $ \m -> when (memberId' m `notElem` rosterIds) $ updateGroupMemberRole db user m defaultRole pure acc where -- entry-level failure (StoreError or IO exception) is muted; the entry is dropped - applyRosterEntry RosterMember {memberId, name, key = MemberKey pubKey, role} (cs, as) = + applyRosterEntry RosterMember {memberId, key = MemberKey pubKey, role} (cs, as) = apply `catchAllErrors` \_ -> pure (cs, as) where applied m = (cs, ((m :: GroupMember) {memberRole = role}, memberRole' m) : as) - apply = getCreateUnknownGMByMemberId db cxt user gInfo memberId name defaultRole True >>= \case + apply = getCreateUnknownGMByMemberId db cxt user gInfo memberId (nameFromMemberId memberId) defaultRole True >>= \case Nothing -> pure (cs, as) Just (m, _) -> case memberPubKey m of Just k @@ -3510,9 +3511,6 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo''' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) groupMsgToView cInfo ci toView $ CEvtLeftMember user gInfo''' m' {memberStatus = GSMemLeft} msgSigned - -- a privileged leaver drops out of the roster - when (useRelays' gInfo'' && memberRole' (membership gInfo'') == GROwner && isRosterRole (memberRole' m)) $ - bumpAndBroadcastRoster user gInfo'' `catchAllErrors` eToView pure $ memberEventDeliveryScope m xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index fc70c66d01..203f74b321 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -388,9 +388,9 @@ data InlineFileInvitation = InlineFileInvitation data RosterMember = RosterMember { memberId :: MemberId, - name :: Text, key :: MemberKey, -- trust-on-first-use pinned per memberId - role :: GroupMemberRole + role :: GroupMemberRole, + privileges :: Word16 -- reserved: serialized as 0, parsed and ignored in v1 } deriving (Eq, Show) @@ -398,7 +398,7 @@ data RosterMember = RosterMember -- The blob codec (encodeRosterBlob / rosterBlobP) is defined with maxGroupRosterSize below, -- so rosterBlobP and the bound it references sit in one declaration group (past the TH splices). instance Encoding RosterMember where - smpEncode RosterMember {memberId, name, key, role} = smpEncode (memberId, name, key, role) + smpEncode RosterMember {memberId, key, role, privileges} = smpEncode (memberId, key, role, privileges) smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP instance Encoding FwdSender where @@ -930,10 +930,15 @@ maxDecompressedMsgLength :: Int maxDecompressedMsgLength = 65536 -- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the --- promotion cap over the privileged (moderator/admin) set. The blob rides over the +-- promotion cap over the promoted (member/moderator/admin) set. The blob rides over the -- inline file transfer, so it is no longer bound by maxEncodedMsgLength. maxGroupRosterSize :: Int -maxGroupRosterSize = 64 +maxGroupRosterSize = 256 + +-- Receive-side byte bound: reject an owner-signed header whose claimed fileSize exceeds what +-- maxGroupRosterSize entries can occupy (128 B/entry is a generous worst case), before a file is created. +maxGroupRosterBytes :: Integer +maxGroupRosterBytes = fromIntegral maxGroupRosterSize * 128 -- The byte sequence the owner-signed digest is computed over and verified against -- before parsing. Word16 count (smpEncodeList's 1-byte count is too small for the future cap). diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 07b3441861..ed500060b2 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -68,6 +68,7 @@ module Simplex.Chat.Store.Groups getSupportScopeMembersByIndexes, getGroupModerators, getGroupRosterMembers, + getGroupOnlyMembers, getGroupOwners, getGroupRelayMembers, getGroupMembersForExpiration, @@ -1224,6 +1225,14 @@ getGroupRosterMembers db cxt user@User {userId, userContactId} GroupInfo {groupI (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?)") (userId, groupId, userContactId, GRModerator, GRAdmin) +getGroupOnlyMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupOnlyMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + filter memberCurrent . map (toContactMember cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?") + (userId, groupId, userContactId, GRMember) + getGroupOwners :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupOwners db cxt user@User {userId, userContactId} GroupInfo {groupId} = do filter memberCurrent . map (toContactMember cxt user) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index ede3c1f2a2..cbd5247a49 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -212,7 +212,7 @@ testCfg = shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, tbqSize = 16, - channelSubscriberRole = GRMember, -- starting role is GRMember to test members sending messages + channelSubscriberRole = GRObserver, confirmMigrations = MCYesUp } diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 04b9e81b3a..1cf6ebfe4b 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -289,12 +289,14 @@ chatGroupTests = do describe "promoted members roster" $ do it "moderator action verifies via owner-signed roster" testChannelModeratorActionViaRoster it "removed moderator drops from the roster cache" testChannelRemovedModeratorRefreshesRoster - it "leaving moderator drops from the roster cache" testChannelLeftModeratorDropsFromRoster it "role transitions update the roster (mod <-> admin, admin -> non-roster)" testChannelRoleTransitionsUpdateRoster it "malicious relay cannot downgrade or re-key a roster-established moderator via XGrpMemNew" testChannelRelayCannotDowngradeRosterMember it "should add relay to channel with roster (relay caches roster before joinable)" testChannelAddRelayWithRoster it "roster blob spanning multiple chunks reassembles" testChannelRosterMultipartReassembly it "corrupted roster blob is rejected on digest mismatch" testChannelRosterDigestMismatchRejected + it "promoted member enters the roster and can post" testChannelPromotedMemberCanPost + it "observer cannot post until promoted" testChannelObserverCannotPost + it "promoted member re-connecting via a new relay is accepted via the roster-pinned key" testChannelPromotedMemberRejoinViaRelay describe "channel message operations" $ do it "should update channel message" testChannelMessageUpdate it "should delete channel message" testChannelMessageDelete @@ -8633,6 +8635,20 @@ createChannel1Relay gName owner relay cath dan eve = do forM_ [cath, dan, eve] $ \member -> memberJoinChannel gName [relay] [owner] shortLink fullLink member +-- Promote a fresh channel subscriber (observer default) to member so it can post; the roster bump +-- re-serves to the other (still-unknown) subscribers, who see the change rendered by member id hash. +promoteChannelMember :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> [TestCC] -> IO () +promoteChannelMember gName owner relay member others = do + mName <- userName member + oName <- userName owner + owner ##> ("/mr #" <> gName <> " " <> mName <> " member") + owner <## ("#" <> gName <> ": you changed the role of " <> mName <> " to member (signed)") + concurrentlyN_ $ + [ relay <## ("#" <> gName <> ": " <> oName <> " changed the role of " <> mName <> " from observer to member (signed)"), + member <## ("#" <> gName <> ": " <> oName <> " changed your role from observer to member (signed)") + ] + <> [o <### [EndsWith "from observer to member (signed)"] | o <- others] + setupRelay :: TestCC -> TestCC -> IO String setupRelay owner relay = do rName <- userName relay @@ -8667,7 +8683,7 @@ prepareChannel' relayId gName owner relay = do ] owner ##> ("/show link #" <> gName) - getGroupLinks owner gName GRMember False + getGroupLinks owner gName GRObserver False createChannel2Relays :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () createChannel2Relays gName owner relay1 relay2 dan eve frank = do @@ -8715,7 +8731,7 @@ prepareChannel2Relays gName owner relay1 relay2 = do ] owner ##> ("/show link #" <> gName) - getGroupLinks owner gName GRMember False + getGroupLinks owner gName GRObserver False memberJoinChannel :: String -> [TestCC] -> [TestCC] -> String -> String -> TestCC -> IO () memberJoinChannel gName = memberJoinChannel' gName 1 0 0 0 @@ -8844,6 +8860,9 @@ testChannelsSenderDeduplicateOwn ps = do withNewTestChat ps "eve" eveProfile $ \eve -> do withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath and dan while the relay is online, so their buffered posts replay as members + promoteChannelMember "team" alice bob cath [dan, eve] + promoteChannelMember "team" alice bob dan [cath, eve] -- chat relay bob is offline alice #> "#team 1" @@ -8869,14 +8888,18 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team dan> 6 [>>]" ] cath - <### [ "#team: bob introduced dan (Daniel) in the channel", + <### [ EndsWith "updated to dan", + "#team: member role did not change", + "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", WithTime "#team dan> 6 [>>]" ] dan - <### [ "#team: bob introduced cath (Catherine) in the channel", + <### [ EndsWith "updated to cath", + "#team: member role did not change", + "#team: bob introduced cath (Catherine) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", @@ -8884,7 +8907,11 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team cath> 5 [>>]" ] eve - <### [ "#team: bob introduced cath (Catherine) in the channel", + <### [ EndsWith "updated to cath", + EndsWith "updated to dan", + "#team: member role did not change", + "#team: member role did not change", + "#team: bob introduced cath (Catherine) in the channel", "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", @@ -8905,11 +8932,13 @@ testChannelLateJoinerReceivesProfile ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] - -- first forward: dan learns cath via prepended XGrpMemNew. + -- first forward: dan resolves cath (roster-known by id hash) on the prepended XGrpMemNew. cath #> "#team hi" bob <# "#team cath> hi" alice <# "#team cath> hi [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hi [>>]" @@ -8945,12 +8974,23 @@ testChannel2RelaysDeduplicateProfile ps = memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink eve + -- promote dan (observer default) so it can post; eve learns dan via the roster (id hash) + alice ##> "/mr #team dan member" + alice <## "#team: you changed the role of dan to member (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of dan from observer to member (signed)", + cath <## "#team: alice changed the role of dan from observer to member (signed)", + dan <## "#team: alice changed your role from observer to member (signed)", + eve <### [EndsWith "from observer to member (signed)"] + ] + -- first forward: both relays prepend XGrpMemNew(dan) for eve; -- second hits xGrpMemNew's "already created via another relay" branch. dan #> "#team hi" bob <# "#team dan> hi" cath <# "#team dan> hi" alice <# "#team dan> hi [>>]" + eve <### [EndsWith "updated to dan"] eve .<## " introduced dan (Daniel) in the channel" eve <# "#team dan> hi [>>]" @@ -8992,6 +9032,7 @@ testChannelLargeProfileFits ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] -- ~14000 chars: profile fits in a singleton batch AND packs -- inline with the forwarded body (exercises the in-body path). @@ -9002,6 +9043,7 @@ testChannelLargeProfileFits ps = cath #> "#team hi" bob <# "#team cath> hi" alice <# "#team cath> hi [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hi [>>]" @@ -9015,6 +9057,8 @@ testChannelMultipleLargeProfiles ps = withNewTestChat ps "dan" danProfile $ \dan -> do withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + promoteChannelMember "team" alice bob dan [cath, eve] -- ~14500 chars each: one rides inline with the body, -- the other spills into a standalone overflow batch. @@ -9036,15 +9080,23 @@ testChannelMultipleLargeProfiles ps = WithTime "#team dan> from dan [>>]" ] cath - <### [ "#team: bob introduced dan (Daniel) in the channel", + <### [ EndsWith "updated to dan", + "#team: member role did not change", + "#team: bob introduced dan (Daniel) in the channel", WithTime "#team dan> from dan [>>]" ] dan - <### [ "#team: bob introduced cath (Catherine) in the channel", + <### [ EndsWith "updated to cath", + "#team: member role did not change", + "#team: bob introduced cath (Catherine) in the channel", WithTime "#team cath> from cath [>>]" ] eve - <### [ "#team: bob introduced dan (Daniel) in the channel", + <### [ EndsWith "updated to cath", + EndsWith "updated to dan", + "#team: member role did not change", + "#team: member role did not change", + "#team: bob introduced dan (Daniel) in the channel", "#team: bob introduced cath (Catherine) in the channel", WithTime "#team cath> from cath [>>]", WithTime "#team dan> from dan [>>]" @@ -9066,10 +9118,12 @@ testChannelProfileUpdateNoRePrepend ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] cath #> "#team hi" bob <# "#team cath> hi" alice <# "#team cath> hi [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hi [>>]" @@ -9095,22 +9149,28 @@ testChannelMultiSendersIndependent ps = withNewTestChat ps "dan" danProfile $ \dan -> do withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + promoteChannelMember "team" alice bob dan [cath, eve] - -- cath posts: dan and eve learn cath via prepended XGrpMemNew + -- cath posts: dan and eve resolve cath (roster-known by id hash) on the prepended XGrpMemNew cath #> "#team from cath" bob <# "#team cath> from cath" alice <# "#team cath> from cath [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> from cath [>>]" + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> from cath [>>]" - -- dan posts: cath and eve learn dan independently of cath's vector + -- dan posts: cath and eve resolve dan independently of cath's vector dan #> "#team from dan" bob <# "#team dan> from dan" alice <# "#team dan> from dan [>>]" + cath <### [EndsWith "updated to dan"] cath <## "#team: bob introduced dan (Daniel) in the channel" cath <# "#team dan> from dan [>>]" + eve <### [EndsWith "updated to dan"] eve <## "#team: bob introduced dan (Daniel) in the channel" eve <# "#team dan> from dan [>>]" @@ -9131,6 +9191,17 @@ testChannels2RelaysDeliver ps = withNewTestChat ps "frank" frankProfile $ \frank -> do createChannel2Relays "team" alice bob cath dan eve frank + -- promote dan (observer default) so it can send; eve/frank learn dan via the roster (id hash) + alice ##> "/mr #team dan member" + alice <## "#team: you changed the role of dan to member (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of dan from observer to member (signed)", + cath <## "#team: alice changed the role of dan from observer to member (signed)", + dan <## "#team: alice changed your role from observer to member (signed)", + eve <### [EndsWith "from observer to member (signed)"], + frank <### [EndsWith "from observer to member (signed)"] + ] + alice #> "#team hi" [bob, cath] *<# "#team> hi" [dan, eve, frank] *<# "#team> hi [>>]" @@ -9143,18 +9214,15 @@ testChannels2RelaysDeliver ps = cath <## " + 👍" alice <# "#team dan> > hi" alice <## " + 👍" + eve .<##. ("#team: unknown member ", " updated to dan") eve .<## " introduced dan (Daniel) in the channel" eve <# "#team dan> > hi" eve <## " + 👍" + frank .<##. ("#team: unknown member ", " updated to dan") frank .<## " introduced dan (Daniel) in the channel" frank <# "#team dan> > hi" frank <## " + 👍" - -- remove below if default role is changed to observer - dan #> "#team hey" - [bob, cath] *<# "#team dan> hey" - [alice, eve, frank] *<# "#team dan> hey [>>]" - testChannels2RelaysIncognito :: HasCallStack => TestParams -> IO () testChannels2RelaysIncognito ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -9168,6 +9236,17 @@ testChannels2RelaysIncognito ps = forM_ [eve, frank] $ \member -> memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink member + -- promote dan (observer default) so it can send; eve/frank learn dan via the roster (id hash) + alice ##> ("/mr #team " <> danIncognito <> " member") + alice <## ("#team: you changed the role of " <> danIncognito <> " to member (signed)") + concurrentlyN_ + [ bob <## ("#team: alice changed the role of " <> danIncognito <> " from observer to member (signed)"), + cath <## ("#team: alice changed the role of " <> danIncognito <> " from observer to member (signed)"), + dan <## "#team: alice changed your role from observer to member (signed)", + eve <### [EndsWith "from observer to member (signed)"], + frank <### [EndsWith "from observer to member (signed)"] + ] + alice #> "#team hi" [bob, cath] *<# "#team> hi" dan ?<# "#team> hi [>>]" @@ -9181,18 +9260,15 @@ testChannels2RelaysIncognito ps = cath <## " + 👍" alice <# ("#team " <> danIncognito <> "> > hi") alice <## " + 👍" + eve .<##. ("#team: unknown member ", (" updated to " <> danIncognito)) eve .<## (" introduced " <> danIncognito <> " in the channel") eve <# ("#team " <> danIncognito <> "> > hi") eve <## " + 👍" + frank .<##. ("#team: unknown member ", (" updated to " <> danIncognito)) frank .<## (" introduced " <> danIncognito <> " in the channel") frank <# ("#team " <> danIncognito <> "> > hi") frank <## " + 👍" - -- remove below if default role is changed to observer - dan ?#> "#team hey" - [bob, cath] *<# ("#team " <> danIncognito <> "> hey") - [alice, eve, frank] *<# ("#team " <> danIncognito <> "> hey [>>]") - testChannelUpdateProfileSigned :: HasCallStack => TestParams -> IO () testChannelUpdateProfileSigned ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -9250,7 +9326,7 @@ testChannelLinkAfterProfileUpdate ps = -- late subscriber joins via the same channel link after profile update threadDelay 100000 alice ##> "/show link #my_team" - (shortLink', fullLink') <- getGroupLinks alice "my_team" GRMember False + (shortLink', fullLink') <- getGroupLinks alice "my_team" GRObserver False shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "my_team" [bob] [alice] shortLink' fullLink' dan @@ -9287,7 +9363,7 @@ testChannelLinkAfterWelcomeUpdate ps = -- re-fetch updated link, late subscriber joins threadDelay 100000 alice ##> "/show link #team" - (shortLink', fullLink') <- getGroupLinks alice "team" GRMember False + (shortLink', fullLink') <- getGroupLinks alice "team" GRObserver False shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "team" [bob] [alice] shortLink' fullLink' dan @@ -9324,7 +9400,7 @@ testChannelOwnerKeyAfterLinkUpdate ps = -- Late subscriber joins via the same channel link after profile update. alice ##> "/show link #my_team" - (shortLink', fullLink') <- getGroupLinks alice "my_team" GRMember False + (shortLink', fullLink') <- getGroupLinks alice "my_team" GRObserver False shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "my_team" [bob] [alice] shortLink' fullLink' dan @@ -9391,15 +9467,20 @@ testChannelChangeRoleSigned ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- other members discover cath cath #> "#team hello from cath" bob <# "#team cath> hello from cath" concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9426,17 +9507,15 @@ testChannelChangeRoleSigned ps = alice ##> "/mr #team dan admin" alice <## "#team: you changed the role of dan to admin (signed)" concurrentlyN_ - [ bob <## "#team: alice changed the role of dan from member to admin (signed)", - dan <## "#team: alice changed your role from member to admin (signed)", - cath <## "#team: alice changed the role of dan from member to admin (signed)", - eve <## "#team: alice changed the role of dan from member to admin (signed)" + [ bob <## "#team: alice changed the role of dan from observer to admin (signed)", + dan <## "#team: alice changed your role from observer to admin (signed)", + cath .<##. ("#team: alice changed the role of ", " from observer to admin (signed)"), + eve .<##. ("#team: alice changed the role of ", " from observer to admin (signed)") ] + -- cath/eve render dan by id hash (unknown to them, roster-TOFU); arrival verified above alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of dan to admin (signed)")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of dan to admin (signed)")]) - -- roster-emitted items don't carry the signed marker in chat content text (live event does) - cath #$> ("/_get chat #1 count=1", chat, [(0, "changed role of dan to admin")]) dan #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) - eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of dan to admin")]) testChannelBlockMemberSigned :: HasCallStack => TestParams -> IO () testChannelBlockMemberSigned ps = @@ -9447,6 +9526,9 @@ testChannelBlockMemberSigned ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- other members discover cath threadDelay 1000000 cath #> "#team hello from cath" @@ -9454,9 +9536,11 @@ testChannelBlockMemberSigned ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9508,6 +9592,16 @@ checkMemberRow cc name expectedRole = do DB.query db "SELECT member_role FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text] map (\(Only r) -> r) roles `shouldBe` maybeToList expectedRole +-- a roster-learned member (e.g. on a relay that only cached the roster) is stored under the +-- placeholder hash name; resolve it from a peer that knows the member by name +memberHashName :: HasCallStack => TestCC -> T.Text -> IO T.Text +memberHashName cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_id FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only (Binary ByteString)] + case rows of + [Only (Binary mid)] -> pure $ nameFromMemberId (MemberId mid) + _ -> fail $ "memberHashName: expected one row for " <> T.unpack name + testChannelModeratorActionViaRoster :: HasCallStack => TestParams -> IO () testChannelModeratorActionViaRoster ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -9520,30 +9614,33 @@ testChannelModeratorActionViaRoster ps = forM_ [cath, dan, eve] $ \member -> memberJoinChannel "team" [bob] [alice] shortLink fullLink member - -- dan posts so cath and eve discover dan + -- promote dan (observer default) so it can post; cath and eve then discover dan threadDelay 1000000 + promoteChannelMember "team" alice bob dan [cath, eve] dan #> "#team hello from dan" bob <# "#team dan> hello from dan" concurrentlyN_ [ alice <# "#team dan> hello from dan [>>]", do + cath <### [EndsWith "updated to dan"] cath <## "#team: bob introduced dan (Daniel) in the channel" cath <# "#team dan> hello from dan [>>]", do + eve <### [EndsWith "updated to dan"] eve <## "#team: bob introduced dan (Daniel) in the channel" eve <# "#team dan> hello from dan [>>]" ] - -- dan/eve XGrpMemRole is skipped (target unknown); roster apply creates cath - -- and emits the role-change chat item + -- cath promoted observer -> moderator; dan/eve learn cath via the roster re-serve + -- (no name yet -> rendered by member id hash) threadDelay 1000000 alice ##> "/mr #team cath moderator" alice <## "#team: you changed the role of cath to moderator (signed)" concurrentlyN_ - [ bob <## "#team: alice changed the role of cath from member to moderator (signed)", - cath <## "#team: alice changed your role from member to moderator (signed)", - dan <## "#team: alice changed the role of cath from member to moderator (signed)", - eve <## "#team: alice changed the role of cath from member to moderator (signed)" + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)", + dan <### [EndsWith "to moderator (signed)"], + eve <### [EndsWith "to moderator (signed)"] ] -- cath (moderator) blocks dan; profile prepend carries cath's full profile to dan/eve @@ -9552,10 +9649,10 @@ testChannelModeratorActionViaRoster ps = cath <## "#team: you blocked dan (signed)" bob <## "#team: cath blocked dan (signed)" alice <## "#team: cath blocked dan (signed)" - eve <## "#team: unknown member cath updated to cath" + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <## "#team: cath blocked dan (signed)" - dan <## "#team: unknown member cath updated to cath" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" -- frank joins after the roster update; cached roster gives him cath as moderator. @@ -9564,7 +9661,11 @@ testChannelModeratorActionViaRoster ps = -- profile may not be loaded yet, so the actor renders by memberId hash) threadDelay 1000000 memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink frank - frank <### [EndsWith "changed the role of cath from member to moderator (signed)"] + -- cached roster carries cath (moderator) and dan (member); both emit a role-change item + frank + <### [ EndsWith "to moderator (signed)", + EndsWith "to member (signed)" + ] threadDelay 500000 checkMemberRole frank "cath" "moderator" where @@ -9585,15 +9686,15 @@ testChannelRemovedModeratorRefreshesRoster ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob forM_ [cath, dan, eve] $ \member -> memberJoinChannel "team" [bob] [alice] shortLink fullLink member - -- dan/eve XGrpMemRole is skipped; roster apply creates cath and emits the chat item + -- cath promoted observer -> moderator; dan/eve learn cath via the roster (id hash) threadDelay 1000000 alice ##> "/mr #team cath moderator" alice <## "#team: you changed the role of cath to moderator (signed)" concurrentlyN_ - [ bob <## "#team: alice changed the role of cath from member to moderator (signed)", - cath <## "#team: alice changed your role from member to moderator (signed)", - dan <## "#team: alice changed the role of cath from member to moderator (signed)", - eve <## "#team: alice changed the role of cath from member to moderator (signed)" + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)", + dan <### [EndsWith "to moderator (signed)"], + eve <### [EndsWith "to moderator (signed)"] ] threadDelay 1000000 alice ##> "/rm #team cath" @@ -9601,8 +9702,8 @@ testChannelRemovedModeratorRefreshesRoster ps = bob <## "#team: alice removed cath from the group (signed)" cath <## "#team: alice removed you from the group (signed)" cath <## "use /d #team to delete the group" - dan <## "#team: alice removed cath from the group (signed)" - eve <## "#team: alice removed cath from the group (signed)" + dan <### [EndsWith "from the group (signed)"] + eve <### [EndsWith "from the group (signed)"] -- frank joins after the removal; cached roster has dropped cath threadDelay 1000000 @@ -9610,44 +9711,6 @@ testChannelRemovedModeratorRefreshesRoster ps = threadDelay 100000 checkMemberRow frank "cath" Nothing -testChannelLeftModeratorDropsFromRoster :: HasCallStack => TestParams -> IO () -testChannelLeftModeratorDropsFromRoster ps = - withNewTestChat ps "alice" aliceProfile $ \alice -> - withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> - withNewTestChat ps "cath" cathProfile $ \cath -> - withNewTestChat ps "dan" danProfile $ \dan -> - withNewTestChat ps "eve" eveProfile $ \eve -> - withNewTestChat ps "frank" frankProfile $ \frank -> do - (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob - forM_ [cath, dan, eve] $ \member -> - memberJoinChannel "team" [bob] [alice] shortLink fullLink member - -- promote cath; dan/eve XGrpMemRole skipped, roster apply emits the chat item - threadDelay 1000000 - alice ##> "/mr #team cath moderator" - alice <## "#team: you changed the role of cath to moderator (signed)" - concurrentlyN_ - [ bob <## "#team: alice changed the role of cath from member to moderator (signed)", - cath <## "#team: alice changed your role from member to moderator (signed)", - dan <## "#team: alice changed the role of cath from member to moderator (signed)", - eve <## "#team: alice changed the role of cath from member to moderator (signed)" - ] - -- cath (moderator) leaves; owner xGrpLeave refreshes the cached roster - threadDelay 1000000 - cath ##> "/leave #team" - cath <## "#team: you left the group" - cath <## "use /d #team to delete the group" - concurrentlyN_ - [ alice <## "#team: cath left the group (signed)", - bob <## "#team: cath left the group (signed)", - dan <## "#team: cath left the group (signed)", - eve <## "#team: cath left the group (signed)" - ] - -- frank joins; cached roster (refreshed) has dropped cath - threadDelay 1000000 - memberJoinChannel "team" [bob] [alice] shortLink fullLink frank - threadDelay 100000 - checkMemberRow frank "cath" Nothing - testChannelRoleTransitionsUpdateRoster :: HasCallStack => TestParams -> IO () testChannelRoleTransitionsUpdateRoster ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -9658,18 +9721,18 @@ testChannelRoleTransitionsUpdateRoster ps = withNewTestChat ps "frank" frankProfile $ \frank -> do (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath - -- member -> moderator + -- observer -> moderator threadDelay 100000 alice ##> "/mr #team cath moderator" alice <## "#team: you changed the role of cath to moderator (signed)" concurrentlyN_ - [ bob <## "#team: alice changed the role of cath from member to moderator (signed)", - cath <## "#team: alice changed your role from member to moderator (signed)" + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" ] -- dan joins; cached roster has cath as moderator threadDelay 100000 memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan - dan <## "#team: alice changed the role of cath from member to moderator (signed)" + dan <### [EndsWith "to moderator (signed)"] threadDelay 100000 checkMemberRow dan "cath" (Just "moderator") -- moderator -> admin: dan now knows cath, role event lands cleanly @@ -9684,18 +9747,18 @@ testChannelRoleTransitionsUpdateRoster ps = -- eve joins; cached roster has cath as admin threadDelay 100000 memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink eve - eve <## "#team: alice changed the role of cath from member to admin (signed)" + eve <### [EndsWith "to admin (signed)"] threadDelay 100000 checkMemberRow eve "cath" (Just "admin") - -- admin -> member (crossing out of roster): roster drops cath + -- admin -> observer (crossing out of roster, since member is now in-roster): roster drops cath threadDelay 100000 - alice ##> "/mr #team cath member" - alice <## "#team: you changed the role of cath to member (signed)" + alice ##> "/mr #team cath observer" + alice <## "#team: you changed the role of cath to observer (signed)" concurrentlyN_ - [ bob <## "#team: alice changed the role of cath from admin to member (signed)", - cath <## "#team: alice changed your role from admin to member (signed)", - dan <## "#team: alice changed the role of cath from admin to member (signed)", - eve <## "#team: alice changed the role of cath from admin to member (signed)" + [ bob <## "#team: alice changed the role of cath from admin to observer (signed)", + cath <## "#team: alice changed your role from admin to observer (signed)", + dan <## "#team: alice changed the role of cath from admin to observer (signed)", + eve <## "#team: alice changed the role of cath from admin to observer (signed)" ] -- frank joins; cath isn't in the roster, so frank has no record of her threadDelay 100000 @@ -9717,9 +9780,9 @@ testChannelRelayCannotDowngradeRosterMember ps = alice ##> "/mr #team cath moderator" alice <## "#team: you changed the role of cath to moderator (signed)" concurrentlyN_ - [ bob <## "#team: alice changed the role of cath from member to moderator (signed)", - cath <## "#team: alice changed your role from member to moderator (signed)", - frank <## "#team: alice changed the role of cath from member to moderator (signed)" + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)", + frank <### [EndsWith "to moderator (signed)"] ] threadDelay 100000 realKey <- getMemberPubKey bob "cath" @@ -9738,7 +9801,7 @@ testChannelRelayCannotDowngradeRosterMember ps = [ alice <# "#team cath> hello from cath [>>]", do frank <##. "warning: x.grp.mem.new: relay asserted key differs from roster-established key, keeping roster key, memberId=" - frank <## "#team: unknown member cath updated to cath" + frank <### [EndsWith "updated to cath"] frank <## "#team: bob introduced cath (Catherine) in the channel" frank <# "#team cath> hello from cath [>>]" ] @@ -9764,15 +9827,20 @@ testChannelRemoveMemberSigned ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote eve to member (observer default) so it can post + promoteChannelMember "team" alice bob eve [cath, dan] + -- other members discover eve eve #> "#team hello from eve" bob <# "#team eve> hello from eve" concurrentlyN_ [ alice <# "#team eve> hello from eve [>>]", do + dan <### [EndsWith "updated to eve"] dan <## "#team: bob introduced eve (Eve) in the channel" dan <# "#team eve> hello from eve [>>]", do + cath <### [EndsWith "updated to eve"] cath <## "#team: bob introduced eve (Eve) in the channel" cath <# "#team eve> hello from eve [>>]" ] @@ -9945,6 +10013,9 @@ testChannelSubscriberLeave ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- other members discover cath threadDelay 1000000 cath #> "#team hello from cath" @@ -9952,9 +10023,11 @@ testChannelSubscriberLeave ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -10180,6 +10253,9 @@ testChannelSubscriberProfileUpdate ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote dan to member early (observer default) so its role-change item precedes the messages + promoteChannelMember "team" alice bob dan [cath, eve] + -- enable support and create support chat for cath (but not dan) threadDelay 1000000 alice ##> "/set support #team on" @@ -10199,6 +10275,9 @@ testChannelSubscriberProfileUpdate ps = (dan "#team hello from cath" @@ -10206,9 +10285,11 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -10238,9 +10319,8 @@ testChannelSubscriberProfileUpdate ps = cath #$> ("/_get chat #1 count=2", chat, [(1, "hello from cath"), (1, "hello from kate")]) -- verify profiles are updated correctly forM_ [alice, bob] $ \cc -> cc `hasContactProfiles` ["alice", "bob", "kate", "dan", "eve"] - cath `hasContactProfiles` ["alice", "bob", "kate"] dan `hasContactProfiles` ["alice", "bob", "kate", "dan"] - eve `hasContactProfiles` ["alice", "bob", "kate", "eve"] + -- cath/eve also know dan by id hash now (roster-learned before dan posts); not asserted -- previously silent subscriber updates profile -- dan has no support chat -> no profile update item created @@ -10252,9 +10332,11 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team dave> hello from dave [>>]", do + eve <### [EndsWith "updated to dave"] eve <## "#team: bob introduced dave in the channel" eve <# "#team dave> hello from dave [>>]", do + cath <### [EndsWith "updated to dave"] cath <## "#team: bob introduced dave in the channel" cath <# "#team dave> hello from dave [>>]" ] @@ -10350,13 +10432,13 @@ testChannelAddRelayWithRoster ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath - -- promote cath to moderator: the roster is created (bob caches it) + -- promote cath observer -> moderator: the roster is created (bob caches it) threadDelay 100000 alice ##> "/mr #team cath moderator" alice <## "#team: you changed the role of cath to moderator (signed)" concurrentlyN_ - [ bob <## "#team: alice changed the role of cath from member to moderator (signed)", - cath <## "#team: alice changed your role from member to moderator (signed)" + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" ] threadDelay 100000 @@ -10389,15 +10471,16 @@ testChannelAddRelayWithRoster ps = cath <## "#team: joining the group (connecting to relay dan)..." cath <## "#team: you joined the group (connected to relay dan)", dan - <### [ EndsWith "changed the role of cath from member to moderator (signed)", + <### [ EndsWith "to moderator (signed)", EndsWith "accepting request to join group #team...", - EndsWith "member cath is connected" + EndsWith "is connected" ] ] threadDelay 100000 - -- the new relay holds the roster (cath is moderator) before it serves joiners - checkMemberRow dan "cath" (Just "moderator") + -- the new relay holds the roster (cath is moderator, by id hash) before it serves joiners + cathHash <- memberHashName bob "cath" + checkMemberRow dan cathHash (Just "moderator") -- With a tiny file chunk size the owner-signed roster blob spans several chunks, exercising -- split + reassembly both owner->relay and relay->joiner. dan getting cath as moderator means @@ -10414,12 +10497,12 @@ testChannelRosterMultipartReassembly ps = alice ##> "/mr #team cath moderator" alice <## "#team: you changed the role of cath to moderator (signed)" concurrentlyN_ - [ bob <## "#team: alice changed the role of cath from member to moderator (signed)", - cath <## "#team: alice changed your role from member to moderator (signed)" + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" ] threadDelay 100000 memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan - dan <## "#team: alice changed the role of cath from member to moderator (signed)" + dan <### [EndsWith "to moderator (signed)"] threadDelay 100000 checkMemberRow dan "cath" (Just "moderator") where @@ -10440,8 +10523,8 @@ testChannelRosterDigestMismatchRejected ps = alice ##> "/mr #team cath moderator" alice <## "#team: you changed the role of cath to moderator (signed)" concurrentlyN_ - [ bob <## "#team: alice changed the role of cath from member to moderator (signed)", - cath <## "#team: alice changed your role from member to moderator (signed)" + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" ] threadDelay 100000 -- corrupt the relay's stored blob (same length, different content) so its digest no @@ -10466,6 +10549,95 @@ testChannelRosterDigestMismatchRejected ps = DB.query_ db "SELECT roster_version FROM groups" :: IO [Only (Maybe Int64)] map (\(Only v) -> v) vs `shouldSatisfy` (\versions -> not (null versions) && Just 1 `notElem` versions) +testChannelPromotedMemberCanPost :: HasCallStack => TestParams -> IO () +testChannelPromotedMemberCanPost ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + -- promote cath to member: cath enters the owner-signed roster (dan learns cath by id hash) + promoteChannelMember "team" alice bob cath [dan] + -- the promoted member can now post; dan resolves cath on the first forward + cath #> "#team hi from cath" + bob <# "#team cath> hi from cath" + alice <# "#team cath> hi from cath [>>]" + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi from cath [>>]" + checkMemberRow dan "cath" (Just "member") + +testChannelObserverCannotPost :: HasCallStack => TestParams -> IO () +testChannelObserverCannotPost ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + -- cath is an observer (default): its own post is rejected locally and never reaches the relay + cath ##> "#team observer attempt" + cath <## "#team: you don't have permission to send messages" + -- promote cath to member; the post is now accepted and delivered, dan resolves cath + promoteChannelMember "team" alice bob cath [dan] + cath #> "#team member post" + bob <# "#team cath> member post" + alice <# "#team cath> member post [>>]" + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> member post [>>]" + +testChannelPromotedMemberRejoinViaRelay :: HasCallStack => TestParams -> IO () +testChannelPromotedMemberRejoinViaRelay ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "dan" danProfile $ \dan -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + -- promote cath to member: cath enters the owner-signed roster with her pinned key + threadDelay 100000 + promoteChannelMember "team" alice bob cath [] + threadDelay 100000 + -- add dan as a 2nd relay; it caches the roster (incl. member cath) before joinable + dan ##> "/ad" + (danSLink, _cLink) <- getContactLinks dan True + alice ##> ("/relays name=dan " <> danSLink) + alice <## "ok" + alice ##> "/_add relays #1 2" + alice <## "#team: group relays:" + alice <## " - relay id 1: active" + alice <## " - relay id 2: invited" + concurrentlyN_ + [ do + alice <## "#team: group link relays updated, current relays:" + alice + <### [ " - relay id 1: active", + " - relay id 2: active" + ] + alice <## "group link:" + void $ getTermLine alice, + dan <## "#team: you joined the group as relay" + ] + -- cath (a promoted member) connects to the new relay; the widened join gate + -- (verifyKey over the roster-pinned key) accepts her and keeps her as member + concurrentlyN_ + [ do + cath <## "#team: joining the group (connecting to relay dan)..." + cath <## "#team: you joined the group (connected to relay dan)", + dan + <### [ EndsWith "to member (signed)", + EndsWith "accepting request to join group #team...", + EndsWith "is connected" + ] + ] + threadDelay 100000 + cathHash <- memberHashName bob "cath" + checkMemberRow dan cathHash (Just "member") + testChannelRemoveRelay :: HasCallStack => TestParams -> IO () testChannelRemoveRelay ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -11135,6 +11307,9 @@ testChannelMessageQuote ps = bob <# "#team> hello from channel" [cath, dan, eve] *<# "#team> hello from channel [>>]" + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- member quotes channel message cath `send` "> #team (hello from) replying to channel" cath <# "#team > hello from channel" @@ -11146,10 +11321,12 @@ testChannelMessageQuote ps = alice <# "#team cath> > hello from channel [>>]" alice <## " replying to channel [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hello from channel [>>]" dan <## " replying to channel [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hello from channel [>>]" eve <## " replying to channel [>>]" @@ -11501,14 +11678,19 @@ testChannelMemberMessageUpdate ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- member sends a message cath #> "#team hello" bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob introduced cath (Catherine) in the channel" + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob introduced cath (Catherine) in the channel" + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] @@ -11532,14 +11714,19 @@ testChannelMemberMessageDelete ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- member sends a message cath #> "#team hello" bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob introduced cath (Catherine) in the channel" + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob introduced cath (Catherine) in the channel" + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ]