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.
Files changed (28) hide show
  1. {chatecnu-0.2.2/src/ChatECNU.egg-info → chatecnu-0.2.4}/PKG-INFO +4 -2
  2. {chatecnu-0.2.2 → chatecnu-0.2.4}/README.md +2 -0
  3. {chatecnu-0.2.2 → chatecnu-0.2.4}/pyproject.toml +4 -1
  4. {chatecnu-0.2.2 → chatecnu-0.2.4/src/ChatECNU.egg-info}/PKG-INFO +4 -2
  5. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/ChatECNU.egg-info/SOURCES.txt +3 -0
  6. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/ChatECNU.egg-info/entry_points.txt +3 -0
  7. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/ChatECNU.egg-info/requires.txt +1 -1
  8. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/__init__.py +1 -1
  9. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/config.py +0 -2
  10. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/ecnu/cli.py +31 -7
  11. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/ecnu/portal.py +58 -0
  12. chatecnu-0.2.4/src/chatecnu/ecnu/session_tokens.py +200 -0
  13. chatecnu-0.2.4/tests/test_docs_rendering.py +35 -0
  14. chatecnu-0.2.4/tests/test_token_store_sessions.py +317 -0
  15. chatecnu-0.2.4/tests/test_version.py +26 -0
  16. chatecnu-0.2.2/tests/test_version.py +0 -15
  17. {chatecnu-0.2.2 → chatecnu-0.2.4}/LICENSE +0 -0
  18. {chatecnu-0.2.2 → chatecnu-0.2.4}/MANIFEST.in +0 -0
  19. {chatecnu-0.2.2 → chatecnu-0.2.4}/setup.cfg +0 -0
  20. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/ChatECNU.egg-info/dependency_links.txt +0 -0
  21. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/ChatECNU.egg-info/top_level.txt +0 -0
  22. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/bin/linux-x86_64/auth_client +0 -0
  23. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/cli.py +0 -0
  24. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/ecnu/__init__.py +0 -0
  25. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/ecnu/captcha.py +0 -0
  26. {chatecnu-0.2.2 → chatecnu-0.2.4}/src/chatecnu/network_auth.py +0 -0
  27. {chatecnu-0.2.2 → chatecnu-0.2.4}/tests/test_cli.py +0 -0
  28. {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.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.4
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.4,<0.3.0",
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.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.4
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
@@ -1,6 +1,9 @@
1
1
  [chatenv.configs]
2
2
  ecnu = chatecnu.config
3
3
 
4
+ [chatenv.token_refreshers]
5
+ ECNU = chatecnu.ecnu.session_tokens:refresh_chatenv_token
6
+
4
7
  [console_scripts]
5
8
  chatecnu = chatecnu.cli:main
6
9
  ecnu = chatecnu.cli:main
@@ -1,6 +1,6 @@
1
1
  click>=8.0
2
2
  chatstyle<0.2.0,>=0.1.0
3
- chatenv<0.3.0,>=0.2.4
3
+ chatenv<0.3.0,>=0.2.7
4
4
  ChatNet<0.3.0,>=0.2.0
5
5
  requests>=2.0
6
6
  pycryptodome>=3.0
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.2"
5
+ __version__ = "0.2.4"
@@ -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="Session state JSON file. Defaults to $CHATARCH_HOME/cache/chatecnu-session.json.",
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="Existing authenticated Cookie header. Defaults to chatenv ECNU_COOKIE.",
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 default_state_file(),
230
- "cookie": cookie or ECNUConfig.ECNU_COOKIE.value,
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.echo(make_client(ctx).cookie_header())
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=config["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
- return redact_state(make_client(ctx).state)
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