由 Codex (gpt-5.6-sol) 实施,Claude 审核。 internal/huohanhan/auth.go - 六步登录:取 CID/CST 作 Basic 认证 → getCltConf 取 clientId → 下载验证码 → OCR 识别 → 提交登录 → app-version/info 在线验证 - OCR 响应递归查找 4~8 位验证码,优先 text/result/data/content/captcha/code - 错误分支:invalid_verify_code 换图重试(上限可配,默认 3); invalid_credentials 与 disabled_credentials 直接报中文错误不重试 - 认证复用改用 SQLite kv 表 + sync.Mutex,不照搬 Python 版的 Redis - GetValidAuth 按「内存 → SQLite 在线验证 → 重新登录」顺序取认证 internal/huohanhan/client.go - 统一请求方法,自动带 Authorization 与 cookies - 认证失败时清状态、重登、最多重试一次;超时、500、普通业务错误不重试 app.go - 新增 TestHuohanhanLogin,供设置页「测试连接」调用 测试全部使用 httptest 假服务,不需要真实账号即可运行。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
341 lines
11 KiB
Go
341 lines
11 KiB
Go
package huohanhan
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"cmsp/internal/config"
|
||
"cmsp/internal/logx"
|
||
"cmsp/internal/store"
|
||
)
|
||
|
||
const (
|
||
testAccount = "13500000000"
|
||
testPassword = "test-password"
|
||
testToken = "test-token"
|
||
testCookie = "test-cookie"
|
||
)
|
||
|
||
type fakeLoginBackend struct {
|
||
t *testing.T
|
||
mu sync.Mutex
|
||
loginCodes []string
|
||
loginCount int
|
||
captchaCount int
|
||
validateCount int
|
||
captchaKeys []string
|
||
issuedToken string
|
||
businessHandler http.HandlerFunc
|
||
server *httptest.Server
|
||
}
|
||
|
||
func newFakeLoginBackend(t *testing.T, loginCodes ...string) *fakeLoginBackend {
|
||
t.Helper()
|
||
backend := &fakeLoginBackend{t: t, loginCodes: loginCodes, issuedToken: testToken}
|
||
backend.server = httptest.NewServer(http.HandlerFunc(backend.serveHTTP))
|
||
t.Cleanup(backend.server.Close)
|
||
return backend
|
||
}
|
||
|
||
func (b *fakeLoginBackend) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case "/login":
|
||
http.SetCookie(w, &http.Cookie{Name: "login-session", Value: testCookie, Path: "/"})
|
||
_, _ = io.WriteString(w, `window.config={"CID":"test-cid", 'CST': 'test-cst'}`)
|
||
case "/api/butler/client/getCltConf":
|
||
if err := r.ParseForm(); err != nil {
|
||
b.t.Errorf("解析网站配置表单失败:%v", err)
|
||
}
|
||
serverURL, _ := url.Parse(b.server.URL)
|
||
if got := r.Form.Get("domain"); got != serverURL.Hostname() {
|
||
b.t.Errorf("domain 期望 %q,实际 %q", serverURL.Hostname(), got)
|
||
}
|
||
writeJSON(w, map[string]any{"id": 12345})
|
||
case "/api/butler/vrify/kaptcha":
|
||
b.mu.Lock()
|
||
b.captchaCount++
|
||
b.captchaKeys = append(b.captchaKeys, r.URL.Query().Get("kaptchaKey"))
|
||
b.mu.Unlock()
|
||
w.Header().Set("Content-Type", "image/jpeg")
|
||
_, _ = w.Write([]byte("fake-image"))
|
||
case "/api/login":
|
||
b.handleLogin(w, r)
|
||
case "/api/butler/app-version/info":
|
||
b.mu.Lock()
|
||
b.validateCount++
|
||
b.mu.Unlock()
|
||
if err := r.ParseForm(); err != nil {
|
||
b.t.Errorf("解析认证校验表单失败:%v", err)
|
||
}
|
||
if r.Form.Get("appCode") != "HHH" {
|
||
b.t.Errorf("认证校验 appCode 期望 HHH,实际 %q", r.Form.Get("appCode"))
|
||
}
|
||
if r.Header.Get("Authorization") != "Bearer "+b.issuedToken {
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if cookie, err := r.Cookie("auth-session"); err != nil || cookie.Value != testCookie {
|
||
b.t.Errorf("认证校验应携带登录 cookie,实际 cookie=%v err=%v", cookie, err)
|
||
}
|
||
writeJSON(w, map[string]any{"appCode": "HHH"})
|
||
default:
|
||
if b.businessHandler != nil {
|
||
b.businessHandler(w, r)
|
||
return
|
||
}
|
||
http.NotFound(w, r)
|
||
}
|
||
}
|
||
|
||
func (b *fakeLoginBackend) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||
user, password, ok := r.BasicAuth()
|
||
if !ok || user != "test-cid" || password != "test-cst" {
|
||
b.t.Errorf("登录请求 Basic 认证不正确")
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
b.t.Errorf("解析登录表单失败:%v", err)
|
||
}
|
||
if r.Form.Get("username") != testAccount || r.Form.Get("password") != testPassword {
|
||
b.t.Errorf("登录表单账号或密码不正确")
|
||
}
|
||
if r.Form.Get("clientId") != "12345" || r.Form.Get("kaptchaCode") != "A1b2" {
|
||
b.t.Errorf("登录表单 clientId 或验证码不正确:%v", r.Form)
|
||
}
|
||
if r.Form.Get("kaptchaKey") == "" {
|
||
b.t.Errorf("登录表单缺少 kaptchaKey")
|
||
}
|
||
|
||
b.mu.Lock()
|
||
index := b.loginCount
|
||
b.loginCount++
|
||
code := ""
|
||
if index < len(b.loginCodes) {
|
||
code = b.loginCodes[index]
|
||
}
|
||
b.mu.Unlock()
|
||
|
||
if code != "" {
|
||
writeJSON(w, map[string]any{"code": code, "message": "fake login error"})
|
||
return
|
||
}
|
||
http.SetCookie(w, &http.Cookie{Name: "auth-session", Value: testCookie, Path: "/"})
|
||
writeJSON(w, map[string]any{
|
||
"access_token": b.issuedToken,
|
||
"token_type": "Bearer",
|
||
"expires_in": 3600,
|
||
"login_time": time.Now().UnixMilli(),
|
||
})
|
||
}
|
||
|
||
func newFakeOCRServer(t *testing.T, calls *int) *httptest.Server {
|
||
t.Helper()
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
(*calls)++
|
||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||
t.Errorf("OCR multipart 解析失败:%v", err)
|
||
http.Error(w, "bad multipart", http.StatusBadRequest)
|
||
return
|
||
}
|
||
file, header, err := r.FormFile("file")
|
||
if err != nil {
|
||
t.Errorf("OCR 请求缺少 file 字段:%v", err)
|
||
http.Error(w, "missing file", http.StatusBadRequest)
|
||
return
|
||
}
|
||
defer file.Close()
|
||
if header.Filename != "captcha.jpg" {
|
||
t.Errorf("OCR 文件名期望 captcha.jpg,实际 %q", header.Filename)
|
||
}
|
||
if got := header.Header.Get("Content-Type"); got != "image/jpeg" {
|
||
t.Errorf("OCR 文件类型期望 image/jpeg,实际 %q", got)
|
||
}
|
||
image, _ := io.ReadAll(file)
|
||
if string(image) != "fake-image" {
|
||
t.Errorf("OCR 图片内容不正确,实际 %q", image)
|
||
}
|
||
writeJSON(w, map[string]any{"data": map[string]any{"text": " A1 b2 "}})
|
||
}))
|
||
t.Cleanup(server.Close)
|
||
return server
|
||
}
|
||
|
||
func newTestAuthManager(t *testing.T, backend *fakeLoginBackend, database *store.Store, logger *logx.Logger, attempts int) *AuthManager {
|
||
t.Helper()
|
||
ocrCalls := 0
|
||
ocr := newFakeOCRServer(t, &ocrCalls)
|
||
cfg := config.HuohanhanConfig{
|
||
BaseURL: backend.server.URL,
|
||
Account: testAccount,
|
||
Password: testPassword,
|
||
OCRURL: ocr.URL,
|
||
}
|
||
return NewAuthManager(cfg, database, logger, AuthOptions{
|
||
HTTPClient: backend.server.Client(),
|
||
MaxCaptchaAttempts: attempts,
|
||
})
|
||
}
|
||
|
||
func newTestStore(t *testing.T) *store.Store {
|
||
t.Helper()
|
||
database, err := store.Open(":memory:")
|
||
if err != nil {
|
||
t.Fatalf("打开测试数据库失败:%v", err)
|
||
}
|
||
t.Cleanup(func() { _ = database.Close() })
|
||
return database
|
||
}
|
||
|
||
func Test登录成功并保存认证状态(t *testing.T) {
|
||
backend := newFakeLoginBackend(t)
|
||
database := newTestStore(t)
|
||
logger := logx.New(100)
|
||
manager := newTestAuthManager(t, backend, database, logger, 3)
|
||
|
||
state, err := manager.ForceLogin(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("登录应当成功,实际错误:%v", err)
|
||
}
|
||
if state.AccessToken != testToken {
|
||
t.Fatalf("token 期望 %q,实际 %q", testToken, state.AccessToken)
|
||
}
|
||
raw, found, err := database.GetKV(authStateKey)
|
||
if err != nil || !found || raw == "" {
|
||
t.Fatalf("认证状态应写入 SQLite,found=%v err=%v", found, err)
|
||
}
|
||
if backend.loginCount != 1 || backend.validateCount != 1 {
|
||
t.Fatalf("登录和在线校验都应各执行 1 次,实际登录 %d 次、校验 %d 次", backend.loginCount, backend.validateCount)
|
||
}
|
||
}
|
||
|
||
func Test验证码错误后更换图片重试成功(t *testing.T) {
|
||
backend := newFakeLoginBackend(t, "invalid_verify_code")
|
||
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3)
|
||
|
||
if _, err := manager.ForceLogin(context.Background()); err != nil {
|
||
t.Fatalf("第二张验证码应登录成功,实际错误:%v", err)
|
||
}
|
||
if backend.loginCount != 2 || backend.captchaCount != 2 {
|
||
t.Fatalf("应下载并提交 2 张验证码,实际下载 %d 次、提交 %d 次", backend.captchaCount, backend.loginCount)
|
||
}
|
||
if len(backend.captchaKeys) != 2 || backend.captchaKeys[0] == backend.captchaKeys[1] {
|
||
t.Fatalf("重试必须更换 kaptchaKey,实际 %v", backend.captchaKeys)
|
||
}
|
||
}
|
||
|
||
func Test账号错误和禁用都不重试(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
code string
|
||
want string
|
||
}{
|
||
{"账号密码错误", "invalid_credentials", "账号或密码错误"},
|
||
{"账号已禁用", "disabled_credentials", "账号已被禁用"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
backend := newFakeLoginBackend(t, tc.code)
|
||
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3)
|
||
_, err := manager.ForceLogin(context.Background())
|
||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||
t.Fatalf("期望错误包含 %q,实际 %v", tc.want, err)
|
||
}
|
||
if backend.loginCount != 1 || backend.captchaCount != 1 {
|
||
t.Fatalf("不可重试的错误应只请求 1 次,实际登录 %d 次、验证码 %d 次", backend.loginCount, backend.captchaCount)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func Test验证码错误达到上限后停止(t *testing.T) {
|
||
backend := newFakeLoginBackend(t, "invalid_verify_code", "invalid_verify_code", "invalid_verify_code")
|
||
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 2)
|
||
|
||
_, err := manager.ForceLogin(context.Background())
|
||
if err == nil || !strings.Contains(err.Error(), "连续多次验证码识别失败") {
|
||
t.Fatalf("达到上限应返回可读错误,实际 %v", err)
|
||
}
|
||
if backend.loginCount != 2 {
|
||
t.Fatalf("上限为 2 时应只提交 2 次,实际 %d 次", backend.loginCount)
|
||
}
|
||
}
|
||
|
||
func Test其它登录错误包含Code和Message且不重试(t *testing.T) {
|
||
backend := newFakeLoginBackend(t, "server_rejected")
|
||
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3)
|
||
|
||
_, err := manager.ForceLogin(context.Background())
|
||
if err == nil || !strings.Contains(err.Error(), "server_rejected") || !strings.Contains(err.Error(), "fake login error") {
|
||
t.Fatalf("其它错误应包含 code 和 message,实际 %v", err)
|
||
}
|
||
if backend.loginCount != 1 {
|
||
t.Fatalf("其它登录错误不应重试,实际登录 %d 次", backend.loginCount)
|
||
}
|
||
}
|
||
|
||
func TestSQLite中的Token重启后复用且内存不重复校验(t *testing.T) {
|
||
backend := newFakeLoginBackend(t)
|
||
database := newTestStore(t)
|
||
logger := logx.New(100)
|
||
first := newTestAuthManager(t, backend, database, logger, 3)
|
||
if _, err := first.ForceLogin(context.Background()); err != nil {
|
||
t.Fatalf("首次登录失败:%v", err)
|
||
}
|
||
|
||
second := newTestAuthManager(t, backend, database, logger, 3)
|
||
if _, err := second.GetValidAuth(context.Background()); err != nil {
|
||
t.Fatalf("重启后读取认证状态失败:%v", err)
|
||
}
|
||
validatedAfterLoad := backend.validateCount
|
||
if _, err := second.GetValidAuth(context.Background()); err != nil {
|
||
t.Fatalf("内存复用认证状态失败:%v", err)
|
||
}
|
||
if backend.loginCount != 1 {
|
||
t.Fatalf("第二个管理器不应重新登录,实际登录 %d 次", backend.loginCount)
|
||
}
|
||
if backend.validateCount != validatedAfterLoad {
|
||
t.Fatalf("未过期内存状态不应再次在线校验,校验次数从 %d 变成 %d", validatedAfterLoad, backend.validateCount)
|
||
}
|
||
}
|
||
|
||
func Test日志不出现密码Token和Cookie(t *testing.T) {
|
||
backend := newFakeLoginBackend(t)
|
||
logger := logx.New(100)
|
||
manager := newTestAuthManager(t, backend, newTestStore(t), logger, 3)
|
||
if _, err := manager.ForceLogin(context.Background()); err != nil {
|
||
t.Fatalf("登录失败:%v", err)
|
||
}
|
||
|
||
logs := logger.Text()
|
||
for _, secret := range []string{testPassword, testToken, testCookie} {
|
||
if strings.Contains(logs, secret) {
|
||
t.Fatalf("日志中不应出现敏感测试值 %q,实际日志:%s", secret, logs)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestOCR递归优先读取常见字段(t *testing.T) {
|
||
payload := map[string]any{
|
||
"unrelated": "ZZZZ",
|
||
"result": map[string]any{"content": " A1 b2 "},
|
||
}
|
||
if got := findCaptchaText(payload); got != "A1b2" {
|
||
t.Fatalf("应优先从 result/content 读取 A1b2,实际 %q", got)
|
||
}
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, value any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||
panic(fmt.Sprintf("写入假服务响应失败:%v", err))
|
||
}
|
||
}
|