170 lines
7.6 KiB
Python
170 lines
7.6 KiB
Python
"""Filesystem boundary and portability tests for the standalone skill."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.dont_write_bytecode = True
|
|
SCRIPTS = Path(__file__).resolve().parents[2] / "skills" / "learnharness" / "scripts"
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
import _data
|
|
|
|
|
|
class DataHelpersTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.base = Path(self.temp.name)
|
|
self.root = self.base / "data"
|
|
self.root.mkdir()
|
|
|
|
def tearDown(self):
|
|
self.temp.cleanup()
|
|
|
|
def record(self, **changes):
|
|
value = {
|
|
"id": "chat_one", "title": "A lesson", "createdAt": "2026-10-01T00:00:00Z",
|
|
"updatedAt": "2026-10-02T00:00:00Z", "messages": [],
|
|
}
|
|
value.update(changes)
|
|
return value
|
|
|
|
def save_chat(self, **changes):
|
|
(self.root / "chats").mkdir(exist_ok=True)
|
|
(self.root / "chats" / "chat_one.json").write_text(json.dumps(self.record(**changes)), encoding="utf-8")
|
|
|
|
def test_explicit_existing_root(self):
|
|
self.assertEqual(_data.data_root(str(self.root)), self.root.resolve())
|
|
|
|
def test_missing_root_is_not_created(self):
|
|
target = self.base / "not-created"
|
|
with self.assertRaisesRegex(_data.DataError, "nothing was created"):
|
|
_data.data_root(str(target))
|
|
self.assertFalse(target.exists())
|
|
|
|
def test_mac_standard_root(self):
|
|
expected = self.base / "Library" / "Application Support" / "LearnHarness" / "data"
|
|
expected.mkdir(parents=True)
|
|
with patch.object(_data.sys, "platform", "darwin"), patch.object(Path, "home", return_value=self.base):
|
|
self.assertEqual(_data.data_root(), expected.resolve())
|
|
|
|
def test_windows_standard_root(self):
|
|
expected = self.base / "roaming" / "LearnHarness" / "data"
|
|
expected.mkdir(parents=True)
|
|
with patch.object(_data.sys, "platform", "win32"), patch.dict(os.environ, {"APPDATA": str(self.base / "roaming")}):
|
|
self.assertEqual(_data.data_root(), expected.resolve())
|
|
|
|
def test_windows_missing_appdata_requires_explicit_root(self):
|
|
with patch.object(_data.sys, "platform", "win32"), patch.dict(os.environ, {}, clear=True):
|
|
with self.assertRaisesRegex(_data.DataError, "--data-dir"):
|
|
_data.data_root()
|
|
|
|
def test_linux_xdg_and_fallback(self):
|
|
xdg = self.base / "config"
|
|
expected = xdg / "LearnHarness" / "data"
|
|
expected.mkdir(parents=True)
|
|
with patch.object(_data.sys, "platform", "linux"), patch.dict(os.environ, {"XDG_CONFIG_HOME": str(xdg)}):
|
|
self.assertEqual(_data.data_root(), expected.resolve())
|
|
fallback = self.base / ".config" / "LearnHarness" / "data"
|
|
fallback.mkdir(parents=True)
|
|
with patch.object(_data.sys, "platform", "linux"), patch.object(Path, "home", return_value=self.base), patch.dict(os.environ, {}, clear=True):
|
|
self.assertEqual(_data.data_root(), fallback.resolve())
|
|
|
|
def test_relative_xdg_is_explicit_error(self):
|
|
with patch.object(_data.sys, "platform", "linux"), patch.dict(os.environ, {"XDG_CONFIG_HOME": "relative"}):
|
|
with self.assertRaisesRegex(_data.DataError, "relative"):
|
|
_data.data_root()
|
|
|
|
def test_explicit_root_works_on_unknown_os(self):
|
|
with patch.object(_data.sys, "platform", "unknown"):
|
|
self.assertEqual(_data.data_root(str(self.root)), self.root.resolve())
|
|
with self.assertRaisesRegex(_data.DataError, "--data-dir"):
|
|
_data.data_root()
|
|
|
|
def test_safe_path_rejects_traversal_and_absolute_paths(self):
|
|
self.assertEqual(_data.safe_path(self.root, "chats/chat_one.json"), self.root.resolve() / "chats" / "chat_one.json")
|
|
for relative in ("../auth.json", self.base / "outside.json"):
|
|
with self.subTest(path=relative), self.assertRaises(_data.DataError):
|
|
_data.safe_path(self.root, relative)
|
|
|
|
def test_safe_path_rejects_escaping_symlink(self):
|
|
outside = self.base / "private.json"
|
|
outside.write_text('{"unrelated": true}', encoding="utf-8")
|
|
try:
|
|
(self.root / "link.json").symlink_to(outside)
|
|
except (OSError, NotImplementedError):
|
|
self.skipTest("Symlinks unavailable for this user")
|
|
with self.assertRaisesRegex(_data.DataError, "outside"):
|
|
_data.read_json(self.root, "link.json")
|
|
|
|
def test_json_read_missing_and_malformed_without_writes(self):
|
|
self.assertEqual(_data.read_json(self.root, "optional.json", missing=[]), [])
|
|
target = self.root / "bad.json"
|
|
target.write_text("{", encoding="utf-8")
|
|
with self.assertRaisesRegex(_data.DataError, "Cannot read"):
|
|
_data.read_json(self.root, "bad.json")
|
|
self.assertEqual(target.read_text(encoding="utf-8"), "{")
|
|
self.assertEqual([p.name for p in self.root.iterdir()], ["bad.json"])
|
|
|
|
def test_non_json_constants_are_not_accepted(self):
|
|
(self.root / "bad.json").write_text('{"value": NaN}', encoding="utf-8")
|
|
with self.assertRaises(_data.DataError):
|
|
_data.read_json(self.root, "bad.json")
|
|
|
|
def test_unstamped_native_chat_is_read_without_stamping(self):
|
|
self.save_chat()
|
|
target = self.root / "chats" / "chat_one.json"
|
|
before = target.read_bytes()
|
|
self.assertEqual(_data.read_chat(self.root, "chat_one"), self.record())
|
|
self.assertEqual(target.read_bytes(), before)
|
|
self.assertEqual(sorted(p.relative_to(self.root).as_posix() for p in self.root.rglob("*")), ["chats", "chats/chat_one.json"])
|
|
|
|
def test_future_chat_version_is_refused(self):
|
|
self.save_chat(version=2)
|
|
with self.assertRaisesRegex(_data.DataError, "Update the skill"):
|
|
_data.read_chat(self.root, "chat_one")
|
|
self.save_chat(version=True)
|
|
with self.assertRaisesRegex(_data.DataError, "invalid version"):
|
|
_data.read_chat(self.root, "chat_one")
|
|
|
|
def test_pre_native_parts_dialect_is_not_silently_empty(self):
|
|
self.save_chat(messages=[{"role": "user", "parts": [{"type": "text", "text": "old shape"}]}])
|
|
with self.assertRaisesRegex(_data.DataError, "older UIMessage"):
|
|
_data.read_chat(self.root, "chat_one")
|
|
|
|
def test_read_chat_rejects_missing_and_mismatched_records(self):
|
|
with self.assertRaisesRegex(_data.DataError, "not found"):
|
|
_data.read_chat(self.root, "chat_one")
|
|
self.save_chat(id="chat_other")
|
|
with self.assertRaisesRegex(_data.DataError, "mismatch"):
|
|
_data.read_chat(self.root, "chat_one")
|
|
|
|
def test_read_chat_rejects_path_in_place_of_id(self):
|
|
for value in ("../auth", "..", "", "chats/chat_one", "chats\\chat_one"):
|
|
with self.subTest(value=value), self.assertRaises(_data.DataError):
|
|
_data.read_chat(self.root, value)
|
|
|
|
def test_common_flags_and_unicode_json(self):
|
|
parser = argparse.ArgumentParser()
|
|
_data.add_common_arguments(parser)
|
|
args = parser.parse_args(["--data-dir", str(self.root), "--format", "json"])
|
|
self.assertEqual(args.data_dir, str(self.root))
|
|
self.assertEqual(args.format, "json")
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out):
|
|
_data.print_json({"statement": "∇ and café"})
|
|
self.assertIn("∇", out.getvalue())
|
|
self.assertEqual(json.loads(out.getvalue()), {"statement": "∇ and café"})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|