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
223 lines
7.0 KiB
Go
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")
|
|
}
|
|
}
|