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
142 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|