package webui import ( "bytes" "context" "errors" "io" "mime/multipart" "net/http" "net/http/httptest" "net/url" "regexp" "strconv" "strings" "testing" "time" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/usecase" "github.com/gin-gonic/gin" ) const testTaskID = "00000000-0000-4000-8000-000000000001" func TestListTasksRendersRealRowsEscapedWithSecurityHeaders(t *testing.T) { now := time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC) service := &fakeService{ listResult: TaskList{Items: []TaskSummary{{ ID: testTaskID, Title: ``, SKU: "SKU-1", Status: "PENDING", UpdatedAt: now, }}}, } router := newTestRouter(t, service) response := performRequest( t, router, http.MethodGet, "/tasks?q=%3Cquery%3E&status=PENDING", nil, "", ) if response.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", response.Code, response.Body) } body := response.Body.String() if strings.Contains(body, ``) || !strings.Contains(body, "<script>") { t.Fatalf("task title was not safely escaped: %s", body) } for _, text := range []string{ "SKU-1", "待领取", "/tasks/" + testTaskID, "value=\"<query>\"", } { if !strings.Contains(body, text) { t.Fatalf("body does not contain %q", text) } } assertSecurityHeaders(t, response) if service.listInput.Query != "" || service.listInput.Status != "PENDING" || service.listInput.Limit != defaultListLimit { t.Fatalf("list input = %+v", service.listInput) } } func TestListTasksRendersHonestEmptyState(t *testing.T) { router := newTestRouter(t, &fakeService{}) response := performRequest( t, router, http.MethodGet, "/tasks", nil, "", ) if response.Code != http.StatusOK { t.Fatalf("status = %d", response.Code) } body := response.Body.String() if !strings.Contains(body, "没有符合条件的任务") || !strings.Contains(body, "创建第一条采购任务") { t.Fatalf("empty state missing: %s", body) } for _, fake := range []string{"RB-DEMO", "演示设备", "演示任务"} { if strings.Contains(body, fake) { t.Fatalf("empty page contains fake data %q", fake) } } } func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) { router := newTestRouter(t, &fakeService{}) response := performRequest( t, router, http.MethodGet, "/tasks/new", nil, "", ) if response.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", response.Code, response.Body) } cookie := csrfCookie(t, response) if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/" { t.Fatalf("CSRF cookie = %+v", cookie) } body := response.Body.String() if !strings.Contains( body, `name="csrf_token" value="`+cookie.Value+`"`, ) { t.Fatal("form CSRF token does not match the cookie") } for _, required := range []string{ `name="title"`, `name="sku"`, `name="quantity"`, `name="max_budget"`, `name="image"`, `action="/logout"`, "最高总预算", } { if !strings.Contains(body, required) { t.Fatalf("new task form missing %q", required) } } if strings.Contains(body, "") { t.Fatal("page contains inline style or script incompatible with CSP") } assertSecurityHeaders(t, response) } func TestNewTaskPrefersRootCSRFCookieDuringLegacyPathMigration( t *testing.T, ) { router := newTestRouter(t, &fakeService{}) legacy := mustToken(t) root := mustToken(t) for root == legacy { root = mustToken(t) } request := httptest.NewRequest(http.MethodGet, "/tasks/new", nil) request.AddCookie(&http.Cookie{ Name: csrfCookieName, Value: legacy, Path: "/tasks", }) request.AddCookie(&http.Cookie{ Name: csrfCookieName, Value: root, Path: "/", }) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusOK || !strings.Contains( response.Body.String(), `name="csrf_token" value="`+root+`"`, ) { t.Fatalf("status/body = %d / %s", response.Code, response.Body) } } func TestCreateTaskRejectsCSRFBeforeCallingService(t *testing.T) { service := &fakeService{} router := newTestRouter(t, service) body, contentType := multipartBody(t, map[string]string{ "csrf_token": "invalid", "title": "标题", "sku": "SKU-1", "quantity": "1", "upload_key": mustToken(t), "create_key": mustToken(t), }, "image", "reference.jpg", []byte("not inspected")) response := performRequest( t, router, http.MethodPost, "/tasks", body, contentType, ) if response.Code != http.StatusForbidden { t.Fatalf("status = %d, body = %s", response.Code, response.Body) } if service.uploadCalls != 0 || service.createCalls != 0 { t.Fatalf( "service calls = upload %d, create %d", service.uploadCalls, service.createCalls, ) } } func TestCreateTaskValidationRetainsEscapedSafeFields(t *testing.T) { service := &fakeService{} router := newTestRouter(t, service) cookie := getCSRFCookie(t, router) body, contentType := multipartBody(t, map[string]string{ "csrf_token": cookie.Value, "title": "", "sku": "", "description": ``, "quantity": "0", "max_budget": "1.001", "upload_key": mustToken(t), "create_key": mustToken(t), }, "", "", nil) request := httptest.NewRequest(http.MethodPost, "/tasks", body) request.Header.Set("Content-Type", contentType) request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusUnprocessableEntity { t.Fatalf("status = %d, body = %s", response.Code, response.Body) } rendered := response.Body.String() for _, message := range []string{ "请输入商品标题", "请输入 SKU", "数量必须是大于 0 的整数", "最多两位小数", "<img src=x onerror="alert(1)">", } { if !strings.Contains(rendered, message) { t.Fatalf("response missing %q", message) } } if strings.Contains(rendered, ``) { t.Fatal("description was rendered as active HTML") } if service.uploadCalls != 0 || service.createCalls != 0 { t.Fatal("invalid form reached the service") } } func TestCreateValidationUsesContractUTF8ByteLimits(t *testing.T) { valid := func() newTaskPageView { token := mustToken(t) return newTaskPageView{ CSRFToken: token, UploadKey: mustToken(t), CreateKey: mustToken(t), Form: createFormView{ Title: "标题", SKU: "SKU-1", Quantity: "1", QuantityValue: 1, }, } } skuPage := valid() skuPage.Form.SKU = strings.Repeat("货", maxSKUBytes/3+1) if !validateCreateForm(&skuPage) || !strings.Contains(skuPage.Errors.SKU, "512") { t.Fatalf("SKU errors = %+v", skuPage.Errors) } descriptionPage := valid() descriptionPage.Form.Description = strings.Repeat( "说", maxDescriptionBytes/3+1, ) if !validateCreateForm(&descriptionPage) || !strings.Contains(descriptionPage.Errors.Description, "8192") { t.Fatalf("description errors = %+v", descriptionPage.Errors) } titlePage := valid() titlePage.Form.Title = strings.Repeat("题", maxTitleRunes+1) if !validateCreateForm(&titlePage) || !strings.Contains(titlePage.Errors.Title, "120") { t.Fatalf("title errors = %+v", titlePage.Errors) } if maxRequestBytes < (20<<20)+(1<<20) { t.Fatalf("maxRequestBytes = %d, does not cover a 20 MiB image", maxRequestBytes) } } func TestCreateTaskUploadsThenRedirectsWithPRG(t *testing.T) { service := &fakeService{ uploadResult: UploadedAsset{ ID: "00000000-0000-4000-8000-000000000099", }, createResult: Task{ID: testTaskID}, } router := newTestRouter(t, service) cookie := getCSRFCookie(t, router) body, contentType := multipartBody(t, map[string]string{ "csrf_token": cookie.Value, "title": " 桌面收纳盒 ", "sku": " SKU-1 ", "description": "浅灰色", "quantity": "2", "max_budget": "60.00", "upload_key": mustToken(t), "create_key": mustToken(t), }, "image", "reference.jpg", []byte("image bytes")) request := httptest.NewRequest(http.MethodPost, "/tasks", body) request.Header.Set("Content-Type", contentType) request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/tasks/"+testTaskID { t.Fatalf( "status/location = %d/%q, body = %s", response.Code, response.Header().Get("Location"), response.Body, ) } if service.uploadCalls != 1 || service.createCalls != 1 { t.Fatalf( "service calls = upload %d, create %d", service.uploadCalls, service.createCalls, ) } if string(service.uploadBody) != "image bytes" || service.createInput.Title != "桌面收纳盒" || service.createInput.SKU != "SKU-1" || service.createInput.Quantity != 2 || service.createInput.MaxBudget != "60.00" || service.createInput.ImageAssetID != service.uploadResult.ID { t.Fatalf( "upload/create input = %q / %+v", service.uploadBody, service.createInput, ) } } func TestCreateTaskFailureRetainsUploadedAssetForRetry(t *testing.T) { assetID := "00000000-0000-4000-8000-000000000099" service := &fakeService{ uploadResult: UploadedAsset{ ID: assetID, }, createErr: ErrUnavailable, } router := newTestRouter(t, service) cookie := getCSRFCookie(t, router) body, contentType := validCreateBody(t, cookie.Value, nil) request := httptest.NewRequest(http.MethodPost, "/tasks", body) request.Header.Set("Content-Type", contentType) request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, body = %s", response.Code, response.Body) } rendered := response.Body.String() for _, value := range []string{ `name="image_asset_id" value="` + assetID + `"`, "再次提交会复用该图片", } { if !strings.Contains(rendered, value) { t.Fatalf("response missing %q", value) } } if strings.Contains(rendered, "reference.jpg") { t.Fatal("server response leaked the client file name") } } func TestCreateTaskRetryReusesAssetWithoutUpload(t *testing.T) { assetID := "00000000-0000-4000-8000-000000000099" service := &fakeService{ createResult: Task{ID: testTaskID}, } router := newTestRouter(t, service) cookie := getCSRFCookie(t, router) body, contentType := validCreateBody(t, cookie.Value, map[string]string{ "image_asset_id": assetID, }) request := httptest.NewRequest(http.MethodPost, "/tasks", body) request.Header.Set("Content-Type", contentType) request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusSeeOther { t.Fatalf("status = %d, body = %s", response.Code, response.Body) } if service.uploadCalls != 0 || service.createInput.ImageAssetID != assetID { t.Fatalf( "upload calls / asset = %d / %q", service.uploadCalls, service.createInput.ImageAssetID, ) } } func TestTaskDetailPendingCancelUsesCSRFAndPRG(t *testing.T) { service := &fakeService{ getResult: Task{ ID: testTaskID, Title: "桌面收纳盒", SKU: "SKU-1", Description: "浅灰色", Quantity: 2, MaxBudget: "60.00", Status: "PENDING", ReferenceAssetID: "00000000-0000-4000-8000-000000000009", CreatedAt: time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC), UpdatedAt: time.Date(2026, 7, 26, 3, 5, 5, 0, time.UTC), }, cancelResult: Task{Status: "CANCELED"}, } router := newTestRouter(t, service) detail := performRequest( t, router, http.MethodGet, "/tasks/"+testTaskID, nil, "", ) if detail.Code != http.StatusOK { t.Fatalf("detail status = %d, body = %s", detail.Code, detail.Body) } cookie := csrfCookie(t, detail) cancelKey := hiddenValue(t, detail.Body.String(), "cancel_key") for _, value := range []string{ "桌面收纳盒", "最高总预算", "/api/v1/assets/00000000-0000-4000-8000-000000000009/content", "取消任务", "确认取消任务", } { if !strings.Contains(detail.Body.String(), value) { t.Fatalf("detail missing %q", value) } } form := url.Values{ "csrf_token": {cookie.Value}, "cancel_key": {cancelKey}, } request := httptest.NewRequest( http.MethodPost, "/tasks/"+testTaskID+"/cancel", strings.NewReader(form.Encode()), ) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/tasks/"+testTaskID+"?notice=canceled" { t.Fatalf( "status/location = %d/%q", response.Code, response.Header().Get("Location"), ) } if service.cancelInput.TaskID != testTaskID || service.cancelInput.IdempotencyKey != cancelKey { t.Fatalf("cancel input = %+v", service.cancelInput) } } func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) { service := &fakeService{getErr: ErrForbidden} router := newTestRouter(t, service) response := performRequest( t, router, http.MethodGet, "/tasks/"+testTaskID, nil, "", ) if response.Code != http.StatusNotFound { t.Fatalf("status = %d", response.Code) } if !strings.Contains(response.Body.String(), "不存在或当前不可访问") { t.Fatalf("safe not-found message missing: %s", response.Body) } } func TestTaskDetailAuthorizesOneCandidateWithCSRFAndPRG(t *testing.T) { firstKey := strings.Repeat("a", 64) secondKey := strings.Repeat("b", 64) service := &fakeService{ getResult: Task{ ID: testTaskID, Title: "候选确认任务", SKU: "BLACK-L", Quantity: 2, Status: "WAITING_CONFIRMATION", Version: 7, ReferenceAssetID: "00000000-0000-4000-8000-000000000009", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), TaskContentSHA256: strings.Repeat("c", 64), Candidates: []AuthorizationCandidate{ { CandidateKey: firstKey, Ordinal: 1, Title: "候选一", SKUText: "BLACK-L", PriceText: "20.00", EvidenceURLs: []string{"/api/v1/assets/evidence-1/content"}, }, { CandidateKey: secondKey, Ordinal: 2, Title: "候选二", SKUText: "BLACK-XL", PriceText: "22.00", }, }, }, } router := newTestRouter(t, service) detail := performRequest( t, router, http.MethodGet, "/tasks/"+testTaskID, nil, "", ) if detail.Code != http.StatusOK { t.Fatalf("detail status/body = %d/%s", detail.Code, detail.Body) } for _, expected := range []string{ "候选确认与下单授权", "系统只创建待付款订单", "候选一", "BLACK-XL", `name="candidate_key"`, } { if !strings.Contains(detail.Body.String(), expected) { t.Fatalf("authorization detail missing %q", expected) } } cookie := csrfCookie(t, detail) authorizationKey := hiddenValue( t, detail.Body.String(), "authorization_key", ) form := url.Values{ "csrf_token": {cookie.Value}, "authorization_key": {authorizationKey}, "expected_task_version": {"7"}, "candidate_key": {firstKey}, "selected_reason_code": {"SKU_MATCH"}, "rejected_reason_code": {"NOT_BEST_MATCH"}, "authorization_note": {"已核对图片、规格与价格"}, "supersedes_authorization_id": {""}, } request := httptest.NewRequest( http.MethodPost, "/tasks/"+testTaskID+"/order-authorizations", strings.NewReader(form.Encode()), ) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/tasks/"+testTaskID+"?notice=authorization-created" { t.Fatalf( "status/location = %d/%q", response.Code, response.Header().Get("Location"), ) } if service.authorizeInput.TaskID != testTaskID || service.authorizeInput.IdempotencyKey != authorizationKey || service.authorizeInput.ExpectedTaskVersion != 7 || service.authorizeInput.CandidateKey != firstKey || service.authorizeInput.SelectedReasonCode != "SKU_MATCH" || service.authorizeInput.RejectedReasonCode != "NOT_BEST_MATCH" { t.Fatalf("authorize input = %+v", service.authorizeInput) } } func TestTaskDetailDisablesAuthorizationAfterDelivery(t *testing.T) { view := taskDetailViewFrom(Task{ Status: "WAITING_CONFIRMATION", Candidates: []AuthorizationCandidate{{ CandidateKey: strings.Repeat("a", 64), }}, OrderAuthorizations: []OrderAuthorization{{ ID: "00000000-0000-4000-8000-000000000010", Status: "DELIVERED", }}, }) if view.CanAuthorizeOrder { t.Fatal("delivered authorization remains editable") } } func TestTaskDetailRendersOrderSubmissionStatesWithoutPaymentActions( t *testing.T, ) { now := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC) tests := []struct { name string taskStatus string submission OrderSubmission expected []string forbidden []string }{ { name: "reconciling", taskStatus: "WAITING_CONFIRMATION", submission: OrderSubmission{ Status: "FENCED", StatusLabel: "正在对账", FencedAt: now, }, expected: []string{ "订单提交结果正在对账", "禁止重复提交", }, forbidden: []string{"待人工确认付款", "验证完成,未提交订单"}, }, { name: "manual review", taskStatus: "WAITING_CONFIRMATION", submission: OrderSubmission{ Status: "MANUAL_REVIEW", StatusLabel: "需要人工对账", ManualReasonLabel: "找到多个可能订单", FencedAt: now, ManualReviewAt: now.Add(time.Minute), }, expected: []string{ "需要人工对账", "找到多个可能订单", "禁止重新提交订单", }, forbidden: []string{"待人工确认付款", "验证完成,未提交订单"}, }, { name: "pending payment", taskStatus: "SUCCEEDED", submission: OrderSubmission{ Status: "RECONCILED", StatusLabel: "待人工确认付款", ExpectedTitle: "已授权灰色上衣", ExpectedSKU: "灰色,2XL", ExpectedQuantity: 2, ExpectedUnitPrice: "21.50", ExpectedTotalPrice: "43.00", PlatformOrderNo: "12345678901234567890", PlatformOrderedAt: now, PlatformOrderStatus: "PENDING_PAYMENT", EvidenceContentURL: "/api/v1/tasks/" + testTaskID + "/evidence/00000000-0000-4000-8000-000000000078/content", FencedAt: now.Add(-time.Minute), ReconciledAt: now.Add(time.Minute), }, expected: []string{ "待人工确认付款", "请采购员打开拼多多订单列表", "12345678901234567890", "灰色,2XL", "¥43.00", "待付款订单列表对账截图", }, forbidden: []string{ "验证完成,未提交订单", "重试提交", `href="pinduoduo`, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { service := &fakeService{ getResult: Task{ ID: testTaskID, Title: "订单状态任务", SKU: "灰色,2XL", Quantity: 2, Status: test.taskStatus, ReferenceAssetID: "00000000-0000-4000-8000-000000000009", CreatedAt: now, UpdatedAt: now, OrderSubmissions: []OrderSubmission{test.submission}, }, } response := performRequest( t, newTestRouter(t, service), http.MethodGet, "/tasks/"+testTaskID, nil, "", ) if response.Code != http.StatusOK { t.Fatalf( "detail status/body = %d/%s", response.Code, response.Body, ) } body := response.Body.String() for _, expected := range test.expected { if !strings.Contains(body, expected) { t.Fatalf("detail missing %q: %s", expected, body) } } for _, forbidden := range test.forbidden { if strings.Contains(body, forbidden) { t.Fatalf("detail contains forbidden %q", forbidden) } } if regexp.MustCompile( `(?s)<(?:a|button)[^>]*>[^<]*(?:立即支付|确认支付|自动付款)`, ).MatchString(body) { t.Fatal("detail exposes a payment action") } }) } } func TestTaskDetailKeepsHistoricalSucceededCopyWithoutSubmission(t *testing.T) { now := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC) response := performRequest( t, newTestRouter(t, &fakeService{getResult: Task{ ID: testTaskID, Title: "历史验证任务", SKU: "HISTORY-SKU", Quantity: 1, Status: "SUCCEEDED", ReferenceAssetID: "00000000-0000-4000-8000-000000000009", CreatedAt: now, UpdatedAt: now, }}), http.MethodGet, "/tasks/"+testTaskID, nil, "", ) if response.Code != http.StatusOK || !strings.Contains( response.Body.String(), "验证完成,未提交订单", ) { t.Fatalf( "historical succeeded detail = %d/%s", response.Code, response.Body, ) } } func TestOrderSubmissionAdapterBuildsAuthenticatedEvidenceURL(t *testing.T) { orderNo := "12345678901234567890" status := "PENDING_PAYMENT" evidenceID := "00000000-0000-4000-8000-000000000078" orderedAt := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC) submission := orderSubmissionFrom(domain.OrderSubmission{ ID: "00000000-0000-4000-8000-000000000079", TaskID: testTaskID, Status: domain.OrderSubmissionReconciled, ExpectedTitle: "已授权商品", ExpectedSKU: "灰色,2XL", ExpectedQuantity: 2, ExpectedUnitPriceCents: 2150, ExpectedTotalPriceCents: 4300, PlatformOrderNo: &orderNo, PlatformOrderedAt: &orderedAt, PlatformOrderStatus: &status, ReconciliationEvidenceAssetID: &evidenceID, FencedAt: orderedAt.Add(-time.Minute), ReconciledAt: &orderedAt, }) if submission.StatusLabel != "待人工确认付款" || submission.PlatformOrderNo != orderNo || submission.ExpectedUnitPrice != "21.50" || submission.ExpectedTotalPrice != "43.00" || submission.EvidenceContentURL != "/api/v1/tasks/"+testTaskID+"/evidence/"+evidenceID+"/content" || strings.Contains(submission.EvidenceContentURL, orderNo) { t.Fatalf("submission view = %+v", submission) } } func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) { tests := []struct { status string canCancel bool requiresAck bool }{ {status: "PENDING", canCancel: true}, {status: "CLAIMED", canCancel: true}, {status: "RUNNING", canCancel: true, requiresAck: true}, { status: "WAITING_CONFIRMATION", canCancel: true, requiresAck: true, }, {status: "SUCCEEDED"}, {status: "FAILED"}, {status: "CANCELED"}, } for _, test := range tests { t.Run(test.status, func(t *testing.T) { view := taskDetailViewFrom(Task{Status: test.status}) if view.CanCancel != test.canCancel || view.CancelRequiresAck != test.requiresAck { t.Fatalf("task detail view = %+v", view) } }) } if notice := detailNotice("cancel-requested"); !strings.Contains( notice, "安全停止", ) { t.Fatalf("cancel requested notice = %q", notice) } } func TestRunningTaskDetailExplainsCancelAcknowledgement(t *testing.T) { service := &fakeService{ getResult: Task{ ID: testTaskID, Title: "运行中任务", SKU: "RUNNING-SKU", Quantity: 1, Status: "RUNNING", ReferenceAssetID: "00000000-0000-4000-8000-000000000009", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }, } router := newTestRouter(t, service) response := performRequest( t, router, http.MethodGet, "/tasks/"+testTaskID, nil, "", ) if response.Code != http.StatusOK { t.Fatalf("running task detail status = %d", response.Code) } body := response.Body.String() for _, expected := range []string{ "请求安全停止任务?", "设备确认前任务仍保持当前执行状态", "确认请求停止", } { if !strings.Contains(body, expected) { t.Fatalf("running detail missing %q", expected) } } if strings.Contains(body, "采购执行员将不能再领取") { t.Fatal("running detail uses immediate cancellation copy") } } func TestStaticFilesAreEmbeddedAndProtected(t *testing.T) { router := newTestRouter(t, &fakeService{}) for _, route := range []string{"/static/admin.css", "/static/admin.js"} { response := performRequest( t, router, http.MethodGet, route, nil, "", ) if response.Code != http.StatusOK || response.Body.Len() == 0 { t.Fatalf("%s status/bytes = %d/%d", route, response.Code, response.Body.Len()) } assertSecurityHeaders(t, response) } } func TestRendererUsesMissingKeyErrors(t *testing.T) { renderer, err := NewRenderer() if err != nil { t.Fatalf("NewRenderer() error = %v", err) } var output bytes.Buffer if err := renderer.Execute(&output, "tasks", struct{}{}); err == nil { t.Fatal("Execute() with incomplete data error = nil") } } func TestFreightPagesEscapeSourceDataAndCreateSynchronousOrderSync(t *testing.T) { now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC) service := &fakeFreightService{ fakeService: &fakeService{}, orders: []FreightOrder{{ ID: testTaskID, ExternalStockID: "12", SourceCode: ``, ShopName: "测试店铺", ItemCount: 2, Revision: 1, UpdatedAt: now, }}, createResult: FreightSync{ ID: testTaskID, Status: "SUCCEEDED", CreatedAt: now, }, } router := newTestRouter(t, service) list := performRequest(t, router, http.MethodGet, "/freight", nil, "") if list.Code != http.StatusOK || strings.Contains(list.Body.String(), ``) || !strings.Contains(list.Body.String(), "<script>private") || !strings.Contains(list.Body.String(), "2 项") { t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body) } assertSecurityHeaders(t, list) form := performRequest( t, router, http.MethodGet, "/freight/import", nil, "", ) if !strings.Contains(form.Body.String(), "同步至现在") || !strings.Contains(form.Body.String(), `name="created_from"`) || !strings.Contains(form.Body.String(), "尚无日期同步水位") { t.Fatalf("freight import form = %s", form.Body) } cookie := csrfCookie(t, form) idempotencyKey := hiddenValue( t, form.Body.String(), "idempotency_key", ) values := url.Values{ "csrf_token": {cookie.Value}, "idempotency_key": {idempotencyKey}, "order_number": {"SOURCE-12"}, } request := httptest.NewRequest( http.MethodPost, "/freight/import", strings.NewReader(values.Encode()), ) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/freight?notice=import-succeeded" { t.Fatalf( "create sync status/location = %d / %q", response.Code, response.Header().Get("Location"), ) } if service.createInput.OrderNumber != "SOURCE-12" || service.createInput.IdempotencyKey != idempotencyKey { t.Fatalf("create input = %+v", service.createInput) } list = performRequest( t, router, http.MethodGet, "/freight?notice=import-succeeded", nil, "", ) if !strings.Contains(list.Body.String(), "ERP 货运单已同步") { t.Fatalf("freight success notice = %s", list.Body) } } func TestFreightDateFormSubmitsManualRange(t *testing.T) { now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC) service := &fakeFreightService{ fakeService: &fakeService{}, watermark: &FreightWatermark{ LastSuccessfulTo: now, LastSuccessfulRunID: testTaskID, UpdatedAt: now, }, createResult: FreightSync{ ID: testTaskID, Mode: "CREATED_RANGE", Status: "PENDING", CreatedAt: now, }, } router := newTestRouter(t, service) form := performRequest(t, router, http.MethodGet, "/freight/import", nil, "") if !strings.Contains(form.Body.String(), "增量同步水位") || !strings.Contains(form.Body.String(), testTaskID) { t.Fatalf("watermark form = %s", form.Body) } cookie := csrfCookie(t, form) key := hiddenValue(t, form.Body.String(), "idempotency_key") values := url.Values{ "csrf_token": {cookie.Value}, "idempotency_key": {key}, "mode": {"CREATED_RANGE"}, "created_from": {"2026-07-22"}, "created_to": {"2026-07-28"}, } request := httptest.NewRequest( http.MethodPost, "/freight/import", strings.NewReader(values.Encode()), ) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/freight/import?sync="+testTaskID || service.createInput.Mode != "CREATED_RANGE" || service.createInput.CreatedFrom != "2026-07-22" || service.createInput.CreatedTo != "2026-07-28" { t.Fatalf( "date form response/input = %d / %+v", response.Code, service.createInput, ) } } func TestFreightSyncToNowIgnoresPrefilledManualDates(t *testing.T) { service := &fakeFreightService{ fakeService: &fakeService{}, createResult: FreightSync{ ID: testTaskID, Mode: "CREATED_RANGE", Status: "PENDING", }, } router := newTestRouter(t, service) form := performRequest(t, router, http.MethodGet, "/freight/import", nil, "") cookie := csrfCookie(t, form) key := hiddenValue(t, form.Body.String(), "idempotency_key") values := url.Values{ "csrf_token": {cookie.Value}, "idempotency_key": {key}, "mode": {"CREATED_RANGE"}, "created_from": {"2026-07-22"}, "created_to": {"2026-07-28"}, "sync_to_now": {"true"}, } request := httptest.NewRequest( http.MethodPost, "/freight/import", strings.NewReader(values.Encode()), ) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || !service.createInput.SyncToNow || service.createInput.CreatedFrom != "" || service.createInput.CreatedTo != "" { t.Fatalf( "sync-to-now response/input = %d / %+v", response.Code, service.createInput, ) } } func TestERPConnectionRoutesAreNotRegistered(t *testing.T) { router := newTestRouter(t, &fakeService{}) for _, target := range []string{"/erp", "/erp/captcha", "/erp/login"} { response := performRequest(t, router, http.MethodGet, target, nil, "") if response.Code != http.StatusNotFound { t.Fatalf("%s status = %d", target, response.Code) } } } func TestFreightImportShowsStablePreflightErrorDialog(t *testing.T) { testCases := []struct { name string err error status int code string title string }{ {"ocr", ErrOCRServiceInvalid, http.StatusServiceUnavailable, "OCR_SERVICE_INVALID", "OCR 服务无效"}, {"not configured", ErrERPNotConfigured, http.StatusUnprocessableEntity, "ERP_NOT_CONFIGURED", "ERP 凭证未配置"}, {"session", ErrERPSessionNeeded, http.StatusServiceUnavailable, "ERP_SESSION_REQUIRED", "ERP 会话已失效"}, {"not found", ErrERPFreightNotFound, http.StatusNotFound, "ERP_FREIGHT_NOT_FOUND", "未找到货运单"}, {"login rejected", ErrERPLoginRejected, http.StatusUnprocessableEntity, "ERP_LOGIN_REJECTED", "ERP 登录被拒绝"}, {"protocol", ErrERPProtocol, http.StatusBadGateway, "ERP_RESPONSE_INVALID", "ERP 响应无效"}, {"unavailable", ErrERPUnavailable, http.StatusServiceUnavailable, "ERP_UNAVAILABLE", "ERP 暂时不可用"}, {"busy", ErrFreightSyncBusy, http.StatusConflict, "FREIGHT_SYNC_BUSY", "ERP 正在同步"}, {"timeout", ErrFreightSyncTimeout, http.StatusGatewayTimeout, "FREIGHT_SYNC_TIMEOUT", "ERP 同步超时"}, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { service := &fakeFreightService{fakeService: &fakeService{}, err: testCase.err} var event string router := newTestRouterWithLogger(t, service, func(value string) { event = value }) page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "") if page.Code != http.StatusOK { t.Fatalf("import page status = %d", page.Code) } cookie := csrfCookie(t, page) values := url.Values{ "csrf_token": {cookie.Value}, "idempotency_key": {mustToken(t)}, "mode": {"ORDER_NUMBER"}, "order_number": {"ORDER-123"}, } request := httptest.NewRequest(http.MethodPost, "/freight/import", strings.NewReader(values.Encode())) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != testCase.status || !strings.Contains(response.Body.String(), testCase.code) || !strings.Contains(response.Body.String(), testCase.title) || !strings.Contains(response.Body.String(), "ORDER-123") || strings.Contains(response.Body.String(), "private") || service.createInput.OrderNumber != "ORDER-123" { t.Fatalf("dialog response/input = %d / %s / %+v", response.Code, response.Body, service.createInput) } wantEvent := "freight_import_failed code=" + testCase.code + " status=" + strconv.Itoa(testCase.status) if event != wantEvent || strings.Contains(event, "private") { t.Fatalf("event = %q, want %q", event, wantEvent) } }) } } func TestFreightImportLogsUnknownFailureWithoutRawError(t *testing.T) { service := &fakeFreightService{ fakeService: &fakeService{}, err: errors.New("private ERP response with cookie and password"), } var event string router := newTestRouterWithLogger(t, service, func(value string) { event = value }) page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "") cookie := csrfCookie(t, page) values := url.Values{ "csrf_token": {cookie.Value}, "idempotency_key": {mustToken(t)}, "mode": {"ORDER_NUMBER"}, "order_number": {"ORDER-123"}, } request := httptest.NewRequest(http.MethodPost, "/freight/import", strings.NewReader(values.Encode())) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusInternalServerError || event != "freight_import_failed status=500" || strings.Contains(event, "private") { t.Fatalf("response/event = %d / %q", response.Code, event) } } func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) { const itemID = "00000000-0000-4000-8000-000000000002" service := &fakeProcurementService{ fakeFreightService: &fakeFreightService{ fakeService: &fakeService{}, orderDetail: FreightOrderDetail{ Order: FreightOrder{ ID: testTaskID, ExternalStockID: "12", SourceCode: "SOURCE-12", }, Items: []FreightItemReview{{ Item: FreightOrderItem{ ID: itemID, ExternalItemID: "88", Title: "商品", SKU: "BLACK-L", Quantity: 2, }, Request: &ProcurementRequest{ ID: itemID, FreightOrderItemID: itemID, Status: "READY", StatusLabel: "可以生成任务", }, }}, }, }, createTaskResult: Task{ ID: testTaskID, Status: "PENDING", }, } router := newTestRouter(t, service) detail := performRequest( t, router, http.MethodGet, "/freight/"+testTaskID, nil, "", ) if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), "生成采购任务") || !strings.Contains(detail.Body.String(), "可以生成任务") { t.Fatalf("detail status/body = %d / %s", detail.Code, detail.Body) } cookie := csrfCookie(t, detail) taskKey := hiddenValue(t, detail.Body.String(), "task_key") values := url.Values{ "csrf_token": {cookie.Value}, "order_id": {testTaskID}, "task_key": {taskKey}, } request := httptest.NewRequest( http.MethodPost, "/freight/procurement-requests/"+itemID+"/purchase-task", strings.NewReader(values.Encode()), ) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.AddCookie(cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/tasks/"+testTaskID { t.Fatalf( "create task status/location = %d / %q", response.Code, response.Header().Get("Location"), ) } if service.createTaskInput.RequestID != itemID || service.createTaskInput.IdempotencyKey != taskKey { t.Fatalf("create task input = %+v", service.createTaskInput) } } type fakeService struct { listInput ListTasksInput listResult TaskList listErr error getResult Task getErr error uploadResult UploadedAsset uploadErr error uploadInput UploadReferenceInput uploadBody []byte uploadCalls int createResult Task createErr error createInput CreateTaskInput createCalls int cancelResult Task cancelErr error cancelInput CancelTaskInput authorizeResult OrderAuthorization authorizeErr error authorizeInput AuthorizeOrderInput } type fakeFreightService struct { *fakeService orders []FreightOrder orderDetail FreightOrderDetail sync FreightSync createInput CreateFreightSyncInput createResult FreightSync watermark *FreightWatermark err error } type fakeERPService struct { *fakeService status ERPConnectionStatus image ERPCaptchaImage ticket string captchaRequests int login ERPLoginInput err error } func (service *fakeERPService) ERPConnectionStatus( context.Context, ) (ERPConnectionStatus, error) { return service.status, service.err } func (service *fakeERPService) RequestERPCaptcha( context.Context, ) (ERPConnectionStatus, error) { service.captchaRequests++ service.status.CaptchaReady = true service.status.CaptchaTicket = service.ticket return service.status, service.err } func (service *fakeERPService) OpenERPCaptcha( context.Context, string, ) (ERPCaptchaImage, error) { return service.image, service.err } func (service *fakeERPService) LoginERP( _ context.Context, input ERPLoginInput, ) (ERPConnectionStatus, error) { service.login = input if service.err == nil { service.status.Authenticated = true service.status.CaptchaReady = false service.status.CaptchaTicket = "" } return service.status, service.err } type fakeProcurementService struct { *fakeFreightService createRequestInput CreateProcurementRequestInput bindInput BindProcurementReferenceInput createTaskInput CreateProcurementTaskInput procurementResult ProcurementRequest createTaskResult Task procurementError error } func (service *fakeProcurementService) CreateProcurementRequest( _ context.Context, input CreateProcurementRequestInput, ) (ProcurementRequest, error) { service.createRequestInput = input return service.procurementResult, service.procurementError } func (service *fakeProcurementService) BindProcurementReference( _ context.Context, input BindProcurementReferenceInput, ) (ProcurementRequest, error) { service.bindInput = input return service.procurementResult, service.procurementError } func (service *fakeProcurementService) CreateProcurementTask( _ context.Context, input CreateProcurementTaskInput, ) (Task, error) { service.createTaskInput = input return service.createTaskResult, service.procurementError } func (service *fakeFreightService) ListFreightOrders( context.Context, int, ) ([]FreightOrder, error) { return service.orders, service.err } func (service *fakeFreightService) GetFreightOrder( context.Context, string, ) (FreightOrderDetail, error) { return service.orderDetail, service.err } func (service *fakeFreightService) GetFreightSync( context.Context, string, ) (FreightSync, error) { return service.sync, service.err } func (service *fakeFreightService) GetFreightWatermark( context.Context, ) (*FreightWatermark, error) { return service.watermark, service.err } func (service *fakeFreightService) CreateFreightSync( _ context.Context, input CreateFreightSyncInput, ) (FreightSync, error) { service.createInput = input return service.createResult, service.err } func (service *fakeService) ListTasks( _ context.Context, input ListTasksInput, ) (TaskList, error) { service.listInput = input return service.listResult, service.listErr } func (service *fakeService) GetTask( context.Context, string, ) (Task, error) { return service.getResult, service.getErr } func (service *fakeService) UploadReference( _ context.Context, input UploadReferenceInput, ) (UploadedAsset, error) { service.uploadCalls++ service.uploadInput = input content, err := io.ReadAll(input.Content) if err != nil { return UploadedAsset{}, err } service.uploadBody = content return service.uploadResult, service.uploadErr } func (service *fakeService) CreateTask( _ context.Context, input CreateTaskInput, ) (Task, error) { service.createCalls++ service.createInput = input return service.createResult, service.createErr } func (service *fakeService) CancelTask( _ context.Context, input CancelTaskInput, ) (Task, error) { service.cancelInput = input return service.cancelResult, service.cancelErr } func (service *fakeService) AuthorizeOrder( _ context.Context, input AuthorizeOrderInput, ) (OrderAuthorization, error) { service.authorizeInput = input return service.authorizeResult, service.authorizeErr } func newTestRouter(t *testing.T, service Service) http.Handler { return newTestRouterWithLogger(t, service, nil) } func newTestRouterWithLogger( t *testing.T, service Service, logEvent EventLogger, ) http.Handler { t.Helper() gin.SetMode(gin.TestMode) renderer, err := NewRenderer() if err != nil { t.Fatalf("NewRenderer() error = %v", err) } handler, err := NewHandler(service, renderer, logEvent) if err != nil { t.Fatalf("NewHandler() error = %v", err) } router := gin.New() handler.Register(router) return router } func performRequest( t *testing.T, handler http.Handler, method string, path string, body io.Reader, contentType string, ) *httptest.ResponseRecorder { t.Helper() request := httptest.NewRequest(method, path, body) if contentType != "" { request.Header.Set("Content-Type", contentType) } response := httptest.NewRecorder() handler.ServeHTTP(response, request) return response } func getCSRFCookie(t *testing.T, handler http.Handler) *http.Cookie { t.Helper() response := performRequest( t, handler, http.MethodGet, "/tasks/new", nil, "", ) return csrfCookie(t, response) } func csrfCookie( t *testing.T, response *httptest.ResponseRecorder, ) *http.Cookie { t.Helper() for _, cookie := range response.Result().Cookies() { if cookie.Name == csrfCookieName && cookie.Path == "/" && cookie.Value != "" { return cookie } } t.Fatal("CSRF cookie not found") return nil } func multipartBody( t *testing.T, fields map[string]string, fileField string, fileName string, content []byte, ) (*bytes.Buffer, string) { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) for name, value := range fields { if err := writer.WriteField(name, value); err != nil { t.Fatalf("WriteField(%s): %v", name, err) } } if fileField != "" { part, err := writer.CreateFormFile(fileField, fileName) if err != nil { t.Fatalf("CreateFormFile(): %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("write file: %v", err) } } if err := writer.Close(); err != nil { t.Fatalf("close multipart: %v", err) } return &body, writer.FormDataContentType() } func validCreateBody( t *testing.T, csrfToken string, overrides map[string]string, ) (*bytes.Buffer, string) { t.Helper() fields := map[string]string{ "csrf_token": csrfToken, "title": "桌面收纳盒", "sku": "SKU-1", "description": "浅灰色", "quantity": "2", "max_budget": "60.00", "upload_key": mustToken(t), "create_key": mustToken(t), } for name, value := range overrides { fields[name] = value } fileField := "image" fileName := "reference.jpg" content := []byte("image bytes") if fields["image_asset_id"] != "" { fileField = "" fileName = "" content = nil } return multipartBody(t, fields, fileField, fileName, content) } func mustToken(t *testing.T) string { t.Helper() token, err := newToken() if err != nil { t.Fatalf("newToken() error = %v", err) } return token } func hiddenValue(t *testing.T, body string, name string) string { t.Helper() pattern := regexp.MustCompile( `name="` + regexp.QuoteMeta(name) + `" value="([^"]+)"`, ) match := pattern.FindStringSubmatch(body) if len(match) != 2 { t.Fatalf("hidden field %q not found", name) } return match[1] } func assertSecurityHeaders( t *testing.T, response *httptest.ResponseRecorder, ) { t.Helper() required := map[string]string{ "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", } for name, want := range required { if got := response.Header().Get(name); got != want { t.Fatalf("%s = %q, want %q", name, got, want) } } csp := response.Header().Get("Content-Security-Policy") for _, directive := range []string{ "default-src 'none'", "form-action 'self'", "frame-ancestors 'none'", "script-src 'self'", "style-src 'self'", } { if !strings.Contains(csp, directive) { t.Fatalf("CSP missing %q: %s", directive, csp) } } } func TestUsecaseErrorMappingKeepsPublicSentinels(t *testing.T) { tests := []struct { kind usecase.ErrorKind code string want error }{ { kind: usecase.ErrorKindInvalid, code: "ASSET_IMAGE_INVALID", want: ErrInvalidFile, }, { kind: usecase.ErrorKindInvalid, code: "TASK_VALIDATION_FAILED", want: ErrValidation, }, { kind: usecase.ErrorKindInvalid, code: "TASK_CANCEL_INVALID", want: ErrNotFound, }, { kind: usecase.ErrorKindNotFound, code: "TASK_NOT_FOUND", want: ErrNotFound, }, { kind: usecase.ErrorKindConflict, code: "TASK_STATE_CONFLICT", want: ErrConflict, }, { kind: usecase.ErrorKindUnavailable, code: "STORAGE_UNAVAILABLE", want: ErrUnavailable, }, } for _, test := range tests { t.Run(test.code, func(t *testing.T) { mapped := mapUsecaseError(&usecase.Error{ Kind: test.kind, Code: test.code, }) if !errors.Is(mapped, test.want) { t.Fatalf("mapped error = %v, want %v", mapped, test.want) } }) } }