Files
Behavision/server/internal/store/api_faces_live_test.go
Suriyakumarvijayanayagam 3f9fb33b24 Accounts people can create, and photos on a server with no bucket
A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.

Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.

Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.

Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.

Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.

Found by running it, not by tests:

  * UPDATE ... RETURNING gives the value AFTER the update, so the prune
    read back empty keys, deleted nothing, and the table grew with
    footfall exactly as if it were not there. The fake agreed with either
    version; only the live Postgres test caught it.
  * Trusting only the auth flag broke every shop card, because Sites.jsx
    rebuilt a partial snapshot object and dropped it. A relative URL is
    now sufficient on its own.
  * ago() renders a future time as "just now", so a code valid for a week
    read "expires just now".

Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-05 11:45:42 +05:30

262 lines
8.3 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)
}
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)
}
}