feat(sense): implement Control API v1 [T-011]
This commit is contained in:
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -286,12 +287,12 @@ func TestPostgresOpenRejectsOverprivilegedRuntimeRole(t *testing.T) {
|
||||
_, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
if _, err := admin.ExecContext(ctx,
|
||||
`GRANT UPDATE ON bell.site_quota_v1 TO yovision_t010_sense`); err != nil {
|
||||
`GRANT UPDATE ON bell.site_quota_v1 TO yovision_t011_sense`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = admin.ExecContext(context.Background(),
|
||||
`REVOKE UPDATE ON bell.site_quota_v1 FROM yovision_t010_sense`)
|
||||
`REVOKE UPDATE ON bell.site_quota_v1 FROM yovision_t011_sense`)
|
||||
}()
|
||||
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
|
||||
if value != nil {
|
||||
@@ -596,12 +597,12 @@ func TestPostgresOpenRejectsAreaSourcePrivilege(t *testing.T) {
|
||||
_, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
if _, err := admin.ExecContext(ctx,
|
||||
`GRANT SELECT ON bell.areas TO yovision_t010_sense`); err != nil {
|
||||
`GRANT SELECT ON bell.areas TO yovision_t011_sense`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = admin.ExecContext(context.Background(),
|
||||
`REVOKE SELECT ON bell.areas FROM yovision_t010_sense`)
|
||||
`REVOKE SELECT ON bell.areas FROM yovision_t011_sense`)
|
||||
}()
|
||||
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
|
||||
if value != nil {
|
||||
@@ -613,6 +614,361 @@ func TestPostgresOpenRejectsAreaSourcePrivilege(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresOpenRejectsPublicControlStatePrivilege(t *testing.T) {
|
||||
_, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
if _, err := admin.ExecContext(ctx, `GRANT SELECT ON sense.batch_operations TO PUBLIC`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = admin.ExecContext(context.Background(), `REVOKE SELECT ON sense.batch_operations FROM PUBLIC`)
|
||||
}()
|
||||
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
|
||||
if value != nil {
|
||||
_ = value.Close()
|
||||
t.Fatal("PUBLIC Control API table privilege was accepted")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "Control API state privilege boundary") {
|
||||
t.Fatalf("expected Control API privilege-boundary error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresControlCreateIdempotencyAndRedactedSnapshot(t *testing.T) {
|
||||
postgres, admin := openPostgresTestStore(t)
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
value := videoDevice(1, "tenant", "site")
|
||||
value.ID = "dev_control_create_1"
|
||||
value.DesiredState = device.DesiredDisabled
|
||||
hash := sha256.Sum256([]byte("canonical-create"))
|
||||
ctx := WithAuditContext(context.Background(), AuditContext{
|
||||
ActorType: AuditActorService, ActorID: "bell-control", TraceID: "trace-control-create",
|
||||
})
|
||||
request := ControlCreateRequest{
|
||||
Scope: IdempotencyScope{
|
||||
PrincipalID: "bell-control", TenantID: "tenant", SiteID: "site",
|
||||
Operation: "createDevice", Key: "create-control-0001",
|
||||
RequestHash: hash, TraceID: "trace-control-create",
|
||||
},
|
||||
Device: value,
|
||||
}
|
||||
first, err := postgres.CreateControlDevice(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Device.ID = "dev_control_create_retry"
|
||||
replayed, err := postgres.CreateControlDevice(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !replayed.Replay || replayed.Device.ID != first.Device.ID || replayed.TraceID != first.TraceID {
|
||||
t.Fatalf("create replay drifted: first=%+v replay=%+v", first, replayed)
|
||||
}
|
||||
conflictHash := sha256.Sum256([]byte("different-create"))
|
||||
request.Scope.RequestHash = conflictHash
|
||||
if _, err := postgres.CreateControlDevice(ctx, request); !errors.Is(err, ErrIdempotencyConflict) {
|
||||
t.Fatalf("same key with different body was not rejected: %v", err)
|
||||
}
|
||||
var devices, receipts int
|
||||
var responseBody string
|
||||
if err := admin.QueryRow(`SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.QueryRow(`SELECT count(*), min(response_body::text)
|
||||
FROM sense.control_idempotency_receipts`).Scan(&receipts, &responseBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if devices != 1 || receipts != 1 {
|
||||
t.Fatalf("idempotent create counts drifted: devices=%d receipts=%d", devices, receipts)
|
||||
}
|
||||
for _, forbidden := range []string{value.EndpointRef, value.CredentialRef, "profile_token", "path_name"} {
|
||||
if strings.Contains(responseBody, forbidden) {
|
||||
t.Fatalf("idempotency response snapshot leaked %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresControlListIsStableFilteredAndTenantScoped(t *testing.T) {
|
||||
postgres, admin := openPostgresTestStore(t)
|
||||
insertBellSite(t, admin, "tenant", "site", 4)
|
||||
insertBellSite(t, admin, "other", "site", 4)
|
||||
for index := 1; index <= 3; index++ {
|
||||
value := videoDevice(index, "tenant", "site")
|
||||
value.ID = fmt.Sprintf("dev_list_%d", index)
|
||||
value.DesiredState = device.DesiredDisabled
|
||||
if index == 3 {
|
||||
value.DesiredState = device.DesiredEnabled
|
||||
}
|
||||
if err := postgres.CreateDevice(context.Background(), value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
other := videoDevice(9, "other", "site")
|
||||
other.ID, other.DesiredState = "dev_list_other", device.DesiredDisabled
|
||||
if err := postgres.CreateDevice(context.Background(), other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := postgres.ListControlDevices(context.Background(), "tenant", "site", ControlListFilter{Limit: 1})
|
||||
if err != nil || len(first.Items) != 1 || !first.HasMore || first.Quota.UsedVideoChannels != 1 {
|
||||
t.Fatalf("unexpected first page: %+v %v", first, err)
|
||||
}
|
||||
second, err := postgres.ListControlDevices(context.Background(), "tenant", "site", ControlListFilter{
|
||||
Limit: 1, AfterCreated: &first.Items[0].CreatedAt, AfterDeviceID: first.Items[0].ID,
|
||||
})
|
||||
if err != nil || len(second.Items) != 1 || second.Items[0].ID == first.Items[0].ID {
|
||||
t.Fatalf("stable cursor position failed: %+v %v", second, err)
|
||||
}
|
||||
desired := device.DesiredEnabled
|
||||
filtered, err := postgres.ListControlDevices(context.Background(), "tenant", "site", ControlListFilter{
|
||||
Limit: 100, DesiredState: &desired,
|
||||
})
|
||||
if err != nil || len(filtered.Items) != 1 || filtered.Items[0].ID != "dev_list_3" {
|
||||
t.Fatalf("desired-state filter or tenant scope failed: %+v %v", filtered, err)
|
||||
}
|
||||
if _, err := postgres.ListControlDevices(context.Background(), "tenant", "missing", ControlListFilter{Limit: 50}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("missing Site did not return not found: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresControlPatchUsesETagAndAuditV2(t *testing.T) {
|
||||
postgres, admin := openPostgresTestStore(t)
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
value := videoDevice(1, "tenant", "site")
|
||||
value.ID = "dev_control_patch_1"
|
||||
value.DesiredState = device.DesiredDisabled
|
||||
if err := postgres.CreateDevice(context.Background(), value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := postgres.GetControlDevice(context.Background(), "tenant", "site", value.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etag := DeviceETag(current.ID, current.ResourceVersion)
|
||||
newName, newProfile := "Updated camera", "profile-main"
|
||||
ctx := WithAuditContext(context.Background(), AuditContext{
|
||||
ActorType: AuditActorUser, ActorID: "operator-1", TraceID: "trace-control-patch",
|
||||
})
|
||||
updated, err := postgres.PatchControlDevice(ctx, "tenant", "site", value.ID, etag, ControlPatch{
|
||||
Name: &newName, ProfileToken: &newProfile,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Device.ResourceVersion != current.ResourceVersion+1 ||
|
||||
updated.Device.Generation != current.Generation+1 || updated.ETag == etag {
|
||||
t.Fatalf("patch did not advance versions: before=%+v after=%+v", current, updated)
|
||||
}
|
||||
if _, err := postgres.PatchControlDevice(ctx, "tenant", "site", value.ID, etag, ControlPatch{Name: &newName}); !errors.Is(err, ErrETagMismatch) {
|
||||
t.Fatalf("stale ETag was accepted: %v", err)
|
||||
}
|
||||
var profileToken, eventType, payload string
|
||||
if err := admin.QueryRow(`SELECT profile_token FROM sense.devices WHERE id = $1`, value.ID).Scan(&profileToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.QueryRow(`SELECT event_type, payload::text
|
||||
FROM sense.device_operation_outbox WHERE event_type = 'device.configuration.accepted'`).
|
||||
Scan(&eventType, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profileToken != newProfile || eventType != "device.configuration.accepted" ||
|
||||
strings.Contains(payload, newProfile) || !strings.Contains(payload, "profile_token") {
|
||||
t.Fatalf("configuration persistence/audit mismatch: profile=%q event=%q payload=%s", profileToken, eventType, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresControlBatchIsPerItemDurableAndReplayable(t *testing.T) {
|
||||
postgres, admin := openPostgresTestStore(t)
|
||||
insertBellSite(t, admin, "tenant", "site", 4)
|
||||
first := videoDevice(1, "tenant", "site")
|
||||
first.ID, first.DesiredState = "dev_batch_1", device.DesiredDisabled
|
||||
second := videoDevice(2, "tenant", "site")
|
||||
second.ID, second.DesiredState = "dev_batch_2", device.DesiredDisabled
|
||||
for _, value := range []device.Device{first, second} {
|
||||
if err := postgres.CreateDevice(context.Background(), value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
hash := sha256.Sum256([]byte("canonical-batch"))
|
||||
ctx := WithAuditContext(context.Background(), AuditContext{
|
||||
ActorType: AuditActorUser, ActorID: "operator-1", Reason: "approved", TraceID: "trace-batch",
|
||||
})
|
||||
request := ControlBatchRequest{
|
||||
Scope: IdempotencyScope{
|
||||
PrincipalID: "operator-1", TenantID: "tenant", SiteID: "site",
|
||||
Operation: "batchSetDeviceDesiredState", Key: "batch-control-0001",
|
||||
RequestHash: hash, TraceID: "trace-batch",
|
||||
},
|
||||
Reason: "approved",
|
||||
Items: []ControlBatchItem{
|
||||
{DeviceID: first.ID, ETag: DeviceETag(first.ID, 1), DesiredState: device.DesiredEnabled},
|
||||
{DeviceID: first.ID, ETag: DeviceETag(first.ID, 1), DesiredState: device.DesiredEnabled},
|
||||
{DeviceID: second.ID, ETag: DeviceETag(second.ID, 1), DesiredState: device.DesiredEnabled},
|
||||
},
|
||||
}
|
||||
operation, err := postgres.BatchSetControlDesiredState(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if operation.Status != "partially_succeeded" || len(operation.Results) != 3 ||
|
||||
operation.Results[0].Status != "rejected" || operation.Results[1].Status != "rejected" ||
|
||||
operation.Results[2].Status != "succeeded" {
|
||||
t.Fatalf("unexpected batch result: %+v", operation)
|
||||
}
|
||||
replayed, err := postgres.BatchSetControlDesiredState(ctx, request)
|
||||
if err != nil || !replayed.Replay || replayed.ID != operation.ID {
|
||||
t.Fatalf("batch replay drifted: %+v %v", replayed, err)
|
||||
}
|
||||
read, err := postgres.GetControlOperation(context.Background(), "tenant", operation.ID)
|
||||
if err != nil || read.SiteID != "site" || len(read.Results) != 3 {
|
||||
t.Fatalf("stored operation could not be read: %+v %v", read, err)
|
||||
}
|
||||
if _, err := postgres.GetControlOperation(context.Background(), "other-tenant", operation.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("cross-tenant operation was visible: %v", err)
|
||||
}
|
||||
var enabled, operations, receipts int
|
||||
if err := admin.QueryRow(`SELECT count(*) FROM sense.devices WHERE desired_state = 'enabled'`).Scan(&enabled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.QueryRow(`SELECT count(*) FROM sense.batch_operations`).Scan(&operations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.QueryRow(`SELECT count(*) FROM sense.control_idempotency_receipts`).Scan(&receipts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enabled != 1 || operations != 1 || receipts != 1 {
|
||||
t.Fatalf("batch durability counts drifted: enabled=%d operations=%d receipts=%d", enabled, operations, receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresConcurrentControlCreateExecutesOnce(t *testing.T) {
|
||||
postgres, admin := openPostgresTestStore(t)
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
hash := sha256.Sum256([]byte("concurrent-create"))
|
||||
start := make(chan struct{})
|
||||
results := make(chan ControlCreateResult, 2)
|
||||
errorsFound := make(chan error, 2)
|
||||
var wait sync.WaitGroup
|
||||
for index := 1; index <= 2; index++ {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
value := videoDevice(index, "tenant", "site")
|
||||
value.ID = fmt.Sprintf("dev_concurrent_%d", index)
|
||||
value.SerialNumber = "same-semantic-serial"
|
||||
value.DesiredState = device.DesiredDisabled
|
||||
request := ControlCreateRequest{
|
||||
Scope: IdempotencyScope{
|
||||
PrincipalID: "service", TenantID: "tenant", SiteID: "site",
|
||||
Operation: "createDevice", Key: "concurrent-create-0001",
|
||||
RequestHash: hash, TraceID: fmt.Sprintf("trace-concurrent-%d", index),
|
||||
}, Device: value,
|
||||
}
|
||||
<-start
|
||||
result, err := postgres.CreateControlDevice(context.Background(), request)
|
||||
if err != nil {
|
||||
errorsFound <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}(index)
|
||||
}
|
||||
close(start)
|
||||
wait.Wait()
|
||||
close(results)
|
||||
close(errorsFound)
|
||||
for err := range errorsFound {
|
||||
t.Fatalf("concurrent idempotent create failed: %v", err)
|
||||
}
|
||||
ids := make(map[string]struct{})
|
||||
for result := range results {
|
||||
ids[result.Device.ID] = struct{}{}
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("concurrent create returned multiple resources: %+v", ids)
|
||||
}
|
||||
var devices, audits, receipts int
|
||||
if err := admin.QueryRow(`SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.QueryRow(`SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.QueryRow(`SELECT count(*) FROM sense.control_idempotency_receipts`).Scan(&receipts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if devices != 1 || audits != 1 || receipts != 1 {
|
||||
t.Fatalf("concurrent create executed more than once: devices=%d audits=%d receipts=%d", devices, audits, receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresConcurrentControlBatchesUseStableDeviceLockOrder(t *testing.T) {
|
||||
postgres, admin := openPostgresTestStore(t)
|
||||
insertBellSite(t, admin, "tenant", "site", 4)
|
||||
ids := []string{"dev_lock_a", "dev_lock_b"}
|
||||
for index, id := range ids {
|
||||
value := videoDevice(index+1, "tenant", "site")
|
||||
value.ID, value.DesiredState = id, device.DesiredDisabled
|
||||
if err := postgres.CreateDevice(context.Background(), value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
start := make(chan struct{})
|
||||
operations := make(chan ControlBatchOperation, 2)
|
||||
errorsFound := make(chan error, 2)
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < 2; index++ {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
order := ids
|
||||
if index == 1 {
|
||||
order = []string{ids[1], ids[0]}
|
||||
}
|
||||
hash := sha256.Sum256([]byte(fmt.Sprintf("batch-order-%d", index)))
|
||||
request := ControlBatchRequest{
|
||||
Scope: IdempotencyScope{
|
||||
PrincipalID: "operator", TenantID: "tenant", SiteID: "site",
|
||||
Operation: "batchSetDeviceDesiredState",
|
||||
Key: fmt.Sprintf("batch-lock-order-%04d", index), RequestHash: hash,
|
||||
TraceID: fmt.Sprintf("trace-lock-order-%d", index),
|
||||
},
|
||||
Reason: "concurrency test",
|
||||
Items: []ControlBatchItem{
|
||||
{DeviceID: order[0], ETag: DeviceETag(order[0], 1), DesiredState: device.DesiredEnabled},
|
||||
{DeviceID: order[1], ETag: DeviceETag(order[1], 1), DesiredState: device.DesiredEnabled},
|
||||
},
|
||||
}
|
||||
<-start
|
||||
operation, err := postgres.BatchSetControlDesiredState(context.Background(), request)
|
||||
if err != nil {
|
||||
errorsFound <- err
|
||||
return
|
||||
}
|
||||
operations <- operation
|
||||
}(index)
|
||||
}
|
||||
close(start)
|
||||
wait.Wait()
|
||||
close(operations)
|
||||
close(errorsFound)
|
||||
for err := range errorsFound {
|
||||
t.Fatalf("opposite-order batch failed or deadlocked: %v", err)
|
||||
}
|
||||
var succeeded, failed int
|
||||
for operation := range operations {
|
||||
switch operation.Status {
|
||||
case "succeeded":
|
||||
succeeded++
|
||||
case "failed":
|
||||
failed++
|
||||
default:
|
||||
t.Fatalf("unexpected concurrent batch status: %+v", operation)
|
||||
}
|
||||
}
|
||||
if succeeded != 1 || failed != 1 {
|
||||
t.Fatalf("expected one winner and one stale loser, got succeeded=%d failed=%d", succeeded, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv(postgresTestDSNEnv)
|
||||
@@ -629,6 +985,9 @@ func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) {
|
||||
t.Fatal("connect PostgreSQL test administrator")
|
||||
}
|
||||
if _, err := admin.ExecContext(context.Background(), `TRUNCATE
|
||||
sense.control_idempotency_receipts,
|
||||
sense.batch_operation_items,
|
||||
sense.batch_operations,
|
||||
sense.device_operation_outbox,
|
||||
sense.device_capabilities,
|
||||
sense.reconcile_state,
|
||||
|
||||
Reference in New Issue
Block a user