Files
Behavision/server/internal/store/api_cameras_live_test.go
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

223 lines
7.0 KiB
Go

package store
import (
"context"
"crypto/rand"
"strings"
"testing"
"github.com/loyaly/behavision-server/internal/api"
"github.com/loyaly/behavision-server/internal/secret"
)
// Live database tests for camera onboarding.
//
// These exist because the fake in the API package cannot catch what actually
// goes wrong here: a uuid column handed a slug, an ON CONFLICT that overwrites
// what it should preserve, a tombstone that a later insert quietly revives.
// The first of those shipped and was caught only by running it.
func sealedStore(t *testing.T) *Store {
t.Helper()
st := liveStore(t)
var key [32]byte
if _, err := rand.Read(key[:]); err != nil {
t.Fatal(err)
}
box, err := secret.New(key[:])
if err != nil {
t.Fatal(err)
}
st.UseSecrets(box)
return st
}
func ptr[T any](v T) *T { return &v }
func TestLiveACameraPasswordSurvivesTheRoundTripEncrypted(t *testing.T) {
st := sealedStore(t)
client, site := seedTenant(t, st, "cam"+stamp(), 0, false)
ctx := context.Background()
if _, err := st.SaveCamera(ctx, client, site, "entrance", api.CameraInput{
Label: ptr("Entrance"), Host: ptr("192.168.0.138"),
Username: ptr("admin"), Password: ptr("office-cam-secret"),
}); err != nil {
t.Fatal(err)
}
// Nothing a person can reach carries the password.
cams, err := st.Cameras(ctx, client, "")
if err != nil {
t.Fatal(err)
}
if len(cams) != 1 || !cams[0].HasPassword {
t.Fatalf("camera not stored with a password: %+v", cams)
}
// The agent, and only the agent, gets it back.
agent, err := st.AgentCameras(ctx, site)
if err != nil {
t.Fatal(err)
}
if agent[0].Password != "office-cam-secret" {
t.Fatalf("password did not survive: %q", agent[0].Password)
}
// And it is genuinely encrypted at rest, not merely hidden by the query.
var raw []byte
if err := st.pool.QueryRow(ctx,
`SELECT password_enc FROM site_cameras WHERE site_id = $1::uuid`, site).
Scan(&raw); err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "office-cam-secret") {
t.Fatal("the password is stored in the clear")
}
}
// The aad is the site id, so a row copied between sites in the database does
// not decrypt into a working credential.
func TestLiveACameraRowCopiedToAnotherSiteDoesNotDecrypt(t *testing.T) {
st := sealedStore(t)
client, siteA := seedTenant(t, st, "aad"+stamp(), 0, false)
ctx := context.Background()
var siteB string
if err := st.pool.QueryRow(ctx, `
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, 'Other', $2)
RETURNING id::text`, client, "other"+stamp()).Scan(&siteB); err != nil {
t.Fatal(err)
}
if _, err := st.SaveCamera(ctx, client, siteA, "entrance", api.CameraInput{
Host: ptr("10.0.0.5"), Password: ptr("office-cam-secret")}); err != nil {
t.Fatal(err)
}
// Move the row, as a database-level attacker would.
if _, err := st.pool.Exec(ctx,
`UPDATE site_cameras SET site_id = $1::uuid WHERE site_id = $2::uuid`,
siteB, siteA); err != nil {
t.Fatal(err)
}
got, err := st.AgentCameras(ctx, siteB)
if err != nil {
t.Fatal(err)
}
if got[0].Password != "" {
t.Fatalf("a relocated row decrypted into a usable credential: %q", got[0].Password)
}
}
// Adoption must never overwrite head office's configuration with whatever the
// shop PC happens to hold - an edit made here would silently revert on the
// agent's next sync.
func TestLiveAdoptionNeverOverwritesHeadOffice(t *testing.T) {
st := sealedStore(t)
client, site := seedTenant(t, st, "adopt"+stamp(), 0, false)
ctx := context.Background()
if _, err := st.SaveCamera(ctx, client, site, "entrance", api.CameraInput{
Label: ptr("Front entrance"), Host: ptr("192.168.0.138")}); err != nil {
t.Fatal(err)
}
// The shop PC reports an older, different configuration.
if err := st.ApplyAgentReport(ctx, client, site, api.AgentCameraReport{
Adopt: []api.AgentCamera{{CameraID: "entrance", Label: "stale",
Host: "10.9.9.9", Enabled: true}},
}); err != nil {
t.Fatal(err)
}
cams, err := st.Cameras(ctx, client, "")
if err != nil {
t.Fatal(err)
}
if cams[0].Host != "192.168.0.138" || cams[0].Label != "Front entrance" {
t.Fatalf("adoption clobbered head office: %+v", cams[0])
}
}
// A hard delete would be undone on the next sync by the very camera the
// operator just removed, and they would have no idea why it kept coming back.
func TestLiveADeletedCameraIsNotResurrectedByAdoption(t *testing.T) {
st := sealedStore(t)
client, site := seedTenant(t, st, "tomb"+stamp(), 0, false)
ctx := context.Background()
cam, err := st.SaveCamera(ctx, client, site, "entrance",
api.CameraInput{Host: ptr("10.0.0.5")})
if err != nil {
t.Fatal(err)
}
if _, err := st.DeleteCamera(ctx, client, cam.ID); err != nil {
t.Fatal(err)
}
if err := st.ApplyAgentReport(ctx, client, site, api.AgentCameraReport{
Adopt: []api.AgentCamera{{CameraID: "entrance", Host: "10.0.0.5", Enabled: true}},
}); err != nil {
t.Fatal(err)
}
cams, err := st.Cameras(ctx, client, "")
if err != nil {
t.Fatal(err)
}
if len(cams) != 0 {
t.Fatalf("a deleted camera came back: %+v", cams)
}
// The agent must still be TOLD it is deleted, or it keeps running it.
agent, err := st.AgentCameras(ctx, site)
if err != nil {
t.Fatal(err)
}
if len(agent) != 1 || !agent[0].Deleted {
t.Fatalf("the agent was not told to stop: %+v", agent)
}
}
// Editing one field must not blank the others - especially not the password,
// which the form cannot resend because the API never returned it.
func TestLiveEditingALabelKeepsTheStoredPassword(t *testing.T) {
st := sealedStore(t)
client, site := seedTenant(t, st, "edit"+stamp(), 0, false)
ctx := context.Background()
if _, err := st.SaveCamera(ctx, client, site, "entrance", api.CameraInput{
Label: ptr("Entrance"), Host: ptr("192.168.0.138"),
Username: ptr("admin"), Password: ptr("office-cam-secret")}); err != nil {
t.Fatal(err)
}
if _, err := st.SaveCamera(ctx, client, site, "entrance",
api.CameraInput{Label: ptr("Front door")}); err != nil {
t.Fatal(err)
}
agent, err := st.AgentCameras(ctx, site)
if err != nil {
t.Fatal(err)
}
if agent[0].Password != "office-cam-secret" {
t.Fatalf("the password was lost by a label edit: %q", agent[0].Password)
}
if agent[0].Host != "192.168.0.138" {
t.Fatalf("the address was lost: %q", agent[0].Host)
}
if agent[0].Label != "Front door" {
t.Fatalf("the edit did not apply: %q", agent[0].Label)
}
// The revision has to move, or the agent will not re-apply it.
if agent[0].Revision < 2 {
t.Fatalf("revision %d - the shop PC would never pick this up", agent[0].Revision)
}
}
// One tenant must not be able to write a camera into another's shop, even
// naming a site id that really exists.
func TestLiveACameraCannotBeWrittenIntoAnotherTenantsShop(t *testing.T) {
st := sealedStore(t)
mine, _ := seedTenant(t, st, "mine"+stamp(), 0, false)
_, theirSite := seedTenant(t, st, "theirs"+stamp(), 0, false)
if _, err := st.SaveCamera(context.Background(), mine, theirSite, "entrance",
api.CameraInput{Host: ptr("10.0.0.5")}); err == nil {
t.Fatal("wrote a camera into another tenant's site")
}
}