Each live store test makes its own client - deliberately, so they can run in any order and so the isolation assertions have a real neighbour to be isolated from - and none of them removed it afterwards. The dev database had reached 242 abandoned tenants against the one real company. That is not untidy, it is a broken screen. The platform admin's Companies view lists every client, so the real company sat under pages of `walk1788761685056287000`, which is the first thing anyone opening tenant administration would see. dropTenant registers the cleanup against the CLIENT rather than each table: every foreign key onto clients is ON DELETE CASCADE, so one delete takes the sites, visitors, visits, face images, embeddings, cameras and agents with it. A per-table list would rot the first time a migration adds a table, and it would rot silently - the same shape as the leak it replaces. A failed cleanup calls t.Errorf rather than being ignored. A tenant left behind is precisely what this exists to prevent, and swallowing the error would let the leak come back with nothing to show for it. Verified against the live database: three consecutive runs of the store suite leave clients, sites and visits unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qiy5iKfz4L8S4vRaYPBdaU
263 lines
8.4 KiB
Go
263 lines
8.4 KiB
Go
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)
|
|
}
|
|
}
|