Skip to content

Commit 71cc4b4

Browse files
committed
PYTHON-5773 Increase uri_parser_shared.py coverage
Add 11 new test methods to test_uri_parser.py covering previously untested branches: _unquoted_percent, parse_ipv6_literal_host, parse_host (case normalization and port boundaries), TLS option conflicts in _handle_security_options, mixed option delimiters, duplicate option warnings, empty authSource, _check_options conflicts (directConnection/loadBalanced), and SRV URI structural validation.
1 parent f41dd5c commit 71cc4b4

1 file changed

Lines changed: 172 additions & 0 deletions

File tree

test/test_uri_parser.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
from pymongo.errors import ConfigurationError, InvalidURI
3232
from pymongo.synchronous.uri_parser import parse_uri
3333
from pymongo.uri_parser_shared import (
34+
_unquoted_percent,
35+
parse_host,
36+
parse_ipv6_literal_host,
3437
parse_userinfo,
3538
split_hosts,
3639
split_options,
@@ -559,6 +562,175 @@ def test_parse_uri_options_type(self):
559562
opts = parse_uri("mongodb://localhost:27017")["options"]
560563
self.assertIsInstance(opts, dict)
561564

565+
def test_unquoted_percent(self):
566+
# Empty string and strings without percent signs are always safe.
567+
self.assertFalse(_unquoted_percent(""))
568+
self.assertFalse(_unquoted_percent("no_percent_here"))
569+
# Valid percent-encoded sequences are not flagged.
570+
self.assertFalse(_unquoted_percent("%25")) # %25 decodes to literal "%"
571+
self.assertFalse(_unquoted_percent("%40")) # %40 decodes to "@"
572+
self.assertFalse(_unquoted_percent("user%40domain.com"))
573+
self.assertFalse(_unquoted_percent("%2B")) # %2B decodes to "+"
574+
self.assertFalse(_unquoted_percent("%E2%85%A8")) # multi-byte sequence
575+
self.assertFalse(_unquoted_percent("%2525")) # double-encoded: %25 -> %
576+
# Unescaped percent signs (invalid percent encodings) are flagged.
577+
self.assertTrue(_unquoted_percent("%foo")) # 'o' is not a hex digit
578+
self.assertTrue(_unquoted_percent("50%off")) # 'o' is not a hex digit
579+
self.assertTrue(_unquoted_percent("100%")) # trailing bare %
580+
581+
def test_parse_ipv6_literal_host_direct(self):
582+
# IPv6 without explicit port uses default_port.
583+
self.assertEqual(("::1", 27017), parse_ipv6_literal_host("[::1]", 27017))
584+
self.assertEqual(("::1", None), parse_ipv6_literal_host("[::1]", None))
585+
# IPv6 with explicit port returns port as a string (int conversion
586+
# happens later in parse_host).
587+
host, port = parse_ipv6_literal_host("[::1]:27018", 27017)
588+
self.assertEqual("::1", host)
589+
self.assertEqual("27018", port)
590+
# Full-form IPv6 address without port.
591+
full_ipv6 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
592+
host, port = parse_ipv6_literal_host(f"[{full_ipv6}]", 27017)
593+
self.assertEqual(full_ipv6, host)
594+
self.assertEqual(27017, port)
595+
# Missing closing bracket must raise.
596+
self.assertRaises(ValueError, parse_ipv6_literal_host, "[::1", 27017)
597+
598+
def test_parse_host_case_normalization(self):
599+
# Hostnames are normalized to lowercase (RFC 4343).
600+
self.assertEqual(("localhost", 27017), parse_host("LOCALHOST:27017"))
601+
self.assertEqual(("example.com", 27017), parse_host("Example.COM"))
602+
self.assertEqual(("example.com", 27017), parse_host("EXAMPLE.COM:27017"))
603+
# IP addresses are unaffected but still lowercased (no-op for digits).
604+
self.assertEqual(("192.168.1.1", 27017), parse_host("192.168.1.1:27017"))
605+
# IPv6 literal addresses are lowercased.
606+
self.assertEqual(("::1", 27017), parse_host("[::1]:27017"))
607+
608+
def test_parse_host_port_boundaries(self):
609+
# Ports 1-65535 are valid.
610+
self.assertEqual(("localhost", 1), parse_host("localhost:1"))
611+
self.assertEqual(("localhost", 65535), parse_host("localhost:65535"))
612+
# Port 0 is invalid.
613+
self.assertRaises(ValueError, parse_host, "localhost:0")
614+
# Port 65536 is invalid.
615+
self.assertRaises(ValueError, parse_host, "localhost:65536")
616+
617+
def test_tls_option_conflicts(self):
618+
# tlsInsecure cannot coexist with any of the options it implicitly sets.
619+
self.assertRaises(
620+
InvalidURI, split_options, "tlsInsecure=true&tlsAllowInvalidCertificates=true"
621+
)
622+
# The conflict is based on presence, not value.
623+
self.assertRaises(
624+
InvalidURI, split_options, "tlsInsecure=true&tlsAllowInvalidHostnames=false"
625+
)
626+
self.assertRaises(
627+
InvalidURI, split_options, "tlsInsecure=true&tlsDisableOCSPEndpointCheck=true"
628+
)
629+
# tlsAllowInvalidCertificates and tlsDisableOCSPEndpointCheck are mutually exclusive.
630+
self.assertRaises(
631+
InvalidURI,
632+
split_options,
633+
"tlsAllowInvalidCertificates=true&tlsDisableOCSPEndpointCheck=true",
634+
)
635+
# ssl and tls must agree when both are present.
636+
self.assertRaises(InvalidURI, split_options, "ssl=true&tls=false")
637+
self.assertRaises(InvalidURI, split_options, "ssl=false&tls=true")
638+
# Matching ssl/tls values are allowed.
639+
self.assertIsNotNone(split_options("ssl=true&tls=true"))
640+
self.assertIsNotNone(split_options("ssl=false&tls=false"))
641+
642+
def test_split_options_mixed_delimiters(self):
643+
# Mixing '&' and ';' as option separators is not permitted.
644+
self.assertRaises(InvalidURI, split_options, "ssl=true&tls=true;appname=foo")
645+
self.assertRaises(InvalidURI, split_options, "appname=foo;ssl=true&tls=true")
646+
647+
def test_split_options_duplicate_warning(self):
648+
# Specifying the same option key more than once emits a warning.
649+
with warnings.catch_warnings(record=True) as w:
650+
warnings.simplefilter("always")
651+
split_options("appname=foo&appname=bar")
652+
self.assertEqual(1, len(w))
653+
self.assertIn("Duplicate URI option", str(w[0].message))
654+
655+
def test_split_options_empty_authsource(self):
656+
# An empty authSource value must be rejected.
657+
self.assertRaises(InvalidURI, split_options, "authSource=")
658+
659+
def test_check_options_conflicts(self):
660+
# directConnection=true is incompatible with multiple hosts.
661+
self.assertRaises(
662+
ConfigurationError,
663+
parse_uri,
664+
"mongodb://host1,host2/?directConnection=true",
665+
)
666+
# loadBalanced=true is incompatible with multiple hosts.
667+
self.assertRaises(
668+
ConfigurationError,
669+
parse_uri,
670+
"mongodb://host1,host2/?loadBalanced=true",
671+
)
672+
# directConnection=true and loadBalanced=true cannot coexist.
673+
self.assertRaises(
674+
ConfigurationError,
675+
parse_uri,
676+
"mongodb://localhost/?directConnection=true&loadBalanced=true",
677+
)
678+
# loadBalanced=true and replicaSet cannot coexist.
679+
self.assertRaises(
680+
ConfigurationError,
681+
parse_uri,
682+
"mongodb://localhost/?loadBalanced=true&replicaSet=rs0",
683+
)
684+
685+
def test_validate_uri_edge_cases(self):
686+
# URI with nothing after the scheme is invalid.
687+
self.assertRaises(InvalidURI, parse_uri, "mongodb://")
688+
# srvServiceName is only valid with mongodb+srv://.
689+
self.assertRaises(
690+
ConfigurationError,
691+
parse_uri,
692+
"mongodb://localhost/?srvServiceName=myService",
693+
)
694+
# srvMaxHosts is only valid with mongodb+srv://.
695+
self.assertRaises(
696+
ConfigurationError,
697+
parse_uri,
698+
"mongodb://localhost/?srvMaxHosts=1",
699+
)
700+
# Dollar sign is a prohibited character in database names.
701+
self.assertRaises(InvalidURI, parse_uri, "mongodb://localhost/%24db")
702+
# Space is a prohibited character in database names.
703+
self.assertRaises(InvalidURI, parse_uri, "mongodb://localhost/my%20db")
704+
705+
def test_validate_uri_srv_structure(self):
706+
# SRV URIs require exactly one hostname with no port number.
707+
with patch("pymongo.uri_parser_shared._have_dnspython", return_value=True):
708+
# Multiple hosts in an SRV URI are not allowed.
709+
self.assertRaises(
710+
InvalidURI,
711+
parse_uri,
712+
"mongodb+srv://host1.example.com,host2.example.com",
713+
)
714+
# A port number in a SRV URI is not allowed.
715+
self.assertRaises(
716+
InvalidURI,
717+
parse_uri,
718+
"mongodb+srv://host1.example.com:27017",
719+
)
720+
# directConnection=true is incompatible with SRV URIs.
721+
self.assertRaises(
722+
ConfigurationError,
723+
parse_uri,
724+
"mongodb+srv://host1.example.com/?directConnection=true",
725+
)
726+
# Without dnspython installed, SRV URIs raise ConfigurationError.
727+
with patch("pymongo.uri_parser_shared._have_dnspython", return_value=False):
728+
self.assertRaises(
729+
ConfigurationError,
730+
parse_uri,
731+
"mongodb+srv://host1.example.com",
732+
)
733+
562734

563735
if __name__ == "__main__":
564736
unittest.main()

0 commit comments

Comments
 (0)