ktalk-cli 3.0.0__tar.gz → 3.0.2__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 (119) hide show
  1. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/PKG-INFO +1 -1
  2. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/pyproject.toml +1 -1
  3. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/client.py +11 -1
  4. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/config.py +20 -0
  5. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_cli_host_config_wiring.py +70 -0
  6. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_config.py +107 -0
  7. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_fr44_auth_status_accepted_rejected.py +94 -6
  8. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_fr48_doctor_summary_diagnostics.py +43 -0
  9. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_token_file.py +17 -17
  10. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/.gitignore +0 -0
  11. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/LICENSE +0 -0
  12. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/README.md +0 -0
  13. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/CLAUDE.md +0 -0
  14. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/__init__.py +0 -0
  15. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/auth.py +0 -0
  16. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/calendar_reader.py +0 -0
  17. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli.py +0 -0
  18. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_contacts.py +0 -0
  19. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_content.py +0 -0
  20. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_doctor.py +0 -0
  21. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_meeting.py +0 -0
  22. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_meeting_args.py +0 -0
  23. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_meeting_confirm.py +0 -0
  24. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_meetings_read.py +0 -0
  25. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_sanction.py +0 -0
  26. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_store.py +0 -0
  27. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_sync.py +0 -0
  28. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/cli_token.py +0 -0
  29. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/confirmation.py +0 -0
  30. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/contacts.py +0 -0
  31. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/contour_diagnostics.py +0 -0
  32. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/download.py +0 -0
  33. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/endpoints.py +0 -0
  34. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/enrichment.py +0 -0
  35. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/formatters.py +0 -0
  36. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/host_config.py +0 -0
  37. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/meeting_body.py +0 -0
  38. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/meeting_cancel.py +0 -0
  39. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/meeting_scheduling.py +0 -0
  40. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/pagination.py +0 -0
  41. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/reconciliation.py +0 -0
  42. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/registry.py +0 -0
  43. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/rooms.py +0 -0
  44. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/store.py +0 -0
  45. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/store_migration.py +0 -0
  46. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/token_file.py +0 -0
  47. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/transcript_identity.py +0 -0
  48. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/write_journal.py +0 -0
  49. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/src/ktalk_cli/write_sanction.py +0 -0
  50. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/conftest.py +0 -0
  51. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/access-info-expired.json +0 -0
  52. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/access-info-full.json +0 -0
  53. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/archive-8col.md +0 -0
  54. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/archive-dup-id.md +0 -0
  55. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/archive-escaped-pipe-7col.md +0 -0
  56. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/archive-page1.json +0 -0
  57. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/archive-page2-empty.json +0 -0
  58. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/archive-pipe-in-name.md +0 -0
  59. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/calendar-item-session.json +0 -0
  60. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/chat-messages-session.json +0 -0
  61. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/conference-history-session-oversized.json +0 -0
  62. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/conference-history-session.json +0 -0
  63. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/error-body-problem-json.json +0 -0
  64. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/error-body-validation.json +0 -0
  65. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/recording-detail-session-full-participants.json +0 -0
  66. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/recording-detail-session-oversized-partial.json +0 -0
  67. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/recording-detail-with-qualities.json +0 -0
  68. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/recording-list-item-session.json +0 -0
  69. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/recordings-list-apikey-page1.json +0 -0
  70. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/recordings-list-apikey-page2.json +0 -0
  71. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/registry-archive-2026-04.md +0 -0
  72. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/registry.md +0 -0
  73. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/fixtures/room-detail-session.json +0 -0
  74. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_adr022_ktalk_cli_rename.py +0 -0
  75. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_archive.py +0 -0
  76. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_auth_modes.py +0 -0
  77. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_calendar.py +0 -0
  78. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_chat.py +0 -0
  79. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_cli.py +0 -0
  80. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_cli_config_show.py +0 -0
  81. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_cli_content.py +0 -0
  82. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_cli_export_store_isolation.py +0 -0
  83. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_cli_meeting.py +0 -0
  84. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_cli_meeting_sanctioned.py +0 -0
  85. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_cli_meetings_surface.py +0 -0
  86. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_cli_migrate_to_central_store.py +0 -0
  87. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_client.py +0 -0
  88. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_concurrency.py +0 -0
  89. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_confirmation.py +0 -0
  90. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_contour_diagnostics.py +0 -0
  91. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_diagnostics.py +0 -0
  92. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_download.py +0 -0
  93. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_enrichment.py +0 -0
  94. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_formatters.py +0 -0
  95. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_fr19_auth_status.py +0 -0
  96. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_fr21_no_vault_layout.py +0 -0
  97. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_fr39_calendar_inclusive_end.py +0 -0
  98. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_fr40_timezone_format.py +0 -0
  99. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_fr41_last_synced.py +0 -0
  100. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_fr42_single_credential_source.py +0 -0
  101. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_fr43_legacy_key_removal_warning.py +0 -0
  102. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_fr47_missing_fields_completeness.py +0 -0
  103. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_host_config.py +0 -0
  104. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_issue3_null_id_fallback.py +0 -0
  105. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_meeting_body.py +0 -0
  106. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_meeting_cancel.py +0 -0
  107. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_meeting_scheduling.py +0 -0
  108. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_migration.py +0 -0
  109. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_nfr17_identity_verification.py +0 -0
  110. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_pagination.py +0 -0
  111. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_reconciliation.py +0 -0
  112. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_registry.py +0 -0
  113. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_rooms.py +0 -0
  114. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_search_contacts.py +0 -0
  115. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_secret_masking.py +0 -0
  116. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_store.py +0 -0
  117. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_store_migration.py +0 -0
  118. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_write_journal.py +0 -0
  119. {ktalk_cli-3.0.0 → ktalk_cli-3.0.2}/tests/test_write_sanction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ktalk-cli
3
- Version: 3.0.0
3
+ Version: 3.0.2
4
4
  Summary: CLI for accessing Kontur Talk (KTalk) recordings, transcripts and summaries
5
5
  Project-URL: Homepage, https://github.com/mdemyanov/ktalk-cli
6
6
  Project-URL: Repository, https://github.com/mdemyanov/ktalk-cli
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ktalk-cli"
3
- version = "3.0.0"
3
+ version = "3.0.2"
4
4
  description = "CLI for accessing Kontur Talk (KTalk) recordings, transcripts and summaries"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -239,7 +239,17 @@ class KTalkClient:
239
239
  отдельно от `KTalkAuthError` — раньше всплывало сырым текстом до `main()`."""
240
240
  try:
241
241
  await self.list_recordings(top=1)
242
- except KTalkAuthError:
242
+ except KTalkAuthError as exc:
243
+ if getattr(exc, "status_code", None) == 403:
244
+ # 403 на пробном запросе — не отказ токена, а нехватка прав на саму
245
+ # пробную операцию (ADR-025 «Разведение 401/403»).
246
+ return AuthStatus(
247
+ alive=True,
248
+ note=(
249
+ "Доступ запрещён: у текущей сессии нет прав на пробную операцию "
250
+ "(список записей). Токен при этом рабочий — обновлять его не нужно."
251
+ ),
252
+ )
243
253
  return AuthStatus(
244
254
  alive=False,
245
255
  note="Токен сессии не прошёл проверку (пробный запрос списка записей).",
@@ -38,6 +38,17 @@ def resolve_db_path(
38
38
  обязана применяться к итоговому пути независимо от источника (ADR-013-spec
39
39
  §«Поток данных» п.4) — до этой правки вызывалась только в ветке машинного
40
40
  дефолта; `--db`/`KTALK_REGISTRY_DB`/конфиг хозяина возвращали путь раньше.
41
+
42
+ FR-23 AC-4/AC-5: относительный `registry.db_path` резолвится
43
+ против `host_config.path.parent` (каталог, где найден `.ktalk.toml`), не
44
+ против cwd вызывающего процесса — резолюция от cwd и есть исходный дефект
45
+ («unable to open database file» при запуске не из корня проекта-хозяина).
46
+ Абсолютный `db_path` используется как есть, каталог `.ktalk.toml` в
47
+ резолюцию не участвует. `host_config.path is None` при относительном
48
+ `db_path` — нарушение инварианта вызывающей стороны (в проде
49
+ `discover_host_config`, SA-003, всегда заполняет `path`) — явное
50
+ исключение, не тихий откат на cwd. `--db`/`KTALK_REGISTRY_DB` вне области
51
+ (BA-031) — резолюция от cwd для них остаётся конвенцией CLI/окружения.
41
52
  """
42
53
  from ktalk_cli.store import resolve_store_root, warn_if_sync_dir
43
54
 
@@ -54,6 +65,15 @@ def resolve_db_path(
54
65
  configured = host_config.registry.get("db_path")
55
66
  if configured:
56
67
  path = Path(configured)
68
+ if not path.is_absolute():
69
+ if host_config.path is None:
70
+ raise KTalkConfigError(
71
+ f"Относительный registry.db_path ({configured!r}) в конфиге "
72
+ "хозяина требует известный каталог .ktalk.toml, а "
73
+ "host_config.path не задан — резолюция от текущего "
74
+ "рабочего каталога воспроизвела бы исходный дефект."
75
+ )
76
+ path = host_config.path.parent / path
57
77
  warn_if_sync_dir(path)
58
78
  return path
59
79
 
@@ -160,6 +160,76 @@ def test_maj02_file_written_after_registry_closes_uses_ambient_umask_not_0o077(
160
160
  # --- MAJ-03: export пишет зеркало рядом с БД из .ktalk.toml, а не рядом с дефолтом ---
161
161
 
162
162
 
163
+ def test_fr23_ac4_bare_cli_relative_db_path_from_subdirectory_reproduces_dashboard_symptom(
164
+ tmp_path, monkeypatch, capsys
165
+ ):
166
+ """Discovery-режим 2 (обход вверх от cwd, SA-003) + FR-23 AC-4 (ktalk-mcp-ds6):
167
+ буквальное воспроизведение отчёта владельца — `cd .../95_TRANSCRIPTS && ktalk
168
+ dashboard` падал `unable to open database file`, потому что относительный
169
+ db_path резолвился от cwd вместо каталога найденного .ktalk.toml (обход
170
+ находит конфиг в родителе, а не в cwd самом). `dashboard` — команда из
171
+ исходного отчёта, не заменена на `list` намеренно."""
172
+ project = tmp_path / "naumen-cto"
173
+ project.mkdir()
174
+ (project / ".git").mkdir()
175
+ db_path = project / "95_TRANSCRIPTS" / ".registry.db"
176
+ db_path.parent.mkdir(parents=True)
177
+ _seed(db_path)
178
+ (project / ".ktalk.toml").write_text(
179
+ '[registry]\ndb_path = "95_TRANSCRIPTS/.registry.db"\n', encoding="utf-8"
180
+ )
181
+
182
+ monkeypatch.delenv("KTALK_REGISTRY_DB", raising=False)
183
+ monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
184
+ monkeypatch.chdir(db_path.parent) # ровно подкаталог хранилища из отчёта
185
+
186
+ from ktalk_cli.cli import main
187
+
188
+ rc = main(["dashboard", "--json"])
189
+ assert rc == 0
190
+ out = json.loads(capsys.readouterr().out)
191
+ ids = {r["recording_id"] for r in out["new"]}
192
+ assert "wired" in ids, (
193
+ "TODO: FR-23 AC-4 — относительный db_path обязан резолвиться от "
194
+ "каталога .ktalk.toml (host_config.path.parent), не от cwd вызова"
195
+ )
196
+
197
+
198
+ def test_fr23_ac4_explicit_claude_project_dir_relative_db_path_ignores_unrelated_cwd(
199
+ tmp_path, monkeypatch, capsys
200
+ ):
201
+ """Discovery-режим 1 (явный CLAUDE_PROJECT_DIR, SA-003) + FR-23 AC-4: cwd —
202
+ каталог, не являющийся ни проектом, ни его предком/потомком (обход вверх в
203
+ этом режиме не выполняется вовсе, discovery ищет ровно по CLAUDE_PROJECT_DIR).
204
+ Относительный db_path обязан резолвиться от CLAUDE_PROJECT_DIR, не от этого
205
+ произвольного cwd."""
206
+ project = tmp_path / "project"
207
+ project.mkdir()
208
+ db_path = project / "store" / "registry.db"
209
+ db_path.parent.mkdir(parents=True)
210
+ _seed(db_path)
211
+ (project / ".ktalk.toml").write_text(
212
+ '[registry]\ndb_path = "store/registry.db"\n', encoding="utf-8"
213
+ )
214
+ unrelated_cwd = tmp_path / "unrelated"
215
+ unrelated_cwd.mkdir()
216
+
217
+ monkeypatch.delenv("KTALK_REGISTRY_DB", raising=False)
218
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(project))
219
+ monkeypatch.chdir(unrelated_cwd)
220
+
221
+ from ktalk_cli.cli import main
222
+
223
+ rc = main(["list", "--json"])
224
+ assert rc == 0
225
+ out = json.loads(capsys.readouterr().out)
226
+ ids = {r["recording_id"] for r in out["recordings"]}
227
+ assert "wired" in ids, (
228
+ "TODO: FR-23 AC-4 — discovery-режим 1: относительный db_path обязан "
229
+ "резолвиться от CLAUDE_PROJECT_DIR, не от cwd"
230
+ )
231
+
232
+
163
233
  def test_maj03_export_mirror_follows_host_config_db_path(tmp_path, monkeypatch, capsys):
164
234
  """`_cmd_export` резолвил путь БД повторно и БЕЗ host_config: реестр читался из
165
235
  `.ktalk.toml`, а `registry.md` уезжал к машинному дефолту (`store.resolve_store_root`).
@@ -135,3 +135,110 @@ def test_resolve_db_path_none_of_the_four_sources_falls_through_to_machine_defau
135
135
  assert not resolved.is_relative_to(Path.cwd()), (
136
136
  "TODO: FR-22 AC-1 — машинный дефолт вне cwd, не 95_TRANSCRIPTS/.registry.db"
137
137
  )
138
+
139
+
140
+ # --- FR-23 AC-4/AC-5 (ktalk-mcp-ds6): относительный db_path из конфига хозяина ---
141
+ # джойнится с каталогом .ktalk.toml (host_config.path.parent), не с cwd;
142
+ # абсолютный db_path остаётся как есть, каталог .ktalk.toml не участвует.
143
+ # Spec: openspec/specs/host-project-config-discovery/spec.md, «A relative
144
+ # host-config path resolves against the config file's own directory» /
145
+ # «An absolute host-config path is used unmodified».
146
+
147
+
148
+ def test_fr23_ac4_relative_db_path_joins_against_host_config_dir_not_cwd(
149
+ monkeypatch, tmp_path
150
+ ):
151
+ """Unit, ADR-013-spec «Контракт с QA-author»: resolve_db_path вызывается
152
+ напрямую с вручную собранным HostConfig — без discovery, без обхода вверх.
153
+ cwd намеренно ставится в каталог, отличный от каталога .ktalk.toml, чтобы
154
+ join нельзя было случайно спутать со старым (дефектным) резолвом от cwd."""
155
+ monkeypatch.delenv("KTALK_REGISTRY_DB", raising=False)
156
+ host_config_dir = tmp_path / "host-project"
157
+ host_config_dir.mkdir()
158
+ elsewhere_cwd = tmp_path / "elsewhere"
159
+ elsewhere_cwd.mkdir()
160
+ monkeypatch.chdir(elsewhere_cwd)
161
+
162
+ from ktalk_cli.config import resolve_db_path
163
+ from ktalk_cli.host_config import HostConfig
164
+
165
+ host_config = HostConfig(
166
+ registry={"db_path": "custom/registry.db"},
167
+ path=host_config_dir / ".ktalk.toml",
168
+ )
169
+ resolved = resolve_db_path(None, host_config=host_config)
170
+ assert resolved == host_config_dir / "custom/registry.db", (
171
+ "TODO: FR-23 AC-4 — относительный db_path обязан джойниться с "
172
+ "host_config.path.parent, не оставаться голым Path(configured)"
173
+ )
174
+ assert resolved != elsewhere_cwd / "custom/registry.db"
175
+
176
+
177
+ def test_fr23_ac5_absolute_db_path_ignores_host_config_dir_even_when_path_set(
178
+ monkeypatch, tmp_path
179
+ ):
180
+ """Unit, регрессия FR-23 AC-5: абсолютный db_path применяется как есть, даже
181
+ когда host_config.path указывает на совсем другой каталог — каталог
182
+ .ktalk.toml не должен «подмешиваться» в резолюцию абсолютного пути."""
183
+ monkeypatch.delenv("KTALK_REGISTRY_DB", raising=False)
184
+ absolute_db = tmp_path / "abs-store" / "registry.db"
185
+ host_config_dir = tmp_path / "unrelated-config-dir"
186
+
187
+ from ktalk_cli.config import resolve_db_path
188
+ from ktalk_cli.host_config import HostConfig
189
+
190
+ host_config = HostConfig(
191
+ registry={"db_path": str(absolute_db)},
192
+ path=host_config_dir / ".ktalk.toml",
193
+ )
194
+ resolved = resolve_db_path(None, host_config=host_config)
195
+ assert resolved == absolute_db
196
+ assert not str(resolved).startswith(str(host_config_dir)), (
197
+ "TODO: FR-23 AC-5 — абсолютный db_path не должен резолвиться относительно "
198
+ "каталога .ktalk.toml"
199
+ )
200
+
201
+
202
+ def test_fr23_ac4_host_config_path_none_with_relative_db_path_raises_not_silent_cwd_fallback(
203
+ monkeypatch, tmp_path
204
+ ):
205
+ """Masked-failure класс (ADR-013-spec, edge case): HostConfig собран вручную
206
+ (не через discover_host_config) с относительным db_path и path=None — базы
207
+ для join нет. Ожидание — явное исключение, НЕ тихий откат на резолюцию от
208
+ cwd (тот самый откат и есть исходный дефект ktalk-mcp-ds6, только замаскированный
209
+ отсутствием ошибки вместо неверного пути)."""
210
+ monkeypatch.delenv("KTALK_REGISTRY_DB", raising=False)
211
+ monkeypatch.chdir(tmp_path)
212
+
213
+ from ktalk_cli.config import resolve_db_path
214
+ from ktalk_cli.host_config import HostConfig
215
+
216
+ host_config = HostConfig(registry={"db_path": "relative/registry.db"}, path=None)
217
+ with pytest.raises(Exception):
218
+ resolve_db_path(None, host_config=host_config)
219
+
220
+
221
+ def test_fr23_ac4_warn_if_sync_dir_applies_to_final_joined_path_not_bare_relative_value(
222
+ monkeypatch, tmp_path, capsys
223
+ ):
224
+ """ADR-013-spec «Поток данных» п.4: warn_if_sync_dir обязан применяться к
225
+ итоговому (уже джойненному) пути. Маркер `Dropbox` присутствует только в
226
+ каталоге .ktalk.toml, не в самом относительном db_path — до починки
227
+ warn_if_sync_dir вызывался на голом Path("registry.db") и маркер не находил,
228
+ после починки видит его в резолвленном абсолютном пути."""
229
+ monkeypatch.delenv("KTALK_REGISTRY_DB", raising=False)
230
+ host_config_dir = tmp_path / "Dropbox" / "project"
231
+ host_config_dir.mkdir(parents=True)
232
+
233
+ from ktalk_cli.config import resolve_db_path
234
+ from ktalk_cli.host_config import HostConfig
235
+
236
+ host_config = HostConfig(
237
+ registry={"db_path": "registry.db"}, path=host_config_dir / ".ktalk.toml"
238
+ )
239
+ resolve_db_path(None, host_config=host_config)
240
+ captured = capsys.readouterr()
241
+ assert "Dropbox" in captured.err, (
242
+ "TODO: FR-23 AC-4 — warn_if_sync_dir обязан видеть маркер в резолвленном "
243
+ "(джойненном) пути, а не в голом относительном db_path"
244
+ )
@@ -1,22 +1,27 @@
1
1
  """AT-design: FR-44 — `auth-status` различает принятое и отвергнутое значение.
2
2
 
3
3
  Покрывает `openspec/specs/talk-api-auth-modes/spec.md`, требование «Auth-status
4
- diagnosis distinguishes an accepted session token from a rejected one» (3 сценария) +
5
- новую форму `--json`-ответа (`{"alive": bool, "note": str|None}`, без `scopes`/
6
- `expired_at`) названную ADR-025 «Решение» п.3. Источник —
7
- `content/30-requirements/single-auth-mode.md` FR-44, issue #11.
4
+ diagnosis distinguishes an accepted session token from a rejected one» (4 сценария,
5
+ включая «Probe rejected for permissions, not credential») + новую форму
6
+ `--json`-ответа (`{"alive": bool, "note": str|None}`, без `scopes`/`expired_at`)
7
+ названную ADR-025 «Решение» п.3. Источник — `content/30-requirements/single-auth-mode.md`
8
+ FR-44, issue #11, дефект `ktalk-mcp-qqc` (403 схлопывается в отказ токена).
8
9
 
9
10
  Урок дерева 2026-09-04 (`content/lessons-learned.md`): признак в теле ответа без
10
11
  эскалации в код возврата неотличим потребителем, читающим только код, от отсутствия
11
12
  отказа — тест обязан утверждать ОБА канала отдельными assert'ами в одном сценарии
12
- (не полагаться на то, что проверка одного канала подразумевает другой).
13
+ (не полагаться на то, что проверка одного канала подразумевает другой). Урок
14
+ действует в обе стороны (ADR-025-spec «Разведение 401/403…»): эскалация в код
15
+ возврата при живом токене — то же нарушение, что и молчание при настоящем отказе.
13
16
 
14
17
  Красные по замыслу: сегодня `cmd_auth_status` (`cli_sync.py`) возвращает `0`
15
18
  безусловно (не пробрасывает `status.alive`), а `--json`-ответ несёт `scopes`/
16
19
  `expired_at` (`cli_sync.py`, `AuthStatus`, `auth.py`). `_auth_status_session`
17
20
  (`client.py`) не перехватывает непарсящееся тело отдельно от `KTalkAuthError` — тест
18
21
  4 падает на утечке сырого текста `json.JSONDecodeError`, не на импорте (имена не
19
- меняются, `client.py`/`cli_sync.py` уже существуют).
22
+ меняются, `client.py`/`cli_sync.py` уже существуют). `_auth_status_session` также
23
+ не разбирает `exc.status_code` на пойманном `KTalkAuthError` — 403 схлопывается в
24
+ тот же вердикт `alive=False`, что и 401 (тесты `test_fr44_5_*`..`test_fr44_7_*`).
20
25
  """
21
26
 
22
27
  from __future__ import annotations
@@ -130,3 +135,86 @@ def test_fr44_4_unparsable_probe_body_yields_honest_result_not_raw_decode_error(
130
135
  assert "Expecting value" not in captured.err
131
136
  assert "Traceback" not in captured.err
132
137
  assert rc != 0
138
+
139
+
140
+ _NOTE_403_VERBATIM = (
141
+ "Доступ запрещён: у текущей сессии нет прав на пробную операцию (список "
142
+ "записей). Токен при этом рабочий — обновлять его не нужно."
143
+ )
144
+
145
+
146
+ def test_fr44_5_probe_rejected_403_alive_true_and_exit_zero(
147
+ monkeypatch, httpx_mock: HTTPXMock, capsys
148
+ ):
149
+ """Scenario «Probe rejected for permissions, not credential (fixture, no live
150
+ contour required)» — 403 на пробном запросе не отказ токена: `alive` остаётся
151
+ `true`, код возврата остаётся `0`. Оба канала — отдельным ассертом (урок
152
+ 2026-09-04 действует и в эту сторону: эскалация в код возврата при живом
153
+ токене — то же нарушение, что и молчание при настоящем отказе).
154
+
155
+ Красный сегодня (дефект `ktalk-mcp-qqc`): `_auth_status_session` ловит
156
+ `KTalkAuthError` без разбора `exc.status_code`, 403 даёт тот же вердикт, что
157
+ 401 — `alive: false`, `rc == 1`.
158
+ """
159
+ monkeypatch.setenv("KTALK_BASE_URL", "https://test.ktalk.ru")
160
+ monkeypatch.setenv("KTALK_SESSION_TOKEN", "sess-forbidden-0001")
161
+ monkeypatch.delenv("KTALK_PERSONAL_API_KEY", raising=False)
162
+ httpx_mock.add_response(status_code=403)
163
+
164
+ from ktalk_cli.cli import main
165
+
166
+ rc = main(["auth-status", "--json"])
167
+ data = json.loads(capsys.readouterr().out)
168
+
169
+ # Канал 1: поле в теле ответа — токен рабочий.
170
+ assert data["alive"] is True
171
+ # Канал 2: код возврата процесса — независимый ассерт, не производный от канала 1.
172
+ assert rc == 0
173
+
174
+
175
+ def test_fr44_6_probe_rejected_403_note_is_verbatim_permission_gap(
176
+ monkeypatch, httpx_mock: HTTPXMock, capsys
177
+ ):
178
+ """Scenario «Probe rejected for permissions, not credential»: `note` называет
179
+ нехватку прав дословно (BA-030) и явно не предлагает обновить токен —
180
+ проверка дословного текста, не только присутствия подстроки (ADR-025-spec
181
+ edge case «403 на пробном запросе внутри `ktalk doctor`»/«разводка 401/403»)."""
182
+ monkeypatch.setenv("KTALK_BASE_URL", "https://test.ktalk.ru")
183
+ monkeypatch.setenv("KTALK_SESSION_TOKEN", "sess-forbidden-0002")
184
+ monkeypatch.delenv("KTALK_PERSONAL_API_KEY", raising=False)
185
+ httpx_mock.add_response(status_code=403)
186
+
187
+ from ktalk_cli.cli import main
188
+
189
+ main(["auth-status", "--json"])
190
+ data = json.loads(capsys.readouterr().out)
191
+
192
+ assert data["note"] == _NOTE_403_VERBATIM
193
+
194
+
195
+ def test_fr44_7_probe_rejected_403_note_differs_from_401_note(
196
+ monkeypatch, httpx_mock: HTTPXMock, capsys
197
+ ):
198
+ """Malformed/masked-failure guard: 403 и 401 — разные причины отказа пробного
199
+ запроса, `note` обязан их различать текстом, а не схлопывать в один и тот же
200
+ вердикт (текущий баг `ktalk-mcp-qqc` — оба кода дают идентичный `note`
201
+ «Токен сессии не прошёл проверку...»). Малое, но наблюдаемое поведение: без
202
+ этого различения потребитель, читающий только `note`, не может отличить
203
+ «токен мёртв» от «токену не хватает прав»."""
204
+ monkeypatch.setenv("KTALK_BASE_URL", "https://test.ktalk.ru")
205
+ monkeypatch.setenv("KTALK_SESSION_TOKEN", "sess-forbidden-0003")
206
+ monkeypatch.delenv("KTALK_PERSONAL_API_KEY", raising=False)
207
+ httpx_mock.add_response(status_code=403)
208
+
209
+ from ktalk_cli.cli import main
210
+
211
+ main(["auth-status", "--json"])
212
+ data_403 = json.loads(capsys.readouterr().out)
213
+
214
+ monkeypatch.setenv("KTALK_SESSION_TOKEN", "sess-forbidden-0003b")
215
+ httpx_mock.add_response(status_code=401)
216
+
217
+ main(["auth-status", "--json"])
218
+ data_401 = json.loads(capsys.readouterr().out)
219
+
220
+ assert data_403["note"] != data_401["note"]
@@ -17,6 +17,12 @@ Dev не зарегистрирует подкоманду. Это стабил
17
17
  причина падения для КАЖДОГО теста этого файла на сегодняшнем коде — как только
18
18
  `doctor` зарегистрирован, тесты начинают падать (или проходить) по своей
19
19
  индивидуальной причине.
20
+
21
+ `test_fr48_15_*` — регрессия дефекта `ktalk-mcp-qqc` (403 на пробном запросе
22
+ `auth-status`, схлопнутый в отказ токена, разведён ADR-025-spec «Разведение
23
+ 401/403 в пробном запросе `auth-status`»): `auth` не должен попасть в
24
+ `failed_items` доктора, если пробный запрос отклонён по нехватке прав, а не по
25
+ невалидному токену.
20
26
  """
21
27
 
22
28
  from __future__ import annotations
@@ -390,6 +396,43 @@ def test_fr48_13_human_readable_output_names_failed_item_by_key(
390
396
  assert "auth" in out
391
397
 
392
398
 
399
+ def test_fr48_15_probe_403_does_not_mark_auth_as_failed(
400
+ monkeypatch, tmp_path, httpx_mock: HTTPXMock, capsys
401
+ ):
402
+ """Регрессия дефекта `ktalk-mcp-qqc` внутри `doctor` (ADR-025-spec, «edge
403
+ cases»/companion-спека ADR-026 §2): пробный запрос отклонён `403` — токен
404
+ рабочий, `_auth_item` кладёт пункт `auth` в `failed_items` по `not
405
+ status.alive`, а `alive` обязан остаться `True` на 403 (не отказ токена).
406
+ `auth.alive=True` не создаёт провала сам по себе — тот же приём, что
407
+ `test_fr48_8` фиксирует для `token_file`, здесь наоборот: провал не должен
408
+ возникнуть там, где его не должно быть.
409
+
410
+ Красный сегодня: `_auth_status_session` (`client.py`) не разбирает
411
+ `exc.status_code` — 403 даёт `alive=False`, `_auth_item` кладёт `auth` в
412
+ `failed_items`, `doctor` возвращает ненулевой код по этой причине одной.
413
+ """
414
+ monkeypatch.chdir(tmp_path)
415
+ monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
416
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
417
+ monkeypatch.delenv("KTALK_TOKEN_FILE", raising=False)
418
+ monkeypatch.setenv("KTALK_REGISTRY_DB", str(tmp_path / "r.db"))
419
+ monkeypatch.setenv("KTALK_SESSION_TOKEN", "sess-doctor-0015")
420
+ monkeypatch.delenv("KTALK_PERSONAL_API_KEY", raising=False)
421
+ httpx_mock.add_response(status_code=403)
422
+
423
+ from ktalk_cli.cli import main
424
+ from ktalk_cli.token_file import write_token
425
+
426
+ write_token("okDOCTORtoken0123456")
427
+
428
+ rc = main(["doctor", "--json"])
429
+ data = json.loads(capsys.readouterr().out)
430
+
431
+ assert data["auth"]["alive"] is True
432
+ assert "auth" not in data["failed_items"]
433
+ assert rc == 0
434
+
435
+
393
436
  def test_fr48_14_found_host_config_path_agrees_with_config_show(
394
437
  monkeypatch, tmp_path, httpx_mock: HTTPXMock, capsys
395
438
  ):
@@ -50,9 +50,9 @@ def test_write_token_creates_private_file_and_directory(monkeypatch, tmp_path):
50
50
  monkeypatch.delenv("KTALK_TOKEN_FILE", raising=False)
51
51
  from ktalk_cli.token_file import write_token
52
52
 
53
- path = write_token("oMGQT83CGEO38F6y7rsL")
53
+ path = write_token("FIXTUREnotArealToken01")
54
54
 
55
- assert path.read_text(encoding="utf-8") == "oMGQT83CGEO38F6y7rsL"
55
+ assert path.read_text(encoding="utf-8") == "FIXTUREnotArealToken01"
56
56
  assert _mode(path) == 0o600
57
57
  assert _mode(path.parent) == 0o700
58
58
 
@@ -63,9 +63,9 @@ def test_write_token_strips_surrounding_whitespace(monkeypatch, tmp_path):
63
63
  monkeypatch.delenv("KTALK_TOKEN_FILE", raising=False)
64
64
  from ktalk_cli.token_file import read_token, write_token
65
65
 
66
- write_token(" oMGQT83CGEO38F6y7rsL\n")
66
+ write_token(" FIXTUREnotArealToken01\n")
67
67
 
68
- assert read_token() == "oMGQT83CGEO38F6y7rsL"
68
+ assert read_token() == "FIXTUREnotArealToken01"
69
69
 
70
70
 
71
71
  def test_write_token_rejects_empty_value(monkeypatch, tmp_path):
@@ -93,7 +93,7 @@ def test_read_token_refuses_world_readable_file(monkeypatch, tmp_path):
93
93
  monkeypatch.delenv("KTALK_TOKEN_FILE", raising=False)
94
94
  from ktalk_cli.token_file import read_token, write_token
95
95
 
96
- path = write_token("oMGQT83CGEO38F6y7rsL")
96
+ path = write_token("FIXTUREnotArealToken01")
97
97
  path.chmod(0o644)
98
98
 
99
99
  assert read_token() is None
@@ -111,10 +111,10 @@ def test_settings_reads_token_file_when_env_empty(monkeypatch, tmp_path):
111
111
  from ktalk_cli.config import Settings
112
112
  from ktalk_cli.token_file import write_token
113
113
 
114
- write_token("oMGQT83CGEO38F6y7rsL")
114
+ write_token("FIXTUREnotArealToken01")
115
115
 
116
116
  settings = Settings()
117
- assert settings.auth_credential == "oMGQT83CGEO38F6y7rsL"
117
+ assert settings.auth_credential == "FIXTUREnotArealToken01"
118
118
 
119
119
 
120
120
  def test_env_session_token_wins_over_file(monkeypatch, tmp_path):
@@ -141,9 +141,9 @@ def test_token_from_file_is_masked_in_error_text(monkeypatch, tmp_path):
141
141
  from ktalk_cli.config import redact_secrets
142
142
  from ktalk_cli.token_file import write_token
143
143
 
144
- write_token("oMGQT83CGEO38F6y7rsL")
144
+ write_token("FIXTUREnotArealToken01")
145
145
 
146
- assert "oMGQT83CGEO38F6y7rsL" not in redact_secrets("token=oMGQT83CGEO38F6y7rsL")
146
+ assert "FIXTUREnotArealToken01" not in redact_secrets("token=FIXTUREnotArealToken01")
147
147
 
148
148
 
149
149
  # --- CLI: ktalk token set | status ----------------------------------------
@@ -156,13 +156,13 @@ def test_cli_token_set_reads_stdin_and_writes_private_file(monkeypatch, tmp_path
156
156
  from ktalk_cli.cli import main
157
157
  from ktalk_cli.token_file import token_path
158
158
 
159
- monkeypatch.setattr("sys.stdin", _FakeStdin("oMGQT83CGEO38F6y7rsL\n"))
159
+ monkeypatch.setattr("sys.stdin", _FakeStdin("FIXTUREnotArealToken01\n"))
160
160
  rc = main(["token", "set", "-"])
161
161
 
162
162
  assert rc == 0
163
- assert token_path().read_text(encoding="utf-8") == "oMGQT83CGEO38F6y7rsL"
163
+ assert token_path().read_text(encoding="utf-8") == "FIXTUREnotArealToken01"
164
164
  assert _mode(token_path()) == 0o600
165
- assert "oMGQT83CGEO38F6y7rsL" not in capsys.readouterr().out
165
+ assert "FIXTUREnotArealToken01" not in capsys.readouterr().out
166
166
 
167
167
 
168
168
  def test_cli_token_status_json_reports_present_without_leaking_value(
@@ -173,7 +173,7 @@ def test_cli_token_status_json_reports_present_without_leaking_value(
173
173
  from ktalk_cli.cli import main
174
174
  from ktalk_cli.token_file import write_token
175
175
 
176
- write_token("oMGQT83CGEO38F6y7rsL")
176
+ write_token("FIXTUREnotArealToken01")
177
177
  rc = main(["token", "status", "--json"])
178
178
 
179
179
  out = capsys.readouterr().out
@@ -181,7 +181,7 @@ def test_cli_token_status_json_reports_present_without_leaking_value(
181
181
  payload = json.loads(out)
182
182
  assert payload["present"] is True
183
183
  assert payload["mode"] == "0600"
184
- assert "oMGQT83CGEO38F6y7rsL" not in out
184
+ assert "FIXTUREnotArealToken01" not in out
185
185
 
186
186
 
187
187
  def test_cli_token_status_absent_file_is_not_an_error(monkeypatch, tmp_path, capsys):
@@ -201,7 +201,7 @@ def test_cli_token_status_flags_too_wide_permissions(monkeypatch, tmp_path, caps
201
201
  from ktalk_cli.cli import main
202
202
  from ktalk_cli.token_file import write_token
203
203
 
204
- write_token("oMGQT83CGEO38F6y7rsL").chmod(0o644)
204
+ write_token("FIXTUREnotArealToken01").chmod(0o644)
205
205
  rc = main(["token", "status", "--json"])
206
206
 
207
207
  payload = json.loads(capsys.readouterr().out)
@@ -264,9 +264,9 @@ def test_cli_token_set_bad_value_exits_nonzero_and_keeps_old_token(
264
264
  from ktalk_cli.cli import main
265
265
  from ktalk_cli.token_file import read_token, write_token
266
266
 
267
- write_token("oMGQT83CGEO38F6y7rsL")
267
+ write_token("FIXTUREnotArealToken01")
268
268
  rc = main(["token", "set", "/Users/mdemyanov/Devel/ktalk-mcp/README.md"])
269
269
 
270
270
  assert rc != 0
271
271
  assert "не похоже на токен" in capsys.readouterr().err
272
- assert read_token() == "oMGQT83CGEO38F6y7rsL"
272
+ assert read_token() == "FIXTUREnotArealToken01"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes