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
332 lines
12 KiB
Go
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)
|
|
}
|
|
}
|