The flow this product is sold on is three tiers: the platform admin
registers a merchant, the merchant registers their sales staff, the
staff sign in on a phone. Tier 1 handed the new owner a password. Tier 2
could not - a manager could only mint an invitation code, which the
salesperson had to redeem themselves, on their own phone, choosing their
own password. Good practice, and no use to a manager setting somebody up
before their first shift with a card and a pen.
POST /api/team/members mirrors POST /api/admin/clients: generated
password unless one is given, returned exactly once, bcrypt-hashed on
the way in and not recoverable after. Same permission shape as an
invitation - manager and above, only an owner mints an owner, admin
refused - so a manager cannot do through one door what they are refused
at the other. The invitation path stays; it is the better one whenever
the salesperson has their phone.
POST /api/team/{id}/password is the everyday case on a shop floor:
they forgot it. It sets a new one AND revokes every session they hold,
in one transaction, because the other reason a manager resets a
password is a lost phone, and a reset that left that phone signed in
would look complete while fixing nothing. Tenant-scoped in the UPDATE
itself; another company's user id is 404, never 403. No self-service
and no reset-by-email, deliberately: a floor account often has no
mailbox anyone checks, and the person who can vouch for the salesperson
standing in front of them is their manager.
RandomPassword moves from a private helper in the store to auth, so the
admin path, the merchant path and the reset all mint the same 80-bit
credential - rather than someone later writing a shorter one for the
"less important" account.
Verified: eight handler tests, and two against a real Postgres for the
things a fake cannot see - the RETURNING list scans on a row with no
last_login_at, the tenant scope holds, and the sessions row is actually
revoked. The tenant cleanup from yesterday held throughout.
API.md now documents the chain with both paths, and the note saying a
merchant could not create a login directly is gone because it is no
longer true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
1054 lines
29 KiB
Go
1054 lines
29 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
|
|
}
|
|
|
|
// 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")
|
|
}
|