fix(formula): 导出文件安全、清理回收、撤权校验与定义库可靠性(F01/F02/F05-F08)
- F01: _safe_unlink_export 不跟随符号链接、逐层校验父目录,导出失败路径与 清理命令统一使用,只清理受控目录内本次创建的文件 - F02: 清理失败记录告警日志;清理命令回收 failed 任务残留文件 - F05: 定义保存事务内锁数据集行,配额原子检查;revision 冲突返回 409; 保留最近 20 版历史;支持个人/团队共享;审计与变更同事务 - F06: 工作簿参数页毛利率改为只读快照;可编辑单元格解锁并加数值校验 - F07: 对账按指标分容差(人数 0、比率 0.0001、金额 0.01);自定义列使用 服务端 Decimal 表达式求值参考 - F08: 导出任务执行/下载阶段重校验账号有效性与角色 - 对话草稿会话恢复;载入定义后追问携带原定义(F04);下载改浏览器直传 - 迁移 0006-0008;PostgreSQL 咨询锁语义修正(import_worker)
This commit is contained in:
@@ -198,6 +198,67 @@ def test_formula_export_worker_records_path_preparation_failure(workspace, setti
|
||||
assert job.status == "failed"
|
||||
|
||||
|
||||
def test_formula_export_failure_does_not_follow_a_symlinked_user_directory(workspace, settings):
|
||||
from finance.exporting import process_formula_export
|
||||
|
||||
ds, _, client, *_ = workspace
|
||||
response = client.post(
|
||||
f"/api/datasets/{ds.pk}/formula-exports",
|
||||
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||||
content_type="application/json",
|
||||
)
|
||||
job = FormulaExport.objects.get(pk=response.json()["id"])
|
||||
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||
protected = Path(settings.MEDIA_ROOT) / "protected"
|
||||
protected.mkdir()
|
||||
target = protected / f"{job.pk}.xlsx"
|
||||
target.write_bytes(b"keep")
|
||||
root.mkdir()
|
||||
(root / str(job.user_id)).symlink_to(protected, target_is_directory=True)
|
||||
|
||||
with pytest.raises(ValueError, match="符号链接"):
|
||||
process_formula_export(job.pk)
|
||||
|
||||
job.refresh_from_db()
|
||||
assert job.status == "failed"
|
||||
assert target.read_bytes() == b"keep"
|
||||
|
||||
|
||||
def test_formula_export_failed_cleanup_is_observable_and_reclaimed(workspace, monkeypatch):
|
||||
from django.core.management import call_command
|
||||
|
||||
from finance.exporting import process_formula_export
|
||||
|
||||
ds, _, client, *_ = workspace
|
||||
response = client.post(
|
||||
f"/api/datasets/{ds.pk}/formula-exports",
|
||||
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||||
content_type="application/json",
|
||||
)
|
||||
job = FormulaExport.objects.get(pk=response.json()["id"])
|
||||
target = Path(job.dataset.file.storage.location) / "formula-exports" / str(job.user_id) / f"{job.pk}.xlsx"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_bytes(b"orphan")
|
||||
monkeypatch.setattr(
|
||||
"finance.exporting.build_formula_workbook",
|
||||
lambda *_a, **_k: (_ for _ in ()).throw(ValueError("retry failed")),
|
||||
)
|
||||
real_unlink = Path.unlink
|
||||
monkeypatch.setattr(Path, "unlink", lambda *_a, **_k: (_ for _ in ()).throw(OSError("read-only")))
|
||||
|
||||
with pytest.raises(ValueError, match="retry failed"):
|
||||
process_formula_export(job.pk)
|
||||
job.refresh_from_db()
|
||||
assert job.status == "failed"
|
||||
assert job.path == str(target)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", real_unlink)
|
||||
call_command("cleanup_formula_exports")
|
||||
job.refresh_from_db()
|
||||
assert job.path == ""
|
||||
assert target.exists() is False
|
||||
|
||||
|
||||
def test_formula_workbook_rejects_unclassified_label_collision(workspace):
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -374,3 +435,20 @@ def test_formula_workbook_rejects_dimension_keys_that_collide_with_reserved_head
|
||||
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "revenue"},
|
||||
BytesIO(),
|
||||
)
|
||||
|
||||
|
||||
def test_custom_reconcile_reference_preserves_large_decimal_and_zero_division():
|
||||
from finance.exporting import _evaluate_expression
|
||||
|
||||
expression = {
|
||||
"operator": "divide",
|
||||
"left": {"metric": "revenue"},
|
||||
"right": {"number": 0},
|
||||
}
|
||||
assert (
|
||||
_evaluate_expression(expression, {"revenue": Decimal("1.2345678901234567890123456789012345")}) is None
|
||||
)
|
||||
assert _evaluate_expression(
|
||||
{"operator": "add", "left": {"metric": "revenue"}, "right": {"number": 1}},
|
||||
{"revenue": Decimal("1234567890123456789012345678901234")},
|
||||
) == Decimal("1234567890123456789012345678901235")
|
||||
|
||||
Reference in New Issue
Block a user