mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-10 05:41:56 +00:00
1f9b6e29de
dispatchResolvedRecord now picks the first nrContactLinks (NTContact) or nrChannelLinks (NTPublicGroup) entry from the resolved record, decodes it as AConnShortLink, fetches the short-link data, and eagerly calls createPreparedContact / createPreparedGroup with the simplex_name set. Returning CPContactAddress (CAPKnown ct) / CPGroupLink (GLPKnown g ...) mirrors the local-store-hit branch of connectPlanName: hit and miss converge on the same plan shape, so the connectWithPlan caller cannot distinguish where the prepared row came from. Threading uses the existing Maybe SimplexNameInfo parameter added in c6f26150 for the local-prepare path -- no new write path or transient carrier. Pure helper firstNameLink is extracted and exported so the link-picker contract is testable without a DB / agent. ResolveNameTests gains five cases covering the per-type selection, the first-link policy, and the empty-list to CESimplexNameNotFound collapse.
160 lines
8.6 KiB
Haskell
160 lines
8.6 KiB
Haskell
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE PatternSynonyms #-}
|
|
|
|
module ResolveNameTests (resolveNameTests) where
|
|
|
|
import Data.Functor.Identity (Identity (..))
|
|
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
|
|
import qualified Data.Map.Strict as M
|
|
import Data.Text (Text)
|
|
import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..))
|
|
import Simplex.Chat.Library.Commands (ResolveError (..), firstNameLink, iterateResolvers, resolveErrorToChatError)
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), SimplexNameDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..))
|
|
import Simplex.Messaging.Client (ProxyClientError (..))
|
|
import Simplex.Messaging.Protocol (NameLink, NameRecord (..), SMPServer, mkNameLink, mkNameOwner, pattern SMPServer)
|
|
import qualified Simplex.Messaging.Protocol as SMP
|
|
import Test.Hspec
|
|
|
|
resolveNameTests :: Spec
|
|
resolveNameTests = do
|
|
-- iterateResolvers is the testable core of resolveOnUserServers: it walks
|
|
-- a list of candidate SMP servers, querying a resolver per server, applying
|
|
-- the AUTH-is-NotFound and PROHIBITED-is-skip rules from the plan.
|
|
describe "iterateResolvers" $ do
|
|
it "returns the first server's NameRecord on hit" $ do
|
|
let r = runIdentity $ iterateResolvers [srv1, srv2] $ \_ -> pure $ Right sampleRecord
|
|
r `shouldBe` Right sampleRecord
|
|
it "skips CMD PROHIBITED servers and uses the next one's success" $ do
|
|
callsRef <- newIORef []
|
|
r <- iterateResolvers [srv1, srv2] (recording callsRef stubProhibitedThenHit)
|
|
r `shouldBe` Right sampleRecord
|
|
-- both servers must be consulted, in order
|
|
readIORef callsRef `shouldReturn` [srv1, srv2]
|
|
it "treats AUTH as definitive NameNotRegistered and stops iteration" $ do
|
|
callsRef <- newIORef []
|
|
r <- iterateResolvers [srv1, srv2] (recording callsRef stubAuthThenHit)
|
|
r `shouldBe` Left NameNotRegistered
|
|
-- second server must NOT be consulted: AUTH is authoritative
|
|
readIORef callsRef `shouldReturn` [srv1]
|
|
it "returns ResolverUnavailable when every server is non-name-capable" $ do
|
|
let r = runIdentity $ iterateResolvers [srv1, srv2] (\_ -> pure $ Left prohibitedErr)
|
|
r `shouldBe` Left ResolverUnavailable
|
|
it "returns ResolverTransport when only transport-style errors are seen" $ do
|
|
let r = runIdentity $ iterateResolvers [srv1, srv2] (\_ -> pure $ Left timeoutErr)
|
|
case r of
|
|
Left (ResolverTransport _) -> pure ()
|
|
other -> expectationFailure $ "expected ResolverTransport, got " <> show other
|
|
it "returns ResolverUnavailable on an empty server list" $ do
|
|
let r = runIdentity $ iterateResolvers [] (\_ -> pure $ Right sampleRecord)
|
|
r `shouldBe` Left ResolverUnavailable
|
|
-- resolveErrorToChatError is the pure mapping used by connectPlanName's
|
|
-- resolveAndDispatch: it converts the resolver outcome (when not a hit) to
|
|
-- the user-visible ChatError. The success path (Right NameRecord) is dispatched
|
|
-- via dispatchResolvedRecord (Task 6); we only exercise the failure mapping here.
|
|
describe "resolveErrorToChatError" $ do
|
|
it "maps NameNotRegistered to CESimplexNameNotFound (local-miss UX)" $
|
|
case resolveErrorToChatError aliceNi NameNotRegistered of
|
|
ChatError (CESimplexNameNotFound ni) -> ni `shouldBe` aliceNi
|
|
other -> expectationFailure $ "expected CESimplexNameNotFound, got " <> show other
|
|
it "maps ResolverUnavailable to CESimplexNameResolverUnavailable" $
|
|
case resolveErrorToChatError aliceNi ResolverUnavailable of
|
|
ChatError (CESimplexNameResolverUnavailable ni) -> ni `shouldBe` aliceNi
|
|
other -> expectationFailure $ "expected CESimplexNameResolverUnavailable, got " <> show other
|
|
it "wraps ResolverTransport via chatErrorAgent so the UI reuses agent-error rendering" $
|
|
case resolveErrorToChatError aliceNi (ResolverTransport timeoutErr) of
|
|
ChatErrorAgent e _ _ -> e `shouldBe` timeoutErr
|
|
other -> expectationFailure $ "expected ChatErrorAgent, got " <> show other
|
|
-- firstNameLink is the pure link-picker used by dispatchResolvedRecord:
|
|
-- it selects nrContactLinks for NTContact, nrChannelLinks for NTPublicGroup,
|
|
-- returning the first entry. Empty lists collapse to CESimplexNameNotFound
|
|
-- so the UX is identical to a local-store miss.
|
|
describe "firstNameLink" $ do
|
|
it "picks the first nrContactLinks entry for NTContact" $
|
|
case firstNameLink NTContact [channelOne] [contactOne, contactTwo] aliceNi of
|
|
Right lnk -> lnk `shouldBe` "simplex:/contact-alice"
|
|
Left e -> expectationFailure $ "expected Right, got " <> show e
|
|
it "picks the first nrChannelLinks entry for NTPublicGroup" $
|
|
case firstNameLink NTPublicGroup [channelOne, channelTwo] [contactOne] groupNi of
|
|
Right lnk -> lnk `shouldBe` "simplex:/channel-team"
|
|
Left e -> expectationFailure $ "expected Right, got " <> show e
|
|
it "returns CESimplexNameNotFound when nrContactLinks is empty for NTContact" $
|
|
case firstNameLink NTContact [channelOne] [] aliceNi of
|
|
Left (ChatError (CESimplexNameNotFound ni)) -> ni `shouldBe` aliceNi
|
|
other -> expectationFailure $ "expected CESimplexNameNotFound, got " <> show other
|
|
it "returns CESimplexNameNotFound when nrChannelLinks is empty for NTPublicGroup" $
|
|
case firstNameLink NTPublicGroup [] [contactOne] groupNi of
|
|
Left (ChatError (CESimplexNameNotFound ni)) -> ni `shouldBe` groupNi
|
|
other -> expectationFailure $ "expected CESimplexNameNotFound, got " <> show other
|
|
-- NTContact ignores nrChannelLinks even when nrContactLinks is empty.
|
|
-- The resolver-side semantics say each name advertises a per-type link
|
|
-- set; cross-type fallback would silently connect to the wrong target.
|
|
it "does not fall back to nrChannelLinks for NTContact" $
|
|
case firstNameLink NTContact [channelOne] [] aliceNi of
|
|
Left (ChatError (CESimplexNameNotFound _)) -> pure ()
|
|
other -> expectationFailure $ "expected CESimplexNameNotFound, got " <> show other
|
|
|
|
-- | Wrap a resolver to record which servers it was called for.
|
|
recording :: IORef [SMPServer] -> (SMPServer -> IO (Either AgentErrorType NameRecord)) -> SMPServer -> IO (Either AgentErrorType NameRecord)
|
|
recording ref f srv = modifyIORef' ref (<> [srv]) >> f srv
|
|
|
|
stubProhibitedThenHit :: SMPServer -> IO (Either AgentErrorType NameRecord)
|
|
stubProhibitedThenHit srv = pure $ M.findWithDefault (Right sampleRecord) srv $ M.fromList [(srv1, Left prohibitedErr)]
|
|
|
|
stubAuthThenHit :: SMPServer -> IO (Either AgentErrorType NameRecord)
|
|
stubAuthThenHit srv = pure $ M.findWithDefault (Right sampleRecord) srv $ M.fromList [(srv1, Left authErr)]
|
|
|
|
aliceNi :: SimplexNameInfo
|
|
aliceNi = SimplexNameInfo NTContact (SimplexNameDomain TLDSimplex "alice" [])
|
|
|
|
groupNi :: SimplexNameInfo
|
|
groupNi = SimplexNameInfo NTPublicGroup (SimplexNameDomain TLDSimplex "team" [])
|
|
|
|
-- Synthetic links to exercise firstNameLink's selection logic. mkNameLink only
|
|
-- enforces the ≤1024-byte upper bound; the strings are deliberately not real
|
|
-- AConnShortLink encodings — the link parser is exercised separately by the
|
|
-- A_LINK error path inside dispatchResolvedRecord and by AConnShortLink's own
|
|
-- round-trip tests in simplexmq.
|
|
mkLink :: Text -> NameLink
|
|
mkLink = either error id . mkNameLink
|
|
|
|
channelOne, channelTwo, contactOne, contactTwo :: NameLink
|
|
channelOne = mkLink "simplex:/channel-team"
|
|
channelTwo = mkLink "simplex:/channel-team-backup"
|
|
contactOne = mkLink "simplex:/contact-alice"
|
|
contactTwo = mkLink "simplex:/contact-alice-backup"
|
|
|
|
srv1 :: SMPServer
|
|
srv1 = SMPServer "smp1.example" "5223" (C.KeyHash "\1\2\3\4")
|
|
|
|
srv2 :: SMPServer
|
|
srv2 = SMPServer "smp2.example" "5223" (C.KeyHash "\5\6\7\8")
|
|
|
|
sampleRecord :: NameRecord
|
|
sampleRecord =
|
|
NameRecord
|
|
{ nrDisplayName = "alice",
|
|
-- mkNameOwner enforces the 20-byte invariant; this string is intentionally 20 ASCII bytes.
|
|
nrOwner = either error id $ mkNameOwner "owner-bytes-1234567x",
|
|
nrChannelLinks = [],
|
|
nrContactLinks = [],
|
|
nrAdminAddress = Nothing,
|
|
nrAdminEmail = Nothing,
|
|
nrExpiry = 0,
|
|
nrIsTest = True
|
|
}
|
|
|
|
-- AUTH from a name-capable destination relay: surfaces as SMP host AUTH
|
|
-- (see Simplex.Messaging.Agent.Client.protocolClientError).
|
|
authErr :: AgentErrorType
|
|
authErr = SMP "smp1.example" SMP.AUTH
|
|
|
|
-- CMD PROHIBITED on the PFWD path: surfaces as PROXY ... (ProxyProtocolError ...).
|
|
prohibitedErr :: AgentErrorType
|
|
prohibitedErr = PROXY "proxy.example" "smp1.example" (ProxyProtocolError (SMP.CMD SMP.PROHIBITED))
|
|
|
|
-- A generic transport-style failure that should bubble up as ResolverTransport
|
|
-- when no name-capable server was reached.
|
|
timeoutErr :: AgentErrorType
|
|
timeoutErr = INTERNAL "simulated network timeout"
|