175 lines
8.9 KiB
Go
175 lines
8.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|