|
31 | 31 | from pymongo.errors import ConfigurationError, InvalidURI |
32 | 32 | from pymongo.synchronous.uri_parser import parse_uri |
33 | 33 | from pymongo.uri_parser_shared import ( |
| 34 | + _unquoted_percent, |
| 35 | + parse_host, |
| 36 | + parse_ipv6_literal_host, |
34 | 37 | parse_userinfo, |
35 | 38 | split_hosts, |
36 | 39 | split_options, |
@@ -559,6 +562,175 @@ def test_parse_uri_options_type(self): |
559 | 562 | opts = parse_uri("mongodb://localhost:27017")["options"] |
560 | 563 | self.assertIsInstance(opts, dict) |
561 | 564 |
|
| 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 | + |
562 | 734 |
|
563 | 735 | if __name__ == "__main__": |
564 | 736 | unittest.main() |
0 commit comments