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
This commit is contained in:
331
server/internal/api/cameras_test.go
Normal file
331
server/internal/api/cameras_test.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const siteA = "aaaaaaaa-1111-2222-3333-444444444444"
|
||||
|
||||
// camPath addresses the camera the fake store creates for a given name.
|
||||
func camPath(cameraID string) string { return "/api/cameras/" + fakeCameraUUID(cameraID) }
|
||||
|
||||
// ---------------------------------------------------------------- the boundary
|
||||
|
||||
// The single most important assertion in this file. An RTSP credential is a
|
||||
// live path into the camera itself, and the only consumer that legitimately
|
||||
// needs the plaintext is the agent for its own site.
|
||||
func TestACameraPasswordIsNeverReturnedToAPerson(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
|
||||
"camera_id": "entrance", "label": "Entrance",
|
||||
"host": "192.168.0.138", "username": "admin", "password": "hunter2",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "hunter2") {
|
||||
t.Fatalf("the camera password came back:\n%s", rec.Body.String())
|
||||
}
|
||||
|
||||
list := do(t, s, "GET", "/api/cameras", sess.Token, nil)
|
||||
if strings.Contains(list.Body.String(), "hunter2") {
|
||||
t.Fatalf("the camera password is in the list:\n%s", list.Body.String())
|
||||
}
|
||||
// The operator still has to be able to tell "no password set" from "a
|
||||
// password is set and I am simply not being shown it".
|
||||
if !strings.Contains(list.Body.String(), `"has_password":true`) {
|
||||
t.Errorf("no indication a password is stored:\n%s", list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The agent is the one caller that gets it, and only for its own site.
|
||||
func TestTheAgentReceivesThePasswordItNeedsToConnect(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{
|
||||
AgentID: "a1", ClientID: "client-acme", Site: siteA, SiteID: siteA})
|
||||
fs.agentCameras = []AgentCamera{{
|
||||
CameraID: "entrance", Host: "192.168.0.138", Port: 554,
|
||||
Username: "admin", Password: "hunter2", Enabled: true, Revision: 1,
|
||||
}}
|
||||
req := do(t, s, "GET", "/api/agent/cameras", "agent-token", nil)
|
||||
if req.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", req.Code, req.Body.String())
|
||||
}
|
||||
if !strings.Contains(req.Body.String(), "hunter2") {
|
||||
t.Fatal("the agent did not get the password, so it cannot connect")
|
||||
}
|
||||
}
|
||||
|
||||
// An agent has no user, no role and no session. A person's token must not open
|
||||
// the agent routes, and vice versa.
|
||||
func TestAgentRoutesRefuseAUserSession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
for _, call := range [][2]string{
|
||||
{"GET", "/api/agent/cameras"},
|
||||
{"POST", "/api/agent/cameras"},
|
||||
} {
|
||||
rec := do(t, s, call[0], call[1], sess.Token, AgentCameraReport{})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s: got %d, want 401", call[0], call[1], rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCameraRoutesNeedASession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
if rec := do(t, s, "GET", "/api/cameras", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- editing
|
||||
|
||||
// The camera id is what visits are recorded against. Renaming it would orphan
|
||||
// every visit already attributed to the old name.
|
||||
func TestEditingACameraCannotRenameTheIdVisitsAreRecordedAgainst(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
|
||||
"camera_id": "entrance", "host": "10.0.0.5"})
|
||||
|
||||
rec := do(t, s, "PATCH", camPath("entrance"), sess.Token, map[string]any{
|
||||
"camera_id": "back-door", "label": "Back door"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var cam Camera
|
||||
json.Unmarshal(rec.Body.Bytes(), &cam) //nolint:errcheck
|
||||
if cam.CameraID != "entrance" {
|
||||
t.Fatalf("the camera id was renamed to %q", cam.CameraID)
|
||||
}
|
||||
if cam.Label != "Back door" {
|
||||
t.Errorf("the label should be editable, got %q", cam.Label)
|
||||
}
|
||||
}
|
||||
|
||||
// A blank field means "leave alone". Sending an empty password on every edit is
|
||||
// how a camera loses its credential the first time somebody fixes a typo in the
|
||||
// label.
|
||||
func TestAnOmittedPasswordIsNotSentToTheStore(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
|
||||
"camera_id": "entrance", "host": "10.0.0.5", "password": "hunter2"})
|
||||
|
||||
do(t, s, "PATCH", camPath("entrance"), sess.Token,
|
||||
map[string]any{"label": "Front"})
|
||||
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if fs.lastSaved.Password != nil {
|
||||
t.Fatalf("an edit that did not mention the password sent %q", *fs.lastSaved.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// Staff can fill in a customer form; changing what a camera connects to is a
|
||||
// different kind of act.
|
||||
func TestStaffCannotChangeCameras(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addUser("staff@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-acme", Role: "staff", Active: true})
|
||||
sess := login(t, s, "staff@acme.com", "correct horse battery")
|
||||
|
||||
for _, call := range [][2]string{
|
||||
{"POST", "/api/sites/" + siteA + "/cameras"},
|
||||
{"PATCH", camPath("entrance")},
|
||||
{"DELETE", camPath("entrance")},
|
||||
} {
|
||||
rec := do(t, s, call[0], call[1], sess.Token, map[string]any{"host": "10.0.0.5"})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s: got %d, want 403", call[0], call[1], rec.Code)
|
||||
}
|
||||
}
|
||||
// Reading is fine - staff need to see whether a camera is working.
|
||||
if rec := do(t, s, "GET", "/api/cameras", sess.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Errorf("staff cannot see cameras at all: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- input
|
||||
|
||||
// The id ends up in an object key, a URL path and a topic segment.
|
||||
func TestACameraIdCannotChangeWhatAPathOrTopicMeans(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Front Entrance": "front-entrance",
|
||||
"ch0/0": "ch0-0",
|
||||
"a+b#c": "a-b-c",
|
||||
" Till 2 ": "till-2",
|
||||
"../../etc": "etc",
|
||||
"!!!": "",
|
||||
} {
|
||||
if got := cameraSlug(in); got != want {
|
||||
t.Errorf("cameraSlug(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The message has to say what to type, not name a field.
|
||||
func TestACameraWithNoAddressIsRefusedWithUsableAdvice(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token,
|
||||
map[string]any{"camera_id": "entrance"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "192.168") {
|
||||
t.Errorf("the message should show the shape of an address: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestACameraNeedsAName(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token,
|
||||
map[string]any{"host": "10.0.0.5"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A camera saved with its password silently dropped will not connect, and the
|
||||
// operator could not tell that from a wrong password.
|
||||
func TestSavingAPasswordWithNoEncryptionKeyFailsLoudly(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.saveCameraErr = ErrNoSecrets
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
|
||||
"camera_id": "entrance", "host": "10.0.0.5", "password": "hunter2"})
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("got %d, want 503: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "encryption key") {
|
||||
t.Errorf("the message does not name the cause: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshots
|
||||
|
||||
// Most deployments store no images at all, so "no picture" is the ordinary
|
||||
// case and must not read as a fault.
|
||||
func TestNoSnapshotIsDataNotAnError(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/cameras", sess.Token, nil)
|
||||
var cams []Camera
|
||||
json.Unmarshal(rec.Body.Bytes(), &cams) //nolint:errcheck
|
||||
if cams[0].Snapshot.Available {
|
||||
t.Fatal("claimed a picture with no key")
|
||||
}
|
||||
if cams[0].Snapshot.Reason == "" {
|
||||
t.Fatal("no reason given for the missing picture")
|
||||
}
|
||||
}
|
||||
|
||||
// A snapshot is a frame of a shop floor: a short-lived signed link, never a
|
||||
// stored URL, and never the raw key.
|
||||
func TestASnapshotIsASignedLinkAndTheKeyStaysHidden(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance",
|
||||
Snapshot: Image{Key: "behavision/v2/acme/main/snap.jpg"}}}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/cameras", sess.Token, nil)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "X-Amz-Signature") {
|
||||
t.Fatalf("no signed link: %s", body)
|
||||
}
|
||||
if strings.Contains(body, `"key"`) || strings.Contains(body, `"Key"`) {
|
||||
t.Fatalf("the raw object key is in the response: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- adoption
|
||||
|
||||
// The agent may report its own site's state; the site comes from its
|
||||
// credential, never from the body.
|
||||
func TestAnAgentReportIsScopedByItsOwnCredential(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{
|
||||
AgentID: "a1", ClientID: "client-acme", Site: siteA, SiteID: siteA})
|
||||
|
||||
rec := do(t, s, "POST", "/api/agent/cameras", "agent-token", AgentCameraReport{
|
||||
State: []AgentCameraState{{CameraID: "entrance", Connected: true}},
|
||||
Adopt: []AgentCamera{{CameraID: "Office Cam", Host: "192.168.0.138"}},
|
||||
})
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if got := fs.lastCameraReport.Adopt[0].CameraID; got != "office-cam" {
|
||||
t.Errorf("an adopted id was not normalised: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The create response must carry the same snapshot explanation the list does.
|
||||
// Decorating a copy and serialising the original returned an empty snapshot
|
||||
// object, so a freshly added camera showed no picture and no reason for it.
|
||||
func TestACreatedCameraExplainsItsMissingPicture(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token,
|
||||
map[string]any{"camera_id": "entrance", "host": "10.0.0.5"})
|
||||
var cam Camera
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &cam); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cam.Snapshot.Reason == "" {
|
||||
t.Fatalf("no reason for the missing picture:\n%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// AgentPrincipal carries both the tenant's uuid and its human slug, and the
|
||||
// slug is the one that reads correctly in a log line - which is exactly why it
|
||||
// gets used by mistake in a query that wants the uuid. This shipped once and
|
||||
// only failed against a real database.
|
||||
func TestAnAgentReportIsStoredAgainstTheTenantUUIDNotTheSlug(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{
|
||||
AgentID: "a1",
|
||||
ClientID: "8f1e0c2a-1111-2222-3333-444444444444", // the uuid
|
||||
Client: "nearle", // the slug
|
||||
SiteID: siteA, Site: "chennai",
|
||||
})
|
||||
do(t, s, "POST", "/api/agent/cameras", "agent-token", AgentCameraReport{
|
||||
State: []AgentCameraState{{CameraID: "entrance", Connected: true}}})
|
||||
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if fs.lastReportClient != "8f1e0c2a-1111-2222-3333-444444444444" {
|
||||
t.Fatalf("stored against %q - a slug will not cast to uuid", fs.lastReportClient)
|
||||
}
|
||||
if fs.lastReportSite != siteA {
|
||||
t.Fatalf("site %q", fs.lastReportSite)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user