mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 19:50:42 +08:00
360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""Tests for channel probe TTL (7d), mandatory-field gate,
|
|
runtime invalidate + re-probe.
|
|
|
|
Every test mocks `sf_cli.run_sf` — no real org calls. Cache dir is
|
|
redirected into a tempdir via monkey-patching `config.PROBE_CACHE_ROOT`
|
|
to keep tests hermetic.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
import unittest.mock as mock
|
|
from pathlib import Path
|
|
|
|
from . import _bootstrap # noqa: F401
|
|
|
|
import config # type: ignore
|
|
import probe_channels # type: ignore
|
|
|
|
|
|
def _fake_describe_ok(fields: list[str]) -> dict:
|
|
"""Shape the sf_cli.run_sf output: success envelope wrapping a fields list."""
|
|
return {
|
|
"status": 0,
|
|
"result": {
|
|
"fields": [{"name": f, "queryable": True} for f in fields],
|
|
},
|
|
}
|
|
|
|
|
|
# A "universal" success payload: every mandatory field for every sObject
|
|
# present. Individual tests override for specific sObjects.
|
|
def _all_fields_ok_run_sf(recipe: str, **params: str) -> dict:
|
|
sobject = params["SOBJECT"]
|
|
mandatory = probe_channels.MANDATORY_FIELDS.get(sobject, set())
|
|
# Add a couple of bonus fields so queryable_fields isn't identical to
|
|
# mandatory_missing-inverse by construction.
|
|
extra = {"CreatedDate", "LastModifiedDate"}
|
|
return _fake_describe_ok(sorted(mandatory | extra))
|
|
|
|
|
|
class ProbeChannelsCacheTests(unittest.TestCase):
|
|
"""Cache hit / miss / TTL semantics."""
|
|
|
|
def setUp(self) -> None:
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._tmp.cleanup)
|
|
self._orig_root = config.PROBE_CACHE_ROOT
|
|
config.PROBE_CACHE_ROOT = Path(self._tmp.name) / "_channel_probe"
|
|
|
|
def tearDown(self) -> None:
|
|
config.PROBE_CACHE_ROOT = self._orig_root
|
|
|
|
def test_cache_hit_within_ttl_no_network(self):
|
|
"""A fresh cache file + force_refresh=False → no run_sf calls."""
|
|
cache_dir = config.build_probe_cache_dir("00D000000000ABC", "v60.0")
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
cache_file = cache_dir / "channels.json"
|
|
payload = {
|
|
"_schema": "channels/1",
|
|
"_built_at_utc": time.time(), # right now → fresh
|
|
"status": "OK",
|
|
"channels": {"BotDefinition": {"queryable_fields": ["Id"],
|
|
"mandatory_missing": [],
|
|
"describe_error": None}},
|
|
}
|
|
cache_file.write_text(json.dumps(payload))
|
|
|
|
with mock.patch.object(probe_channels, "run_sf") as m:
|
|
out = probe_channels.probe_channels(
|
|
"myorg", "00D000000000ABC", "v60.0", force_refresh=False,
|
|
)
|
|
self.assertEqual(out["status"], "OK")
|
|
m.assert_not_called()
|
|
|
|
def test_cache_stale_triggers_reprobe(self):
|
|
"""Cached file older than TTL → re-run describes."""
|
|
cache_dir = config.build_probe_cache_dir("00D000000000ABC", "v60.0")
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
cache_file = cache_dir / "channels.json"
|
|
stale = {
|
|
"_schema": "channels/1",
|
|
"_built_at_utc": time.time() - (8 * 86400), # 8 days old
|
|
"status": "OK",
|
|
"channels": {},
|
|
}
|
|
cache_file.write_text(json.dumps(stale))
|
|
|
|
with mock.patch.object(probe_channels, "run_sf",
|
|
side_effect=_all_fields_ok_run_sf) as m:
|
|
out = probe_channels.probe_channels(
|
|
"myorg", "00D000000000ABC", "v60.0", force_refresh=False,
|
|
)
|
|
self.assertEqual(out["status"], "OK")
|
|
# One call per sObject.
|
|
self.assertEqual(m.call_count, len(probe_channels.ALL_SOBJECTS))
|
|
|
|
def test_force_refresh_always_reprobes(self):
|
|
"""force_refresh=True bypasses a fresh cache."""
|
|
cache_dir = config.build_probe_cache_dir("00D000000000ABC", "v60.0")
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
cache_file = cache_dir / "channels.json"
|
|
fresh = {
|
|
"_schema": "channels/1",
|
|
"_built_at_utc": time.time(),
|
|
"status": "OK",
|
|
"channels": {},
|
|
}
|
|
cache_file.write_text(json.dumps(fresh))
|
|
|
|
with mock.patch.object(probe_channels, "run_sf",
|
|
side_effect=_all_fields_ok_run_sf) as m:
|
|
probe_channels.probe_channels(
|
|
"myorg", "00D000000000ABC", "v60.0", force_refresh=True,
|
|
)
|
|
self.assertEqual(m.call_count, len(probe_channels.ALL_SOBJECTS))
|
|
|
|
|
|
class InvalidateAndReprobeTests(unittest.TestCase):
|
|
"""runtime escape valve when SOQL returns INVALID_FIELD."""
|
|
|
|
def setUp(self) -> None:
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._tmp.cleanup)
|
|
self._orig_root = config.PROBE_CACHE_ROOT
|
|
config.PROBE_CACHE_ROOT = Path(self._tmp.name) / "_channel_probe"
|
|
|
|
def tearDown(self) -> None:
|
|
config.PROBE_CACHE_ROOT = self._orig_root
|
|
|
|
def test_invalidate_deletes_cache_and_reruns(self):
|
|
cache_dir = config.build_probe_cache_dir("00D000000000ABC", "v60.0")
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
cache_file = cache_dir / "channels.json"
|
|
cache_file.write_text(json.dumps({
|
|
"_schema": "channels/1",
|
|
"_built_at_utc": time.time(),
|
|
"status": "OK",
|
|
"channels": {},
|
|
}))
|
|
|
|
with mock.patch.object(probe_channels, "run_sf",
|
|
side_effect=_all_fields_ok_run_sf) as m:
|
|
out = probe_channels.invalidate_and_reprobe(
|
|
"myorg", "00D000000000ABC", "v60.0",
|
|
)
|
|
|
|
self.assertEqual(out["status"], "OK")
|
|
self.assertTrue(cache_file.is_file(), "fresh cache must be rewritten")
|
|
self.assertEqual(m.call_count, len(probe_channels.ALL_SOBJECTS))
|
|
|
|
def test_invalidate_tolerates_missing_cache(self):
|
|
"""Cold-path invalidate (no file yet) must not crash."""
|
|
with mock.patch.object(probe_channels, "run_sf",
|
|
side_effect=_all_fields_ok_run_sf):
|
|
out = probe_channels.invalidate_and_reprobe(
|
|
"myorg", "00D000000000ABC", "v60.0",
|
|
)
|
|
self.assertEqual(out["status"], "OK")
|
|
|
|
|
|
class MandatoryFieldGateTests(unittest.TestCase):
|
|
"""Missing mandatory field → status flips to PROBE_FAILED."""
|
|
|
|
def setUp(self) -> None:
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._tmp.cleanup)
|
|
self._orig_root = config.PROBE_CACHE_ROOT
|
|
config.PROBE_CACHE_ROOT = Path(self._tmp.name) / "_channel_probe"
|
|
|
|
def tearDown(self) -> None:
|
|
config.PROBE_CACHE_ROOT = self._orig_root
|
|
|
|
def test_missing_invocation_target_flags_probe_failed(self):
|
|
"""SF release drops `InvocationTarget` on
|
|
GenAiFunctionDefinition → probe must surface PROBE_FAILED.
|
|
"""
|
|
|
|
def mock_run_sf(recipe: str, **params: str) -> dict:
|
|
sobject = params["SOBJECT"]
|
|
mandatory = set(probe_channels.MANDATORY_FIELDS.get(sobject, set()))
|
|
if sobject == "GenAiFunctionDefinition":
|
|
# Simulate the schema drift: drop InvocationTarget.
|
|
mandatory.discard("InvocationTarget")
|
|
return _fake_describe_ok(sorted(mandatory))
|
|
|
|
with mock.patch.object(probe_channels, "run_sf", side_effect=mock_run_sf):
|
|
out = probe_channels.probe_channels(
|
|
"myorg", "00D000000000ABC", "v60.0", force_refresh=True,
|
|
)
|
|
|
|
self.assertEqual(out["status"], "PROBE_FAILED")
|
|
entry = out["channels"]["GenAiFunctionDefinition"]
|
|
self.assertIn("InvocationTarget", entry["mandatory_missing"])
|
|
# Other sObjects unaffected.
|
|
self.assertEqual(
|
|
out["channels"]["BotDefinition"]["mandatory_missing"], [],
|
|
)
|
|
|
|
|
|
class CacheFilePermissionsTests(unittest.TestCase):
|
|
"""cached channels.json must be 0o600 (owner RW only)."""
|
|
|
|
def setUp(self) -> None:
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._tmp.cleanup)
|
|
self._orig_root = config.PROBE_CACHE_ROOT
|
|
config.PROBE_CACHE_ROOT = Path(self._tmp.name) / "_channel_probe"
|
|
|
|
def tearDown(self) -> None:
|
|
config.PROBE_CACHE_ROOT = self._orig_root
|
|
|
|
def test_cache_file_is_mode_0o600(self):
|
|
with mock.patch.object(probe_channels, "run_sf",
|
|
side_effect=_all_fields_ok_run_sf):
|
|
probe_channels.probe_channels(
|
|
"myorg", "00D000000000ABC", "v60.0", force_refresh=True,
|
|
)
|
|
cache_file = (
|
|
config.build_probe_cache_dir("00D000000000ABC", "v60.0")
|
|
/ "channels.json"
|
|
)
|
|
self.assertTrue(cache_file.is_file())
|
|
mode = stat.S_IMODE(cache_file.stat().st_mode)
|
|
self.assertEqual(
|
|
mode, 0o600,
|
|
f"cache file mode must be 0o600, got 0o{mode:03o} ",
|
|
)
|
|
|
|
|
|
class CacheFreshnessBoundsTests(unittest.TestCase):
|
|
"""`_is_cache_fresh` must reject future timestamps as stale.
|
|
|
|
A `_built_at_utc` in the future is a symptom of clock skew (NTP
|
|
drift after suspend, container clock jump) or cache tampering —
|
|
either way, we treat it as untrustworthy and force a reprobe.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._tmp.cleanup)
|
|
|
|
def test_future_timestamp_is_not_fresh(self):
|
|
# Year 3000 → unambiguously future.
|
|
future_epoch = 32503680000.0
|
|
cache_file = Path(self._tmp.name) / "channels.json"
|
|
cache_file.write_text(json.dumps({
|
|
"_schema": "channels/1",
|
|
"_built_at_utc": future_epoch,
|
|
"status": "OK",
|
|
"channels": {},
|
|
}))
|
|
self.assertFalse(
|
|
probe_channels._is_cache_fresh(cache_file, ttl_days=7),
|
|
"future _built_at_utc must be rejected as stale",
|
|
)
|
|
|
|
def test_present_timestamp_is_fresh(self):
|
|
cache_file = Path(self._tmp.name) / "channels.json"
|
|
cache_file.write_text(json.dumps({
|
|
"_schema": "channels/1",
|
|
"_built_at_utc": time.time(),
|
|
"status": "OK",
|
|
"channels": {},
|
|
}))
|
|
self.assertTrue(probe_channels._is_cache_fresh(cache_file, ttl_days=7))
|
|
|
|
|
|
class WriteCacheConcurrencyTests(unittest.TestCase):
|
|
"""concurrent _write_cache calls must not collide on tmp names.
|
|
|
|
The prior design used `path.with_suffix(path.suffix + ".tmp")` — a
|
|
deterministic name shared by every concurrent writer. This test
|
|
fires N threads that all write the same target path; after the dust
|
|
settles there must be exactly one final file, no leftover `.tmp`
|
|
files, and the contents must be valid JSON from ONE of the writers
|
|
(any of them is acceptable — what matters is integrity).
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._tmp.cleanup)
|
|
|
|
def test_concurrent_writes_no_collision_no_corruption(self):
|
|
import threading
|
|
|
|
target_dir = Path(self._tmp.name) / "probe"
|
|
target_path = target_dir / "channels.json"
|
|
|
|
N = 8
|
|
errors: list[BaseException] = []
|
|
barrier = threading.Barrier(N)
|
|
|
|
def worker(i: int) -> None:
|
|
payload = {
|
|
"_schema": "channels/1",
|
|
"_built_at_utc": float(i + 1),
|
|
"status": "OK",
|
|
"channels": {"writer": {"queryable_fields": [f"w{i}"]}},
|
|
}
|
|
barrier.wait() # maximise contention
|
|
try:
|
|
probe_channels._write_cache(target_path, payload)
|
|
except BaseException as e: # noqa: BLE001 — thread boundary
|
|
errors.append(e)
|
|
|
|
threads = [threading.Thread(target=worker, args=(i,)) for i in range(N)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=5.0)
|
|
|
|
self.assertEqual(errors, [], f"worker errors: {errors}")
|
|
self.assertTrue(target_path.is_file())
|
|
|
|
# No stray tmp files left behind.
|
|
leftover = list(target_dir.glob(".channels.*.tmp"))
|
|
self.assertEqual(
|
|
leftover, [],
|
|
f"concurrent writes left tmp leftovers: {leftover}",
|
|
)
|
|
|
|
# Final file is parseable JSON (not a half-written corruption).
|
|
parsed = json.loads(target_path.read_text())
|
|
# One of the N payloads won the race; its _built_at_utc is in [1, N].
|
|
self.assertIn(parsed["_built_at_utc"], [float(i + 1) for i in range(N)])
|
|
|
|
def test_parent_dir_permissions_are_0o700(self):
|
|
"""parent dir is normalized to 0o700 after write.
|
|
|
|
Even if the directory pre-existed with looser permissions, a
|
|
successful _write_cache leaves it at 0o700.
|
|
"""
|
|
target_path = Path(self._tmp.name) / "probe" / "channels.json"
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# Deliberately widen perms to prove _write_cache narrows them.
|
|
os.chmod(target_path.parent, 0o755)
|
|
|
|
probe_channels._write_cache(target_path, {
|
|
"_schema": "channels/1",
|
|
"_built_at_utc": time.time(),
|
|
"status": "OK",
|
|
"channels": {},
|
|
})
|
|
|
|
mode = stat.S_IMODE(target_path.parent.stat().st_mode)
|
|
self.assertEqual(
|
|
mode, 0o700,
|
|
f"parent dir mode must be 0o700, got 0o{mode:03o}",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|