test_oidc_icon_blob_roundtrip.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. """Type-mapping coverage for the OIDC icon BLOB column (#1333).
  2. Bambuddy's ``create_backup_zip`` rebuilds the SQLite backup schema from
  3. ``Base.metadata`` when the source database is PostgreSQL. The column-type
  4. mapping previously fell through to ``TEXT`` for any unknown SQLAlchemy
  5. type — including ``LargeBinary`` / ``BYTEA`` — which corrupts non-UTF8
  6. icon bytes during the PG → SQLite-ZIP round trip.
  7. These tests exercise the extracted ``_sqlalchemy_type_to_sqlite_type``
  8. helper directly so the regression guard doesn't depend on a full backup
  9. pipeline. The SQLite source path is just ``shutil.copy2`` of the live
  10. .db file and is therefore unaffected by the type mapping.
  11. """
  12. import hashlib
  13. import sqlite3
  14. import pytest
  15. from sqlalchemy import Column, LargeBinary
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.api.routes.settings import _sqlalchemy_type_to_sqlite_type
  18. from backend.tests._fixtures.oidc_icon import PNG_BYTES as _PNG_BYTES
  19. class TestTypeMapping:
  20. """Unit-level coverage of the helper that backups use for PG→SQLite."""
  21. def test_largebinary_maps_to_blob(self):
  22. # Direct from a SQLAlchemy LargeBinary column — this is exactly
  23. # what the create_backup_zip loop calls str() on.
  24. col = Column(LargeBinary)
  25. assert _sqlalchemy_type_to_sqlite_type(str(col.type)) == "BLOB"
  26. @pytest.mark.parametrize(
  27. "type_repr",
  28. ["BLOB", "BYTEA", "BYTEA(1024)", "VARBINARY", "BINARY", "binary varying"],
  29. )
  30. def test_binary_type_strings_map_to_blob(self, type_repr):
  31. assert _sqlalchemy_type_to_sqlite_type(type_repr) == "BLOB"
  32. def test_integer_unchanged(self):
  33. assert _sqlalchemy_type_to_sqlite_type("INTEGER") == "INTEGER"
  34. assert _sqlalchemy_type_to_sqlite_type("BIGINT") == "INTEGER"
  35. def test_boolean_unchanged(self):
  36. assert _sqlalchemy_type_to_sqlite_type("BOOLEAN") == "BOOLEAN"
  37. def test_unknown_falls_back_to_text(self):
  38. assert _sqlalchemy_type_to_sqlite_type("VARCHAR(500)") == "TEXT"
  39. assert _sqlalchemy_type_to_sqlite_type("DATETIME") == "TEXT"
  40. class TestSqliteBinaryRoundtrip:
  41. """SQLite natively stores BLOB without escaping — sanity-check that the
  42. serialise/deserialise path used by the PG→SQLite backup (``executemany``
  43. with bytes values) preserves non-UTF8 bytes exactly."""
  44. def test_binary_value_roundtrips_through_sqlite_blob(self, tmp_path):
  45. db_path = tmp_path / "roundtrip.db"
  46. conn = sqlite3.connect(str(db_path))
  47. try:
  48. conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, blob BLOB)")
  49. # A payload that's deliberately not UTF8-decodable.
  50. payload = bytes(range(256))
  51. conn.execute("INSERT INTO t (id, blob) VALUES (?, ?)", (1, payload))
  52. conn.commit()
  53. row = conn.execute("SELECT blob FROM t WHERE id = 1").fetchone()
  54. assert row[0] == payload
  55. finally:
  56. conn.close()
  57. class TestIconTripletCheckConstraint:
  58. """N10 — DB-level enforcement of the icon-cache triplet invariant.
  59. The CHECK constraint applies on SQLite fresh installs (via
  60. metadata.create_all) and on PostgreSQL fresh + stale installs (via
  61. ALTER TABLE ADD CONSTRAINT). Stale SQLite installs do not get it
  62. (SQLite cannot ADD CONSTRAINT to an existing table) — documented
  63. trade-off, application layer enforces.
  64. """
  65. @pytest.mark.asyncio
  66. @pytest.mark.integration
  67. async def test_full_triplet_accepted(self, db_session: AsyncSession):
  68. from backend.app.models.oidc_provider import OIDCProvider
  69. prov = OIDCProvider(
  70. name="TripletFullProv",
  71. issuer_url="https://idp.example.com",
  72. client_id="c",
  73. scopes="openid",
  74. is_enabled=True,
  75. )
  76. prov.client_secret = "secret"
  77. prov.icon_data = _PNG_BYTES
  78. prov.icon_content_type = "image/png"
  79. prov.icon_etag = hashlib.sha256(_PNG_BYTES).hexdigest()
  80. db_session.add(prov)
  81. await db_session.commit() # must not raise
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_all_null_triplet_accepted(self, db_session: AsyncSession):
  85. from backend.app.models.oidc_provider import OIDCProvider
  86. prov = OIDCProvider(
  87. name="TripletEmptyProv",
  88. issuer_url="https://idp.example.com",
  89. client_id="c",
  90. scopes="openid",
  91. is_enabled=True,
  92. )
  93. prov.client_secret = "secret"
  94. # All three icon columns left as default None.
  95. db_session.add(prov)
  96. await db_session.commit() # must not raise
  97. @pytest.mark.asyncio
  98. @pytest.mark.integration
  99. async def test_partial_triplet_rejected_by_check_constraint(self, db_session: AsyncSession):
  100. """Direct UPDATE that sets only icon_content_type (no icon_data, no
  101. icon_etag) must violate the CHECK constraint on a fresh SQLite
  102. install (CHECK constraints fire on SQLite even when foreign keys
  103. are off). Demonstrates the CHECK is the catch-net for raw-SQL
  104. maintenance paths that bypass _fetch_icon_or_400.
  105. """
  106. from sqlalchemy import text
  107. from sqlalchemy.exc import IntegrityError
  108. from backend.app.models.oidc_provider import OIDCProvider
  109. prov = OIDCProvider(
  110. name="TripletPartialProv",
  111. issuer_url="https://idp.example.com",
  112. client_id="c",
  113. scopes="openid",
  114. is_enabled=True,
  115. )
  116. prov.client_secret = "secret"
  117. db_session.add(prov)
  118. await db_session.commit()
  119. pid = prov.id
  120. with pytest.raises(IntegrityError):
  121. await db_session.execute(
  122. text("UPDATE oidc_providers SET icon_content_type = :ct WHERE id = :pid"),
  123. {"ct": "image/png", "pid": pid},
  124. )
  125. await db_session.commit()