Files
Behavision/server/internal/api/fake_test.go
Suriyakumarvijayanayagam 9182f70442 A customer number people can say out loud
Every id in the schema is a uuid and stays one. What was wrong was
putting one in front of a person: RecordVisit named every new customer
'Visitor ' || left(id::text, 8), so the arrivals feed, the shop PC and
the mobile app all read "Visitor 3446ec35" - the string a shop assistant
reads to a colleague and types into a search box. label is a stored
column staff can overwrite and SearchVisitors matches on, so formatting
around it in a front end would have left the data wrong on three
surfaces.

Migration 012 adds a per-client visitors.number, taken from a counter on
clients with UPDATE ... RETURNING inside the visit transaction. Per
client rather than global: a global sequence would tell any customer who
signs up how many people the whole platform has ever seen, from their
own first visitor number. The backfill numbers existing rows by
first_seen_at and relabels only the eight-hex pattern the old statement
produced, so a human-typed name is never overwritten.

Three of the four things anyone addresses by URL already had a human
name and the API simply refused it - a site has a slug, a camera has the
id the engine knows it by. refs.go accepts either form anywhere an id is
taken; a uuid resolves with no lookup, so every URL a client already
stored keeps working.

- An ambiguous camera name resolves to nothing, never to a guess: two
  shops may each have an "Office1" and acting on the first row would
  edit the wrong shop's camera.
- 404 on a path, 400 on a query filter. /api/visits answered fine and it
  was the filter that was wrong.
- site and site_id are both accepted everywhere now. They differed per
  endpoint, and an unknown query parameter is silently ignored, so
  getting it the wrong way round returned the whole estate.
- The search matches V-13, which is what the product now shows.

Two bugs found by running it rather than testing it:

- 'Visitor ' || $2::text beside number = $2 makes Postgres deduce two
  types for one parameter and refuse the insert. It compiled and passed
  every in-memory test; the first real database rejected it, along with
  the existing face tests that share the path.
- The fallback avatar said "V1" for Visitor 13, Visitor 10 and Visitor
  15 alike, and read as the V-1 reference for a fourth person. It shows
  the number now. The prop is customerRef, not ref - React reserves
  that name and it would never have arrived.

Verified on the live database and through the running API: 13 hex labels
became Visitor 1-13 in first-seen order, two typed names left alone, and
the same customer reachable by uuid, V-13 and 13.

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

998 lines
27 KiB
Go

package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
"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 {
// Which tenant and site each camera belongs to. The live relay is keyed on
// a camera id and a hub does not know whose camera it holds, so ownership
// is proved before anything streams - and that is what these tests check.
cameraRefs map[string]cameraRef
// 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
// Invitations, and the faces this server holds itself.
invites map[string]*fakeInvite // by code hash hex
faces map[string][]byte // "client/id"
lastFaceClient string
lastFaceSite string
deletedFaces []string
faceDeleteErr error
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
device string
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++
// uuid-SHAPED, because the handlers validate the shape of an id before
// spending a database round trip on it. A fake that mints "sess-1" would
// make every id-addressed session route 404 in tests and pass in
// production, which is the wrong way round.
id := fmt.Sprintf("00000000-0000-4000-8000-%012d", 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,
device: n.Device,
}
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
}
// ------------------------------------------------------------ live relay --
func (f *fakeStore) CameraRef(_ context.Context, clientID, cameraID string) (string, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
ref, ok := f.cameraRefs[cameraID]
if !ok || ref.client != clientID {
return "", "", ErrNoSnapshot
}
return ref.site, ref.engineID, nil
}
func (f *fakeStore) CameraRefBySite(_ context.Context, siteID, cameraID string) (string, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
ref, ok := f.cameraRefs[cameraID]
if !ok || ref.site != siteID {
return "", "", ErrNoSnapshot
}
return ref.site, ref.engineID, nil
}
func (f *fakeStore) SiteCameraIDs(_ context.Context, siteID string) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for id, ref := range f.cameraRefs {
if ref.site == siteID {
out = append(out, id)
}
}
return out, nil
}
// addCameraRef registers a camera so ownership checks have something to check.
func (f *fakeStore) addCameraRef(id, client, site, engineID string) {
f.mu.Lock()
defer f.mu.Unlock()
if f.cameraRefs == nil {
f.cameraRefs = map[string]cameraRef{}
}
f.cameraRefs[id] = cameraRef{client: client, site: site, engineID: engineID}
}
type cameraRef struct{ client, site, engineID string }
// ==================================== team, invitations, sessions, faces ====
//
// These behave rather than merely satisfy the interface: single use, tenant
// scoping and "the role comes from the invitation" are the properties the
// handlers are trusted for, so a fake that always says yes would make the tests
// that check them meaningless.
type fakeInvite struct {
id, clientID, email, fullName, role string
expires time.Time
used, revoked bool
}
func (f *fakeStore) CreateInvitation(_ context.Context, in NewInvitation) (Invitation, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.invites == nil {
f.invites = map[string]*fakeInvite{}
}
f.nextID++
id := fmt.Sprintf("00000000-0000-4000-9000-%012d", f.nextID)
f.invites[hex.EncodeToString(in.CodeHash)] = &fakeInvite{
id: id, clientID: in.ClientID, email: in.Email,
fullName: in.FullName, role: in.Role, expires: in.ExpiresAt,
}
return Invitation{
ID: id, Email: in.Email, FullName: in.FullName, Role: in.Role,
ExpiresAt: in.ExpiresAt.UTC().Format(time.RFC3339),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}, nil
}
func (f *fakeStore) PendingInvitations(_ context.Context, clientID string) ([]Invitation, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Invitation
for _, v := range f.invites {
if v.clientID != clientID || v.used || v.revoked {
continue
}
out = append(out, Invitation{ID: v.id, Email: v.email,
FullName: v.fullName, Role: v.role,
ExpiresAt: v.expires.UTC().Format(time.RFC3339)})
}
return out, nil
}
func (f *fakeStore) RevokeInvitation(_ context.Context, clientID, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, v := range f.invites {
if v.id == id && v.clientID == clientID && !v.used && !v.revoked {
v.revoked = true
return nil
}
}
return errors.New("no such pending invitation")
}
func (f *fakeStore) InvitationByCode(_ context.Context, hash []byte) (InvitationPreview, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.invites[hex.EncodeToString(hash)]
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
return InvitationPreview{}, errors.New("that invitation is not valid")
}
return InvitationPreview{Client: "Fake Co", Email: v.email,
FullName: v.fullName, Role: v.role}, nil
}
func (f *fakeStore) RedeemInvitation(_ context.Context, hash []byte,
fullName, passwordHash string) (UserRecord, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.invites[hex.EncodeToString(hash)]
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
return UserRecord{}, errors.New("that invitation is not valid")
}
if _, taken := f.users[v.email]; taken {
return UserRecord{}, errors.New("app_users_email_idx")
}
// Marked spent BEFORE the account exists, mirroring the real store's one
// transaction: a test that redeems the same code twice must get one user.
v.used = true
f.nextID++
rec := UserRecord{
ID: fmt.Sprintf("00000000-0000-4000-a000-%012d", f.nextID),
// From the INVITATION, never from the request - which is the property
// worth having a fake at all for.
ClientID: v.clientID, ClientName: "Fake Co", Email: v.email,
FullName: fullName, Role: v.role, Active: true, Found: true,
PasswordHash: passwordHash,
}
if rec.FullName == "" {
rec.FullName = v.fullName
}
f.users[v.email] = rec
return rec, nil
}
func (f *fakeStore) Team(_ context.Context, clientID string) ([]TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []TeamMember
for _, u := range f.users {
if u.ClientID != clientID {
continue
}
out = append(out, TeamMember{ID: u.ID, Email: u.Email,
FullName: u.FullName, Role: u.Role, Active: u.Active})
}
return out, nil
}
func (f *fakeStore) UpdateTeamMember(_ context.Context, clientID, userID string,
up TeamUpdate) (TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
for email, u := range f.users {
if u.ID != userID || u.ClientID != clientID {
continue
}
if up.Role != nil {
u.Role = *up.Role
}
if up.Active != nil {
u.Active = *up.Active
if !u.Active {
// The real store revokes in the same transaction; the fake
// does it here so a test can prove "they have left" actually
// signs them out rather than waiting twelve hours.
for _, s := range f.sessions {
if s.p.UserID == userID {
s.revoked = true
}
}
}
}
f.users[email] = u
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
Role: u.Role, Active: u.Active}, nil
}
return TeamMember{}, errors.New("no such team member")
}
func (f *fakeStore) UserSessions(_ context.Context, userID string) ([]DeviceSession, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []DeviceSession
for _, s := range f.sessions {
if s.p.UserID != userID || s.revoked {
continue
}
out = append(out, DeviceSession{ID: s.id, Device: s.device,
ExpiresAt: s.refreshExp.UTC().Format(time.RFC3339)})
}
return out, nil
}
func (f *fakeStore) RevokeUserSession(_ context.Context, userID, sessionID string) error {
f.mu.Lock()
defer f.mu.Unlock()
s, ok := f.sessions[sessionID]
// Scoped by user id, exactly as the real UPDATE is: a session id travels in
// a list and is not a secret, so it must not sign anybody else out.
if !ok || s.p.UserID != userID || s.revoked {
return errors.New("no such session")
}
s.revoked = true
return nil
}
func (f *fakeStore) RevokeOtherSessions(_ context.Context, userID, keep string) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, s := range f.sessions {
if s.p.UserID == userID && s.id != keep && !s.revoked {
s.revoked = true
n++
}
}
return n, nil
}
func (f *fakeStore) PutVisitFace(_ context.Context, clientID, siteID string,
jpeg []byte) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.faces == nil {
f.faces = map[string][]byte{}
}
f.nextID++
id := fmt.Sprintf("00000000-0000-4000-b000-%012d", f.nextID)
f.faces[clientID+"/"+id] = jpeg
f.lastFaceClient, f.lastFaceSite = clientID, siteID
return "db:" + id, nil
}
func (f *fakeStore) VisitFace(_ context.Context, clientID, key string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
img, ok := f.faces[clientID+"/"+strings.TrimPrefix(key, "db:")]
if !ok {
return nil, errors.New("no such face image")
}
return img, nil
}
func (f *fakeStore) DeleteVisitFaces(_ context.Context, clientID string, keys []string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.faceDeleteErr != nil {
return f.faceDeleteErr
}
for _, k := range keys {
delete(f.faces, clientID+"/"+strings.TrimPrefix(k, "db:"))
f.deletedFaces = append(f.deletedFaces, k)
}
return nil
}
// ============================================ public reference resolution ===
//
// These behave rather than merely satisfy the interface. The properties the
// handlers are trusted for - a reference resolves only within the caller's own
// tenant, and an ambiguous camera name resolves to nothing rather than to
// whichever row came first - are exactly what a fake that always said yes would
// stop any test from checking.
func (f *fakeStore) SiteIDBySlug(_ context.Context, clientID, slug string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, s := range f.sites {
// An owner of "" is a site the fake was not told about, which is the
// ordinary case: SiteHealth carries no client id, and most tests seed
// one tenant. Tests that assert cross-tenant resolution seed a camera,
// which is what gives a site an owner here.
if owner := f.siteClient(s.Slug, s.SiteID); s.Slug == slug &&
(owner == "" || owner == clientID) {
return s.SiteID, nil
}
}
return "", nil
}
// siteClient answers which tenant a site belongs to. SiteHealth carries no
// client id of its own - it is already scoped by the query that returns it - so
// the fake reads ownership from the cameras it was seeded with.
func (f *fakeStore) siteClient(_, siteID string) string {
for _, ref := range f.cameraRefs {
if ref.site == siteID {
return ref.client
}
}
for _, c := range f.cameras {
if c.SiteID == siteID {
return f.cameraOwner(c.ID)
}
}
return ""
}
func (f *fakeStore) cameraOwner(id string) string {
if ref, ok := f.cameraRefs[id]; ok {
return ref.client
}
return ""
}
func (f *fakeStore) CameraIDByRef(_ context.Context, clientID, ref string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
var found []string
for _, c := range f.cameras {
if c.CameraID != ref {
continue
}
if owner := f.cameraOwner(c.ID); owner != "" && owner != clientID {
continue
}
found = append(found, c.ID)
}
// A camera id is unique per site, not per tenant. Two shops may each have
// an "Office1", and acting on whichever sorted first would edit the wrong
// shop's camera, so ambiguity is no match.
if len(found) != 1 {
return "", nil
}
return found[0], nil
}
func (f *fakeStore) VisitorIDByNumber(_ context.Context, clientID string, number int64) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
want := VisitorRef(number)
for _, v := range f.visitors {
if v.Ref == want {
return v.ID, nil
}
}
return "", nil
}