273 lines
12 KiB
Go
273 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|