package controlapi import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "yovision/sense/internal/auth" "yovision/sense/internal/device" "yovision/sense/internal/store" ) const testOperationID = "op_01K20Y8Q3E0000000000000000" type fixedAuthenticator struct { principal auth.Principal err error } func (a fixedAuthenticator) Authenticate(context.Context, string) (auth.Principal, error) { return a.principal, a.err } type fakeControlStore struct { device store.ControlDevice err error } func (f *fakeControlStore) ListControlDevices(context.Context, string, string, store.ControlListFilter) (store.ControlDevicePage, error) { return store.ControlDevicePage{ Items: []store.ControlDevice{f.device}, Quota: store.ControlSiteQuota{Status: "current"}, }, f.err } func (f *fakeControlStore) CreateControlDevice(_ context.Context, request store.ControlCreateRequest) (store.ControlCreateResult, error) { return store.ControlCreateResult{ Device: f.device, AcceptedAt: time.Now(), TraceID: request.Scope.TraceID, ETag: store.DeviceETag(f.device.ID, f.device.ResourceVersion), Location: "/api/v1/sites/site-a/devices/" + f.device.ID, }, f.err } func (f *fakeControlStore) GetControlDevice(context.Context, string, string, string) (store.ControlDevice, error) { return f.device, f.err } func (f *fakeControlStore) PatchControlDevice(_ context.Context, _, _, _, _ string, _ store.ControlPatch) (store.ControlMutationResult, error) { return f.mutation(), f.err } func (f *fakeControlStore) SetControlDesiredState(_ context.Context, _, _, _, _ string, _ device.DesiredState) (store.ControlMutationResult, error) { return f.mutation(), f.err } func (f *fakeControlStore) mutation() store.ControlMutationResult { return store.ControlMutationResult{ Device: f.device, AcceptedAt: time.Now(), TraceID: "trace_0123456789abcdef0123456789abcdef", ETag: store.DeviceETag(f.device.ID, f.device.ResourceVersion), } } func (f *fakeControlStore) BatchSetControlDesiredState(_ context.Context, request store.ControlBatchRequest) (store.ControlBatchOperation, error) { completed := time.Now().UTC() generation := f.device.Generation return store.ControlBatchOperation{ ID: testOperationID, TenantID: request.Scope.TenantID, SiteID: request.Scope.SiteID, Status: "succeeded", SubmittedAt: completed, CompletedAt: &completed, TraceID: request.Scope.TraceID, Results: []store.ControlBatchItemResult{{ DeviceID: f.device.ID, Status: "succeeded", Generation: &generation, }}, }, f.err } func (f *fakeControlStore) GetControlOperation(context.Context, string, string) (store.ControlBatchOperation, error) { completed := time.Now().UTC() return store.ControlBatchOperation{ ID: testOperationID, TenantID: "tenant-a", SiteID: "site-a", Status: "succeeded", SubmittedAt: completed, CompletedAt: &completed, Results: []store.ControlBatchItemResult{}, TraceID: "trace_0123456789abcdef0123456789abcdef", }, f.err } func testHTTPHandler(repository *fakeControlStore, permissions ...string) http.Handler { grants := make(map[string]struct{}, len(permissions)) for _, permission := range permissions { grants[permission] = struct{}{} } principal := auth.Principal{ SubjectID: "operator-1", ActorType: "user", TenantID: "tenant-a", SiteIDs: []string{"site-a"}, Permissions: grants, } return NewHTTPHandler(repository, fixedAuthenticator{principal: principal}, NewCursorCodec(bytes.Repeat([]byte{4}, 32))) } func testControlDevice() store.ControlDevice { quotaVersion, areaVersion := int64(1), int64(2) now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC) return store.ControlDevice{ ID: "dev_01K20Y8Q3E0000000000000000", TenantID: "tenant-a", SiteID: "site-a", SerialNumber: "serial-1", Name: "Camera", Modality: device.ModalityVideo, Capabilities: []device.Capability{device.CapabilityVideoCapture}, AreaID: "area-a", DesiredState: device.DesiredDisabled, ActualState: device.ActualPending, AdapterStatus: "pending", EndpointConfigured: true, CredentialConfigured: true, Generation: 1, ResourceVersion: 1, FailureCount: 0, ProjectionVersions: store.ControlProjectionVersions{ QuotaSourceVersion: "aVersion, AreaPolicySourceVersion: &areaVersion, SyncedAt: &now, }, CreatedAt: now, UpdatedAt: now, } } func performRequest(handler http.Handler, method, path, contentType, body string) *httptest.ResponseRecorder { request := httptest.NewRequest(method, path, strings.NewReader(body)) request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") if contentType != "" { request.Header.Set("Content-Type", contentType) } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) return recorder } func TestAllFrozenControlRoutesReturnContractShapes(t *testing.T) { value := testControlDevice() repository := &fakeControlStore{device: value} handler := testHTTPHandler(repository, auth.PermissionDevicesRead, auth.PermissionDevicesWrite) etag := store.DeviceETag(value.ID, value.ResourceVersion) list := performRequest(handler, http.MethodGet, "/api/v1/sites/site-a/devices", "", "") if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"quota"`) { t.Fatalf("list route failed: %d %s", list.Code, list.Body.String()) } createBody := `{"serial_number":"serial-1","name":"Camera","modality":"video",` + `"capabilities":["video_capture"],"area_id":"area-a",` + `"endpoint_ref":"onvif://camera","credential_ref":"env://CAMERA"}` create := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices", strings.NewReader(createBody)) create.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") create.Header.Set("Content-Type", "application/json") create.Header.Set("Idempotency-Key", "create-request-0001") createResult := httptest.NewRecorder() handler.ServeHTTP(createResult, create) if createResult.Code != http.StatusCreated || createResult.Header().Get("ETag") == "" || strings.Contains(createResult.Body.String(), "endpoint_ref") || strings.Contains(createResult.Body.String(), "credential_ref") { t.Fatalf("create route failed or leaked write-only data: %d %s", createResult.Code, createResult.Body.String()) } get := performRequest(handler, http.MethodGet, "/api/v1/sites/site-a/devices/"+value.ID, "", "") if get.Code != http.StatusOK || get.Header().Get("ETag") != etag { t.Fatalf("get route failed: %d %s", get.Code, get.Body.String()) } patch := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"Updated"}`)) patch.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") patch.Header.Set("Content-Type", "application/merge-patch+json") patch.Header.Set("If-Match", etag) patchResult := httptest.NewRecorder() handler.ServeHTTP(patchResult, patch) if patchResult.Code != http.StatusAccepted || !strings.Contains(patchResult.Body.String(), `"accepted_at"`) { t.Fatalf("patch route failed: %d %s", patchResult.Code, patchResult.Body.String()) } desired := httptest.NewRequest(http.MethodPut, "/api/v1/sites/site-a/devices/"+value.ID+"/desired-state", strings.NewReader(`{"desired_state":"enabled","reason":"test"}`)) desired.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") desired.Header.Set("Content-Type", "application/json") desired.Header.Set("If-Match", etag) desiredResult := httptest.NewRecorder() handler.ServeHTTP(desiredResult, desired) if desiredResult.Code != http.StatusAccepted { t.Fatalf("desired-state route failed: %d %s", desiredResult.Code, desiredResult.Body.String()) } batchBody := `{"items":[{"device_id":"` + value.ID + `","etag":"` + strings.ReplaceAll(etag, `"`, `\"`) + `","desired_state":"enabled"}],"reason":"test"}` batch := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices:batchDesiredState", strings.NewReader(batchBody)) batch.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") batch.Header.Set("Content-Type", "application/json") batch.Header.Set("Idempotency-Key", "batch-request-0001") batchResult := httptest.NewRecorder() handler.ServeHTTP(batchResult, batch) if batchResult.Code != http.StatusAccepted || batchResult.Header().Get("Location") == "" { t.Fatalf("batch route failed: %d %s", batchResult.Code, batchResult.Body.String()) } operation := performRequest(handler, http.MethodGet, "/api/v1/operations/"+testOperationID, "", "") if operation.Code != http.StatusOK || !strings.Contains(operation.Body.String(), `"results"`) { t.Fatalf("operation route failed: %d %s", operation.Code, operation.Body.String()) } } func TestAuthenticationScopeAndPreconditionsUseStableProblems(t *testing.T) { value := testControlDevice() handler := testHTTPHandler(&fakeControlStore{device: value}, auth.PermissionDevicesRead) unauthenticated := httptest.NewRecorder() handler.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/api/v1/sites/site-a/devices", nil)) if unauthenticated.Code != http.StatusUnauthorized || unauthenticated.Header().Get("WWW-Authenticate") == "" { t.Fatalf("missing auth did not return 401: %d", unauthenticated.Code) } hidden := performRequest(handler, http.MethodGet, "/api/v1/sites/site-b/devices", "", "") if hidden.Code != http.StatusNotFound { t.Fatalf("out-of-scope site was not hidden: %d", hidden.Code) } missingPrecondition := performRequest(handler, http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, "application/merge-patch+json", `{"name":"Updated"}`) if missingPrecondition.Code != http.StatusPreconditionRequired { t.Fatalf("missing If-Match did not return 428: %d %s", missingPrecondition.Code, missingPrecondition.Body.String()) } var problem Problem if err := json.Unmarshal(missingPrecondition.Body.Bytes(), &problem); err != nil || problem.Code != ErrorCodePreconditionRequired || problem.TraceId == "" { t.Fatalf("invalid Problem response: %+v %v", problem, err) } } func TestStrictJSONAndStoreConflictsAreMapped(t *testing.T) { value := testControlDevice() handler := testHTTPHandler(&fakeControlStore{device: value}, auth.PermissionDevicesWrite) etag := store.DeviceETag(value.ID, value.ResourceVersion) duplicate := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"one","name":"two"}`)) duplicate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") duplicate.Header.Set("Content-Type", "application/merge-patch+json") duplicate.Header.Set("If-Match", etag) result := httptest.NewRecorder() handler.ServeHTTP(result, duplicate) if result.Code != http.StatusBadRequest { t.Fatalf("duplicate JSON property was accepted: %d %s", result.Code, result.Body.String()) } nullCreate := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices", strings.NewReader( `{"serial_number":"serial","name":"Camera","modality":"radar",`+ `"capabilities":["telemetry"],"area_id":"area-a","profile_token":null}`)) nullCreate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") nullCreate.Header.Set("Content-Type", "application/json") nullCreate.Header.Set("Idempotency-Key", "null-create-0001") nullResult := httptest.NewRecorder() handler.ServeHTTP(nullResult, nullCreate) if nullResult.Code != http.StatusUnprocessableEntity { t.Fatalf("explicit null was accepted: %d %s", nullResult.Code, nullResult.Body.String()) } conflictHandler := testHTTPHandler(&fakeControlStore{device: value, err: store.ErrETagMismatch}, auth.PermissionDevicesWrite) request := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"two"}`)) request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") request.Header.Set("Content-Type", "application/merge-patch+json") request.Header.Set("If-Match", etag) recorder := httptest.NewRecorder() conflictHandler.ServeHTTP(recorder, request) if recorder.Code != http.StatusPreconditionFailed || !strings.Contains(recorder.Body.String(), "etag_mismatch") { t.Fatalf("ETag mismatch was not mapped: %d %s", recorder.Code, recorder.Body.String()) } } func TestProjectionVersionsAlwaysEmitRequiredNullableKeys(t *testing.T) { encoded, err := json.Marshal(ProjectionVersions{}) if err != nil { t.Fatal(err) } for _, key := range []string{"quota_source_version", "area_policy_source_version", "synced_at"} { if !strings.Contains(string(encoded), `"`+key+`":null`) { t.Fatalf("required nullable key %s was omitted: %s", key, encoded) } } }