feat(sense): implement Control API v1 [T-011]
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
// Package controlapi implements the frozen Sense Control API v1.
|
||||
package controlapi
|
||||
|
||||
// The input is the repository-owned public contract frozen by T-008.
|
||||
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../../docs/contracts/sense-control-v1.openapi.json
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
package controlapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
func newULID(prefix string, now time.Time) (string, error) {
|
||||
value := make([]byte, 16)
|
||||
milliseconds := uint64(now.UTC().UnixMilli())
|
||||
value[0] = byte(milliseconds >> 40)
|
||||
value[1] = byte(milliseconds >> 32)
|
||||
value[2] = byte(milliseconds >> 24)
|
||||
value[3] = byte(milliseconds >> 16)
|
||||
value[4] = byte(milliseconds >> 8)
|
||||
value[5] = byte(milliseconds)
|
||||
if _, err := rand.Read(value[6:]); err != nil {
|
||||
return "", errors.New("generate identifier randomness")
|
||||
}
|
||||
number := new(big.Int).SetBytes(value)
|
||||
base := big.NewInt(32)
|
||||
remainder := new(big.Int)
|
||||
encoded := make([]byte, 26)
|
||||
for index := len(encoded) - 1; index >= 0; index-- {
|
||||
number.QuoRem(number, base, remainder)
|
||||
encoded[index] = crockford[remainder.Int64()]
|
||||
}
|
||||
return prefix + string(encoded), nil
|
||||
}
|
||||
|
||||
func newTraceID() (string, error) {
|
||||
value := make([]byte, 16)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", errors.New("generate trace identifier")
|
||||
}
|
||||
return "trace_" + hex.EncodeToString(value), nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package controlapi
|
||||
|
||||
import (
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
func publicDevice(value store.ControlDevice) Device {
|
||||
capabilities := make([]Capability, len(value.Capabilities))
|
||||
for index := range value.Capabilities {
|
||||
capabilities[index] = Capability(value.Capabilities[index])
|
||||
}
|
||||
tenantID := LogicalID(value.TenantID)
|
||||
generation, observed := value.Generation, value.ObservedGeneration
|
||||
converged, failureCount := value.Converged, value.FailureCount
|
||||
endpointConfigured, credentialConfigured := value.EndpointConfigured, value.CredentialConfigured
|
||||
createdAt, updatedAt := value.CreatedAt, value.UpdatedAt
|
||||
result := Device{
|
||||
Id: value.ID, TenantId: &tenantID, SiteId: value.SiteID,
|
||||
SerialNumber: value.SerialNumber, Name: value.Name, Modality: Modality(value.Modality),
|
||||
Capabilities: &capabilities, AreaId: value.AreaID,
|
||||
DesiredState: DesiredState(value.DesiredState), ActualState: ActualState(value.ActualState),
|
||||
AdapterStatus: AdapterStatus(value.AdapterStatus),
|
||||
EndpointConfigured: &endpointConfigured, CredentialConfigured: &credentialConfigured,
|
||||
Generation: &generation, ObservedGeneration: &observed, Converged: &converged,
|
||||
FailureCount: &failureCount, NextAttemptAt: value.NextAttemptAt,
|
||||
LastErrorCode: value.LastErrorCode, CreatedAt: &createdAt, UpdatedAt: &updatedAt,
|
||||
}
|
||||
if value.ProjectionVersions.QuotaSourceVersion != nil {
|
||||
version := *value.ProjectionVersions.QuotaSourceVersion
|
||||
result.ProjectionVersions.QuotaSourceVersion = &version
|
||||
}
|
||||
if value.ProjectionVersions.AreaPolicySourceVersion != nil {
|
||||
version := *value.ProjectionVersions.AreaPolicySourceVersion
|
||||
result.ProjectionVersions.AreaPolicySourceVersion = &version
|
||||
}
|
||||
result.ProjectionVersions.SyncedAt = value.ProjectionVersions.SyncedAt
|
||||
return result
|
||||
}
|
||||
|
||||
func publicQuota(value store.ControlSiteQuota) SiteQuotaStatus {
|
||||
return SiteQuotaStatus{
|
||||
Status: SiteQuotaStatusStatus(value.Status), UsedVideoChannels: value.UsedVideoChannels,
|
||||
MaxVideoChannels: value.MaxVideoChannels,
|
||||
AvailableVideoChannels: value.AvailableVideoChannels,
|
||||
OverLimit: value.OverLimit, SourceVersion: value.SourceVersion, SyncedAt: value.SyncedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func publicOperation(value store.ControlBatchOperation) BatchOperation {
|
||||
results := make([]BatchItemResult, 0, len(value.Results))
|
||||
for _, item := range value.Results {
|
||||
var code *ErrorCode
|
||||
if item.ErrorCode != nil {
|
||||
converted := ErrorCode(*item.ErrorCode)
|
||||
code = &converted
|
||||
}
|
||||
results = append(results, BatchItemResult{
|
||||
DeviceId: item.DeviceID, Status: BatchItemResultStatus(item.Status),
|
||||
ErrorCode: code, Message: item.Message, Generation: item.Generation,
|
||||
})
|
||||
}
|
||||
return BatchOperation{
|
||||
Id: value.ID, Status: BatchOperationStatus(value.Status), SubmittedAt: value.SubmittedAt,
|
||||
CompletedAt: value.CompletedAt, Results: results, TraceId: value.TraceID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package: controlapi
|
||||
output: generated.gen.go
|
||||
generate:
|
||||
models: true
|
||||
std-http-server: true
|
||||
output-options:
|
||||
skip-prune: false
|
||||
@@ -0,0 +1,108 @@
|
||||
package controlapi
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidCursor = errors.New("invalid cursor")
|
||||
|
||||
type CursorPosition struct {
|
||||
CreatedAt time.Time
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type cursorPayload struct {
|
||||
Version int `json:"v"`
|
||||
TenantID string `json:"t"`
|
||||
SiteID string `json:"s"`
|
||||
FilterHash string `json:"f"`
|
||||
CreatedAt string `json:"c"`
|
||||
DeviceID string `json:"d"`
|
||||
}
|
||||
|
||||
type CursorCodec struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
func LoadCursorCodec(path string) (*CursorCodec, error) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read Control API cursor key")
|
||||
}
|
||||
key, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(contents)))
|
||||
if err != nil || len(key) < 32 {
|
||||
return nil, errors.New("Control API cursor key must be base64url for at least 32 bytes")
|
||||
}
|
||||
return NewCursorCodec(key), nil
|
||||
}
|
||||
|
||||
func NewCursorCodec(key []byte) *CursorCodec {
|
||||
copyOfKey := append([]byte(nil), key...)
|
||||
return &CursorCodec{key: copyOfKey}
|
||||
}
|
||||
|
||||
func (c *CursorCodec) Encode(tenantID, siteID, filterHash string, position CursorPosition) (string, error) {
|
||||
payload, err := json.Marshal(cursorPayload{
|
||||
Version: 1, TenantID: tenantID, SiteID: siteID, FilterHash: filterHash,
|
||||
CreatedAt: position.CreatedAt.UTC().Format(time.RFC3339Nano), DeviceID: position.DeviceID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", errors.New("encode cursor payload")
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
||||
mac := hmac.New(sha256.New, c.key)
|
||||
_, _ = mac.Write([]byte(encoded))
|
||||
return encoded + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (c *CursorCodec) Decode(value, tenantID, siteID, filterHash string) (CursorPosition, error) {
|
||||
var position CursorPosition
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 2 || len(value) > 512 {
|
||||
return position, ErrInvalidCursor
|
||||
}
|
||||
provided, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil || len(provided) != sha256.Size {
|
||||
return position, ErrInvalidCursor
|
||||
}
|
||||
mac := hmac.New(sha256.New, c.key)
|
||||
_, _ = mac.Write([]byte(parts[0]))
|
||||
if subtle.ConstantTimeCompare(provided, mac.Sum(nil)) != 1 {
|
||||
return position, ErrInvalidCursor
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return position, ErrInvalidCursor
|
||||
}
|
||||
var payload cursorPayload
|
||||
decoder := json.NewDecoder(strings.NewReader(string(payloadBytes)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&payload); err != nil || payload.Version != 1 ||
|
||||
payload.TenantID != tenantID || payload.SiteID != siteID || payload.FilterHash != filterHash {
|
||||
return position, ErrInvalidCursor
|
||||
}
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, payload.CreatedAt)
|
||||
if err != nil || payload.DeviceID == "" {
|
||||
return position, ErrInvalidCursor
|
||||
}
|
||||
position.CreatedAt = createdAt.UTC()
|
||||
position.DeviceID = payload.DeviceID
|
||||
return position, nil
|
||||
}
|
||||
|
||||
func filterFingerprint(values ...string) string {
|
||||
hash := sha256.New()
|
||||
for _, value := range values {
|
||||
_, _ = hash.Write([]byte{byte(len(value) >> 8), byte(len(value))})
|
||||
_, _ = hash.Write([]byte(value))
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package controlapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCursorIsScopedAndTamperEvident(t *testing.T) {
|
||||
codec := NewCursorCodec(bytes.Repeat([]byte{7}, 32))
|
||||
position := CursorPosition{CreatedAt: time.Date(2026, 8, 7, 1, 2, 3, 4, time.UTC), DeviceID: "dev_1"}
|
||||
value, err := codec.Encode("tenant-a", "site-a", "filters", position)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := codec.Decode(value, "tenant-a", "site-a", "filters")
|
||||
if err != nil || decoded.DeviceID != position.DeviceID || !decoded.CreatedAt.Equal(position.CreatedAt) {
|
||||
t.Fatalf("cursor did not round trip: %+v %v", decoded, err)
|
||||
}
|
||||
for name, candidate := range map[string]string{
|
||||
"tenant": "tenant-b", "site": "site-b", "filter": "other",
|
||||
} {
|
||||
tenant, site, filter := "tenant-a", "site-a", "filters"
|
||||
switch name {
|
||||
case "tenant":
|
||||
tenant = candidate
|
||||
case "site":
|
||||
site = candidate
|
||||
case "filter":
|
||||
filter = candidate
|
||||
}
|
||||
if _, err := codec.Decode(value, tenant, site, filter); err == nil {
|
||||
t.Fatalf("cursor was accepted across %s scope", name)
|
||||
}
|
||||
}
|
||||
tampered := value[:len(value)-1] + strings.ToUpper(value[len(value)-1:])
|
||||
if tampered == value {
|
||||
tampered = value[:len(value)-1] + "A"
|
||||
}
|
||||
if _, err := codec.Decode(tampered, "tenant-a", "site-a", "filters"); err == nil {
|
||||
t.Fatal("tampered cursor was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedIdentifiersMatchContractShape(t *testing.T) {
|
||||
value, err := newULID("op_", time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(value) != 29 || !strings.HasPrefix(value, "op_") {
|
||||
t.Fatalf("invalid operation ID %q", value)
|
||||
}
|
||||
trace, err := newTraceID()
|
||||
if err != nil || len(trace) != 38 {
|
||||
t.Fatalf("invalid trace ID %q: %v", trace, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package controlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/auth"
|
||||
"yovision/sense/internal/device"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
type principalContextKey struct{}
|
||||
type traceContextKey struct{}
|
||||
|
||||
type Server struct {
|
||||
store store.ControlRepository
|
||||
authenticator auth.Authenticator
|
||||
cursors *CursorCodec
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewHTTPHandler(
|
||||
repository store.ControlRepository, authenticator auth.Authenticator, cursors *CursorCodec,
|
||||
) http.Handler {
|
||||
server := &Server{
|
||||
store: repository, authenticator: authenticator, cursors: cursors, now: time.Now,
|
||||
}
|
||||
generated := HandlerWithOptions(server, StdHTTPServerOptions{ErrorHandlerFunc: server.bindError})
|
||||
return server.authenticate(generated)
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
traceID, err := newTraceID()
|
||||
if err != nil {
|
||||
http.Error(writer, "service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(request.Context(), traceContextKey{}, traceID)
|
||||
request = request.WithContext(ctx)
|
||||
values := request.Header.Values("Authorization")
|
||||
if len(values) != 1 {
|
||||
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
|
||||
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
|
||||
return
|
||||
}
|
||||
parts := strings.Fields(values[0])
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
|
||||
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
|
||||
return
|
||||
}
|
||||
principal, err := s.authenticator.Authenticate(request.Context(), parts[1])
|
||||
if err != nil {
|
||||
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
|
||||
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(request.Context(), principalContextKey{}, principal)
|
||||
next.ServeHTTP(writer, request.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func principalFromContext(ctx context.Context) auth.Principal {
|
||||
value, _ := ctx.Value(principalContextKey{}).(auth.Principal)
|
||||
return value
|
||||
}
|
||||
|
||||
func traceFromContext(ctx context.Context) string {
|
||||
value, _ := ctx.Value(traceContextKey{}).(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Server) bindError(writer http.ResponseWriter, request *http.Request, err error) {
|
||||
var required *RequiredHeaderError
|
||||
if errors.As(err, &required) && required.ParamName == "If-Match" {
|
||||
s.writeProblem(writer, request, http.StatusPreconditionRequired,
|
||||
ErrorCodePreconditionRequired, "If-Match is required")
|
||||
return
|
||||
}
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "request parameters are invalid")
|
||||
}
|
||||
|
||||
func (s *Server) requireSite(
|
||||
writer http.ResponseWriter, request *http.Request, siteID, permission string,
|
||||
) (auth.Principal, bool) {
|
||||
principal := principalFromContext(request.Context())
|
||||
if !validLogicalID(siteID) {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "site_id is invalid")
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
if !principal.AllowsSite(siteID) {
|
||||
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
if !principal.Has(permission) {
|
||||
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func (s *Server) writeProblem(
|
||||
writer http.ResponseWriter, request *http.Request, status int, code ErrorCode, message string,
|
||||
) {
|
||||
traceID := traceFromContext(request.Context())
|
||||
writer.Header().Set("Content-Type", "application/problem+json")
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("X-Trace-ID", traceID)
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(Problem{
|
||||
Type: "/problems/" + string(code), Title: http.StatusText(status), Status: status,
|
||||
Code: code, Message: message, TraceId: traceID, FieldErrors: []FieldError{},
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, traceID string, value any) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("X-Trace-ID", traceID)
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(value)
|
||||
}
|
||||
|
||||
func (s *Server) ListDevices(
|
||||
writer http.ResponseWriter, request *http.Request, siteID SiteID, params ListDevicesParams,
|
||||
) {
|
||||
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if params.Limit != nil {
|
||||
limit = *params.Limit
|
||||
}
|
||||
if limit < 1 || limit > 100 ||
|
||||
(params.Modality != nil && !Modality(*params.Modality).Valid()) ||
|
||||
(params.Capability != nil && !Capability(*params.Capability).Valid()) ||
|
||||
(params.DesiredState != nil && !DesiredState(*params.DesiredState).Valid()) ||
|
||||
(params.ActualState != nil && !ActualState(*params.ActualState).Valid()) {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "list filters are invalid")
|
||||
return
|
||||
}
|
||||
filterHash := filterFingerprint(
|
||||
optionalString(params.Modality), optionalString(params.Capability),
|
||||
optionalString(params.DesiredState), optionalString(params.ActualState),
|
||||
)
|
||||
filter := store.ControlListFilter{Limit: limit}
|
||||
if params.Modality != nil {
|
||||
value := device.Modality(*params.Modality)
|
||||
filter.Modality = &value
|
||||
}
|
||||
if params.Capability != nil {
|
||||
value := device.Capability(*params.Capability)
|
||||
filter.Capability = &value
|
||||
}
|
||||
if params.DesiredState != nil {
|
||||
value := device.DesiredState(*params.DesiredState)
|
||||
filter.DesiredState = &value
|
||||
}
|
||||
if params.ActualState != nil {
|
||||
value := device.ActualState(*params.ActualState)
|
||||
filter.ActualState = &value
|
||||
}
|
||||
if params.Cursor != nil {
|
||||
position, err := s.cursors.Decode(*params.Cursor, principal.TenantID, siteID, filterHash)
|
||||
if err != nil {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "cursor is invalid")
|
||||
return
|
||||
}
|
||||
filter.AfterCreated = &position.CreatedAt
|
||||
filter.AfterDeviceID = position.DeviceID
|
||||
}
|
||||
page, err := s.store.ListControlDevices(request.Context(), principal.TenantID, siteID, filter)
|
||||
if err != nil {
|
||||
s.writeStoreError(writer, request, err, false)
|
||||
return
|
||||
}
|
||||
items := make([]Device, 0, len(page.Items))
|
||||
for _, value := range page.Items {
|
||||
items = append(items, publicDevice(value))
|
||||
}
|
||||
var nextCursor *string
|
||||
if page.HasMore && len(page.Items) > 0 {
|
||||
last := page.Items[len(page.Items)-1]
|
||||
value, err := s.cursors.Encode(principal.TenantID, siteID, filterHash,
|
||||
CursorPosition{CreatedAt: last.CreatedAt, DeviceID: last.ID})
|
||||
if err != nil {
|
||||
s.writeProblem(writer, request, http.StatusInternalServerError, ErrorCodeInternalError, "response could not be created")
|
||||
return
|
||||
}
|
||||
nextCursor = &value
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), DevicePage{
|
||||
Items: items, Page: PageInfo{Limit: limit, HasMore: page.HasMore, NextCursor: nextCursor},
|
||||
Quota: publicQuota(page.Quota),
|
||||
})
|
||||
}
|
||||
|
||||
func optionalString[T ~string](value *T) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return string(*value)
|
||||
}
|
||||
|
||||
func (s *Server) CreateDevice(
|
||||
writer http.ResponseWriter, request *http.Request, siteID SiteID, params CreateDeviceParams,
|
||||
) {
|
||||
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
|
||||
return
|
||||
}
|
||||
contents, err := readRequestBody(request, "application/json")
|
||||
if err != nil {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := rejectTopLevelNulls(contents); err != nil {
|
||||
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var body DeviceCreate
|
||||
if err := decodeStrictJSON(contents, &body); err != nil {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if code, message := validateDeviceCreate(body); code != "" {
|
||||
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
|
||||
return
|
||||
}
|
||||
desired := Disabled
|
||||
if body.DesiredState != nil {
|
||||
desired = *body.DesiredState
|
||||
}
|
||||
body.DesiredState = &desired
|
||||
canonical, _ := json.Marshal(body)
|
||||
requestHash := sha256.Sum256(canonical)
|
||||
now := s.now().UTC()
|
||||
deviceID, err := newULID("dev_", now)
|
||||
if err != nil {
|
||||
s.writeProblem(writer, request, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "identifier could not be generated")
|
||||
return
|
||||
}
|
||||
capabilities := make([]device.Capability, len(body.Capabilities))
|
||||
for index := range body.Capabilities {
|
||||
capabilities[index] = device.Capability(body.Capabilities[index])
|
||||
}
|
||||
value := device.Device{
|
||||
ID: deviceID, TenantID: principal.TenantID, SiteID: siteID, AreaID: body.AreaId,
|
||||
SerialNumber: body.SerialNumber, Name: body.Name, Modality: device.Modality(body.Modality),
|
||||
Capabilities: capabilities, DesiredState: device.DesiredState(desired),
|
||||
ActualState: device.ActualPending, PathName: "devices/" + deviceID,
|
||||
Generation: 1, ResourceVersion: 1,
|
||||
}
|
||||
if body.EndpointRef != nil {
|
||||
value.EndpointRef = *body.EndpointRef
|
||||
}
|
||||
if body.CredentialRef != nil {
|
||||
value.CredentialRef = *body.CredentialRef
|
||||
}
|
||||
if body.ProfileToken != nil {
|
||||
value.ProfileToken = *body.ProfileToken
|
||||
}
|
||||
traceID := traceFromContext(request.Context())
|
||||
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
|
||||
result, err := s.store.CreateControlDevice(ctx, store.ControlCreateRequest{
|
||||
Scope: store.IdempotencyScope{
|
||||
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
|
||||
Operation: "createDevice", Key: params.IdempotencyKey,
|
||||
RequestHash: requestHash, TraceID: traceID,
|
||||
},
|
||||
Device: value,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStoreError(writer, request, err, true)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("ETag", result.ETag)
|
||||
writer.Header().Set("Location", result.Location)
|
||||
writeJSON(writer, http.StatusCreated, result.TraceID, publicDevice(result.Device))
|
||||
}
|
||||
|
||||
func validateDeviceCreate(body DeviceCreate) (string, string) {
|
||||
if !validLogicalID(body.AreaId) || !validLength(body.SerialNumber, 1, 128) ||
|
||||
!validLength(body.Name, 1, 200) || strings.TrimSpace(body.SerialNumber) == "" ||
|
||||
strings.TrimSpace(body.Name) == "" || !body.Modality.Valid() ||
|
||||
len(body.Capabilities) < 1 || len(body.Capabilities) > 16 {
|
||||
return "invalid_request", "device fields are invalid"
|
||||
}
|
||||
seen := make(map[Capability]struct{}, len(body.Capabilities))
|
||||
hasVideo := false
|
||||
for _, capability := range body.Capabilities {
|
||||
if !capability.Valid() {
|
||||
return "invalid_request", "device capability is invalid"
|
||||
}
|
||||
if _, exists := seen[capability]; exists {
|
||||
return "invalid_request", "device capabilities contain a duplicate"
|
||||
}
|
||||
seen[capability] = struct{}{}
|
||||
hasVideo = hasVideo || capability == VideoCapture
|
||||
}
|
||||
if body.Modality == Video && !hasVideo {
|
||||
return "invalid_request", "video modality requires video_capture"
|
||||
}
|
||||
if hasVideo && (body.EndpointRef == nil || body.CredentialRef == nil) {
|
||||
return "adapter_not_ready", "video capture requires endpoint and credential references"
|
||||
}
|
||||
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
|
||||
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
|
||||
}
|
||||
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
|
||||
return "endpoint_credentials_forbidden", "credential reference is invalid"
|
||||
}
|
||||
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
|
||||
return "invalid_request", "profile token is invalid"
|
||||
}
|
||||
if body.DesiredState != nil && !body.DesiredState.Valid() {
|
||||
return "invalid_request", "desired_state is invalid"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func auditContext(principal auth.Principal, reason, traceID string) store.AuditContext {
|
||||
actorType := store.AuditActorService
|
||||
if principal.ActorType == "user" {
|
||||
actorType = store.AuditActorUser
|
||||
}
|
||||
return store.AuditContext{
|
||||
ActorType: actorType, ActorID: principal.SubjectID, Reason: reason, TraceID: traceID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) GetDevice(
|
||||
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
|
||||
) {
|
||||
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validLogicalID(deviceID) {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id is invalid")
|
||||
return
|
||||
}
|
||||
value, err := s.store.GetControlDevice(request.Context(), principal.TenantID, siteID, deviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(writer, request, err, false)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("ETag", store.DeviceETag(value.ID, value.ResourceVersion))
|
||||
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicDevice(value))
|
||||
}
|
||||
|
||||
func (s *Server) UpdateDevice(
|
||||
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
|
||||
params UpdateDeviceParams,
|
||||
) {
|
||||
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
|
||||
return
|
||||
}
|
||||
contents, err := readRequestBody(request, "application/merge-patch+json")
|
||||
if err != nil {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(contents, &fields); err != nil || len(fields) == 0 {
|
||||
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch must contain a field")
|
||||
return
|
||||
}
|
||||
for _, value := range fields {
|
||||
if string(value) == "null" {
|
||||
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch fields cannot be null")
|
||||
return
|
||||
}
|
||||
}
|
||||
var body DevicePatch
|
||||
if err := decodeStrictJSON(contents, &body); err != nil {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if code, message := validateDevicePatch(body); code != "" {
|
||||
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
|
||||
return
|
||||
}
|
||||
patch := store.ControlPatch{
|
||||
Name: body.Name, EndpointRef: body.EndpointRef,
|
||||
CredentialRef: body.CredentialRef, ProfileToken: body.ProfileToken,
|
||||
}
|
||||
if body.AreaId != nil {
|
||||
value := string(*body.AreaId)
|
||||
patch.AreaID = &value
|
||||
}
|
||||
traceID := traceFromContext(request.Context())
|
||||
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
|
||||
result, err := s.store.PatchControlDevice(
|
||||
ctx, principal.TenantID, siteID, deviceID, params.IfMatch, patch,
|
||||
)
|
||||
if err != nil {
|
||||
s.writeStoreError(writer, request, err, true)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("ETag", result.ETag)
|
||||
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
|
||||
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
|
||||
})
|
||||
}
|
||||
|
||||
func validateDevicePatch(body DevicePatch) (string, string) {
|
||||
if body.Name != nil && (!validLength(*body.Name, 1, 200) || strings.TrimSpace(*body.Name) == "") {
|
||||
return "invalid_request", "name is invalid"
|
||||
}
|
||||
if body.AreaId != nil && !validLogicalID(*body.AreaId) {
|
||||
return "invalid_request", "area_id is invalid"
|
||||
}
|
||||
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
|
||||
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
|
||||
}
|
||||
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
|
||||
return "endpoint_credentials_forbidden", "credential reference is invalid"
|
||||
}
|
||||
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
|
||||
return "invalid_request", "profile token is invalid"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func (s *Server) SetDeviceDesiredState(
|
||||
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
|
||||
params SetDeviceDesiredStateParams,
|
||||
) {
|
||||
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
|
||||
return
|
||||
}
|
||||
contents, err := readRequestBody(request, "application/json")
|
||||
if err != nil {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var body DesiredStateChange
|
||||
if err := decodeStrictJSON(contents, &body); err != nil {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !body.DesiredState.Valid() || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
|
||||
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "desired_state or reason is invalid")
|
||||
return
|
||||
}
|
||||
traceID := traceFromContext(request.Context())
|
||||
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
|
||||
result, err := s.store.SetControlDesiredState(
|
||||
ctx, principal.TenantID, siteID, deviceID, params.IfMatch,
|
||||
device.DesiredState(body.DesiredState),
|
||||
)
|
||||
if err != nil {
|
||||
s.writeStoreError(writer, request, err, true)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("ETag", result.ETag)
|
||||
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
|
||||
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) BatchSetDeviceDesiredState(
|
||||
writer http.ResponseWriter, request *http.Request, siteID SiteID,
|
||||
params BatchSetDeviceDesiredStateParams,
|
||||
) {
|
||||
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
|
||||
return
|
||||
}
|
||||
contents, err := readRequestBody(request, "application/json")
|
||||
if err != nil {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var body BatchDesiredStateRequest
|
||||
if err := decodeStrictJSON(contents, &body); err != nil {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(body.Items) > 128 {
|
||||
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeBatchTooLarge, "batch contains more than 128 items")
|
||||
return
|
||||
}
|
||||
if len(body.Items) == 0 || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
|
||||
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch items or reason is invalid")
|
||||
return
|
||||
}
|
||||
items := make([]store.ControlBatchItem, 0, len(body.Items))
|
||||
for _, item := range body.Items {
|
||||
if !validLogicalID(item.DeviceId) || !validStrongETag(item.Etag) || !item.DesiredState.Valid() {
|
||||
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch item is invalid")
|
||||
return
|
||||
}
|
||||
items = append(items, store.ControlBatchItem{
|
||||
DeviceID: item.DeviceId, ETag: item.Etag,
|
||||
DesiredState: device.DesiredState(item.DesiredState),
|
||||
})
|
||||
}
|
||||
canonical, _ := json.Marshal(body)
|
||||
requestHash := sha256.Sum256(canonical)
|
||||
traceID := traceFromContext(request.Context())
|
||||
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
|
||||
operation, err := s.store.BatchSetControlDesiredState(ctx, store.ControlBatchRequest{
|
||||
Scope: store.IdempotencyScope{
|
||||
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
|
||||
Operation: "batchSetDeviceDesiredState", Key: params.IdempotencyKey,
|
||||
RequestHash: requestHash, TraceID: traceID,
|
||||
},
|
||||
Reason: body.Reason, Items: items,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStoreError(writer, request, err, true)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Location", "/api/v1/operations/"+operation.ID)
|
||||
writeJSON(writer, http.StatusAccepted, operation.TraceID, publicOperation(operation))
|
||||
}
|
||||
|
||||
func (s *Server) GetOperation(
|
||||
writer http.ResponseWriter, request *http.Request, operationID OperationID,
|
||||
) {
|
||||
principal := principalFromContext(request.Context())
|
||||
if !principal.Has(auth.PermissionDevicesRead) {
|
||||
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
|
||||
return
|
||||
}
|
||||
if !operationIDRegex.MatchString(operationID) {
|
||||
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "operation_id is invalid")
|
||||
return
|
||||
}
|
||||
operation, err := s.store.GetControlOperation(request.Context(), principal.TenantID, operationID)
|
||||
if err != nil {
|
||||
s.writeStoreError(writer, request, err, false)
|
||||
return
|
||||
}
|
||||
if !principal.AllowsSite(operation.SiteID) {
|
||||
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicOperation(operation))
|
||||
}
|
||||
|
||||
func (s *Server) writeStoreError(
|
||||
writer http.ResponseWriter, request *http.Request, err error, mutation bool,
|
||||
) {
|
||||
status, code, message := http.StatusInternalServerError, ErrorCodeInternalError, "request could not be completed"
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
status, code, message = http.StatusNotFound, ErrorCodeNotFound, "resource was not found"
|
||||
case errors.Is(err, store.ErrETagMismatch):
|
||||
status, code, message = http.StatusPreconditionFailed, ErrorCodeEtagMismatch, "device ETag does not match"
|
||||
case errors.Is(err, store.ErrIdempotencyConflict):
|
||||
status, code, message = http.StatusConflict, ErrorCodeIdempotencyConflict, "Idempotency-Key was used with another request"
|
||||
case errors.Is(err, store.ErrDuplicateSerialNumber):
|
||||
status, code, message = http.StatusConflict, ErrorCodeDuplicateSerialNumber, "serial_number already exists in this site"
|
||||
case errors.Is(err, store.ErrAreaPolicyDenied):
|
||||
status, code, message = http.StatusUnprocessableEntity, ErrorCodeAreaPolicyDenied, "Area policy denies this change"
|
||||
case errors.Is(err, store.ErrAreaPolicyUnavailable), errors.Is(err, store.ErrAreaPolicyInvalid):
|
||||
status, code, message = http.StatusServiceUnavailable, ErrorCodeAreaPolicyUnavailable, "Area policy is unavailable"
|
||||
case errors.Is(err, store.ErrQuotaProjectionUnavailable):
|
||||
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionUnavailable, "Site quota is unavailable"
|
||||
case errors.Is(err, store.ErrQuotaProjectionInvalid):
|
||||
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionInvalid, "Site quota is invalid"
|
||||
default:
|
||||
var quotaError *device.QuotaExceededError
|
||||
if errors.As(err, "aError) {
|
||||
status, code, message = http.StatusConflict, ErrorCodeQuotaExceeded, "Site video channel quota is exceeded"
|
||||
} else if mutation {
|
||||
status, code, message = http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "device change could not be accepted"
|
||||
}
|
||||
}
|
||||
s.writeProblem(writer, request, status, code, message)
|
||||
}
|
||||
|
||||
var _ ServerInterface = (*Server)(nil)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package controlapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const maximumRequestBody = 1 << 20
|
||||
|
||||
var (
|
||||
logicalIDRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
|
||||
idempotencyKeyRegex = regexp.MustCompile(`^[A-Za-z0-9._:-]{16,128}$`)
|
||||
operationIDRegex = regexp.MustCompile(`^op_[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||
strongETagRegex = regexp.MustCompile(`^"[A-Za-z0-9_-]{24}"$`)
|
||||
)
|
||||
|
||||
func readRequestBody(request *http.Request, expectedMediaType string) ([]byte, error) {
|
||||
mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != expectedMediaType {
|
||||
return nil, fmt.Errorf("Content-Type must be %s", expectedMediaType)
|
||||
}
|
||||
contents, err := io.ReadAll(io.LimitReader(request.Body, maximumRequestBody+1))
|
||||
if err != nil {
|
||||
return nil, errors.New("read request body")
|
||||
}
|
||||
if len(contents) == 0 || len(contents) > maximumRequestBody {
|
||||
return nil, errors.New("request body is empty or too large")
|
||||
}
|
||||
if err := rejectDuplicateJSONKeys(contents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(contents []byte, destination any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(contents))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(destination); err != nil {
|
||||
return errors.New("request body does not match the API schema")
|
||||
}
|
||||
if decoder.Decode(&struct{}{}) != io.EOF {
|
||||
return errors.New("request body contains trailing JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectTopLevelNulls(contents []byte) error {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(contents, &fields); err != nil || fields == nil {
|
||||
return errors.New("request body must be a JSON object")
|
||||
}
|
||||
for _, value := range fields {
|
||||
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
return errors.New("request body properties cannot be null")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectDuplicateJSONKeys(contents []byte) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(contents))
|
||||
decoder.UseNumber()
|
||||
var visit func(int) error
|
||||
visit = func(depth int) error {
|
||||
if depth > 64 {
|
||||
return errors.New("request body nesting is too deep")
|
||||
}
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return errors.New("request body is not valid JSON")
|
||||
}
|
||||
delimiter, ok := token.(json.Delim)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch delimiter {
|
||||
case '{':
|
||||
seen := make(map[string]struct{})
|
||||
for decoder.More() {
|
||||
keyToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return errors.New("request body is not valid JSON")
|
||||
}
|
||||
key, ok := keyToken.(string)
|
||||
if !ok {
|
||||
return errors.New("request body is not a JSON object")
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
return errors.New("request body contains a duplicate property")
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if err := visit(depth + 1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
if err != nil || end != json.Delim('}') {
|
||||
return errors.New("request body is not valid JSON")
|
||||
}
|
||||
case '[':
|
||||
for decoder.More() {
|
||||
if err := visit(depth + 1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
if err != nil || end != json.Delim(']') {
|
||||
return errors.New("request body is not valid JSON")
|
||||
}
|
||||
default:
|
||||
return errors.New("request body is not valid JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := visit(0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := decoder.Token(); err != io.EOF {
|
||||
return errors.New("request body contains trailing JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validLogicalID(value string) bool {
|
||||
return logicalIDRegex.MatchString(value)
|
||||
}
|
||||
|
||||
func validLength(value string, minimum, maximum int) bool {
|
||||
length := utf8.RuneCountInString(value)
|
||||
return utf8.ValidString(value) && length >= minimum && length <= maximum
|
||||
}
|
||||
|
||||
func validateEndpoint(value string) bool {
|
||||
if !validLength(value, 1, 2048) {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
return err == nil && parsed.Scheme != "" && parsed.User == nil &&
|
||||
!strings.ContainsAny(value, "\r\n")
|
||||
}
|
||||
|
||||
func validStrongETag(value string) bool {
|
||||
return strongETagRegex.MatchString(value) && value != "*" && !strings.Contains(value, ",")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package controlapi
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// MarshalJSON preserves the OpenAPI-required nullable projection keys. The
|
||||
// generator represents JSON null as nil pointers but marks read-only pointers
|
||||
// omitempty, so the default encoder would otherwise violate the v1 wire shape.
|
||||
func (value ProjectionVersions) MarshalJSON() ([]byte, error) {
|
||||
type projectionWire struct {
|
||||
QuotaSourceVersion *int64 `json:"quota_source_version"`
|
||||
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
|
||||
SyncedAt any `json:"synced_at"`
|
||||
}
|
||||
return json.Marshal(projectionWire{
|
||||
QuotaSourceVersion: value.QuotaSourceVersion,
|
||||
AreaPolicySourceVersion: value.AreaPolicySourceVersion,
|
||||
SyncedAt: value.SyncedAt,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user