pro-sejong-auth 1.2.16__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.
@@ -0,0 +1,86 @@
1
+ /.idea
2
+ /.claude/settings.local.json
3
+ /.report
4
+
5
+ # Gradle
6
+ .gradle/
7
+ build/
8
+ !gradle/wrapper/gradle-wrapper.jar
9
+ !gradle/wrapper/gradle-wrapper.properties
10
+ gradle.properties
11
+
12
+ # Compiled Files
13
+ *.class
14
+ *.jar
15
+ !gradle/wrapper/gradle-wrapper.jar
16
+
17
+ # IDE - IntelliJ IDEA
18
+ *.iws
19
+ *.iml
20
+ *.ipr
21
+ out/
22
+ !**/src/main/**/out/
23
+ !**/src/test/**/out/
24
+
25
+ # IDE - Eclipse
26
+ .classpath
27
+ .project
28
+ .settings/
29
+ bin/
30
+ !**/src/main/**/bin/
31
+ !**/src/test/**/bin/
32
+
33
+ # IDE - VS Code
34
+ .vscode/
35
+
36
+ # IDE - NetBeans
37
+ /nbproject/private/
38
+ /nbbuild/
39
+ /dist/
40
+ /nbdist/
41
+ /.nbgradle/
42
+
43
+ # OS Files
44
+ .DS_Store
45
+ .DS_Store?
46
+ ._*
47
+ .Spotlight-V100
48
+ .Trashes
49
+ ehthumbs.db
50
+ Thumbs.db
51
+ .AppleDouble
52
+ .LSOverride
53
+
54
+ # Logs
55
+ *.log
56
+ *.log.*
57
+
58
+ # Local Configuration
59
+ *.local
60
+
61
+ # Test Reports
62
+ .report/
63
+
64
+ # Test Configuration (민감 정보 포함)
65
+ **/src/test/resources/application-dev.yml
66
+
67
+ # Python (기존 항목 유지)
68
+ __pycache__/
69
+ *.py[cod]
70
+ *$py.class
71
+ *.so
72
+
73
+ # Python
74
+ python/.venv/
75
+ python/dist/
76
+ .pytest_cache/
77
+ *.egg-info/
78
+
79
+ # Node
80
+ node/node_modules/
81
+ node/dist/
82
+ *.tgz
83
+
84
+ # SDD workspace
85
+ .superpowers/
86
+ node/src/version.ts
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Suh Saechan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.5
2
+ Name: pro-sejong-auth
3
+ Version: 1.2.16
4
+ Summary: Sejong University login API for Python: verify students with portal SSO in one line (세종대 로그인 API)
5
+ Project-URL: Homepage, https://cassiiopeia.github.io/pro-sejong-auth/
6
+ Project-URL: Source, https://github.com/Cassiiopeia/pro-sejong-auth
7
+ Project-URL: Issues, https://github.com/Cassiiopeia/pro-sejong-auth/issues
8
+ Author: Suh Saechan
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: authentication,sejong,sejong-university,sso,student-verification,세종대
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: Korean
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # pro-sejong-auth (Python)
21
+
22
+ **Sejong University login API for Python** · 세종대 로그인 API
23
+
24
+ Verify a Sejong University student with a student ID and password through the portal SSO, and get their major, year, classic-reading certification and contact info. No dependencies beyond the standard library.
25
+
26
+ ```bash
27
+ pip install pro-sejong-auth
28
+ ```
29
+
30
+ ```python
31
+ from sejong_auth import authenticate, SejongAuthError, ErrorCode
32
+
33
+ try:
34
+ result = authenticate("21011234", "password")
35
+ print(result.name, result.major, result.grade)
36
+ except SejongAuthError as error:
37
+ if error.code == ErrorCode.AUTHENTICATION_FAILED:
38
+ print("Wrong student ID or password")
39
+ ```
40
+
41
+ | Function | Returns |
42
+ |---|---|
43
+ | `authenticate(id, pw)` | student info + classic reading + contact (contact is `None` if that lookup fails) |
44
+ | `authenticate_dhc(id, pw, include_raw=False)` | student info + classic reading |
45
+ | `authenticate_sis(id, pw, include_raw=False)` | student info + email, phone, English name |
46
+
47
+ Settings: `SejongAuthClient(timeout_seconds=10, max_retry=3, ssl_verification=True)`.
48
+
49
+ Requires Python 3.9+. Not an official Sejong University product.
50
+
51
+ Docs, live demo and Java / Node versions: https://cassiiopeia.github.io/pro-sejong-auth/
@@ -0,0 +1,32 @@
1
+ # pro-sejong-auth (Python)
2
+
3
+ **Sejong University login API for Python** · 세종대 로그인 API
4
+
5
+ Verify a Sejong University student with a student ID and password through the portal SSO, and get their major, year, classic-reading certification and contact info. No dependencies beyond the standard library.
6
+
7
+ ```bash
8
+ pip install pro-sejong-auth
9
+ ```
10
+
11
+ ```python
12
+ from sejong_auth import authenticate, SejongAuthError, ErrorCode
13
+
14
+ try:
15
+ result = authenticate("21011234", "password")
16
+ print(result.name, result.major, result.grade)
17
+ except SejongAuthError as error:
18
+ if error.code == ErrorCode.AUTHENTICATION_FAILED:
19
+ print("Wrong student ID or password")
20
+ ```
21
+
22
+ | Function | Returns |
23
+ |---|---|
24
+ | `authenticate(id, pw)` | student info + classic reading + contact (contact is `None` if that lookup fails) |
25
+ | `authenticate_dhc(id, pw, include_raw=False)` | student info + classic reading |
26
+ | `authenticate_sis(id, pw, include_raw=False)` | student info + email, phone, English name |
27
+
28
+ Settings: `SejongAuthClient(timeout_seconds=10, max_retry=3, ssl_verification=True)`.
29
+
30
+ Requires Python 3.9+. Not an official Sejong University product.
31
+
32
+ Docs, live demo and Java / Node versions: https://cassiiopeia.github.io/pro-sejong-auth/
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pro-sejong-auth"
7
+ version = "1.2.16"
8
+ description = "Sejong University login API for Python: verify students with portal SSO in one line (세종대 로그인 API)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Suh Saechan" }]
14
+ keywords = ["sejong", "sejong-university", "세종대", "authentication", "sso", "student-verification"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Intended Audience :: Developers",
19
+ "Natural Language :: Korean",
20
+ "Topic :: Internet :: WWW/HTTP",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://cassiiopeia.github.io/pro-sejong-auth/"
26
+ Source = "https://github.com/Cassiiopeia/pro-sejong-auth"
27
+ Issues = "https://github.com/Cassiiopeia/pro-sejong-auth/issues"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/sejong_auth"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+ pythonpath = ["src", "tests"]
@@ -0,0 +1,52 @@
1
+ """세종대 로그인 API: 학번·비밀번호로 세종대학교 포털(SSO) 인증
2
+
3
+ from sejong_auth import authenticate
4
+ result = authenticate("21011234", "password")
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ # 버전 단일 원본: pyproject.toml (projectops 가 version.yml 과 함께 갱신)
11
+ __version__ = version("pro-sejong-auth")
12
+ except PackageNotFoundError: # 설치 없이 소스로 실행 (테스트)
13
+ __version__ = "0.0.0-dev"
14
+
15
+ from .client import SejongAuthClient # noqa: E402
16
+ from .errors import ErrorCode, SejongAuthError # noqa: E402
17
+ from .models import ( # noqa: E402
18
+ ClassicCertification,
19
+ ClassicContestRecord,
20
+ ClassicExamRecord,
21
+ ClassicSubjectRecord,
22
+ SejongAuthResult,
23
+ SejongClassicReading,
24
+ SejongDhcAuthResult,
25
+ SejongSisAuthResult,
26
+ )
27
+
28
+ _default_client = SejongAuthClient()
29
+
30
+
31
+ def authenticate(student_id: str, password: str) -> SejongAuthResult:
32
+ """통합 인증 (기본 설정: 10초, 재시도 3회, 인증서 검증)"""
33
+ return _default_client.authenticate(student_id, password)
34
+
35
+
36
+ def authenticate_dhc(student_id: str, password: str, include_raw: bool = False) -> SejongDhcAuthResult:
37
+ """DHC 인증: 학생 정보 + 고전독서"""
38
+ return _default_client.authenticate_dhc(student_id, password, include_raw=include_raw)
39
+
40
+
41
+ def authenticate_sis(student_id: str, password: str, include_raw: bool = False) -> SejongSisAuthResult:
42
+ """SIS 인증: 학생 정보 + 연락처"""
43
+ return _default_client.authenticate_sis(student_id, password, include_raw=include_raw)
44
+
45
+
46
+ __all__ = [
47
+ "authenticate", "authenticate_dhc", "authenticate_sis", "SejongAuthClient",
48
+ "SejongAuthError", "ErrorCode",
49
+ "SejongAuthResult", "SejongDhcAuthResult", "SejongSisAuthResult", "SejongClassicReading",
50
+ "ClassicCertification", "ClassicExamRecord", "ClassicSubjectRecord", "ClassicContestRecord",
51
+ "__version__",
52
+ ]
@@ -0,0 +1,171 @@
1
+ """세종대 포털 인증 클라이언트: 입력 검증 > 포털 로그인(재시도) > SSO > 데이터 조회 > 파싱 (흐름: spec/PORTAL_FLOW.md)"""
2
+
3
+ import base64
4
+ import json
5
+ import ssl
6
+ import urllib.parse
7
+ from datetime import datetime
8
+ from typing import Callable, Optional
9
+
10
+ from . import __version__
11
+ from .errors import ErrorCode, SejongAuthError
12
+ from .models import SejongAuthResult, SejongDhcAuthResult, SejongSisAuthResult
13
+ from .parsers import ensure_sis_logged_in, parse_classic_reading, parse_sis_user_info, parse_student_info
14
+ from .transport import HttpResponse, UrllibSession, create_ssl_context
15
+
16
+ PORTAL_LOGIN_URL = "https://portal.sejong.ac.kr/jsp/login/login_action.jsp"
17
+ PORTAL_REFERER = "https://portal.sejong.ac.kr"
18
+ # 포털이 요구하는 초기 쿠키 (없으면 로그인 폼 거부)
19
+ PORTAL_DEFAULT_COOKIE = "chknos=false"
20
+
21
+ # 로그인 후 SSO 대상 (rtUrl)
22
+ CLASSIC_RETURN_URL = "classic.sejong.ac.kr"
23
+ SIS_RETURN_URL = "sjpt.sejong.ac.kr/main/view/Login/doSsoLogin.do?p="
24
+
25
+ CLASSIC_SSO_URL = "https://classic.sejong.ac.kr/_custom/sejong/sso/sso-return.jsp?returnUrl=https://classic.sejong.ac.kr/classic/index.do"
26
+ CLASSIC_STATUS_URL = "https://classic.sejong.ac.kr/classic/reading/status.do"
27
+ SIS_SSO_URL = "https://sjpt.sejong.ac.kr/main/view/Login/doSsoLogin.do?p="
28
+ SIS_USER_INFO_URL = "https://sjpt.sejong.ac.kr/main/sys/UserInfo/initUserInfo.do"
29
+ SIS_ORIGIN = "https://sjpt.sejong.ac.kr"
30
+ # 브라우저 초기 호출과 같은 빈 addParam (JSON > URL 폼 인코딩 > Base64)
31
+ SIS_EMPTY_ADD_PARAM = base64.b64encode(
32
+ urllib.parse.quote_plus('{"_runIntgUsrNo":"","_runPgLoginDt":"","_runningSejong":""}').encode("utf-8")
33
+ ).decode("ascii")
34
+
35
+ USER_AGENT = f"pro-sejong-auth-python/{__version__}"
36
+ HTTP_UNAUTHORIZED = 401
37
+
38
+
39
+ class SejongAuthClient:
40
+ """세종대 포털 인증 (설정: 제한 시간 초, 로그인 재시도 횟수, 인증서 검증)"""
41
+
42
+ def __init__(self, timeout_seconds: float = 10, max_retry: int = 3, ssl_verification: bool = True,
43
+ _session_factory: Optional[Callable[[], object]] = None):
44
+ self.timeout_seconds = timeout_seconds
45
+ self.max_retry = max_retry
46
+ self.ssl_verification = ssl_verification
47
+ self._session_factory = _session_factory or self._create_session
48
+
49
+ def authenticate(self, student_id: str, password: str) -> SejongAuthResult:
50
+ """통합 인증: DHC 학생 정보·고전독서 + SIS 연락처 (SIS 실패는 무시하고 None)"""
51
+ self._validate_credentials(student_id, password)
52
+ dhc_result = self._fetch_dhc(student_id, password, should_include_raw_html=False)
53
+
54
+ email = phone_number = english_name = None
55
+ try:
56
+ sis_user_info = parse_sis_user_info(self._fetch_sis_json(student_id, password))
57
+ email, phone_number, english_name = sis_user_info.email, sis_user_info.phone_number, sis_user_info.english_name
58
+ except Exception: # SIS 는 연락처 보조 조회 > 어떤 실패든 통합 결과를 막지 않음 (Java 와 동일)
59
+ pass
60
+
61
+ return SejongAuthResult(
62
+ success=True, student_id=dhc_result.student_id, name=dhc_result.name, major=dhc_result.major,
63
+ grade=dhc_result.grade, status=dhc_result.status, classic_reading=dhc_result.classic_reading,
64
+ email=email, phone_number=phone_number, english_name=english_name, authenticated_at=datetime.now(),
65
+ )
66
+
67
+ def authenticate_dhc(self, student_id: str, password: str, include_raw: bool = False) -> SejongDhcAuthResult:
68
+ """DHC 인증: 학생 정보 + 고전독서 (include_raw=True 면 원본 HTML 포함)"""
69
+ self._validate_credentials(student_id, password)
70
+ return self._fetch_dhc(student_id, password, should_include_raw_html=include_raw)
71
+
72
+ def authenticate_sis(self, student_id: str, password: str, include_raw: bool = False) -> SejongSisAuthResult:
73
+ """SIS 인증: 학생 정보 + 연락처 (학년·상태 빈 문자열, include_raw=True 면 원본 JSON 포함)"""
74
+ self._validate_credentials(student_id, password)
75
+ json_text = self._fetch_sis_json(student_id, password)
76
+ user_info = parse_sis_user_info(json_text)
77
+ return SejongSisAuthResult(
78
+ success=True, student_id=user_info.student_id, name=user_info.name, major=user_info.major,
79
+ grade="", status="", email=user_info.email, phone_number=user_info.phone_number,
80
+ english_name=user_info.english_name, authenticated_at=datetime.now(),
81
+ raw_json=json_text if include_raw else None,
82
+ )
83
+
84
+ def _fetch_dhc(self, student_id: str, password: str, should_include_raw_html: bool) -> SejongDhcAuthResult:
85
+ """포털 로그인 > classic SSO > 고전독서 페이지 > 학생 정보·고전독서 파싱"""
86
+ session = self._session_factory()
87
+ self._login(session, student_id, password, CLASSIC_RETURN_URL)
88
+ self._expect_session_step(self._call(session, "GET", CLASSIC_SSO_URL), "SSO redirect failed")
89
+ html = self._expect_data(self._call(session, "GET", CLASSIC_STATUS_URL), "Classic reading page request failed")
90
+ student_info = parse_student_info(html)
91
+ return SejongDhcAuthResult(
92
+ success=True, student_id=student_info.student_id, name=student_info.name, major=student_info.major,
93
+ grade=student_info.grade, status=student_info.status, classic_reading=parse_classic_reading(html),
94
+ authenticated_at=datetime.now(), raw_html=html if should_include_raw_html else None,
95
+ )
96
+
97
+ def _fetch_sis_json(self, student_id: str, password: str) -> str:
98
+ """포털 로그인 > sjpt SSO > initUserInfo JSON"""
99
+ session = self._session_factory()
100
+ self._login(session, student_id, password, SIS_RETURN_URL)
101
+ self._expect_session_step(
102
+ self._call(session, "GET", SIS_SSO_URL, headers={"Referer": PORTAL_REFERER}), "SIS SSO page request failed")
103
+ response = self._call(session, "POST", f"{SIS_USER_INFO_URL}?addParam={SIS_EMPTY_ADD_PARAM}", data=b"{}", headers={
104
+ "Accept": "application/json",
105
+ "Content-Type": "application/json; charset=UTF-8",
106
+ "Referer": SIS_SSO_URL,
107
+ "Origin": SIS_ORIGIN,
108
+ })
109
+ json_text = self._expect_data(response, "initUserInfo API request failed")
110
+ # 틀린 비밀번호는 401 이 아니라 200 + _SUBMIT_ERROR_(LOGOUT)
111
+ ensure_sis_logged_in(json_text)
112
+ return json_text
113
+
114
+ def _login(self, session, student_id: str, password: str, return_url: str) -> None:
115
+ """포털 로그인 (실패 응답·시간 초과 시 max_retry 회까지, 성공 여부는 이후 401 로 판별)"""
116
+ form = urllib.parse.urlencode({"mainLogin": "N", "rtUrl": return_url, "id": student_id, "password": password})
117
+ headers = {"Content-Type": "application/x-www-form-urlencoded", "Referer": PORTAL_REFERER, "Cookie": PORTAL_DEFAULT_COOKIE}
118
+ for attempt in range(1, self.max_retry + 1):
119
+ try:
120
+ if self._call_raw(session, "POST", PORTAL_LOGIN_URL, data=form.encode("utf-8"), headers=headers).is_successful:
121
+ return
122
+ except TimeoutError as error:
123
+ if attempt == self.max_retry:
124
+ raise SejongAuthError(ErrorCode.CONNECTION_TIMEOUT) from error
125
+ raise SejongAuthError(ErrorCode.CONNECTION_FAILED, f"Max retry count exceeded : {self.max_retry}")
126
+
127
+ def _call(self, session, method: str, url: str, **kwargs) -> HttpResponse:
128
+ """로그인 외 요청 (시간 초과·네트워크 오류 > CONNECTION_FAILED)"""
129
+ try:
130
+ return self._call_raw(session, method, url, **kwargs)
131
+ except TimeoutError as error:
132
+ raise SejongAuthError(ErrorCode.CONNECTION_FAILED) from error
133
+
134
+ @staticmethod
135
+ def _call_raw(session, method: str, url: str, **kwargs) -> HttpResponse:
136
+ try:
137
+ return session.request(method, url, **kwargs)
138
+ except (TimeoutError, SejongAuthError):
139
+ raise
140
+ except ssl.SSLError as error:
141
+ raise SejongAuthError(ErrorCode.CONNECTION_FAILED, "TLS handshake with the portal failed") from error
142
+ except OSError as error:
143
+ raise SejongAuthError(ErrorCode.CONNECTION_FAILED) from error
144
+
145
+ @staticmethod
146
+ def _expect_session_step(response: HttpResponse, failure_message: str) -> None:
147
+ if not response.is_successful:
148
+ raise SejongAuthError(ErrorCode.SESSION_ERROR, f"{failure_message} : {response.status}")
149
+
150
+ @staticmethod
151
+ def _expect_data(response: HttpResponse, failure_message: str) -> str:
152
+ """데이터 응답 판정: 401 > AUTHENTICATION_FAILED, 그 외 200 아님·빈 본문 > DATA_FETCH_FAILED"""
153
+ if response.status == HTTP_UNAUTHORIZED:
154
+ raise SejongAuthError(ErrorCode.AUTHENTICATION_FAILED)
155
+ if response.status != 200 or not response.body:
156
+ raise SejongAuthError(ErrorCode.DATA_FETCH_FAILED, f"{failure_message} : {response.status}")
157
+ return response.text()
158
+
159
+ @staticmethod
160
+ def _validate_credentials(student_id: Optional[str], password: Optional[str]) -> None:
161
+ if not student_id or not str(student_id).strip():
162
+ raise SejongAuthError(ErrorCode.INVALID_INPUT, "Student ID is empty")
163
+ if not password or not str(password).strip():
164
+ raise SejongAuthError(ErrorCode.INVALID_INPUT, "Password is empty")
165
+
166
+ def _create_session(self) -> UrllibSession:
167
+ try:
168
+ ssl_context = create_ssl_context(self.ssl_verification)
169
+ except ssl.SSLError as error:
170
+ raise SejongAuthError(ErrorCode.SSL_CONFIGURATION_ERROR) from error
171
+ return UrllibSession(ssl_context, self.timeout_seconds, USER_AGENT)
@@ -0,0 +1,38 @@
1
+ """에러 코드·예외: Java SejongAuthErrorCode 와 같은 이름 (spec/RESULT_SCHEMA.md)"""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class ErrorCode(str, Enum):
7
+ """인증 실패 사유 코드 (값 = Java enum 이름)"""
8
+
9
+ INVALID_INPUT = "INVALID_INPUT"
10
+ AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED"
11
+ CONNECTION_FAILED = "CONNECTION_FAILED"
12
+ CONNECTION_TIMEOUT = "CONNECTION_TIMEOUT"
13
+ SESSION_ERROR = "SESSION_ERROR"
14
+ DATA_FETCH_FAILED = "DATA_FETCH_FAILED"
15
+ PARSE_ERROR = "PARSE_ERROR"
16
+ SSL_CONFIGURATION_ERROR = "SSL_CONFIGURATION_ERROR"
17
+
18
+
19
+ # 코드별 기본 메시지 (Java 메시지와 같은 영어 문구)
20
+ DEFAULT_MESSAGE_BY_CODE = {
21
+ ErrorCode.INVALID_INPUT: "Invalid input.",
22
+ ErrorCode.AUTHENTICATION_FAILED: "Sejong University portal authentication failed. Check the student ID and password.",
23
+ ErrorCode.CONNECTION_FAILED: "Cannot connect to the Sejong University portal.",
24
+ ErrorCode.CONNECTION_TIMEOUT: "Connection to the Sejong University portal timed out.",
25
+ ErrorCode.SESSION_ERROR: "An error occurred while handling the portal session.",
26
+ ErrorCode.DATA_FETCH_FAILED: "Failed to fetch student information.",
27
+ ErrorCode.PARSE_ERROR: "Failed to parse student information.",
28
+ ErrorCode.SSL_CONFIGURATION_ERROR: "Failed to configure SSL.",
29
+ }
30
+
31
+
32
+ class SejongAuthError(Exception):
33
+ """세종 인증 실패 (code 로 분기, 메시지에 비밀번호·개인정보 없음)"""
34
+
35
+ def __init__(self, code: ErrorCode, message: str = ""):
36
+ self.code = code
37
+ self.message = message or DEFAULT_MESSAGE_BY_CODE[code]
38
+ super().__init__(f"{code.value}: {self.message}")
@@ -0,0 +1,131 @@
1
+ """결과 모델: Java 결과 클래스와 같은 필드 (snake_case, spec/RESULT_SCHEMA.md)"""
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from datetime import datetime
5
+ from typing import List, Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ClassicCertification:
10
+ area: str
11
+ required_count: str
12
+ certified_count: str
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ClassicExamRecord:
17
+ semester: str
18
+ area: str
19
+ book_title: str
20
+ exam_date: str
21
+ score: str
22
+ pass_status: str
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ClassicSubjectRecord:
27
+ semester: str
28
+ subject_name: str
29
+ area: str
30
+ book_title: str
31
+ completion: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ClassicContestRecord:
36
+ semester: str
37
+ contest_name: str
38
+ area: str
39
+ book_title: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SejongClassicReading:
44
+ """고전독서 인증 현황: 영역별 인증, 시험, 과목 대체, 대회, 교과연계"""
45
+
46
+ certifications: List[ClassicCertification] = field(default_factory=list)
47
+ exam_records: List[ClassicExamRecord] = field(default_factory=list)
48
+ subject_substitutions: List[ClassicSubjectRecord] = field(default_factory=list)
49
+ contest_records: List[ClassicContestRecord] = field(default_factory=list)
50
+ curriculum_records: List[ClassicSubjectRecord] = field(default_factory=list)
51
+
52
+ def to_dict(self) -> dict:
53
+ return asdict(self)
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class SejongStudentInfo:
58
+ """DHC 사용자 정보 표: 학과, 학번, 이름, 학년, 학적 상태 (없으면 빈 문자열)"""
59
+
60
+ major: str
61
+ student_id: str
62
+ name: str
63
+ grade: str
64
+ status: str
65
+
66
+ def to_dict(self) -> dict:
67
+ return asdict(self)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class SejongSisUserInfo:
72
+ """SIS initUserInfo: 학번, 이름, 학과, 이메일, 전화번호, 영문 이름"""
73
+
74
+ student_id: str
75
+ name: str
76
+ major: str
77
+ email: str
78
+ phone_number: str
79
+ english_name: str
80
+
81
+ def to_dict(self) -> dict:
82
+ return asdict(self)
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class SejongAuthResult:
87
+ """통합 인증 결과 (연락처는 SIS 실패 시 None)"""
88
+
89
+ success: bool
90
+ student_id: str
91
+ name: str
92
+ major: str
93
+ grade: str
94
+ status: str
95
+ classic_reading: SejongClassicReading
96
+ email: Optional[str]
97
+ phone_number: Optional[str]
98
+ english_name: Optional[str]
99
+ authenticated_at: datetime
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class SejongDhcAuthResult:
104
+ """DHC 인증 결과 (raw_html 은 include_raw=True 일 때만)"""
105
+
106
+ success: bool
107
+ student_id: str
108
+ name: str
109
+ major: str
110
+ grade: str
111
+ status: str
112
+ classic_reading: SejongClassicReading
113
+ authenticated_at: datetime
114
+ raw_html: Optional[str] = None
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class SejongSisAuthResult:
119
+ """SIS 인증 결과 (학년·상태는 SIS 미제공 > 빈 문자열, raw_json 은 include_raw=True 일 때만)"""
120
+
121
+ success: bool
122
+ student_id: str
123
+ name: str
124
+ major: str
125
+ grade: str
126
+ status: str
127
+ email: str
128
+ phone_number: str
129
+ english_name: str
130
+ authenticated_at: datetime
131
+ raw_json: Optional[str] = None
@@ -0,0 +1,227 @@
1
+ """포털 응답 파싱: DHC 고전독서 페이지 HTML, SIS initUserInfo JSON (규칙: spec/PORTAL_FLOW.md)"""
2
+
3
+ import json
4
+ import re
5
+ from html.parser import HTMLParser
6
+ from typing import Dict, List, Optional
7
+
8
+ from .errors import ErrorCode, SejongAuthError
9
+ from .models import (
10
+ ClassicCertification,
11
+ ClassicContestRecord,
12
+ ClassicExamRecord,
13
+ ClassicSubjectRecord,
14
+ SejongClassicReading,
15
+ SejongSisUserInfo,
16
+ SejongStudentInfo,
17
+ )
18
+
19
+ # 섹션 제목 (포털 화면 문구 그대로, 바꾸면 파싱 실패)
20
+ SECTION_USER_INFO = "사용자 정보"
21
+ SECTION_CERTIFICATIONS = "영역별 인증현황"
22
+ SECTION_EXAMS = "인증 시험 현황"
23
+ SECTION_SUBJECT_SUBSTITUTIONS = "과목 대체 인증 현황"
24
+ SECTION_CONTESTS = "대회 인증 현황"
25
+ SECTION_CURRICULUM = "교과연계 인증 현황"
26
+
27
+ # 사용자 정보 표 라벨
28
+ LABEL_MAJOR = "학과명"
29
+ LABEL_STUDENT_ID = "학번"
30
+ LABEL_NAME = "이름"
31
+ LABEL_GRADE = "학년"
32
+ LABEL_STATUS = "사용자 상태"
33
+
34
+ _WHITESPACE = re.compile(r"\s+")
35
+
36
+
37
+ class _Cell:
38
+ def __init__(self, tag: str):
39
+ self.tag = tag
40
+ self.text_parts: List[str] = []
41
+
42
+ @property
43
+ def text(self) -> str:
44
+ return _WHITESPACE.sub(" ", "".join(self.text_parts)).strip()
45
+
46
+
47
+ class _Section:
48
+ def __init__(self):
49
+ self.title = ""
50
+ self.rows: List[List[_Cell]] = []
51
+
52
+
53
+ class _BoardTableParser(HTMLParser):
54
+ """div.b-con-box 섹션 > h4.b-h4-tit01 제목 + table.b-board-table tbody 행·칸 수집"""
55
+
56
+ def __init__(self):
57
+ super().__init__(convert_charrefs=True)
58
+ self.sections: List[_Section] = []
59
+ self._section: Optional[_Section] = None
60
+ self._section_div_depth = 0
61
+ self._is_in_title = False
62
+ self._is_in_board_table = False
63
+ self._is_in_tbody = False
64
+ self._row: Optional[List[_Cell]] = None
65
+ self._cell: Optional[_Cell] = None
66
+
67
+ def handle_starttag(self, tag, attrs):
68
+ classes = (dict(attrs).get("class") or "").split()
69
+ if tag == "div":
70
+ if self._section is not None:
71
+ self._section_div_depth += 1
72
+ elif "b-con-box" in classes:
73
+ self._section = _Section()
74
+ self._section_div_depth = 1
75
+ return
76
+ if self._section is None:
77
+ return
78
+ if tag == "h4" and "b-h4-tit01" in classes:
79
+ self._is_in_title = True
80
+ elif tag == "table" and "b-board-table" in classes:
81
+ self._is_in_board_table = True
82
+ elif tag == "tbody" and self._is_in_board_table:
83
+ self._is_in_tbody = True
84
+ elif tag == "tr" and self._is_in_tbody:
85
+ self._row = []
86
+ elif tag in ("th", "td") and self._row is not None:
87
+ self._cell = _Cell(tag)
88
+
89
+ def handle_endtag(self, tag):
90
+ if tag == "div" and self._section is not None:
91
+ self._section_div_depth -= 1
92
+ if self._section_div_depth == 0:
93
+ self.sections.append(self._section)
94
+ self._section = None
95
+ elif tag == "h4":
96
+ self._is_in_title = False
97
+ elif tag == "table":
98
+ self._is_in_board_table = False
99
+ self._is_in_tbody = False
100
+ elif tag == "tbody":
101
+ self._is_in_tbody = False
102
+ elif tag == "tr" and self._row is not None:
103
+ self._section.rows.append(self._row)
104
+ self._row = None
105
+ elif tag in ("th", "td") and self._cell is not None:
106
+ self._row.append(self._cell)
107
+ self._cell = None
108
+
109
+ def handle_data(self, data):
110
+ if self._is_in_title and self._section is not None:
111
+ self._section.title += data
112
+ elif self._cell is not None:
113
+ self._cell.text_parts.append(data)
114
+
115
+
116
+ def _read_sections(html: str) -> List[_Section]:
117
+ try:
118
+ parser = _BoardTableParser()
119
+ parser.feed(html)
120
+ parser.close()
121
+ return parser.sections
122
+ except Exception as error: # HTMLParser 내부 오류 > PARSE_ERROR
123
+ raise SejongAuthError(ErrorCode.PARSE_ERROR) from error
124
+
125
+
126
+ def _find_rows(sections: List[_Section], title: str) -> List[List[_Cell]]:
127
+ for section in sections:
128
+ if title in section.title:
129
+ return section.rows
130
+ return []
131
+
132
+
133
+ def _td_texts(row: List[_Cell]) -> List[str]:
134
+ return [cell.text for cell in row if cell.tag == "td"]
135
+
136
+
137
+ def _th_text(row: List[_Cell]) -> str:
138
+ return " ".join(cell.text for cell in row if cell.tag == "th").strip()
139
+
140
+
141
+ def parse_student_info(html: str) -> SejongStudentInfo:
142
+ """사용자 정보 표 > 학과, 학번, 이름, 학년, 상태 (표 없으면 PARSE_ERROR)"""
143
+ value_by_label: Dict[str, str] = {}
144
+ for row in _find_rows(_read_sections(html), SECTION_USER_INFO):
145
+ label = _th_text(row)
146
+ if label:
147
+ value_by_label[label] = " ".join(_td_texts(row)).strip()
148
+ if not value_by_label:
149
+ raise SejongAuthError(ErrorCode.PARSE_ERROR, "Student info table not found")
150
+ return SejongStudentInfo(
151
+ major=value_by_label.get(LABEL_MAJOR, ""),
152
+ student_id=value_by_label.get(LABEL_STUDENT_ID, ""),
153
+ name=value_by_label.get(LABEL_NAME, ""),
154
+ grade=value_by_label.get(LABEL_GRADE, ""),
155
+ status=value_by_label.get(LABEL_STATUS, ""),
156
+ )
157
+
158
+
159
+ def parse_classic_reading(html: str) -> SejongClassicReading:
160
+ """고전독서 5개 표 > SejongClassicReading (칸 수 부족한 행은 건너뜀)"""
161
+ sections = _read_sections(html)
162
+
163
+ certifications = []
164
+ for row in _find_rows(sections, SECTION_CERTIFICATIONS):
165
+ area, counts = _th_text(row), _td_texts(row)
166
+ if area and len(counts) >= 2:
167
+ certifications.append(ClassicCertification(area, counts[0], counts[1]))
168
+
169
+ def rows_with(title: str, minimum_cell_count: int) -> List[List[str]]:
170
+ return [cells for cells in map(_td_texts, _find_rows(sections, title)) if len(cells) >= minimum_cell_count]
171
+
172
+ return SejongClassicReading(
173
+ certifications=certifications,
174
+ exam_records=[ClassicExamRecord(*cells[:6]) for cells in rows_with(SECTION_EXAMS, 6)],
175
+ subject_substitutions=[ClassicSubjectRecord(*cells[:5]) for cells in rows_with(SECTION_SUBJECT_SUBSTITUTIONS, 5)],
176
+ contest_records=[ClassicContestRecord(*cells[:4]) for cells in rows_with(SECTION_CONTESTS, 4)],
177
+ curriculum_records=[ClassicSubjectRecord(*cells[:5]) for cells in rows_with(SECTION_CURRICULUM, 5)],
178
+ )
179
+
180
+
181
+ def _text(node: dict, field_name: str) -> str:
182
+ value = node.get(field_name) if isinstance(node, dict) else None
183
+ return "" if value is None else str(value)
184
+
185
+
186
+ # SIS 오류 응답: 200 + {"_SUBMIT_ERROR_": {"ERRTYPE": ...}}, 로그인 세션 없음(틀린 비밀번호) = LOGOUT
187
+ SIS_SUBMIT_ERROR_KEY = "_SUBMIT_ERROR_"
188
+ SIS_LOGGED_OUT_ERROR_TYPE = "LOGOUT"
189
+
190
+
191
+ def ensure_sis_logged_in(json_text: str) -> None:
192
+ """initUserInfo 오류 응답 판정: LOGOUT > AUTHENTICATION_FAILED, 그 외 오류 > DATA_FETCH_FAILED"""
193
+ try:
194
+ root = json.loads(json_text)
195
+ except (TypeError, ValueError):
196
+ return # JSON 아님 > 파싱 단계에서 PARSE_ERROR
197
+ submit_error = root.get(SIS_SUBMIT_ERROR_KEY) if isinstance(root, dict) else None
198
+ if not isinstance(submit_error, dict):
199
+ return
200
+ error_type = _text(submit_error, "ERRTYPE")
201
+ if error_type == SIS_LOGGED_OUT_ERROR_TYPE:
202
+ raise SejongAuthError(ErrorCode.AUTHENTICATION_FAILED)
203
+ raise SejongAuthError(ErrorCode.DATA_FETCH_FAILED, f"SIS returned an error : {error_type}")
204
+
205
+
206
+ def parse_sis_user_info(json_text: str) -> SejongSisUserInfo:
207
+ """initUserInfo JSON > 학번, 이름, 학과, 이메일, 전화번호, 영문 이름 (dm_UserInfo 없으면 PARSE_ERROR)"""
208
+ try:
209
+ root = json.loads(json_text)
210
+ except (TypeError, ValueError) as error:
211
+ raise SejongAuthError(ErrorCode.PARSE_ERROR) from error
212
+ if not isinstance(root, dict) or not isinstance(root.get("dm_UserInfo"), dict):
213
+ raise SejongAuthError(ErrorCode.PARSE_ERROR, "dm_UserInfo field not found")
214
+
215
+ user_info = root["dm_UserInfo"]
216
+ gam = root.get("dm_UserInfoGam") or {}
217
+ sch = root.get("dm_UserInfoSch") or {}
218
+ phone_parts = [_text(gam, key).strip() for key in ("USER_PHONE_NO1", "USER_PHONE_NO2", "USER_PHONE_NO3")]
219
+
220
+ return SejongSisUserInfo(
221
+ student_id=_text(user_info, "INTG_USR_NO"),
222
+ name=_text(user_info, "INTG_USR_NM"),
223
+ major=_text(gam, "DEPT_NM").strip() or _text(sch, "DEPT_NM"),
224
+ email=_text(gam, "USER_EMAIL"),
225
+ phone_number="-".join(part for part in phone_parts if part),
226
+ english_name=_text(user_info, "INTG_ENG_NM").strip() or _text(sch, "NM_ENG"),
227
+ )
File without changes
@@ -0,0 +1,64 @@
1
+ """HTTP 세션: 인증 1회당 쿠키 저장소 1개, 포털 레거시 TLS, 리다이렉트 자동 (표준 라이브러리만)"""
2
+
3
+ import http.cookiejar
4
+ import socket
5
+ import ssl
6
+ import urllib.error
7
+ import urllib.request
8
+ from dataclasses import dataclass
9
+ from typing import Dict, Optional
10
+
11
+ # 포털은 TLS_RSA_WITH_AES_256_CBC_SHA 만 제공 > 기본 cipher 목록에 추가 (spec/PORTAL_FLOW.md)
12
+ PORTAL_CIPHERS = "DEFAULT:AES256-SHA:@SECLEVEL=1"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class HttpResponse:
17
+ status: int
18
+ body: bytes
19
+
20
+ @property
21
+ def is_successful(self) -> bool:
22
+ return 200 <= self.status < 300
23
+
24
+ def text(self) -> str:
25
+ return self.body.decode("utf-8", errors="replace")
26
+
27
+
28
+ def create_ssl_context(ssl_verification: bool) -> ssl.SSLContext:
29
+ """포털 cipher 허용 컨텍스트 (ssl_verification=False 면 인증서·호스트 검증 해제)"""
30
+ context = ssl.create_default_context()
31
+ context.set_ciphers(PORTAL_CIPHERS)
32
+ if not ssl_verification:
33
+ context.check_hostname = False
34
+ context.verify_mode = ssl.CERT_NONE
35
+ return context
36
+
37
+
38
+ class UrllibSession:
39
+ """요청 1건: 상태 코드가 4xx·5xx 여도 예외 대신 HttpResponse, 시간 초과는 TimeoutError"""
40
+
41
+ def __init__(self, ssl_context: ssl.SSLContext, timeout_seconds: float, user_agent: str):
42
+ self._timeout_seconds = timeout_seconds
43
+ self._opener = urllib.request.build_opener(
44
+ urllib.request.HTTPSHandler(context=ssl_context),
45
+ urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
46
+ )
47
+ # 기본 Python-urllib 에이전트는 포털 방화벽이 차단
48
+ self._opener.addheaders = [("User-Agent", user_agent)]
49
+
50
+ def request(self, method: str, url: str, *, data: Optional[bytes] = None,
51
+ headers: Optional[Dict[str, str]] = None) -> HttpResponse:
52
+ request = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
53
+ try:
54
+ with self._opener.open(request, timeout=self._timeout_seconds) as response:
55
+ return HttpResponse(status=response.status, body=response.read())
56
+ except urllib.error.HTTPError as error:
57
+ with error:
58
+ return HttpResponse(status=error.code, body=error.read() or b"")
59
+ except urllib.error.URLError as error:
60
+ if isinstance(error.reason, (socket.timeout, TimeoutError)):
61
+ raise TimeoutError(str(error.reason)) from error
62
+ raise OSError(str(error.reason)) from error
63
+ except socket.timeout as error:
64
+ raise TimeoutError(str(error)) from error
@@ -0,0 +1,54 @@
1
+ """테스트 공통: spec/fixtures 읽기, 네트워크 없는 가짜 세션"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from sejong_auth.transport import HttpResponse
9
+
10
+ FIXTURE_DIRECTORY = Path(__file__).resolve().parents[2] / "spec" / "fixtures"
11
+
12
+
13
+ def read_fixture(file_name: str) -> str:
14
+ return (FIXTURE_DIRECTORY / file_name).read_text(encoding="utf-8")
15
+
16
+
17
+ def read_expected(file_name: str) -> dict:
18
+ return json.loads(read_fixture(file_name))
19
+
20
+
21
+ class FakeSession:
22
+ """URL 앞부분 > 응답 목록(차례로 소비). 값이 예외 인스턴스면 raise"""
23
+
24
+ def __init__(self, responses_by_url_prefix):
25
+ self.responses_by_url_prefix = {prefix: list(responses) for prefix, responses in responses_by_url_prefix.items()}
26
+ self.requested_urls = []
27
+
28
+ def request(self, method, url, *, data=None, headers=None):
29
+ self.requested_urls.append(url)
30
+ for prefix, responses in self.responses_by_url_prefix.items():
31
+ if url.startswith(prefix):
32
+ outcome = responses.pop(0) if len(responses) > 1 else responses[0]
33
+ if isinstance(outcome, BaseException):
34
+ raise outcome
35
+ return outcome
36
+ raise AssertionError(f"unexpected request: {method} {url}")
37
+
38
+
39
+ def ok(body: str = "") -> HttpResponse:
40
+ return HttpResponse(status=200, body=body.encode("utf-8"))
41
+
42
+
43
+ def status(code: int) -> HttpResponse:
44
+ return HttpResponse(status=code, body=b"")
45
+
46
+
47
+ @pytest.fixture
48
+ def dhc_html() -> str:
49
+ return read_fixture("dhc_status.html")
50
+
51
+
52
+ @pytest.fixture
53
+ def sis_json() -> str:
54
+ return read_fixture("sis_user_info.json")
@@ -0,0 +1,134 @@
1
+ """클라이언트 흐름: 입력 검증, 401, SIS 실패 무시, 타임아웃, 재시도 (네트워크 없음)"""
2
+
3
+ import pytest
4
+ from conftest import FakeSession, ok, status
5
+
6
+ from sejong_auth import ErrorCode, SejongAuthClient, SejongAuthError
7
+
8
+ LOGIN = "https://portal.sejong.ac.kr/jsp/login/login_action.jsp"
9
+ CLASSIC_SSO = "https://classic.sejong.ac.kr/_custom/sejong/sso/sso-return.jsp"
10
+ CLASSIC_STATUS = "https://classic.sejong.ac.kr/classic/reading/status.do"
11
+ SIS_SSO = "https://sjpt.sejong.ac.kr/main/view/Login/doSsoLogin.do"
12
+ SIS_USER_INFO = "https://sjpt.sejong.ac.kr/main/sys/UserInfo/initUserInfo.do"
13
+
14
+
15
+ def client_with(responses_by_url_prefix, **options):
16
+ sessions = []
17
+
18
+ def session_factory():
19
+ session = FakeSession(responses_by_url_prefix)
20
+ sessions.append(session)
21
+ return session
22
+
23
+ return SejongAuthClient(_session_factory=session_factory, **options), sessions
24
+
25
+
26
+ @pytest.mark.parametrize("student_id,password", [("", "pw"), ("21011234", ""), (" ", "pw"), (None, "pw")])
27
+ def test_empty_input_raises_invalid_input_without_network(student_id, password):
28
+ client, sessions = client_with({})
29
+
30
+ with pytest.raises(SejongAuthError) as error:
31
+ client.authenticate(student_id, password)
32
+
33
+ assert error.value.code == ErrorCode.INVALID_INPUT
34
+ assert sessions == []
35
+
36
+
37
+ def test_wrong_password_401_raises_authentication_failed():
38
+ client, _ = client_with({LOGIN: [ok()], CLASSIC_SSO: [ok()], CLASSIC_STATUS: [status(401)]})
39
+
40
+ with pytest.raises(SejongAuthError) as error:
41
+ client.authenticate_dhc("21011234", "wrong")
42
+
43
+ assert error.value.code == ErrorCode.AUTHENTICATION_FAILED
44
+
45
+
46
+ def test_integrated_sis_failure_returns_dhc_with_null_contacts(dhc_html):
47
+ client, _ = client_with({
48
+ LOGIN: [ok()], CLASSIC_SSO: [ok()], CLASSIC_STATUS: [ok(dhc_html)],
49
+ SIS_SSO: [ok()], SIS_USER_INFO: [status(500)],
50
+ })
51
+
52
+ result = client.authenticate("21011234", "pw")
53
+
54
+ assert result.success is True
55
+ assert result.name == "홍길동"
56
+ assert result.major == "컴퓨터공학과"
57
+ assert (result.email, result.phone_number, result.english_name) == (None, None, None)
58
+
59
+
60
+ def test_integrated_success_merges_dhc_and_sis(dhc_html, sis_json):
61
+ client, _ = client_with({
62
+ LOGIN: [ok()], CLASSIC_SSO: [ok()], CLASSIC_STATUS: [ok(dhc_html)],
63
+ SIS_SSO: [ok()], SIS_USER_INFO: [ok(sis_json)],
64
+ })
65
+
66
+ result = client.authenticate("21011234", "pw")
67
+
68
+ assert result.grade == "3"
69
+ assert result.classic_reading.certifications[2].area == "동·서양의 문학 (3권)"
70
+ assert result.phone_number == "010-1234-5678"
71
+ assert result.english_name == "Hong Gildong"
72
+
73
+
74
+ def test_timeout_raises_connection_timeout():
75
+ client, _ = client_with({LOGIN: [TimeoutError("timed out")]}, max_retry=3)
76
+
77
+ with pytest.raises(SejongAuthError) as error:
78
+ client.authenticate_dhc("21011234", "pw")
79
+
80
+ assert error.value.code == ErrorCode.CONNECTION_TIMEOUT
81
+
82
+
83
+ def test_retry_exhausted_raises_connection_failed():
84
+ client, sessions = client_with({LOGIN: [status(500)]}, max_retry=3)
85
+
86
+ with pytest.raises(SejongAuthError) as error:
87
+ client.authenticate_dhc("21011234", "pw")
88
+
89
+ assert error.value.code == ErrorCode.CONNECTION_FAILED
90
+ assert sessions[0].requested_urls.count(LOGIN) == 3
91
+
92
+
93
+ def test_retry_then_success(dhc_html):
94
+ client, _ = client_with({LOGIN: [status(503), ok()], CLASSIC_SSO: [ok()], CLASSIC_STATUS: [ok(dhc_html)]})
95
+
96
+ assert client.authenticate_dhc("21011234", "pw").student_id == "21011234"
97
+
98
+
99
+ def test_sis_result_has_empty_grade_and_status_and_raw_json(sis_json):
100
+ client, _ = client_with({LOGIN: [ok()], SIS_SSO: [ok()], SIS_USER_INFO: [ok(sis_json)]})
101
+
102
+ result = client.authenticate_sis("21011234", "pw", include_raw=True)
103
+
104
+ assert (result.grade, result.status) == ("", "")
105
+ assert result.email == "student@example.com"
106
+ assert result.raw_json == sis_json
107
+
108
+
109
+ def test_dhc_raw_html_only_when_requested(dhc_html):
110
+ client, _ = client_with({LOGIN: [ok()], CLASSIC_SSO: [ok()], CLASSIC_STATUS: [ok(dhc_html)]})
111
+
112
+ assert client.authenticate_dhc("21011234", "pw").raw_html is None
113
+ assert client.authenticate_dhc("21011234", "pw", include_raw=True).raw_html == dhc_html
114
+
115
+
116
+ def test_error_message_never_contains_password():
117
+ client, _ = client_with({LOGIN: [OSError("connection reset")]})
118
+
119
+ with pytest.raises(SejongAuthError) as error:
120
+ client.authenticate_dhc("21011234", "secret-password-value")
121
+
122
+ assert error.value.code == ErrorCode.CONNECTION_FAILED
123
+ assert "secret-password-value" not in str(error.value)
124
+
125
+
126
+ def test_sis_wrong_password_submit_error_raises_authentication_failed():
127
+ # 틀린 비밀번호: initUserInfo 가 200 + _SUBMIT_ERROR_(ERRTYPE LOGOUT) 를 돌려줌
128
+ from conftest import read_fixture
129
+ client, _ = client_with({LOGIN: [ok()], SIS_SSO: [ok()], SIS_USER_INFO: [ok(read_fixture("sis_submit_error_logout.json"))]})
130
+
131
+ with pytest.raises(SejongAuthError) as error:
132
+ client.authenticate_sis("21011234", "wrong")
133
+
134
+ assert error.value.code == ErrorCode.AUTHENTICATION_FAILED
@@ -0,0 +1,31 @@
1
+ """실제 세종 포털 연동 (환경변수 SEJONG_TEST_STUDENT_ID, SEJONG_TEST_PASSWORD 있을 때만)"""
2
+
3
+ import os
4
+
5
+ import pytest
6
+
7
+ from sejong_auth import ErrorCode, SejongAuthError, authenticate, authenticate_dhc, authenticate_sis
8
+
9
+ STUDENT_ID = os.environ.get("SEJONG_TEST_STUDENT_ID")
10
+ PASSWORD = os.environ.get("SEJONG_TEST_PASSWORD")
11
+
12
+ pytestmark = pytest.mark.skipif(not (STUDENT_ID and PASSWORD), reason="SEJONG_TEST_* not set")
13
+
14
+
15
+ def test_live_integrated():
16
+ result = authenticate(STUDENT_ID, PASSWORD)
17
+ assert result.student_id == STUDENT_ID
18
+ assert result.major and result.name
19
+ assert result.classic_reading.certifications
20
+ assert result.email is not None
21
+
22
+
23
+ def test_live_dhc_and_sis():
24
+ assert authenticate_dhc(STUDENT_ID, PASSWORD).student_id == STUDENT_ID
25
+ assert authenticate_sis(STUDENT_ID, PASSWORD).student_id == STUDENT_ID
26
+
27
+
28
+ def test_live_wrong_password():
29
+ with pytest.raises(SejongAuthError) as error:
30
+ authenticate_dhc(STUDENT_ID, PASSWORD + "-wrong")
31
+ assert error.value.code == ErrorCode.AUTHENTICATION_FAILED
@@ -0,0 +1,39 @@
1
+ """spec/fixtures 파싱 결과 == 기대값 (Java, Node 와 같은 기준)"""
2
+
3
+ import re
4
+
5
+ from conftest import read_expected
6
+
7
+ from sejong_auth.parsers import parse_classic_reading, parse_sis_user_info, parse_student_info
8
+
9
+
10
+ def to_snake_case(name: str) -> str:
11
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
12
+
13
+
14
+ def snake_case_keys(value):
15
+ if isinstance(value, dict):
16
+ return {to_snake_case(key): snake_case_keys(item) for key, item in value.items()}
17
+ if isinstance(value, list):
18
+ return [snake_case_keys(item) for item in value]
19
+ return value
20
+
21
+
22
+ def test_parse_dhc_fixture(dhc_html):
23
+ expected = snake_case_keys(read_expected("expected_dhc.json"))
24
+
25
+ assert parse_student_info(dhc_html).to_dict() == expected["student_info"]
26
+ assert parse_classic_reading(dhc_html).to_dict() == expected["classic_reading"]
27
+
28
+
29
+ def test_parse_sis_fixture(sis_json):
30
+ expected = snake_case_keys(read_expected("expected_sis.json"))
31
+
32
+ assert parse_sis_user_info(sis_json).to_dict() == expected
33
+
34
+
35
+ def test_decodes_common_named_entities():
36
+ # Node·Jsoup 과 같은 기대값
37
+ html = ('<div class="b-con-box"><h4 class="b-h4-tit01">사용자 정보</h4><table class="b-board-table"><tbody>'
38
+ '<tr><th>학과명</th><td>&lsquo;컴퓨터&rsquo; &ldquo;공학&rdquo;&hellip; &ndash;&mdash;</td></tr></tbody></table></div>')
39
+ assert parse_student_info(html).major == "‘컴퓨터’ “공학”… –—"