feat(sense): implement Control API v1 [T-011]
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user