diff --git a/CHANGELOG.md b/CHANGELOG.md index 883e762..1ff746f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to netbox-super-cli are tracked here. Format follows [Keep a ## Unreleased +### Fixed + +- **A `--schema`/`NSC_SCHEMA` override no longer leaks into later runs** ([#196]). + The override was saved as the profile's freshly fetched schema. So for the + next 24 hours (default `schema_refresh: daily`), and whenever NetBox was + unreachable, runs *without* the override silently used it. Overrides are now + never written to the profile's cache. + ## v1.8.5 — 2026-10-23 Patch release. `--schema` and `NSC_SCHEMA` report load failures clearly and behave the same way. @@ -167,6 +175,7 @@ notices, and a fix for `nsc login` reporting the wrong user. [#186]: https://github.com/thomaschristory/netbox-super-cli/issues/186 [#189]: https://github.com/thomaschristory/netbox-super-cli/issues/189 [#192]: https://github.com/thomaschristory/netbox-super-cli/issues/192 +[#196]: https://github.com/thomaschristory/netbox-super-cli/issues/196 [#171]: https://github.com/thomaschristory/netbox-super-cli/issues/171 [#179]: https://github.com/thomaschristory/netbox-super-cli/issues/179 [#173]: https://github.com/thomaschristory/netbox-super-cli/issues/173 diff --git a/docs/architecture/schema-loading.md b/docs/architecture/schema-loading.md index 3b9735e..bc8be4a 100644 --- a/docs/architecture/schema-loading.md +++ b/docs/architecture/schema-loading.md @@ -9,7 +9,9 @@ and an optional `--refresh-schema` override: 1. `--schema ` (or `NSC_SCHEMA`; the flag wins) — one-shot override. Always wins; never consults the TTL fast-path, the cache - fallback or the bundled snapshot. A missing file, unreachable URL or + fallback or the bundled snapshot. It never writes or refreshes the + profile's cache, so a later run without it can't pick it up; a cache + entry with the same hash (identical schema) is reused read-only. A missing file, unreachable URL or invalid document exits 3 with `cannot load schema from : …`. 2. **TTL fast-path.** If the policy is `daily`, `weekly`, or `manual` and the newest sidecar-validated cache entry for the active profile is diff --git a/nsc/schema/source.py b/nsc/schema/source.py index b885f2b..79b470e 100644 --- a/nsc/schema/source.py +++ b/nsc/schema/source.py @@ -1,7 +1,8 @@ """Schema-source resolution chain. Order, given a `schema_refresh` policy and an optional `force_refresh`: - 1. --schema flag (path or URL) — always wins, never consults TTL. + 1. --schema flag / NSC_SCHEMA (path or URL) — always wins, never consults + TTL, and never writes the profile's cache (#196). 2. TTL fast-path: if `schema_refresh` allows it and a cache entry for this profile is fresher than the policy's TTL, return it directly without any HTTP roundtrip. Skipped when `force_refresh=True`. @@ -75,7 +76,7 @@ def resolve_command_model( ) except SchemaLoadError as exc: raise SchemaSourceError(f"cannot load schema from {exc}") from exc - return _build_and_cache(loaded, paths, profile) + return _build_override(loaded, paths, profile) if not force_refresh: ttl = _ttl_for_policy(schema_refresh) @@ -129,6 +130,20 @@ def _fetch_schema(url: str, profile: ResolvedProfile) -> LoadedSchema: return LoadedSchema(source=url, body=body, hash=canonical_sha256(body), document=document) +def _build_override(loaded: LoadedSchema, paths: Paths, profile: ResolvedProfile) -> CommandModel: + """Issue #196: an override never writes or refreshes the profile's cache. + Persisting it (with a fresh `fetched_at`) let later runs without the + override serve it via the TTL fast-path or the offline fallback. A + same-hash entry is the identical schema, so reading it is safe.""" + # ValueError: CacheStore rejects a profile name outside `_PROFILE_RE`; + # the override still works, just without the cache shortcut. + with contextlib.suppress(ValueError): + cached = CacheStore(root=paths.cache_dir).load(profile.name, loaded.hash) + if cached is not None: + return cached + return build_command_model(loaded) + + def _build_and_cache(loaded: LoadedSchema, paths: Paths, profile: ResolvedProfile) -> CommandModel: store = CacheStore(root=paths.cache_dir) cached = store.load(profile.name, loaded.hash) diff --git a/tests/schema/test_source_ttl.py b/tests/schema/test_source_ttl.py index f1b5559..1aa53c8 100644 --- a/tests/schema/test_source_ttl.py +++ b/tests/schema/test_source_ttl.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Any +import httpx2 import pytest import respx @@ -444,3 +445,73 @@ def test_aged_cache_with_unchanged_hash_refreshes_sidecar( assert route.call_count == 2, ( "the second resolve refreshed the sidecar, so the third must use the fast path" ) + + +def _override_schema_doc() -> dict[str, Any]: + doc = _minimal_schema_doc() + doc["info"] = {"title": "override", "version": "9.9.9"} + return doc + + +def _write_override(tmp_path: Path) -> str: + path = tmp_path / "override.json" + path.write_text(json.dumps(_override_schema_doc()), encoding="utf-8") + return str(path) + + +def test_schema_override_does_not_satisfy_later_ttl_fast_path( + tmp_path: Path, httpx2_mock: respx.Router +) -> None: + """#196: an override must not become the profile's fresh cache entry, or the + next run without it serves the override schema for the whole TTL window.""" + route = httpx2_mock.get("https://nb.example/api/schema/?format=json").respond( + 200, json=_minimal_schema_doc() + ) + paths = _paths(tmp_path) + override = resolve_command_model( + paths=paths, + profile=_profile(), + schema_override=_write_override(tmp_path), + schema_refresh=SchemaRefresh.DAILY, + ) + live = resolve_command_model( + paths=paths, profile=_profile(), schema_override=None, schema_refresh=SchemaRefresh.DAILY + ) + assert route.call_count == 1 + assert live.schema_hash != override.schema_hash + + +def test_schema_override_is_not_served_by_offline_fallback( + tmp_path: Path, httpx2_mock: respx.Router +) -> None: + httpx2_mock.get("https://nb.example/api/schema/?format=json").mock( + side_effect=httpx2.ConnectError("offline") + ) + paths = _paths(tmp_path) + override = resolve_command_model( + paths=paths, profile=_profile(), schema_override=_write_override(tmp_path) + ) + fallback = resolve_command_model(paths=paths, profile=_profile(), schema_override=None) + assert fallback.schema_hash != override.schema_hash + + +@pytest.mark.httpx2(assert_all_called=False) +def test_schema_override_reuses_matching_cache_entry_without_refreshing_it( + tmp_path: Path, httpx2_mock: respx.Router +) -> None: + httpx2_mock.get("https://nb.example/api/schema/?format=json").respond( + 200, json=_minimal_schema_doc() + ) + paths = _paths(tmp_path) + live = resolve_command_model(paths=paths, profile=_profile(), schema_override=None) + profile_dir = paths.cache_dir / "prod" + stale = time.time() - 10 * 86_400 + _age_sidecars(profile_dir, stale) + same = tmp_path / "same.json" + same.write_text(json.dumps(_minimal_schema_doc()), encoding="utf-8") + + model = resolve_command_model(paths=paths, profile=_profile(), schema_override=str(same)) + + assert model.schema_hash == live.schema_hash + meta = json.loads((profile_dir / f"{live.schema_hash}.meta.json").read_text()) + assert meta["fetched_at"] == stale