216 lines
12 KiB
Go
216 lines
12 KiB
Go
package server_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"cmbuyer/admin/internal/deviceauth"
|
|
"cmbuyer/admin/internal/taskclaim"
|
|
)
|
|
|
|
const (
|
|
claimDeviceID = "10000000-0000-4000-8000-000000000001"
|
|
claimSessionID = "20000000-0000-4000-8000-000000000001"
|
|
claimRequestID = "30000000-0000-4000-8000-000000000001"
|
|
claimTaskID = "40000000-0000-4000-8000-000000000001"
|
|
claimAttemptID = "50000000-0000-4000-8000-000000000001"
|
|
claimRenewID = "60000000-0000-4000-8000-000000000001"
|
|
)
|
|
|
|
func TestTaskClaimEndpointsAuthenticateBeforeBody(t *testing.T) {
|
|
for _, authentication := range []struct {
|
|
name string
|
|
err error
|
|
status int
|
|
}{
|
|
{"unauthenticated", deviceauth.ErrUnauthenticated, http.StatusUnauthorized},
|
|
{"authentication storage unavailable", deviceauth.ErrUnavailable, http.StatusServiceUnavailable},
|
|
} {
|
|
t.Run(authentication.name, func(t *testing.T) {
|
|
authenticator := &fakeDeviceAuthenticator{err: authentication.err}
|
|
service := &fakeTaskClaimService{}
|
|
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
|
for _, path := range []string{"/api/v1/tasks/claim-next", "/api/v1/tasks/" + claimTaskID + "/lease/renew"} {
|
|
body := &poisonBody{}
|
|
request := httptest.NewRequest(http.MethodPost, path, nil)
|
|
request.Body = body
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
if response.Code != authentication.status || response.Body.Len() != 0 || body.reads != 0 || service.calls != 0 {
|
|
t.Fatalf("%s = status %d, body %q, reads %d, calls %d", path, response.Code, response.Body.String(), body.reads, service.calls)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) {
|
|
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
|
service := &fakeTaskClaimService{claimResponse: taskclaim.ClaimResponse{
|
|
Task: taskclaim.ClaimedTask{ID: claimTaskID, Version: 3, Title: "测试", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", GoodsID: "1", SKUColor: "黑色", SKUSize: "M", Quantity: 1, MaxTotalPrice: "1.00"},
|
|
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "2026-08-04T01:10:00Z"},
|
|
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:03:00Z"},
|
|
}, claimFound: true}
|
|
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
|
valid := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
|
|
|
|
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json; charset=utf-8")
|
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), strings.Repeat("a", 64)) || service.claimCommand.ClaimRequestID != claimRequestID {
|
|
t.Fatalf("claim success = %d %q command %#v", response.Code, response.Body.String(), service.claimCommand)
|
|
}
|
|
service.claimFound = false
|
|
response = serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json")
|
|
if response.Code != http.StatusNoContent || response.Body.Len() != 0 {
|
|
t.Fatalf("claim empty = %d %q", response.Code, response.Body.String())
|
|
}
|
|
|
|
tests := []struct {
|
|
name, body, contentType, code string
|
|
status int
|
|
}{
|
|
{"unsupported type", valid, "text/plain", "unsupported_media_type", http.StatusUnsupportedMediaType},
|
|
{"unknown field", strings.TrimSuffix(valid, "}") + `,"device_id":"` + claimDeviceID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
|
{"duplicate session", `{"session_id":"` + claimSessionID + `","session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
|
{"duplicate request", `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
|
{"extra json", valid + `{}`, "application/json", "invalid_request", http.StatusBadRequest},
|
|
{"too large", strings.Repeat(" ", 4097), "application/json", "request_too_large", http.StatusRequestEntityTooLarge},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", test.body, test.contentType)
|
|
if response.Code != test.status || response.Body.String() != `{"error":"`+test.code+`"}` {
|
|
t.Fatalf("response = %d %q", response.Code, response.Body.String())
|
|
}
|
|
})
|
|
}
|
|
invalidUTF8 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/claim-next", bytes.NewReader([]byte{'{', 0xff, '}'}))
|
|
invalidUTF8.Header.Set("Content-Type", "application/json")
|
|
invalidResponse := httptest.NewRecorder()
|
|
router.ServeHTTP(invalidResponse, invalidUTF8)
|
|
if invalidResponse.Code != http.StatusBadRequest || invalidResponse.Body.String() != `{"error":"invalid_request"}` {
|
|
t.Fatalf("invalid UTF-8 = %d %q", invalidResponse.Code, invalidResponse.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsClosed(t *testing.T) {
|
|
goodsID := strings.Repeat("1", 32)
|
|
worst := taskclaim.ClaimResponse{
|
|
Task: taskclaim.ClaimedTask{
|
|
ID: claimTaskID, Version: math.MaxInt, Title: strings.Repeat("<", 120),
|
|
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID,
|
|
GoodsID: goodsID, SKUColor: strings.Repeat("<", 80), SKUSize: strings.Repeat("<", 80),
|
|
Quantity: 9_223_372_036_854_775_807, MaxTotalPrice: strings.Repeat("9", 29) + ".00",
|
|
},
|
|
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: math.MaxInt - 1, ExpiresAt: "9999-12-31T23:59:59.999999999Z"},
|
|
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 9_223_372_036_854_775_807, LeaseExpiresAt: "9999-12-31T23:59:59.999999999Z"},
|
|
}
|
|
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
|
service := &fakeTaskClaimService{claimResponse: worst, claimFound: true}
|
|
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
|
request := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
|
|
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
|
|
if response.Code != http.StatusOK || !json.Valid(response.Body.Bytes()) || response.Body.Len() >= 32*1024 {
|
|
t.Fatalf("worst legal response = status %d, bytes %d, valid JSON %v", response.Code, response.Body.Len(), json.Valid(response.Body.Bytes()))
|
|
}
|
|
|
|
mutations := map[string]func(*taskclaim.ClaimResponse){
|
|
"invalid utf8 title": func(response *taskclaim.ClaimResponse) { response.Task.Title = string([]byte{0xff}) },
|
|
"c0 separator title": func(response *taskclaim.ClaimResponse) { response.Task.Title = "visible\u001dhidden" },
|
|
"overlong title": func(response *taskclaim.ClaimResponse) { response.Task.Title += "<" },
|
|
"overlong goods id": func(response *taskclaim.ClaimResponse) {
|
|
response.Task.GoodsID += "1"
|
|
response.Task.ProductURL += "1"
|
|
},
|
|
"overlong color": func(response *taskclaim.ClaimResponse) { response.Task.SKUColor += "<" },
|
|
"overlong size": func(response *taskclaim.ClaimResponse) { response.Task.SKUSize += "<" },
|
|
"overlong money": func(response *taskclaim.ClaimResponse) { response.Task.MaxTotalPrice = strings.Repeat("9", 30) + ".00" },
|
|
}
|
|
for name, mutate := range mutations {
|
|
t.Run(name, func(t *testing.T) {
|
|
invalid := worst
|
|
mutate(&invalid)
|
|
service := &fakeTaskClaimService{claimResponse: invalid, claimFound: true}
|
|
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
|
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
|
|
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 {
|
|
t.Fatalf("invalid service response = %d %q", response.Code, response.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) {
|
|
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
|
service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}}
|
|
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
|
body := `{"renew_request_id":"` + claimRenewID + `","session_id":"` + claimSessionID + `","attempt_id":"` + claimAttemptID + `","claim_generation":1,"claim_token":"` + strings.Repeat("a", 64) + `","expected_lease_expires_at":"2026-08-04T01:03:00Z"}`
|
|
response := serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
|
|
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "claim_token") || service.renewCommand.TaskID != claimTaskID {
|
|
t.Fatalf("renew response = %d %q command %#v", response.Code, response.Body.String(), service.renewCommand)
|
|
}
|
|
|
|
duplicateToken := strings.Replace(body, `"expected_lease_expires_at"`, `"claim_token":"`+strings.Repeat("a", 64)+`","expected_lease_expires_at"`, 1)
|
|
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", duplicateToken, "application/json")
|
|
if response.Code != http.StatusBadRequest {
|
|
t.Fatalf("duplicate token status = %d", response.Code)
|
|
}
|
|
|
|
errorsToCodes := []struct {
|
|
err error
|
|
status int
|
|
body string
|
|
}{
|
|
{taskclaim.ErrIdempotencyConflict, http.StatusConflict, `{"error":"idempotency_conflict"}`},
|
|
{taskclaim.ErrRequiresManual, http.StatusConflict, `{"error":"claim_requires_manual"}`},
|
|
{taskclaim.ErrNotCurrent, http.StatusConflict, `{"error":"claim_not_current"}`},
|
|
{taskclaim.ErrDeviceInactive, http.StatusUnauthorized, ""},
|
|
{errors.New("database path and SQL must stay private"), http.StatusServiceUnavailable, ""},
|
|
}
|
|
for _, test := range errorsToCodes {
|
|
service.renewErr = test.err
|
|
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
|
|
if response.Code != test.status || response.Body.String() != test.body {
|
|
t.Fatalf("error %v = %d %q", test.err, response.Code, response.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
type fakeTaskClaimService struct {
|
|
claimResponse taskclaim.ClaimResponse
|
|
claimFound bool
|
|
claimErr error
|
|
renewResponse taskclaim.RenewResponse
|
|
renewErr error
|
|
claimCommand taskclaim.ClaimCommand
|
|
renewCommand taskclaim.RenewCommand
|
|
calls int
|
|
}
|
|
|
|
func (service *fakeTaskClaimService) ClaimNext(_ context.Context, _ string, command taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
|
|
service.calls++
|
|
service.claimCommand = command
|
|
return service.claimResponse, service.claimFound, service.claimErr
|
|
}
|
|
|
|
func (service *fakeTaskClaimService) Renew(_ context.Context, _ string, command taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
|
|
service.calls++
|
|
service.renewCommand = command
|
|
return service.renewResponse, service.renewErr
|
|
}
|
|
|
|
func serveClaimJSON(router http.Handler, path, body, contentType string) *httptest.ResponseRecorder {
|
|
request := httptest.NewRequest(http.MethodPost, path, io.NopCloser(strings.NewReader(body)))
|
|
request.Header.Set("Content-Type", contentType)
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
return response
|
|
}
|