test_oidc_icon_blob_roundtrip.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """Backup-schema fidelity for the PG→SQLite portable export (#1333, #2526).
  2. Bambuddy's ``create_backup_zip`` rebuilds the SQLite backup schema when the
  3. source database is PostgreSQL. It now uses ``Base.metadata.create_all()``
  4. against a SQLite engine — the same DDL a native SQLite install gets — rather
  5. than a hand-rolled ``name + type`` CREATE TABLE. The old rebuild dropped two
  6. things that these tests pin:
  7. * ``LargeBinary`` fell through to ``TEXT``, corrupting non-UTF8 OIDC icon
  8. bytes during the round trip (#1333). ``create_all`` renders it as ``BLOB``.
  9. * ``NOT NULL`` / ``DEFAULT`` / FK / ``UNIQUE`` were all dropped, so a
  10. Postgres→SQLite restore left ``server_default`` columns (e.g.
  11. ``spoolbuddy_devices.created_at``) with no ``DEFAULT`` — later inserts
  12. wrote ``NULL`` and 500'd on read (#2526). ``create_all`` emits the default.
  13. The SQLite *source* path is just ``shutil.copy2`` of the live .db file and is
  14. therefore unaffected — these guards only matter for the PostgreSQL branch.
  15. """
  16. import hashlib
  17. import sqlite3
  18. import pytest
  19. from sqlalchemy import create_engine
  20. from sqlalchemy.ext.asyncio import AsyncSession
  21. from backend.app.core.database import Base
  22. from backend.tests._fixtures.oidc_icon import PNG_BYTES as _PNG_BYTES
  23. def _build_backup_schema(db_path) -> dict[str, dict]:
  24. """Build the portable SQLite schema exactly as create_backup_zip's
  25. PostgreSQL branch does, then return ``{table: {col: PRAGMA row}}``.
  26. PRAGMA table_info rows are ``(cid, name, type, notnull, dflt_value, pk)``.
  27. """
  28. engine = create_engine(f"sqlite:///{db_path}")
  29. try:
  30. Base.metadata.create_all(engine)
  31. finally:
  32. engine.dispose()
  33. conn = sqlite3.connect(str(db_path))
  34. try:
  35. schema: dict[str, dict] = {}
  36. tables = [
  37. row[0]
  38. for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
  39. ]
  40. for table in tables:
  41. schema[table] = {row[1]: row for row in conn.execute(f"PRAGMA table_info({table})")} # noqa: S608
  42. return schema
  43. finally:
  44. conn.close()
  45. class TestBackupSchemaFidelity:
  46. """The real backup-schema builder (metadata.create_all on SQLite),
  47. inspected via sqlite_master, keeps the constraints the old name+type
  48. rebuild dropped."""
  49. def test_icon_data_column_is_blob(self, tmp_path):
  50. # #1333 — LargeBinary must render as BLOB, not TEXT, or non-UTF8
  51. # OIDC icon bytes are corrupted on the PG→SQLite round trip.
  52. schema = _build_backup_schema(tmp_path / "schema.db")
  53. assert schema["oidc_providers"]["icon_data"][2] == "BLOB"
  54. def test_server_default_column_keeps_default(self, tmp_path):
  55. # #2526 — a server_default=func.now() column must carry a DEFAULT so
  56. # inserts that omit it (SQLAlchemy does, for server-side defaults)
  57. # don't write NULL after a Postgres→SQLite restore.
  58. schema = _build_backup_schema(tmp_path / "schema.db")
  59. created_at = schema["spoolbuddy_devices"]["created_at"]
  60. assert created_at[4] is not None, "created_at lost its DEFAULT clause"
  61. assert "CURRENT_TIMESTAMP" in str(created_at[4]).upper()
  62. def test_not_null_column_keeps_not_null(self, tmp_path):
  63. # #2526 — NOT NULL columns must stay NOT NULL. A single-column PK is
  64. # implicitly NOT NULL, so assert on a non-PK required column.
  65. schema = _build_backup_schema(tmp_path / "schema.db")
  66. # notnull flag is index 3 of the PRAGMA row.
  67. assert schema["spoolbuddy_devices"]["device_id"][3] == 1
  68. class TestSqliteBinaryRoundtrip:
  69. """SQLite natively stores BLOB without escaping — sanity-check that the
  70. serialise/deserialise path used by the PG→SQLite backup (``executemany``
  71. with bytes values) preserves non-UTF8 bytes exactly."""
  72. def test_binary_value_roundtrips_through_sqlite_blob(self, tmp_path):
  73. db_path = tmp_path / "roundtrip.db"
  74. conn = sqlite3.connect(str(db_path))
  75. try:
  76. conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, blob BLOB)")
  77. # A payload that's deliberately not UTF8-decodable.
  78. payload = bytes(range(256))
  79. conn.execute("INSERT INTO t (id, blob) VALUES (?, ?)", (1, payload))
  80. conn.commit()
  81. row = conn.execute("SELECT blob FROM t WHERE id = 1").fetchone()
  82. assert row[0] == payload
  83. finally:
  84. conn.close()
  85. class TestIconTripletCheckConstraint:
  86. """N10 — DB-level enforcement of the icon-cache triplet invariant.
  87. The CHECK constraint applies on SQLite fresh installs (via
  88. metadata.create_all) and on PostgreSQL fresh + stale installs (via
  89. ALTER TABLE ADD CONSTRAINT). Stale SQLite installs do not get it
  90. (SQLite cannot ADD CONSTRAINT to an existing table) — documented
  91. trade-off, application layer enforces.
  92. """
  93. @pytest.mark.asyncio
  94. @pytest.mark.integration
  95. async def test_full_triplet_accepted(self, db_session: AsyncSession):
  96. from backend.app.models.oidc_provider import OIDCProvider
  97. prov = OIDCProvider(
  98. name="TripletFullProv",
  99. issuer_url="https://idp.example.com",
  100. client_id="c",
  101. scopes="openid",
  102. is_enabled=True,
  103. )
  104. prov.client_secret = "secret"
  105. prov.icon_data = _PNG_BYTES
  106. prov.icon_content_type = "image/png"
  107. prov.icon_etag = hashlib.sha256(_PNG_BYTES).hexdigest()
  108. db_session.add(prov)
  109. await db_session.commit() # must not raise
  110. @pytest.mark.asyncio
  111. @pytest.mark.integration
  112. async def test_all_null_triplet_accepted(self, db_session: AsyncSession):
  113. from backend.app.models.oidc_provider import OIDCProvider
  114. prov = OIDCProvider(
  115. name="TripletEmptyProv",
  116. issuer_url="https://idp.example.com",
  117. client_id="c",
  118. scopes="openid",
  119. is_enabled=True,
  120. )
  121. prov.client_secret = "secret"
  122. # All three icon columns left as default None.
  123. db_session.add(prov)
  124. await db_session.commit() # must not raise
  125. @pytest.mark.asyncio
  126. @pytest.mark.integration
  127. async def test_partial_triplet_rejected_by_check_constraint(self, db_session: AsyncSession):
  128. """Direct UPDATE that sets only icon_content_type (no icon_data, no
  129. icon_etag) must violate the CHECK constraint on a fresh SQLite
  130. install (CHECK constraints fire on SQLite even when foreign keys
  131. are off). Demonstrates the CHECK is the catch-net for raw-SQL
  132. maintenance paths that bypass _fetch_icon_or_400.
  133. """
  134. from sqlalchemy import text
  135. from sqlalchemy.exc import IntegrityError
  136. from backend.app.models.oidc_provider import OIDCProvider
  137. prov = OIDCProvider(
  138. name="TripletPartialProv",
  139. issuer_url="https://idp.example.com",
  140. client_id="c",
  141. scopes="openid",
  142. is_enabled=True,
  143. )
  144. prov.client_secret = "secret"
  145. db_session.add(prov)
  146. await db_session.commit()
  147. pid = prov.id
  148. with pytest.raises(IntegrityError):
  149. await db_session.execute(
  150. text("UPDATE oidc_providers SET icon_content_type = :ct WHERE id = :pid"),
  151. {"ct": "image/png", "pid": pid},
  152. )
  153. await db_session.commit()