Files
Behavision/server/internal/api/cameras_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

332 lines
12 KiB
Go

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)
}
}