1699 lines
46 KiB
Go
1699 lines
46 KiB
Go
package webui
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"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: `<script>alert("private")</script>`,
|
|
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, `<script>alert("private")</script>`) ||
|
|
!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 != "<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, "<style") ||
|
|
strings.Contains(body, "<script>") {
|
|
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": `<img src=x onerror="alert(1)">`,
|
|
"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, `<img src=x onerror="alert(1)">`) {
|
|
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 TestFreightPagesEscapeSourceDataAndCreateAsyncSync(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: `<script>private</script>`,
|
|
ShopName: "测试店铺",
|
|
ItemCount: 2,
|
|
Revision: 1,
|
|
UpdatedAt: now,
|
|
}},
|
|
createResult: FreightSync{
|
|
ID: testTaskID,
|
|
Status: "PENDING",
|
|
CreatedAt: now,
|
|
},
|
|
}
|
|
router := newTestRouter(t, service)
|
|
list := performRequest(t, router, http.MethodGet, "/freight", nil, "")
|
|
if list.Code != http.StatusOK ||
|
|
strings.Contains(list.Body.String(), `<script>private</script>`) ||
|
|
!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/import?sync="+testTaskID {
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 ||
|
|
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 TestERPConnectionPageUsesCaptchaOnlyAndPreservesNoCredentials(t *testing.T) {
|
|
ticket := mustToken(t)
|
|
service := &fakeERPService{
|
|
fakeService: &fakeService{},
|
|
status: ERPConnectionStatus{
|
|
Configured: true,
|
|
},
|
|
image: ERPCaptchaImage{
|
|
Content: []byte("captcha-image"),
|
|
ContentType: "image/png",
|
|
},
|
|
ticket: ticket,
|
|
}
|
|
router := newTestRouter(t, service)
|
|
page := performRequest(t, router, http.MethodGet, "/erp", nil, "")
|
|
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "获取验证码") ||
|
|
strings.Contains(page.Body.String(), "private-password") {
|
|
t.Fatalf("ERP page = %d / %s", page.Code, page.Body)
|
|
}
|
|
assertSecurityHeaders(t, page)
|
|
cookie := csrfCookie(t, page)
|
|
values := url.Values{"csrf_token": {cookie.Value}}
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/erp/captcha",
|
|
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") != "/erp?notice=captcha-ready" ||
|
|
service.captchaRequests != 1 {
|
|
t.Fatalf("captcha response/calls = %d / %q / %d", response.Code, response.Header().Get("Location"), service.captchaRequests)
|
|
}
|
|
service.status.CaptchaReady = true
|
|
service.status.CaptchaTicket = ticket
|
|
page = performRequest(t, router, http.MethodGet, "/erp", nil, "")
|
|
if page.Code != http.StatusOK ||
|
|
!strings.Contains(page.Body.String(), "/erp/captcha/"+ticket) ||
|
|
!strings.Contains(page.Body.String(), `name="captcha_code"`) ||
|
|
strings.Contains(page.Body.String(), "password") {
|
|
t.Fatalf("captcha page = %d / %s", page.Code, page.Body)
|
|
}
|
|
image := performRequest(t, router, http.MethodGet, "/erp/captcha/"+ticket, nil, "")
|
|
if image.Code != http.StatusOK || image.Header().Get("Cache-Control") != "no-store" ||
|
|
image.Body.String() != "captcha-image" {
|
|
t.Fatalf("captcha image = %d / %q / %s", image.Code, image.Header(), image.Body)
|
|
}
|
|
cookie = csrfCookie(t, page)
|
|
values = url.Values{
|
|
"csrf_token": {cookie.Value},
|
|
"captcha_ticket": {ticket},
|
|
"captcha_code": {"1234"},
|
|
}
|
|
request = httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/erp/login",
|
|
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") != "/erp?notice=login-succeeded" ||
|
|
service.login.CaptchaTicket != ticket || service.login.CaptchaCode != "1234" {
|
|
t.Fatalf("login response/input = %d / %q / %+v", response.Code, response.Header().Get("Location"), service.login)
|
|
}
|
|
service.status = ERPConnectionStatus{
|
|
Configured: true,
|
|
CaptchaReady: true,
|
|
CaptchaTicket: ticket,
|
|
}
|
|
service.err = ErrERPLoginRejected
|
|
page = performRequest(t, router, http.MethodGet, "/erp", nil, "")
|
|
cookie = csrfCookie(t, page)
|
|
values = url.Values{
|
|
"csrf_token": {cookie.Value},
|
|
"captcha_ticket": {ticket},
|
|
"captcha_code": {"1234"},
|
|
}
|
|
request = httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/erp/login",
|
|
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.StatusUnprocessableEntity ||
|
|
!strings.Contains(response.Body.String(), "验证码不正确或 ERP 拒绝登录") ||
|
|
strings.Contains(response.Body.String(), "private") {
|
|
t.Fatalf("rejected login page = %d / %s", response.Code, response.Body)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
renderer, err := NewRenderer()
|
|
if err != nil {
|
|
t.Fatalf("NewRenderer() error = %v", err)
|
|
}
|
|
handler, err := NewHandler(service, renderer)
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|