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:
101
server/internal/web/web_test.go
Normal file
101
server/internal/web/web_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func get(t *testing.T, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
h, err := Handler()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestTheRootServesTheApp(t *testing.T) {
|
||||
rec := get(t, "/")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "<") {
|
||||
t.Fatal("no markup came back")
|
||||
}
|
||||
}
|
||||
|
||||
// A deep link or a browser reload must land on the app. Returning 404 for a
|
||||
// path the client router owns is the classic single-page-app deployment bug,
|
||||
// and it only shows up when somebody refreshes a page that is not the root -
|
||||
// which is to say, in front of a customer.
|
||||
func TestAnyClientRouteReturnsTheApp(t *testing.T) {
|
||||
for _, path := range []string{"/customers", "/reports", "/sites/abc/live"} {
|
||||
rec := get(t, path)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: got %d, want the app", path, rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("%s: content-type %q", path, ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Caching the entry document is how a browser ends up running last week's
|
||||
// bundle against this week's API - a version skew nobody can reproduce because
|
||||
// it depends on one machine's cache.
|
||||
func TestTheEntryDocumentIsNeverCached(t *testing.T) {
|
||||
if got := get(t, "/").Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control %q, want no-store", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A path that climbs out of the bundle must not reach the filesystem. Cleaned
|
||||
// before it is opened, so this resolves inside dist or not at all.
|
||||
func TestPathTraversalCannotEscapeTheBundle(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/../../../../etc/passwd",
|
||||
"/assets/../../../etc/passwd",
|
||||
"/..%2f..%2fetc%2fpasswd",
|
||||
} {
|
||||
rec := get(t, path)
|
||||
if body := rec.Body.String(); strings.Contains(body, "root:") {
|
||||
t.Fatalf("%s leaked a system file", path)
|
||||
}
|
||||
// Falling through to the app is the correct answer: it is not a file.
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Logf("%s -> %d (fine, as long as nothing leaked)", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fingerprinted assets are safe to cache hard - their names change with their
|
||||
// contents - and caching them is most of what makes the app load instantly on
|
||||
// a shop's connection.
|
||||
func TestFingerprintedAssetsAreCachedHard(t *testing.T) {
|
||||
h, err := Handler()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Find whatever the current build named them.
|
||||
rec := get(t, "/")
|
||||
body := rec.Body.String()
|
||||
i := strings.Index(body, "/assets/")
|
||||
if i < 0 {
|
||||
t.Skip("this build has no fingerprinted assets (placeholder index.html)")
|
||||
}
|
||||
rest := body[i:]
|
||||
name := rest[:strings.IndexAny(rest, "\"'")]
|
||||
|
||||
got := httptest.NewRecorder()
|
||||
h.ServeHTTP(got, httptest.NewRequest("GET", name, nil))
|
||||
if got.Code != http.StatusOK {
|
||||
t.Fatalf("%s: got %d", name, got.Code)
|
||||
}
|
||||
if cc := got.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
|
||||
t.Errorf("%s: Cache-Control %q, want immutable", name, cc)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user