feat(t230): use OCR for ERP session login
This commit is contained in:
@@ -39,6 +39,11 @@ type SessionConfig struct {
|
||||
Timeout time.Duration
|
||||
CaptchaTTL time.Duration
|
||||
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
|
||||
CaptchaRecognizer CaptchaRecognizer
|
||||
}
|
||||
|
||||
type CaptchaRecognizer interface {
|
||||
Recognize(context.Context, []byte, string) (string, error)
|
||||
}
|
||||
|
||||
type SessionStatus struct {
|
||||
@@ -54,6 +59,7 @@ type CaptchaImage struct {
|
||||
}
|
||||
|
||||
type SessionManager struct {
|
||||
authMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
baseURL string
|
||||
username string
|
||||
@@ -62,6 +68,7 @@ type SessionManager struct {
|
||||
captchaTTL time.Duration
|
||||
headers http.Header
|
||||
http *http.Client
|
||||
recognizer CaptchaRecognizer
|
||||
authenticated bool
|
||||
captchaTicket string
|
||||
captchaContent []byte
|
||||
@@ -111,9 +118,39 @@ func NewSessionManager(config SessionConfig) (*SessionManager, error) {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
recognizer: config.CaptchaRecognizer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EnsureAuthenticated establishes the single in-memory ERP session only when
|
||||
// the current cookie jar cannot be validated.
|
||||
func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error {
|
||||
manager.authMu.Lock()
|
||||
defer manager.authMu.Unlock()
|
||||
if _, err := manager.Validate(ctx); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, domain.ErrFreightSourceSessionNeeded) {
|
||||
return err
|
||||
}
|
||||
if manager.recognizer == nil {
|
||||
return domain.ErrFreightSourceOCRInvalid
|
||||
}
|
||||
status, err := manager.FetchCaptcha(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
image, err := manager.OpenCaptcha(status.CaptchaTicket)
|
||||
if err != nil {
|
||||
return domain.ErrFreightSourceProtocol
|
||||
}
|
||||
code, err := manager.recognizer.Recognize(ctx, image.Content, image.ContentType)
|
||||
if err != nil || !validCaptchaCode(code) {
|
||||
return domain.ErrFreightSourceOCRInvalid
|
||||
}
|
||||
_, err = manager.Login(ctx, status.CaptchaTicket, code)
|
||||
return err
|
||||
}
|
||||
|
||||
func (manager *SessionManager) Status() SessionStatus {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
@@ -166,6 +166,66 @@ func TestSessionManagerSerializesCaptchaRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerEnsureAuthenticatedUsesRecognizerOnce(t *testing.T) {
|
||||
var captchaCalls, loginCalls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case CaptchaPath:
|
||||
captchaCalls++
|
||||
http.SetCookie(w, &http.Cookie{Name: "captcha", Value: "ready", Path: "/"})
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("captcha-image"))
|
||||
case LoginPath:
|
||||
loginCalls++
|
||||
http.SetCookie(w, &http.Cookie{Name: "authenticated", Value: "yes", Path: "/"})
|
||||
_, _ = w.Write([]byte(`{"status":true,"data":{"user":{"id":12}}}`))
|
||||
case UserPath:
|
||||
_, _ = w.Write([]byte(`{"status":true,"data":{"id":12}}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
recognizer := &fixedRecognizer{code: "1234"}
|
||||
manager := testSessionManager(t, server.URL, "test-user", "test-password")
|
||||
manager.recognizer = recognizer
|
||||
if err := manager.EnsureAuthenticated(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureAuthenticated() error = %v", err)
|
||||
}
|
||||
if !manager.Status().Authenticated || recognizer.calls != 1 || captchaCalls != 1 || loginCalls != 1 {
|
||||
t.Fatalf("state/calls = %+v / %d / %d / %d", manager.Status(), recognizer.calls, captchaCalls, loginCalls)
|
||||
}
|
||||
if err := manager.EnsureAuthenticated(context.Background()); err != nil {
|
||||
t.Fatalf("second EnsureAuthenticated() error = %v", err)
|
||||
}
|
||||
if recognizer.calls != 1 || captchaCalls != 1 || loginCalls != 1 {
|
||||
t.Fatalf("second call repeated OCR/login = %d / %d / %d", recognizer.calls, captchaCalls, loginCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerEnsureAuthenticatedRequiresRecognizer(t *testing.T) {
|
||||
manager := testSessionManager(t, "https://erp.example.test", "test-user", "test-password")
|
||||
err := manager.EnsureAuthenticated(context.Background())
|
||||
if !errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
|
||||
t.Fatalf("EnsureAuthenticated() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fixedRecognizer struct {
|
||||
code string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (recognizer *fixedRecognizer) Recognize(
|
||||
context.Context,
|
||||
[]byte,
|
||||
string,
|
||||
) (string, error) {
|
||||
recognizer.calls++
|
||||
return recognizer.code, recognizer.err
|
||||
}
|
||||
|
||||
func testSessionManager(
|
||||
t *testing.T,
|
||||
baseURL, username, password string,
|
||||
|
||||
Reference in New Issue
Block a user