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 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, _ 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) 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") }