Files
Behavision/server/internal/blob/blob_live_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

142 lines
4.3 KiB
Go

package blob
import (
"context"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
// Live tests. Skipped unless the bucket credentials are in the environment, so
// the suite stays runnable with no network - the same rule the Python tests
// follow about needing no camera and no models.
func liveStore(t *testing.T) *Store {
t.Helper()
cfg := Config{
Region: os.Getenv("DO_SPACES_REGION"),
Endpoint: os.Getenv("DO_SPACES_ENDPOINT"),
Bucket: os.Getenv("DO_SPACES_BUCKET"),
AccessKey: os.Getenv("DO_SPACES_ACCESS_KEY"),
SecretKey: os.Getenv("DO_SPACES_SECRET_KEY"),
Prefix: "behavision/_test",
}
if cfg.AccessKey == "" {
t.Skip("object storage credentials not in the environment")
}
s, err := New(cfg)
if err != nil {
t.Fatal(err)
}
return s
}
func anonGet(t *testing.T, url string) int {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body) //nolint:errcheck
return resp.StatusCode
}
// The one that matters. The bucket is world-readable at the bucket level, so
// an object written with the wrong ACL is a customer's face downloadable by
// anyone who lists the bucket.
func TestUploadedImagesAreNotPubliclyReadable(t *testing.T) {
s := liveStore(t)
ctx := context.Background()
key := s.Key("acme", "store1", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", time.Now())
if err := s.Put(ctx, key, []byte("not really a jpeg"), "image/jpeg"); err != nil {
t.Fatal(err)
}
defer s.Delete(ctx, key) //nolint:errcheck
if code := anonGet(t, "https://"+s.host+"/"+key); code != http.StatusForbidden {
t.Fatalf("an uploaded image answered %d to an anonymous GET, want 403 - "+
"face images are being published to the internet", code)
}
url, err := s.PresignGet(key, 5*time.Minute)
if err != nil {
t.Fatal(err)
}
if code := anonGet(t, url); code != http.StatusOK {
t.Fatalf("presigned read returned %d, want 200", code)
}
}
// Erasure has to actually erase. A deletion that leaves the image in the
// bucket has deleted nothing, and this is the operation a DPDP/GDPR request
// turns into.
func TestDeleteRemovesTheObjectAndIsIdempotent(t *testing.T) {
s := liveStore(t)
ctx := context.Background()
key := s.Key("acme", "store1", "11111111-2222-3333-4444-555555555555", time.Now())
if err := s.Put(ctx, key, []byte("x"), "image/jpeg"); err != nil {
t.Fatal(err)
}
if err := s.Delete(ctx, key); err != nil {
t.Fatal(err)
}
url, _ := s.PresignGet(key, 5*time.Minute)
if code := anonGet(t, url); code != http.StatusNotFound {
t.Fatalf("presigned read after delete returned %d, want 404", code)
}
// A retry after a half-finished erasure must be able to finish rather than
// failing forever on the object that already went.
if err := s.Delete(ctx, key); err != nil {
t.Fatalf("deleting an absent object failed: %v", err)
}
}
// The agent PUTs through a presigned URL and holds no bucket credentials.
func TestPresignedPutAcceptsOnlyThePrivateACL(t *testing.T) {
s := liveStore(t)
ctx := context.Background()
key := s.Key("acme", "store1", "99999999-8888-7777-6666-555555555555", time.Now())
url, hdr, err := s.PresignPut(key, 5*time.Minute)
if err != nil {
t.Fatal(err)
}
defer s.Delete(ctx, key) //nolint:errcheck
upload := func(acl string) int {
req, _ := http.NewRequest(http.MethodPut, url, strings.NewReader("jpeg bytes"))
req.Header.Set("Content-Type", hdr.Get("Content-Type"))
req.Header.Set("x-amz-acl", acl)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body) //nolint:errcheck
return resp.StatusCode
}
if code := upload("private"); code != http.StatusOK {
t.Fatalf("presigned upload returned %d, want 200", code)
}
// The ACL is signed into the URL, so an agent cannot decide to publish the
// image instead. Without this the shop PC picks the privacy policy.
if code := upload("public-read"); code == http.StatusOK {
t.Fatal("an agent was able to override the ACL and make the image public")
}
if code := anonGet(t, "https://"+s.host+"/"+key); code == http.StatusOK {
t.Fatal("the uploaded object is publicly readable")
}
}
func TestCheckRefusesABucketThatServesObjectsPublicly(t *testing.T) {
s := liveStore(t)
if err := s.Check(context.Background()); err != nil {
t.Fatalf("self-test failed: %v", err)
}
}