s-corekit 0.0.2__tar.gz → 0.0.4__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 (25) hide show
  1. {s_corekit-0.0.2 → s_corekit-0.0.4}/CHANGELOG.md +18 -0
  2. {s_corekit-0.0.2 → s_corekit-0.0.4}/PKG-INFO +1 -1
  3. {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/__init__.py +3 -2
  4. {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/context.py +11 -0
  5. {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/diagnosis/access.py +14 -0
  6. s_corekit-0.0.4/corekit/dto.py +149 -0
  7. s_corekit-0.0.4/corekit/filelock.py +165 -0
  8. {s_corekit-0.0.2 → s_corekit-0.0.4}/pyproject.toml +1 -1
  9. {s_corekit-0.0.2 → s_corekit-0.0.4}/tests/test_diagnosis_access.py +30 -0
  10. s_corekit-0.0.4/tests/test_filelock.py +87 -0
  11. s_corekit-0.0.4/tests/test_session_ref.py +65 -0
  12. s_corekit-0.0.2/corekit/dto.py +0 -83
  13. {s_corekit-0.0.2 → s_corekit-0.0.4}/.gitignore +0 -0
  14. {s_corekit-0.0.2 → s_corekit-0.0.4}/LICENSE +0 -0
  15. {s_corekit-0.0.2 → s_corekit-0.0.4}/README.md +0 -0
  16. {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/diagnosis/__init__.py +0 -0
  17. {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/diagnosis/egress.py +0 -0
  18. {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/diagnosis/verdict.py +0 -0
  19. {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/enums.py +0 -0
  20. {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/errors.py +0 -0
  21. {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/limits.py +0 -0
  22. {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/redaction.py +0 -0
  23. {s_corekit-0.0.2 → s_corekit-0.0.4}/tests/test_context.py +0 -0
  24. {s_corekit-0.0.2 → s_corekit-0.0.4}/tests/test_errors.py +0 -0
  25. {s_corekit-0.0.2 → s_corekit-0.0.4}/tests/test_stdlib_only.py +0 -0
@@ -1,5 +1,23 @@
1
1
  # CHANGELOG — s-corekit
2
2
 
3
+ ## [0.0.3] — 2026-08-08
4
+
5
+ ### Добавлено
6
+
7
+ - `SessionRef` получил две координаты: `instance` — конкретный вход
8
+ многоинстансного сервиса (портал Битрикса, национальный домен), `stage` —
9
+ стадия сессии (боевая или черновик записи трафика). Обе добавлены В КОНЕЦ и
10
+ с дефолтами, как когда-то `tenant`: позиционный
11
+ `SessionRef(profile, social, account_id)` из чужих навыков и уже записанные
12
+ адреса продолжают работать.
13
+ - `DEFAULT_STAGE` — стадия боевой сессии (`"live"`), дефолт адреса.
14
+
15
+ Зачем. Стадия до сих пор занимала поле `profile`, и два смысла в одном поле
16
+ означали адрес, по которому нельзя спросить «дай боевую сессию ЭТОГО
17
+ аккаунта», не зная заранее, как её назвал пишущий. Порталы одного продукта
18
+ разводились ВТОРЫМ именем сервиса — от этого у аккаунта появлялась вторая
19
+ сессия, о которой никто не знал.
20
+
3
21
  ## [0.0.2] — 2026-08-04
4
22
 
5
23
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: s-corekit
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: ОСНОВАНИЕ китов: ExecutionContext, базовые DTO (SessionRef/Creds/LimitSpec/QuotaInfo), единая иерархия ошибок и чистая классификация состояния доступа (AccessState/AccessVerdict). Только stdlib.
5
5
  Author: Dmitry
6
6
  License: MIT
@@ -45,7 +45,7 @@
45
45
  """
46
46
  from __future__ import annotations
47
47
 
48
- from corekit.context import DEFAULT_TENANT, ExecutionContext
48
+ from corekit.context import DEFAULT_STAGE, DEFAULT_TENANT, ExecutionContext
49
49
  from corekit.diagnosis import (
50
50
  ACCESS_STATE_MESSAGES,
51
51
  REASON_MESSAGES,
@@ -99,12 +99,13 @@ from corekit.redaction import (
99
99
  truncate_body,
100
100
  )
101
101
 
102
- __version__ = "0.0.2"
102
+ __version__ = "0.0.4"
103
103
 
104
104
  __all__ = [
105
105
  "__version__",
106
106
  # контекст исполнения
107
107
  "ExecutionContext",
108
+ "DEFAULT_STAGE",
108
109
  "DEFAULT_TENANT",
109
110
  # базовые DTO
110
111
  "SessionRef",
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  from typing import Any
18
18
 
19
19
  __all__ = [
20
+ "DEFAULT_STAGE",
20
21
  "DEFAULT_TENANT",
21
22
  "ExecutionContext",
22
23
  ]
@@ -26,6 +27,16 @@ __all__ = [
26
27
  #: байт-в-байт совпадают с тем, что было до введения мультитенантности.
27
28
  DEFAULT_TENANT = "local"
28
29
 
30
+ #: Стадия БОЕВОЙ сессии — той, которой работают. Дефолт `SessionRef`: пока
31
+ #: стадию не назвали явно, адрес совпадает с досегодняшним, и все уже
32
+ #: записанные сессии читаются как боевые (чем они и были).
33
+ #:
34
+ #: Вторая стадия — черновик записи трафика (`capture`): вход туда может не
35
+ #: удаться (в окне осталась форма), и такой снимок не должен затирать рабочую
36
+ #: сессию. Стадий ровно две по замыслу: третья означала бы, что мы моделируем
37
+ #: здесь жизненный цикл, а не разводим «проверено» и «ещё нет».
38
+ DEFAULT_STAGE = "live"
39
+
29
40
 
30
41
  #: Сентинел «аргумент не передан» для `ExecutionContext.__init__`: отличает
31
42
  #: «поле оставили по умолчанию» от «поле передали, и значение совпало с дефолтом».
@@ -131,6 +131,14 @@ LOGIN_HOST_MARKERS: tuple[str, ...] = (
131
131
  "/login?continue=",
132
132
  "/oauth/authorize",
133
133
  "/auth/login",
134
+ # Ниже — из браузерного кита, где годами жила ВТОРАЯ таблица тех же улик.
135
+ # Копии расходятся молча: одна узнавала увод на вход, другая нет, и вердикт
136
+ # зависел от того, каким путём пошёл вызывающий.
137
+ "/login",
138
+ "/signin",
139
+ "/auth/",
140
+ "passport.", # внешний провайдер входа (Яндекс и подобные)
141
+ "pincode", # экран второго фактора — вход ещё не завершён
134
142
  )
135
143
 
136
144
  #: Тело-улики «сервер не знает сессию». Именно ПРЕДЪЯВЛЕННЫЕ и отвергнутые куки,
@@ -169,6 +177,12 @@ TOKEN_EXPIRED_MARKERS: tuple[str, ...] = (
169
177
  "unauthenticated",
170
178
  "invalid authentication credentials",
171
179
  "csrf token",
180
+ "csrf-token",
181
+ "invalid-csrf",
182
+ # Замаскированное протухание csrf: сервис отвечает «неверный параметр»,
183
+ # не называя причину. Улика попала сюда из браузерного кита — там она
184
+ # была снята живьём и служила ровно этому диагнозу.
185
+ "illegalparameterexception",
172
186
  "xsrf",
173
187
  "sapisidhash",
174
188
  "invalid at token",
@@ -0,0 +1,149 @@
1
+ """Базовые DTO-ЗНАЧЕНИЯ основания: координаты сессии, креды, тело RPC-запроса.
2
+
3
+ Критерий, по которому DTO попал сюда, ровно один: он НЕ ТЯНЕТ РЕАЛИЗАЦИЮ. Это
4
+ неизменяемые значения (`frozen=True, slots=True`) — их можно шарить между
5
+ потоками, класть в ключи, логировать (кроме `Creds`) и сериализовать
6
+ ``dataclasses.asdict``. Всё, что имеет жизненный цикл (клиенты, сторы,
7
+ браузеры), остаётся слоями выше.
8
+
9
+ ТОЛЬКО stdlib. Перенесено из ``librarykit.contract`` БЕЗ
10
+ изменения формы: порядок полей и дефолты те же, поэтому позиционные вызовы и
11
+ уже записанные данные продолжают работать.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ from corekit.context import DEFAULT_STAGE, DEFAULT_TENANT
20
+
21
+ __all__ = [
22
+ "Creds",
23
+ "RequestBody",
24
+ "SessionRef",
25
+ "cookies_from_storage_state",
26
+ ]
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SessionRef:
31
+ """Координаты сессии в `SessionStore`: profile / social / account-id.
32
+
33
+ Тройка ``profile``/``social``/``account_id`` однозначно адресует каталог
34
+ сессии ``<profile>/<social>/<account_id>/`` под корнем sessions. Без секретов —
35
+ это только ключ/путь, поэтому безопасно логировать и индексировать.
36
+
37
+ ``tenant`` — ЧЕТВЁРТАЯ (последняя, с дефолтом) координата: владелец сессии в
38
+ мультитенантном хосте. Поле добавлено В КОНЕЦ, поэтому позиционное
39
+ ``SessionRef(profile, social, account_id)`` и сравнение старых объектов
40
+ продолжают работать.
41
+
42
+ Координата ПОДКЛЮЧЕНА: при ``tenant != DEFAULT_TENANT`` каталог сессии
43
+ получает ведущий сегмент — ``<tenant>/<profile>/<social>/<account_id>/``, а
44
+ первичный ключ индекса — ``(tenant, profile, social, account_id)``. При
45
+ дефолтном ``"local"`` раскладка и ключ байт-в-байт прежние (сегмент не
46
+ добавляется), поэтому существующие сессии читаются как раньше. Пересборка
47
+ индекса из каталога — `SessionStore.reindex` (понимает обе глубины).
48
+
49
+ ``instance`` — КОНКРЕТНЫЙ вход сервиса, у которого их много: портал
50
+ Битрикса (``acme.bitrix24.ru``), национальный домен, отдельная установка.
51
+ Продукт при этом один, и разводить такие входы вторым ИМЕНЕМ сервиса
52
+ (``bitrix24-kz``) нельзя: имена размножаются, а у одного аккаунта появляется
53
+ вторая сессия, о которой никто не знает. Пусто — у сервиса один вход, и
54
+ адрес совпадает с прежним.
55
+
56
+ ``stage`` — стадия сессии: боевая (:data:`~corekit.context.DEFAULT_STAGE`)
57
+ или черновик записи трафика (``capture``). Стадия обязана быть ОТДЕЛЬНОЙ
58
+ координатой, а не значением ``profile``: профиль отвечает на вопрос «чей
59
+ это вход», стадия — «проверен ли он». Совмещая два смысла в одном поле,
60
+ получаешь адрес, по которому нельзя спросить «дай боевую сессию ЭТОГО
61
+ аккаунта», не зная заранее, как её назвал пишущий.
62
+
63
+ Обе координаты добавлены В КОНЕЦ и с дефолтами — по той же причине, что и
64
+ ``tenant``: позиционные вызовы, сравнение старых объектов и уже записанные
65
+ адреса продолжают работать.
66
+ """
67
+
68
+ profile: str
69
+ social: str
70
+ account_id: str
71
+ tenant: str = DEFAULT_TENANT
72
+ instance: str = ""
73
+ stage: str = DEFAULT_STAGE
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class RequestBody:
78
+ """Тело RPC-запроса после `Codec.encode_request` (E1.T2): json XOR data.
79
+
80
+ Нейтральный носитель сериализованного payload между codec и транспортом:
81
+ `json` уходит в `Transport.request(json=...)` (httpx сам сериализует), а
82
+ `data` — сырое тело (bytes/str/form) для не-JSON envelope. Заполняется ровно
83
+ одно из двух; `headers` опц. добавляет codec-специфичные заголовки
84
+ (`Content-Type` и т.п.) к запросу.
85
+ """
86
+
87
+ json: Any | None = None
88
+ data: Any | None = None
89
+ headers: Mapping[str, str] = field(default_factory=dict)
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class Creds:
94
+ """Секреты авторизации одной сессии (НЕ логировать, НЕ сериализовать в индекс).
95
+
96
+ Гибкий контейнер под разные `AuthMode`: токены (token-auth), refresh-токен
97
+ (oauth2), cookies (cookie-сессия), произвольные `extra` (например, подписи).
98
+ Все поля опциональны — заполняется то, что нужно конкретному режиму.
99
+ """
100
+
101
+ token: str | None = None
102
+ refresh_token: str | None = None
103
+ cookies: Mapping[str, str] = field(default_factory=dict)
104
+ extra: Mapping[str, Any] = field(default_factory=dict)
105
+
106
+
107
+ def cookies_from_storage_state(
108
+ state: object,
109
+ *,
110
+ domain_substr: str = "",
111
+ ) -> dict[str, str]:
112
+ """``storage_state`` → плоский ``{имя: значение}``, как хочет HTTP-клиент.
113
+
114
+ ОДИН РАЗБОР НА ВСЕ КИТЫ. Одно и то же преобразование жило копиями: одна
115
+ читала файл и умела фильтр по домену, другая работала со словарём и знала
116
+ про плоскую форму. Копии расходятся молча, а цена расхождения здесь
117
+ несимметрична — лишняя кука уезжает чужому сервису, недостающая ломает
118
+ сессию.
119
+
120
+ Принимает обе формы, потому что обе живут в системе:
121
+
122
+ * Playwright: ``{"cookies": [{"name", "value", "domain", …}]}``;
123
+ * плоскую: ``{имя: значение}`` (так их хранят креды).
124
+
125
+ ``domain_substr`` отсекает куки сторонних доменов — их отправка боевому API
126
+ и есть тот запрос, который сервис вправе счесть подозрительным.
127
+
128
+ Здесь, в основании, потому что это чистая работа со структурой: ни сети, ни
129
+ диска, ни знания о сервисе. Кто и где хранит снимок — вопрос сессионного
130
+ слоя, а не этого разбора.
131
+ """
132
+ if not isinstance(state, Mapping):
133
+ return {}
134
+ raw = state.get("cookies")
135
+ if isinstance(raw, list):
136
+ out: dict[str, str] = {}
137
+ for item in raw:
138
+ if not isinstance(item, Mapping):
139
+ continue
140
+ if domain_substr and domain_substr not in str(item.get("domain") or ""):
141
+ continue
142
+ name, value = item.get("name"), item.get("value")
143
+ if name is not None and value is not None:
144
+ out[str(name)] = str(value)
145
+ return out
146
+ # Плоская форма домена не несёт, поэтому фильтровать по нему нечем: молча
147
+ # вернуть пусто значило бы «сессии нет» там, где она есть.
148
+ flat = raw if isinstance(raw, Mapping) else state
149
+ return {str(k): str(v) for k, v in flat.items() if k != "origins"}
@@ -0,0 +1,165 @@
1
+ """Межпроцессный advisory-лок на файле: одна механика на все киты.
2
+
3
+ ЗАЧЕМ ЭТОТ МОДУЛЬ. Одна и та же вещь была написана трижды — в сессионном ките
4
+ (`SessionFileLock`), в пуле аккаунтов (`_AccountLock`) и там же ещё раз под
5
+ строковый ключ (`KeyedProcessLock`, чей докстринг прямо называет себя портом
6
+ второго). Три копии критической секции — это три разных ответа на вопрос «что
7
+ делать с осиротевшим локом»: процесс упал, файл остался, и дальше поведение
8
+ зависит от того, чей код первым дошёл до записи. Для сессии цена такой разницы —
9
+ затёртая чужая ротация кук, то есть потерянный вход.
10
+
11
+ ПОЧЕМУ В ОСНОВАНИИ. Здесь нет ни сети, ни домена, ни знания о сессиях: только
12
+ файл, часы и stdlib. Кит с нулевыми зависимостями — единственное место, куда
13
+ могут смотреть все три прежних владельца, не создавая обратных связей.
14
+
15
+ ЧЕГО ЗДЕСЬ НЕТ. Реентерабельности (её отсутствие намеренно: рекурсивный захват
16
+ скрывает ошибку владения) и очереди справедливости — ожидание опросом, потому
17
+ что критические секции здесь короткие, а зависимостей ради «правильной» очереди
18
+ кит заводить не станет.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import os
24
+ import time
25
+ from pathlib import Path
26
+
27
+ __all__ = ["FileLock", "LockTimeout"]
28
+
29
+
30
+ class LockTimeout(TimeoutError): # noqa: N818 — подтип TimeoutError, доменное имя
31
+ """Лок не взят за отведённое время: его держит другой писатель."""
32
+
33
+
34
+ class FileLock:
35
+ """Эксклюзивный лок через атомарное создание файла (``O_CREAT|O_EXCL``).
36
+
37
+ Атомарность создания даёт и POSIX, и Windows — поэтому внешняя библиотека не
38
+ нужна. Лок держится на всё окно работы, чтобы конкурентные писатели (фоновое
39
+ продление, команда человека, авто-обновление) не затирали друг друга.
40
+
41
+ ``stale_after`` ломает ОСИРОТЕВШИЙ лок: процесс упал, файл остался, и без
42
+ этого правила система встаёт намертво до ручной уборки. Значение по
43
+ умолчанию заведомо больше любой честной критической секции — лучше подождать
44
+ лишнее, чем забрать лок у живого писателя.
45
+
46
+ Не реентерабелен: повторный захват тем же владельцем — это почти всегда
47
+ потерянный ``release`` в чужой ветке, и молча его прощать значит однажды
48
+ отпустить лок дважды.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ path: str | os.PathLike[str],
54
+ *,
55
+ timeout: float = 30.0,
56
+ poll: float = 0.05,
57
+ stale_after: float | None = 900.0,
58
+ clock: object | None = None,
59
+ ) -> None:
60
+ self._path = Path(path)
61
+ self._timeout = timeout
62
+ self._poll = poll
63
+ self._stale_after = stale_after
64
+ #: Источник настенного времени. Инъекция нужна не ради красоты: тест на
65
+ #: перехват осиротевшего лока иначе идёт столько же, сколько порог, —
66
+ #: то есть его не пишут вовсе, и правило остаётся непроверенным.
67
+ self._clock = clock or time.time
68
+ self._fd: int | None = None
69
+
70
+ @property
71
+ def path(self) -> Path:
72
+ return self._path
73
+
74
+ @property
75
+ def locked(self) -> bool:
76
+ return self._fd is not None
77
+
78
+ def _try_acquire(self) -> bool:
79
+ try:
80
+ self._path.parent.mkdir(parents=True, exist_ok=True)
81
+ fd = os.open(self._path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
82
+ except FileExistsError:
83
+ return False
84
+ # Кто держит и с какого момента — это читают люди, разбирая зависший
85
+ # лок. Без записи остаётся пустой файл, по которому не сказать ничего.
86
+ os.write(fd, f"{os.getpid()}\n{time.time()}".encode())
87
+ self._fd = fd
88
+ return True
89
+
90
+ def _break_if_stale(self) -> None:
91
+ if self._stale_after is None:
92
+ return
93
+ try:
94
+ age = self._clock() - self._path.stat().st_mtime
95
+ except OSError:
96
+ return
97
+ if age > self._stale_after:
98
+ with contextlib.suppress(OSError):
99
+ self._path.unlink()
100
+
101
+ def acquire(self, *, raising: bool = True) -> bool:
102
+ """Взять лок (блокирующе, опросом). Возвращает, взят ли он.
103
+
104
+ ДВА ЧЕСТНЫХ ОТВЕТА НА ЗАНЯТЫЙ ЛОК, и выбор за вызывающим:
105
+
106
+ * ``raising=True`` — ``LockTimeout``. Так ведёт себя тот, для кого
107
+ войти в секцию обязательно: писатель сессии, затерев чужую ротацию,
108
+ отбирает у человека вход.
109
+ * ``raising=False`` — ``False`` без исключения. Так ведёт себя тот, кому
110
+ важнее не встать намертво: пул аккаунтов уже держит внутрипроцессный
111
+ лок, и одна возможная гонка между процессами для него дешевле вечной
112
+ блокировки.
113
+
114
+ Разные ответы — не небрежность, а разная цена ошибки; поэтому они
115
+ названы явно, а не разъехались двумя копиями кода.
116
+ """
117
+ if self._fd is not None:
118
+ return True
119
+ deadline = time.monotonic() + self._timeout
120
+ while True:
121
+ if self._try_acquire():
122
+ return True
123
+ self._break_if_stale()
124
+ if time.monotonic() >= deadline:
125
+ if raising:
126
+ raise LockTimeout(
127
+ f"Лок {self._path} занят другим писателем: не взят за "
128
+ f"{self._timeout:.1f}s."
129
+ )
130
+ return False
131
+ time.sleep(self._poll)
132
+
133
+ def release(self) -> None:
134
+ """Отпустить лок. Идемпотентно: отпустить дважды — не ошибка."""
135
+ if self._fd is not None:
136
+ with contextlib.suppress(OSError):
137
+ os.close(self._fd)
138
+ self._fd = None
139
+ with contextlib.suppress(OSError):
140
+ self._path.unlink()
141
+
142
+ async def acquire_async(self, *, raising: bool = True) -> bool:
143
+ """Взять лок, не стопоря event loop: опрос уходит в поток."""
144
+ import asyncio
145
+
146
+ return await asyncio.to_thread(self.acquire, raising=raising)
147
+
148
+ async def release_async(self) -> None:
149
+ import asyncio
150
+
151
+ await asyncio.to_thread(self.release)
152
+
153
+ def __enter__(self) -> FileLock:
154
+ self.acquire()
155
+ return self
156
+
157
+ def __exit__(self, *exc: object) -> None:
158
+ self.release()
159
+
160
+ async def __aenter__(self) -> FileLock:
161
+ await self.acquire_async()
162
+ return self
163
+
164
+ async def __aexit__(self, *exc: object) -> None:
165
+ await self.release_async()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "s-corekit"
7
- version = "0.0.2"
7
+ version = "0.0.4"
8
8
  description = "ОСНОВАНИЕ китов: ExecutionContext, базовые DTO (SessionRef/Creds/LimitSpec/QuotaInfo), единая иерархия ошибок и чистая классификация состояния доступа (AccessState/AccessVerdict). Только stdlib."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -130,3 +130,33 @@ def test_verdict_serializes_flat_with_its_evidence():
130
130
  assert payload["state"] == "session_dead"
131
131
  assert payload["why"] == "почему" and payload["evidence"] == "улика"
132
132
  assert payload["message"]
133
+
134
+
135
+ # =========================================================================== #
136
+ # Улики входа и протухания — ОДИН словарь на все киты #
137
+ # =========================================================================== #
138
+
139
+
140
+ def test_login_markers_cover_what_the_browser_kit_knew_alone():
141
+ """Улики увода на вход собраны в основании, а не в копии браузерного кита.
142
+
143
+ Копии расходятся молча: одна таблица узнавала внешний провайдер входа и
144
+ экран второго фактора, другая — хосты Google. Вердикт «сессия мертва»
145
+ зависел от того, каким путём пошёл вызывающий.
146
+ """
147
+ from corekit.diagnosis.access import LOGIN_HOST_MARKERS
148
+
149
+ for marker in ("accounts.google.com", "/login", "/signin", "passport.", "pincode"):
150
+ assert marker in LOGIN_HOST_MARKERS, f"улика входа {marker!r} потеряна при сведении"
151
+
152
+
153
+ def test_token_markers_keep_the_masked_csrf_evidence():
154
+ """Замаскированное протухание csrf: сервис отвечает «неверный параметр».
155
+
156
+ Улика снята живьём в браузерном ките; при сведении таблиц её легко было
157
+ потерять — она не похожа ни на одно слово про токен.
158
+ """
159
+ from corekit.diagnosis.access import TOKEN_EXPIRED_MARKERS
160
+
161
+ for marker in ("csrf-token", "invalid-csrf", "illegalparameterexception"):
162
+ assert marker in TOKEN_EXPIRED_MARKERS, f"улика {marker!r} потеряна при сведении"
@@ -0,0 +1,87 @@
1
+ """Файловый лок: одна механика на все киты.
2
+
3
+ Раньше та же вещь была написана трижды — в сессионном ките, в пуле аккаунтов и
4
+ там же ещё раз под строковый ключ. Три копии критической секции — это три разных
5
+ ответа на вопрос «что делать с осиротевшим локом». Для сессии цена расхождения:
6
+ затёртая чужая ротация кук, то есть потерянный вход.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+
12
+ from corekit.filelock import FileLock, LockTimeout
13
+
14
+
15
+ def test_second_writer_does_not_get_the_lock(tmp_path):
16
+ """Двое не входят в критическую секцию одновременно — иначе она бессмысленна."""
17
+ path = tmp_path / "session.lock"
18
+ with FileLock(path):
19
+ with pytest.raises(LockTimeout):
20
+ FileLock(path, timeout=0.1, poll=0.01, stale_after=None).acquire()
21
+
22
+
23
+ def test_lock_is_released_for_the_next_writer(tmp_path):
24
+ """После выхода лок свободен: иначе первый же писатель вешает систему."""
25
+ path = tmp_path / "session.lock"
26
+ with FileLock(path):
27
+ pass
28
+
29
+ with FileLock(path, timeout=0.5):
30
+ assert path.exists(), "лок взят, но файла нет — второй писатель войдёт следом"
31
+
32
+
33
+ def test_orphaned_lock_is_broken(tmp_path):
34
+ """Процесс упал, файл остался — без перехвата система встаёт до ручной уборки."""
35
+ path = tmp_path / "session.lock"
36
+ path.write_text("999999\n0", encoding="utf-8")
37
+
38
+ # Возраст файла заведомо больше порога: перехват обязан сработать.
39
+ with FileLock(path, timeout=0.5, poll=0.01, stale_after=0.0) as lock:
40
+ assert lock.locked
41
+
42
+
43
+ def test_living_writer_is_never_robbed(tmp_path):
44
+ """Порог перехвата не должен забирать лок у живого: он держит секцию честно."""
45
+ path = tmp_path / "session.lock"
46
+ with FileLock(path):
47
+ with pytest.raises(LockTimeout):
48
+ FileLock(path, timeout=0.1, poll=0.01, stale_after=900.0).acquire()
49
+
50
+
51
+ def test_release_twice_is_not_an_error(tmp_path):
52
+ """Отпустить дважды — обычный путь при ошибке в середине работы."""
53
+ lock = FileLock(tmp_path / "session.lock")
54
+ lock.acquire()
55
+ lock.release()
56
+ lock.release()
57
+
58
+ assert not lock.locked
59
+
60
+
61
+ def test_non_raising_mode_answers_false_instead_of_blocking(tmp_path):
62
+ """Пулу аккаунтов важнее не встать намертво, чем не разойтись с соседом.
63
+
64
+ Он уже держит внутрипроцессный лок, поэтому одна возможная гонка между
65
+ процессами для него дешевле вечной блокировки. Писателю сессии — наоборот:
66
+ затерев чужую ротацию, он отбирает у человека вход. Два ответа названы
67
+ явно, чтобы они снова не разъехались двумя копиями кода.
68
+ """
69
+ path = tmp_path / "session.lock"
70
+ with FileLock(path):
71
+ assert FileLock(path, timeout=0.1, poll=0.01, stale_after=None).acquire(
72
+ raising=False
73
+ ) is False
74
+
75
+
76
+ def test_clock_injection_keeps_the_stale_test_short(tmp_path):
77
+ """Порог перехвата проверяется подменой часов, а не ожиданием в сутки.
78
+
79
+ Без инъекции такой тест идёт столько же, сколько порог, — то есть его не
80
+ пишут вовсе, и правило остаётся непроверенным.
81
+ """
82
+ path = tmp_path / "session.lock"
83
+ path.write_text("1\n0", encoding="utf-8")
84
+ ahead = path.stat().st_mtime + 10_000
85
+
86
+ with FileLock(path, timeout=0.5, poll=0.01, stale_after=900.0, clock=lambda: ahead) as lock:
87
+ assert lock.locked, "осиротевший лок не перехвачен при сдвинутых часах"
@@ -0,0 +1,65 @@
1
+ """Адрес сессии: две новые координаты не ломают ни одного старого вызова.
2
+
3
+ Почему это проверяется отдельно. `SessionRef` — общий словарь между китами,
4
+ навыками и хранилищем: его собирают в одном месте, а разбирают в четырёх. Любая
5
+ правка формы здесь дороже обычной: старый позиционный вызов, лежащий в чужом
6
+ навыке, обязан продолжить работать, а уже записанные адреса — читаться.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+
12
+ import pytest
13
+
14
+ from corekit import DEFAULT_STAGE, DEFAULT_TENANT, SessionRef
15
+
16
+
17
+ def test_old_positional_call_still_works() -> None:
18
+ """Тройка без новых координат собирается как раньше — и это боевая сессия."""
19
+ ref = SessionRef("default", "grok", "me")
20
+
21
+ assert (ref.profile, ref.social, ref.account_id) == ("default", "grok", "me")
22
+ assert ref.tenant == DEFAULT_TENANT
23
+ assert ref.instance == ""
24
+ assert ref.stage == DEFAULT_STAGE
25
+
26
+
27
+ def test_stage_makes_two_different_addresses() -> None:
28
+ """Черновик и боевая — РАЗНЫЕ адреса одного аккаунта, а не один.
29
+
30
+ Ровно это и защищает рабочую сессию от неудачного захода в capture.
31
+ """
32
+ live = SessionRef("default", "grok", "me")
33
+ draft = SessionRef("default", "grok", "me", stage="capture")
34
+
35
+ assert live != draft
36
+
37
+
38
+ def test_instance_separates_portals_of_one_service() -> None:
39
+ """Два портала одного продукта — разные адреса при одном имени сервиса."""
40
+ acme = SessionRef("default", "bitrix24", "me", instance="acme.bitrix24.ru")
41
+ beta = SessionRef("default", "bitrix24", "me", instance="beta.bitrix24.kz")
42
+
43
+ assert acme != beta
44
+ assert acme.social == beta.social == "bitrix24"
45
+
46
+
47
+ def test_ref_stays_hashable_and_frozen() -> None:
48
+ """Адрес остаётся значением: годится в ключ и не меняется на ходу."""
49
+ ref = SessionRef("default", "grok", "me", stage="capture")
50
+
51
+ assert {ref: 1}[SessionRef("default", "grok", "me", stage="capture")] == 1
52
+ with pytest.raises(dataclasses.FrozenInstanceError):
53
+ ref.stage = "live" # type: ignore[misc]
54
+
55
+
56
+ def test_new_fields_are_last() -> None:
57
+ """Новые координаты стоят В КОНЦЕ — иначе позиционные вызовы молча съедут.
58
+
59
+ Проверка не про эстетику: `SessionRef("default", "grok", "me")` живёт в
60
+ чужих навыках, и перестановка полей превратила бы имя сервиса в стадию,
61
+ не вызвав ни одной ошибки.
62
+ """
63
+ names = [f.name for f in dataclasses.fields(SessionRef)]
64
+
65
+ assert names == ["profile", "social", "account_id", "tenant", "instance", "stage"]
@@ -1,83 +0,0 @@
1
- """Базовые DTO-ЗНАЧЕНИЯ основания: координаты сессии, креды, тело RPC-запроса.
2
-
3
- Критерий, по которому DTO попал сюда, ровно один: он НЕ ТЯНЕТ РЕАЛИЗАЦИЮ. Это
4
- неизменяемые значения (`frozen=True, slots=True`) — их можно шарить между
5
- потоками, класть в ключи, логировать (кроме `Creds`) и сериализовать
6
- ``dataclasses.asdict``. Всё, что имеет жизненный цикл (клиенты, сторы,
7
- браузеры), остаётся слоями выше.
8
-
9
- ТОЛЬКО stdlib. Перенесено из ``librarykit.contract`` БЕЗ
10
- изменения формы: порядок полей и дефолты те же, поэтому позиционные вызовы и
11
- уже записанные данные продолжают работать.
12
- """
13
- from __future__ import annotations
14
-
15
- from collections.abc import Mapping
16
- from dataclasses import dataclass, field
17
- from typing import Any
18
-
19
- from corekit.context import DEFAULT_TENANT
20
-
21
- __all__ = [
22
- "Creds",
23
- "RequestBody",
24
- "SessionRef",
25
- ]
26
-
27
-
28
- @dataclass(frozen=True, slots=True)
29
- class SessionRef:
30
- """Координаты сессии в `SessionStore`: profile / social / account-id.
31
-
32
- Тройка ``profile``/``social``/``account_id`` однозначно адресует каталог
33
- сессии ``<profile>/<social>/<account_id>/`` под корнем sessions. Без секретов —
34
- это только ключ/путь, поэтому безопасно логировать и индексировать.
35
-
36
- ``tenant`` — ЧЕТВЁРТАЯ (последняя, с дефолтом) координата: владелец сессии в
37
- мультитенантном хосте. Поле добавлено В КОНЕЦ, поэтому позиционное
38
- ``SessionRef(profile, social, account_id)`` и сравнение старых объектов
39
- продолжают работать.
40
-
41
- Координата ПОДКЛЮЧЕНА: при ``tenant != DEFAULT_TENANT`` каталог сессии
42
- получает ведущий сегмент — ``<tenant>/<profile>/<social>/<account_id>/``, а
43
- первичный ключ индекса — ``(tenant, profile, social, account_id)``. При
44
- дефолтном ``"local"`` раскладка и ключ байт-в-байт прежние (сегмент не
45
- добавляется), поэтому существующие сессии читаются как раньше. Пересборка
46
- индекса из каталога — `SessionStore.reindex` (понимает обе глубины).
47
- """
48
-
49
- profile: str
50
- social: str
51
- account_id: str
52
- tenant: str = DEFAULT_TENANT
53
-
54
-
55
- @dataclass(frozen=True, slots=True)
56
- class RequestBody:
57
- """Тело RPC-запроса после `Codec.encode_request` (E1.T2): json XOR data.
58
-
59
- Нейтральный носитель сериализованного payload между codec и транспортом:
60
- `json` уходит в `Transport.request(json=...)` (httpx сам сериализует), а
61
- `data` — сырое тело (bytes/str/form) для не-JSON envelope. Заполняется ровно
62
- одно из двух; `headers` опц. добавляет codec-специфичные заголовки
63
- (`Content-Type` и т.п.) к запросу.
64
- """
65
-
66
- json: Any | None = None
67
- data: Any | None = None
68
- headers: Mapping[str, str] = field(default_factory=dict)
69
-
70
-
71
- @dataclass(frozen=True, slots=True)
72
- class Creds:
73
- """Секреты авторизации одной сессии (НЕ логировать, НЕ сериализовать в индекс).
74
-
75
- Гибкий контейнер под разные `AuthMode`: токены (token-auth), refresh-токен
76
- (oauth2), cookies (cookie-сессия), произвольные `extra` (например, подписи).
77
- Все поля опциональны — заполняется то, что нужно конкретному режиму.
78
- """
79
-
80
- token: str | None = None
81
- refresh_token: str | None = None
82
- cookies: Mapping[str, str] = field(default_factory=dict)
83
- extra: Mapping[str, Any] = field(default_factory=dict)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes