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,"username":"test-user"},"token":"never-exposed"}}`)) case UserPath: userCalls++ if request.URL.Query().Get("id") != "12" { t.Fatalf("user query = %q", request.URL.RawQuery) } 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,"username":"test-user"}}`)) 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 manager.identity = sessionIdentity{ID: "12", Username: "test-user"} 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 TestSessionManagerUserValidationClassifiesResponses(t *testing.T) { testCases := []struct { name string response string want error }{ { name: "ordinary ERP failure is protocol error", response: `{"status":false,"code":0,"data":null,"msg":"没有任何操作"}`, want: domain.ErrFreightSourceProtocol, }, { name: "unauthenticated code expires session", response: `{"status":false,"code":-2,"data":null,"msg":"未登录"}`, want: domain.ErrFreightSourceSessionNeeded, }, { name: "different user is protocol error", response: `{"status":true,"code":0,"data":{"id":13,"username":"other-user"}}`, want: domain.ErrFreightSourceProtocol, }, } 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 != UserPath { writer.WriteHeader(http.StatusNotFound) return } if request.URL.Query().Get("id") != "12" { t.Fatalf("user query = %q", request.URL.RawQuery) } _, _ = writer.Write([]byte(testCase.response)) })) defer server.Close() manager := testSessionManager(t, server.URL, "test-user", "test-password") manager.authenticated = true manager.identity = sessionIdentity{ID: "12", Username: "test-user"} _, err := manager.Validate(context.Background()) if !errors.Is(err, testCase.want) || manager.Status().Authenticated { t.Fatalf("Validate() 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,"username":"test-user"}}}`)) case UserPath: if r.URL.Query().Get("id") != "12" { t.Fatalf("user query = %q", r.URL.RawQuery) } _, _ = w.Write([]byte(`{"status":true,"data":{"id":12,"username":"test-user"}}`)) 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) } }) } } func TestSessionManagerDiagnosticLogOmitsUserIdentityQuery(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func( writer http.ResponseWriter, request *http.Request, ) { if request.URL.Path != UserPath || request.URL.Query().Get("id") != "12" { writer.WriteHeader(http.StatusNotFound) return } writer.Header().Set("Content-Type", "application/json") _, _ = writer.Write([]byte(`{"status":true,"data":{"id":12,"username":"test-user"}}`)) })) defer server.Close() var events []string manager, err := NewSessionManager(SessionConfig{ BaseURL: server.URL, Username: "test-user", Password: "test-password", Timeout: time.Second, AllowInsecureHTTP: true, DiagnosticLogger: func(event string) { events = append(events, event) }, }) if err != nil { t.Fatalf("NewSessionManager() error = %v", err) } manager.authenticated = true manager.identity = sessionIdentity{ID: "12", Username: "test-user"} if _, err := manager.Validate(context.Background()); err != nil { t.Fatalf("Validate() error = %v", err) } actual := strings.Join(events, "\n") if !strings.Contains(actual, "erp_request method=GET path=/am/user/get") || strings.Contains(actual, "?id=") || strings.Contains(actual, "test-user") || strings.Contains(actual, `"id":12`) { t.Fatalf("diagnostic log leaked user identity: %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) } }