debug-control-plane 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/PKG-INFO +4 -4
  2. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/README.md +3 -3
  3. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/__init__.py +1 -1
  4. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/server.py +5 -2
  5. debug_control_plane-0.5.0/debug_control_plane/mcp_plane/token_provider.py +138 -0
  6. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/PKG-INFO +4 -4
  7. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/SOURCES.txt +3 -1
  8. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/pyproject.toml +1 -1
  9. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_server.py +1 -1
  10. debug_control_plane-0.5.0/tests/test_token_provider.py +168 -0
  11. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/LICENSE +0 -0
  12. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/__init__.py +0 -0
  13. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/device_candidates.py +0 -0
  14. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
  15. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
  16. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
  17. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
  18. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +0 -0
  19. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
  20. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
  21. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/endpoint.py +0 -0
  22. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/protocol.py +0 -0
  23. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/__init__.py +0 -0
  24. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/bridge_client.py +0 -0
  25. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/capability_mirror.py +0 -0
  26. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/semantic_provider.py +0 -0
  27. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
  28. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
  29. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/requires.txt +0 -0
  30. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/top_level.txt +0 -0
  31. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/setup.cfg +0 -0
  32. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_acceptance_flutter_app_auth.py +0 -0
  33. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_bridge_client.py +0 -0
  34. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_capability_mirror.py +0 -0
  35. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_cross_lang_kotlin_plane.py +0 -0
  36. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_device_candidates.py +0 -0
  37. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_device_pool.py +0 -0
  38. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_discovery_cross_identify.py +0 -0
  39. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_discovery_lan_scan.py +0 -0
  40. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_discovery_manual_registry.py +0 -0
  41. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_discovery_usb_identity.py +0 -0
  42. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_discovery_vpn_immune.py +0 -0
  43. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_e2e_mock.py +0 -0
  44. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_endpoint.py +0 -0
  45. {debug_control_plane-0.4.0 → debug_control_plane-0.5.0}/tests/test_import_sanity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debug-control-plane
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Multi-product reusable debug control plane (device discovery + MCP adapter).
5
5
  Author: tangxiaolu
6
6
  License: MIT
@@ -62,7 +62,7 @@ python/
62
62
  ├── README.md # this file
63
63
  ├── LICENSE # MIT
64
64
  └── debug_control_plane/
65
- ├── __init__.py # __version__ = "0.4.0"
65
+ ├── __init__.py # __version__ = "0.5.0"
66
66
  ├── device_discovery/ # USB/LAN device discovery + device pool
67
67
  │ ├── device_candidates.py
68
68
  │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
@@ -92,8 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
92
92
 
93
93
  ## Version / 版本
94
94
 
95
- `0.4.0` — aligned with Kotlin/Dart/Flutter `0.4.0`, API unstable.
96
- `0.4.0` —— 与 Kotlin/Dart/Flutter `0.4.0` 对齐,API 不稳定。
95
+ `0.5.0` — aligned with Kotlin/Dart/Flutter `0.5.0`, API unstable.
96
+ `0.5.0` —— 与 Kotlin/Dart/Flutter `0.5.0` 对齐,API 不稳定。
97
97
 
98
98
  ## License / 许可证
99
99
 
@@ -43,7 +43,7 @@ python/
43
43
  ├── README.md # this file
44
44
  ├── LICENSE # MIT
45
45
  └── debug_control_plane/
46
- ├── __init__.py # __version__ = "0.4.0"
46
+ ├── __init__.py # __version__ = "0.5.0"
47
47
  ├── device_discovery/ # USB/LAN device discovery + device pool
48
48
  │ ├── device_candidates.py
49
49
  │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
@@ -73,8 +73,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
73
73
 
74
74
  ## Version / 版本
75
75
 
76
- `0.4.0` — aligned with Kotlin/Dart/Flutter `0.4.0`, API unstable.
77
- `0.4.0` —— 与 Kotlin/Dart/Flutter `0.4.0` 对齐,API 不稳定。
76
+ `0.5.0` — aligned with Kotlin/Dart/Flutter `0.5.0`, API unstable.
77
+ `0.5.0` —— 与 Kotlin/Dart/Flutter `0.5.0` 对齐,API 不稳定。
78
78
 
79
79
  ## License / 许可证
80
80
 
@@ -4,4 +4,4 @@ Reusable across products: device discovery (USB/WiFi/identity) + MCP adapter
4
4
  (debug HTTP protocol → MCP tool surface). Extracted from an internal app
5
5
  (R019/R020, S2 Python slice, R021-BF004).
6
6
  """
7
- __version__ = "0.4.0"
7
+ __version__ = "0.5.0"
@@ -87,6 +87,7 @@ from .bridge_client import (
87
87
  )
88
88
  from .capability_mirror import CapabilityMirror, CapabilitySchema, ToolSpec
89
89
  from .semantic_provider import SemanticProvider
90
+ from .token_provider import FileTokenProvider
90
91
 
91
92
  # ★ BF008-010 (Contract §0.1 边界 1 收尾 + 方案 X): capability-specific semantic
92
93
  # sugar 已迁业务侧(强耦合产品 protocol,属业务知识)。平面 server 零业务依赖
@@ -256,7 +257,7 @@ class McpServer:
256
257
  pool: DevicePool,
257
258
  *,
258
259
  server_name: str = "mcp-debug-bridge",
259
- server_version: str = "0.4.0",
260
+ server_version: str = "0.5.0",
260
261
  providers: list[SemanticProvider] | None = None,
261
262
  tool_handlers: dict[str, Any] | None = None,
262
263
  ) -> None:
@@ -1052,7 +1053,9 @@ def main() -> None:
1052
1053
  format="%(asctime)s %(name)s %(levelname)s %(message)s",
1053
1054
  )
1054
1055
  pool = DevicePool(persist_path=Path.home() / ".debug-control-plane" / "devices.json")
1055
- client = BridgeClient(pool=pool)
1056
+ client = BridgeClient(
1057
+ pool=pool, token_provider=FileTokenProvider()
1058
+ )
1056
1059
  mirror = CapabilityMirror(client=client)
1057
1060
  server = McpServer(mirror=mirror, client=client, pool=pool)
1058
1061
  server.run_stdio() # blocks; exits when the AI client closes stdin
@@ -0,0 +1,138 @@
1
+ """File-backed debug auth token provider (R004-BF001).
2
+
3
+ FileTokenProvider is the production implementation of
4
+ :class:`debug_control_plane.mcp_plane.bridge_client.DebugAuthTokenProvider`.
5
+ It persists per-device bearer tokens to ``~/.debug-control-plane/tokens.json``
6
+ so tokens survive python process restarts (design R004 §3.2).
7
+
8
+ Schema (independent from devices.json — no shared structure or imports)::
9
+
10
+ {"version": 1, "tokens": {device_id: {"token": ..., "tokenId": ...,
11
+ "expiresAt": ...}}}
12
+
13
+ Concurrency model: lazy-loaded, single-threaded. This provider assumes it is
14
+ only used from the asyncio event-loop thread (the MCP server's threading
15
+ model). No locking is performed — the design explicitly accepted this
16
+ asyncio-single-thread assumption.
17
+
18
+ Security: plaintext tokens on the developer machine with 0600 permissions
19
+ (user-approved decision). The 0600 mode is guaranteed by creating the tmp
20
+ file via ``os.open(..., 0o600)`` (bypassing umask, so no 0644 window exists)
21
+ followed by an atomic ``os.replace``.
22
+
23
+ Corruption policy: a corrupt file or an unknown ``version`` silently falls
24
+ back to an empty store — tokens are regenerable and the server must not
25
+ crash on load.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any, Mapping
35
+
36
+ _SCHEMA_VERSION = 1
37
+ _DEFAULT_PATH = Path.home() / ".debug-control-plane" / "tokens.json"
38
+
39
+
40
+ def _is_expired(value: str) -> bool:
41
+ """Return True if ``value`` is a tz-aware ISO timestamp in the past.
42
+
43
+ Parse failures and naive timestamps are treated as *not* expired: the
44
+ phone's 401 is the final arbiter, and a parsing bug must not break the
45
+ auth chain (design §5). Evaluated at read time; never written back.
46
+ """
47
+ try:
48
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
49
+ except (ValueError, TypeError):
50
+ return False
51
+ if parsed.tzinfo is None:
52
+ return False
53
+ return parsed <= datetime.now(timezone.utc)
54
+
55
+
56
+ class FileTokenProvider:
57
+ """File-backed token store implementing the DebugAuthTokenProvider protocol."""
58
+
59
+ def __init__(self, path: Path | None = None) -> None:
60
+ self._path = path or _DEFAULT_PATH
61
+ self._loaded = False
62
+ self._tokens: dict[str, dict[str, str]] = {}
63
+
64
+ # ------------------------------------------------------------------
65
+ # Protocol surface
66
+ # ------------------------------------------------------------------
67
+
68
+ def get_token(self, device_id: str) -> str | None:
69
+ """Return the stored token for ``device_id`` if present and unexpired."""
70
+ self._ensure_loaded()
71
+ row = self._tokens.get(device_id)
72
+ if row is None:
73
+ return None
74
+ expires_at = row.get("expiresAt", "")
75
+ if expires_at and _is_expired(expires_at):
76
+ return None
77
+ return row.get("token")
78
+
79
+ def save_token(
80
+ self, device_id: str, token: str, metadata: Mapping[str, Any]
81
+ ) -> None:
82
+ """Store ``token`` for ``device_id`` and persist (full file rewrite)."""
83
+ self._ensure_loaded()
84
+ row: dict[str, str] = {"token": token}
85
+ for key, value in metadata.items():
86
+ row[key] = value if isinstance(value, str) else str(value)
87
+ self._tokens[device_id] = row
88
+ self._flush()
89
+
90
+ def clear_token(self, device_id: str, reason: str) -> None:
91
+ """Drop the row for ``device_id`` and persist (rewrite without it)."""
92
+ self._ensure_loaded()
93
+ if device_id in self._tokens:
94
+ del self._tokens[device_id]
95
+ self._flush()
96
+
97
+ # ------------------------------------------------------------------
98
+ # Internals
99
+ # ------------------------------------------------------------------
100
+
101
+ def _ensure_loaded(self) -> None:
102
+ if self._loaded:
103
+ return
104
+ self._loaded = True
105
+ try:
106
+ data = json.loads(self._path.read_text(encoding="utf-8"))
107
+ except (OSError, ValueError):
108
+ return # missing or corrupt → silent empty fallback
109
+ if not isinstance(data, dict) or data.get("version") != _SCHEMA_VERSION:
110
+ return # unknown schema → silent empty fallback
111
+ tokens = data.get("tokens")
112
+ if not isinstance(tokens, dict):
113
+ return
114
+ self._tokens = {
115
+ device_id: row
116
+ for device_id, row in tokens.items()
117
+ if isinstance(row, dict)
118
+ }
119
+
120
+ def _flush(self) -> None:
121
+ """Atomically write the store with 0600 permissions."""
122
+ payload = json.dumps(
123
+ {"version": _SCHEMA_VERSION, "tokens": self._tokens},
124
+ indent=2,
125
+ ensure_ascii=False,
126
+ )
127
+ self._path.parent.mkdir(parents=True, exist_ok=True)
128
+ tmp_path = self._path.with_name(self._path.name + ".tmp")
129
+ fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
130
+ try:
131
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
132
+ f.write(payload)
133
+ f.flush()
134
+ os.fsync(f.fileno())
135
+ except BaseException:
136
+ tmp_path.unlink(missing_ok=True)
137
+ raise
138
+ os.replace(tmp_path, self._path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debug-control-plane
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Multi-product reusable debug control plane (device discovery + MCP adapter).
5
5
  Author: tangxiaolu
6
6
  License: MIT
@@ -62,7 +62,7 @@ python/
62
62
  ├── README.md # this file
63
63
  ├── LICENSE # MIT
64
64
  └── debug_control_plane/
65
- ├── __init__.py # __version__ = "0.4.0"
65
+ ├── __init__.py # __version__ = "0.5.0"
66
66
  ├── device_discovery/ # USB/LAN device discovery + device pool
67
67
  │ ├── device_candidates.py
68
68
  │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
@@ -92,8 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
92
92
 
93
93
  ## Version / 版本
94
94
 
95
- `0.4.0` — aligned with Kotlin/Dart/Flutter `0.4.0`, API unstable.
96
- `0.4.0` —— 与 Kotlin/Dart/Flutter `0.4.0` 对齐,API 不稳定。
95
+ `0.5.0` — aligned with Kotlin/Dart/Flutter `0.5.0`, API unstable.
96
+ `0.5.0` —— 与 Kotlin/Dart/Flutter `0.5.0` 对齐,API 不稳定。
97
97
 
98
98
  ## License / 许可证
99
99
 
@@ -24,6 +24,7 @@ debug_control_plane/mcp_plane/bridge_client.py
24
24
  debug_control_plane/mcp_plane/capability_mirror.py
25
25
  debug_control_plane/mcp_plane/semantic_provider.py
26
26
  debug_control_plane/mcp_plane/server.py
27
+ debug_control_plane/mcp_plane/token_provider.py
27
28
  tests/test_acceptance_flutter_app_auth.py
28
29
  tests/test_bridge_client.py
29
30
  tests/test_capability_mirror.py
@@ -38,4 +39,5 @@ tests/test_discovery_vpn_immune.py
38
39
  tests/test_e2e_mock.py
39
40
  tests/test_endpoint.py
40
41
  tests/test_import_sanity.py
41
- tests/test_server.py
42
+ tests/test_server.py
43
+ tests/test_token_provider.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "debug-control-plane"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Multi-product reusable debug control plane (device discovery + MCP adapter)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1313,7 +1313,7 @@ class TestMakeApp:
1313
1313
  srv = assembled[0]
1314
1314
  app = srv._make_app() # noqa: SLF001
1315
1315
  assert app.name == "mcp-debug-bridge"
1316
- assert app.version == "0.4.0"
1316
+ assert app.version == "0.5.0"
1317
1317
 
1318
1318
  def test_make_app_registers_stub_provider(self, assembled):
1319
1319
  """★ BF008-010 (A 类): assembled fixture 注入的 _StubProvider 已注册到
@@ -0,0 +1,168 @@
1
+ """Unit tests for FileTokenProvider (R004-BF001).
2
+
3
+ Covers the 7 BF001-T scenarios from test §2:
4
+ 1. roundtrip-cross-instance: save → new instance get hits disk.
5
+ 2. 0600-permission: file mode == 0o600 after save.
6
+ 3. expired-past-future-Z-invalid: expiresAt four-state judgement.
7
+ 4. clear-deletes-row: clear_token rewrites file without the row.
8
+ 5. corrupt-fallback: invalid JSON → silent empty fallback.
9
+ 6. metadata-merge: metadata str values merged into the row.
10
+ 7. version-mismatch: version != 1 → silent empty fallback.
11
+
12
+ All tests use tmp_path — the real ~/.debug-control-plane/ is never touched.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import stat
19
+ from datetime import datetime, timedelta, timezone
20
+ from pathlib import Path
21
+
22
+ from debug_control_plane.mcp_plane.token_provider import FileTokenProvider
23
+
24
+
25
+ def _provider(tmp_path: Path) -> FileTokenProvider:
26
+ return FileTokenProvider(path=tmp_path / "tokens.json")
27
+
28
+
29
+ def _write_raw(tmp_path: Path, payload: dict) -> Path:
30
+ p = tmp_path / "tokens.json"
31
+ p.write_text(json.dumps(payload), encoding="utf-8")
32
+ return p
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # 1. roundtrip-cross-instance
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def test_roundtrip_cross_instance(tmp_path: Path) -> None:
40
+ p1 = _provider(tmp_path)
41
+ p1.save_token(
42
+ "device-1",
43
+ "dcp_tok_1",
44
+ {"tokenId": "tid-1", "expiresAt": "2099-01-01T00:00:00+00:00"},
45
+ )
46
+ # A new instance must see the token (persistence, not memory).
47
+ p2 = _provider(tmp_path)
48
+ assert p2.get_token("device-1") == "dcp_tok_1"
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # 2. 0600-permission
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def test_file_mode_0600(tmp_path: Path) -> None:
56
+ provider = _provider(tmp_path)
57
+ provider.save_token("device-1", "dcp_tok_1", {"tokenId": "tid-1"})
58
+ mode = stat.S_IMODE(provider._path.stat().st_mode) # noqa: SLF001
59
+ assert mode == 0o600
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # 3. expired four states
64
+ # ---------------------------------------------------------------------------
65
+
66
+ def test_expired_past_returns_none(tmp_path: Path) -> None:
67
+ past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
68
+ provider = _provider(tmp_path)
69
+ provider.save_token("d", "t", {"expiresAt": past})
70
+ assert provider.get_token("d") is None
71
+
72
+
73
+ def test_not_expired_future(tmp_path: Path) -> None:
74
+ future = (datetime.now(timezone.utc) + timedelta(days=6)).isoformat()
75
+ provider = _provider(tmp_path)
76
+ provider.save_token("d", "t", {"expiresAt": future})
77
+ assert provider.get_token("d") == "t"
78
+
79
+
80
+ def test_not_expired_z_suffix(tmp_path: Path) -> None:
81
+ future = (
82
+ datetime.now(timezone.utc) + timedelta(days=6)
83
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
84
+ provider = _provider(tmp_path)
85
+ provider.save_token("d", "t", {"expiresAt": future})
86
+ assert provider.get_token("d") == "t"
87
+
88
+
89
+ def test_invalid_expiresat_treated_as_not_expired(tmp_path: Path) -> None:
90
+ provider = _provider(tmp_path)
91
+ provider.save_token("d", "t", {"expiresAt": "not-a-date"})
92
+ assert provider.get_token("d") == "t"
93
+
94
+
95
+ def test_naive_expiresat_treated_as_not_expired(tmp_path: Path) -> None:
96
+ provider = _provider(tmp_path)
97
+ provider.save_token("d", "t", {"expiresAt": "2099-01-01T00:00:00"})
98
+ assert provider.get_token("d") == "t"
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # 4. clear-deletes-row
103
+ # ---------------------------------------------------------------------------
104
+
105
+ def test_clear_deletes_row_on_disk(tmp_path: Path) -> None:
106
+ provider = _provider(tmp_path)
107
+ provider.save_token("d1", "t1", {"tokenId": "tid-1"})
108
+ provider.save_token("d2", "t2", {"tokenId": "tid-2"})
109
+ provider.clear_token("d1", reason="token_expired")
110
+ assert provider.get_token("d1") is None
111
+ assert provider.get_token("d2") == "t2"
112
+ # A fresh instance sees the same on-disk state.
113
+ assert _provider(tmp_path).get_token("d1") is None
114
+
115
+
116
+ def test_clear_missing_device_is_noop(tmp_path: Path) -> None:
117
+ provider = _provider(tmp_path)
118
+ provider.clear_token("ghost", reason="token_expired") # must not raise
119
+ assert provider.get_token("ghost") is None
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # 5. corrupt-fallback
124
+ # ---------------------------------------------------------------------------
125
+
126
+ def test_corrupt_file_silent_fallback(tmp_path: Path) -> None:
127
+ p = tmp_path / "tokens.json"
128
+ p.write_text("{not valid json", encoding="utf-8")
129
+ provider = FileTokenProvider(path=p)
130
+ assert provider.get_token("d") is None # no raise, empty
131
+ # After a save the file becomes valid again (self-heal).
132
+ provider.save_token("d", "t", {"tokenId": "tid"})
133
+ assert json.loads(p.read_text(encoding="utf-8"))["version"] == 1
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # 6. metadata-merge
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def test_metadata_merged_into_row(tmp_path: Path) -> None:
141
+ provider = _provider(tmp_path)
142
+ provider.save_token("d", "t", {"tokenId": "tid-1", "expiresAt": "x", "count": 3})
143
+ row = json.loads((tmp_path / "tokens.json").read_text(encoding="utf-8"))[
144
+ "tokens"
145
+ ]["d"]
146
+ assert row["token"] == "t"
147
+ assert row["tokenId"] == "tid-1"
148
+ assert row["expiresAt"] == "x"
149
+ # Non-str metadata values are stringified (schema keeps str values only).
150
+ assert isinstance(row["count"], str)
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # 7. version-mismatch
155
+ # ---------------------------------------------------------------------------
156
+
157
+ def test_version_mismatch_fallback(tmp_path: Path) -> None:
158
+ _write_raw(
159
+ tmp_path,
160
+ {"version": 2, "tokens": {"d": {"token": "t", "tokenId": "tid"}}},
161
+ )
162
+ provider = _provider(tmp_path)
163
+ assert provider.get_token("d") is None
164
+ # Save under v1 schema after the fallback.
165
+ provider.save_token("d", "t2", {})
166
+ data = json.loads((tmp_path / "tokens.json").read_text(encoding="utf-8"))
167
+ assert data["version"] == 1
168
+ assert data["tokens"]["d"]["token"] == "t2"