package httpapi import ( "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "data-terminal-cloud/internal/concurrency" "data-terminal-cloud/internal/identity" "data-terminal-cloud/internal/pagination" "data-terminal-cloud/internal/saas" ) func TestSaaSInputHelpers(t *testing.T) { if fields := validateSubject("长者", "zh-CN", "Asia/Shanghai", map[string]any{}, map[string]any{}); len(fields) != 0 { t.Fatalf("valid subject fields=%#v", fields) } if fields := validateSubject("", "zh_CN", "Unknown/Zone", nil, nil); len(fields) != 5 { t.Fatalf("invalid subject fields=%#v", fields) } validProfile := map[string]any{"background": "铁路工作经历", "interests": []any{"老照片", "家乡变化"}} validPreferences := map[string]any{"conversation_goal": "聊聊第一次工作", "pace": "slow", "preferred_topics": []any{"工作经历"}} if fields := validateSubject("长者", "zh-CN", "Asia/Shanghai", validProfile, validPreferences); len(fields) != 0 { t.Fatalf("valid conversation context fields=%#v", fields) } invalidProfile := map[string]any{"background": strings.Repeat("长", 501), "interests": []any{"", 1}} invalidPreferences := map[string]any{"conversation_goal": strings.Repeat("长", 201), "pace": "fast", "preferred_topics": strings.Repeat("x", 9)} if fields := validateSubject("长者", "zh-CN", "Asia/Shanghai", invalidProfile, invalidPreferences); len(fields) != 5 { t.Fatalf("invalid conversation context fields=%#v", fields) } if !validLanguage("zh-CN") || validLanguage("zh_CN") || !validTimezone("UTC") || validTimezone("Unknown/Zone") || !validUUID("10000000-0000-0000-0000-000000000001") || validUUID("bad") { t.Fatal("language, timezone or UUID validation contract changed") } fact := saas.CreateFactInput{FactType: " hometown ", Content: " 合成城市 ", SourceType: " model_candidate ", SourceRef: " synthetic://turn/1 ", ConfirmationStatus: " pending ", Sensitivity: " normal "} normalizeCreateFact(&fact) if fields := validateFact(fact.FactType, fact.Content, fact.SourceType, fact.SourceRef, fact.ConfirmationStatus, fact.Sensitivity, nil, nil); len(fields) != 0 { t.Fatalf("valid fact fields=%#v", fields) } now := time.Now().UTC() earlier := now.Add(-time.Hour) if fields := validateFact("", "", "unknown", strings.Repeat("x", 301), "confirmed", "unknown", &now, &earlier); len(fields) != 6 { t.Fatalf("invalid fact fields=%#v", fields) } config := map[string]any{"audio": []any{map[string]any{"codec": "opus"}, map[string]any{"api_token": "secret"}}} if path, found := forbiddenDeviceConfigField(config, "desired_config"); !found || path != "desired_config.audio[1].api_token" { t.Fatalf("forbidden config path=%q found=%v", path, found) } if _, found := forbiddenDeviceConfigField(map[string]any{"audio": map[string]any{"codec": "opus"}}, "desired_config"); found { t.Fatal("safe device configuration was rejected") } for _, config := range []map[string]any{ {"volume": map[string]any{"speaker": float64(0)}}, {"volume": map[string]any{"speaker": float64(65)}}, {"volume": map[string]any{"speaker": float64(100)}}, {"interaction": map[string]any{"wake_word": "你好"}}, } { if fields := validateDesiredDeviceConfig(config); len(fields) != 0 { t.Fatalf("valid desired device config=%#v fields=%#v", config, fields) } } for _, config := range []map[string]any{ {"volume": "loud"}, {"volume": map[string]any{"speaker": float64(-1)}}, {"volume": map[string]any{"speaker": 50.5}}, {"volume": map[string]any{"speaker": float64(101)}}, {"volume": map[string]any{"speaker": "65"}}, } { if fields := validateDesiredDeviceConfig(config); len(fields) != 1 { t.Fatalf("invalid desired device config=%#v fields=%#v", config, fields) } } } func TestDecodeAndResourcePrincipal(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"ok"}`)) recorder := httptest.NewRecorder() var body struct { Name string `json:"name"` } if !decodeWebJSON(recorder, request, &body, "请求错误") || body.Name != "ok" { t.Fatalf("decoded body=%#v status=%d", body, recorder.Code) } request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"ok","extra":1}`)) recorder = httptest.NewRecorder() if decodeWebJSON(recorder, request, &body, "请求错误") || recorder.Code != http.StatusBadRequest { t.Fatalf("unknown field status=%d", recorder.Code) } request = httptest.NewRequest(http.MethodGet, "/resources/bad", nil) request.SetPathValue("id", "bad") request = request.WithContext(identity.WithPrincipal(request.Context(), identity.Principal{Issuer: "issuer", Subject: "subject"})) recorder = httptest.NewRecorder() if _, ok := resourcePrincipal(recorder, request, "id"); ok || recorder.Code != http.StatusBadRequest { t.Fatalf("invalid resource principal status=%d", recorder.Code) } request = httptest.NewRequest(http.MethodGet, "/resources/1", nil) recorder = httptest.NewRecorder() if _, ok := resourcePrincipal(recorder, request, "id"); ok || recorder.Code != http.StatusUnauthorized { t.Fatalf("anonymous resource principal status=%d", recorder.Code) } } func TestClaimDeviceRejectsDeviceIdentifierAsClaimCode(t *testing.T) { request := authenticatedRequest(http.MethodPost, "/api/v1/device-claims", `{"claim_code":"10000000-0000-0000-0000-000000000512","household_id":"10000000-0000-0000-0000-000000000211","subject_id":"10000000-0000-0000-0000-000000000411"}`, identity.Principal{Issuer: "test", Subject: "owner"}) recorder := httptest.NewRecorder() ClaimDeviceHandler(nil).ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "不能填写设备编号") { t.Fatalf("device identifier claim status=%d body=%s", recorder.Code, recorder.Body.String()) } } func TestSaaSErrorAndETagMapping(t *testing.T) { tests := []struct { err error status int code string }{ {saas.ErrAccessDenied, http.StatusForbidden, "RESOURCE_ACCESS_DENIED"}, {saas.ErrNotFound, http.StatusNotFound, "RESOURCE_NOT_FOUND"}, {saas.ErrVersionConflict, http.StatusPreconditionFailed, "VERSION_CONFLICT"}, {saas.ErrEffectiveConsentExist, http.StatusConflict, "EFFECTIVE_CONSENT_EXISTS"}, {saas.ErrPolicyVersionMismatch, http.StatusConflict, "POLICY_VERSION_MISMATCH"}, {saas.ErrDeviceClaimInvalid, http.StatusConflict, "DEVICE_CLAIM_INVALID"}, {saas.ErrDeviceClaimUsed, http.StatusConflict, "DEVICE_CLAIM_ALREADY_USED"}, {saas.ErrDeviceAlreadyBound, http.StatusConflict, "DEVICE_ALREADY_BOUND"}, {saas.ErrIdempotencyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT"}, {saas.ErrSubjectDeleteConflict, http.StatusConflict, "SUBJECT_ACTIVE_CONVERSATION"}, {saas.ErrDeleteConfirmation, http.StatusConflict, "DELETE_CONFIRMATION_MISMATCH"}, {saas.ErrExportUnavailable, http.StatusServiceUnavailable, "EXPORT_DOWNLOAD_UNAVAILABLE"}, {saas.ErrFirmwareStateConflict, http.StatusConflict, "FIRMWARE_STATE_CONFLICT"}, {saas.ErrFirmwareSigningKeyConflict, http.StatusConflict, "FIRMWARE_SIGNING_KEY_CONFLICT"}, {saas.ErrFirmwareSigningKeyInUse, http.StatusConflict, "FIRMWARE_SIGNING_KEY_IN_USE"}, {saas.ErrFirmwareSigningKeyInactive, http.StatusConflict, "FIRMWARE_SIGNING_KEY_INACTIVE"}, {saas.ErrFirmwareReleaseInUse, http.StatusConflict, "FIRMWARE_RELEASE_IN_USE"}, {saas.ErrInvitationInvalid, http.StatusConflict, "MEMBER_INVITATION_INVALID"}, {saas.ErrMemberExists, http.StatusConflict, "HOUSEHOLD_MEMBER_EXISTS"}, {saas.ErrOwnerProtected, http.StatusConflict, "HOUSEHOLD_OWNER_PROTECTED"}, {saas.ErrFactSourceRequired, http.StatusConflict, "FACT_SOURCE_REQUIRED"}, {saas.ErrStorySourceRequired, http.StatusConflict, "STORY_SOURCE_REQUIRED"}, {saas.ErrPersonalizedMemoryConsentNeeded, http.StatusConflict, "PERSONALIZED_MEMORY_CONSENT_REQUIRED"}, {saas.ErrReviewStateConflict, http.StatusConflict, "STORY_REVIEW_STATE_CONFLICT"}, {pagination.ErrInvalidCursor, http.StatusBadRequest, "VALIDATION_FAILED"}, {errors.New("unexpected"), http.StatusInternalServerError, "INTERNAL_ERROR"}, } for _, item := range tests { recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/", nil) writeSaaSError(recorder, request, item.err) var response responseEnvelope if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatal(err) } if recorder.Code != item.status || response.Error == nil || response.Error.Code != item.code { t.Fatalf("error=%v status=%d body=%s", item.err, recorder.Code, recorder.Body.String()) } } for _, item := range []struct { err error status int }{ {concurrency.ErrPreconditionRequired, http.StatusPreconditionRequired}, {concurrency.ErrVersionConflict, http.StatusPreconditionFailed}, {errors.New("bad etag"), http.StatusBadRequest}, } { recorder := httptest.NewRecorder() writeETagError(recorder, httptest.NewRequest(http.MethodPatch, "/", nil), item.err) if recorder.Code != item.status { t.Fatalf("etag error=%v status=%d", item.err, recorder.Code) } } }