test_sqlite_autoincrement.py 8.8 KB

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