branch: elpa/jabber
commit 7fde8db5e5ec6a9958c436ff19fdea6d43ffbda1
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
srv: Add XEP-0368 direct TLS support
Query both _xmpps-client._tcp and _xmpp-client._tcp SRV records,
merge by priority and weight per RFC 2782. Direct TLS targets use
make-network-process with :tls-parameters for TLS-on-connect with
SNI. The FSM skips STARTTLS when already encrypted.
Includes per-target connection timeout, always-append domain:5222
fallback, jabber-direct-tls-lookup defcustom, and 17 unit tests.
---
doap.xml | 9 ++
lisp/jabber-conn.el | 194 ++++++++++++++++++++++++++-------------
lisp/jabber-core.el | 9 +-
lisp/jabber-srv.el | 91 ++++++++++++++++---
tests/jabber-srv-tests.el | 224 ++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 451 insertions(+), 76 deletions(-)
diff --git a/doap.xml b/doap.xml
index 58a4b4cbbb..8e85ad132e 100644
--- a/doap.xml
+++ b/doap.xml
@@ -464,6 +464,15 @@
<xmpp:since>0.8.0</xmpp:since>
</xmpp:SupportedXep>
</implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0368.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.1.0</xmpp:version>
+ <xmpp:since>0.10.1-alpha</xmpp:since>
+ <xmpp:note>ALPN not supported (Emacs GnuTLS bindings lack ALPN API).
Servers multiplexing on port 443 that require ALPN will fall through to
STARTTLS targets.</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0373.html"/>
diff --git a/lisp/jabber-conn.el b/lisp/jabber-conn.el
index d8ffd67197..bf27e69dcb 100644
--- a/lisp/jabber-conn.el
+++ b/lisp/jabber-conn.el
@@ -72,6 +72,15 @@ of your JID.
This option has effect only when using native GnuTLS."
:type '(repeat string))
+(defcustom jabber-direct-tls-lookup t
+ "Whether to query _xmpps-client SRV records for direct TLS.
+When non-nil, `jabber-srv-targets' queries both _xmpps-client._tcp
+and _xmpp-client._tcp SRV records per XEP-0368, merging them by
+priority and weight. Direct TLS targets use TLS-on-connect without
+a STARTTLS upgrade."
+ :type 'boolean
+ :group 'jabber-conn)
+
(defvar jabber-connect-methods
'((network jabber-network-connect jabber-network-send)
(starttls jabber-network-connect jabber-network-send)
@@ -108,17 +117,27 @@ TYPE is a symbol; see `jabber-connection-type'."
(nth 2 entry)))
(defun jabber-srv-targets (server network-server port)
- "Find host and port to connect to.
-If NETWORK-SERVER and/or PORT are specified, use them.
-If we can't find SRV records, use standard defaults."
- ;; If the user has specified a host or a port, obey that.
+ "Find connection targets for SERVER.
+If NETWORK-SERVER and/or PORT are specified, use them (always STARTTLS).
+Otherwise query SRV records; when `jabber-direct-tls-lookup' is non-nil,
+query both _xmpps-client and _xmpp-client per XEP-0368.
+
+Returns a list of (HOST PORT DIRECTTLS-P) where DIRECTTLS-P is
+non-nil for direct TLS targets."
(if (or network-server port)
- (list (cons (or network-server server)
- (or port 5222)))
+ ;; User override: cannot assume direct TLS without SRV.
+ (list (list (or network-server server)
+ (or port 5222)
+ nil))
(or (condition-case nil
- (jabber-srv-lookup (concat "_xmpp-client._tcp." server))
+ (if jabber-direct-tls-lookup
+ (jabber-srv-lookup-mixed server)
+ (mapcar (lambda (pair)
+ (list (car pair) (cdr pair) nil))
+ (jabber-srv-lookup
+ (concat "_xmpp-client._tcp." server))))
(error nil))
- (list (cons server 5222)))))
+ (list (list server 5222 nil)))))
;; Plain TCP/IP connection
(defun jabber-network-connect (fsm server network-server port)
@@ -128,6 +147,39 @@ connection succeeds. Send a message (:connection-failed
ERRORS) if
connection fails."
(jabber-network-connect-async fsm server network-server port))
+(defun jabber-conn--tls-parameters (server)
+ "Build :tls-parameters for direct TLS to SERVER.
+SERVER is the JID domain, used for SNI and certificate verification."
+ (let ((verifyp (not (member server jabber-invalid-certificate-servers))))
+ (cons 'gnutls-x509pki
+ (gnutls-boot-parameters
+ :type 'gnutls-x509pki
+ :hostname server
+ :verify-hostname-error verifyp
+ :verify-error verifyp))))
+
+(defcustom jabber-connection-timeout 30
+ "Seconds to wait for each connection target before trying the next.
+Set to nil to disable the per-target timeout and rely on the OS
+TCP timeout instead."
+ :type '(choice (integer :tag "Seconds")
+ (const :tag "No timeout" nil))
+ :group 'jabber-conn)
+
+(defun jabber-conn--make-process (host port buffer directtls-p server)
+ "Create a network process connecting to HOST:PORT in BUFFER.
+When DIRECTTLS-P is non-nil, use TLS-on-connect with SNI for SERVER."
+ (let ((args (list :name "jabber"
+ :buffer buffer
+ :host host :service port
+ :coding 'utf-8
+ :nowait t)))
+ (when directtls-p
+ (setq args (nconc args
+ (list :tls-parameters
+ (jabber-conn--tls-parameters server)))))
+ (apply #'make-network-process args)))
+
(defun jabber-network-connect-async (fsm server network-server port)
;; Get all potential targets...
(let ((targets (jabber-srv-targets server network-server port))
@@ -137,59 +189,79 @@ connection fails."
(cl-labels
((connect
(target remaining-targets)
- (cl-labels ((connection-successful
- (c)
- ;; This mustn't be `fsm-send-sync', because the FSM
- ;; needs to change the sentinel, which cannot be done
- ;; from inside the sentinel.
- (fsm-send fsm (list :connected c)))
- (connection-failed
- (c status)
- (when (and (> (length status) 0)
- (eq (aref status (1- (length status))) ?\n))
- (setq status (substring status 0 -1)))
- (let ((err
- (format "Couldn't connect to %s:%s: %s"
- (car target) (cdr target) status)))
- (message "%s" err)
- (push err errors))
- (when c (delete-process c))
- (if remaining-targets
- (progn
- (message
- "Connecting to %s:%s..."
- (caar remaining-targets) (cdar
remaining-targets))
- (connect (car remaining-targets) (cdr
remaining-targets)))
- (fsm-send fsm (list :connection-failed (nreverse
errors))))))
- (condition-case e
- (make-network-process
- :name "jabber"
- :buffer (generate-new-buffer jabber-process-buffer)
- :host (car target) :service (cdr target)
- :coding 'utf-8
- :nowait t
- :sentinel
- (lambda (connection status)
- (cond
- ((string-match "^open" status)
- (connection-successful connection))
- ((string-match "^failed" status)
- (connection-failed connection status))
- ((string-match "^deleted" status)
- ;; This happens when we delete a process in the
- ;; "failed" case above.
- nil)
- (t
- (message "Unknown sentinel status `%s'" status)))))
- (file-error
- ;; A file-error has the error message in the third list
- ;; element.
- (connection-failed nil (car (cddr e))))
- (error
- ;; Not sure if we ever get anything but file-errors,
- ;; but let's make sure we report them:
- (connection-failed nil (error-message-string e)))))))
- (message "Connecting to %s:%s..." (caar targets) (cdar targets))
+ (let ((host (nth 0 target))
+ (svc (nth 1 target))
+ (directtls-p (nth 2 target))
+ (timeout-timer nil)
+ (settled nil))
+ (cl-labels ((cancel-timeout
+ ()
+ (when timeout-timer
+ (cancel-timer timeout-timer)
+ (setq timeout-timer nil)))
+ (connection-successful
+ (c)
+ (unless settled
+ (setq settled t)
+ (cancel-timeout)
+ ;; This mustn't be `fsm-send-sync', because the FSM
+ ;; needs to change the sentinel, which cannot be
done
+ ;; from inside the sentinel.
+ (fsm-send fsm (list :connected c directtls-p))))
+ (connection-failed
+ (c status)
+ (unless settled
+ (setq settled t)
+ (cancel-timeout)
+ (when (and (> (length status) 0)
+ (eq (aref status (1- (length status)))
?\n))
+ (setq status (substring status 0 -1)))
+ (let ((err
+ (format "Couldn't connect to %s:%s: %s"
+ host svc status)))
+ (message "%s" err)
+ (push err errors))
+ (when c (delete-process c))
+ (if remaining-targets
+ (progn
+ (message
+ "Connecting to %s:%s..."
+ (nth 0 (car remaining-targets))
+ (nth 1 (car remaining-targets)))
+ (connect (car remaining-targets)
+ (cdr remaining-targets)))
+ (fsm-send fsm (list :connection-failed
+ (nreverse errors)))))))
+ (condition-case e
+ (let ((proc (jabber-conn--make-process
+ host svc
+ (generate-new-buffer jabber-process-buffer)
+ directtls-p server)))
+ (set-process-sentinel
+ proc
+ (lambda (connection status)
+ (cond
+ ((string-match "^open" status)
+ (connection-successful connection))
+ ((string-match "^failed" status)
+ (connection-failed connection status))
+ ((string-match "^deleted" status)
+ nil)
+ (t
+ (message "Unknown sentinel status `%s'" status)))))
+ (when jabber-connection-timeout
+ (setq timeout-timer
+ (run-at-time
+ jabber-connection-timeout nil
+ (lambda ()
+ (connection-failed
+ proc "connection timed out"))))))
+ (file-error
+ (connection-failed nil (car (cddr e))))
+ (error
+ (connection-failed nil (error-message-string e))))))))
+ (message "Connecting to %s:%s..."
+ (nth 0 (car targets)) (nth 1 (car targets)))
(connect (car targets) (cdr targets)))))
(defun jabber-network-send (connection string)
diff --git a/lisp/jabber-core.el b/lisp/jabber-core.el
index 3be00428a7..37a3c08ef1 100644
--- a/lisp/jabber-core.el
+++ b/lisp/jabber-core.el
@@ -437,9 +437,12 @@ With double prefix argument, specify more connection
details."
(pcase (or (car-safe event) event)
(:connected
(let ((connection (cadr event))
- ) ;; (registerp (plist-get state-data :registerp))
+ (directtls-p (caddr event)))
(setq state-data (plist-put state-data :connection connection))
+ ;; Direct TLS (XEP-0368): connection is already encrypted.
+ (when directtls-p
+ (setq state-data (plist-put state-data :encrypted t)))
(when (processp connection)
;; TLS connections leave data in the process buffer, which
@@ -535,7 +538,9 @@ With double prefix argument, specify more connection
details."
:disconnection-reason
(format "Unexpected stanza %s" stanza))))
((and (jabber-xml-get-children stanza 'starttls)
- (eq (plist-get state-data :connection-type) 'starttls))
+ (eq (plist-get state-data :connection-type) 'starttls)
+ ;; XEP-0368: STARTTLS MUST NOT be used over direct TLS.
+ (not (plist-get state-data :encrypted)))
(list :starttls state-data))
;; XXX: require encryption for registration?
((plist-get state-data :registerp)
diff --git a/lisp/jabber-srv.el b/lisp/jabber-srv.el
index a729568203..0552bcb48d 100644
--- a/lisp/jabber-srv.el
+++ b/lisp/jabber-srv.el
@@ -94,6 +94,79 @@ Returns the entries reordered by weighted random selection."
(setq weight-order (delq next weight-order))))
(nreverse result)))
+(defun jabber-srv--fetch-answers (target)
+ "Perform DNS SRV query for TARGET and return parsed answer records.
+Returns a list of alists, each containing priority, weight, port,
+and target entries. Returns nil if no records found, or `:dot' if
+the single-dot target (\"service not available\") was returned."
+ (let* ((result (jabber-srv--dns-query target))
+ (answers (mapcar (lambda (a) (cadr (assq 'data a)))
+ (cadr (assq 'answers result)))))
+ (cond
+ ((null answers) nil)
+ ((and (length= answers 1)
+ (string= (cadr (assq 'target (car answers))) "."))
+ :dot)
+ (t answers))))
+
+(defun jabber-srv--sort-answers (answers)
+ "Sort ANSWERS by priority with weighted randomization per RFC 2782.
+ANSWERS is a list of alists as returned by `jabber-srv--fetch-answers'.
+Returns the entries in connection-attempt order."
+ (let (ordered)
+ (dolist (group (jabber-srv--group-by-priority answers))
+ (setq ordered (nconc ordered
+ (jabber-srv--weighted-select (cdr group)))))
+ ordered))
+
+(defun jabber-srv--tag-answers (answers directtls-p)
+ "Tag each record in ANSWERS with DIRECTTLS-P flag.
+Adds a (directtls DIRECTTLS-P) entry to each alist so the flag
+survives the priority/weight sort pipeline."
+ (mapcar (lambda (a) (cons (list 'directtls directtls-p) a))
+ answers))
+
+(defun jabber-srv--has-fallback-p (targets server)
+ "Return non-nil if TARGETS already includes SERVER on port 5222 via
STARTTLS."
+ (cl-some (lambda (t_)
+ (and (string= (nth 0 t_) server)
+ (= (nth 1 t_) 5222)
+ (not (nth 2 t_))))
+ targets))
+
+;;;###autoload
+(defun jabber-srv-lookup-mixed (server)
+ "Query both _xmpps-client and _xmpp-client SRV records for SERVER.
+Merges results by priority and weight per RFC 2782. Returns a list
+of (HOST PORT DIRECTTLS-P) where DIRECTTLS-P is non-nil for targets
+from _xmpps-client._tcp (XEP-0368 direct TLS).
+
+Always appends SERVER:5222 STARTTLS as a lowest-priority fallback
+unless the SRV results already include it."
+ (let ((xmpps (condition-case nil
+ (jabber-srv--fetch-answers
+ (concat "_xmpps-client._tcp." server))
+ (error nil)))
+ (xmpp (condition-case nil
+ (jabber-srv--fetch-answers
+ (concat "_xmpp-client._tcp." server))
+ (error nil))))
+ ;; :dot means "service explicitly unavailable"
+ (when (eq xmpps :dot) (setq xmpps nil))
+ (when (eq xmpp :dot) (setq xmpp nil))
+ (let ((merged (nconc (jabber-srv--tag-answers xmpps t)
+ (jabber-srv--tag-answers xmpp nil))))
+ (when merged
+ (let ((result (mapcar (lambda (a)
+ (list (cadr (assq 'target a))
+ (cadr (assq 'port a))
+ (cadr (assq 'directtls a))))
+ (jabber-srv--sort-answers merged))))
+ ;; Append domain:5222 STARTTLS fallback if not already present.
+ (unless (jabber-srv--has-fallback-p result server)
+ (setq result (nconc result (list (list server 5222 nil)))))
+ result)))))
+
;;;###autoload
(defun jabber-srv-lookup (target)
"Perform SRV lookup of TARGET and return connection candidates.
@@ -102,19 +175,11 @@ TARGET is a string of the form \"_Service._Proto.Name\".
Returns a list of (HOST . PORT) pairs sorted by priority with
weighted randomization per RFC 2782. The caller should attempt
connections in order. Returns nil if no SRV records were found."
- (let* ((result (jabber-srv--dns-query target))
- (answers (mapcar (lambda (a) (cadr (assq 'data a)))
- (cadr (assq 'answers result)))))
- (when (and answers
- (not (and (length= answers 1)
- (string= (cadr (assq 'target (car answers))) "."))))
- (let (ordered)
- (dolist (group (jabber-srv--group-by-priority answers))
- (setq ordered (nconc ordered
- (jabber-srv--weighted-select (cdr group)))))
- (mapcar (lambda (a) (cons (cadr (assq 'target a))
- (cadr (assq 'port a))))
- ordered)))))
+ (let ((answers (jabber-srv--fetch-answers target)))
+ (when (and answers (not (eq answers :dot)))
+ (mapcar (lambda (a) (cons (cadr (assq 'target a))
+ (cadr (assq 'port a))))
+ (jabber-srv--sort-answers answers)))))
(provide 'jabber-srv)
diff --git a/tests/jabber-srv-tests.el b/tests/jabber-srv-tests.el
new file mode 100644
index 0000000000..a95aabb4f7
--- /dev/null
+++ b/tests/jabber-srv-tests.el
@@ -0,0 +1,224 @@
+;;; jabber-srv-tests.el --- Tests for jabber-srv -*- lexical-binding: t; -*-
+
+(require 'ert)
+(require 'jabber-srv)
+(require 'jabber-conn)
+
+;;; Test data helpers
+
+(defun jabber-srv-test--make-answer (priority weight port target)
+ "Build an SRV answer alist."
+ (list (list 'priority priority)
+ (list 'weight weight)
+ (list 'port port)
+ (list 'target target)))
+
+;;; Group by priority
+
+(ert-deftest jabber-srv-test-group-by-priority-single ()
+ "Single priority group."
+ (let* ((a1 (jabber-srv-test--make-answer 10 50 5222 "a.example.com"))
+ (a2 (jabber-srv-test--make-answer 10 50 5222 "b.example.com"))
+ (groups (jabber-srv--group-by-priority (list a1 a2))))
+ (should (= (length groups) 1))
+ (should (= (caar groups) 10))
+ (should (= (length (cdar groups)) 2))))
+
+(ert-deftest jabber-srv-test-group-by-priority-multiple ()
+ "Multiple priority groups sorted lowest first."
+ (let* ((a1 (jabber-srv-test--make-answer 20 50 5222 "low.example.com"))
+ (a2 (jabber-srv-test--make-answer 5 50 5222 "high.example.com"))
+ (a3 (jabber-srv-test--make-answer 20 50 5223 "low2.example.com"))
+ (groups (jabber-srv--group-by-priority (list a1 a2 a3))))
+ (should (= (length groups) 2))
+ (should (= (caar groups) 5))
+ (should (= (caadr groups) 20))
+ (should (= (length (cdadr groups)) 2))))
+
+;;; Sort answers
+
+(ert-deftest jabber-srv-test-sort-answers-preserves-all ()
+ "Sort preserves all elements."
+ (let* ((a1 (jabber-srv-test--make-answer 10 50 5222 "a.example.com"))
+ (a2 (jabber-srv-test--make-answer 20 50 5222 "b.example.com"))
+ (a3 (jabber-srv-test--make-answer 10 50 5223 "c.example.com"))
+ (sorted (jabber-srv--sort-answers (list a1 a2 a3))))
+ (should (= (length sorted) 3))
+ ;; Priority 10 entries come before priority 20
+ (let ((targets (mapcar (lambda (a) (cadr (assq 'target a))) sorted)))
+ (should (member "a.example.com" targets))
+ (should (member "b.example.com" targets))
+ (should (member "c.example.com" targets))
+ ;; b.example.com (priority 20) must be last
+ (should (string= (nth 2 targets) "b.example.com")))))
+
+;;; Fetch answers
+
+(ert-deftest jabber-srv-test-fetch-answers-nil ()
+ "No DNS results returns nil."
+ (cl-letf (((symbol-function 'jabber-srv--dns-query)
+ (lambda (_target)
+ '((answers nil)))))
+ (should (null (jabber-srv--fetch-answers
"_xmpp-client._tcp.example.com")))))
+
+(ert-deftest jabber-srv-test-fetch-answers-dot ()
+ "Single dot target returns :dot."
+ (cl-letf (((symbol-function 'jabber-srv--dns-query)
+ (lambda (_target)
+ `((answers
+ (((data ((priority 0) (weight 0) (port 0) (target
"."))))))))))
+ (should (eq :dot (jabber-srv--fetch-answers
+ "_xmpp-client._tcp.example.com")))))
+
+(ert-deftest jabber-srv-test-fetch-answers-records ()
+ "Normal records returned as list."
+ (cl-letf (((symbol-function 'jabber-srv--dns-query)
+ (lambda (_target)
+ `((answers
+ (((data ((priority 10) (weight 50) (port 5222)
+ (target "xmpp.example.com"))))))))))
+ (let ((result (jabber-srv--fetch-answers "_xmpp-client._tcp.example.com")))
+ (should (listp result))
+ (should (= (length result) 1))
+ (should (string= (cadr (assq 'target (car result)))
+ "xmpp.example.com")))))
+
+;;; Lookup mixed
+
+(defun jabber-srv-test--mock-fetch (xmpps-answers xmpp-answers)
+ "Return a mock for `jabber-srv--fetch-answers'.
+XMPPS-ANSWERS is returned for _xmpps queries, XMPP-ANSWERS for _xmpp."
+ (lambda (target)
+ (cond
+ ((string-match "^_xmpps-client" target) xmpps-answers)
+ ((string-match "^_xmpp-client" target) xmpp-answers))))
+
+(ert-deftest jabber-srv-test-lookup-mixed-both ()
+ "Both services return records, merged by priority, with fallback appended."
+ (cl-letf (((symbol-function 'jabber-srv--fetch-answers)
+ (jabber-srv-test--mock-fetch
+ (list (jabber-srv-test--make-answer 5 50 443 "tls.example.com"))
+ (list (jabber-srv-test--make-answer 10 50 5222
"plain.example.com")))))
+ (let ((result (jabber-srv-lookup-mixed "example.com")))
+ ;; 2 SRV records + 1 fallback
+ (should (= (length result) 3))
+ ;; Priority 5 (direct TLS) comes first
+ (should (string= (nth 0 (car result)) "tls.example.com"))
+ (should (= (nth 1 (car result)) 443))
+ (should (nth 2 (car result)))
+ ;; Priority 10 (STARTTLS) comes second
+ (should (string= (nth 0 (cadr result)) "plain.example.com"))
+ (should (= (nth 1 (cadr result)) 5222))
+ (should-not (nth 2 (cadr result)))
+ ;; Fallback: example.com:5222 STARTTLS at the end
+ (let ((last (car (last result))))
+ (should (string= (nth 0 last) "example.com"))
+ (should (= (nth 1 last) 5222))
+ (should-not (nth 2 last))))))
+
+(ert-deftest jabber-srv-test-lookup-mixed-only-xmpp ()
+ "Only _xmpp-client returns records, fallback appended."
+ (cl-letf (((symbol-function 'jabber-srv--fetch-answers)
+ (jabber-srv-test--mock-fetch
+ nil
+ (list (jabber-srv-test--make-answer 10 50 5222
"plain.example.com")))))
+ (let ((result (jabber-srv-lookup-mixed "example.com")))
+ (should (= (length result) 2))
+ (should (string= (nth 0 (car result)) "plain.example.com"))
+ (should-not (nth 2 (car result)))
+ ;; Fallback appended
+ (should (equal (cadr result) '("example.com" 5222 nil))))))
+
+(ert-deftest jabber-srv-test-lookup-mixed-only-xmpps ()
+ "Only _xmpps-client returns records, with STARTTLS fallback."
+ (cl-letf (((symbol-function 'jabber-srv--fetch-answers)
+ (jabber-srv-test--mock-fetch
+ (list (jabber-srv-test--make-answer 5 50 443 "tls.example.com"))
+ nil)))
+ (let ((result (jabber-srv-lookup-mixed "example.com")))
+ (should (= (length result) 2))
+ (should (string= (nth 0 (car result)) "tls.example.com"))
+ (should (nth 2 (car result)))
+ ;; Fallback
+ (should (equal (cadr result) '("example.com" 5222 nil))))))
+
+(ert-deftest jabber-srv-test-lookup-mixed-xmpps-dot ()
+ "xmpps-client returns dot, only STARTTLS targets with fallback."
+ (cl-letf (((symbol-function 'jabber-srv--fetch-answers)
+ (jabber-srv-test--mock-fetch
+ :dot
+ (list (jabber-srv-test--make-answer 10 50 5222
"plain.example.com")))))
+ (let ((result (jabber-srv-lookup-mixed "example.com")))
+ (should (= (length result) 2))
+ (should-not (nth 2 (car result)))
+ (should (equal (cadr result) '("example.com" 5222 nil))))))
+
+(ert-deftest jabber-srv-test-lookup-mixed-neither ()
+ "Neither service returns records."
+ (cl-letf (((symbol-function 'jabber-srv--fetch-answers)
+ (jabber-srv-test--mock-fetch nil nil)))
+ (should (null (jabber-srv-lookup-mixed "example.com")))))
+
+(ert-deftest jabber-srv-test-lookup-mixed-xmpps-error ()
+ "DNS error for xmpps is caught, xmpp results still used with fallback."
+ (cl-letf (((symbol-function 'jabber-srv--fetch-answers)
+ (lambda (target)
+ (cond
+ ((string-match "^_xmpps-client" target)
+ (error "DNS query failed"))
+ ((string-match "^_xmpp-client" target)
+ (list (jabber-srv-test--make-answer 10 50 5222
+ "plain.example.com")))))))
+ (let ((result (jabber-srv-lookup-mixed "example.com")))
+ (should (= (length result) 2))
+ (should (string= (nth 0 (car result)) "plain.example.com"))
+ (should (equal (cadr result) '("example.com" 5222 nil))))))
+
+;;; Fallback dedup
+
+(ert-deftest jabber-srv-test-has-fallback-p ()
+ "Detect existing domain:5222 STARTTLS in target list."
+ (should (jabber-srv--has-fallback-p
+ '(("example.com" 5222 nil)) "example.com"))
+ (should-not (jabber-srv--has-fallback-p
+ '(("other.example.com" 5222 nil)) "example.com"))
+ (should-not (jabber-srv--has-fallback-p
+ '(("example.com" 443 t)) "example.com")))
+
+(ert-deftest jabber-srv-test-lookup-mixed-no-dup-fallback ()
+ "No fallback appended when SRV already includes domain:5222 STARTTLS."
+ (cl-letf (((symbol-function 'jabber-srv--fetch-answers)
+ (jabber-srv-test--mock-fetch
+ nil
+ (list (jabber-srv-test--make-answer 10 50 5222 "example.com")))))
+ (let ((result (jabber-srv-lookup-mixed "example.com")))
+ ;; SRV already has example.com:5222, no dup fallback
+ (should (= (length result) 1))
+ (should (equal (car result) '("example.com" 5222 nil))))))
+
+;;; jabber-srv-targets
+
+(ert-deftest jabber-srv-test-targets-user-override ()
+ "User-specified network-server returns single STARTTLS target."
+ (let ((result (jabber-srv-targets "example.com" "custom.host" nil)))
+ (should (= (length result) 1))
+ (should (equal (car result) '("custom.host" 5222 nil)))))
+
+(ert-deftest jabber-srv-test-targets-user-port ()
+ "User-specified port returns single STARTTLS target."
+ (let ((result (jabber-srv-targets "example.com" nil 5223)))
+ (should (= (length result) 1))
+ (should (equal (car result) '("example.com" 5223 nil)))))
+
+(ert-deftest jabber-srv-test-targets-fallback ()
+ "No SRV records falls back to server:5222."
+ (cl-letf (((symbol-function 'jabber-srv-lookup-mixed)
+ (lambda (_server) nil)))
+ (let ((jabber-direct-tls-lookup t))
+ (let ((result (jabber-srv-targets "example.com" nil nil)))
+ (should (= (length result) 1))
+ (should (equal (car result) '("example.com" 5222 nil)))))))
+
+(provide 'jabber-srv-tests)
+
+;;; jabber-srv-tests.el ends here