POST /api/customers and POST /api/visitors/{id}/merge. They ship together
because the first creates the need for the second: a customer typed in at
a counter has no face template, so when a camera sees that person later
the matcher has nothing to compare against and enrols them as somebody
new. That is the design working, not failing - and it means every
hand-created customer is a duplicate waiting to happen. Shipping the
create alone would manufacture duplicates into the state CLAUDE.md
already flags: "there is no merge endpoint server-side, so its
duplicates would be unrecoverable."
The number comes from clients.visitor_seq, taken exactly as RecordVisit
takes it. Two sources of visitor numbers that could disagree would be
worse than none: V-42 has to mean one person whichever way they arrived.
The label is the typed name, or "Visitor N" when they gave none - the
same string the engine writes, so a record created by hand is
indistinguishable from an enrolled one afterwards.
The merge is one transaction over FIVE tables, and the count is the
point. visits, purchases, visitor_embeddings, consents and
visitor_profiles all reference visitors ON DELETE CASCADE, so a table
this forgets to re-point is not an error - those rows are destroyed with
the source and nobody finds out until a customer's history is short.
visitor_profiles is UNIQUE on visitor_id, so the two cannot simply both
move and something has to win. Blanks on the survivor are filled from the
source and nothing it already holds is overwritten, which is exactly
right for the case this exists for: a hand-typed name and phone joining
the face that was recognised a week later.
Policies carried over from the edge gallery's merge, which had to settle
all of this once already: a human-assigned name outranks an auto
"Visitor N" whichever direction the operator merged; visit_count is
recomputed with COUNT(*) and never summed, because the stored counter may
be stale and the row count cannot be; first_seen_at takes the earlier of
the two, since it is one person and always was.
Two things that are this side's own:
- The source is deleted for real, not soft-deleted. A tombstone would
leave its number resolving to a record holding nothing, which reads as
"this customer exists and has never been here" - a worse answer than
"no such customer".
- The response names the RETIRED reference. Staff write V-42 on cards and
read it aloud; a merge that does not say which one stopped working
leaves somebody to discover it at a counter.
Manager and above, not staff. Apart from erasure this is the only
irreversible operation on a customer: two people welded together cannot
be separated, because nothing records which visit came from whom. It logs
at WARNING and writes an audit row for the same reason.
Also fixed while here: two s.Log.Printf calls - one of them mine, from
the password endpoint - that would panic on a nil logger. The package has
a nil-guarded s.logf and those were the only two not using it. The
password one sat in an error path no test reaches, which is exactly where
that bug waits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
1336 lines
37 KiB
Go
1336 lines
37 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"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
|
|
|
|
// The admin drill-down is the one surface where the fake MUST know which
|
|
// merchant owns what. Everywhere else the client id comes from the session
|
|
// and every query scopes on it, so a fake that ignores it still exercises
|
|
// the handler. Here the client id comes from the PATH and the scoping is
|
|
// the thing under test - a fake that ignored it would pass the
|
|
// cross-merchant tests while returning another company's shops.
|
|
// Camera ownership already has a home: cameraRefs, read through the
|
|
// cameraOwner method below.
|
|
siteOwner map[string]string // site id -> client id
|
|
salesRows []Sale
|
|
visitorSeq int64
|
|
lastMerge [2]string
|
|
saleOwner map[string]string // sale id -> client id
|
|
lastSaleQuery SaleQuery
|
|
clientRows map[string]ClientDetail
|
|
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) CreateSite(_ context.Context, clientID, slug, name, tz string) (NewSite, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, s := range f.sites {
|
|
if s.Slug == slug {
|
|
return NewSite{}, &pgconn.PgError{Code: "23505"}
|
|
}
|
|
}
|
|
id := "site-" + slug
|
|
f.sites = append(f.sites, SiteHealth{SiteID: id, Slug: slug, Name: name, Timezone: tz})
|
|
return NewSite{SiteID: id, Slug: slug, Name: name, Timezone: tz, Username: "acme." + slug, Password: "pw-" + slug}, nil
|
|
}
|
|
|
|
func (f *fakeStore) DeleteNewSite(_ context.Context, _ string, siteID string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
kept := f.sites[:0]
|
|
for _, s := range f.sites {
|
|
if s.SiteID != siteID {
|
|
kept = append(kept, s)
|
|
}
|
|
}
|
|
f.sites = kept
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) SiteHealth(_ context.Context, clientID string) ([]SiteHealth, error) {
|
|
// Scoped only when a test has declared ownership; otherwise every existing
|
|
// tenant test would have to grow a fixture it does not care about.
|
|
if f.siteOwner == nil {
|
|
return f.sites, nil
|
|
}
|
|
var out []SiteHealth
|
|
for _, s := range f.sites {
|
|
if f.siteOwner[s.SiteID] == clientID {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateCustomer(_ context.Context, clientID string,
|
|
in Profile, createdBy string) (Customer, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.visitorSeq++
|
|
label := in.FullName
|
|
if label == "" {
|
|
label = fmt.Sprintf("Visitor %d", f.visitorSeq)
|
|
}
|
|
c := Customer{
|
|
ID: fmt.Sprintf("new-%d", f.visitorSeq), Ref: VisitorRef(f.visitorSeq),
|
|
Label: label, FullName: in.FullName, Phone: in.Phone, Email: in.Email,
|
|
HasProfile: true,
|
|
}
|
|
f.visitors = append(f.visitors, c)
|
|
f.lastProfile = in
|
|
return c, nil
|
|
}
|
|
|
|
func (f *fakeStore) MergeVisitors(_ context.Context, clientID, sourceID, targetID string) (
|
|
MergeResult, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastMerge = [2]string{sourceID, targetID}
|
|
if sourceID == targetID {
|
|
return MergeResult{}, ErrSameVisitor
|
|
}
|
|
var src, dst *Customer
|
|
for i := range f.visitors {
|
|
switch f.visitors[i].ID {
|
|
case sourceID:
|
|
src = &f.visitors[i]
|
|
case targetID:
|
|
dst = &f.visitors[i]
|
|
}
|
|
}
|
|
if src == nil || dst == nil {
|
|
return MergeResult{}, pgx.ErrNoRows
|
|
}
|
|
out := MergeResult{VisitorID: dst.ID, Ref: dst.Ref, Label: dst.Label,
|
|
RetiredRef: src.Ref}
|
|
var kept []Customer
|
|
for _, c := range f.visitors {
|
|
if c.ID != sourceID {
|
|
kept = append(kept, c)
|
|
}
|
|
}
|
|
f.visitors = kept
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) SetUserPassword(_ context.Context, userID, hash string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for email, u := range f.users {
|
|
if u.ID == userID {
|
|
u.PasswordHash = hash
|
|
f.users[email] = u
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("no such user")
|
|
}
|
|
|
|
func (f *fakeStore) Sales(_ context.Context, q SaleQuery) ([]Sale, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastSaleQuery = q
|
|
var out []Sale
|
|
for _, sale := range f.salesRows {
|
|
if q.SiteID != "" && sale.SiteID != q.SiteID {
|
|
continue
|
|
}
|
|
if q.VisitorID != "" && sale.VisitorID != q.VisitorID {
|
|
continue
|
|
}
|
|
out = append(out, sale)
|
|
}
|
|
if q.Limit > 0 && len(out) > q.Limit {
|
|
out = out[:q.Limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) Sale(_ context.Context, clientID, id string) (Sale, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, sale := range f.salesRows {
|
|
// Scoped, so the cross-tenant test is not vacuous.
|
|
if sale.ID == id && f.saleOwner[id] == clientID {
|
|
return sale, nil
|
|
}
|
|
}
|
|
return Sale{}, nil
|
|
}
|
|
|
|
func (f *fakeStore) ClientDetail(_ context.Context, clientID string) (ClientDetail, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.clientRows[clientID], nil
|
|
}
|
|
|
|
func (f *fakeStore) AdminSiteID(_ context.Context, clientID, ref string) (string, error) {
|
|
for _, s := range f.sites {
|
|
if s.SiteID != ref && s.Slug != ref {
|
|
continue
|
|
}
|
|
if f.siteOwner != nil && f.siteOwner[s.SiteID] != clientID {
|
|
return "", nil // owned by somebody else: a miss, not a match
|
|
}
|
|
return s.SiteID, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (f *fakeStore) AdminCameraID(_ context.Context, clientID, siteID, ref string) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, c := range f.cameras {
|
|
if c.ID != ref && c.CameraID != ref {
|
|
continue
|
|
}
|
|
if c.SiteID != siteID {
|
|
return "", nil
|
|
}
|
|
if f.cameraRefs != nil && f.cameraOwner(c.ID) != clientID {
|
|
return "", nil
|
|
}
|
|
return c.ID, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (f *fakeStore) PlatformSummary(_ context.Context) (PlatformSummary, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return PlatformSummary{
|
|
CamerasTotal: len(f.cameras), MerchantsActive: len(f.clientRows),
|
|
SitesTotal: len(f.sites), AsOf: "2026-09-28T00:00:00Z",
|
|
}, 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) SetClientActive(_ context.Context, clientID string, active bool) (ClientRow, int, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i := range f.clients {
|
|
if f.clients[i].ID != clientID {
|
|
continue
|
|
}
|
|
f.clients[i].Active = active
|
|
revoked := 0
|
|
if !active {
|
|
for _, sess := range f.sessions {
|
|
if sess.p.ClientID == clientID && !sess.revoked {
|
|
sess.revoked = true
|
|
revoked++
|
|
}
|
|
}
|
|
}
|
|
return f.clients[i], revoked, nil
|
|
}
|
|
return ClientRow{}, 0, pgx.ErrNoRows
|
|
}
|
|
|
|
func (f *fakeStore) ClientOwners(_ 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 && u.Role == "owner" && u.Active {
|
|
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) ClientImageKeys(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
|
|
|
func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow, []string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i, c := range f.clients {
|
|
if c.ID != clientID {
|
|
continue
|
|
}
|
|
if c.Active {
|
|
return c, nil, errors.New("client is active")
|
|
}
|
|
f.clients = append(f.clients[:i], f.clients[i+1:]...)
|
|
return c, []string{c.Slug + ".shop1"}, nil
|
|
}
|
|
return ClientRow{}, nil, pgx.ErrNoRows
|
|
}
|
|
|
|
func (f *fakeStore) UpdateSite(_ context.Context, _ string, siteID string, in SiteUpdate) (SiteHealth, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i := range f.sites {
|
|
if f.sites[i].SiteID == siteID {
|
|
if in.Name != nil {
|
|
f.sites[i].Name = *in.Name
|
|
}
|
|
if in.Timezone != nil {
|
|
f.sites[i].Timezone = *in.Timezone
|
|
}
|
|
return f.sites[i], nil
|
|
}
|
|
}
|
|
return SiteHealth{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, c := range f.cameras {
|
|
if c.SiteID == siteID {
|
|
return "", ErrSiteInUse
|
|
}
|
|
}
|
|
for i, s := range f.sites {
|
|
if s.SiteID == siteID {
|
|
f.sites = append(f.sites[:i], f.sites[i+1:]...)
|
|
return "acme." + s.Slug, nil
|
|
}
|
|
}
|
|
return "", pgx.ErrNoRows
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// CreateMember behaves like the real store on the two things the handler
|
|
// branches on: the account lands in the caller's tenant and nowhere else, and
|
|
// an address that already exists anywhere is a conflict named the way Postgres
|
|
// names it, so conflictMessage recognises it.
|
|
func (f *fakeStore) CreateMember(_ context.Context, clientID string,
|
|
in NewMemberInput, hash string) (TeamMember, error) {
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, taken := f.users[in.Email]; taken {
|
|
return TeamMember{}, errors.New(`duplicate key value violates unique constraint "app_users_email_idx"`)
|
|
}
|
|
// The real UserByEmail joins clients for the name; this fake reads it off
|
|
// the record, so copy it from a tenant-mate or a login as the new member
|
|
// comes back with no company name and looks like it landed nowhere.
|
|
clientName := ""
|
|
for _, u := range f.users {
|
|
if u.ClientID == clientID && u.ClientName != "" {
|
|
clientName = u.ClientName
|
|
break
|
|
}
|
|
}
|
|
id := "member-" + itoa(len(f.users)+1)
|
|
f.users[in.Email] = UserRecord{
|
|
ID: id, ClientID: clientID, ClientName: clientName,
|
|
Email: in.Email, FullName: in.FullName,
|
|
Role: in.Role, Active: true, PasswordHash: hash, Found: true,
|
|
}
|
|
return TeamMember{ID: id, Email: in.Email, FullName: in.FullName,
|
|
Role: in.Role, Active: true}, nil
|
|
}
|
|
|
|
// ResetMemberPassword mirrors the real one: tenant-scoped, and every session
|
|
// the member holds is revoked with it.
|
|
func (f *fakeStore) ResetMemberPassword(_ context.Context, clientID, userID,
|
|
hash string) (TeamMember, error) {
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for email, u := range f.users {
|
|
if u.ID != userID || u.ClientID != clientID {
|
|
continue
|
|
}
|
|
u.PasswordHash = hash
|
|
f.users[email] = u
|
|
for _, s := range f.sessions {
|
|
if s.p.UserID == userID {
|
|
s.revoked = true
|
|
}
|
|
}
|
|
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")
|
|
}
|