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
241 lines
8.4 KiB
Go
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)
|
|
}
|
|
}
|