test_backup.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. """备份功能(契约1.21):引擎链式备份、配置存储、保留策略与 HTTP 接口。
  2. 引擎级测试不依赖数据库(数据库导出以伪实现替代),始终执行;接口级测试沿用
  3. B2 惯例,提供 DMS_TEST_* 环境变量时执行,否则安全跳过。
  4. """
  5. from __future__ import annotations
  6. import json
  7. import time
  8. from pathlib import Path
  9. import pytest
  10. CONFIG_URL = "/api/v1/backup/config"
  11. JOBS_URL = "/api/v1/backup/jobs"
  12. CURRENT_URL = "/api/v1/backup/jobs/current"
  13. CANCEL_URL = "/api/v1/backup/jobs/current/cancel"
  14. PROGRESS_KEYS = {
  15. "backupId",
  16. "type",
  17. "trigger",
  18. "phase",
  19. "bytesTotal",
  20. "bytesCopied",
  21. "filesTotal",
  22. "filesCopied",
  23. "pushPercent",
  24. "error",
  25. "startedAt",
  26. "finishedAt",
  27. }
  28. HISTORY_ENTRY_KEYS = {
  29. "id",
  30. "type",
  31. "parent",
  32. "started_at",
  33. "finished_at",
  34. "database_file",
  35. "database_size",
  36. "transferred_files",
  37. "transferred_bytes",
  38. "storage_file_count",
  39. "storage_total_bytes",
  40. }
  41. # database_url 仅做字符串解析(伪造导出后不连库),任意含 user@host 的串即可
  42. _FAKE_DATABASE_URL = "dm+dmPython://DMS_TESTER:secret@localhost:5236/DMS_TESTER"
  43. def _fake_dump(monkeypatch) -> None:
  44. from dms.services import sql_dump
  45. def fake_export_dump(database_url, output_directory, schema):
  46. output_directory.mkdir(parents=True, exist_ok=True)
  47. dump_file = output_directory / sql_dump.DUMP_FILE_NAME
  48. dump_file.write_text("-- FAKE-DM-DATA-DUMP\n", encoding="utf-8")
  49. return dump_file
  50. monkeypatch.setattr(sql_dump, "export_dump", fake_export_dump)
  51. @pytest.fixture()
  52. def engine_env(monkeypatch, tmp_path: Path):
  53. """独立引擎(存储/配置/env/备份根全在 tmp_path),不依赖数据库。"""
  54. from dms.services.backup_service import BackupConfig, BackupEngine
  55. engine = BackupEngine(
  56. storage_root=tmp_path / "storage",
  57. business_timezone="Asia/Shanghai",
  58. database_url=_FAKE_DATABASE_URL,
  59. backup_config_path=tmp_path / "dms-backup-config.json",
  60. env_path=tmp_path / ".env",
  61. )
  62. (tmp_path / ".env").write_text("DMS_BACKUP_TEST_ENV=1\n", encoding="utf-8")
  63. backup_root = tmp_path / "backup-root"
  64. BackupConfig(local_path=str(backup_root)).save(engine.backup_config_path)
  65. _fake_dump(monkeypatch)
  66. engine.backup_root = backup_root # type: ignore[attr-defined]
  67. return engine
  68. @pytest.fixture()
  69. def backup_env(b2_app, monkeypatch, tmp_path: Path):
  70. """接口测试用:替换应用引擎(备份根/配置/环境文件均指向 tmp_path)。"""
  71. from dms.services.backup_service import BackupEngine
  72. b2_app.extensions["dms_backup_engine"].stop_scheduler()
  73. original = b2_app.extensions["dms_backup_engine"]
  74. engine = BackupEngine(
  75. storage_root=original.storage_root,
  76. business_timezone=b2_app.config["DMS_BUSINESS_TIMEZONE"],
  77. database_url=b2_app.config["SQLALCHEMY_DATABASE_URI"],
  78. backup_config_path=tmp_path / "dms-backup-config.json",
  79. env_path=tmp_path / ".env",
  80. )
  81. b2_app.extensions["dms_backup_engine"] = engine
  82. (tmp_path / ".env").write_text("DMS_BACKUP_TEST_ENV=1\n", encoding="utf-8")
  83. _fake_dump(monkeypatch)
  84. b2_app.config["BACKUP_TEST_ROOT"] = tmp_path / "backup-root"
  85. return engine
  86. def _auth(token: str) -> dict[str, str]:
  87. return {"Authorization": f"Bearer {token}"}
  88. def _save_local_config(client, token, backup_root: Path, **overrides):
  89. payload = {
  90. "target_mode": "LOCAL",
  91. "local_path": str(backup_root),
  92. "rsync_url": None,
  93. "rsync_password": None,
  94. "schedule_enabled": False,
  95. "schedule_time": "02:00",
  96. "full_interval_days": 7,
  97. "retain_full_count": 2,
  98. }
  99. payload.update(overrides)
  100. response = client.put(CONFIG_URL, json=payload, headers=_auth(token))
  101. assert response.status_code == 200
  102. return response.get_json()["data"]
  103. def _wait_finished(engine, timeout_seconds: float = 15.0) -> dict:
  104. deadline = time.monotonic() + timeout_seconds
  105. while engine.is_running or engine.progress_snapshot()["phase"] is None:
  106. if time.monotonic() > deadline:
  107. pytest.fail("备份任务超时未结束")
  108. time.sleep(0.05)
  109. return engine.progress_snapshot()
  110. def _write_storage_file(engine, relative: str, content: bytes) -> None:
  111. path = engine.storage_root / relative
  112. path.parent.mkdir(parents=True, exist_ok=True)
  113. path.write_bytes(content)
  114. # ================================================================ 引擎级(无数据库,始终执行)
  115. def test_engine_progress_snapshot_initial_shape(engine_env):
  116. snapshot = engine_env.progress_snapshot()
  117. assert set(snapshot) == PROGRESS_KEYS
  118. assert snapshot["backupId"] is None
  119. assert snapshot["phase"] is None
  120. assert snapshot["bytesTotal"] == 0
  121. assert snapshot["pushPercent"] is None
  122. def test_engine_full_and_incremental_chain(engine_env):
  123. engine = engine_env
  124. _write_storage_file(engine, "original/a.pdf", b"AAA")
  125. _write_storage_file(engine, "original/sub/b.docx", b"BBBB")
  126. _write_storage_file(engine, "preview/c.jpg", b"C")
  127. # 全量
  128. engine.start_async("FULL", "MANUAL")
  129. full_snapshot = _wait_finished(engine)
  130. assert full_snapshot["phase"] == "SUCCESS"
  131. assert full_snapshot["error"] is None
  132. full_id = full_snapshot["backupId"]
  133. assert full_id.endswith("-F")
  134. assert full_snapshot["trigger"] == "MANUAL"
  135. assert full_snapshot["filesCopied"] == 3
  136. assert full_snapshot["bytesCopied"] == 8
  137. assert full_snapshot["pushPercent"] == 100
  138. # 目标目录布局:manifest / 数据脚本 / env / 存储文件 / 索引
  139. full_dir = engine.backup_root / full_id
  140. assert (full_dir / "manifest.json").is_file()
  141. assert (full_dir / "database" / "dms-data.sql").is_file()
  142. assert (full_dir / "env" / ".env").is_file()
  143. assert (full_dir / "storage" / "original" / "a.pdf").is_file()
  144. assert (full_dir / "storage" / "original" / "sub" / "b.docx").is_file()
  145. assert (full_dir / "storage" / "preview" / "c.jpg").is_file()
  146. assert (engine.backup_root / "index.json").is_file()
  147. full_manifest = json.loads(
  148. (full_dir / "manifest.json").read_text(encoding="utf-8")
  149. )
  150. assert full_manifest["type"] == "FULL"
  151. assert full_manifest["parent"] is None
  152. assert set(full_manifest["files"]) == {
  153. "original/a.pdf",
  154. "original/sub/b.docx",
  155. "preview/c.jpg",
  156. }
  157. assert full_manifest["transferred_files"] == 3
  158. # 增量:新增一个文件
  159. _write_storage_file(engine, "original/new.txt", b"NEW-DATA")
  160. engine.start_async("INCREMENTAL", "SCHEDULE")
  161. incremental_snapshot = _wait_finished(engine)
  162. incremental_id = incremental_snapshot["backupId"]
  163. assert incremental_id.endswith("-I")
  164. assert incremental_snapshot["phase"] == "SUCCESS"
  165. assert incremental_snapshot["filesCopied"] == 1
  166. assert incremental_snapshot["trigger"] == "SCHEDULE"
  167. incremental_manifest = json.loads(
  168. (engine.backup_root / incremental_id / "manifest.json").read_text(
  169. encoding="utf-8"
  170. )
  171. )
  172. assert incremental_manifest["parent"] == full_id
  173. # 增量目录仅落差异文件
  174. assert (
  175. engine.backup_root / incremental_id / "storage" / "original" / "new.txt"
  176. ).is_file()
  177. assert not (
  178. engine.backup_root / incremental_id / "storage" / "original" / "a.pdf"
  179. ).exists()
  180. # 增量 manifest.files 为链尾累计清单
  181. assert set(incremental_manifest["files"]) == {
  182. "original/a.pdf",
  183. "original/sub/b.docx",
  184. "preview/c.jpg",
  185. "original/new.txt",
  186. }
  187. # 历史与定时类型决策:当日已有全量 → 距今未超间隔 → INCREMENTAL
  188. config = engine.load_config()
  189. history = engine.list_history(config)
  190. assert [entry["id"] for entry in history] == [full_id, incremental_id]
  191. assert history[1]["parent"] == full_id
  192. assert history[1]["storage_file_count"] == 4
  193. assert engine.decide_scheduled_type(config) == "INCREMENTAL"
  194. # 距最近全量超过间隔 → FULL(用三天前的全量独占索引,避免与今日真实全量混淆)
  195. config.full_interval_days = 1
  196. old_full_id = "dms-bak-" + time.strftime(
  197. "%Y%m%d", time.localtime(time.time() - 3 * 86400)
  198. ) + "-F"
  199. (engine.backup_root / "index.json").write_text(
  200. json.dumps([{"id": old_full_id, "type": "FULL"}]), encoding="utf-8"
  201. )
  202. assert engine.decide_scheduled_type(config) == "FULL"
  203. def test_engine_incremental_without_history_upgrades_to_full(engine_env):
  204. engine = engine_env
  205. _write_storage_file(engine, "original/only.txt", b"X")
  206. engine.start_async("INCREMENTAL", "MANUAL")
  207. snapshot = _wait_finished(engine)
  208. assert snapshot["phase"] == "SUCCESS"
  209. assert snapshot["type"] == "FULL"
  210. assert snapshot["backupId"].endswith("-F")
  211. def test_engine_fails_when_database_export_fails(engine_env, monkeypatch):
  212. from dms.common.errors import BackupFailedError
  213. from dms.services import sql_dump
  214. def failing_export(database_url, output_directory, schema):
  215. raise BackupFailedError("数据库导出失败(测试模拟)")
  216. monkeypatch.setattr(sql_dump, "export_dump", failing_export)
  217. engine = engine_env
  218. _write_storage_file(engine, "original/a.pdf", b"AAA")
  219. engine.start_async("FULL", "MANUAL")
  220. snapshot = _wait_finished(engine)
  221. assert snapshot["phase"] == "FAILED"
  222. assert "数据库导出失败" in snapshot["error"]
  223. # 失败备份不进历史(目标端无 manifest 即不可见),暂存目录已清理
  224. assert engine.list_history(engine.load_config()) == []
  225. assert not (engine.storage_root / "backup-staging").exists()
  226. def test_engine_completion_callback_success_and_failure(engine_env, monkeypatch):
  227. """终态回调(BACKUP_CREATE 审计结果来源):恰调一次、先于 running 复位。"""
  228. from dms.common.errors import BackupFailedError
  229. from dms.services import sql_dump
  230. engine = engine_env
  231. _write_storage_file(engine, "original/a.pdf", b"AAA")
  232. # 成功:outcome 携带最终类型/触发方式/备份ID,回调内任务仍视为运行中
  233. outcomes = []
  234. running_inside = []
  235. engine.start_async(
  236. "FULL",
  237. "MANUAL",
  238. on_complete=lambda outcome: (
  239. outcomes.append(outcome),
  240. running_inside.append(engine.is_running),
  241. ),
  242. )
  243. snapshot = _wait_finished(engine)
  244. assert snapshot["phase"] == "SUCCESS"
  245. assert len(outcomes) == 1
  246. success = outcomes[0]
  247. assert success.success is True
  248. assert success.type == "FULL"
  249. assert success.trigger == "MANUAL"
  250. assert success.backup_id == snapshot["backupId"]
  251. assert success.error is None
  252. assert running_inside == [True]
  253. # 失败:outcome 携带失败原因与备份ID(导出失败发生于备份目录建立后)
  254. def failing_export(database_url, output_directory, schema):
  255. raise BackupFailedError("数据库导出失败(测试模拟)")
  256. monkeypatch.setattr(sql_dump, "export_dump", failing_export)
  257. failed_outcomes = []
  258. engine.start_async(
  259. "INCREMENTAL", "SCHEDULE", on_complete=failed_outcomes.append
  260. )
  261. failed_snapshot = _wait_finished(engine)
  262. assert failed_snapshot["phase"] == "FAILED"
  263. assert len(failed_outcomes) == 1
  264. failure = failed_outcomes[0]
  265. assert failure.success is False
  266. assert failure.type == "INCREMENTAL"
  267. assert failure.trigger == "SCHEDULE"
  268. assert failure.backup_id == failed_snapshot["backupId"]
  269. assert "数据库导出失败" in failure.error
  270. # 早期失败(目标不可用,备份目录建立前):backup_id 为 None
  271. config_path = engine.backup_config_path
  272. from dms.services.backup_service import BackupConfig
  273. blocking = engine.storage_root / "not-a-directory"
  274. blocking.write_text("x", encoding="utf-8")
  275. BackupConfig(local_path=str(blocking)).save(config_path)
  276. early_outcomes = []
  277. engine.start_async("FULL", "MANUAL", on_complete=early_outcomes.append)
  278. _wait_finished(engine)
  279. assert len(early_outcomes) == 1
  280. assert early_outcomes[0].success is False
  281. assert early_outcomes[0].backup_id is None
  282. assert early_outcomes[0].error
  283. def test_engine_running_mutually_exclusive(engine_env):
  284. engine = engine_env
  285. from dms.common.errors import BackupRunningError
  286. engine._running.set()
  287. with pytest.raises(BackupRunningError):
  288. engine.start_async("FULL", "MANUAL")
  289. engine._running.clear()
  290. # 取消空闲任务为空操作,不抛异常
  291. engine.cancel()
  292. def test_retention_cleanup_removes_oldest_chains_and_residue(engine_env):
  293. from dms.services.backup_service import LocalBackupTarget
  294. target = LocalBackupTarget(engine_env.backup_root.parent / "retention-root")
  295. ids = {
  296. "full1": "dms-bak-20260101-000000-F",
  297. "inc1": "dms-bak-20260102-000000-I",
  298. "full2": "dms-bak-20260108-000000-F",
  299. "inc2": "dms-bak-20260109-000000-I",
  300. "full3": "dms-bak-20260115-000000-F",
  301. "residue": "dms-bak-20260105-000000-F",
  302. }
  303. for backup_id in ids.values():
  304. directory = target.root / backup_id
  305. directory.mkdir(parents=True)
  306. (directory / "manifest.json").write_text("{}", encoding="utf-8")
  307. index = [
  308. {"id": ids["full1"], "type": "FULL"},
  309. {"id": ids["inc1"], "type": "INCREMENTAL"},
  310. {"id": ids["full2"], "type": "FULL"},
  311. {"id": ids["inc2"], "type": "INCREMENTAL"},
  312. {"id": ids["full3"], "type": "FULL"},
  313. ]
  314. engine_env._cleanup_retention_policy(target, index, retain_full_count=2)
  315. # 最旧链(full1+inc1)与残留目录被清理,剩余两条链
  316. assert not (target.root / ids["full1"]).exists()
  317. assert not (target.root / ids["inc1"]).exists()
  318. assert not (target.root / ids["residue"]).exists()
  319. assert [entry["id"] for entry in index] == [
  320. ids["full2"],
  321. ids["inc2"],
  322. ids["full3"],
  323. ]
  324. # 按备份 ID 字典序(= 时间序)
  325. assert sorted(target.list_backup_directories()) == [
  326. ids["full2"],
  327. ids["inc2"],
  328. ids["full3"],
  329. ]
  330. def test_backup_config_json_roundtrip_and_validation(tmp_path: Path):
  331. from dms.common.errors import BackupConfigInvalidError
  332. from dms.services.backup_service import BackupConfig
  333. config = BackupConfig(
  334. local_path=str(tmp_path / "backup-root"),
  335. rsync_password="secret-value",
  336. schedule_enabled=True,
  337. )
  338. config.save(tmp_path / "dms-backup-config.json")
  339. # 磁盘 JSON 为 snake_case 全字段(含明文密码,双后端共享)
  340. disk = json.loads(
  341. (tmp_path / "dms-backup-config.json").read_text(encoding="utf-8")
  342. )
  343. assert disk == {
  344. "target_mode": "LOCAL",
  345. "local_path": str(tmp_path / "backup-root"),
  346. "rsync_url": "",
  347. "rsync_password": "secret-value",
  348. "schedule_enabled": True,
  349. "schedule_time": "02:00",
  350. "full_interval_days": 7,
  351. "retain_full_count": 2,
  352. }
  353. loaded = BackupConfig.load(tmp_path / "dms-backup-config.json")
  354. assert loaded.rsync_password == "secret-value"
  355. assert loaded.schedule_enabled is True
  356. # 回显视图永不回明文密码
  357. view = loaded.public_view()
  358. assert "rsync_password" not in view
  359. assert view["rsync_password_set"] is True
  360. # 损坏文件回退默认配置
  361. (tmp_path / "broken.json").write_text("{", encoding="utf-8")
  362. assert BackupConfig.load(tmp_path / "broken.json") == BackupConfig()
  363. assert BackupConfig.load(tmp_path / "missing.json") == BackupConfig()
  364. # 校验:模式/路径/时刻/间隔/保留数
  365. with pytest.raises(BackupConfigInvalidError):
  366. BackupConfig(target_mode="FTP", local_path="x").validate()
  367. with pytest.raises(BackupConfigInvalidError):
  368. BackupConfig().validate() # LOCAL 缺 local_path
  369. rsync = BackupConfig(target_mode="RSYNC", rsync_url="ftp://bad/x")
  370. with pytest.raises(BackupConfigInvalidError):
  371. rsync.validate()
  372. ok_rsync = BackupConfig(target_mode="RSYNC", rsync_url="rsync://u@h:873/mod/")
  373. ok_rsync.validate()
  374. with pytest.raises(BackupConfigInvalidError):
  375. BackupConfig(local_path="x", schedule_time="24:99").validate()
  376. with pytest.raises(BackupConfigInvalidError):
  377. BackupConfig(local_path="x", full_interval_days=0).validate()
  378. with pytest.raises(BackupConfigInvalidError):
  379. BackupConfig(local_path="x", retain_full_count=0).validate()
  380. # ================================================================ 接口级(B2 门控)
  381. def test_backup_endpoints_require_admin(b2_client, backup_env, token_for):
  382. missing = b2_client.get(CONFIG_URL)
  383. invalid = b2_client.get(
  384. CONFIG_URL, headers={"Authorization": "Bearer invalid-token"}
  385. )
  386. assert missing.status_code == 401
  387. assert missing.get_json()["code"] == "TOKEN_INVALID"
  388. assert invalid.status_code == 401
  389. assert invalid.get_json()["code"] == "TOKEN_INVALID"
  390. user_headers = _auth(token_for("user"))
  391. checks = (
  392. ("get", CONFIG_URL, None),
  393. ("put", CONFIG_URL, {}),
  394. ("post", JOBS_URL, {"type": "FULL"}),
  395. ("get", CURRENT_URL, None),
  396. ("post", CANCEL_URL, None),
  397. ("get", JOBS_URL, None),
  398. )
  399. for method, url, body in checks:
  400. response = getattr(b2_client, method)(
  401. url, json=body, headers=user_headers
  402. )
  403. assert response.status_code == 403
  404. assert response.get_json()["code"] == "FORBIDDEN"
  405. def test_config_default_view_and_roundtrip_with_password_masking(
  406. b2_app, b2_client, backup_env, login_token
  407. ):
  408. engine = backup_env
  409. response = b2_client.get(CONFIG_URL, headers=_auth(login_token))
  410. assert response.status_code == 200
  411. default = response.get_json()["data"]
  412. assert default == {
  413. "target_mode": "LOCAL",
  414. "local_path": "",
  415. "rsync_url": "",
  416. "rsync_password_set": False,
  417. "schedule_enabled": False,
  418. "schedule_time": "02:00",
  419. "full_interval_days": 7,
  420. "retain_full_count": 2,
  421. }
  422. saved = _save_local_config(
  423. b2_client,
  424. login_token,
  425. b2_app.config["BACKUP_TEST_ROOT"],
  426. rsync_password="secret-value",
  427. )
  428. assert saved["rsync_password_set"] is True
  429. assert "rsync_password" not in saved
  430. assert saved["local_path"] == str(b2_app.config["BACKUP_TEST_ROOT"])
  431. # 空密码 = 保持不变
  432. _save_local_config(b2_client, login_token, b2_app.config["BACKUP_TEST_ROOT"])
  433. disk = json.loads(
  434. engine.backup_config_path.read_text(encoding="utf-8")
  435. )
  436. assert disk["rsync_password"] == "secret-value"
  437. def test_config_validation_rejects_invalid_payload(b2_client, backup_env, login_token):
  438. headers = _auth(login_token)
  439. incomplete = b2_client.put(
  440. CONFIG_URL,
  441. json={"target_mode": "LOCAL"},
  442. headers=headers,
  443. )
  444. assert incomplete.status_code == 400
  445. assert incomplete.get_json()["code"] == "INVALID_ARGUMENT"
  446. base = {
  447. "target_mode": "LOCAL",
  448. "local_path": str(backup_env.backup_config_path.parent / "backup-root"),
  449. "rsync_url": None,
  450. "rsync_password": None,
  451. "schedule_enabled": False,
  452. "schedule_time": "02:00",
  453. "full_interval_days": 7,
  454. "retain_full_count": 2,
  455. }
  456. for field, value in (
  457. ("target_mode", "FTP"),
  458. ("schedule_time", "24:99"),
  459. ("full_interval_days", 0),
  460. ("retain_full_count", 0),
  461. ):
  462. payload = dict(base, **{field: value})
  463. response = b2_client.put(CONFIG_URL, json=payload, headers=headers)
  464. assert response.status_code == 400
  465. assert response.get_json()["code"] == "BACKUP_CONFIG_INVALID"
  466. def test_api_full_and_incremental_with_history_and_audit(
  467. b2_app, b2_client, backup_env, login_token
  468. ):
  469. from dms.extensions import db
  470. from dms.models import AuditLog
  471. engine = backup_env
  472. backup_root: Path = b2_app.config["BACKUP_TEST_ROOT"]
  473. _save_local_config(b2_client, login_token, backup_root)
  474. _write_storage_file(engine, "original/a.pdf", b"AAA")
  475. # 触发:202 受理 + 初始快照(12 业务字段)
  476. started = b2_client.post(
  477. JOBS_URL, json={"type": "FULL"}, headers=_auth(login_token)
  478. )
  479. assert started.status_code == 202
  480. assert set(started.get_json()["data"]) == PROGRESS_KEYS
  481. full_snapshot = _wait_finished(engine)
  482. full_id = full_snapshot["backupId"]
  483. assert full_snapshot["phase"] == "SUCCESS"
  484. assert full_id.endswith("-F")
  485. # 当前进度:12 业务字段 + running
  486. current = b2_client.get(CURRENT_URL, headers=_auth(login_token))
  487. assert current.status_code == 200
  488. current_data = current.get_json()["data"]
  489. assert set(current_data) == PROGRESS_KEYS | {"running"}
  490. assert current_data["running"] is False
  491. assert current_data["backupId"] == full_id
  492. # 历史:字段形状与链尾累计口径
  493. history = b2_client.get(JOBS_URL, headers=_auth(login_token))
  494. assert history.status_code == 200
  495. entries = history.get_json()["data"]["entries"]
  496. assert [entry["id"] for entry in entries] == [full_id]
  497. assert set(entries[0]) == HISTORY_ENTRY_KEYS
  498. assert entries[0]["storage_file_count"] == 1
  499. # 触发审计:记一条 BACKUP_CREATE(任务终态落库,成功)
  500. with b2_app.app_context():
  501. rows = db.session.execute(
  502. db.select(AuditLog).where(AuditLog.action_type == "BACKUP_CREATE")
  503. ).scalars().all()
  504. assert len(rows) == 1
  505. assert rows[0].target_type == "BACKUP"
  506. assert rows[0].operation_result == "SUCCESS"
  507. assert rows[0].failure_reason is None
  508. assert rows[0].target_name == "FULL"
  509. assert rows[0].operation_detail["backupId"] == full_id
  510. def test_api_failed_backup_audit_records_failure(
  511. b2_app, b2_client, backup_env, login_token, monkeypatch
  512. ):
  513. """备份任务失败:审计落 FAILURE 且携带失败原因(修复“失败显示成功”)。"""
  514. from dms.common.errors import BackupFailedError
  515. from dms.extensions import db
  516. from dms.models import AuditLog
  517. from dms.services import sql_dump
  518. def failing_export(database_url, output_directory, schema):
  519. raise BackupFailedError("数据库导出失败(测试模拟)")
  520. monkeypatch.setattr(sql_dump, "export_dump", failing_export)
  521. engine = backup_env
  522. _save_local_config(b2_client, login_token, b2_app.config["BACKUP_TEST_ROOT"])
  523. _write_storage_file(engine, "original/a.pdf", b"AAA")
  524. started = b2_client.post(
  525. JOBS_URL, json={"type": "FULL"}, headers=_auth(login_token)
  526. )
  527. assert started.status_code == 202
  528. snapshot = _wait_finished(engine)
  529. assert snapshot["phase"] == "FAILED"
  530. with b2_app.app_context():
  531. rows = db.session.execute(
  532. db.select(AuditLog).where(AuditLog.action_type == "BACKUP_CREATE")
  533. ).scalars().all()
  534. assert len(rows) == 1
  535. row = rows[0]
  536. assert row.operation_result == "FAILURE"
  537. assert "数据库导出失败" in row.failure_reason
  538. assert row.target_name == "FULL"
  539. assert row.operation_detail["backupId"] == snapshot["backupId"]
  540. assert row.operation_detail["trigger"] == "MANUAL"
  541. def test_api_invalid_type_rejected(b2_client, backup_env, login_token):
  542. response = b2_client.post(
  543. JOBS_URL, json={"type": "PARTIAL"}, headers=_auth(login_token)
  544. )
  545. assert response.status_code == 400
  546. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  547. def test_api_start_conflict_while_running(b2_client, backup_env, login_token):
  548. engine = backup_env
  549. engine._running.set()
  550. try:
  551. response = b2_client.post(
  552. JOBS_URL, json={"type": "FULL"}, headers=_auth(login_token)
  553. )
  554. assert response.status_code == 409
  555. assert response.get_json()["code"] == "BACKUP_RUNNING"
  556. finally:
  557. engine._running.clear()
  558. def test_api_cancel_without_running_job(b2_client, backup_env, login_token):
  559. response = b2_client.post(CANCEL_URL, headers=_auth(login_token))
  560. assert response.status_code == 200
  561. assert response.get_json()["data"] == {"canceled": False}