Files
story/internal/httpapi/saas_helpers_test.go
T

166 lines
8.3 KiB
Go
Raw Normal View History

2026-08-26 06:41:07 +08:00
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 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)
}
}
}