package store import ( "context" "fmt" "testing" "time" "github.com/loyaly/behavision-server/internal/contract" "github.com/loyaly/behavision-server/internal/ingest" ) // Face images held by this server, against a real database. // // The prune is the whole reason this is allowed to live in Postgres at all - // migration 011 argues it explicitly against 009's "face images grow with every // visitor who ever walks in" - so it is the one behaviour that must be proved // against the real thing rather than a fake that would simply agree with me. func seedAgentSite(t *testing.T, st *Store, name string) ingest.Site { t.Helper() ctx := context.Background() var site ingest.Site if err := st.pool.QueryRow(ctx, ` INSERT INTO clients (name, slug) VALUES ($1, $1) RETURNING id::text`, name).Scan(&site.ClientID); err != nil { t.Fatalf("seed client: %v", err) } dropTenant(t, st, site.ClientID) if err := st.pool.QueryRow(ctx, ` INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3) RETURNING id::text`, site.ClientID, name, name).Scan(&site.SiteID); err != nil { t.Fatalf("seed site: %v", err) } if err := st.pool.QueryRow(ctx, ` INSERT INTO agents (client_id, site_id, mqtt_username) VALUES ($1::uuid, $2::uuid, $3) RETURNING id::text`, site.ClientID, site.SiteID, name).Scan(&site.AgentID); err != nil { t.Fatalf("seed agent: %v", err) } site.Slug = name return site } func embedding(seed float32) []float32 { v := make([]float32, contract.EmbeddingDim) for i := range v { v[i] = seed } return v } // The bound: one person seen many times leaves ONE stored image, not one per // visit. Without this the table grows with footfall, which is precisely the // property that keeps face images out of the database everywhere else. func TestLiveOnlyOneFaceSurvivesPerVisitor(t *testing.T) { st := liveStore(t) ctx := context.Background() site := seedAgentSite(t, st, "faces-"+stamp()) var keys []string for i := 0; i < 5; i++ { key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, []byte(fmt.Sprintf("jpeg-%d", i))) if err != nil { t.Fatalf("store face %d: %v", i, err) } keys = append(keys, key) // The SAME person every time: one embedding, so the matcher resolves // them to one visitor. ok, err := st.RecordVisit(ctx, site, &contract.Visit{ EventID: fmt.Sprintf("%s-%d", site.Slug, i), OccurredAt: time.Now().UTC().Add(time.Duration(i) * time.Second), CameraID: "door", IsNew: i == 0, Quality: 0.8, Similarity: 0.9, Embedding: embedding(0.05), ImageKey: key, }) if err != nil || !ok { t.Fatalf("visit %d: ok=%v err=%v", i, ok, err) } } var stored int if err := st.pool.QueryRow(ctx, `SELECT count(*) FROM visit_faces WHERE client_id = $1::uuid`, site.ClientID).Scan(&stored); err != nil { t.Fatal(err) } if stored != 1 { t.Fatalf("five visits by one person left %d stored faces - the table "+ "grows with footfall, which is exactly what migration 011 promises "+ "it does not", stored) } // And it is the NEWEST that survived: every surface shows a customer's // latest view, so keeping an older one would quietly show a stale face. var surviving string if err := st.pool.QueryRow(ctx, `SELECT 'db:' || id::text FROM visit_faces WHERE client_id = $1::uuid`, site.ClientID).Scan(&surviving); err != nil { t.Fatal(err) } if surviving != keys[len(keys)-1] { t.Errorf("kept %s, want the newest %s", surviving, keys[len(keys)-1]) } // The superseded keys are blanked, not left dangling. A visit advertising // an image that is not there renders as a broken picture on the one screen // meant to show it. var dangling int if err := st.pool.QueryRow(ctx, ` SELECT count(*) FROM visits v WHERE v.client_id = $1::uuid AND v.image_key LIKE 'db:%' AND NOT EXISTS (SELECT 1 FROM visit_faces f WHERE 'db:' || f.id::text = v.image_key)`, site.ClientID).Scan(&dangling); err != nil { t.Fatal(err) } if dangling != 0 { t.Errorf("%d visits point at a face that is gone", dangling) } // image_deleted_at is the record of an ERASURE and is what an auditor // reads. Ordinary housekeeping must not write into it. var marked int if err := st.pool.QueryRow(ctx, ` SELECT count(*) FROM visits WHERE client_id = $1::uuid AND image_deleted_at IS NOT NULL`, site.ClientID).Scan(&marked); err != nil { t.Fatal(err) } if marked != 0 { t.Errorf("%d visits were marked as erased by a routine prune", marked) } } // Two different people keep one face each. The prune must be scoped to the // person, not to the site - otherwise every new arrival would delete the // previous customer's photo. func TestLiveThePruneIsPerPersonNotPerSite(t *testing.T) { st := liveStore(t) ctx := context.Background() site := seedAgentSite(t, st, "faces2-"+stamp()) for i, seed := range []float32{0.05, -0.05} { key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, []byte(fmt.Sprintf("person-%d", i))) if err != nil { t.Fatal(err) } if ok, err := st.RecordVisit(ctx, site, &contract.Visit{ EventID: fmt.Sprintf("%s-p%d", site.Slug, i), OccurredAt: time.Now().UTC(), CameraID: "door", IsNew: true, Quality: 0.8, Embedding: embedding(seed), ImageKey: key, }); err != nil || !ok { t.Fatalf("visit: ok=%v err=%v", ok, err) } } var stored int if err := st.pool.QueryRow(ctx, `SELECT count(*) FROM visit_faces WHERE client_id = $1::uuid`, site.ClientID).Scan(&stored); err != nil { t.Fatal(err) } if stored != 2 { t.Fatalf("two people should keep one face each, got %d", stored) } } // An agent uploads a face BEFORE the server has decided who it is, so a row is // briefly unreferenced by design - and permanently so if the visit that would // have claimed it never arrives. That is a stored photograph of a real person // that nothing points at, which erasure could never reach because it is found // through the visitor and this row has none. func TestLiveAnUnclaimedFaceIsSweptAway(t *testing.T) { st := liveStore(t) ctx := context.Background() site := seedAgentSite(t, st, "faces3-"+stamp()) key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, []byte("orphan")) if err != nil { t.Fatal(err) } // Age it past the sweep window rather than sleeping. if _, err := st.pool.Exec(ctx, ` UPDATE visit_faces SET captured_at = now() - interval '3 days' WHERE 'db:' || id::text = $1`, key); err != nil { t.Fatal(err) } n, err := st.SweepOrphanFaces(ctx, "1 day") if err != nil { t.Fatalf("sweep: %v", err) } if n < 1 { t.Fatal("the orphan was not swept") } if _, err := st.VisitFace(ctx, site.ClientID, key); err == nil { t.Fatal("the orphan is still readable") } } // A face a visit DOES point at must survive the sweep, however old it is. A // regular customer's photo is exactly the row that gets old. func TestLiveTheSweepKeepsAClaimedFace(t *testing.T) { st := liveStore(t) ctx := context.Background() site := seedAgentSite(t, st, "faces4-"+stamp()) key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, []byte("kept")) if err != nil { t.Fatal(err) } if ok, err := st.RecordVisit(ctx, site, &contract.Visit{ EventID: site.Slug + "-keep", OccurredAt: time.Now().UTC(), CameraID: "door", IsNew: true, Quality: 0.8, Embedding: embedding(0.07), ImageKey: key, }); err != nil || !ok { t.Fatalf("visit: ok=%v err=%v", ok, err) } if _, err := st.pool.Exec(ctx, ` UPDATE visit_faces SET captured_at = now() - interval '400 days' WHERE 'db:' || id::text = $1`, key); err != nil { t.Fatal(err) } if _, err := st.SweepOrphanFaces(ctx, "1 day"); err != nil { t.Fatal(err) } if _, err := st.VisitFace(ctx, site.ClientID, key); err != nil { t.Fatalf("a claimed face was swept away: %v", err) } } // An image key travels in API responses. A caller who kept one, or guessed one, // must get nothing rather than another company's customer. func TestLiveAFaceIsNotReadableByAnotherTenant(t *testing.T) { st := liveStore(t) ctx := context.Background() a := seedAgentSite(t, st, "facesa-"+stamp()) b := seedAgentSite(t, st, "facesb-"+stamp()) key, err := st.PutVisitFace(ctx, a.ClientID, a.SiteID, []byte("private")) if err != nil { t.Fatal(err) } if _, err := st.VisitFace(ctx, b.ClientID, key); err == nil { t.Fatal("another tenant read a stored face") } if _, err := st.VisitFace(ctx, a.ClientID, key); err != nil { t.Fatalf("the owning tenant could not read its own face: %v", err) } }