"""Pin down the minimal SAML 2.0 metadata parser used by the SSO grant approval flow. The parser is invoked at execute time on the requester's uploaded metadata XML to derive the ACS URL + Entity ID + signing cert that get fed to Cloud Identity ``inboundSamlSsoProfiles.create``. Bugs here ship the wrong SAML profile to Google, breaking SSO for everyone the IdP routes to that app. """ from __future__ import annotations import pytest from app.domains.sso.saml_metadata import ( SamlMetadataParseError, parse_saml_metadata, ) # A minimal but realistic SP-side EntityDescriptor — what a vendor # typically hands over. Cert body is fake but PEM-shaped. _VALID_SP_METADATA = """\ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA """ def test_parse_extracts_entity_id_and_acs(): parsed = parse_saml_metadata(_VALID_SP_METADATA) assert parsed.entity_id == "https://sso.vendor.com/saml/sp" # HTTP-POST is preferred over HTTP-Artifact even though both # exist; the parser must choose the binding Google expects. assert parsed.acs_url == "https://sso.vendor.com/saml/acs" assert parsed.descriptor_kind == "SPSSODescriptor" def test_parse_wraps_bare_cert_as_pem(): parsed = parse_saml_metadata(_VALID_SP_METADATA) assert parsed.x509_cert_pem is not None assert parsed.x509_cert_pem.startswith("-----BEGIN CERTIFICATE-----") assert parsed.x509_cert_pem.rstrip().endswith("-----END CERTIFICATE-----") # Body bytes are preserved (whitespace stripped + 64-char wrapped). body_lines = [ line for line in parsed.x509_cert_pem.splitlines() if not line.startswith("-----") ] body = "".join(body_lines) assert body == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA" def test_parse_prefers_http_post_when_listed_second(): """SAML core says index ordering wins for the "default" ACS, but Google's IdP requires HTTP-POST specifically. The parser must pick HTTP-POST EVEN WHEN index=0 is something else.""" xml = """\ """ parsed = parse_saml_metadata(xml) assert parsed.acs_url == "https://sso.example.com/right" def test_parse_falls_back_to_lowest_index_when_no_http_post(): """If no HTTP-POST binding exists, fall back to the default (index=0) ACS rather than failing.""" xml = """\ """ parsed = parse_saml_metadata(xml) assert parsed.acs_url == "https://sso.example.com/fallback" def test_parse_rejects_empty_input(): with pytest.raises(SamlMetadataParseError, match="Empty"): parse_saml_metadata("") with pytest.raises(SamlMetadataParseError, match="Empty"): parse_saml_metadata(" \n\n\t ") def test_parse_rejects_non_xml(): with pytest.raises(SamlMetadataParseError, match="parse"): parse_saml_metadata("not xml at all <<<") def test_parse_rejects_wrong_root_element(): """Vendor occasionally hands over a Response or AuthnRequest XML by mistake — refuse them with a clear error message rather than silently extracting nothing.""" xml = """\ """ with pytest.raises(SamlMetadataParseError, match="EntityDescriptor"): parse_saml_metadata(xml) def test_parse_rejects_missing_entity_id(): xml = """\ """ with pytest.raises(SamlMetadataParseError, match="entityID"): parse_saml_metadata(xml) def test_parse_rejects_missing_acs(): xml = """\ """ with pytest.raises(SamlMetadataParseError, match="AssertionConsumerService"): parse_saml_metadata(xml) def test_parse_returns_none_cert_when_only_encryption_key(): """SP metadata that only carries an encryption-use KeyDescriptor (no signing key) is valid — Google's inbound SAML profile create accepts a profile without a cert.""" xml = """\ ZW5jcnlwdGlvbi1vbmx5 """ parsed = parse_saml_metadata(xml) assert parsed.x509_cert_pem is None assert parsed.acs_url == "https://sso.example.com/acs" def test_parse_falls_back_to_keydescriptor_without_use_attribute(): """SAML spec says a KeyDescriptor with no ``use`` attribute is implicitly both signing and encryption — so we use it as the signing cert if no explicit ``use=signing`` exists.""" xml = """\ aW1wbGljaXRfYm90aA== """ parsed = parse_saml_metadata(xml) assert parsed.x509_cert_pem is not None assert "aW1wbGljaXRfYm90aA==" in parsed.x509_cert_pem