"""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