feat(t230): use OCR for ERP session login
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package ocrapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 5 * time.Second
|
||||
maximumReplyBytes = 64 << 10
|
||||
)
|
||||
|
||||
var ErrServiceInvalid = errors.New("OCR service is invalid")
|
||||
|
||||
type Client struct {
|
||||
endpoint string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(endpoint string, timeout time.Duration) (*Client, error) {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if endpoint == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
return &Client{
|
||||
endpoint: endpoint,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *Client) Recognize(
|
||||
ctx context.Context,
|
||||
image []byte,
|
||||
contentType string,
|
||||
) (string, error) {
|
||||
if client == nil || len(image) == 0 || !strings.HasPrefix(contentType, "image/") {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "captcha"+extension(contentType))
|
||||
if err != nil {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
if _, err := part.Write(image); err != nil || writer.Close() != nil {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
client.endpoint,
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
response, err := client.http.Do(request)
|
||||
if err != nil {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
reply, err := readBounded(response.Body, maximumReplyBytes)
|
||||
if err != nil {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
code, err := parseCode(reply, response.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func parseCode(reply []byte, contentType string) (string, error) {
|
||||
value := ""
|
||||
if strings.HasPrefix(strings.ToLower(contentType), "application/json") {
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(reply, &object); err != nil {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
for _, key := range []string{"text", "result", "data"} {
|
||||
if candidate, ok := object[key].(string); ok {
|
||||
value = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value = string(reply)
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || len([]byte(value)) > 64 || !utf8.ValidString(value) {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < 32 || character == 127 {
|
||||
return "", ErrServiceInvalid
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func extension(contentType string) string {
|
||||
switch contentType {
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
default:
|
||||
return ".img"
|
||||
}
|
||||
}
|
||||
|
||||
func readBounded(reader io.Reader, maximum int64) ([]byte, error) {
|
||||
result, err := io.ReadAll(io.LimitReader(reader, maximum+1))
|
||||
if err != nil || int64(len(result)) > maximum {
|
||||
return nil, ErrServiceInvalid
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ocrapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientRecognizePostsMultipartAndParsesSupportedReplies(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
contentType string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"text", "text/plain", "aB12", "aB12"},
|
||||
{"json", "application/json", `{"text":"K9"}`, "K9"},
|
||||
{"result", "application/json", `{"result":"Z7"}`, "Z7"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/ocr" {
|
||||
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil || header.Filename != "captcha.png" {
|
||||
t.Fatalf("FormFile() = %v / %#v", err, header)
|
||||
}
|
||||
content, _ := io.ReadAll(file)
|
||||
if string(content) != "image-bytes" {
|
||||
t.Fatalf("image content = %q", content)
|
||||
}
|
||||
w.Header().Set("Content-Type", testCase.contentType)
|
||||
_, _ = w.Write([]byte(testCase.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL+"/ocr", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
actual, err := client.Recognize(context.Background(), []byte("image-bytes"), "image/png")
|
||||
if err != nil || actual != testCase.want {
|
||||
t.Fatalf("Recognize() = %q, %v", actual, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRecognizeFailsClosed(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/other", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL+"/ocr", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
_, err = client.Recognize(context.Background(), []byte("image"), "image/png")
|
||||
if !errors.Is(err, ErrServiceInvalid) {
|
||||
t.Fatalf("Recognize() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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