package server_test import ( "bytes" "context" "errors" "io" "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 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 }