feat(backend): implement task creation and admin web
This commit is contained in:
@@ -0,0 +1,792 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 != "/tasks" {
|
||||
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"`,
|
||||
"最高总预算",
|
||||
} {
|
||||
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 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),
|
||||
},
|
||||
}
|
||||
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 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")
|
||||
}
|
||||
}
|
||||
|
||||
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 CancelPendingInput
|
||||
}
|
||||
|
||||
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) CancelPending(
|
||||
_ context.Context,
|
||||
input CancelPendingInput,
|
||||
) (Task, error) {
|
||||
service.cancelInput = input
|
||||
return service.cancelResult, service.cancelErr
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user