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

241 lines
8.4 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/loyaly/behavision-server/internal/auth"
)
// enrol runs a real enrolment and returns the agent's own API token.
func enrol(t *testing.T, s *Server, fs *fakeStore) string {
t.Helper()
code := "ABCDEF-123456"
fs.enrolment[hashHex(code)] = Enrolment{
ClientID: "client-acme", AgentID: "agent-1", SiteID: "site-1",
SiteName: "Chennai", SiteSlug: "store1",
MQTTUser: "acme.store1", MQTTPass: "broker-secret",
}
rec := do(t, s, "POST", "/api/agent/enrol", "", map[string]string{"site_token": code})
if rec.Code != http.StatusOK {
t.Fatalf("enrol failed: %d %s", rec.Code, rec.Body.String())
}
var got map[string]any
json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck
tok, _ := got["agent_token"].(string)
if tok == "" {
t.Fatal("enrolment did not return an agent token")
}
return tok
}
func TestEnrolmentIssuesAnAgentTokenSeparateFromTheBrokerPassword(t *testing.T) {
s, fs := newServer(t)
tok := enrol(t, s, fs)
// Two secrets for two different questions: the broker password says this
// site may publish events, the agent token says it may ask the API for
// something. One secret for both means rotating either breaks the other.
if tok == "broker-secret" {
t.Fatal("the agent token is the broker password")
}
if _, err := fs.AgentByToken(t.Context(), auth.HashToken(tok)); err != nil {
t.Fatalf("the issued token does not authenticate: %v", err)
}
}
func TestUploadURLRequiresAnEnrolledAgent(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
if rec := do(t, s, "POST", "/api/agent/upload-url", "", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated upload-url returned %d", rec.Code)
}
if rec := do(t, s, "POST", "/api/agent/upload-url", "not-a-token", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("a bogus agent token was accepted: %d", rec.Code)
}
// A staff session is not an agent. The two are authenticated differently
// and must not be interchangeable.
sess := login(t, s, "manager@acme.com", "correct horse battery")
if rec := do(t, s, "POST", "/api/agent/upload-url", sess.Token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("a user session was accepted as an agent: %d", rec.Code)
}
}
// The server picks the key from the credential the request authenticated with.
// A caller-supplied key would let one site overwrite another's images, which is
// the entire reason uploads are presigned instead of shipping a bucket password.
func TestTheServerChoosesTheKeyNotTheAgent(t *testing.T) {
s, fs := newServer(t)
blob := &fakeBlob{}
s.Blob = blob
tok := enrol(t, s, fs)
rec := do(t, s, "POST", "/api/agent/upload-url", tok,
map[string]string{"content_type": "image/jpeg"})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var target UploadTarget
json.Unmarshal(rec.Body.Bytes(), &target) //nolint:errcheck
if !strings.HasPrefix(target.Key, "behavision/acme/store1/") {
t.Fatalf("key is not namespaced to the authenticated site: %q", target.Key)
}
// The ACL must be handed back for the agent to send, because it is inside
// the signature: the shop PC cannot decide to publish the image instead.
if target.Headers["x-amz-acl"] != "private" {
t.Fatalf("upload does not force a private ACL: %v", target.Headers)
}
if target.URL == "" || target.ExpiresIn <= 0 {
t.Fatalf("incomplete upload target: %+v", target)
}
// Two requests must not collide on one object.
rec2 := do(t, s, "POST", "/api/agent/upload-url", tok, nil)
var second UploadTarget
json.Unmarshal(rec2.Body.Bytes(), &second) //nolint:errcheck
if second.Key == target.Key {
t.Fatal("two uploads were given the same key")
}
}
func TestUploadIsRefusedCleanlyWhenImagesAreOff(t *testing.T) {
s, fs := newServer(t)
s.Blob = nil // the default: this product stores no images unless told to
tok := enrol(t, s, fs)
rec := do(t, s, "POST", "/api/agent/upload-url", tok, nil)
// 501, not 500: the agent should carry on sending visits without a photo
// rather than treating this as a failure to retry.
if rec.Code != http.StatusNotImplemented {
t.Fatalf("got %d, want 501", rec.Code)
}
}
func TestVisitorImageIsAShortLivedLinkAndIsAudited(t *testing.T) {
s, fs := newServer(t)
blob := &fakeBlob{}
s.Blob = blob
seedUser(fs)
fs.imageKeys[visitorAID] = "behavision/acme/store1/2026/08/31/abc.jpg"
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", visitorA+"/image", sess.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var body map[string]any
json.Unmarshal(rec.Body.Bytes(), &body) //nolint:errcheck
url, _ := body["url"].(string)
if !strings.Contains(url, "X-Amz-Signature") {
t.Fatalf("not a presigned link: %q", url)
}
if body["expires_in"] == nil {
t.Fatal("the caller is not told the link expires")
}
// Every read of a face image is worth a row: "who looked at my customers"
// needs an answer that is not a guess.
var audited bool
for _, a := range fs.audits {
if a.Action == "image.view" && a.EntityID == visitorAID {
audited = true
}
}
if !audited {
t.Fatalf("viewing a face image was not audited: %+v", fs.audits)
}
}
func TestAnotherTenantsImageIsNotFound(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
// The store scopes by client, so a foreign id simply has no key.
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/visitors/"+visitorB+"/image", sess.Token, nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404", rec.Code)
}
}
// -- erasure ----------------------------------------------------------------
func TestErasureDeletesTheImageBeforeTheDatabaseRow(t *testing.T) {
s, fs := newServer(t)
blob := &fakeBlob{}
s.Blob = blob
seedUser(fs)
key := "behavision/acme/store1/2026/08/31/abc.jpg"
fs.imageKeys[visitorAID] = key
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "DELETE", visitorA, sess.Token, nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if len(blob.deleted) != 1 || blob.deleted[0] != key {
t.Fatalf("the face image was not deleted from storage: %v", blob.deleted)
}
if len(fs.forgotten) != 1 || fs.forgotten[0] != visitorAID {
t.Fatalf("the database record was not erased: %v", fs.forgotten)
}
}
// If the object delete fails and the row is erased anyway, the keys are gone
// and nothing knows which files to remove - the image outlives the request with
// no record that it should not. Reporting success there is the one outcome this
// endpoint must never produce.
func TestAFailedImageDeleteAbortsTheWholeErasure(t *testing.T) {
s, fs := newServer(t)
blob := &fakeBlob{failNext: errors.New("bucket unreachable")}
s.Blob = blob
seedUser(fs)
fs.imageKeys[visitorAID] = "behavision/acme/store1/2026/08/31/abc.jpg"
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "DELETE", visitorA, sess.Token, nil)
if rec.Code != http.StatusBadGateway {
t.Fatalf("got %d, want 502", rec.Code)
}
if len(fs.forgotten) != 0 {
t.Fatal("the database row was erased while the photo survived")
}
// And the operator is told to retry rather than believing it is done.
if !strings.Contains(strings.ToLower(rec.Body.String()), "try again") {
t.Fatalf("unhelpful message: %s", rec.Body.String())
}
}
func TestOnlyManagersAndAboveCanErase(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
fs.addUser("shopfloor@acme.com", "correct horse battery", UserRecord{
ID: "u7", ClientID: "client-acme", Role: "staff", Active: true,
})
sess := login(t, s, "shopfloor@acme.com", "correct horse battery")
// Staff fill in the customer form; destroying a record is a different
// decision with a different blast radius.
if rec := do(t, s, "DELETE", visitorA, sess.Token, nil); rec.Code != http.StatusForbidden {
t.Fatalf("staff could erase a customer: %d", rec.Code)
}
}
func TestErasureWithNoImageStillErasesTheRecord(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
// Most visitors have no photo. Erasure must not depend on there being one.
if rec := do(t, s, "DELETE", visitorA, sess.Token, nil); rec.Code != http.StatusNoContent {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if len(fs.forgotten) != 1 {
t.Fatalf("record not erased: %v", fs.forgotten)
}
}