268 lines
12 KiB
Python
268 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
|
|
RUNTIME = Path(__file__).resolve().parents[1] / "runtime"
|
|
sys.path.insert(0, str(RUNTIME))
|
|
import fs_transaction # noqa: E402
|
|
import layout_check # noqa: E402
|
|
import vault_migrate # noqa: E402
|
|
|
|
|
|
class VaultMigrateTest(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.tempdir = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tempdir.cleanup)
|
|
self.root = Path(self.tempdir.name)
|
|
for path in (
|
|
"raw/project-notes",
|
|
"raw/branch-notes",
|
|
"harness/source",
|
|
"harness/adapters",
|
|
"harness/runtime",
|
|
"harness/tests",
|
|
"vault/10-projects",
|
|
):
|
|
(self.root / path).mkdir(parents=True, exist_ok=True)
|
|
self.project = self.root / "raw/project-notes/sample-project.md"
|
|
self.branch = self.root / "raw/branch-notes/feature-sample-project-runtime-contract.md"
|
|
self.project.write_text("---\ntitle: Sample project\n---\n# Sample project\n", encoding="utf-8")
|
|
self.branch.write_text(
|
|
"---\ntitle: Sample branch\nproject: sample-project\n---\n# Sample branch\n",
|
|
encoding="utf-8",
|
|
)
|
|
self.layout = self.root / "harness/source/vault-layout.json"
|
|
self.layout.write_text(
|
|
json.dumps({
|
|
"schema_version": "vault-layout/v1",
|
|
"mode": "compatibility",
|
|
"vault_root": "vault",
|
|
"canonical_mapping": {
|
|
"schema_version": "project-first-paths/v1",
|
|
"project_relation": "branch-to-project",
|
|
"project_member_pattern": "{vault_root}/{area}/{project}/{category}/{relative_path}",
|
|
"default_pattern": "{vault_root}/{area}/{category}/{relative_path}",
|
|
},
|
|
"write_roots": {
|
|
"compatibility": ["raw", "wiki"],
|
|
"shadow": ["raw", "wiki"],
|
|
"canonical": ["vault"],
|
|
},
|
|
"migration_manifest": {"schema_version": "vault-migration/v1", "entries": []},
|
|
"rollback_mapping": {"schema_version": "vault-rollback/v1", "entries": []},
|
|
"areas": {"10-projects": ["raw/project-notes", "raw/branch-notes"]},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
self.relations = self.root / "harness/source/document-relations.json"
|
|
self.relations.write_text(
|
|
json.dumps({
|
|
"schema_version": "document-relations/v1",
|
|
"relations": [{
|
|
"id": "branch-to-project",
|
|
"child_roots": ["raw/branch-notes"],
|
|
"parent_field": "project",
|
|
"parent_roots": ["raw/project-notes"],
|
|
"marker": "branches",
|
|
}],
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def _prepare(self, target: str) -> vault_migrate.MigrationPlan:
|
|
return vault_migrate.prepare(
|
|
self.root,
|
|
target,
|
|
layout_path=Path("harness/source/vault-layout.json"),
|
|
relations_path=Path("harness/source/document-relations.json"),
|
|
)
|
|
|
|
def test_project_first_mapping_is_relation_driven_and_unique(self) -> None:
|
|
config = json.loads(self.layout.read_text(encoding="utf-8"))
|
|
relations = json.loads(self.relations.read_text(encoding="utf-8"))
|
|
mapping = vault_migrate.build_mapping(self.root, config, relations)
|
|
self.assertEqual(
|
|
mapping[self.project],
|
|
self.root / "vault/10-projects/sample-project/project-notes/sample-project.md",
|
|
)
|
|
self.assertEqual(
|
|
mapping[self.branch],
|
|
self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md",
|
|
)
|
|
|
|
def test_shadow_dry_run_is_read_only_and_atomic_apply_is_converged(self) -> None:
|
|
original_layout = self.layout.read_bytes()
|
|
plan = self._prepare("shadow")
|
|
self.assertRegex(plan.result["plan_sha256"], r"^[0-9a-f]{64}$")
|
|
self.assertEqual(plan.result["migration_entries"], 2)
|
|
self.assertEqual(self.layout.read_bytes(), original_layout)
|
|
self.assertFalse((self.root / "vault/10-projects/sample-project/project-notes/sample-project.md").exists())
|
|
vault_migrate.apply(plan)
|
|
result = layout_check.check_layout(self.root)
|
|
self.assertEqual(result["status"], "PASS", result["findings"])
|
|
self.assertEqual(result["mode"], "shadow")
|
|
self.assertEqual(result["migration_entries"], 2)
|
|
|
|
def test_shadow_writer_updates_legacy_mirror_and_manifest_hash_together(self) -> None:
|
|
vault_migrate.apply(self._prepare("shadow"))
|
|
updated = self.branch.read_bytes() + b"updated\n"
|
|
changes, authority = vault_migrate.expand_authoritative_changes(self.root, {self.branch: updated})
|
|
self.assertEqual(authority["mode"], "shadow")
|
|
canonical = self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md"
|
|
self.assertEqual(changes[canonical], updated)
|
|
fs_transaction.replace_many(changes)
|
|
self.assertEqual(self.branch.read_bytes(), canonical.read_bytes())
|
|
self.assertEqual(layout_check.check_layout(self.root)["status"], "PASS")
|
|
|
|
def test_shadow_refresh_recovers_new_documents_and_mirror_drift(self) -> None:
|
|
vault_migrate.apply(self._prepare("shadow"))
|
|
self.branch.write_bytes(self.branch.read_bytes() + b"changed outside harness\n")
|
|
added = self.root / "raw/branch-notes/feature-new-shadow-document.md"
|
|
added.write_text(
|
|
"---\ntitle: New shadow document\nproject: sample-project\n---\n# New\n",
|
|
encoding="utf-8",
|
|
)
|
|
dirty = layout_check.check_layout(self.root)
|
|
self.assertEqual(dirty["status"], "FAIL")
|
|
self.assertEqual(
|
|
{item["code"] for item in dirty["findings"]},
|
|
{"MIGRATION_ENTRY_MISSING", "SHADOW_MIRROR_DRIFT"},
|
|
)
|
|
|
|
refresh = self._prepare("shadow")
|
|
self.assertEqual(refresh.result["from_mode"], "shadow")
|
|
self.assertEqual(refresh.result["to_mode"], "shadow")
|
|
vault_migrate.apply(refresh)
|
|
|
|
result = layout_check.check_layout(self.root)
|
|
self.assertEqual(result["status"], "PASS", result["findings"])
|
|
canonical_added = self.root / "vault/10-projects/sample-project/branch-notes/feature-new-shadow-document.md"
|
|
self.assertEqual(added.read_bytes(), canonical_added.read_bytes())
|
|
|
|
def test_canonical_apply_creates_compatibility_symlinks_and_rollback_restores_shadow(self) -> None:
|
|
vault_migrate.apply(self._prepare("shadow"))
|
|
canonical_plan = self._prepare("canonical")
|
|
vault_migrate.apply(canonical_plan)
|
|
result = layout_check.check_layout(self.root)
|
|
self.assertEqual(result["status"], "PASS", result["findings"])
|
|
self.assertEqual(result["authority"], "vault")
|
|
self.assertTrue(self.branch.is_symlink())
|
|
self.assertEqual(
|
|
self.branch.resolve(),
|
|
(self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md").resolve(),
|
|
)
|
|
rollback = self._prepare("shadow")
|
|
vault_migrate.apply(rollback)
|
|
self.assertEqual(layout_check.check_layout(self.root)["mode"], "shadow")
|
|
canonical = self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md"
|
|
self.assertEqual(self.branch.read_bytes(), canonical.read_bytes())
|
|
# 롤백은 legacy 를 독립 *실파일* 미러로 복원해야 한다. 바이트 동일만 확인하면
|
|
# 심링크도 통과하므로(그 자체가 SHADOW_MIRROR_SYMLINK 가 잡는 결함), 실파일임을 못박는다.
|
|
self.assertFalse(self.branch.is_symlink())
|
|
|
|
def test_canonical_symlink_preserves_escaped_alias_wikilink_without_stale_authority(self) -> None:
|
|
vault_migrate.apply(self._prepare("shadow"))
|
|
updated = self.branch.read_text(encoding="utf-8") + (
|
|
"[[raw/project-notes/sample-project\\|project]]\n"
|
|
)
|
|
changes, _authority = vault_migrate.expand_authoritative_changes(
|
|
self.root,
|
|
{self.branch: updated.encode("utf-8")},
|
|
)
|
|
fs_transaction.replace_many(changes)
|
|
|
|
vault_migrate.apply(self._prepare("canonical"))
|
|
canonical = self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md"
|
|
self.assertIn(
|
|
"[[raw/project-notes/sample-project\\|project]]",
|
|
canonical.read_text(encoding="utf-8"),
|
|
)
|
|
self.assertEqual(layout_check.check_layout(self.root)["status"], "PASS")
|
|
|
|
def test_transaction_failure_restores_layout_and_removes_new_mirrors(self) -> None:
|
|
plan = self._prepare("shadow")
|
|
original_layout = self.layout.read_bytes()
|
|
real_replace = fs_transaction.os.replace
|
|
calls = 0
|
|
|
|
def fail_second(source, target):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 2:
|
|
raise OSError("injected cutover failure")
|
|
return real_replace(source, target)
|
|
|
|
with mock.patch("fs_transaction.os.replace", side_effect=fail_second):
|
|
with self.assertRaises(fs_transaction.TransactionError):
|
|
vault_migrate.apply(plan)
|
|
self.assertEqual(self.layout.read_bytes(), original_layout)
|
|
self.assertFalse((self.root / "vault/10-projects/sample-project/project-notes/sample-project.md").exists())
|
|
self.assertFalse((self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md").exists())
|
|
|
|
def test_plan_hash_and_snapshot_preconditions_block_stale_apply(self) -> None:
|
|
first = self._prepare("shadow")
|
|
second = self._prepare("shadow")
|
|
self.assertEqual(first.result["plan_sha256"], second.result["plan_sha256"])
|
|
original_layout = self.layout.read_bytes()
|
|
self.branch.write_bytes(self.branch.read_bytes() + b"concurrent\n")
|
|
concurrent = self.branch.read_bytes()
|
|
with self.assertRaises(vault_migrate.MigrationError) as raised:
|
|
vault_migrate.apply(first)
|
|
self.assertEqual(raised.exception.code, "CONCURRENT_MODIFICATION")
|
|
self.assertEqual(self.branch.read_bytes(), concurrent)
|
|
self.assertEqual(self.layout.read_bytes(), original_layout)
|
|
|
|
def test_cli_apply_requires_matching_dry_run_plan_hash(self) -> None:
|
|
command = [
|
|
sys.executable,
|
|
str(RUNTIME / "vault_migrate.py"),
|
|
"--root",
|
|
str(self.root),
|
|
"--to",
|
|
"shadow",
|
|
]
|
|
rejected = subprocess.run(
|
|
[*command, "--apply", "--expected-plan-sha256", "0" * 64],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
self.assertEqual(rejected.returncode, 1, rejected.stdout)
|
|
self.assertEqual(json.loads(rejected.stdout)["errors"][0]["code"], "PLAN_HASH_MISMATCH")
|
|
self.assertEqual(json.loads(self.layout.read_text(encoding="utf-8"))["mode"], "compatibility")
|
|
|
|
def test_non_markdown_asset_uses_symlink_redirect_and_rolls_back(self) -> None:
|
|
diagram_root = self.root / "raw/diagrams"
|
|
diagram_root.mkdir()
|
|
diagram = diagram_root / "sample.drawio"
|
|
diagram.write_bytes(b"diagram")
|
|
config = json.loads(self.layout.read_text(encoding="utf-8"))
|
|
config["areas"]["10-projects"].append("raw/diagrams")
|
|
self.layout.write_text(json.dumps(config), encoding="utf-8")
|
|
vault_migrate.apply(self._prepare("shadow"))
|
|
vault_migrate.apply(self._prepare("canonical"))
|
|
canonical = self.root / "vault/10-projects/diagrams/sample.drawio"
|
|
self.assertTrue(diagram.is_symlink())
|
|
self.assertEqual(diagram.resolve(), canonical.resolve())
|
|
self.assertEqual(layout_check.check_layout(self.root)["status"], "PASS")
|
|
authority = layout_check.resolve_authority(self.root)
|
|
with self.assertRaises(layout_check.LayoutContractError) as raised:
|
|
layout_check.enforce_write_paths(self.root, [diagram], authority)
|
|
self.assertEqual(raised.exception.code, "WRITE_ROOT_VIOLATION")
|
|
|
|
vault_migrate.apply(self._prepare("shadow"))
|
|
self.assertFalse(diagram.is_symlink())
|
|
self.assertEqual(diagram.read_bytes(), b"diagram")
|
|
self.assertEqual(layout_check.check_layout(self.root)["mode"], "shadow")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|