Files
simplex-chat/tests/ChatTests/ChatRelays.hs
T
2026-05-30 22:02:41 +00:00

434 lines
19 KiB
Haskell

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module ChatTests.ChatRelays where
import ChatClient
import ChatTests.DBUtils
import ChatTests.Groups (memberJoinChannel, memberJoinChannel', prepareChannel, prepareChannel', prepareChannel1Relay, setupRelay)
import ChatTests.Utils
import Control.Concurrent (threadDelay)
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import ProtocolTests (testGroupProfile)
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..))
import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..))
import Simplex.Chat.Types (GroupProfile (..))
import Simplex.Chat.Web (CorsOrigin (..), WebChannelPreview (..), WebMessage (..), renderWebPreviews, writeCorsConfig)
import Simplex.Messaging.Encoding.String (StrEncoding (..))
import Simplex.Messaging.Util (decodeJSON)
import System.Directory (listDirectory)
import System.FilePath ((</>))
import Test.Hspec hiding (it)
import UnliftIO.STM (readTVarIO)
chatRelayTests :: SpecWith TestParams
chatRelayTests = do
describe "configure chat relays" $ do
it "get and set chat relays" testGetSetChatRelays
it "re-add soft-deleted relay by same address" testReAddRelaySameAddress
it "re-add soft-deleted relay by same name" testReAddRelaySameName
it "test chat relay" testChatRelayTest
it "relay profile updated in address" testRelayProfileUpdateInAddress
describe "relay capabilities" $ do
it "relay sends baseWebUrl in capabilities" testRelayWebCapabilities
describe "web preview" $ do
it "render web preview JSON for channel" testWebPreviewRender
it "generate CORS config" testWebPreviewCors
describe "share channel card" $ do
it "share channel card in direct chat" testShareChannelDirect
it "share channel card in group" testShareChannelGroup
it "share channel card in channel" testShareChannelChannel
testGetSetChatRelays :: HasCallStack => TestParams -> IO ()
testGetSetChatRelays ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do
bob ##> "/ad"
(bobSLink, _cLink) <- getContactLinks bob True
cath ##> "/ad"
(cathSLink, _cLink) <- getContactLinks cath True
alice ##> ("/relays name=bob_relay " <> bobSLink)
alice <## "ok"
alice ##> "/relays"
alice <## "Your servers"
alice <## " Chat relays"
alice <## (" bob_relay: " <> bobSLink)
alice ##> ("/relays name=cath_relay " <> cathSLink)
alice <## "ok"
alice ##> "/relays"
alice <## "Your servers"
alice <## " Chat relays"
alice <## (" cath_relay: " <> cathSLink)
alice ##> ("/relays name=bob_relay " <> bobSLink <> " name=cath_relay " <> cathSLink)
alice <## "ok"
alice ##> "/relays"
alice <## "Your servers"
alice <## " Chat relays"
alice
<### [ ConsoleString $ " bob_relay: " <> bobSLink,
ConsoleString $ " cath_relay: " <> cathSLink
]
-- Relay used by a channel is soft-deleted (referenced in group_relays).
-- Re-adding with same address should un-delete it.
testReAddRelaySameAddress :: HasCallStack => TestParams -> IO ()
testReAddRelaySameAddress ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do
bob ##> "/ad"
(bobSLink, _cLink) <- getContactLinks bob True
cath ##> "/ad"
(cathSLink, _cLink) <- getContactLinks cath True
-- Configure bob as relay and create channel (creates group_relays reference)
alice ##> ("/relays name=bob_relay " <> bobSLink)
alice <## "ok"
createChannelWithRelay "team" alice bob
-- Replace bob_relay with cath_relay (bob_relay is soft-deleted, referenced in group_relays)
alice ##> ("/relays name=cath_relay " <> cathSLink)
alice <## "ok"
alice ##> "/relays"
alice <## "Your servers"
alice <## " Chat relays"
alice <## (" cath_relay: " <> cathSLink)
-- Re-add with same address but different name - should succeed (un-deletes soft-deleted row by address)
alice ##> ("/relays name=bob_relay2 " <> bobSLink)
alice <## "ok"
alice ##> "/relays"
alice <## "Your servers"
alice <## " Chat relays"
alice <## (" bob_relay2: " <> bobSLink)
-- Relay used by a channel is soft-deleted (referenced in group_relays).
-- Re-adding with same name and same address should un-delete it.
testReAddRelaySameName :: HasCallStack => TestParams -> IO ()
testReAddRelaySameName ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do
bob ##> "/ad"
(bobSLink, _cLink) <- getContactLinks bob True
cath ##> "/ad"
(cathSLink, _cLink) <- getContactLinks cath True
-- Configure bob as relay named "my_relay" and create channel
alice ##> ("/relays name=my_relay " <> bobSLink)
alice <## "ok"
createChannelWithRelay "team" alice bob
-- Replace with cath_relay (my_relay is soft-deleted)
alice ##> ("/relays name=cath_relay " <> cathSLink)
alice <## "ok"
-- Re-add with same name and same address - should succeed (un-deletes by address match)
alice ##> ("/relays name=my_relay " <> bobSLink)
alice <## "ok"
alice ##> "/relays"
alice <## "Your servers"
alice <## " Chat relays"
alice <## (" my_relay: " <> bobSLink)
testChatRelayTest :: HasCallStack => TestParams -> IO ()
testChatRelayTest ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath -> do
-- Setup: bob (relay) creates address
bob ##> "/ad"
(bobSLink, _cLink) <- getContactLinks bob True
-- Setup: cath (normal user) creates address
cath ##> "/ad"
(cathSLink, _cLink) <- getContactLinks cath True
-- Scenario 1: Happy path - test relay address succeeds
alice ##> ("/relay test " <> bobSLink)
alice <## "relay test passed, profile: bob (Bob)"
-- Scenario 2: Non-relay address - cath is not a relay user,
-- her address has ContactShortLinkData, not RelayAddressLinkData
alice ##> ("/relay test " <> cathSLink)
alice <##. "relay test failed at RTSDecodeLink, error: "
-- Scenario 3: Deleted address - bob deletes his address
bob ##> "/da"
bob <## "Your chat address is deleted - accepted contacts will remain connected."
bob <## "To create a new chat address use /ad"
alice ##> ("/relay test " <> bobSLink)
alice <##. "relay test failed at RTSGetLink, error: "
testRelayProfileUpdateInAddress :: HasCallStack => TestParams -> IO ()
testRelayProfileUpdateInAddress ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do
bob ##> "/ad"
(bobSLink, _cLink) <- getContactLinks bob True
alice ##> ("/relay test " <> bobSLink)
alice <## "relay test passed, profile: bob (Bob)"
bob ##> "/p bob2 Bob relay"
bob <## "user profile is changed to bob2 (Bob relay) (your 0 contacts are notified)"
threadDelay 100000
alice ##> ("/relay test " <> bobSLink)
alice <## "relay test passed, profile: bob2 (Bob relay)"
testShareChannelDirect :: HasCallStack => TestParams -> IO ()
testShareChannelDirect ps =
testChat3 aliceProfile bobProfile cathProfile test ps
where
test alice bob cath = withRelay ps $ \relay -> do
(shortLink, fullLink) <- prepareChannel1Relay "news" alice relay
connectUsers alice bob
-- alice gets ownerSig from share content API (for validation later)
alice ##> "/_share chat content #1 @2"
alice <## "link to join channel #news (signed):"
(_, apiOwnerSig) <- getTermLine2 alice
-- alice sends the card to bob
alice ##> "/share chat #news @bob"
alice <# "@bob link to join channel #news (signed):"
_ <- getTermLine2 alice -- alice's testView ownerSig
bob <# "alice> link to join channel #news (signed):"
-- bob captures the received ownerSig from message view (testView)
(sLink, cSig) <- getTermLine2 bob
sLink `shouldBe` shortLink
cSig `shouldBe` apiOwnerSig
-- bob verifies owner signature via connect plan
bob ##> ("/_connect plan 1 " <> shortLink <> " sig=" <> cSig)
bob <## "group link: ok to connect via relays"
bob <## "owner signature: verified"
_ <- getTermLine bob -- group link data
-- bob joins
memberJoinChannel' "news" 1 0 1 0 [relay] [alice] shortLink fullLink bob
connectUsers bob cath
-- bob (subscriber) shares unsigned - not owner
bob ##> "/share chat #news @cath"
bob <# "@cath link to join channel #news:"
_ <- getTermLine bob
cath <# "bob> link to join channel #news:"
_ <- getTermLine cath
-- bob tries to replay alice's signed card to cath - binding mismatch, sig stripped at receive
let sig = fromMaybe (error "bad sig") (decodeJSON (T.pack cSig) :: Maybe LinkOwnerSig)
cLink = either error id $ strDecode (B.pack sLink)
mc = MCChat (T.pack sLink) (MCLGroup cLink (testGroupProfile {displayName = "news"} :: GroupProfile)) (Just sig)
cm = "{\"msgContent\":" <> LB.unpack (J.encode mc) <> "}"
bob ##> ("/_send @3 json [" <> cm <> "]")
bob <# "@cath link to join group #news (signed):"
_ <- getTermLine2 bob -- bob's testView ownerSig (his sent has the sig data)
-- cath sees it without signature - binding was for alice->bob, not bob->cath, sig stripped
cath <# "bob> link to join group #news:"
_ <- getTermLine cath
-- cath joins anyway
memberJoinChannel "news" [relay] [alice] shortLink fullLink cath
alice #> "#news hello"
relay <# "#news> hello"
[bob, cath] *<# "#news> hello [>>]"
testShareChannelGroup :: HasCallStack => TestParams -> IO ()
testShareChannelGroup ps =
testChat3 aliceProfile bobProfile cathProfile test ps
where
test alice bob cath = withRelay ps $ \relay -> do
(shortLink, fullLink) <- prepareChannel1Relay "news" alice relay
createGroup2 "team" alice bob
alice ##> "/share chat #news #team"
alice <# "#team link to join channel #news:"
_ <- getTermLine alice
bob <# "#team alice> link to join channel #news:"
sLink <- getTermLine bob
sLink `shouldBe` shortLink
memberJoinChannel' "news" 2 0 1 0 [relay] [alice] sLink fullLink bob
createGroup2 "work" bob cath
bob ##> "/share chat #news #work"
bob <# "#work link to join channel #news:"
_ <- getTermLine bob
cath <# "#work bob> link to join channel #news:"
_ <- getTermLine cath
memberJoinChannel' "news" 2 0 0 0 [relay] [alice] shortLink fullLink cath
alice #> "#news hello"
relay <# "#news> hello"
[bob, cath] *<# "#news> hello [>>]"
testShareChannelChannel :: HasCallStack => TestParams -> IO ()
testShareChannelChannel ps =
testChat3 aliceProfile bobProfile cathProfile test ps
where
test alice bob cath = withRelay ps $ \relay -> do
relaySLink <- setupRelay alice relay
(sLink1, fLink1) <- prepareChannel "news" alice relay
(sLink2, fLink2) <- prepareChannel' 2 "updates" alice relay
-- bob joins "updates" first (relay doesn't know bob yet, no suffix)
memberJoinChannel "updates" [relay] [alice] sLink2 fLink2 bob
-- alice (owner) shares "news" to "updates" - signed
alice ##> "/_share chat content #1 #2(as_group=on)"
alice <## "link to join channel #news (signed):"
(apiLink, apiOwnerSig) <- getTermLine2 alice
apiLink `shouldBe` sLink1
alice ##> "/share chat #news #updates"
alice <# "#updates link to join channel #news (signed):"
_ <- getTermLine2 alice -- link, ownerSig
relay <# "#updates> link to join channel #news (signed):"
_ <- getTermLine2 relay -- link, ownerSig
bob <# "#updates> link to join channel #news (signed): [>>]"
(cLink, cSig) <- getTermLine2 bob
cLink `shouldBe` (sLink1 <> " [>>]")
cSig `shouldBe` apiOwnerSig
-- bob verifies alice's signature via connect plan
bob ##> ("/_connect plan 1 " <> sLink1 <> " sig=" <> cSig)
bob <## "group link: ok to connect via relays"
bob <## "owner signature: verified"
_ <- getTermLine bob -- group link data
-- bob joins "news" (group #2 for bob, relay knows bob from "updates" so sfx=1)
memberJoinChannel' "news" 2 1 1 1 [relay] [alice] sLink1 fLink1 bob
-- bob creates channel "bob_ch" for delivery to cath
bob ##> ("/relays name=relay " <> relaySLink)
bob <## "ok"
(sLink3, fLink3) <- prepareChannel "bob_ch" bob relay
memberJoinChannel "bob_ch" [relay] [bob] sLink3 fLink3 cath
-- bob (subscriber) shares "news" to "bob_ch" - unsigned (not owner)
bob ##> "/share chat #news #bob_ch"
bob <# "#bob_ch link to join channel #news:"
_ <- getTermLine bob
relay <# "#bob_ch> link to join channel #news:"
_ <- getTermLine relay
cath <# "#bob_ch> link to join channel #news: [>>]"
_ <- getTermLine cath
-- bob tries to replay alice's signed card to bob_ch - binding mismatch, sig stripped at receive
let sig = fromMaybe (error "bad sig") (decodeJSON (T.pack cSig) :: Maybe LinkOwnerSig)
cLink' = either error id $ strDecode (B.pack sLink1)
mc = MCChat (T.pack sLink1) (MCLGroup cLink' (testGroupProfile {displayName = "news"} :: GroupProfile)) (Just sig)
cm = "{\"msgContent\":" <> LB.unpack (J.encode mc) <> "}"
bob ##> ("/_send #3 json [" <> cm <> "]")
bob <# "#bob_ch link to join group #news (signed):"
_ <- getTermLine2 bob -- bob's testView ownerSig (his sent has the sig data)
relay <# "#bob_ch bob_2> link to join group #news:"
_ <- getTermLine relay
cath <# "#bob_ch bob> link to join group #news: [>>]"
_ <- getTermLine cath
-- cath joins "news" (group #2 for cath since "bob_ch" is #1)
memberJoinChannel' "news" 2 1 0 1 [relay] [alice] sLink1 fLink1 cath
-- alice sends message, both receive
alice #> "#news hello"
relay <# "#news> hello"
[bob, cath] *<# "#news> hello [>>]"
getTermLine2 :: TestCC -> IO (String, String)
getTermLine2 c = (,) <$> getTermLine c <*> getTermLine c
testRelayWebCapabilities :: HasCallStack => TestParams -> IO ()
testRelayWebCapabilities ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps (relayWebTestOpts "https://relay.example.com/preview" (tmpPath ps </> "web_cap")) "bob" bobProfile $ \relay -> do
rName <- userName relay
relay ##> "/ad"
(relaySLink, _cLink) <- getContactLinks relay True
alice ##> ("/relays name=" <> rName <> " " <> relaySLink)
alice <## "ok"
alice ##> "/public group relays=1 #news"
alice <## "group #news is created"
alice <## "wait for selected relay(s) to join, then you can invite members via group link"
concurrentlyN_
[ do
alice <## "#news: group link relays updated, current relays:"
alice <## " - relay id 1: active, web: https://relay.example.com/preview"
alice <## "group link:"
_ <- getTermLine alice
pure (),
relay <## "#news: you joined the group as relay"
]
testWebPreviewRender :: HasCallStack => TestParams -> IO ()
testWebPreviewRender ps = do
let webDir = tmpPath ps </> "web_preview"
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps (relayWebTestOpts "https://relay.example.com/preview" webDir) "bob" bobProfile $ \relay -> do
_ <- setupRelay alice relay
alice ##> "/public group relays=1 #news"
alice <## "group #news is created"
alice <## "wait for selected relay(s) to join, then you can invite members via group link"
concurrentlyN_
[ do
alice <## "#news: group link relays updated, current relays:"
alice <## " - relay id 1: active, web: https://relay.example.com/preview"
alice <## "group link:"
_ <- getTermLine alice
pure (),
relay <## "#news: you joined the group as relay"
]
alice #> "#news hello from the channel"
relay <# "#news> hello from the channel"
alice #> "#news second message"
relay <# "#news> second message"
threadDelay 500000
let cc = chatController relay
ChatConfig {webPreviewConfig = cfg_} = config cc
Just user <- readTVarIO (currentUser cc)
case cfg_ of
Nothing -> error "no web preview config"
Just cfg -> do
renderWebPreviews cfg cc user
files <- listDirectory webDir
length files `shouldBe` 1
let jsonFile = webDir </> head files
jsonBytes <- LB.readFile jsonFile
case J.eitherDecode jsonBytes of
Left err -> error $ "JSON decode error: " <> err
Right (wPreview :: WebChannelPreview) -> do
length (messages wPreview) `shouldBe` 2
let WebMessage {content = mc1} = messages wPreview !! 0
WebMessage {content = mc2} = messages wPreview !! 1
mc1 `shouldBe` MCText "hello from the channel"
mc2 `shouldBe` MCText "second message"
testWebPreviewCors :: HasCallStack => TestParams -> IO ()
testWebPreviewCors ps = do
let corsFile = tmpPath ps </> "simplex-cors.conf"
entries =
[ ("abc123.json", CorsAny),
("def456.json", CorsOrigins ["https://owner-site.com"]),
("ghi789.json", CorsOrigins [])
]
writeCorsConfig entries corsFile
corsContent <- readFile corsFile
corsContent `shouldContain` "/preview/abc123.json \"*\""
corsContent `shouldContain` "/preview/def456.json \"https://owner-site.com\""
corsContent `shouldContain` "# ghi789.json (no origin configured)"
corsContent `shouldContain` "Access-Control-Allow-Origin"
-- Create a public group with relay=1, wait for relay to join
createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO ()
createChannelWithRelay gName owner relay = do
owner ##> ("/public group relays=1 #" <> gName)
owner <## ("group #" <> gName <> " is created")
owner <## "wait for selected relay(s) to join, then you can invite members via group link"
concurrentlyN_
[ do
owner <## ("#" <> gName <> ": group link relays updated, current relays:")
owner <## " - relay id 1: active"
owner <## "group link:"
_ <- getTermLine owner
pure (),
relay <## ("#" <> gName <> ": you joined the group as relay")
]