Files
Behavision/server/internal/api/faces_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

231 lines
8.2 KiB
Go

package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Face images held by this server, for a deployment with no object storage.
//
// The property under test throughout is that the two storage routes differ in
// exactly one hop: the key is minted differently and everything downstream -
// ingest, the feed, the customer record, erasure - is one implementation.
func putFace(t *testing.T, srv *Server, token string, body []byte) *httptest.ResponseRecorder {
t.Helper()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/agent/faces", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
srv.Routes().ServeHTTP(rr, req)
return rr
}
func TestAnAgentStoresAFaceAndAPersonReadsItBack(t *testing.T) {
srv, fs := newServer(t)
srv.Blob = nil // no object storage anywhere: the case this exists for
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
seedUser(fs)
img := jpegBytes(512)
rr := putFace(t, srv, "agent-token", img)
if rr.Code != http.StatusCreated {
t.Fatalf("upload: %d %s", rr.Code, rr.Body)
}
var out struct {
Key string `json:"key"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
// A prefixed key, so `visits.image_key` can name an object in either store
// and the read path can tell which without a second lookup.
if !strings.HasPrefix(out.Key, "db:") {
t.Fatalf("want a db: key, got %q", out.Key)
}
// The tenant and the site come from the AGENT's credential, never the
// request, so a shop PC cannot file an image under another company.
if fs.lastFaceClient != "client-acme" || fs.lastFaceSite != "site-1" {
t.Fatalf("stored against %s/%s", fs.lastFaceClient, fs.lastFaceSite)
}
sess := login(t, srv, "manager@acme.com", "correct horse battery")
rec := do(t, srv, "GET", faceURL(out.Key), sess.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("read back: %d %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
t.Errorf("content type %q", got)
}
if !bytes.Equal(rec.Body.Bytes(), img) {
t.Error("the bytes that came back are not the ones that went in")
}
}
// This endpoint stores what it is handed and serves it back to a browser, so
// the one thing it must not become is a way to park arbitrary content under a
// URL this server will serve. Checked against the bytes, never the header.
func TestOnlyAJPEGIsStoredAsAFace(t *testing.T) {
srv, fs := newServer(t)
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
for _, body := range []string{
"<html><script>alert(1)</script></html>",
"GIF89a",
"%PDF-1.4",
"",
} {
rr := putFace(t, srv, "agent-token", []byte(body))
if rr.Code == http.StatusCreated {
t.Errorf("accepted %q as a face image", body)
}
}
}
func TestAFaceIsNotReadableWithoutASession(t *testing.T) {
srv, fs := newServer(t)
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
rr := putFace(t, srv, "agent-token", jpegBytes(64))
var out struct {
Key string `json:"key"`
}
_ = json.Unmarshal(rr.Body.Bytes(), &out)
// The reason it is session-authenticated rather than a signed link: there
// is no third party to delegate to, and an unauthenticated URL would be a
// way to reach a customer's photograph with no session at all.
if rec := do(t, srv, "GET", faceURL(out.Key), "", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("a face was served with no session, got %d", rec.Code)
}
}
func TestAnotherTenantCannotReadYourStoredFace(t *testing.T) {
srv, fs := newServer(t)
fs.addAgent("acme-agent", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
seedUser(fs)
fs.addUser("other@beta.com", "correct horse battery", UserRecord{
ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd",
FullName: "Bo", Role: "manager", Active: true,
})
rr := putFace(t, srv, "acme-agent", jpegBytes(64))
var out struct {
Key string `json:"key"`
}
_ = json.Unmarshal(rr.Body.Bytes(), &out)
// An image key travels in API responses, so a caller who kept one - or
// guessed one - must get nothing rather than somebody else's customer.
beta := login(t, srv, "other@beta.com", "correct horse battery")
if rec := do(t, srv, "GET", faceURL(out.Key), beta.Token, nil); rec.Code != http.StatusNotFound {
t.Fatalf("another tenant read a stored face, got %d", rec.Code)
}
acme := login(t, srv, "manager@acme.com", "correct horse battery")
if rec := do(t, srv, "GET", faceURL(out.Key), acme.Token, nil); rec.Code != http.StatusOK {
t.Fatalf("the owning tenant could not read its own face, got %d", rec.Code)
}
}
// The customer record has to work on a deployment with no bucket too - it is
// the screen staff use to recognise the person in front of them.
func TestTheCustomerPhotoWorksWithNoObjectStorage(t *testing.T) {
srv, fs := newServer(t)
srv.Blob = nil
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
seedUser(fs)
rr := putFace(t, srv, "agent-token", jpegBytes(64))
var up struct {
Key string `json:"key"`
}
_ = json.Unmarshal(rr.Body.Bytes(), &up)
const visitor = "44444444-4444-4444-8444-444444444444"
fs.imageKeys[visitor] = up.Key
sess := login(t, srv, "manager@acme.com", "correct horse battery")
rec := do(t, srv, "GET", "/api/visitors/"+visitor+"/image", sess.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("customer photo: %d %s", rec.Code, rec.Body.String())
}
var img Image
if err := json.Unmarshal(rec.Body.Bytes(), &img); err != nil {
t.Fatal(err)
}
if !img.Available || !img.Auth {
t.Fatalf("want an available image that needs the session, got %+v", img)
}
// The storage key names a tenant's prefix and must never be what a client
// receives, on either route.
if strings.Contains(rec.Body.String(), "db:") {
t.Errorf("the storage key leaked: %s", rec.Body.String())
}
// Reading a face is worth an audit row wherever the LINK is handed out.
// Recorded here rather than at the byte fetch, because the bucket route's
// bytes never touch this server and the two must be counted the same way.
if !audited(fs, "image.view") {
t.Error("reading a customer photo left no audit row")
}
}
func audited(fs *fakeStore, action string) bool {
fs.mu.Lock()
defer fs.mu.Unlock()
for _, a := range fs.audits {
if a.Action == action {
return true
}
}
return false
}
// Erasure has to destroy an image this server holds, not only one in a bucket.
// A face image that survives an erasure request is the one outcome that
// endpoint must never produce.
func TestErasureDestroysAStoredFace(t *testing.T) {
srv, fs := newServer(t)
srv.Blob = nil
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
seedUser(fs)
rr := putFace(t, srv, "agent-token", jpegBytes(64))
var up struct {
Key string `json:"key"`
}
_ = json.Unmarshal(rr.Body.Bytes(), &up)
const visitor = "55555555-5555-4555-8555-555555555555"
fs.imageKeys[visitor] = up.Key
sess := login(t, srv, "manager@acme.com", "correct horse battery")
if rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil); rec.Code != http.StatusNoContent {
t.Fatalf("erase: %d %s", rec.Code, rec.Body.String())
}
if rec := do(t, srv, "GET", faceURL(up.Key), sess.Token, nil); rec.Code != http.StatusNotFound {
t.Fatalf("the face survived erasure, got %d", rec.Code)
}
}
// If the image cannot be destroyed, NOTHING is erased and the caller is told.
// Reporting a legal request as honoured when it was not is the failure this
// path exists to prevent.
func TestAFailedFaceDeleteAbortsTheWholeErasure(t *testing.T) {
srv, fs := newServer(t)
srv.Blob = nil
seedUser(fs)
const visitor = "66666666-6666-4666-8666-666666666666"
fs.imageKeys[visitor] = "db:66666666-6666-4666-8666-666666666666"
fs.faceDeleteErr = errors.New("storage is down")
sess := login(t, srv, "manager@acme.com", "correct horse battery")
rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil)
if rec.Code != http.StatusBadGateway {
t.Fatalf("want 502 and nothing erased, got %d: %s", rec.Code, rec.Body.String())
}
if len(fs.forgotten) != 0 {
t.Fatalf("the record was erased even though the photo could not be: %v", fs.forgotten)
}
}