ChatECNU 0.2.2__tar.gz → 0.2.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.
- {chatecnu-0.2.2/src/ChatECNU.egg-info → chatecnu-0.2.4}/PKG-INFO +4 -2
- {chatecnu-0.2.2 → chatecnu-0.2.4}/README.md +2 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/pyproject.toml +4 -1
- {chatecnu-0.2.2 → chatecnu-0.2.4/src/ChatECNU.egg-info}/PKG-INFO +4 -2
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/ChatECNU.egg-info/SOURCES.txt +3 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/ChatECNU.egg-info/entry_points.txt +3 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/ChatECNU.egg-info/requires.txt +1 -1
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/__init__.py +1 -1
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/config.py +0 -2
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/ecnu/cli.py +31 -7
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/ecnu/portal.py +58 -0
- chatecnu-0.2.4/src/chatecnu/ecnu/session_tokens.py +200 -0
- chatecnu-0.2.4/tests/test_docs_rendering.py +35 -0
- chatecnu-0.2.4/tests/test_token_store_sessions.py +317 -0
- chatecnu-0.2.4/tests/test_version.py +26 -0
- chatecnu-0.2.2/tests/test_version.py +0 -15
- {chatecnu-0.2.2 → chatecnu-0.2.4}/LICENSE +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/MANIFEST.in +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/setup.cfg +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/ChatECNU.egg-info/dependency_links.txt +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/ChatECNU.egg-info/top_level.txt +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/bin/linux-x86_64/auth_client +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/cli.py +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/ecnu/__init__.py +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/ecnu/captcha.py +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/network_auth.py +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/tests/test_cli.py +0 -0
- {chatecnu-0.2.2 → chatecnu-0.2.4}/tests/test_network_auth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ChatECNU
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: ChatArch ECNU campus portal automation CLI
|
|
5
5
|
Author-email: ChatArch <1073853456@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -15,7 +15,7 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
License-File: LICENSE
|
|
16
16
|
Requires-Dist: click>=8.0
|
|
17
17
|
Requires-Dist: chatstyle<0.2.0,>=0.1.0
|
|
18
|
-
Requires-Dist: chatenv<0.3.0,>=0.2.
|
|
18
|
+
Requires-Dist: chatenv<0.3.0,>=0.2.7
|
|
19
19
|
Requires-Dist: ChatNet<0.3.0,>=0.2.0
|
|
20
20
|
Requires-Dist: requests>=2.0
|
|
21
21
|
Requires-Dist: pycryptodome>=3.0
|
|
@@ -99,6 +99,8 @@ ecnu home user
|
|
|
99
99
|
ecnu home logout
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
门户会话默认写入 ChatEnv runtime token-store:`~/.chatarch/tokens/ECNU/<profile>.json`;`ecnu home status --json` 只回读安全摘要,不输出原始 Cookie/CSRF。`chatenv token refresh ECNU <profile>` 可从同名 stable env profile 发起非交互 OCR 自动登录并由 ChatEnv 写入 runtime token-store;遇到短信验证码或验证码候选失败时会安全失败。
|
|
103
|
+
|
|
102
104
|
校园网联网统一放在 `net` 下:
|
|
103
105
|
|
|
104
106
|
```bash
|
|
@@ -63,6 +63,8 @@ ecnu home user
|
|
|
63
63
|
ecnu home logout
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
门户会话默认写入 ChatEnv runtime token-store:`~/.chatarch/tokens/ECNU/<profile>.json`;`ecnu home status --json` 只回读安全摘要,不输出原始 Cookie/CSRF。`chatenv token refresh ECNU <profile>` 可从同名 stable env profile 发起非交互 OCR 自动登录并由 ChatEnv 写入 runtime token-store;遇到短信验证码或验证码候选失败时会安全失败。
|
|
67
|
+
|
|
66
68
|
校园网联网统一放在 `net` 下:
|
|
67
69
|
|
|
68
70
|
```bash
|
|
@@ -12,7 +12,7 @@ license = "MIT"
|
|
|
12
12
|
dependencies = [
|
|
13
13
|
"click>=8.0",
|
|
14
14
|
"chatstyle>=0.1.0,<0.2.0",
|
|
15
|
-
"chatenv>=0.2.
|
|
15
|
+
"chatenv>=0.2.7,<0.3.0",
|
|
16
16
|
"ChatNet>=0.2.0,<0.3.0",
|
|
17
17
|
"requests>=2.0",
|
|
18
18
|
"pycryptodome>=3.0",
|
|
@@ -31,6 +31,9 @@ chatecnu = "chatecnu.cli:main"
|
|
|
31
31
|
[project.entry-points."chatenv.configs"]
|
|
32
32
|
ecnu = "chatecnu.config"
|
|
33
33
|
|
|
34
|
+
[project.entry-points."chatenv.token_refreshers"]
|
|
35
|
+
ECNU = "chatecnu.ecnu.session_tokens:refresh_chatenv_token"
|
|
36
|
+
|
|
34
37
|
[project.urls]
|
|
35
38
|
Homepage = "https://github.com/ChatArch/ChatECNU"
|
|
36
39
|
Repository = "https://github.com/ChatArch/ChatECNU"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ChatECNU
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: ChatArch ECNU campus portal automation CLI
|
|
5
5
|
Author-email: ChatArch <1073853456@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -15,7 +15,7 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
License-File: LICENSE
|
|
16
16
|
Requires-Dist: click>=8.0
|
|
17
17
|
Requires-Dist: chatstyle<0.2.0,>=0.1.0
|
|
18
|
-
Requires-Dist: chatenv<0.3.0,>=0.2.
|
|
18
|
+
Requires-Dist: chatenv<0.3.0,>=0.2.7
|
|
19
19
|
Requires-Dist: ChatNet<0.3.0,>=0.2.0
|
|
20
20
|
Requires-Dist: requests>=2.0
|
|
21
21
|
Requires-Dist: pycryptodome>=3.0
|
|
@@ -99,6 +99,8 @@ ecnu home user
|
|
|
99
99
|
ecnu home logout
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
门户会话默认写入 ChatEnv runtime token-store:`~/.chatarch/tokens/ECNU/<profile>.json`;`ecnu home status --json` 只回读安全摘要,不输出原始 Cookie/CSRF。`chatenv token refresh ECNU <profile>` 可从同名 stable env profile 发起非交互 OCR 自动登录并由 ChatEnv 写入 runtime token-store;遇到短信验证码或验证码候选失败时会安全失败。
|
|
103
|
+
|
|
102
104
|
校园网联网统一放在 `net` 下:
|
|
103
105
|
|
|
104
106
|
```bash
|
|
@@ -17,6 +17,9 @@ src/chatecnu/ecnu/__init__.py
|
|
|
17
17
|
src/chatecnu/ecnu/captcha.py
|
|
18
18
|
src/chatecnu/ecnu/cli.py
|
|
19
19
|
src/chatecnu/ecnu/portal.py
|
|
20
|
+
src/chatecnu/ecnu/session_tokens.py
|
|
20
21
|
tests/test_cli.py
|
|
22
|
+
tests/test_docs_rendering.py
|
|
21
23
|
tests/test_network_auth.py
|
|
24
|
+
tests/test_token_store_sessions.py
|
|
22
25
|
tests/test_version.py
|
|
@@ -14,7 +14,6 @@ class ECNUConfig(BaseEnvConfig):
|
|
|
14
14
|
|
|
15
15
|
ECNU_USERNAME = EnvField("ECNU_USERNAME", desc="ECNU 用户名。")
|
|
16
16
|
ECNU_PASSWORD = EnvField("ECNU_PASSWORD", desc="ECNU 密码。", is_sensitive=True)
|
|
17
|
-
ECNU_COOKIE = EnvField("ECNU_COOKIE", desc="ECNU 门户 Cookie。", is_sensitive=True)
|
|
18
17
|
ECNU_BASE_URL = EnvField("ECNU_BASE_URL", default="https://login.ecnu.edu.cn:8800", desc="ECNU 门户地址。")
|
|
19
18
|
ECNU_VISITOR_PASSWORD1 = EnvField("ECNU_VISITOR_PASSWORD1", desc="默认访客账号 m1 密码。", is_sensitive=True)
|
|
20
19
|
ECNU_VISITOR_PASSWORD2 = EnvField("ECNU_VISITOR_PASSWORD2", desc="默认访客账号 m2 密码。", is_sensitive=True)
|
|
@@ -33,7 +32,6 @@ class ECNUConfig(BaseEnvConfig):
|
|
|
33
32
|
return
|
|
34
33
|
print(f"✅ Config loaded. Base URL: {base_url}")
|
|
35
34
|
print(f" Username configured: {bool(cls.ECNU_USERNAME.value)}")
|
|
36
|
-
print(f" Cookie configured: {bool(cls.ECNU_COOKIE.value)}")
|
|
37
35
|
|
|
38
36
|
|
|
39
37
|
__all__ = ["ECNUConfig"]
|
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any, Callable
|
|
10
10
|
|
|
11
11
|
import click
|
|
12
|
+
from chatenv import TokenStore
|
|
12
13
|
from chatstyle import CommandField, CommandSchema, add_interactive_option, resolve_command_inputs
|
|
13
14
|
from chatenv.fields import BaseEnvConfig
|
|
14
15
|
from chatenv.paths import get_paths
|
|
@@ -24,6 +25,7 @@ from chatecnu.network_auth import (
|
|
|
24
25
|
resolve_auth_client_path,
|
|
25
26
|
)
|
|
26
27
|
from .portal import BASE_URL
|
|
28
|
+
from .session_tokens import ECNU_SESSION_SERVICE, portal_session_profile
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
LOGIN_SCHEMA = CommandSchema(
|
|
@@ -200,13 +202,13 @@ def _tree_callback(ctx: click.Context, param: click.Option, value: bool) -> None
|
|
|
200
202
|
"--state-file",
|
|
201
203
|
default=None,
|
|
202
204
|
hidden=True,
|
|
203
|
-
help="
|
|
205
|
+
help="Explicit legacy session state JSON file. Defaults to ChatEnv token-store tokens/ECNU/<profile>.json.",
|
|
204
206
|
)
|
|
205
207
|
@click.option(
|
|
206
208
|
"--cookie",
|
|
207
209
|
default=None,
|
|
208
210
|
hidden=True,
|
|
209
|
-
help="
|
|
211
|
+
help="Explicit one-shot authenticated Cookie header override.",
|
|
210
212
|
)
|
|
211
213
|
@click.option("-e", "--env", "env_profile", default=None, help="ChatEnv 配置名。")
|
|
212
214
|
@click.option("--env-file", default=None, hidden=True, help="Explicit env file override for ECNU values.")
|
|
@@ -224,10 +226,13 @@ def cli(
|
|
|
224
226
|
"""ECNU 门户工具。"""
|
|
225
227
|
|
|
226
228
|
load_chatenv(env_profile=env_profile, env_file=env_file)
|
|
229
|
+
token_profile = portal_session_profile(env_profile)
|
|
227
230
|
ctx.obj = {
|
|
228
231
|
"base_url": base_url or ECNUConfig.ECNU_BASE_URL.value or BASE_URL,
|
|
229
|
-
"state_file": Path(state_file).expanduser() if state_file else
|
|
230
|
-
"
|
|
232
|
+
"state_file": Path(state_file).expanduser() if state_file else None,
|
|
233
|
+
"use_token_store": state_file is None,
|
|
234
|
+
"token_profile": token_profile,
|
|
235
|
+
"cookie": cookie,
|
|
231
236
|
"timeout": timeout,
|
|
232
237
|
"env_profile": env_profile,
|
|
233
238
|
"env_file": env_file,
|
|
@@ -382,7 +387,7 @@ def session_info(ctx: click.Context, json_output: bool) -> None:
|
|
|
382
387
|
def cookie_header(ctx: click.Context) -> None:
|
|
383
388
|
"""Print the current Cookie header from state/session."""
|
|
384
389
|
|
|
385
|
-
click.
|
|
390
|
+
raise click.ClickException("Raw cookie output is disabled; use `ecnu home status --json` for redacted session metadata.")
|
|
386
391
|
|
|
387
392
|
|
|
388
393
|
@home_group.command(name="logout")
|
|
@@ -929,11 +934,20 @@ def make_client(ctx: click.Context) -> Any:
|
|
|
929
934
|
from .portal import PortalClient
|
|
930
935
|
|
|
931
936
|
config = ctx.find_root().obj or ctx.obj or {}
|
|
937
|
+
token_store = TokenStore() if config.get("use_token_store") else None
|
|
938
|
+
token_profile = config.get("token_profile") or portal_session_profile(config.get("env_profile"))
|
|
939
|
+
state_file = config.get("state_file")
|
|
940
|
+
if token_store is not None:
|
|
941
|
+
state_file = token_store.token_path(ECNU_SESSION_SERVICE, token_profile)
|
|
942
|
+
elif state_file is None:
|
|
943
|
+
state_file = default_state_file()
|
|
932
944
|
return PortalClient(
|
|
933
945
|
base_url=config["base_url"],
|
|
934
|
-
state_file=
|
|
946
|
+
state_file=Path(state_file),
|
|
935
947
|
cookie_header=config.get("cookie"),
|
|
936
948
|
timeout=config["timeout"],
|
|
949
|
+
token_store=token_store,
|
|
950
|
+
token_profile=token_profile,
|
|
937
951
|
)
|
|
938
952
|
|
|
939
953
|
|
|
@@ -1105,6 +1119,12 @@ def format_status_summary(result: dict[str, Any]) -> str:
|
|
|
1105
1119
|
else:
|
|
1106
1120
|
lines.append("Authenticated at: not logged in")
|
|
1107
1121
|
lines.append(f"Cookies saved: {len(cookies) if isinstance(cookies, dict) else 0}")
|
|
1122
|
+
if result.get("session_storage") == "token_store":
|
|
1123
|
+
lines.append(f"Session storage: token-store ({result.get('token_profile')})")
|
|
1124
|
+
if result.get("token_file"):
|
|
1125
|
+
lines.append(f"Token file: {result['token_file']}")
|
|
1126
|
+
elif result.get("state_file"):
|
|
1127
|
+
lines.append(f"Session storage: state-file ({result['state_file']})")
|
|
1108
1128
|
if result.get("base_url"):
|
|
1109
1129
|
lines.append(f"Base URL: {result['base_url']}")
|
|
1110
1130
|
return "\n".join(lines)
|
|
@@ -1246,7 +1266,11 @@ def resolve_default_visitor_inputs(
|
|
|
1246
1266
|
|
|
1247
1267
|
|
|
1248
1268
|
def session_status(ctx: click.Context) -> dict[str, Any]:
|
|
1249
|
-
|
|
1269
|
+
client = make_client(ctx)
|
|
1270
|
+
result = redact_state(client.state)
|
|
1271
|
+
if hasattr(client, "session_storage_status"):
|
|
1272
|
+
result.update(client.session_storage_status())
|
|
1273
|
+
return result
|
|
1250
1274
|
|
|
1251
1275
|
|
|
1252
1276
|
def ensure_default_visitors(
|
|
@@ -23,6 +23,7 @@ from chatnet.portal import (
|
|
|
23
23
|
find_table_with_headers,
|
|
24
24
|
first_multirow_table,
|
|
25
25
|
parse_detail_view_table,
|
|
26
|
+
parse_cookie_header,
|
|
26
27
|
parse_tables,
|
|
27
28
|
request_spec,
|
|
28
29
|
require_value,
|
|
@@ -30,6 +31,14 @@ from chatnet.portal import (
|
|
|
30
31
|
table_to_dicts,
|
|
31
32
|
)
|
|
32
33
|
|
|
34
|
+
from .session_tokens import (
|
|
35
|
+
ECNU_SESSION_SERVICE,
|
|
36
|
+
load_portal_session_state,
|
|
37
|
+
portal_session_profile,
|
|
38
|
+
portal_session_status,
|
|
39
|
+
save_portal_session_state,
|
|
40
|
+
)
|
|
41
|
+
|
|
33
42
|
BASE_URL = "https://login.ecnu.edu.cn:8800"
|
|
34
43
|
LOGIN_PATH = "/login"
|
|
35
44
|
VALIDATE_USER_PATH = "/site/validate-user"
|
|
@@ -113,6 +122,55 @@ class VisitorRow:
|
|
|
113
122
|
|
|
114
123
|
|
|
115
124
|
class PortalClient(BrowserPortalClient):
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
base_url: str,
|
|
128
|
+
state_file: Path,
|
|
129
|
+
cookie_header: str | None = None,
|
|
130
|
+
timeout: int = 20,
|
|
131
|
+
*,
|
|
132
|
+
token_store: Any | None = None,
|
|
133
|
+
token_profile: str | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
self._token_store = token_store
|
|
136
|
+
self._token_profile = portal_session_profile(token_profile)
|
|
137
|
+
super().__init__(base_url=base_url, state_file=state_file, cookie_header=None, timeout=timeout)
|
|
138
|
+
if self._token_store is not None:
|
|
139
|
+
self.state = load_portal_session_state(env_profile=self._token_profile, token_store=self._token_store)
|
|
140
|
+
self.session.cookies.clear()
|
|
141
|
+
state_cookies = self.state.get("cookies") or {}
|
|
142
|
+
if isinstance(state_cookies, dict) and state_cookies:
|
|
143
|
+
requests.utils.add_dict_to_cookiejar(self.session.cookies, state_cookies)
|
|
144
|
+
if cookie_header:
|
|
145
|
+
requests.utils.add_dict_to_cookiejar(self.session.cookies, parse_cookie_header(cookie_header))
|
|
146
|
+
|
|
147
|
+
def _save_state(self, extra: dict[str, Any] | None = None) -> None:
|
|
148
|
+
if self._token_store is None:
|
|
149
|
+
return super()._save_state(extra)
|
|
150
|
+
payload = dict(self.state)
|
|
151
|
+
payload["base_url"] = self.base_url
|
|
152
|
+
payload["cookies"] = requests.utils.dict_from_cookiejar(self.session.cookies)
|
|
153
|
+
if extra:
|
|
154
|
+
payload.update(extra)
|
|
155
|
+
save_portal_session_state(payload, env_profile=self._token_profile, token_store=self._token_store)
|
|
156
|
+
self.state = payload
|
|
157
|
+
|
|
158
|
+
def session_storage_status(self) -> dict[str, Any]:
|
|
159
|
+
if self._token_store is None:
|
|
160
|
+
return {
|
|
161
|
+
"session_storage": "state_file",
|
|
162
|
+
"state_file": str(self.state_file),
|
|
163
|
+
}
|
|
164
|
+
status = portal_session_status(env_profile=self._token_profile, token_store=self._token_store)
|
|
165
|
+
return {
|
|
166
|
+
"session_storage": "token_store",
|
|
167
|
+
"token_profile": status["profile"],
|
|
168
|
+
"token_file": status["token_file"],
|
|
169
|
+
"token_type": status["token_type"],
|
|
170
|
+
"token_present": status["token_present"],
|
|
171
|
+
"token_summary": status.get("summary") or {},
|
|
172
|
+
}
|
|
173
|
+
|
|
116
174
|
def _authenticated_response(self, resp: requests.Response) -> requests.Response:
|
|
117
175
|
resp.raise_for_status()
|
|
118
176
|
if urlparse(resp.url).path == LOGIN_PATH:
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""ECNU portal session adapter for ChatEnv's generic token store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tempfile import TemporaryDirectory
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from chatenv import EnvStore, TokenRefreshResult, TokenStore, get_paths
|
|
10
|
+
from chatenv.tokens import normalize_token_profile
|
|
11
|
+
|
|
12
|
+
from chatecnu.config import ECNUConfig
|
|
13
|
+
|
|
14
|
+
ECNU_SESSION_SERVICE = "ECNU"
|
|
15
|
+
ECNU_PORTAL_TOKEN_TYPE = "portal_session"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def portal_session_profile(env_profile: str | None = None) -> str:
|
|
19
|
+
"""Return the token-store profile paired with the selected ChatEnv profile."""
|
|
20
|
+
|
|
21
|
+
return normalize_token_profile(env_profile)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def portal_session_summary(state: dict[str, Any]) -> dict[str, Any]:
|
|
25
|
+
"""Return safe metadata for a stored ECNU portal session."""
|
|
26
|
+
|
|
27
|
+
cookies = state.get("cookies") if isinstance(state, dict) else {}
|
|
28
|
+
cookie_count = len(cookies) if isinstance(cookies, dict) else 0
|
|
29
|
+
login_bootstrap = state.get("login_bootstrap") if isinstance(state, dict) else None
|
|
30
|
+
return {
|
|
31
|
+
"base_url": state.get("base_url") or "",
|
|
32
|
+
"username": state.get("username") or "",
|
|
33
|
+
"authenticated_at": state.get("authenticated_at") or "",
|
|
34
|
+
"cookie_count": cookie_count,
|
|
35
|
+
"has_login_bootstrap": isinstance(login_bootstrap, dict) and bool(login_bootstrap),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _store(token_store: TokenStore | None = None, *, home: str | Path | None = None) -> TokenStore:
|
|
40
|
+
return token_store or TokenStore(home=home)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_refresh_profile_values(
|
|
44
|
+
profile: str | None,
|
|
45
|
+
*,
|
|
46
|
+
home: str | Path | None = None,
|
|
47
|
+
env_store: EnvStore | None = None,
|
|
48
|
+
) -> tuple[str, dict[str, str]]:
|
|
49
|
+
profile_name = normalize_token_profile(profile)
|
|
50
|
+
store = env_store or EnvStore(get_paths(home).envs_dir)
|
|
51
|
+
try:
|
|
52
|
+
profile_path = (
|
|
53
|
+
store.active_path(ECNUConfig)
|
|
54
|
+
if profile_name == "default"
|
|
55
|
+
else store.profile_path(ECNUConfig, profile_name)
|
|
56
|
+
)
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise ValueError(f"ECNU ChatEnv profile not found or invalid: {profile_name}") from exc
|
|
59
|
+
if not profile_path.exists():
|
|
60
|
+
raise ValueError(f"ECNU ChatEnv profile not found or invalid: {profile_name}")
|
|
61
|
+
try:
|
|
62
|
+
values = store.load_active(ECNUConfig) if profile_name == "default" else store.load_profile(ECNUConfig, profile_name)
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
raise ValueError(f"ECNU ChatEnv profile not found or invalid: {profile_name}") from exc
|
|
65
|
+
return profile_name, {str(key): str(value) for key, value in values.items() if value is not None}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _refresh_portal_client_class():
|
|
69
|
+
from .portal import PortalClient
|
|
70
|
+
|
|
71
|
+
return PortalClient
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _default_base_url() -> str:
|
|
75
|
+
from .portal import BASE_URL
|
|
76
|
+
|
|
77
|
+
return BASE_URL
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def refresh_chatenv_token(
|
|
81
|
+
*,
|
|
82
|
+
service: str,
|
|
83
|
+
profile: str,
|
|
84
|
+
home: str | Path | None = None,
|
|
85
|
+
env_store: EnvStore | None = None,
|
|
86
|
+
token_store: TokenStore | None = None,
|
|
87
|
+
) -> TokenRefreshResult:
|
|
88
|
+
"""Refresh an ECNU portal session for ChatEnv's provider lifecycle.
|
|
89
|
+
|
|
90
|
+
The ECNU portal requires captcha OCR and can require SMS. This provider is
|
|
91
|
+
intentionally non-interactive: it runs the existing best-effort auto-login
|
|
92
|
+
path from a matching stable ChatEnv profile and fails closed if the portal
|
|
93
|
+
asks for SMS or rejects all captcha candidates. ChatEnv owns persistence
|
|
94
|
+
after this function returns, so the provider itself never writes the
|
|
95
|
+
token-store record.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
del token_store # ChatEnv persists the returned TokenRefreshResult.
|
|
99
|
+
if service != ECNU_SESSION_SERVICE:
|
|
100
|
+
raise ValueError(f"ChatECNU can refresh only {ECNU_SESSION_SERVICE} tokens")
|
|
101
|
+
profile_name, values = _load_refresh_profile_values(profile, home=home, env_store=env_store)
|
|
102
|
+
for key in ["ECNU_USERNAME", "ECNU_PASSWORD"]:
|
|
103
|
+
if not values.get(key):
|
|
104
|
+
raise ValueError(f"ECNU ChatEnv profile {profile_name} is missing {key}")
|
|
105
|
+
|
|
106
|
+
base_url = (values.get("ECNU_BASE_URL") or _default_base_url()).rstrip("/")
|
|
107
|
+
with TemporaryDirectory(prefix=f"chatecnu-refresh-{profile_name}-") as tmpdir:
|
|
108
|
+
tmp = Path(tmpdir)
|
|
109
|
+
client = _refresh_portal_client_class()(
|
|
110
|
+
base_url=base_url,
|
|
111
|
+
state_file=tmp / "ecnu-session.json",
|
|
112
|
+
cookie_header=None,
|
|
113
|
+
timeout=20,
|
|
114
|
+
token_store=None,
|
|
115
|
+
token_profile=profile_name,
|
|
116
|
+
)
|
|
117
|
+
result = client.login_auto(
|
|
118
|
+
values["ECNU_USERNAME"],
|
|
119
|
+
values["ECNU_PASSWORD"],
|
|
120
|
+
sms_code=None,
|
|
121
|
+
rounds=3,
|
|
122
|
+
topk=5,
|
|
123
|
+
captcha_path=tmp / "ecnu-login-captcha.png",
|
|
124
|
+
)
|
|
125
|
+
state = dict(getattr(client, "state", {}) or {})
|
|
126
|
+
|
|
127
|
+
if result.get("requires_sms"):
|
|
128
|
+
raise ValueError("ECNU portal refresh requires SMS verification and cannot run non-interactively")
|
|
129
|
+
if not result.get("success"):
|
|
130
|
+
raise ValueError("ECNU portal automatic refresh failed")
|
|
131
|
+
cookies = state.get("cookies")
|
|
132
|
+
if not isinstance(cookies, dict) or not cookies:
|
|
133
|
+
raise ValueError("ECNU portal refresh completed without cookies")
|
|
134
|
+
state.setdefault("base_url", base_url)
|
|
135
|
+
state.setdefault("username", values["ECNU_USERNAME"])
|
|
136
|
+
return TokenRefreshResult(
|
|
137
|
+
values=state,
|
|
138
|
+
token_type=ECNU_PORTAL_TOKEN_TYPE,
|
|
139
|
+
summary=portal_session_summary(state),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def save_portal_session_state(
|
|
144
|
+
state: dict[str, Any],
|
|
145
|
+
*,
|
|
146
|
+
env_profile: str | None = None,
|
|
147
|
+
token_store: TokenStore | None = None,
|
|
148
|
+
home: str | Path | None = None,
|
|
149
|
+
source: str = "refresh",
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
"""Persist opaque ECNU portal runtime state in ChatEnv's token store."""
|
|
152
|
+
|
|
153
|
+
values = dict(state)
|
|
154
|
+
return _store(token_store, home=home).write(
|
|
155
|
+
ECNU_SESSION_SERVICE,
|
|
156
|
+
portal_session_profile(env_profile),
|
|
157
|
+
values=values,
|
|
158
|
+
token_type=ECNU_PORTAL_TOKEN_TYPE,
|
|
159
|
+
summary=portal_session_summary(values),
|
|
160
|
+
source=source,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load_portal_session_state(
|
|
165
|
+
*,
|
|
166
|
+
env_profile: str | None = None,
|
|
167
|
+
token_store: TokenStore | None = None,
|
|
168
|
+
home: str | Path | None = None,
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
"""Load opaque ECNU portal runtime state from ChatEnv's token store."""
|
|
171
|
+
|
|
172
|
+
payload = _store(token_store, home=home).read(ECNU_SESSION_SERVICE, portal_session_profile(env_profile))
|
|
173
|
+
values = payload.get("values") if isinstance(payload, dict) else None
|
|
174
|
+
return dict(values) if isinstance(values, dict) else {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def portal_session_status(
|
|
178
|
+
*,
|
|
179
|
+
env_profile: str | None = None,
|
|
180
|
+
token_store: TokenStore | None = None,
|
|
181
|
+
home: str | Path | None = None,
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
"""Return safe token-store metadata for the ECNU portal session."""
|
|
184
|
+
|
|
185
|
+
status = _store(token_store, home=home).status(ECNU_SESSION_SERVICE, portal_session_profile(env_profile))
|
|
186
|
+
if not status.get("token_file_exists"):
|
|
187
|
+
status["token_type"] = ECNU_PORTAL_TOKEN_TYPE
|
|
188
|
+
return status
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
__all__ = [
|
|
192
|
+
"ECNU_PORTAL_TOKEN_TYPE",
|
|
193
|
+
"ECNU_SESSION_SERVICE",
|
|
194
|
+
"load_portal_session_state",
|
|
195
|
+
"portal_session_profile",
|
|
196
|
+
"portal_session_status",
|
|
197
|
+
"portal_session_summary",
|
|
198
|
+
"refresh_chatenv_token",
|
|
199
|
+
"save_portal_session_state",
|
|
200
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_quickstart_uses_active_ecnu_env_profile() -> None:
|
|
9
|
+
repo = Path(__file__).resolve().parents[1]
|
|
10
|
+
for relative in ("docs/quickstart.md", "docs/quickstart.en.md"):
|
|
11
|
+
text = (repo / relative).read_text(encoding="utf-8")
|
|
12
|
+
assert "chatenv init -t ecnu -I" in text
|
|
13
|
+
assert "chatenv new default -t ecnu -I --yes" not in text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_mkdocs_material_icons_render_without_literal_tokens(tmp_path: Path) -> None:
|
|
17
|
+
site_dir = tmp_path / "site"
|
|
18
|
+
result = subprocess.run(
|
|
19
|
+
[sys.executable, "-m", "mkdocs", "build", "--strict", "--site-dir", str(site_dir)],
|
|
20
|
+
cwd=Path(__file__).resolve().parents[1],
|
|
21
|
+
text=True,
|
|
22
|
+
stdout=subprocess.PIPE,
|
|
23
|
+
stderr=subprocess.STDOUT,
|
|
24
|
+
check=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
assert result.returncode == 0, result.stdout
|
|
28
|
+
checked = {
|
|
29
|
+
"zh_home": site_dir / "index.html",
|
|
30
|
+
"en_home": site_dir / "en" / "index.html",
|
|
31
|
+
}
|
|
32
|
+
for name, page in checked.items():
|
|
33
|
+
html = page.read_text(encoding="utf-8")
|
|
34
|
+
assert ":material-" not in html, name
|
|
35
|
+
assert "twemoji" in html or "md-icon" in html, name
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
from chatenv import EnvStore, TokenRefreshResult, TokenStore
|
|
6
|
+
import chatenv.token_refreshers as chatenv_refreshers
|
|
7
|
+
from chatenv.token_refreshers import refresh_token
|
|
8
|
+
|
|
9
|
+
from chatecnu.cli import main
|
|
10
|
+
from chatecnu.config import ECNUConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_ecnu_config_does_not_persist_portal_cookie_in_stable_env_schema():
|
|
14
|
+
assert not hasattr(ECNUConfig, "ECNU_COOKIE")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_portal_session_state_roundtrips_through_chatenv_token_store(tmp_path):
|
|
18
|
+
from chatecnu.ecnu.session_tokens import (
|
|
19
|
+
ECNU_PORTAL_TOKEN_TYPE,
|
|
20
|
+
load_portal_session_state,
|
|
21
|
+
portal_session_status,
|
|
22
|
+
save_portal_session_state,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
store = TokenStore(tokens_dir=tmp_path / "tokens")
|
|
26
|
+
state = {
|
|
27
|
+
"base_url": "https://login.ecnu.edu.cn:8800",
|
|
28
|
+
"username": "student001",
|
|
29
|
+
"authenticated_at": "2026-08-11T12:34:56",
|
|
30
|
+
"cookies": {"PHPSESSID_8800": "secret-cookie"},
|
|
31
|
+
"login_bootstrap": {"csrf_param": "_csrf-8800", "csrf_token": "secret-csrf"},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
written = save_portal_session_state(
|
|
35
|
+
state,
|
|
36
|
+
env_profile="RexWzh",
|
|
37
|
+
token_store=store,
|
|
38
|
+
source="login",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
assert written["service"] == "ECNU"
|
|
42
|
+
assert written["profile"] == "RexWzh"
|
|
43
|
+
assert written["token_type"] == ECNU_PORTAL_TOKEN_TYPE
|
|
44
|
+
assert written["token_present"] is True
|
|
45
|
+
assert written["summary"] == {
|
|
46
|
+
"base_url": "https://login.ecnu.edu.cn:8800",
|
|
47
|
+
"username": "student001",
|
|
48
|
+
"authenticated_at": "2026-08-11T12:34:56",
|
|
49
|
+
"cookie_count": 1,
|
|
50
|
+
"has_login_bootstrap": True,
|
|
51
|
+
}
|
|
52
|
+
assert "secret-cookie" not in json.dumps(written)
|
|
53
|
+
assert "secret-csrf" not in json.dumps(written)
|
|
54
|
+
|
|
55
|
+
assert load_portal_session_state(env_profile="RexWzh", token_store=store) == state
|
|
56
|
+
status = portal_session_status(env_profile="RexWzh", token_store=store)
|
|
57
|
+
assert status["token_file"].endswith("tokens/ECNU/RexWzh.json")
|
|
58
|
+
assert status["summary"]["cookie_count"] == 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_home_status_reads_profile_token_store_and_redacts_session_values(tmp_path, monkeypatch):
|
|
62
|
+
chatarch_home = tmp_path / "chatarch"
|
|
63
|
+
monkeypatch.setenv("CHATARCH_HOME", str(chatarch_home))
|
|
64
|
+
env_file = chatarch_home / "envs" / "ECNU" / "RexWzh.env"
|
|
65
|
+
env_file.parent.mkdir(parents=True)
|
|
66
|
+
env_file.write_text("ECNU_BASE_URL='https://login.ecnu.edu.cn:8800'\n", encoding="utf-8")
|
|
67
|
+
TokenStore(home=chatarch_home).write(
|
|
68
|
+
"ECNU",
|
|
69
|
+
"RexWzh",
|
|
70
|
+
values={
|
|
71
|
+
"base_url": "https://login.ecnu.edu.cn:8800",
|
|
72
|
+
"username": "student001",
|
|
73
|
+
"authenticated_at": "2026-08-11T12:34:56",
|
|
74
|
+
"cookies": {"PHPSESSID_8800": "secret-cookie"},
|
|
75
|
+
"login_bootstrap": {"csrf_param": "_csrf-8800", "csrf_token": "secret-csrf"},
|
|
76
|
+
},
|
|
77
|
+
token_type="portal_session",
|
|
78
|
+
summary={
|
|
79
|
+
"base_url": "https://login.ecnu.edu.cn:8800",
|
|
80
|
+
"username": "student001",
|
|
81
|
+
"authenticated_at": "2026-08-11T12:34:56",
|
|
82
|
+
"cookie_count": 1,
|
|
83
|
+
"has_login_bootstrap": True,
|
|
84
|
+
},
|
|
85
|
+
source="test",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
result = CliRunner().invoke(main, ["-e", "RexWzh", "home", "status", "--json"])
|
|
89
|
+
|
|
90
|
+
assert result.exit_code == 0, result.output
|
|
91
|
+
data = json.loads(result.output)
|
|
92
|
+
assert data["username"] == "student001"
|
|
93
|
+
assert data["cookies"] == {"PHPSESSID_8800": "***"}
|
|
94
|
+
assert data["token_profile"] == "RexWzh"
|
|
95
|
+
assert data["token_type"] == "portal_session"
|
|
96
|
+
assert data["token_file"].endswith("tokens/ECNU/RexWzh.json")
|
|
97
|
+
assert "secret-cookie" not in result.output
|
|
98
|
+
assert "secret-csrf" not in result.output
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_cookie_header_command_is_disabled_instead_of_printing_raw_cookie(tmp_path):
|
|
102
|
+
state_file = tmp_path / "legacy-session.json"
|
|
103
|
+
state_file.write_text(
|
|
104
|
+
json.dumps({"cookies": {"PHPSESSID_8800": "secret-cookie"}}),
|
|
105
|
+
encoding="utf-8",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
result = CliRunner().invoke(main, ["--state-file", str(state_file), "home", "cookie-header"])
|
|
109
|
+
|
|
110
|
+
assert result.exit_code != 0
|
|
111
|
+
assert "secret-cookie" not in result.output
|
|
112
|
+
assert "disabled" in result.output.lower()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _save_ecnu_profile(env_store: EnvStore, name: str = "RexWzh") -> None:
|
|
116
|
+
env_store.save_profile(
|
|
117
|
+
ECNUConfig,
|
|
118
|
+
name,
|
|
119
|
+
{
|
|
120
|
+
"ECNU_BASE_URL": "https://login.ecnu.edu.cn:8800",
|
|
121
|
+
"ECNU_USERNAME": "student001",
|
|
122
|
+
"ECNU_PASSWORD": "secret-password-fixture",
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _save_active_ecnu_profile(env_store: EnvStore) -> None:
|
|
128
|
+
env_store.save_active(
|
|
129
|
+
ECNUConfig,
|
|
130
|
+
{
|
|
131
|
+
"ECNU_BASE_URL": "https://login.ecnu.edu.cn:8800",
|
|
132
|
+
"ECNU_USERNAME": "active-student",
|
|
133
|
+
"ECNU_PASSWORD": "active-secret-password-fixture",
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_refresh_chatenv_token_uses_matching_stable_profile_without_writing_store(tmp_path, monkeypatch):
|
|
139
|
+
from chatecnu.ecnu import session_tokens
|
|
140
|
+
|
|
141
|
+
home = tmp_path / "chatarch"
|
|
142
|
+
env_store = EnvStore(home / "envs")
|
|
143
|
+
_save_ecnu_profile(env_store)
|
|
144
|
+
captured: dict[str, object] = {}
|
|
145
|
+
|
|
146
|
+
class FakePortalClient:
|
|
147
|
+
def __init__(self, *, base_url, state_file, cookie_header=None, timeout=20, token_store=None, token_profile=None):
|
|
148
|
+
captured.update(
|
|
149
|
+
{
|
|
150
|
+
"base_url": base_url,
|
|
151
|
+
"state_file": str(state_file),
|
|
152
|
+
"cookie_header": cookie_header,
|
|
153
|
+
"timeout": timeout,
|
|
154
|
+
"token_store": token_store,
|
|
155
|
+
"token_profile": token_profile,
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
self.state: dict[str, object] = {}
|
|
159
|
+
|
|
160
|
+
def login_auto(self, username, password, sms_code, rounds, topk, captcha_path):
|
|
161
|
+
captured.update(
|
|
162
|
+
{
|
|
163
|
+
"username": username,
|
|
164
|
+
"password": password,
|
|
165
|
+
"sms_code": sms_code,
|
|
166
|
+
"rounds": rounds,
|
|
167
|
+
"topk": topk,
|
|
168
|
+
"captcha_path": str(captcha_path),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
self.state = {
|
|
172
|
+
"base_url": "https://login.ecnu.edu.cn:8800",
|
|
173
|
+
"username": username,
|
|
174
|
+
"authenticated_at": "2026-08-11T12:34:56",
|
|
175
|
+
"cookies": {"PHPSESSID_8800": "secret-cookie"},
|
|
176
|
+
"login_bootstrap": {"csrf_param": "_csrf-8800", "csrf_token": "secret-csrf"},
|
|
177
|
+
}
|
|
178
|
+
return {"success": True, "requires_sms": False}
|
|
179
|
+
|
|
180
|
+
monkeypatch.setattr(session_tokens, "_refresh_portal_client_class", lambda: FakePortalClient)
|
|
181
|
+
|
|
182
|
+
result = session_tokens.refresh_chatenv_token(
|
|
183
|
+
service="ECNU",
|
|
184
|
+
profile="RexWzh",
|
|
185
|
+
home=home,
|
|
186
|
+
env_store=env_store,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
assert result.token_type == "portal_session"
|
|
190
|
+
assert result.values["cookies"] == {"PHPSESSID_8800": "secret-cookie"}
|
|
191
|
+
assert result.summary == {
|
|
192
|
+
"base_url": "https://login.ecnu.edu.cn:8800",
|
|
193
|
+
"username": "student001",
|
|
194
|
+
"authenticated_at": "2026-08-11T12:34:56",
|
|
195
|
+
"cookie_count": 1,
|
|
196
|
+
"has_login_bootstrap": True,
|
|
197
|
+
}
|
|
198
|
+
assert "secret-cookie" not in json.dumps(result.summary)
|
|
199
|
+
assert "secret-csrf" not in json.dumps(result.summary)
|
|
200
|
+
assert captured["token_store"] is None
|
|
201
|
+
assert captured["token_profile"] == "RexWzh"
|
|
202
|
+
assert captured["password"] == "secret-password-fixture"
|
|
203
|
+
assert captured["rounds"] == 3
|
|
204
|
+
assert captured["topk"] == 5
|
|
205
|
+
assert not TokenStore(home=home).token_path("ECNU", "RexWzh").exists()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_refresh_chatenv_token_default_uses_active_env_profile(tmp_path, monkeypatch):
|
|
209
|
+
from chatecnu.ecnu import session_tokens
|
|
210
|
+
|
|
211
|
+
home = tmp_path / "chatarch"
|
|
212
|
+
env_store = EnvStore(home / "envs")
|
|
213
|
+
_save_active_ecnu_profile(env_store)
|
|
214
|
+
captured: dict[str, object] = {}
|
|
215
|
+
|
|
216
|
+
class ActivePortalClient:
|
|
217
|
+
def __init__(self, **kwargs):
|
|
218
|
+
captured.update(kwargs)
|
|
219
|
+
self.state: dict[str, object] = {}
|
|
220
|
+
|
|
221
|
+
def login_auto(self, username, password, sms_code, rounds, topk, captcha_path):
|
|
222
|
+
captured.update({"username": username, "password": password})
|
|
223
|
+
self.state = {
|
|
224
|
+
"base_url": "https://login.ecnu.edu.cn:8800",
|
|
225
|
+
"username": username,
|
|
226
|
+
"authenticated_at": "2026-08-11T13:00:00",
|
|
227
|
+
"cookies": {"PHPSESSID_8800": "active-secret-cookie"},
|
|
228
|
+
}
|
|
229
|
+
return {"success": True, "requires_sms": False}
|
|
230
|
+
|
|
231
|
+
monkeypatch.setattr(session_tokens, "_refresh_portal_client_class", lambda: ActivePortalClient)
|
|
232
|
+
|
|
233
|
+
result = session_tokens.refresh_chatenv_token(
|
|
234
|
+
service="ECNU",
|
|
235
|
+
profile="default",
|
|
236
|
+
home=home,
|
|
237
|
+
env_store=env_store,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
assert result.token_type == "portal_session"
|
|
241
|
+
assert result.summary["username"] == "active-student"
|
|
242
|
+
assert captured["token_profile"] == "default"
|
|
243
|
+
assert captured["username"] == "active-student"
|
|
244
|
+
assert captured["password"] == "active-secret-password-fixture"
|
|
245
|
+
assert not TokenStore(home=home).token_path("ECNU", "default").exists()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_refresh_chatenv_token_fails_without_matching_stable_profile(tmp_path):
|
|
249
|
+
from chatecnu.ecnu.session_tokens import refresh_chatenv_token
|
|
250
|
+
|
|
251
|
+
home = tmp_path / "chatarch"
|
|
252
|
+
env_store = EnvStore(home / "envs")
|
|
253
|
+
|
|
254
|
+
with pytest.raises(ValueError, match="profile not found"):
|
|
255
|
+
refresh_chatenv_token(service="ECNU", profile="RexWzh", home=home, env_store=env_store)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_refresh_chatenv_token_fails_closed_when_auto_login_requires_sms(tmp_path, monkeypatch):
|
|
259
|
+
from chatecnu.ecnu import session_tokens
|
|
260
|
+
|
|
261
|
+
home = tmp_path / "chatarch"
|
|
262
|
+
env_store = EnvStore(home / "envs")
|
|
263
|
+
_save_ecnu_profile(env_store)
|
|
264
|
+
|
|
265
|
+
class SmsPortalClient:
|
|
266
|
+
def __init__(self, **kwargs):
|
|
267
|
+
self.state = {"cookies": {"PHPSESSID_8800": "secret-cookie"}}
|
|
268
|
+
|
|
269
|
+
def login_auto(self, username, password, sms_code, rounds, topk, captcha_path):
|
|
270
|
+
return {"success": False, "requires_sms": True, "message": "SMS verification is required"}
|
|
271
|
+
|
|
272
|
+
monkeypatch.setattr(session_tokens, "_refresh_portal_client_class", lambda: SmsPortalClient)
|
|
273
|
+
|
|
274
|
+
with pytest.raises(ValueError, match="requires SMS") as exc_info:
|
|
275
|
+
session_tokens.refresh_chatenv_token(service="ECNU", profile="RexWzh", home=home, env_store=env_store)
|
|
276
|
+
assert "secret-cookie" not in str(exc_info.value)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_chat_env_refresh_writes_ecnu_provider_result(tmp_path, monkeypatch):
|
|
280
|
+
home = tmp_path / "chatarch"
|
|
281
|
+
env_store = EnvStore(home / "envs")
|
|
282
|
+
_save_ecnu_profile(env_store)
|
|
283
|
+
|
|
284
|
+
def fake_provider(**kwargs):
|
|
285
|
+
return TokenRefreshResult(
|
|
286
|
+
values={
|
|
287
|
+
"base_url": "https://login.ecnu.edu.cn:8800",
|
|
288
|
+
"username": "student001",
|
|
289
|
+
"authenticated_at": "2026-08-11T12:34:56",
|
|
290
|
+
"cookies": {"PHPSESSID_8800": "secret-cookie"},
|
|
291
|
+
"login_bootstrap": {"csrf_token": "secret-csrf"},
|
|
292
|
+
},
|
|
293
|
+
token_type="portal_session",
|
|
294
|
+
summary={
|
|
295
|
+
"base_url": "https://login.ecnu.edu.cn:8800",
|
|
296
|
+
"username": "student001",
|
|
297
|
+
"authenticated_at": "2026-08-11T12:34:56",
|
|
298
|
+
"cookie_count": 1,
|
|
299
|
+
"has_login_bootstrap": True,
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
chatenv_refreshers.clear_token_refreshers()
|
|
304
|
+
monkeypatch.setitem(chatenv_refreshers._token_refreshers, "ecnu", fake_provider)
|
|
305
|
+
monkeypatch.setattr(chatenv_refreshers, "_loaded", True)
|
|
306
|
+
|
|
307
|
+
status = refresh_token("ECNU", "RexWzh", home=home, env_store=env_store)
|
|
308
|
+
|
|
309
|
+
assert status["service"] == "ECNU"
|
|
310
|
+
assert status["profile"] == "RexWzh"
|
|
311
|
+
assert status["token_type"] == "portal_session"
|
|
312
|
+
assert status["token_present"] is True
|
|
313
|
+
assert status["source"] == "refresh"
|
|
314
|
+
assert status["summary"]["cookie_count"] == 1
|
|
315
|
+
dumped = json.dumps(status)
|
|
316
|
+
assert "secret-cookie" not in dumped
|
|
317
|
+
assert "secret-csrf" not in dumped
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from chatecnu import __version__
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_version_present():
|
|
5
|
+
assert __version__ == "0.2.4"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_cli_version_option():
|
|
9
|
+
from click.testing import CliRunner
|
|
10
|
+
from chatecnu.cli import main
|
|
11
|
+
|
|
12
|
+
result = CliRunner().invoke(main, ["--version"])
|
|
13
|
+
|
|
14
|
+
assert result.exit_code == 0
|
|
15
|
+
assert "0.2.4" in result.output
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_chatenv_dependency_and_refresh_provider_entry_point_declared():
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
pyproject = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(
|
|
22
|
+
encoding="utf-8"
|
|
23
|
+
)
|
|
24
|
+
assert '"chatenv>=0.2.7,<0.3.0"' in pyproject
|
|
25
|
+
assert '[project.entry-points."chatenv.token_refreshers"]' in pyproject
|
|
26
|
+
assert 'ECNU = "chatecnu.ecnu.session_tokens:refresh_chatenv_token"' in pyproject
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
from chatecnu import __version__
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def test_version_present():
|
|
5
|
-
assert __version__ == "0.2.2"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_cli_version_option():
|
|
9
|
-
from click.testing import CliRunner
|
|
10
|
-
from chatecnu.cli import main
|
|
11
|
-
|
|
12
|
-
result = CliRunner().invoke(main, ["--version"])
|
|
13
|
-
|
|
14
|
-
assert result.exit_code == 0
|
|
15
|
-
assert "0.2.2" in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|