from pathlib import Path import pytest from tht.config import ConfigError, load_config, workspace_id_from_path from tht.paths import resolve_workspace_paths def _write_config(path: Path, *, sessions: str = "sessions", absolute: Path | None = None) -> Path: root = absolute or Path("artifacts") path.write_text( "dwh:\n" " type: postgres_direct\n" " connection:\n" " database: db\n" " schema: public\n" " user: user\n" " password: secret\n" "roots:\n" f" artifacts: {root}\n" f" indexes: {root if absolute else 'indexes'}\n" f" sessions: {absolute if absolute else sessions}\n" ) return path def test_relative_paths_resolve_under_workspace_root(tmp_path): cfg_path = _write_config(tmp_path / "demo.yaml") cfg = load_config(cfg_path) resolved = resolve_workspace_paths(cfg_path, cfg, tmp_path / "data") assert resolved.workspace == tmp_path / "data/workspaces/demo" assert resolved.sessions == tmp_path / "data/workspaces/demo/sessions" assert resolved.artifacts == tmp_path / "data/workspaces/demo/artifacts" assert resolved.indexes == tmp_path / "data/workspaces/demo/indexes" assert resolved.corpus == tmp_path / "data/workspaces/demo/corpus" def test_absolute_legacy_paths_are_preserved(tmp_path): legacy = tmp_path / "existing-workspace" cfg_path = _write_config(tmp_path / "demo.yaml", absolute=legacy) cfg = load_config(cfg_path) resolved = resolve_workspace_paths(cfg_path, cfg, tmp_path / "data") assert resolved.sessions == legacy assert resolved.artifacts == legacy assert resolved.indexes == legacy def test_path_escape_is_rejected(tmp_path): cfg_path = _write_config(tmp_path / "demo.yaml", sessions="../../private") cfg = load_config(cfg_path) with pytest.raises(ConfigError, match="outside workspace root"): resolve_workspace_paths(cfg_path, cfg, tmp_path / "data") def test_workspace_symlink_escape_is_rejected(tmp_path): cfg_path = _write_config(tmp_path / "demo.yaml") cfg = load_config(cfg_path) data_root = tmp_path / "data" (data_root / "workspaces").mkdir(parents=True) (data_root / "workspaces" / "demo").symlink_to(tmp_path / "private", target_is_directory=True) with pytest.raises(ConfigError, match="outside workspaces root"): resolve_workspace_paths(cfg_path, cfg, data_root) def test_nested_root_symlink_escape_is_rejected(tmp_path): cfg_path = _write_config(tmp_path / "demo.yaml") cfg = load_config(cfg_path) workspace = tmp_path / "data/workspaces/demo" workspace.mkdir(parents=True) (workspace / "sessions").symlink_to(tmp_path / "private", target_is_directory=True) with pytest.raises(ConfigError, match="outside workspace root"): resolve_workspace_paths(cfg_path, cfg, tmp_path / "data") def test_corpus_symlink_escape_is_rejected(tmp_path): cfg_path = _write_config(tmp_path / "demo.yaml") cfg = load_config(cfg_path) workspace = tmp_path / "data/workspaces/demo" workspace.mkdir(parents=True) (workspace / "corpus").symlink_to(tmp_path / "private", target_is_directory=True) with pytest.raises(ConfigError, match="outside workspace root"): resolve_workspace_paths(cfg_path, cfg, tmp_path / "data") def test_data_root_environment_activates_portable_paths(monkeypatch, tmp_path): cfg_path = _write_config(tmp_path / "demo.yaml") monkeypatch.setenv("THT_DATA_ROOT", str(tmp_path / "data")) cfg = load_config(cfg_path) assert cfg.paths.sessions == tmp_path / "data/workspaces/demo/sessions" assert cfg.paths.artifacts == tmp_path / "data/workspaces/demo/artifacts" def test_no_data_root_preserves_legacy_relative_paths(monkeypatch, tmp_path): cfg_path = _write_config(tmp_path / "demo.yaml") monkeypatch.delenv("THT_DATA_ROOT", raising=False) cfg = load_config(cfg_path) assert cfg.paths.sessions == Path("sessions") assert cfg.paths.artifacts == Path("artifacts") def test_workspace_identity_resolves_config_symlink(tmp_path): (tmp_path / "workspaces").mkdir() workspace = _write_config(tmp_path / "workspaces/local.yaml") alias = tmp_path / "config/tht.yaml" alias.parent.mkdir() alias.symlink_to(workspace) assert workspace_id_from_path(alias) == "local" assert load_config(alias)._workspace_id == "local" assert load_config(alias)._config_source == workspace.resolve().as_posix()