test_sqlite_autoincrement.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. """SQLite never hands a freed id of these tables to a new row.
  2. Without AUTOINCREMENT, SQLite gives the highest freed id to the next row
  3. created, and whatever still pointed at the deleted row (a token, a socket,
  4. in-memory state, a row inserted while the delete committed) then pointed at the
  5. new one. users, api_keys, printers, print_archives and long_lived_tokens are
  6. created with AUTOINCREMENT, and existing databases are rebuilt once.
  7. """
  8. import re
  9. import pytest
  10. from sqlalchemy import text
  11. from sqlalchemy.ext.asyncio import create_async_engine
  12. from backend.app.core.database import AUTOINCREMENT_TABLES, Base, _migrate_sqlite_autoincrement, load_all_models
  13. # SQLite behaviour: PostgreSQL sequences never hand an id out twice.
  14. pytestmark = [pytest.mark.asyncio, pytest.mark.sqlite_only]
  15. @pytest.fixture
  16. async def engine(tmp_path):
  17. load_all_models()
  18. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'db.sqlite'}")
  19. async with engine.begin() as conn:
  20. await conn.run_sync(Base.metadata.create_all)
  21. yield engine
  22. await engine.dispose()
  23. async def _sql(conn, name: str) -> str:
  24. return (await conn.execute(text("SELECT sql FROM sqlite_master WHERE name = :n"), {"n": name})).scalar_one()
  25. async def _make_old_format(conn, table: str) -> None:
  26. """Rebuild a table the way installs before this change have it."""
  27. sql = await _sql(conn, table)
  28. old = sql.replace("id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,", "id INTEGER NOT NULL,")
  29. old = re.sub(r"\)\s*$", ", \n\tPRIMARY KEY (id)\n)", old)
  30. old = old.replace(f"CREATE TABLE {table}", f"CREATE TABLE {table}__old", 1)
  31. indexes = [
  32. r[0]
  33. for r in await conn.execute(
  34. text("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = :t AND sql IS NOT NULL"),
  35. {"t": table},
  36. )
  37. ]
  38. await conn.execute(text(old))
  39. await conn.execute(text(f"DROP TABLE {table}"))
  40. await conn.execute(text(f"ALTER TABLE {table}__old RENAME TO {table}"))
  41. for index in indexes:
  42. await conn.execute(text(index))
  43. assert "AUTOINCREMENT" not in await _sql(conn, table)
  44. async def test_new_installs_create_the_tables_with_autoincrement(engine):
  45. async with engine.begin() as conn:
  46. for table in AUTOINCREMENT_TABLES:
  47. assert "AUTOINCREMENT" in await _sql(conn, table), table
  48. async def test_an_existing_table_is_rebuilt_keeping_its_rows_columns_and_indexes(engine):
  49. async with engine.begin() as conn:
  50. await _make_old_format(conn, "users")
  51. for i in (1, 2, 3):
  52. await conn.execute(
  53. text(
  54. "INSERT INTO users (id, username, role, auth_source, is_active, created_at, updated_at) "
  55. "VALUES (:i, :n, 'user', 'local', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  56. ),
  57. {"i": i, "n": f"u{i}"},
  58. )
  59. columns_before = [r[1] for r in await conn.execute(text("PRAGMA table_info(users)"))]
  60. await _migrate_sqlite_autoincrement(conn)
  61. assert "AUTOINCREMENT" in await _sql(conn, "users")
  62. assert [r[1] for r in await conn.execute(text("PRAGMA table_info(users)"))] == columns_before
  63. assert [r[0] for r in await conn.execute(text("SELECT username FROM users ORDER BY id"))] == ["u1", "u2", "u3"]
  64. indexes = {
  65. r[0]
  66. for r in await conn.execute(
  67. text("SELECT name FROM sqlite_master WHERE tbl_name = 'users' AND type = 'index'")
  68. )
  69. }
  70. assert {"ix_users_username", "ix_users_email"} <= indexes
  71. async def test_a_deleted_highest_id_is_not_given_out_again(engine):
  72. async def key(conn, name):
  73. await conn.execute(
  74. text(
  75. "INSERT INTO api_keys (name, key_hash, key_prefix, can_queue, can_control_printer, "
  76. "can_read_status, enabled, created_at) VALUES (:n, 'h', :n, 1, 0, 1, 1, CURRENT_TIMESTAMP)"
  77. ),
  78. {"n": name},
  79. )
  80. async with engine.begin() as conn:
  81. await _make_old_format(conn, "api_keys")
  82. await _migrate_sqlite_autoincrement(conn)
  83. await key(conn, "k1")
  84. await key(conn, "k2")
  85. await conn.execute(text("DELETE FROM api_keys WHERE name = 'k2'"))
  86. await key(conn, "k3")
  87. assert (await conn.execute(text("SELECT id FROM api_keys WHERE name = 'k3'"))).scalar_one() == 3
  88. async def test_an_id_still_referenced_is_not_given_out(engine):
  89. """Deleted before the rebuild, but a row still points at it (an API key
  90. created while its user was deleted): the counter starts above it."""
  91. async with engine.begin() as conn:
  92. await _make_old_format(conn, "users")
  93. await conn.execute(
  94. text(
  95. "INSERT INTO users (id, username, role, auth_source, is_active, created_at, updated_at) "
  96. "VALUES (1, 'u1', 'user', 'local', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  97. )
  98. )
  99. await conn.execute(
  100. text(
  101. "INSERT INTO api_keys (name, key_hash, key_prefix, user_id, can_queue, can_control_printer, "
  102. "can_read_status, enabled, created_at) VALUES ('k', 'h', 'bb_k', 7, 1, 0, 1, 1, CURRENT_TIMESTAMP)"
  103. )
  104. )
  105. await _migrate_sqlite_autoincrement(conn)
  106. await conn.execute(
  107. text(
  108. "INSERT INTO users (username, role, auth_source, is_active, created_at, updated_at) "
  109. "VALUES ('u2', 'user', 'local', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  110. )
  111. )
  112. assert (await conn.execute(text("SELECT id FROM users WHERE username = 'u2'"))).scalar_one() == 8
  113. async def test_running_it_again_changes_nothing(engine):
  114. async with engine.begin() as conn:
  115. await _make_old_format(conn, "api_keys")
  116. await _migrate_sqlite_autoincrement(conn)
  117. before = {t: await _sql(conn, t) for t in AUTOINCREMENT_TABLES}
  118. await _migrate_sqlite_autoincrement(conn)
  119. assert {t: await _sql(conn, t) for t in AUTOINCREMENT_TABLES} == before
  120. async def test_a_printer_id_kept_by_the_print_log_is_not_given_out(engine):
  121. """The log keeps a deleted printer's id on purpose, with no foreign key: the
  122. next printer given it would show that history to whoever may see it."""
  123. async with engine.begin() as conn:
  124. await _make_old_format(conn, "printers")
  125. await conn.execute(
  126. text(
  127. "INSERT INTO print_log_entries (printer_id, print_name, status, created_at) "
  128. "VALUES (5, 'cube', 'completed', CURRENT_TIMESTAMP)"
  129. )
  130. )
  131. await _migrate_sqlite_autoincrement(conn)
  132. assert (await conn.execute(text("SELECT seq FROM sqlite_sequence WHERE name = 'printers'"))).scalar() == 5
  133. async def test_every_id_column_without_a_foreign_key_counts_for_the_seed():
  134. """A new one must be listed, or ids it keeps could be given out at upgrade."""
  135. from sqlalchemy import Integer
  136. from backend.app.core.database import NON_FK_ID_REFERENCES
  137. metadata = load_all_models()
  138. listed = {pair for pairs in NON_FK_ID_REFERENCES.values() for pair in pairs}
  139. found = {
  140. (table.name, column.name)
  141. for table in metadata.tables.values()
  142. for column in table.columns
  143. if re.search(r"(printer_id|user_id|api_key_id|archive_id|token_id|_by_id)$", column.name)
  144. and isinstance(column.type, Integer)
  145. and not column.foreign_keys
  146. and not column.primary_key
  147. }
  148. assert found <= listed, found - listed
  149. async def _keys(conn, ids):
  150. for i in ids:
  151. await conn.execute(
  152. text(
  153. "INSERT INTO api_keys (id, name, key_hash, key_prefix, can_queue, can_control_printer, "
  154. "can_read_status, enabled, created_at) VALUES (:i, :n, 'h', :n, 1, 0, 1, 1, CURRENT_TIMESTAMP)"
  155. ),
  156. {"i": i, "n": f"k{i}"},
  157. )
  158. async def test_a_table_already_rebuilt_still_skips_ids_kept_elsewhere(engine):
  159. """A PostgreSQL backup restored here has the tables with AUTOINCREMENT, its
  160. counter at the highest id left: a token of a deleted key 7 is still there."""
  161. async with engine.begin() as conn:
  162. await _keys(conn, range(1, 7))
  163. await conn.execute(
  164. text(
  165. "INSERT INTO auth_ephemeral_tokens (token, token_type, username, api_key_id, expires_at, created_at) "
  166. "VALUES ('t', 'camera_stream', '', 7, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  167. )
  168. )
  169. await _migrate_sqlite_autoincrement(conn)
  170. assert (await conn.execute(text("SELECT seq FROM sqlite_sequence WHERE name = 'api_keys'"))).scalar() == 7
  171. async def test_the_counter_is_never_lowered(engine):
  172. async with engine.begin() as conn:
  173. await _keys(conn, [10])
  174. await conn.execute(text("DELETE FROM api_keys"))
  175. await _keys(conn, [1])
  176. await _migrate_sqlite_autoincrement(conn)
  177. assert (await conn.execute(text("SELECT seq FROM sqlite_sequence WHERE name = 'api_keys'"))).scalar() == 10