Files
story/internal/httpapi/saas_helpers_test.go
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

192 lines
9.8 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{"工作经历"}}
validPreferences["avoided_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 fields := validateSubjectContext(map[string]any{}, map[string]any{"avoided_topics": []any{"收入", 42}}); len(fields) != 1 || fields[0].Field != "conversation_preferences.avoided_topics" {
t.Fatal("invalid boundary accepted", 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": "你好"}},
{"identity": map[string]any{"display_name": "客厅陪伴设备"}},
{"identity": map[string]any{"display_name": ""}},
{"power": map[string]any{"mode": "standby"}},
{"power": map[string]any{"mode": "active"}},
} {
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"},
{"identity": nil},
{"power": nil},
{"power": map[string]any{"mode": "off"}},
{"power": map[string]any{"mode": "standby", "unknown": true}},
{"identity": map[string]any{}},
{"identity": map[string]any{"display_name": 1}},
{"identity": map[string]any{"display_name": "a", "unknown": true}},
{"identity": map[string]any{"display_name": "首尾空格 "}},
{"identity": map[string]any{"display_name": strings.Repeat("名", 33)}},
{"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)
}
}
}