Files

273 lines
12 KiB
Go
Raw Permalink Normal View History

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: &quotaVersion, 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)
}
}
}