Files
Behavision/server/internal/api/fake_test.go
Suriyakumarvijayanayagam e0ceb14589 Camera pictures without an object-storage bucket
Head office shows a camera's latest frame rather than live video, for a
reason that has not changed: the engine serves MJPEG on 127.0.0.1 on a PC
behind a shop's router with no inbound route, and relaying it needs
WebRTC/TURN. Pointing a browser straight at the shop PC is not the escape
either - the engine's API is Basic-authenticated with a credential it
generates locally and never sends anywhere, and shipping that to the
cloud so a web page could use it would put the key to the biometric API
and the live face feed in the server's database.

But that picture only worked if you had an S3 bucket. Without one,
attachSnapshots reported "This system is not storing images" for every
camera forever - on the two screens whose whole job is to show the
camera. Making them picture-led turned a missing feature into a wall of
empty tiles, on every local install and any self-hosted customer who does
not want a bucket.

migrations/009 adds camera_snapshots and the agent falls back to
PUT /api/agent/cameras/{camera}/snapshot when the presigned route answers
images_disabled - chosen by sentinel, never by matching the message, since
it picks between two routes. One row per camera is what makes this safe in
the database when face images are not: the key IS the camera, so storage
is (cameras x ~100 KB) and does not grow with footfall.

The read is session-authenticated rather than a signed link, which an
<img> cannot use - hence Shot.jsx and useAuthedImage, keyed on the URL
string rather than the snapshot object so a poll does not re-fetch 90 KB
per camera every few seconds, and revoking the object URL on cleanup.

Verified against the real office camera with no bucket configured: 90,587
bytes stored in Postgres, served as image/jpeg to a signed-in user, 401
without a session, rendered on both the Cameras and Shops cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 12:53:18 +05:30

625 lines
16 KiB
Go

package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/auth"
)
// fakeStore is an in-memory Store. Handlers are where the security decisions
// live - which tenant, which message on failure, what is echoed back - and
// those are exactly what a real database would make slow and awkward to test.
type fakeStore struct {
// Camera pictures held by the server, for a deployment with no bucket.
// Keyed as written by PutCameraSnapshot (by camera_id) and as read by
// CameraSnapshot ("client/camera"), so a test has to say which it means.
snapshots map[string][]byte
snapshotRejects bool
lastSnapshotClient string
lastSnapshotSite string
mu sync.Mutex
users map[string]UserRecord // by lower-cased email
sessions map[string]*fakeSession
byAccess map[string]string // access hash hex -> session id
byRefresh map[string]string
visitors []Customer
history []VisitRow
footfall []FootfallPoint
totals Totals
sales SalesReport
sites []SiteHealth
enrolment map[string]Enrolment
// Recorded calls, so a test can assert what the handler asked for rather
// than only what it returned.
lastReport ReportQuery
lastProfile Profile
lastProfileClient string
lastPurchase PurchaseInput
audits []AuditEntry
// arrivals is the whole table; arrivalQ records what the handler asked for
// so a test can assert on the keyset window rather than only its output.
arrivals []Arrival
arrivalQ ArrivalQuery
arrivalsErr error
arrivalCalls int
pendingChecks []AgentCheckJob
checkResults []AgentCheckResult
releasedStale int
lastCodeActor string
lastCodeTTL time.Duration
lastCheckKind string
lastCheckSeconds int
cameras []Camera
agentCameras []AgentCamera
lastCameraReport AgentCameraReport
lastReportClient string
lastReportSite string
saveCameraErr error
lastSaved CameraInput
clients []ClientRow
lastNewClient NewClientInput
newClientErr error
loginTouched []string
profileErr error
purchaseErr error
forgetErr error
nextID int
agentTokens map[string]AgentPrincipal
imageKeys map[string]string
forgotten []string
}
type fakeSession struct {
id string
p auth.Principal
accessExp, refreshExp time.Time
revoked bool
}
func newFakeStore() *fakeStore {
return &fakeStore{
users: map[string]UserRecord{},
sessions: map[string]*fakeSession{},
byAccess: map[string]string{},
byRefresh: map[string]string{},
enrolment: map[string]Enrolment{},
agentTokens: map[string]AgentPrincipal{},
imageKeys: map[string]string{},
}
}
func (f *fakeStore) addUser(email, password string, rec UserRecord) {
hash, err := auth.HashPassword(password)
if err != nil {
panic(err)
}
rec.Email = email
rec.PasswordHash = hash
rec.Found = true
if rec.ID == "" {
rec.ID = "user-" + email
}
if rec.Role == "" {
rec.Role = "manager"
}
f.users[auth.NormalizeEmail(email)] = rec
}
func (f *fakeStore) UserByEmail(_ context.Context, email string) (UserRecord, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.users[email]
if !ok {
return UserRecord{Found: false}, nil
}
return u, nil
}
func (f *fakeStore) TouchUserLogin(_ context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.loginTouched = append(f.loginTouched, id)
return nil
}
func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
f.mu.Lock()
defer f.mu.Unlock()
f.nextID++
id := "sess-" + itoa(f.nextID)
var rec UserRecord
for _, u := range f.users {
if u.ID == n.UserID {
rec = u
}
}
s := &fakeSession{
id: id,
p: auth.Principal{
UserID: n.UserID, SessionID: id, ClientID: n.ClientID,
ClientName: rec.ClientName, Email: rec.Email,
FullName: rec.FullName, Role: rec.Role,
},
accessExp: n.AccessExpiry, refreshExp: n.RefreshExp,
}
f.sessions[id] = s
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
return nil
}
func (f *fakeStore) lookup(index map[string]string, hash []byte, refresh bool) (
auth.Principal, time.Time, error) {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := index[hex.EncodeToString(hash)]
if !ok {
return auth.Principal{}, time.Time{}, auth.ErrNoSession
}
s := f.sessions[id]
if s == nil || s.revoked {
return auth.Principal{}, time.Time{}, auth.ErrNoSession
}
if refresh {
return s.p, s.refreshExp, nil
}
return s.p, s.accessExp, nil
}
func (f *fakeStore) SessionByAccess(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
return f.lookup(f.byAccess, h, false)
}
func (f *fakeStore) SessionByRefresh(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
return f.lookup(f.byRefresh, h, true)
}
func (f *fakeStore) RotateSession(_ context.Context, id string, n NewSession) error {
f.mu.Lock()
defer f.mu.Unlock()
s := f.sessions[id]
if s == nil || s.revoked {
return auth.ErrNoSession
}
// Mirrors the real store: the old hashes stop resolving the moment the new
// ones are written.
for k, v := range f.byAccess {
if v == id {
delete(f.byAccess, k)
}
}
for k, v := range f.byRefresh {
if v == id {
delete(f.byRefresh, k)
}
}
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
s.accessExp, s.refreshExp = n.AccessExpiry, n.RefreshExp
return nil
}
func (f *fakeStore) RevokeSession(_ context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if s := f.sessions[id]; s != nil {
s.revoked = true
}
return nil
}
func (f *fakeStore) Footfall(_ context.Context, q ReportQuery) ([]FootfallPoint, Totals, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastReport = q
return f.footfall, f.totals, nil
}
func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastReport = q
return f.sales, nil
}
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
return f.sites, nil
}
func (f *fakeStore) SearchVisitors(_ context.Context, clientID, q string, limit int) (
[]Customer, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastReport = ReportQuery{ClientID: clientID}
if limit < len(f.visitors) {
return f.visitors[:limit], nil
}
return f.visitors, nil
}
func (f *fakeStore) VisitorHistory(_ context.Context, _, _ string, _ int) ([]VisitRow, error) {
return f.history, nil
}
// Arrivals fakes the keyset window in memory: rows are held oldest-first, a
// cursor slices past it, and no cursor returns the newest Limit - the same
// contract the SQL implements, so a handler test that passes here is testing
// the handler and not a stub that is easier than the real thing.
func (f *fakeStore) Arrivals(_ context.Context, q ArrivalQuery) ([]Arrival, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.arrivalQ = q
f.arrivalCalls++
if f.arrivalsErr != nil {
return nil, f.arrivalsErr
}
rows := make([]Arrival, 0, len(f.arrivals))
for _, a := range f.arrivals {
if q.SiteID != "" && a.SiteID != q.SiteID {
continue
}
if q.AfterSeq != nil && a.Seq <= *q.AfterSeq {
continue
}
rows = append(rows, a)
}
if q.AfterSeq == nil && len(rows) > q.Limit {
// No cursor: the newest window, matching the real query.
rows = rows[len(rows)-q.Limit:]
} else if len(rows) > q.Limit {
rows = rows[:q.Limit]
}
return rows, nil
}
func (f *fakeStore) SaveProfile(_ context.Context, clientID string, p Profile, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.lastProfile, f.lastProfileClient = p, clientID
return f.profileErr
}
func (f *fakeStore) RecordPurchase(_ context.Context, _ string, p PurchaseInput, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.lastPurchase = p
return f.purchaseErr
}
func (f *fakeStore) RedeemEnrolment(_ context.Context, hash []byte) (Enrolment, error) {
f.mu.Lock()
defer f.mu.Unlock()
en, ok := f.enrolment[hex.EncodeToString(hash)]
if !ok {
return Enrolment{}, errors.New("unknown token")
}
// Single use, like the real UPDATE.
delete(f.enrolment, hex.EncodeToString(hash))
return en, nil
}
func (f *fakeStore) Cameras(_ context.Context, _, siteID string) ([]Camera, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Camera
for _, c := range f.cameras {
if siteID == "" || c.SiteID == siteID {
out = append(out, c)
}
}
return out, nil
}
func (f *fakeStore) CameraByID(_ context.Context, _, id string) (Camera, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.cameras {
if c.ID == id {
return c, nil
}
}
return Camera{}, errors.New("no rows in result set")
}
func (f *fakeStore) SaveCamera(_ context.Context, _, siteID, cameraID string,
in CameraInput) (Camera, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastSaved = in
if f.saveCameraErr != nil {
return Camera{}, f.saveCameraErr
}
// A real-shaped uuid: the handlers check the shape before touching SQL, so
// a placeholder id would exercise the 404 path instead of the one under
// test.
cam := Camera{ID: fakeCameraUUID(cameraID), SiteID: siteID, CameraID: cameraID,
Port: 554, Path: "/", MaxWidth: 1280, Enabled: true, Revision: 1}
if in.Label != nil {
cam.Label = *in.Label
}
if in.Host != nil {
cam.Host = *in.Host
}
if in.Username != nil {
cam.Username = *in.Username
}
cam.HasPassword = in.Password != nil && *in.Password != ""
f.cameras = append(f.cameras, cam)
return cam, nil
}
// fakeCameraUUID derives a stable uuid-shaped id from a camera name so tests
// can address a camera they just created without reading the response.
func fakeCameraUUID(cameraID string) string {
sum := sha256.Sum256([]byte(cameraID))
h := hex.EncodeToString(sum[:16])
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
}
func (f *fakeStore) DeleteCamera(_ context.Context, _, id string) (Camera, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i, c := range f.cameras {
if c.ID == id {
f.cameras = append(f.cameras[:i], f.cameras[i+1:]...)
return c, nil
}
}
return Camera{}, errors.New("no rows in result set")
}
func (f *fakeStore) AgentCameras(_ context.Context, _ string) ([]AgentCamera, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.agentCameras, nil
}
func (f *fakeStore) ApplyAgentReport(_ context.Context, clientID, siteID string,
rep AgentCameraReport) error {
f.mu.Lock()
defer f.mu.Unlock()
f.lastCameraReport = rep
f.lastReportClient, f.lastReportSite = clientID, siteID
return nil
}
func (f *fakeStore) IssueEnrolmentCode(_ context.Context, clientID, siteID,
actorID, label string, ttl time.Duration) (EnrolmentCode, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, si := range f.sites {
if si.SiteID == siteID {
f.lastCodeActor, f.lastCodeTTL = actorID, ttl
return EnrolmentCode{
Code: "ABCDEF-123456-GHIJKL-789012", SiteID: siteID,
SiteName: si.Name, Label: label,
ExpiresAt: time.Now().Add(ttl).UTC(),
}, nil
}
}
return EnrolmentCode{}, pgx.ErrNoRows
}
func (f *fakeStore) RequestCheck(_ context.Context, _, id, kind string, seconds int) error {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.cameras {
if f.cameras[i].ID == id {
f.cameras[i].Check = CameraCheck{Kind: kind, Seconds: seconds, State: "requested"}
f.lastCheckKind, f.lastCheckSeconds = kind, seconds
return nil
}
}
return errors.New("no rows in result set")
}
func (f *fakeStore) ClaimChecks(_ context.Context, _ string) ([]AgentCheckJob, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := f.pendingChecks
f.pendingChecks = nil // claimed once, like the real UPDATE ... RETURNING
return out, nil
}
func (f *fakeStore) RecordCheckResult(_ context.Context, _ string, res AgentCheckResult) error {
f.mu.Lock()
defer f.mu.Unlock()
f.checkResults = append(f.checkResults, res)
return nil
}
func (f *fakeStore) ReleaseStaleChecks(_ context.Context, _ time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
f.releasedStale++
return nil
}
func (f *fakeStore) ListClients(_ context.Context) ([]ClientRow, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.clients, nil
}
func (f *fakeStore) CreateClientWithOwner(_ context.Context, in NewClientInput) (
NewClientResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastNewClient = in
if f.newClientErr != nil {
return NewClientResult{}, f.newClientErr
}
pw := in.Password
if pw == "" {
pw = "generated-password"
}
return NewClientResult{ClientID: "new-client-id", Slug: in.Slug,
OwnerEmail: in.OwnerEmail, Password: pw}, nil
}
func (f *fakeStore) Audit(_ context.Context, e AuditEntry) {
f.mu.Lock()
defer f.mu.Unlock()
f.audits = append(f.audits, e)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
// -- images and agents ------------------------------------------------------
func (f *fakeStore) SetAgentAPIToken(_ context.Context, agentID string, hash []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.agentTokens == nil {
f.agentTokens = map[string]AgentPrincipal{}
}
f.agentTokens[hex.EncodeToString(hash)] = AgentPrincipal{
AgentID: agentID, ClientID: "client-acme", SiteID: "site-1",
Slug: "acme.store1", Client: "acme", Site: "store1",
}
return nil
}
// addAgent registers a plaintext agent token, hashed the way the middleware
// will look it up.
func (f *fakeStore) addAgent(token string, ap AgentPrincipal) {
f.mu.Lock()
defer f.mu.Unlock()
f.agentTokens[hex.EncodeToString(auth.HashToken(token))] = ap
}
func (f *fakeStore) AgentByToken(_ context.Context, hash []byte) (AgentPrincipal, error) {
f.mu.Lock()
defer f.mu.Unlock()
ap, ok := f.agentTokens[hex.EncodeToString(hash)]
if !ok {
return AgentPrincipal{}, errors.New("no such agent")
}
return ap, nil
}
func (f *fakeStore) VisitorImageKey(_ context.Context, _, visitorID string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.imageKeys[visitorID], nil
}
func (f *fakeStore) VisitorImageKeys(_ context.Context, _, visitorID string) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if k := f.imageKeys[visitorID]; k != "" {
return []string{k}, nil
}
return nil, nil
}
func (f *fakeStore) ForgetVisitor(_ context.Context, _, visitorID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.forgetErr != nil {
return f.forgetErr
}
f.forgotten = append(f.forgotten, visitorID)
delete(f.imageKeys, visitorID)
return nil
}
// fakeBlob records what the handlers asked storage to do. Deleting is the part
// worth recording: an erasure that reports success without removing the object
// is the failure this whole path exists to prevent.
type fakeBlob struct {
mu sync.Mutex
deleted []string
presigns []string
failNext error
}
func (b *fakeBlob) Key(client, site, objectID string, at time.Time) string {
return fmt.Sprintf("behavision/%s/%s/%04d/%02d/%02d/%s.jpg",
client, site, at.Year(), int(at.Month()), at.Day(), objectID)
}
func (b *fakeBlob) PresignPut(key string, _ time.Duration) (string, http.Header, error) {
h := http.Header{}
h.Set("x-amz-acl", "private")
h.Set("Content-Type", "image/jpeg")
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", h, nil
}
func (b *fakeBlob) PresignGet(key string, _ time.Duration) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.presigns = append(b.presigns, key)
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", nil
}
func (b *fakeBlob) Delete(_ context.Context, key string) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.failNext != nil {
err := b.failNext
b.failNext = nil
return err
}
b.deleted = append(b.deleted, key)
return nil
}
// ------------------------------------------------------- camera snapshots --
func (f *fakeStore) PutCameraSnapshot(_ context.Context,
clientID, siteID, cameraID string, jpeg []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.snapshots == nil {
f.snapshots = map[string][]byte{}
}
if f.snapshotRejects {
return ErrNoSnapshot
}
f.lastSnapshotClient, f.lastSnapshotSite = clientID, siteID
f.snapshots[cameraID] = append([]byte(nil), jpeg...)
return nil
}
func (f *fakeStore) CameraSnapshot(_ context.Context, clientID, cameraID string) (
[]byte, time.Time, error) {
f.mu.Lock()
defer f.mu.Unlock()
img, ok := f.snapshots[clientID+"/"+cameraID]
if !ok {
return nil, time.Time{}, ErrNoSnapshot
}
return img, time.Unix(1756900000, 0).UTC(), nil
}