376 lines
12 KiB
Go
376 lines
12 KiB
Go
package shunyunbao
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"cmroubao/backend-api/internal/domain"
|
|
)
|
|
|
|
func TestSessionManagerCaptchaLoginAndValidationShareCookieJar(t *testing.T) {
|
|
var captchaCalls, loginCalls, userCalls int
|
|
server := httptest.NewServer(http.HandlerFunc(func(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
switch request.URL.Path {
|
|
case CaptchaPath:
|
|
captchaCalls++
|
|
assertERPHeaders(t, request)
|
|
http.SetCookie(writer, &http.Cookie{Name: "captcha", Value: "ready", Path: "/"})
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
_, _ = writer.Write([]byte("sanitized-captcha-image"))
|
|
case LoginPath:
|
|
loginCalls++
|
|
assertERPHeaders(t, request)
|
|
if cookie, err := request.Cookie("captcha"); err != nil || cookie.Value != "ready" {
|
|
t.Fatalf("login captcha cookie = %v / %v", cookie, err)
|
|
}
|
|
content, _ := io.ReadAll(request.Body)
|
|
if string(content) != `{"code":"1234","password":"test-password","username":"test-user"}` {
|
|
t.Fatalf("login body = %s", content)
|
|
}
|
|
http.SetCookie(writer, &http.Cookie{Name: "authenticated", Value: "yes", Path: "/"})
|
|
_, _ = writer.Write([]byte(`{"status":true,"data":{"user":{"id":12},"token":"never-exposed"}}`))
|
|
case UserPath:
|
|
userCalls++
|
|
if cookie, err := request.Cookie("authenticated"); err != nil || cookie.Value != "yes" {
|
|
t.Fatalf("user cookie = %v / %v", cookie, err)
|
|
}
|
|
_, _ = writer.Write([]byte(`{"status":true,"data":{"id":12}}`))
|
|
default:
|
|
writer.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
manager := testSessionManager(t, server.URL, "test-user", "test-password")
|
|
status, err := manager.FetchCaptcha(context.Background())
|
|
if err != nil || !status.Configured || !status.CaptchaReady || status.CaptchaTicket == "" {
|
|
t.Fatalf("FetchCaptcha() = %+v, %v", status, err)
|
|
}
|
|
image, err := manager.OpenCaptcha(status.CaptchaTicket)
|
|
if err != nil || image.ContentType != "image/png" ||
|
|
string(image.Content) != "sanitized-captcha-image" {
|
|
t.Fatalf("OpenCaptcha() = %+v, %v", image, err)
|
|
}
|
|
status, err = manager.Login(context.Background(), status.CaptchaTicket, "1234")
|
|
if err != nil || !status.Authenticated || status.CaptchaReady || status.CaptchaTicket != "" {
|
|
t.Fatalf("Login() = %+v, %v", status, err)
|
|
}
|
|
status, err = manager.Validate(context.Background())
|
|
if err != nil || !status.Authenticated || captchaCalls != 1 || loginCalls != 1 || userCalls != 2 {
|
|
t.Fatalf("Validate()/calls = %+v, %v / %d %d %d", status, err, captchaCalls, loginCalls, userCalls)
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerMapsAnonymousFailuresAndExpiresState(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
switch request.URL.Path {
|
|
case CaptchaPath:
|
|
writer.Header().Set("Content-Type", "image/jpeg")
|
|
_, _ = writer.Write([]byte("captcha"))
|
|
case LoginPath:
|
|
_, _ = writer.Write([]byte(`{"status":false,"msg":"private rejected response","data":null}`))
|
|
case UserPath:
|
|
writer.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = writer.Write([]byte("private session response"))
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
missing := testSessionManager(t, server.URL, "", "")
|
|
if _, err := missing.FetchCaptcha(context.Background()); !errors.Is(err, domain.ErrFreightSourceNotConfigured) {
|
|
t.Fatalf("missing FetchCaptcha() error = %v", err)
|
|
}
|
|
manager := testSessionManager(t, server.URL, "test-user", "test-password")
|
|
status, err := manager.FetchCaptcha(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("FetchCaptcha() error = %v", err)
|
|
}
|
|
_, err = manager.Login(context.Background(), status.CaptchaTicket, "bad")
|
|
if !errors.Is(err, ErrLoginRejected) || strings.Contains(err.Error(), "private") {
|
|
t.Fatalf("Login() error = %v", err)
|
|
}
|
|
if manager.Status().Authenticated || manager.Status().CaptchaReady {
|
|
t.Fatalf("rejected login state = %+v", manager.Status())
|
|
}
|
|
if _, err := manager.OpenCaptcha(status.CaptchaTicket); !errors.Is(err, ErrCaptchaTicketInvalid) {
|
|
t.Fatalf("OpenCaptcha() after login error = %v", err)
|
|
}
|
|
|
|
manager = testSessionManager(t, server.URL, "test-user", "test-password")
|
|
manager.captchaTTL = time.Nanosecond
|
|
status, err = manager.FetchCaptcha(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("short FetchCaptcha() error = %v", err)
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
if _, err := manager.OpenCaptcha(status.CaptchaTicket); !errors.Is(err, ErrCaptchaTicketInvalid) {
|
|
t.Fatalf("expired captcha error = %v", err)
|
|
}
|
|
manager.authenticated = true
|
|
if _, err := manager.Validate(context.Background()); !errors.Is(err, domain.ErrFreightSourceSessionNeeded) ||
|
|
manager.Status().Authenticated {
|
|
t.Fatalf("expired session error/status = %v / %+v", err, manager.Status())
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerSerializesCaptchaRequests(t *testing.T) {
|
|
var mutex sync.Mutex
|
|
inFlight, maximum := 0, 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
if request.URL.Path != CaptchaPath {
|
|
writer.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
mutex.Lock()
|
|
inFlight++
|
|
if inFlight > maximum {
|
|
maximum = inFlight
|
|
}
|
|
mutex.Unlock()
|
|
time.Sleep(20 * time.Millisecond)
|
|
mutex.Lock()
|
|
inFlight--
|
|
mutex.Unlock()
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
_, _ = writer.Write([]byte("captcha"))
|
|
}))
|
|
defer server.Close()
|
|
manager := testSessionManager(t, server.URL, "test-user", "test-password")
|
|
var group sync.WaitGroup
|
|
for range 2 {
|
|
group.Add(1)
|
|
go func() {
|
|
defer group.Done()
|
|
if _, err := manager.FetchCaptcha(context.Background()); err != nil {
|
|
t.Errorf("FetchCaptcha() error = %v", err)
|
|
}
|
|
}()
|
|
}
|
|
group.Wait()
|
|
if maximum != 1 {
|
|
t.Fatalf("maximum concurrent ERP requests = %d", maximum)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerDiagnosticLogsAreRedacted(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case CaptchaPath:
|
|
w.Header().Set("Content-Type", "image/png")
|
|
_, _ = w.Write([]byte("private-captcha-image"))
|
|
case LoginPath:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"status":false,
|
|
"msg":"ERP says order 123456789 is blocked",
|
|
"data":{"username":"test-user","password":"test-password","token":"secret-token","orderNumber":"order-123456789"}
|
|
}`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
var events []string
|
|
manager, err := NewSessionManager(SessionConfig{
|
|
BaseURL: server.URL,
|
|
Username: "test-user",
|
|
Password: "test-password",
|
|
Timeout: time.Second,
|
|
AllowInsecureHTTP: true,
|
|
CaptchaRecognizer: &fixedRecognizer{code: "1234"},
|
|
DiagnosticLogger: func(event string) {
|
|
events = append(events, event)
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSessionManager() error = %v", err)
|
|
}
|
|
if err := manager.EnsureAuthenticated(context.Background()); !errors.Is(err, domain.ErrFreightSourceLoginRejected) {
|
|
t.Fatalf("EnsureAuthenticated() error = %v", err)
|
|
}
|
|
actual := strings.Join(events, "\n")
|
|
for _, expected := range []string{
|
|
"erp_request method=GET path=/api/p/code1",
|
|
"erp_response method=GET path=/api/p/code1 status=200",
|
|
"body=omitted_non_json",
|
|
`erp_ocr_result value="1234" length=4`,
|
|
"erp_request method=POST path=/am/auth/login",
|
|
"erp_response method=POST path=/am/auth/login status=200",
|
|
`"status":false`,
|
|
`"password":"[REDACTED]"`,
|
|
} {
|
|
if !strings.Contains(actual, expected) {
|
|
t.Fatalf("diagnostic log missing %q: %s", expected, actual)
|
|
}
|
|
}
|
|
for _, secret := range []string{
|
|
"test-user", "test-password", "secret-token", "123456789", "private-captcha-image",
|
|
} {
|
|
if strings.Contains(actual, secret) {
|
|
t.Fatalf("diagnostic log leaked %q: %s", secret, actual)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerDiagnosticLogsHideInvalidOCRResults(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
recognizer *fixedRecognizer
|
|
wantEvent string
|
|
secret string
|
|
}{
|
|
{
|
|
name: "invalid text",
|
|
recognizer: &fixedRecognizer{code: "private\nOCR output"},
|
|
wantEvent: "erp_ocr_result class=invalid",
|
|
secret: "private",
|
|
},
|
|
{
|
|
name: "recognizer failure",
|
|
recognizer: &fixedRecognizer{err: errors.New("private OCR response")},
|
|
wantEvent: "erp_ocr_result class=failed",
|
|
secret: "private",
|
|
},
|
|
}
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
if request.URL.Path != CaptchaPath {
|
|
writer.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
_, _ = writer.Write([]byte("private-captcha-image"))
|
|
}))
|
|
defer server.Close()
|
|
var events []string
|
|
manager, err := NewSessionManager(SessionConfig{
|
|
BaseURL: server.URL,
|
|
Username: "test-user",
|
|
Password: "test-password",
|
|
Timeout: time.Second,
|
|
AllowInsecureHTTP: true,
|
|
CaptchaRecognizer: testCase.recognizer,
|
|
DiagnosticLogger: func(event string) {
|
|
events = append(events, event)
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSessionManager() error = %v", err)
|
|
}
|
|
if err := manager.EnsureAuthenticated(context.Background()); !errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
|
|
t.Fatalf("EnsureAuthenticated() error = %v", err)
|
|
}
|
|
actual := strings.Join(events, "\n")
|
|
if !strings.Contains(actual, testCase.wantEvent) || strings.Contains(actual, testCase.secret) ||
|
|
strings.Contains(actual, "private-captcha-image") || strings.Contains(actual, "test-password") {
|
|
t.Fatalf("diagnostic log = %s", actual)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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,
|
|
) *SessionManager {
|
|
t.Helper()
|
|
manager, err := NewSessionManager(SessionConfig{
|
|
BaseURL: baseURL,
|
|
Username: username,
|
|
Password: password,
|
|
Timeout: time.Second,
|
|
AllowInsecureHTTP: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSessionManager() error = %v", err)
|
|
}
|
|
return manager
|
|
}
|
|
|
|
func assertERPHeaders(t *testing.T, request *http.Request) {
|
|
t.Helper()
|
|
if request.Header.Get("Accept") != "application/json, text/plain, */*" ||
|
|
request.Header.Get("X-Requested-With") != "XMLHttpRequest" ||
|
|
request.Header.Get("Origin") == "" || request.Header.Get("Referer") == "" {
|
|
t.Fatalf("ERP headers = %#v", request.Header)
|
|
}
|
|
}
|