Behavision: face recognition for retail, edge to head office
Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
178
server/internal/api/admin_test.go
Normal file
178
server/internal/api/admin_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedPlatformAdmin(fs *fakeStore) {
|
||||
// No client id. A platform admin is defined by the ABSENCE of a tenant,
|
||||
// not by a flag, which is what keeps its scope from depending on which
|
||||
// query happens to check first.
|
||||
fs.addUser("root@loyaly.ai", "admin123", UserRecord{
|
||||
ID: "admin-1", ClientID: "", FullName: "Platform", Role: "admin", Active: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnAdminCreatesATenantAndItsOwnerInOneCall(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
rec := do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{
|
||||
"company_name": "Nearle Retail",
|
||||
"owner_email": "aravind@nearle.in",
|
||||
"owner_name": "Aravind",
|
||||
"password": "admin123",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out NewClientResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Password != "admin123" {
|
||||
t.Errorf("the password is shown once and must be the one that was set, got %q", out.Password)
|
||||
}
|
||||
// The slug becomes an MQTT topic segment, so it has to be derived rather
|
||||
// than left to whatever the operator typed.
|
||||
if out.Slug != "nearle-retail" {
|
||||
t.Errorf("slug %q, want it derived from the company name", out.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
// A tenant user must not learn that a platform-administration surface exists.
|
||||
func TestATenantUserGets404FromTheAdminRoutes(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs) // a manager inside client-acme
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
for _, call := range []struct{ method, path string }{
|
||||
{"GET", "/api/admin/clients"},
|
||||
{"POST", "/api/admin/clients"},
|
||||
} {
|
||||
rec := do(t, s, call.method, call.path, sess.Token,
|
||||
map[string]string{"company_name": "X", "owner_email": "x@x.com"})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s: got %d, want 404 - a 403 confirms the surface exists",
|
||||
call.method, call.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The one that would be a cross-tenant breach: an account INSIDE a client whose
|
||||
// role happens to be "admin". Scope is the absent client id, not the role.
|
||||
func TestARoleOfAdminInsideATenantIsNotAPlatformAdmin(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addUser("sneaky@acme.com", "admin123", UserRecord{
|
||||
ID: "u9", ClientID: "client-acme", Role: "admin", Active: true,
|
||||
})
|
||||
sess := login(t, s, "sneaky@acme.com", "admin123")
|
||||
|
||||
if rec := do(t, s, "GET", "/api/admin/clients", sess.Token, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("a tenant-scoped 'admin' reached the platform routes: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAdminRoutesNeedASession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
if rec := do(t, s, "GET", "/api/admin/clients", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Without an owner the tenant is invisible-broken: it looks normal in every
|
||||
// list and nobody can sign into it.
|
||||
func TestACompanyWithNoOwnerEmailIsRefused(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
rec := do(t, s, "POST", "/api/admin/clients", sess.Token,
|
||||
map[string]string{"company_name": "Nearle"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("got %d, want 400", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "nobody can sign in") {
|
||||
t.Errorf("the message should say why it matters: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A clashing slug means the operator is about to hand somebody else's tenant to
|
||||
// a new owner. It must fail, and say which mistake it was.
|
||||
func TestADuplicateIsAConflictAnOperatorCanActapon(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
for _, tc := range []struct{ pgErr, want string }{
|
||||
{`duplicate key value violates unique constraint "clients_slug_key"`, "short name already exists"},
|
||||
{`duplicate key value violates unique constraint "app_users_email_idx"`, "already has an account"},
|
||||
} {
|
||||
fs.newClientErr = errors.New(tc.pgErr)
|
||||
rec := do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{
|
||||
"company_name": "Nearle", "owner_email": "a@nearle.in"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("got %d, want 409 for %q", rec.Code, tc.pgErr)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), tc.want) {
|
||||
t.Errorf("message %q does not name the clash", rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugsCannotChangeWhatAnMQTTTopicMeans(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Nearle Retail Pvt Ltd": "nearle-retail-pvt-ltd",
|
||||
" Acme ": "acme",
|
||||
"a/b+c#d": "a-b-c-d", // the three MQTT wildcards and separator
|
||||
"---Nearle---": "nearle",
|
||||
"Café 21": "caf-21",
|
||||
"!!!": "",
|
||||
} {
|
||||
if got := slugify(in); got != want {
|
||||
t.Errorf("slugify(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Creating a tenant is rare and consequential. It should leave a name against it.
|
||||
func TestCreatingATenantIsAudited(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{
|
||||
"company_name": "Nearle", "owner_email": "aravind@nearle.in"})
|
||||
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
for _, a := range fs.audits {
|
||||
if a.Action == "client.create" && a.ActorID == "admin-1" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("no audit row for creating a tenant: %+v", fs.audits)
|
||||
}
|
||||
|
||||
// An operator inventing a password for somebody else invents a weak one and
|
||||
// sends it over chat. Omitting it must generate one, not create an account with
|
||||
// an empty password.
|
||||
func TestAnOmittedPasswordIsGeneratedNotBlank(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
rec := do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{
|
||||
"company_name": "Nearle", "owner_email": "aravind@nearle.in"})
|
||||
var out NewClientResult
|
||||
json.Unmarshal(rec.Body.Bytes(), &out) //nolint:errcheck
|
||||
if out.Password == "" {
|
||||
t.Fatal("no password was returned, so nobody can ever sign in")
|
||||
}
|
||||
}
|
||||
436
server/internal/api/api.go
Normal file
436
server/internal/api/api.go
Normal file
@@ -0,0 +1,436 @@
|
||||
// Package api is the request/response half of the server: staff signing in
|
||||
// from the desktop app, reports, the in-store customer form, and a fresh PC
|
||||
// collecting its broker credentials.
|
||||
//
|
||||
// The MQTT consumer in `ingest` is the other half and shares nothing with this
|
||||
// but the database. That separation is deliberate — an agent is authenticated
|
||||
// by the broker and identified by its topic, a person is authenticated by a
|
||||
// password and identified by a session. Merging the two would mean one code
|
||||
// path deciding two very different questions about who is asking.
|
||||
//
|
||||
// Every handler here derives the tenant from the SESSION, never from the
|
||||
// request body. A client_id parameter that the caller can set is a cross-tenant
|
||||
// read waiting for someone to try it.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// Store is what the API needs from the database. Declared here, implemented in
|
||||
// package store, so handlers can be tested against a fake with no Postgres —
|
||||
// the same pattern ingest already uses.
|
||||
type Store interface {
|
||||
// --- identity ---
|
||||
UserByEmail(ctx context.Context, email string) (UserRecord, error)
|
||||
TouchUserLogin(ctx context.Context, userID string) error
|
||||
CreateSession(ctx context.Context, s NewSession) error
|
||||
SessionByAccess(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
|
||||
SessionByRefresh(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
|
||||
RotateSession(ctx context.Context, sessionID string, s NewSession) error
|
||||
RevokeSession(ctx context.Context, sessionID string) error
|
||||
|
||||
// --- reports ---
|
||||
Footfall(ctx context.Context, q ReportQuery) ([]FootfallPoint, Totals, error)
|
||||
Conversion(ctx context.Context, q ReportQuery) (SalesReport, error)
|
||||
SiteHealth(ctx context.Context, clientID string) ([]SiteHealth, error)
|
||||
|
||||
// --- people ---
|
||||
SearchVisitors(ctx context.Context, clientID, query string, limit int) ([]Customer, error)
|
||||
VisitorHistory(ctx context.Context, clientID, visitorID string, limit int) ([]VisitRow, error)
|
||||
// Arrivals is the live feed: who walked in, newest window or from a cursor.
|
||||
Arrivals(ctx context.Context, q ArrivalQuery) ([]Arrival, error)
|
||||
SaveProfile(ctx context.Context, clientID string, p Profile, actor string) error
|
||||
RecordPurchase(ctx context.Context, clientID string, p PurchaseInput, actor string) error
|
||||
|
||||
// --- cameras ---
|
||||
Cameras(ctx context.Context, clientID, siteID string) ([]Camera, error)
|
||||
CameraByID(ctx context.Context, clientID, id string) (Camera, error)
|
||||
SaveCamera(ctx context.Context, clientID, siteID, cameraID string, in CameraInput) (Camera, error)
|
||||
DeleteCamera(ctx context.Context, clientID, id string) (Camera, error)
|
||||
// AgentCameras is the only path that decrypts a camera password, and it is
|
||||
// reachable only with that site's own agent token.
|
||||
AgentCameras(ctx context.Context, siteID string) ([]AgentCamera, error)
|
||||
ApplyAgentReport(ctx context.Context, clientID, siteID string, rep AgentCameraReport) error
|
||||
|
||||
// --- claiming a shop PC ---
|
||||
IssueEnrolmentCode(ctx context.Context, clientID, siteID, actorID,
|
||||
label string, ttl time.Duration) (EnrolmentCode, error)
|
||||
|
||||
// --- proving a camera works ---
|
||||
RequestCheck(ctx context.Context, clientID, id, kind string, seconds int) error
|
||||
ClaimChecks(ctx context.Context, siteID string) ([]AgentCheckJob, error)
|
||||
RecordCheckResult(ctx context.Context, siteID string, res AgentCheckResult) error
|
||||
ReleaseStaleChecks(ctx context.Context, olderThan time.Duration) error
|
||||
|
||||
// --- platform administration ---
|
||||
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
|
||||
ListClients(ctx context.Context) ([]ClientRow, error)
|
||||
|
||||
// --- enrolment ---
|
||||
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
|
||||
SetAgentAPIToken(ctx context.Context, agentID string, hash []byte) error
|
||||
AgentByToken(ctx context.Context, hash []byte) (AgentPrincipal, error)
|
||||
|
||||
// --- images ---
|
||||
VisitorImageKey(ctx context.Context, clientID, visitorID string) (string, error)
|
||||
VisitorImageKeys(ctx context.Context, clientID, visitorID string) ([]string, error)
|
||||
ForgetVisitor(ctx context.Context, clientID, visitorID string) error
|
||||
|
||||
Audit(ctx context.Context, e AuditEntry)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Store Store
|
||||
Log *log.Logger
|
||||
Bootstrap BootstrapConfig
|
||||
// Now is injectable so expiry logic is testable without sleeping.
|
||||
Now func() time.Time
|
||||
// Throttle limits failed sign-ins per account, IPThrottle per source
|
||||
// address. Built on first use so a zero-value Server is still safe: an
|
||||
// unlimited login endpoint reached by forgetting one field is not a failure
|
||||
// mode worth leaving open.
|
||||
Throttle *Throttle
|
||||
IPThrottle *Throttle
|
||||
// Blob is object storage. Nil means this deployment stores no images,
|
||||
// which is a supported configuration and the default: the product shipped
|
||||
// without images on purpose, and turning them on changes what the database
|
||||
// is under data-protection law.
|
||||
Blob BlobStore
|
||||
// Assistant answers questions in plain language by calling the same
|
||||
// business questions the screens ask. Nil means this deployment has no
|
||||
// API key, which is supported: the UI hides the panel.
|
||||
Assistant Assistant
|
||||
// Hub wakes live arrival streams when the MQTT consumer records a visit.
|
||||
// Nil is supported and means the streams fall back to their slow tick -
|
||||
// a server assembled without one is slower, not broken.
|
||||
Hub *Hub
|
||||
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *Server) throttles() (perUser, perIP *Throttle) {
|
||||
s.once.Do(func() {
|
||||
if s.Throttle == nil {
|
||||
s.Throttle = NewThrottle(10, 15*time.Minute)
|
||||
}
|
||||
if s.IPThrottle == nil {
|
||||
// Far looser than the per-account limit, and deliberately so. A
|
||||
// whole shop sits behind one NAT address, so a per-IP limit tight
|
||||
// enough to stop a targeted attack locks out every member of staff
|
||||
// because one of them fumbled their password. The per-ACCOUNT limit
|
||||
// is what actually stops somebody working through a password list;
|
||||
// this is only a backstop against spraying one guess across many
|
||||
// addresses.
|
||||
s.IPThrottle = NewThrottle(60, 15*time.Minute)
|
||||
}
|
||||
})
|
||||
return s.Throttle, s.IPThrottle
|
||||
}
|
||||
|
||||
// BootstrapConfig is what a newly enrolled PC is told about the estate. It
|
||||
// comes from the server's own environment, never from the request: an agent
|
||||
// asking where to connect must not be able to influence the answer.
|
||||
type BootstrapConfig struct {
|
||||
MQTTURL string
|
||||
CACert string
|
||||
Models []ModelRef
|
||||
}
|
||||
|
||||
type ModelRef struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
func (s *Server) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func (s *Server) logf(format string, v ...any) {
|
||||
if s.Log != nil {
|
||||
s.Log.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
// Routes returns the mux. Patterns use method-qualified paths so a GET to a
|
||||
// write endpoint is a 405 rather than falling through to the catch-all as a
|
||||
// confusing 404.
|
||||
func (s *Server) Routes() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
||||
mux.HandleFunc("POST /api/auth/refresh", s.handleRefresh)
|
||||
mux.HandleFunc("POST /api/auth/logout", s.authed(s.handleLogout))
|
||||
mux.HandleFunc("GET /api/auth/me", s.authed(s.handleMe))
|
||||
|
||||
mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall))
|
||||
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
|
||||
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
|
||||
|
||||
// Cameras, onboarded from head office. The shop PC still does the
|
||||
// connecting - it is the only thing on the camera's network - so these
|
||||
// write desired state that its agent pulls and applies.
|
||||
mux.HandleFunc("GET /api/cameras", s.authed(s.handleCameras))
|
||||
mux.HandleFunc("POST /api/sites/{site}/cameras", s.authed(s.handleCreateCamera))
|
||||
mux.HandleFunc("PATCH /api/cameras/{id}", s.authed(s.handleUpdateCamera))
|
||||
mux.HandleFunc("DELETE /api/cameras/{id}", s.authed(s.handleDeleteCamera))
|
||||
// Prove a camera works: "connection" asks whether the shop PC can open the
|
||||
// stream, "placement" asks whether somebody walking past produces a view
|
||||
// good enough to recognise. Two questions, because a camera passes the
|
||||
// first and fails the second all the time - that is the Office1 case.
|
||||
mux.HandleFunc("POST /api/cameras/{id}/check", s.authed(s.handleRequestCheck))
|
||||
// The end-to-end answer for one shop, assembled from what head office
|
||||
// already knows - so it works even when the shop PC is off, which is one of
|
||||
// the things it reports.
|
||||
mux.HandleFunc("GET /api/sites/{site}/check", s.authed(s.handleSiteCheck))
|
||||
mux.HandleFunc("POST /api/sites/{site}/enrolment-code",
|
||||
s.authed(s.handleIssueEnrolmentCode))
|
||||
|
||||
// The assistant. Every tool it calls runs as the signed-in user, so it can
|
||||
// only ever see what the person asking could already see.
|
||||
mux.HandleFunc("POST /api/assistant", s.authed(s.handleAssistant))
|
||||
|
||||
// The live feed. `visitors` searches a customer list by name; `visits`
|
||||
// answers the question a shop screen or a mobile app actually asks - who
|
||||
// came through the door just now - and carries each person's photo with
|
||||
// them so rendering four simultaneous arrivals is one request, not nine.
|
||||
mux.HandleFunc("GET /api/visits", s.authed(s.handleArrivals))
|
||||
mux.HandleFunc("GET /api/visits/stream", s.authed(s.handleArrivalStream))
|
||||
|
||||
mux.HandleFunc("GET /api/visitors", s.authed(s.handleVisitors))
|
||||
mux.HandleFunc("GET /api/visitors/{id}/history", s.authed(s.handleVisitorHistory))
|
||||
mux.HandleFunc("PUT /api/visitors/{id}/profile", s.authed(s.handleSaveProfile))
|
||||
mux.HandleFunc("POST /api/purchases", s.authed(s.handlePurchase))
|
||||
|
||||
// Platform administration. Not public registration: an open endpoint that
|
||||
// mints tenants is a far larger thing to secure than one behind an account
|
||||
// that already exists. The `provision` CLI remains the bootstrap path,
|
||||
// because creating the first admin cannot require being signed in as one.
|
||||
mux.HandleFunc("GET /api/admin/clients", s.adminOnly(s.handleListClients))
|
||||
mux.HandleFunc("POST /api/admin/clients", s.adminOnly(s.handleCreateClient))
|
||||
|
||||
// Not session-authenticated: this is how a PC with no credentials gets
|
||||
// some. The enrolment token is the credential.
|
||||
mux.HandleFunc("POST /api/agent/enrol", s.handleEnrol)
|
||||
// Authenticated by the agent's own API token, not a user session.
|
||||
mux.HandleFunc("POST /api/agent/upload-url", s.agentAuthed(s.handleUploadURL))
|
||||
// What this shop PC should be running, and what it reports back.
|
||||
mux.HandleFunc("GET /api/agent/cameras", s.agentAuthed(s.handleAgentCameras))
|
||||
mux.HandleFunc("POST /api/agent/cameras", s.agentAuthed(s.handleAgentCameraReport))
|
||||
mux.HandleFunc("GET /api/agent/checks", s.agentAuthed(s.handleAgentChecks))
|
||||
mux.HandleFunc("POST /api/agent/checks", s.agentAuthed(s.handleAgentCheckResult))
|
||||
|
||||
mux.HandleFunc("GET /api/visitors/{id}/image", s.authed(s.handleVisitorImage))
|
||||
// The erasure path. Destroys the template and the photo; keeps the
|
||||
// anonymous visit counts, which are legitimate aggregate data.
|
||||
mux.HandleFunc("DELETE /api/visitors/{id}", s.authed(s.handleForgetVisitor))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- plumbing
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const principalKey ctxKey = 1
|
||||
|
||||
// PrincipalFrom returns the authenticated caller. Handlers behind authed() can
|
||||
// rely on it being present.
|
||||
func PrincipalFrom(ctx context.Context) auth.Principal {
|
||||
p, _ := ctx.Value(principalKey).(auth.Principal)
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tok := auth.BearerToken(r)
|
||||
if tok == "" {
|
||||
unauthorized(w, "sign in to continue")
|
||||
return
|
||||
}
|
||||
p, expires, err := s.Store.SessionByAccess(r.Context(), auth.HashToken(tok))
|
||||
if err != nil {
|
||||
// One message for "no such session" and "revoked": telling the
|
||||
// difference is only useful to someone probing tokens.
|
||||
unauthorized(w, "sign in to continue")
|
||||
return
|
||||
}
|
||||
if s.now().After(expires) {
|
||||
// A distinct code so the client refreshes silently instead of
|
||||
// throwing the user back to a login form every twelve hours.
|
||||
writeErr(w, http.StatusUnauthorized, "token_expired",
|
||||
"your session needs refreshing")
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), principalKey, p)))
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
// Nothing useful left to do: the status line is already sent.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// writeCompactJSON writes JSON with no trailing newline.
|
||||
//
|
||||
// Encoder.Encode appends one, which inside an SSE `data:` line closes the event
|
||||
// a frame early. Everywhere else that newline is invisible; here it is a
|
||||
// protocol bug, so the stream gets its own writer rather than a comment on the
|
||||
// shared one asking people to remember.
|
||||
func writeCompactJSON(w io.Writer, body any) error {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeErr uses the shape the desktop client parses: it shows `message`
|
||||
// verbatim, so that string is user-facing text, not a developer note.
|
||||
func writeErr(w http.ResponseWriter, code int, kind, message string) {
|
||||
writeJSON(w, code, map[string]string{"error": kind, "message": message})
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, msg string) {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", msg)
|
||||
}
|
||||
|
||||
func badRequest(w http.ResponseWriter, msg string) {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", msg)
|
||||
}
|
||||
|
||||
func (s *Server) serverError(w http.ResponseWriter, where string, err error) {
|
||||
// The error text stays in the log. A database error surfaced to a shop
|
||||
// floor tells an attacker about the schema and tells the operator nothing
|
||||
// they can act on.
|
||||
s.logf("ERROR %s: %v", where, err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error",
|
||||
"something went wrong at our end - please try again")
|
||||
}
|
||||
|
||||
// decode reads a JSON body with a hard size limit. Unknown fields are rejected
|
||||
// so a client sending `client_id` to a handler that ignores it finds out,
|
||||
// rather than believing it took effect.
|
||||
func decode(w http.ResponseWriter, r *http.Request, out any) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(out); err != nil {
|
||||
return errors.New("could not read the request: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeOptional is decode for a body where every field has a default.
|
||||
//
|
||||
// An empty body is then a legitimate request - "mint me a code, I have nothing
|
||||
// to say about it" - and answering that with 400 "could not read the request:
|
||||
// EOF" is a confusing failure for the simplest possible call. Not the default,
|
||||
// because for most endpoints an empty body IS the mistake, and silently
|
||||
// treating it as an empty object would let a PUT wipe a profile.
|
||||
func decodeOptional(w http.ResponseWriter, r *http.Request, out any) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(out); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("could not read the request: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, name string, def, max int) int {
|
||||
v, err := strconv.Atoi(r.URL.Query().Get(name))
|
||||
if err != nil || v <= 0 {
|
||||
return def
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func trim(s string) string { return strings.TrimSpace(s) }
|
||||
|
||||
// looksLikeUUID checks the shape of a path id before it reaches SQL.
|
||||
//
|
||||
// Every id in this schema is a uuid, and `$1::uuid` on a malformed string is a
|
||||
// Postgres cast error - which surfaces as a 500. A mistyped URL is not a server
|
||||
// fault, and answering one with "something went wrong at our end" sends an
|
||||
// operator looking for an outage that is not there. It also means a scanner
|
||||
// walking the API can tell, from the status code alone, which of its guesses
|
||||
// reached a query.
|
||||
func looksLikeUUID(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
for i, c := range s {
|
||||
switch i {
|
||||
case 8, 13, 18, 23:
|
||||
if c != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||
if !isHex {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ErrNoSecrets means the server has no encryption key, so camera passwords can
|
||||
// be neither stored nor handed out. Declared here so handlers can recognise it
|
||||
// without importing the store package.
|
||||
var ErrNoSecrets = errors.New("this server has no encryption key, so camera passwords cannot be stored")
|
||||
|
||||
// BlobStore is what the API needs from object storage. Declared here and
|
||||
// implemented by internal/blob, so the handlers can be tested without a bucket
|
||||
// and so a deployment with images switched off is a nil field rather than a
|
||||
// second code path.
|
||||
type BlobStore interface {
|
||||
Key(client, site, objectID string, at time.Time) string
|
||||
PresignPut(key string, ttl time.Duration) (string, http.Header, error)
|
||||
PresignGet(key string, ttl time.Duration) (string, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
// newObjectID names one uploaded image.
|
||||
//
|
||||
// Random rather than derived from the event id: an object key is guessable if
|
||||
// it is derived, and this bucket allows anonymous listing, so a predictable key
|
||||
// would let someone enumerate a shop's customers by date. It is also the only
|
||||
// identifier the agent gets, and it must not encode who the person is.
|
||||
func newObjectID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// Cannot happen short of a broken kernel, and a predictable key here
|
||||
// would be worse than a failed upload.
|
||||
panic("api: no entropy for an object id: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
650
server/internal/api/api_test.go
Normal file
650
server/internal/api/api_test.go
Normal file
@@ -0,0 +1,650 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// Real-shaped ids: every id in the schema is a uuid, and the handlers now
|
||||
// check that before touching SQL, so a placeholder like "v1" would be testing
|
||||
// the wrong path.
|
||||
const (
|
||||
visitorAID = "98bf7587-4d55-4ae0-99e0-de8c05dd3e78"
|
||||
visitorB = "11111111-2222-3333-4444-555555555555"
|
||||
visitorA = "/api/visitors/" + visitorAID
|
||||
)
|
||||
|
||||
func newServer(t *testing.T) (*Server, *fakeStore) {
|
||||
t.Helper()
|
||||
fs := newFakeStore()
|
||||
return &Server{Store: fs}, fs
|
||||
}
|
||||
|
||||
func do(t *testing.T, s *Server, method, path, token string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
// Every request looks like it came through the proxy, which is where the
|
||||
// throttle reads the client address from.
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Routes().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func login(t *testing.T, s *Server, email, password string) Session {
|
||||
t.Helper()
|
||||
rec := do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": email, "password": password})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: got %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var sess Session
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &sess); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
func seedUser(fs *fakeStore) {
|
||||
fs.addUser("manager@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u1", ClientID: "client-acme", ClientName: "Acme Retail",
|
||||
FullName: "Asha", Role: "manager", Active: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sign in
|
||||
|
||||
func TestLoginReturnsSessionAndNeverThePasswordHash(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
|
||||
rec := do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "Manager@Acme.com", "password": "correct horse battery"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
// The single most important assertion here: whatever else changes about the
|
||||
// response shape, a hash must never travel to a shop floor.
|
||||
if strings.Contains(body, "$2a$") || strings.Contains(body, "password_hash") {
|
||||
t.Fatalf("password hash leaked into the response: %s", body)
|
||||
}
|
||||
var sess Session
|
||||
if err := json.Unmarshal([]byte(body), &sess); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Token == "" || sess.RefreshToken == "" {
|
||||
t.Fatal("expected both tokens")
|
||||
}
|
||||
if sess.Token == sess.RefreshToken {
|
||||
t.Fatal("access and refresh tokens must be different secrets")
|
||||
}
|
||||
if sess.User.ClientID != "client-acme" || sess.User.Client != "Acme Retail" {
|
||||
t.Fatalf("user not populated: %+v", sess.User)
|
||||
}
|
||||
// The address is normalised on the way in, so a capitalised sign-in and a
|
||||
// lower-case one are one account.
|
||||
if len(fs.loginTouched) != 1 {
|
||||
t.Fatalf("expected last_login to be recorded once, got %v", fs.loginTouched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEmailAndWrongPasswordAreIndistinguishable(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
|
||||
unknown := do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "nobody@acme.com", "password": "correct horse battery"})
|
||||
wrong := do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "manager@acme.com", "password": "not the password"})
|
||||
|
||||
if unknown.Code != http.StatusUnauthorized || wrong.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("codes: unknown=%d wrong=%d", unknown.Code, wrong.Code)
|
||||
}
|
||||
// Any difference here is a membership oracle for a customer's staff list.
|
||||
if unknown.Body.String() != wrong.Body.String() {
|
||||
t.Fatalf("responses differ:\n unknown: %s\n wrong: %s",
|
||||
unknown.Body.String(), wrong.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInactiveUserCannotSignIn(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addUser("gone@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-acme", Active: false,
|
||||
})
|
||||
rec := do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "gone@acme.com", "password": "correct horse battery"})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a deactivated account signed in: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedFailuresAreThrottledAndSuccessClearsIt(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
s.Throttle = NewThrottle(3, time.Minute)
|
||||
s.IPThrottle = NewThrottle(1000, time.Minute)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "manager@acme.com", "password": "wrong"})
|
||||
}
|
||||
blocked := do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "manager@acme.com", "password": "correct horse battery"})
|
||||
if blocked.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429 after 3 failures, got %d", blocked.Code)
|
||||
}
|
||||
|
||||
// A cleared window lets the real password through again, and the success
|
||||
// resets the counter so the next mistake does not lock the shop out.
|
||||
s.Throttle = NewThrottle(3, time.Minute)
|
||||
s.IPThrottle = NewThrottle(1000, time.Minute)
|
||||
login(t, s, "manager@acme.com", "correct horse battery")
|
||||
for i := 0; i < 2; i++ {
|
||||
do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "manager@acme.com", "password": "wrong"})
|
||||
}
|
||||
again := do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "manager@acme.com", "password": "correct horse battery"})
|
||||
if again.Code != http.StatusOK {
|
||||
t.Fatalf("success did not reset the counter: %d", again.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessions
|
||||
|
||||
func TestAuthenticatedRoutesRequireAToken(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
for _, path := range []string{
|
||||
"/api/auth/me", "/api/reports/footfall", "/api/reports/conversion",
|
||||
"/api/visitors", "/api/sites",
|
||||
} {
|
||||
if rec := do(t, s, "GET", path, "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s without a token returned %d, want 401", path, rec.Code)
|
||||
}
|
||||
if rec := do(t, s, "GET", path, "not-a-real-token", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s with a bogus token returned %d, want 401", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredAccessTokenAsksForARefreshRatherThanALogin(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
now := time.Now()
|
||||
s.Now = func() time.Time { return now }
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
// Move past the access lifetime but stay well inside the refresh one.
|
||||
s.Now = func() time.Time { return now.Add(auth.AccessTTL + time.Minute) }
|
||||
rec := do(t, s, "GET", "/api/auth/me", sess.Token, nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expired token returned %d", rec.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
json.Unmarshal(rec.Body.Bytes(), &body) //nolint:errcheck
|
||||
// The distinct code is what lets the desktop app refresh silently instead
|
||||
// of throwing a shop assistant back to a login form twice a day.
|
||||
if body["error"] != "token_expired" {
|
||||
t.Fatalf("want token_expired so the client can refresh, got %q", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRotatesAndTheOldRefreshTokenStopsWorking(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
first := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/auth/refresh", "",
|
||||
map[string]string{"refresh_token": first.RefreshToken})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("refresh failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var second Session
|
||||
json.Unmarshal(rec.Body.Bytes(), &second) //nolint:errcheck
|
||||
if second.Token == first.Token || second.RefreshToken == first.RefreshToken {
|
||||
t.Fatal("refresh must issue new secrets, not return the same ones")
|
||||
}
|
||||
if got := do(t, s, "GET", "/api/auth/me", second.Token, nil); got.Code != http.StatusOK {
|
||||
t.Fatalf("new access token rejected: %d", got.Code)
|
||||
}
|
||||
// A refresh token copied off a resold shop PC must not keep working
|
||||
// alongside the real one.
|
||||
replay := do(t, s, "POST", "/api/auth/refresh", "",
|
||||
map[string]string{"refresh_token": first.RefreshToken})
|
||||
if replay.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("the old refresh token still works: %d", replay.Code)
|
||||
}
|
||||
if old := do(t, s, "GET", "/api/auth/me", first.Token, nil); old.Code == http.StatusOK {
|
||||
t.Fatal("the old access token still works after rotation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRevokesTheSession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
if rec := do(t, s, "POST", "/api/auth/logout", sess.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("logout returned %d", rec.Code)
|
||||
}
|
||||
if rec := do(t, s, "GET", "/api/auth/me", sess.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("token still valid after logout: %d", rec.Code)
|
||||
}
|
||||
if rec := do(t, s, "POST", "/api/auth/refresh", "",
|
||||
map[string]string{"refresh_token": sess.RefreshToken}); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh still works after logout: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tenancy
|
||||
|
||||
func TestTenantComesFromTheSessionNotTheRequest(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
// A client_id in the query string must not steer the report.
|
||||
do(t, s, "GET", "/api/reports/footfall?client_id=client-rival&site=", sess.Token, nil)
|
||||
if fs.lastReport.ClientID != "client-acme" {
|
||||
t.Fatalf("report ran against %q - a caller-supplied tenant was honoured",
|
||||
fs.lastReport.ClientID)
|
||||
}
|
||||
|
||||
do(t, s, "GET", "/api/visitors?q=x", sess.Token, nil)
|
||||
if fs.lastReport.ClientID != "client-acme" {
|
||||
t.Fatalf("visitor search ran against %q", fs.lastReport.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileWriteUsesThePathIdNotTheBody(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "PUT", visitorA + "/profile", sess.Token, Profile{
|
||||
// A body id pointing at somebody else's record.
|
||||
VisitorID: visitorB,
|
||||
FullName: "Asha Menon",
|
||||
Consent: true,
|
||||
})
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("save profile returned %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fs.lastProfile.VisitorID != visitorAID {
|
||||
t.Fatalf("wrote to %q - the body overrode the URL",
|
||||
fs.lastProfile.VisitorID)
|
||||
}
|
||||
if fs.lastProfileClient != "client-acme" {
|
||||
t.Fatalf("wrote into tenant %q", fs.lastProfileClient)
|
||||
}
|
||||
// Naming a face is the moment ordinary PII gets attached to a biometric
|
||||
// template, so it has to leave a trace.
|
||||
var found bool
|
||||
for _, a := range fs.audits {
|
||||
if a.Action == "profile.save" && a.EntityID == visitorAID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("profile save was not audited: %+v", fs.audits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffCanWriteProfilesButAViewerCannot(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addUser("viewer@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u3", ClientID: "client-acme", Role: "viewer", Active: true,
|
||||
})
|
||||
sess := login(t, s, "viewer@acme.com", "correct horse battery")
|
||||
rec := do(t, s, "PUT", visitorA + "/profile", sess.Token,
|
||||
Profile{FullName: "Someone"})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("an unknown role could write a profile: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- reports
|
||||
|
||||
func TestReportWindowValidation(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
bad := []string{
|
||||
"/api/reports/footfall?bucket=fortnight",
|
||||
"/api/reports/footfall?tz=Mars/Olympus",
|
||||
"/api/reports/footfall?from=2026-03-01&to=2026-02-01",
|
||||
"/api/reports/footfall?from=not-a-date",
|
||||
"/api/reports/footfall?from=2000-01-01&to=2026-01-01",
|
||||
}
|
||||
for _, path := range bad {
|
||||
if rec := do(t, s, "GET", path, sess.Token, nil); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s returned %d, want 400", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInclusiveEndDateIncludesItsOwnLastDay(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
do(t, s, "GET", "/api/reports/footfall?from=2026-08-01&to=2026-08-07", sess.Token, nil)
|
||||
// "1st to the 7th" means the 7th is in the report. Without the conversion
|
||||
// the range stops at midnight on the 7th and quietly loses a day's trade.
|
||||
want := time.Date(2026, 8, 8, 0, 0, 0, 0, time.UTC)
|
||||
if !fs.lastReport.To.Equal(want) {
|
||||
t.Fatalf("to = %s, want %s (exclusive end of the 7th)",
|
||||
fs.lastReport.To, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportDefaultsAreSensible(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
do(t, s, "GET", "/api/reports/footfall", sess.Token, nil)
|
||||
if fs.lastReport.Bucket != "day" || fs.lastReport.Timezone != "UTC" {
|
||||
t.Fatalf("defaults: %+v", fs.lastReport)
|
||||
}
|
||||
if d := fs.lastReport.To.Sub(fs.lastReport.From); d < 28*24*time.Hour {
|
||||
t.Fatalf("default window is %s, expected about a month", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFootfallReportCarriesItsOwnConfidence(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.footfall = []FootfallPoint{{Bucket: "2026-08-01T00:00:00", Visitors: 9, New: 4, Returning: 5}}
|
||||
fs.totals = Totals{UniqueVisitors: 7, Visits: 9, FractionBelowGate: 0.727, WorstSite: "Chennai"}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/reports/footfall", sess.Token, nil)
|
||||
var got FootfallReport
|
||||
json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck
|
||||
|
||||
// A footfall figure from a badly placed camera is wrong in a way the figure
|
||||
// itself cannot show. Measured on Office1 this was 0.727 - 73% of visitors
|
||||
// seen and discarded - while the report looked like a quiet week.
|
||||
if got.FractionBelowGate != 0.727 || got.WorstSite != "Chennai" {
|
||||
t.Fatalf("confidence not reported: %+v", got)
|
||||
}
|
||||
// Unique people over the window, not the sum of the buckets: a customer who
|
||||
// came twice is one person and two bucket-visitors.
|
||||
if got.Total != 7 || got.Visits != 9 {
|
||||
t.Fatalf("total=%d visits=%d, want 7 and 9", got.Total, got.Visits)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- purchases
|
||||
|
||||
func TestPurchaseValidation(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body PurchaseInput
|
||||
}{
|
||||
{"no visitor", PurchaseInput{Amount: 100}},
|
||||
// A refund is a different record with a different meaning. Allowing a
|
||||
// negative here silently deflates the revenue figure the conversion
|
||||
// report is judged by.
|
||||
{"negative amount", PurchaseInput{VisitorID: visitorAID, Amount: -50}},
|
||||
{"bad currency", PurchaseInput{VisitorID: visitorAID, Amount: 10, Currency: "rupees"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if rec := do(t, s, "POST", "/api/purchases", sess.Token, tc.body); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: got %d, want 400", tc.name, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
if rec := do(t, s, "POST", "/api/purchases", sess.Token,
|
||||
PurchaseInput{VisitorID: visitorAID, Amount: 1499.50}); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("valid purchase returned %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fs.lastPurchase.Currency != "INR" || fs.lastPurchase.Source != "manual" {
|
||||
t.Fatalf("defaults not applied: %+v", fs.lastPurchase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPurchaseForAVisitorWithNoSiteExplainsWhatToDo(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.purchaseErr = errNoSite
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/purchases", sess.Token,
|
||||
PurchaseInput{VisitorID: visitorAID, Amount: 10})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("got %d, want 400", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "site_id") {
|
||||
t.Fatalf("the error does not say how to fix it: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- enrolment
|
||||
|
||||
func TestEnrolmentHandsOutCredentialsExactlyOnce(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
code := "ABCDEF-123456"
|
||||
fs.enrolment[hashHex(code)] = Enrolment{
|
||||
ClientID: "client-acme", SiteID: "site-1", SiteName: "Chennai",
|
||||
SiteSlug: "store1", MQTTUser: "acme.store1", MQTTPass: "broker-secret",
|
||||
}
|
||||
s.Bootstrap = BootstrapConfig{MQTTURL: "tls://mcp.loyaly.ai:8883", CACert: "-----BEGIN"}
|
||||
|
||||
rec := do(t, s, "POST", "/api/agent/enrol", "",
|
||||
map[string]string{"site_token": "abcdef 123456"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("enrol failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck
|
||||
if got["mqtt_password"] != "broker-secret" || got["mqtt_username"] != "acme.store1" {
|
||||
t.Fatalf("credentials missing: %v", got)
|
||||
}
|
||||
if got["models"] == nil {
|
||||
t.Fatal("models must be an empty list, not null, so the agent can " +
|
||||
"fall back to its own list without special-casing")
|
||||
}
|
||||
|
||||
// Second use of the same code must fail: it is read aloud, pasted into
|
||||
// chat and photographed.
|
||||
again := do(t, s, "POST", "/api/agent/enrol", "",
|
||||
map[string]string{"site_token": code})
|
||||
if again.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a spent code was accepted again: %d", again.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrolmentFailuresAreIndistinguishable(t *testing.T) {
|
||||
s, _ := newServer(t)
|
||||
rec := do(t, s, "POST", "/api/agent/enrol", "",
|
||||
map[string]string{"site_token": "NOPE-NOPE-NOPE"})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("got %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
// Unknown, expired and already-used must read the same. The difference only
|
||||
// helps somebody guessing codes; the operator's next step is identical.
|
||||
for _, leak := range []string{"expired", "used", "unknown"} {
|
||||
if strings.Contains(strings.ToLower(body), leak) {
|
||||
t.Fatalf("the message says which failure it was: %s", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- plumbing
|
||||
|
||||
func TestUnknownBodyFieldsAreRejected(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/purchases",
|
||||
strings.NewReader(`{"visitor_id":"`+visitorAID+`","amount":10,"client_id":"client-rival"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+sess.Token)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Routes().ServeHTTP(rec, req)
|
||||
// Silently ignoring it would let a caller believe the field took effect.
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("an unknown field was accepted: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongMethodIsNotAConfusing404(t *testing.T) {
|
||||
s, _ := newServer(t)
|
||||
rec := do(t, s, "GET", "/api/auth/login", "", nil)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("got %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEndpointsReturnAnEmptyArrayNotNull(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
for _, path := range []string{"/api/visitors", "/api/sites",
|
||||
visitorA + "/history"} {
|
||||
rec := do(t, s, "GET", path, sess.Token, nil)
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != "[]" {
|
||||
t.Errorf("%s returned %q - a null makes every caller handle "+
|
||||
"two empty cases", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerErrorsDoNotLeakInternals(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.profileErr = errBoom
|
||||
s.Log = nil // errors must not be echoed to the caller either way
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "PUT", visitorA + "/profile", sess.Token,
|
||||
Profile{FullName: "Asha"})
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("got %d", rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "relation") ||
|
||||
strings.Contains(rec.Body.String(), "boom") {
|
||||
t.Fatalf("database detail reached the client: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// A shop is one NAT address shared by every member of staff, so the per-IP
|
||||
// limit has to be far looser than the per-account one or a single person
|
||||
// fumbling their password locks the whole floor out.
|
||||
func TestOneStaffMemberLockingThemselvesOutDoesNotLockOutTheShop(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.addUser("colleague@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u9", ClientID: "client-acme", Role: "staff", Active: true,
|
||||
})
|
||||
s.Throttle = NewThrottle(3, time.Minute)
|
||||
s.IPThrottle = NewThrottle(60, time.Minute)
|
||||
|
||||
// One person gets their own password wrong until they are locked out.
|
||||
for i := 0; i < 4; i++ {
|
||||
do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "manager@acme.com", "password": "wrong"})
|
||||
}
|
||||
locked := do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "manager@acme.com", "password": "correct horse battery"})
|
||||
if locked.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("the account was not locked: %d", locked.Code)
|
||||
}
|
||||
// Their colleague, on the same address, must still be able to work.
|
||||
ok := do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "colleague@acme.com", "password": "correct horse battery"})
|
||||
if ok.Code != http.StatusOK {
|
||||
t.Fatalf("a colleague on the same address was locked out too: %d %s",
|
||||
ok.Code, ok.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The per-IP limiter still has to exist: without it one guess sprayed across
|
||||
// every address at a site costs an attacker nothing.
|
||||
func TestSprayingManyAddressesFromOneSourceIsStillStopped(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
s.Throttle = NewThrottle(10, time.Minute)
|
||||
s.IPThrottle = NewThrottle(5, time.Minute)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "person" + itoa(i) + "@acme.com", "password": "Summer2026!"})
|
||||
}
|
||||
blocked := do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": "manager@acme.com", "password": "correct horse battery"})
|
||||
if blocked.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("spraying five addresses from one source was not throttled: %d",
|
||||
blocked.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A mistyped id must not read as a server fault. `$1::uuid` on a malformed
|
||||
// string is a Postgres cast error, so without a shape check every typo'd URL
|
||||
// answers "something went wrong at our end" and sends an operator looking for
|
||||
// an outage that is not there.
|
||||
func TestMalformedVisitorIdsAreNotFoundNotServerErrors(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
for _, id := range []string{
|
||||
"not-a-uuid",
|
||||
"98bf7587-4d55-4ae0-99e0",
|
||||
"98bf7587-4d55-4ae0-99e0-de8c05dd3e7z",
|
||||
"%27%3B%20DROP%20TABLE%20visits%3B%20--",
|
||||
} {
|
||||
hist := do(t, s, "GET", "/api/visitors/"+id+"/history", sess.Token, nil)
|
||||
if hist.Code != http.StatusNotFound {
|
||||
t.Errorf("history %q returned %d, want 404", id, hist.Code)
|
||||
}
|
||||
prof := do(t, s, "PUT", "/api/visitors/"+id+"/profile", sess.Token,
|
||||
Profile{FullName: "Asha"})
|
||||
if prof.Code != http.StatusNotFound {
|
||||
t.Errorf("profile %q returned %d, want 404", id, prof.Code)
|
||||
}
|
||||
}
|
||||
// A well-formed id must still reach the store.
|
||||
ok := do(t, s, "PUT", "/api/visitors/98bf7587-4d55-4ae0-99e0-de8c05dd3e78/profile",
|
||||
sess.Token, Profile{FullName: "Asha"})
|
||||
if ok.Code != http.StatusNoContent {
|
||||
t.Fatalf("a valid id was rejected: %d %s", ok.Code, ok.Body.String())
|
||||
}
|
||||
|
||||
pur := do(t, s, "POST", "/api/purchases", sess.Token,
|
||||
PurchaseInput{VisitorID: "not-a-uuid", Amount: 10})
|
||||
if pur.Code != http.StatusNotFound {
|
||||
t.Fatalf("purchase with a malformed id returned %d, want 404", pur.Code)
|
||||
}
|
||||
}
|
||||
643
server/internal/api/arrivals_test.go
Normal file
643
server/internal/api/arrivals_test.go
Normal file
@@ -0,0 +1,643 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const siteMain = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
|
||||
func base() time.Time { return time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC) }
|
||||
|
||||
// seedArrivals lays down n visits one second apart, each with a photo.
|
||||
func seedArrivals(fs *fakeStore, n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
fs.arrivals = append(fs.arrivals, Arrival{
|
||||
VisitID: fmt.Sprintf("00000000-0000-0000-0000-%012d", i),
|
||||
Seq: int64(i + 1),
|
||||
OccurredAt: base().Add(time.Duration(i) * time.Second).Format(time.RFC3339Nano),
|
||||
SiteID: siteMain,
|
||||
Site: "Anna Nagar",
|
||||
CameraID: "door",
|
||||
VisitorID: fmt.Sprintf("11111111-0000-0000-0000-%012d", i),
|
||||
Label: fmt.Sprintf("Visitor %d", i),
|
||||
ImageKey: fmt.Sprintf("behavision/v2/acme/main/2026/09/02/%d.jpg", i),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getPage(t *testing.T, s *Server, path, token string) ArrivalPage {
|
||||
t.Helper()
|
||||
rec := do(t, s, "GET", path, token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s: got %d, body %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
var page ArrivalPage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
// Four people walking through a door together is the case this endpoint was
|
||||
// built for, and the one that used to take nine requests to render: a search
|
||||
// that could not tell you who arrived, then one image call per person.
|
||||
func TestFourPeopleArrivingTogetherComeBackInOneRequest(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
// All four in the same millisecond - a burst is exactly when this happens.
|
||||
for i := 0; i < 4; i++ {
|
||||
fs.arrivals = append(fs.arrivals, Arrival{
|
||||
VisitID: fmt.Sprintf("00000000-0000-0000-0000-%012d", i),
|
||||
Seq: int64(i + 1),
|
||||
OccurredAt: base().Format(time.RFC3339Nano),
|
||||
SiteID: siteMain, Site: "Anna Nagar",
|
||||
VisitorID: fmt.Sprintf("11111111-0000-0000-0000-%012d", i),
|
||||
ImageKey: fmt.Sprintf("k%d.jpg", i),
|
||||
})
|
||||
}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
if len(page.Arrivals) != 4 {
|
||||
t.Fatalf("want 4 arrivals in one response, got %d", len(page.Arrivals))
|
||||
}
|
||||
for i, a := range page.Arrivals {
|
||||
if !a.Image.Available || a.Image.URL == "" {
|
||||
t.Errorf("arrival %d has no photo link: %+v", i, a.Image)
|
||||
}
|
||||
}
|
||||
// Identical timestamps must still produce a usable cursor, or a burst
|
||||
// wedges the feed forever at the same instant.
|
||||
if page.Cursor == "" {
|
||||
t.Fatal("a burst at one instant produced no cursor")
|
||||
}
|
||||
seq, err := decodeCursor(page.Cursor)
|
||||
if err != nil {
|
||||
t.Fatalf("cursor from a burst is unreadable: %v", err)
|
||||
}
|
||||
if seq != page.Arrivals[3].Seq {
|
||||
t.Errorf("cursor should point at the LAST row of the burst, got %d want %d",
|
||||
seq, page.Arrivals[3].Seq)
|
||||
}
|
||||
}
|
||||
|
||||
// The property the whole feed rests on: poll twice and you see every person
|
||||
// exactly once, even when more arrive than fit in one page.
|
||||
func TestCursorLosesNobodyAndRepeatsNobody(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 25)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
seen := map[string]int{}
|
||||
cursor := ""
|
||||
for poll := 0; poll < 5; poll++ {
|
||||
path := "/api/visits?limit=10"
|
||||
if cursor != "" {
|
||||
path += "&cursor=" + cursor
|
||||
} else {
|
||||
// Start from position zero so the walk covers every row rather
|
||||
// than starting at the newest window.
|
||||
path += "&cursor=" + encodeCursor(0) + "&limit=10"
|
||||
}
|
||||
page := getPage(t, s, path, sess.Token)
|
||||
for _, a := range page.Arrivals {
|
||||
seen[a.VisitID]++
|
||||
}
|
||||
cursor = page.Cursor
|
||||
}
|
||||
|
||||
if len(seen) != 25 {
|
||||
t.Fatalf("walked the feed and saw %d of 25 people", len(seen))
|
||||
}
|
||||
for id, n := range seen {
|
||||
if n != 1 {
|
||||
t.Errorf("visit %s delivered %d times, want exactly 1", id, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An app that has just opened wants the last few arrivals, not the first few
|
||||
// ever recorded - but still ascending, so its cursor handling is the same on
|
||||
// the first poll as on every one after.
|
||||
func TestFirstPollReturnsTheNewestWindowAscending(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 25)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits?limit=5", sess.Token)
|
||||
if len(page.Arrivals) != 5 {
|
||||
t.Fatalf("want 5, got %d", len(page.Arrivals))
|
||||
}
|
||||
if got := page.Arrivals[0].VisitID; !strings.HasSuffix(got, "020") {
|
||||
t.Errorf("first poll should start at the 21st of 25 rows, got %s", got)
|
||||
}
|
||||
for i := 1; i < len(page.Arrivals); i++ {
|
||||
if page.Arrivals[i-1].OccurredAt >= page.Arrivals[i].OccurredAt {
|
||||
t.Fatalf("feed is not ascending at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A quiet minute must not reset the feed. Handing back an empty cursor would
|
||||
// make the next poll re-deliver the whole recent window.
|
||||
func TestAnEmptyPollHandsTheCursorBack(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 3)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
first := getPage(t, s, "/api/visits", sess.Token)
|
||||
again := getPage(t, s, "/api/visits?cursor="+first.Cursor, sess.Token)
|
||||
|
||||
if len(again.Arrivals) != 0 {
|
||||
t.Fatalf("nothing new arrived, got %d rows", len(again.Arrivals))
|
||||
}
|
||||
if again.Cursor != first.Cursor {
|
||||
t.Errorf("empty poll moved the cursor: %q -> %q", first.Cursor, again.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
// The object key names a tenant's storage prefix and is the input to every
|
||||
// signing call. It must be structurally incapable of reaching a client.
|
||||
func TestTheObjectKeyIsNeverInTheResponse(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 3)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/visits", sess.Token, nil)
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, `"image_key"`) || strings.Contains(body, "ImageKey") {
|
||||
t.Fatalf("the response carries an object key:\n%s", body)
|
||||
}
|
||||
// The signed URL legitimately contains the key; what must not appear is a
|
||||
// bare key in its own field.
|
||||
if !strings.Contains(body, "X-Amz-Signature") {
|
||||
t.Fatalf("expected signed links in the response:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Photos are off by default across the product, so "no photo" is the normal
|
||||
// case and must not read as a fault. Two different absences need two different
|
||||
// sentences, because a shop can fix one of them and not the other.
|
||||
func TestNoPhotoIsDataNotAnError(t *testing.T) {
|
||||
t.Run("images switched off for the deployment", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = nil
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
got := page.Arrivals[0].Image
|
||||
if got.Available || got.Reason == "" {
|
||||
t.Fatalf("want an unavailable photo with a reason, got %+v", got)
|
||||
}
|
||||
if !strings.Contains(got.Reason, "not storing") {
|
||||
t.Errorf("reason should say the system stores no photos, got %q", got.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("this visit simply had none", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1)
|
||||
fs.arrivals[0].ImageKey = ""
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
got := page.Arrivals[0].Image
|
||||
if got.Available {
|
||||
t.Fatalf("there is no key, so there is no photo: %+v", got)
|
||||
}
|
||||
if !strings.Contains(got.Reason, "No photo") {
|
||||
t.Errorf("want a per-visit reason, got %q", got.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A visit with no visitor_id is a site reporting footfall without templates.
|
||||
// It is a real person walking in and must appear, or the feed disagrees with
|
||||
// the footfall report about how many people came.
|
||||
func TestAnUnidentifiedVisitStillAppears(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1)
|
||||
fs.arrivals[0].VisitorID = ""
|
||||
fs.arrivals[0].Label = ""
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
if len(page.Arrivals) != 1 {
|
||||
t.Fatalf("an anonymous visit was dropped from the feed")
|
||||
}
|
||||
}
|
||||
|
||||
// A tablet polling every two seconds would write tens of thousands of audit
|
||||
// rows a day and bury the one deliberate look an investigation is after.
|
||||
func TestTheFeedWritesOneAuditRowPerPageNotPerPhoto(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 6)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
getPage(t, s, "/api/visits", sess.Token)
|
||||
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
var views []AuditEntry
|
||||
for _, a := range fs.audits {
|
||||
if strings.HasPrefix(a.Action, "image.view") {
|
||||
views = append(views, a)
|
||||
}
|
||||
}
|
||||
if len(views) != 1 {
|
||||
t.Fatalf("want exactly 1 audit row for a page of 6 photos, got %d", len(views))
|
||||
}
|
||||
if got := views[0].Detail["count"]; got != 6 {
|
||||
t.Errorf("the audit row should record how many faces were surfaced, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is audited when no face was actually shown - otherwise the log fills
|
||||
// with rows recording that somebody looked at nothing.
|
||||
func TestAQuietPollWritesNoAuditRow(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
getPage(t, s, "/api/visits", sess.Token)
|
||||
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
for _, a := range fs.audits {
|
||||
if strings.HasPrefix(a.Action, "image.view") {
|
||||
t.Fatalf("audited an image view with no images: %+v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tenant comes from the session. A site_id in the query string is
|
||||
// caller-controlled and is a cross-tenant read the moment it is trusted alone.
|
||||
func TestTheFeedIsScopedToTheSessionsTenant(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 3)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
getPage(t, s, "/api/visits?site_id="+siteMain, sess.Token)
|
||||
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if fs.arrivalQ.ClientID != "client-acme" {
|
||||
t.Fatalf("query ran for client %q, want the session's own", fs.arrivalQ.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFeedNeedsASession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
if rec := do(t, s, "GET", "/api/visits", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401 without a token, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed cursor is the caller's, not a server fault, and the message has
|
||||
// to tell a client how to recover - it cannot parse the cursor to fix it.
|
||||
func TestARubbishCursorIsARecoverableBadRequest(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
for _, bad := range []string{"not-base64!!", "Zm9v",
|
||||
base64.RawURLEncoding.EncodeToString([]byte("v1:banana"))} {
|
||||
rec := do(t, s, "GET", "/api/visits?cursor="+bad, sess.Token, nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("cursor %q: got %d, want 400", bad, rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "poll again without one") {
|
||||
t.Errorf("cursor %q: message does not say how to recover: %s", bad, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The id goes into $4::uuid. A malformed one is a Postgres cast error, which
|
||||
// surfaces as a 500 on a value the caller supplied.
|
||||
// A cursor whose body is not a number must be refused rather than reaching the
|
||||
// query, and a cursor from a FUTURE encoding must fail cleanly rather than being
|
||||
// misread as a position that now means something else.
|
||||
func TestOnlyAWellFormedV1CursorIsAccepted(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
base64.RawURLEncoding.EncodeToString([]byte("v1:not-a-number")),
|
||||
base64.RawURLEncoding.EncodeToString([]byte("v1:-5")),
|
||||
base64.RawURLEncoding.EncodeToString([]byte("v2:12")),
|
||||
base64.RawURLEncoding.EncodeToString([]byte("12")),
|
||||
base64.RawURLEncoding.EncodeToString([]byte("v1:'; DROP TABLE visits; --")),
|
||||
} {
|
||||
if _, err := decodeCursor(bad); err == nil {
|
||||
t.Errorf("accepted a malformed cursor: %q", bad)
|
||||
}
|
||||
}
|
||||
if seq, err := decodeCursor(encodeCursor(41)); err != nil || seq != 41 {
|
||||
t.Fatalf("a cursor did not round-trip: %d %v", seq, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASiteFilterMustBeASiteID(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/visits?site_id=main", sess.Token, nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for a non-uuid site_id, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitIsCappedRatherThanRejected(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
// A client coming back from a tunnel asking for everything gets a big page
|
||||
// and a fresh cursor, not an error it cannot recover from.
|
||||
getPage(t, s, "/api/visits?limit=100000", sess.Token)
|
||||
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if fs.arrivalQ.Limit != maxArrivals {
|
||||
t.Fatalf("limit %d, want it capped at %d", fs.arrivalQ.Limit, maxArrivals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADatabaseFailureIsAServerErrorNotAnEmptyFeed(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.arrivalsErr = errors.New("connection refused")
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/visits", sess.Token, nil)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
// An empty 200 here would tell a shop nobody came in.
|
||||
t.Fatalf("want 500, got %d", rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "connection refused") {
|
||||
t.Error("the database error leaked to the client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrivalsIsAlwaysAListNeverNull(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/visits", sess.Token, nil)
|
||||
if !strings.Contains(rec.Body.String(), `"arrivals":[]`) {
|
||||
t.Fatalf("a quiet feed must be an empty list: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- streaming
|
||||
|
||||
func TestTheStreamSendsWhatIsAlreadyThereThenRespondsToTheDoorbell(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
s.Hub = NewHub()
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 2)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
req := httptest.NewRequest("GET", "/api/visits/stream", nil).WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+sess.Token)
|
||||
rec := newStreamRecorder()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); s.Routes().ServeHTTP(rec, req) }()
|
||||
|
||||
// The first frame is the catch-up: a client connecting after people have
|
||||
// walked in must not be blind until the next person arrives.
|
||||
waitFor(t, rec, "event: arrivals")
|
||||
if h := rec.Header().Get("Content-Type"); h != "text/event-stream" {
|
||||
t.Errorf("Content-Type %q", h)
|
||||
}
|
||||
if h := rec.Header().Get("X-Accel-Buffering"); h != "no" {
|
||||
t.Error("without this the proxy buffers the stream into a single response at the end")
|
||||
}
|
||||
if !strings.Contains(rec.body(), "id: ") {
|
||||
t.Error("no SSE id, so a reconnect cannot resume via Last-Event-ID")
|
||||
}
|
||||
|
||||
before := len(rec.body())
|
||||
fs.mu.Lock()
|
||||
fs.arrivals = append(fs.arrivals, Arrival{
|
||||
VisitID: "00000000-0000-0000-0000-000000000099", Seq: 99,
|
||||
OccurredAt: base().Add(time.Hour).Format(time.RFC3339Nano),
|
||||
SiteID: siteMain, VisitorID: "11111111-0000-0000-0000-000000000099",
|
||||
})
|
||||
fs.mu.Unlock()
|
||||
s.Hub.Notify("client-acme")
|
||||
|
||||
waitForGrowth(t, rec, before)
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
// One process serves every tenant. A doorbell for another shop must not make
|
||||
// this connection query, let alone emit anything.
|
||||
func TestAnotherTenantsDoorbellIsIgnored(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Hub = NewHub()
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
req := httptest.NewRequest("GET", "/api/visits/stream", nil).WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+sess.Token)
|
||||
rec := newStreamRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); s.Routes().ServeHTTP(rec, req) }()
|
||||
|
||||
waitForSubscriber(t, s.Hub)
|
||||
fs.mu.Lock()
|
||||
fs.arrivals = append(fs.arrivals, Arrival{
|
||||
VisitID: "00000000-0000-0000-0000-000000000001", Seq: 1,
|
||||
OccurredAt: base().Format(time.RFC3339Nano), SiteID: siteMain,
|
||||
})
|
||||
queriesBefore := fs.arrivalCalls
|
||||
fs.mu.Unlock()
|
||||
|
||||
s.Hub.Notify("client-someone-else")
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
fs.mu.Lock()
|
||||
after := fs.arrivalCalls
|
||||
fs.mu.Unlock()
|
||||
if after != queriesBefore {
|
||||
t.Fatalf("another tenant's doorbell caused %d queries", after-queriesBefore)
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestTheStreamNeedsASession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
if rec := do(t, s, "GET", "/api/visits/stream", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A subscriber that is not released leaks a goroutine and a channel for the
|
||||
// life of the process, on an endpoint mobile clients reconnect to all day.
|
||||
func TestReleasingASubscriberRemovesIt(t *testing.T) {
|
||||
h := NewHub()
|
||||
_, release := h.Subscribe()
|
||||
if h.Subscribers() != 1 {
|
||||
t.Fatalf("want 1 subscriber, got %d", h.Subscribers())
|
||||
}
|
||||
release()
|
||||
if h.Subscribers() != 0 {
|
||||
t.Fatalf("subscriber leaked: %d still registered", h.Subscribers())
|
||||
}
|
||||
release() // must be safe twice: defer plus an early return is normal
|
||||
}
|
||||
|
||||
// A doorbell is idempotent, so a busy listener that misses one loses nothing -
|
||||
// but Notify must never block waiting for it, or one slow subscriber stalls
|
||||
// ingest for the whole estate.
|
||||
func TestNotifyNeverBlocksOnASlowSubscriber(t *testing.T) {
|
||||
h := NewHub()
|
||||
ch, release := h.Subscribe()
|
||||
defer release()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 1000; i++ {
|
||||
h.Notify("client-acme")
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Notify blocked - ingest would stall behind a slow reader")
|
||||
}
|
||||
if len(ch) != 1 {
|
||||
t.Errorf("want the single pending doorbell, got %d", len(ch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyOnANilHubIsSafe(t *testing.T) {
|
||||
var h *Hub
|
||||
h.Notify("client-acme") // a server assembled without one must still ingest
|
||||
if h.Subscribers() != 0 {
|
||||
t.Fatal("nil hub reported subscribers")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
// streamRecorder is a ResponseWriter a test can read WHILE the handler is
|
||||
// still writing to it. httptest.ResponseRecorder cannot be: the handler runs on
|
||||
// its own goroutine for the life of the stream, so every Body.String() from the
|
||||
// test is a data race that -race turns into a failure and, without it, into an
|
||||
// occasional mystery.
|
||||
//
|
||||
// It implements Flusher because the handler refuses to stream without one - a
|
||||
// recorder that silently lacked it would make these tests exercise the error
|
||||
// path while appearing to pass.
|
||||
type streamRecorder struct {
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
hdr http.Header
|
||||
code int
|
||||
pushed chan struct{}
|
||||
}
|
||||
|
||||
func newStreamRecorder() *streamRecorder {
|
||||
return &streamRecorder{hdr: http.Header{}, code: 200,
|
||||
pushed: make(chan struct{}, 64)}
|
||||
}
|
||||
|
||||
func (r *streamRecorder) Header() http.Header { return r.hdr }
|
||||
|
||||
func (r *streamRecorder) Write(b []byte) (int, error) {
|
||||
r.mu.Lock()
|
||||
n, err := r.buf.Write(b)
|
||||
r.mu.Unlock()
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *streamRecorder) WriteHeader(code int) { r.code = code }
|
||||
|
||||
func (r *streamRecorder) Flush() {
|
||||
// Signals the test that a frame is complete, so it can wait on an event
|
||||
// rather than on a sleep long enough to hide a real stall.
|
||||
select {
|
||||
case r.pushed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (r *streamRecorder) body() string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.buf.String()
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, rec *streamRecorder, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if strings.Contains(rec.body(), want) {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("never saw %q in the stream:\n%s", want, rec.body())
|
||||
}
|
||||
|
||||
func waitForGrowth(t *testing.T, rec *streamRecorder, was int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(rec.body()) > was {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("the doorbell did not push a new arrival within 2s")
|
||||
}
|
||||
|
||||
func waitForSubscriber(t *testing.T, h *Hub) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if h.Subscribers() > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("stream never subscribed to the hub")
|
||||
}
|
||||
331
server/internal/api/cameras_test.go
Normal file
331
server/internal/api/cameras_test.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const siteA = "aaaaaaaa-1111-2222-3333-444444444444"
|
||||
|
||||
// camPath addresses the camera the fake store creates for a given name.
|
||||
func camPath(cameraID string) string { return "/api/cameras/" + fakeCameraUUID(cameraID) }
|
||||
|
||||
// ---------------------------------------------------------------- the boundary
|
||||
|
||||
// The single most important assertion in this file. An RTSP credential is a
|
||||
// live path into the camera itself, and the only consumer that legitimately
|
||||
// needs the plaintext is the agent for its own site.
|
||||
func TestACameraPasswordIsNeverReturnedToAPerson(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
|
||||
"camera_id": "entrance", "label": "Entrance",
|
||||
"host": "192.168.0.138", "username": "admin", "password": "hunter2",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "hunter2") {
|
||||
t.Fatalf("the camera password came back:\n%s", rec.Body.String())
|
||||
}
|
||||
|
||||
list := do(t, s, "GET", "/api/cameras", sess.Token, nil)
|
||||
if strings.Contains(list.Body.String(), "hunter2") {
|
||||
t.Fatalf("the camera password is in the list:\n%s", list.Body.String())
|
||||
}
|
||||
// The operator still has to be able to tell "no password set" from "a
|
||||
// password is set and I am simply not being shown it".
|
||||
if !strings.Contains(list.Body.String(), `"has_password":true`) {
|
||||
t.Errorf("no indication a password is stored:\n%s", list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The agent is the one caller that gets it, and only for its own site.
|
||||
func TestTheAgentReceivesThePasswordItNeedsToConnect(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{
|
||||
AgentID: "a1", ClientID: "client-acme", Site: siteA, SiteID: siteA})
|
||||
fs.agentCameras = []AgentCamera{{
|
||||
CameraID: "entrance", Host: "192.168.0.138", Port: 554,
|
||||
Username: "admin", Password: "hunter2", Enabled: true, Revision: 1,
|
||||
}}
|
||||
req := do(t, s, "GET", "/api/agent/cameras", "agent-token", nil)
|
||||
if req.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", req.Code, req.Body.String())
|
||||
}
|
||||
if !strings.Contains(req.Body.String(), "hunter2") {
|
||||
t.Fatal("the agent did not get the password, so it cannot connect")
|
||||
}
|
||||
}
|
||||
|
||||
// An agent has no user, no role and no session. A person's token must not open
|
||||
// the agent routes, and vice versa.
|
||||
func TestAgentRoutesRefuseAUserSession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
for _, call := range [][2]string{
|
||||
{"GET", "/api/agent/cameras"},
|
||||
{"POST", "/api/agent/cameras"},
|
||||
} {
|
||||
rec := do(t, s, call[0], call[1], sess.Token, AgentCameraReport{})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s: got %d, want 401", call[0], call[1], rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCameraRoutesNeedASession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
if rec := do(t, s, "GET", "/api/cameras", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- editing
|
||||
|
||||
// The camera id is what visits are recorded against. Renaming it would orphan
|
||||
// every visit already attributed to the old name.
|
||||
func TestEditingACameraCannotRenameTheIdVisitsAreRecordedAgainst(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
|
||||
"camera_id": "entrance", "host": "10.0.0.5"})
|
||||
|
||||
rec := do(t, s, "PATCH", camPath("entrance"), sess.Token, map[string]any{
|
||||
"camera_id": "back-door", "label": "Back door"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var cam Camera
|
||||
json.Unmarshal(rec.Body.Bytes(), &cam) //nolint:errcheck
|
||||
if cam.CameraID != "entrance" {
|
||||
t.Fatalf("the camera id was renamed to %q", cam.CameraID)
|
||||
}
|
||||
if cam.Label != "Back door" {
|
||||
t.Errorf("the label should be editable, got %q", cam.Label)
|
||||
}
|
||||
}
|
||||
|
||||
// A blank field means "leave alone". Sending an empty password on every edit is
|
||||
// how a camera loses its credential the first time somebody fixes a typo in the
|
||||
// label.
|
||||
func TestAnOmittedPasswordIsNotSentToTheStore(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
|
||||
"camera_id": "entrance", "host": "10.0.0.5", "password": "hunter2"})
|
||||
|
||||
do(t, s, "PATCH", camPath("entrance"), sess.Token,
|
||||
map[string]any{"label": "Front"})
|
||||
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if fs.lastSaved.Password != nil {
|
||||
t.Fatalf("an edit that did not mention the password sent %q", *fs.lastSaved.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// Staff can fill in a customer form; changing what a camera connects to is a
|
||||
// different kind of act.
|
||||
func TestStaffCannotChangeCameras(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addUser("staff@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-acme", Role: "staff", Active: true})
|
||||
sess := login(t, s, "staff@acme.com", "correct horse battery")
|
||||
|
||||
for _, call := range [][2]string{
|
||||
{"POST", "/api/sites/" + siteA + "/cameras"},
|
||||
{"PATCH", camPath("entrance")},
|
||||
{"DELETE", camPath("entrance")},
|
||||
} {
|
||||
rec := do(t, s, call[0], call[1], sess.Token, map[string]any{"host": "10.0.0.5"})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s: got %d, want 403", call[0], call[1], rec.Code)
|
||||
}
|
||||
}
|
||||
// Reading is fine - staff need to see whether a camera is working.
|
||||
if rec := do(t, s, "GET", "/api/cameras", sess.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Errorf("staff cannot see cameras at all: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- input
|
||||
|
||||
// The id ends up in an object key, a URL path and a topic segment.
|
||||
func TestACameraIdCannotChangeWhatAPathOrTopicMeans(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Front Entrance": "front-entrance",
|
||||
"ch0/0": "ch0-0",
|
||||
"a+b#c": "a-b-c",
|
||||
" Till 2 ": "till-2",
|
||||
"../../etc": "etc",
|
||||
"!!!": "",
|
||||
} {
|
||||
if got := cameraSlug(in); got != want {
|
||||
t.Errorf("cameraSlug(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The message has to say what to type, not name a field.
|
||||
func TestACameraWithNoAddressIsRefusedWithUsableAdvice(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token,
|
||||
map[string]any{"camera_id": "entrance"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "192.168") {
|
||||
t.Errorf("the message should show the shape of an address: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestACameraNeedsAName(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token,
|
||||
map[string]any{"host": "10.0.0.5"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A camera saved with its password silently dropped will not connect, and the
|
||||
// operator could not tell that from a wrong password.
|
||||
func TestSavingAPasswordWithNoEncryptionKeyFailsLoudly(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.saveCameraErr = ErrNoSecrets
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
|
||||
"camera_id": "entrance", "host": "10.0.0.5", "password": "hunter2"})
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("got %d, want 503: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "encryption key") {
|
||||
t.Errorf("the message does not name the cause: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshots
|
||||
|
||||
// Most deployments store no images at all, so "no picture" is the ordinary
|
||||
// case and must not read as a fault.
|
||||
func TestNoSnapshotIsDataNotAnError(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/cameras", sess.Token, nil)
|
||||
var cams []Camera
|
||||
json.Unmarshal(rec.Body.Bytes(), &cams) //nolint:errcheck
|
||||
if cams[0].Snapshot.Available {
|
||||
t.Fatal("claimed a picture with no key")
|
||||
}
|
||||
if cams[0].Snapshot.Reason == "" {
|
||||
t.Fatal("no reason given for the missing picture")
|
||||
}
|
||||
}
|
||||
|
||||
// A snapshot is a frame of a shop floor: a short-lived signed link, never a
|
||||
// stored URL, and never the raw key.
|
||||
func TestASnapshotIsASignedLinkAndTheKeyStaysHidden(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance",
|
||||
Snapshot: Image{Key: "behavision/v2/acme/main/snap.jpg"}}}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/cameras", sess.Token, nil)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "X-Amz-Signature") {
|
||||
t.Fatalf("no signed link: %s", body)
|
||||
}
|
||||
if strings.Contains(body, `"key"`) || strings.Contains(body, `"Key"`) {
|
||||
t.Fatalf("the raw object key is in the response: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- adoption
|
||||
|
||||
// The agent may report its own site's state; the site comes from its
|
||||
// credential, never from the body.
|
||||
func TestAnAgentReportIsScopedByItsOwnCredential(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{
|
||||
AgentID: "a1", ClientID: "client-acme", Site: siteA, SiteID: siteA})
|
||||
|
||||
rec := do(t, s, "POST", "/api/agent/cameras", "agent-token", AgentCameraReport{
|
||||
State: []AgentCameraState{{CameraID: "entrance", Connected: true}},
|
||||
Adopt: []AgentCamera{{CameraID: "Office Cam", Host: "192.168.0.138"}},
|
||||
})
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if got := fs.lastCameraReport.Adopt[0].CameraID; got != "office-cam" {
|
||||
t.Errorf("an adopted id was not normalised: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The create response must carry the same snapshot explanation the list does.
|
||||
// Decorating a copy and serialising the original returned an empty snapshot
|
||||
// object, so a freshly added camera showed no picture and no reason for it.
|
||||
func TestACreatedCameraExplainsItsMissingPicture(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token,
|
||||
map[string]any{"camera_id": "entrance", "host": "10.0.0.5"})
|
||||
var cam Camera
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &cam); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cam.Snapshot.Reason == "" {
|
||||
t.Fatalf("no reason for the missing picture:\n%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// AgentPrincipal carries both the tenant's uuid and its human slug, and the
|
||||
// slug is the one that reads correctly in a log line - which is exactly why it
|
||||
// gets used by mistake in a query that wants the uuid. This shipped once and
|
||||
// only failed against a real database.
|
||||
func TestAnAgentReportIsStoredAgainstTheTenantUUIDNotTheSlug(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{
|
||||
AgentID: "a1",
|
||||
ClientID: "8f1e0c2a-1111-2222-3333-444444444444", // the uuid
|
||||
Client: "nearle", // the slug
|
||||
SiteID: siteA, Site: "chennai",
|
||||
})
|
||||
do(t, s, "POST", "/api/agent/cameras", "agent-token", AgentCameraReport{
|
||||
State: []AgentCameraState{{CameraID: "entrance", Connected: true}}})
|
||||
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if fs.lastReportClient != "8f1e0c2a-1111-2222-3333-444444444444" {
|
||||
t.Fatalf("stored against %q - a slug will not cast to uuid", fs.lastReportClient)
|
||||
}
|
||||
if fs.lastReportSite != siteA {
|
||||
t.Fatalf("site %q", fs.lastReportSite)
|
||||
}
|
||||
}
|
||||
234
server/internal/api/checks_test.go
Normal file
234
server/internal/api/checks_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func stepsByName(steps []CheckStep) map[string]CheckStep {
|
||||
out := map[string]CheckStep{}
|
||||
for _, s := range steps {
|
||||
out[s.Name] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func healthySite() *SiteHealth {
|
||||
now := time.Now().UTC()
|
||||
return &SiteHealth{
|
||||
SiteID: siteA, Name: "Chennai", Online: true,
|
||||
LastHeartbeatAt: now.Add(-20 * time.Second).Format(time.RFC3339),
|
||||
LastEventAt: now.Add(-3 * time.Minute).Format(time.RFC3339),
|
||||
RecognitionModel: "w600k_r50.onnx",
|
||||
CamerasUp: 2, CamerasTotal: 2, FractionBelowGate: 0.11,
|
||||
}
|
||||
}
|
||||
|
||||
func connectedCameras(n int) []Camera {
|
||||
up := true
|
||||
out := make([]Camera, n)
|
||||
for i := range out {
|
||||
out[i] = Camera{ID: "c", SiteID: siteA, CameraID: "entrance", Connected: &up}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAHealthySitePassesEveryStep(t *testing.T) {
|
||||
steps := BuildSiteSteps(healthySite(), connectedCameras(2), time.Now().UTC())
|
||||
for _, s := range steps {
|
||||
if s.Status != "pass" {
|
||||
t.Errorf("%q: %s — %s", s.Name, s.Status, s.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the whole check: a site whose PC is off cannot be judged on
|
||||
// anything else, and printing guesses next to the real failure buries it.
|
||||
func TestAnOfflinePCStopsTheRestBeingJudged(t *testing.T) {
|
||||
site := healthySite()
|
||||
site.Online = false
|
||||
site.LastHeartbeatAt = time.Now().Add(-3 * time.Hour).UTC().Format(time.RFC3339)
|
||||
|
||||
steps := BuildSiteSteps(site, connectedCameras(2), time.Now().UTC())
|
||||
by := stepsByName(steps)
|
||||
|
||||
if by["The shop's PC is online"].Status != "fail" {
|
||||
t.Fatal("an offline PC was not reported as a failure")
|
||||
}
|
||||
for _, name := range []string{"Cameras are connected", "Cameras can recognise faces",
|
||||
"Visits are reaching head office"} {
|
||||
if got := by[name].Status; got != "unknown" {
|
||||
t.Errorf("%q reported %q on an offline PC - it cannot be known", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A PC that has never reported needs installing, not restarting. Different
|
||||
// sentence, different action.
|
||||
func TestAPCThatHasNeverReportedIsToldToInstall(t *testing.T) {
|
||||
site := healthySite()
|
||||
site.Online, site.LastHeartbeatAt = false, ""
|
||||
|
||||
by := stepsByName(BuildSiteSteps(site, nil, time.Now().UTC()))
|
||||
advice := by["The shop's PC is online"].Advice
|
||||
if !strings.Contains(advice, "enrolment code") {
|
||||
t.Fatalf("advice does not say how to claim the PC: %q", advice)
|
||||
}
|
||||
}
|
||||
|
||||
// The Office1 case, and the reason this check exists at all: everything is
|
||||
// plugged in, everything is online, and almost nobody is being recognised.
|
||||
func TestASiteWhereMostVisitorsAreMissedFailsEvenThoughEverythingIsOnline(t *testing.T) {
|
||||
site := healthySite()
|
||||
site.FractionBelowGate = 0.727 // the number measured on Office1
|
||||
|
||||
steps := BuildSiteSteps(site, connectedCameras(2), time.Now().UTC())
|
||||
by := stepsByName(steps)
|
||||
|
||||
if by["The shop's PC is online"].Status != "pass" {
|
||||
t.Error("the PC is fine and should say so")
|
||||
}
|
||||
faces := by["Cameras can recognise faces"]
|
||||
if faces.Status != "fail" {
|
||||
t.Fatalf("73%% of visitors missed was reported as %q", faces.Status)
|
||||
}
|
||||
if !strings.Contains(faces.Detail, "73%") {
|
||||
t.Errorf("the number is not shown: %q", faces.Detail)
|
||||
}
|
||||
if !strings.Contains(faces.Advice, "moving") {
|
||||
t.Errorf("advice does not say what to do: %q", faces.Advice)
|
||||
}
|
||||
// And the site as a whole must not read as working.
|
||||
ok := true
|
||||
for _, s := range steps {
|
||||
if s.Status != "pass" {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("a site missing 73% of its visitors was reported as working")
|
||||
}
|
||||
}
|
||||
|
||||
// A shop set up before opening has seen nobody. That is not a fault, and
|
||||
// calling it one sends an installer looking for a problem that is not there.
|
||||
func TestAShopThatHasSeenNobodyYetIsUnknownNotBroken(t *testing.T) {
|
||||
site := healthySite()
|
||||
site.LastEventAt = ""
|
||||
site.FractionBelowGate = 0
|
||||
|
||||
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
|
||||
if got := by["Cameras can recognise faces"].Status; got != "unknown" {
|
||||
t.Errorf("a new shop reported %q, want unknown", got)
|
||||
}
|
||||
if got := by["Visits are reaching head office"].Status; got != "unknown" {
|
||||
t.Errorf("no visits yet reported %q, want unknown", got)
|
||||
}
|
||||
// But it must still tell them how to prove it before opening.
|
||||
if !strings.Contains(by["Cameras can recognise faces"].Advice, "walk-past") {
|
||||
t.Error("no advice on how to prove the camera before the shop opens")
|
||||
}
|
||||
}
|
||||
|
||||
// Lost footfall can never be recovered, so it has to be visible rather than
|
||||
// inferred from a report that is quietly short.
|
||||
func TestDroppedVisitsAreAFailureAndSayTheyCannotBeRecovered(t *testing.T) {
|
||||
site := healthySite()
|
||||
site.Dropped = 412
|
||||
|
||||
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
|
||||
send := by["Visits are reaching head office"]
|
||||
if send.Status != "fail" {
|
||||
t.Fatalf("lost visits reported as %q", send.Status)
|
||||
}
|
||||
if !strings.Contains(send.Detail, "412") {
|
||||
t.Errorf("the count is not shown: %q", send.Detail)
|
||||
}
|
||||
if !strings.Contains(send.Advice, "cannot be recovered") {
|
||||
t.Errorf("advice implies they might come back: %q", send.Advice)
|
||||
}
|
||||
}
|
||||
|
||||
// Recording but not sending is its own state: the shop is working, head office
|
||||
// is blind, and the two look identical on a footfall report.
|
||||
func TestABackedUpQueueIsAWarningNotAFailure(t *testing.T) {
|
||||
site := healthySite()
|
||||
site.Queued = 340
|
||||
|
||||
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
|
||||
if got := by["Visits are reaching head office"].Status; got != "warn" {
|
||||
t.Fatalf("a backed-up queue reported as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASiteWithNoCamerasSaysToAddOne(t *testing.T) {
|
||||
by := stepsByName(BuildSiteSteps(healthySite(), nil, time.Now().UTC()))
|
||||
cam := by["Cameras are connected"]
|
||||
if cam.Status != "fail" {
|
||||
t.Fatalf("no cameras reported as %q", cam.Status)
|
||||
}
|
||||
if !strings.Contains(cam.Advice, "Add a camera") {
|
||||
t.Errorf("advice: %q", cam.Advice)
|
||||
}
|
||||
}
|
||||
|
||||
// A camera nobody has tried is not a camera that is down.
|
||||
func TestCamerasNotYetTriedAreAWarningNotAFailure(t *testing.T) {
|
||||
cams := []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}} // Connected nil
|
||||
|
||||
by := stepsByName(BuildSiteSteps(healthySite(), cams, time.Now().UTC()))
|
||||
cam := by["Cameras are connected"]
|
||||
if cam.Status != "warn" {
|
||||
t.Fatalf("an untried camera reported as %q, want warn", cam.Status)
|
||||
}
|
||||
if !strings.Contains(cam.Detail, "none tried yet") {
|
||||
t.Errorf("detail: %q", cam.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADownCameraFails(t *testing.T) {
|
||||
down := false
|
||||
up := true
|
||||
cams := []Camera{
|
||||
{ID: "c1", SiteID: siteA, CameraID: "entrance", Connected: &up},
|
||||
{ID: "c2", SiteID: siteA, CameraID: "till", Connected: &down},
|
||||
}
|
||||
by := stepsByName(BuildSiteSteps(healthySite(), cams, time.Now().UTC()))
|
||||
if got := by["Cameras are connected"].Status; got != "fail" {
|
||||
t.Fatalf("a down camera reported as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A running process is not a working engine: on a memory-starved box the large
|
||||
// model loses the fallback chain and the process stays up regardless.
|
||||
func TestAnEngineThatHasNotSaidWhichModelItLoadedIsAWarning(t *testing.T) {
|
||||
site := healthySite()
|
||||
site.RecognitionModel = ""
|
||||
|
||||
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
|
||||
if got := by["Recognition is running"].Status; got != "warn" {
|
||||
t.Fatalf("reported %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanAgoReadsLikeAPersonWouldSayIt(t *testing.T) {
|
||||
now := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
ago time.Duration
|
||||
want string
|
||||
}{
|
||||
{10 * time.Second, "just now"},
|
||||
{20 * time.Minute, "20 min ago"},
|
||||
{5 * time.Hour, "5 h ago"},
|
||||
{80 * time.Hour, "3 days ago"},
|
||||
} {
|
||||
got := humanAgo(now.Add(-tc.ago).Format(time.RFC3339), now)
|
||||
if got != tc.want {
|
||||
t.Errorf("%s ago -> %q, want %q", tc.ago, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := humanAgo("not a time", now); got != "at an unknown time" {
|
||||
t.Errorf("unparseable -> %q", got)
|
||||
}
|
||||
}
|
||||
90
server/internal/api/enrolment_test.go
Normal file
90
server/internal/api/enrolment_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedSite(fs *fakeStore) {
|
||||
fs.sites = []SiteHealth{{SiteID: siteA, Slug: "chennai", Name: "TeNext Chennai"}}
|
||||
}
|
||||
|
||||
func TestAManagerCanGetACodeForTheirOwnShop(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedSite(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/enrolment-code", sess.Token,
|
||||
map[string]any{"label": "counter PC"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out EnrolmentCode
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code == "" || out.ExpiresAt.IsZero() {
|
||||
t.Fatalf("no usable code came back: %s", rec.Body.String())
|
||||
}
|
||||
// Grouped for reading aloud - the installer is on the phone.
|
||||
if !strings.Contains(out.Code, "-") {
|
||||
t.Errorf("code is not grouped for dictation: %q", out.Code)
|
||||
}
|
||||
if out.SiteName != "TeNext Chennai" {
|
||||
t.Errorf("the shop is not named back to the operator: %q", out.SiteName)
|
||||
}
|
||||
}
|
||||
|
||||
// Staff must not be able to mint one. The code is redeemed for the site's
|
||||
// broker password, so it is a credential and not a convenience - and an
|
||||
// instruction in a prompt or a hidden button is not a permission check.
|
||||
func TestStaffCannotMintAnEnrolmentCode(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSite(fs)
|
||||
fs.addUser("staff@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-acme", Role: "staff", Active: true,
|
||||
})
|
||||
sess := login(t, s, "staff@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites/"+siteA+"/enrolment-code", sess.Token, nil)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("staff minted a credential: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A shop belonging to somebody else is NOT FOUND, not forbidden: a tenant has
|
||||
// no business learning that another tenant's shop exists.
|
||||
func TestAnotherTenantsShopIsNotFound(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedSite(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
other := "bbbbbbbb-1111-2222-3333-444444444444"
|
||||
rec := do(t, s, "POST", "/api/sites/"+other+"/enrolment-code", sess.Token, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("got %d, want 404: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A code is read aloud, photographed and pasted into chat on its way to a
|
||||
// shop. A caller asking for a year of validity gets a month.
|
||||
func TestCodeLifetimeIsCapped(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedSite(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
do(t, s, "POST", "/api/sites/"+siteA+"/enrolment-code", sess.Token,
|
||||
map[string]any{"days": 3650})
|
||||
if got := fs.lastCodeTTL.Hours(); got > 30*24 {
|
||||
t.Fatalf("ttl was %v, want at most 30 days", fs.lastCodeTTL)
|
||||
}
|
||||
// And a code records who minted it - it hands out a broker password.
|
||||
if fs.lastCodeActor == "" {
|
||||
t.Error("the code was not attributed to the person who asked for it")
|
||||
}
|
||||
}
|
||||
588
server/internal/api/fake_test.go
Normal file
588
server/internal/api/fake_test.go
Normal file
@@ -0,0 +1,588 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// fakeStore is an in-memory Store. Handlers are where the security decisions
|
||||
// live - which tenant, which message on failure, what is echoed back - and
|
||||
// those are exactly what a real database would make slow and awkward to test.
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
|
||||
users map[string]UserRecord // by lower-cased email
|
||||
sessions map[string]*fakeSession
|
||||
byAccess map[string]string // access hash hex -> session id
|
||||
byRefresh map[string]string
|
||||
|
||||
visitors []Customer
|
||||
history []VisitRow
|
||||
footfall []FootfallPoint
|
||||
totals Totals
|
||||
sales SalesReport
|
||||
sites []SiteHealth
|
||||
enrolment map[string]Enrolment
|
||||
|
||||
// Recorded calls, so a test can assert what the handler asked for rather
|
||||
// than only what it returned.
|
||||
lastReport ReportQuery
|
||||
lastProfile Profile
|
||||
lastProfileClient string
|
||||
lastPurchase PurchaseInput
|
||||
audits []AuditEntry
|
||||
|
||||
// arrivals is the whole table; arrivalQ records what the handler asked for
|
||||
// so a test can assert on the keyset window rather than only its output.
|
||||
arrivals []Arrival
|
||||
arrivalQ ArrivalQuery
|
||||
arrivalsErr error
|
||||
arrivalCalls int
|
||||
|
||||
pendingChecks []AgentCheckJob
|
||||
checkResults []AgentCheckResult
|
||||
releasedStale int
|
||||
lastCodeActor string
|
||||
lastCodeTTL time.Duration
|
||||
lastCheckKind string
|
||||
lastCheckSeconds int
|
||||
|
||||
cameras []Camera
|
||||
agentCameras []AgentCamera
|
||||
lastCameraReport AgentCameraReport
|
||||
lastReportClient string
|
||||
lastReportSite string
|
||||
saveCameraErr error
|
||||
lastSaved CameraInput
|
||||
|
||||
clients []ClientRow
|
||||
lastNewClient NewClientInput
|
||||
newClientErr error
|
||||
loginTouched []string
|
||||
|
||||
profileErr error
|
||||
purchaseErr error
|
||||
forgetErr error
|
||||
nextID int
|
||||
|
||||
agentTokens map[string]AgentPrincipal
|
||||
imageKeys map[string]string
|
||||
forgotten []string
|
||||
}
|
||||
|
||||
type fakeSession struct {
|
||||
id string
|
||||
p auth.Principal
|
||||
accessExp, refreshExp time.Time
|
||||
revoked bool
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{
|
||||
users: map[string]UserRecord{},
|
||||
sessions: map[string]*fakeSession{},
|
||||
byAccess: map[string]string{},
|
||||
byRefresh: map[string]string{},
|
||||
enrolment: map[string]Enrolment{},
|
||||
agentTokens: map[string]AgentPrincipal{},
|
||||
imageKeys: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeStore) addUser(email, password string, rec UserRecord) {
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rec.Email = email
|
||||
rec.PasswordHash = hash
|
||||
rec.Found = true
|
||||
if rec.ID == "" {
|
||||
rec.ID = "user-" + email
|
||||
}
|
||||
if rec.Role == "" {
|
||||
rec.Role = "manager"
|
||||
}
|
||||
f.users[auth.NormalizeEmail(email)] = rec
|
||||
}
|
||||
|
||||
func (f *fakeStore) UserByEmail(_ context.Context, email string) (UserRecord, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.users[email]
|
||||
if !ok {
|
||||
return UserRecord{Found: false}, nil
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) TouchUserLogin(_ context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.loginTouched = append(f.loginTouched, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.nextID++
|
||||
id := "sess-" + itoa(f.nextID)
|
||||
var rec UserRecord
|
||||
for _, u := range f.users {
|
||||
if u.ID == n.UserID {
|
||||
rec = u
|
||||
}
|
||||
}
|
||||
s := &fakeSession{
|
||||
id: id,
|
||||
p: auth.Principal{
|
||||
UserID: n.UserID, SessionID: id, ClientID: n.ClientID,
|
||||
ClientName: rec.ClientName, Email: rec.Email,
|
||||
FullName: rec.FullName, Role: rec.Role,
|
||||
},
|
||||
accessExp: n.AccessExpiry, refreshExp: n.RefreshExp,
|
||||
}
|
||||
f.sessions[id] = s
|
||||
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
||||
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) lookup(index map[string]string, hash []byte, refresh bool) (
|
||||
auth.Principal, time.Time, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id, ok := index[hex.EncodeToString(hash)]
|
||||
if !ok {
|
||||
return auth.Principal{}, time.Time{}, auth.ErrNoSession
|
||||
}
|
||||
s := f.sessions[id]
|
||||
if s == nil || s.revoked {
|
||||
return auth.Principal{}, time.Time{}, auth.ErrNoSession
|
||||
}
|
||||
if refresh {
|
||||
return s.p, s.refreshExp, nil
|
||||
}
|
||||
return s.p, s.accessExp, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SessionByAccess(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
|
||||
return f.lookup(f.byAccess, h, false)
|
||||
}
|
||||
|
||||
func (f *fakeStore) SessionByRefresh(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
|
||||
return f.lookup(f.byRefresh, h, true)
|
||||
}
|
||||
|
||||
func (f *fakeStore) RotateSession(_ context.Context, id string, n NewSession) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s := f.sessions[id]
|
||||
if s == nil || s.revoked {
|
||||
return auth.ErrNoSession
|
||||
}
|
||||
// Mirrors the real store: the old hashes stop resolving the moment the new
|
||||
// ones are written.
|
||||
for k, v := range f.byAccess {
|
||||
if v == id {
|
||||
delete(f.byAccess, k)
|
||||
}
|
||||
}
|
||||
for k, v := range f.byRefresh {
|
||||
if v == id {
|
||||
delete(f.byRefresh, k)
|
||||
}
|
||||
}
|
||||
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
||||
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
|
||||
s.accessExp, s.refreshExp = n.AccessExpiry, n.RefreshExp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeSession(_ context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if s := f.sessions[id]; s != nil {
|
||||
s.revoked = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Footfall(_ context.Context, q ReportQuery) ([]FootfallPoint, Totals, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastReport = q
|
||||
return f.footfall, f.totals, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastReport = q
|
||||
return f.sales, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
|
||||
return f.sites, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SearchVisitors(_ context.Context, clientID, q string, limit int) (
|
||||
[]Customer, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastReport = ReportQuery{ClientID: clientID}
|
||||
if limit < len(f.visitors) {
|
||||
return f.visitors[:limit], nil
|
||||
}
|
||||
return f.visitors, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitorHistory(_ context.Context, _, _ string, _ int) ([]VisitRow, error) {
|
||||
return f.history, nil
|
||||
}
|
||||
|
||||
// Arrivals fakes the keyset window in memory: rows are held oldest-first, a
|
||||
// cursor slices past it, and no cursor returns the newest Limit - the same
|
||||
// contract the SQL implements, so a handler test that passes here is testing
|
||||
// the handler and not a stub that is easier than the real thing.
|
||||
func (f *fakeStore) Arrivals(_ context.Context, q ArrivalQuery) ([]Arrival, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.arrivalQ = q
|
||||
f.arrivalCalls++
|
||||
if f.arrivalsErr != nil {
|
||||
return nil, f.arrivalsErr
|
||||
}
|
||||
rows := make([]Arrival, 0, len(f.arrivals))
|
||||
for _, a := range f.arrivals {
|
||||
if q.SiteID != "" && a.SiteID != q.SiteID {
|
||||
continue
|
||||
}
|
||||
if q.AfterSeq != nil && a.Seq <= *q.AfterSeq {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, a)
|
||||
}
|
||||
if q.AfterSeq == nil && len(rows) > q.Limit {
|
||||
// No cursor: the newest window, matching the real query.
|
||||
rows = rows[len(rows)-q.Limit:]
|
||||
} else if len(rows) > q.Limit {
|
||||
rows = rows[:q.Limit]
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SaveProfile(_ context.Context, clientID string, p Profile, _ string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastProfile, f.lastProfileClient = p, clientID
|
||||
return f.profileErr
|
||||
}
|
||||
|
||||
func (f *fakeStore) RecordPurchase(_ context.Context, _ string, p PurchaseInput, _ string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastPurchase = p
|
||||
return f.purchaseErr
|
||||
}
|
||||
|
||||
func (f *fakeStore) RedeemEnrolment(_ context.Context, hash []byte) (Enrolment, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
en, ok := f.enrolment[hex.EncodeToString(hash)]
|
||||
if !ok {
|
||||
return Enrolment{}, errors.New("unknown token")
|
||||
}
|
||||
// Single use, like the real UPDATE.
|
||||
delete(f.enrolment, hex.EncodeToString(hash))
|
||||
return en, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Cameras(_ context.Context, _, siteID string) ([]Camera, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []Camera
|
||||
for _, c := range f.cameras {
|
||||
if siteID == "" || c.SiteID == siteID {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CameraByID(_ context.Context, _, id string) (Camera, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.cameras {
|
||||
if c.ID == id {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return Camera{}, errors.New("no rows in result set")
|
||||
}
|
||||
|
||||
func (f *fakeStore) SaveCamera(_ context.Context, _, siteID, cameraID string,
|
||||
in CameraInput) (Camera, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastSaved = in
|
||||
if f.saveCameraErr != nil {
|
||||
return Camera{}, f.saveCameraErr
|
||||
}
|
||||
// A real-shaped uuid: the handlers check the shape before touching SQL, so
|
||||
// a placeholder id would exercise the 404 path instead of the one under
|
||||
// test.
|
||||
cam := Camera{ID: fakeCameraUUID(cameraID), SiteID: siteID, CameraID: cameraID,
|
||||
Port: 554, Path: "/", MaxWidth: 1280, Enabled: true, Revision: 1}
|
||||
if in.Label != nil {
|
||||
cam.Label = *in.Label
|
||||
}
|
||||
if in.Host != nil {
|
||||
cam.Host = *in.Host
|
||||
}
|
||||
if in.Username != nil {
|
||||
cam.Username = *in.Username
|
||||
}
|
||||
cam.HasPassword = in.Password != nil && *in.Password != ""
|
||||
f.cameras = append(f.cameras, cam)
|
||||
return cam, nil
|
||||
}
|
||||
|
||||
// fakeCameraUUID derives a stable uuid-shaped id from a camera name so tests
|
||||
// can address a camera they just created without reading the response.
|
||||
func fakeCameraUUID(cameraID string) string {
|
||||
sum := sha256.Sum256([]byte(cameraID))
|
||||
h := hex.EncodeToString(sum[:16])
|
||||
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteCamera(_ context.Context, _, id string) (Camera, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i, c := range f.cameras {
|
||||
if c.ID == id {
|
||||
f.cameras = append(f.cameras[:i], f.cameras[i+1:]...)
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return Camera{}, errors.New("no rows in result set")
|
||||
}
|
||||
|
||||
func (f *fakeStore) AgentCameras(_ context.Context, _ string) ([]AgentCamera, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.agentCameras, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ApplyAgentReport(_ context.Context, clientID, siteID string,
|
||||
rep AgentCameraReport) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastCameraReport = rep
|
||||
f.lastReportClient, f.lastReportSite = clientID, siteID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) IssueEnrolmentCode(_ context.Context, clientID, siteID,
|
||||
actorID, label string, ttl time.Duration) (EnrolmentCode, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, si := range f.sites {
|
||||
if si.SiteID == siteID {
|
||||
f.lastCodeActor, f.lastCodeTTL = actorID, ttl
|
||||
return EnrolmentCode{
|
||||
Code: "ABCDEF-123456-GHIJKL-789012", SiteID: siteID,
|
||||
SiteName: si.Name, Label: label,
|
||||
ExpiresAt: time.Now().Add(ttl).UTC(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return EnrolmentCode{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) RequestCheck(_ context.Context, _, id, kind string, seconds int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := range f.cameras {
|
||||
if f.cameras[i].ID == id {
|
||||
f.cameras[i].Check = CameraCheck{Kind: kind, Seconds: seconds, State: "requested"}
|
||||
f.lastCheckKind, f.lastCheckSeconds = kind, seconds
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("no rows in result set")
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClaimChecks(_ context.Context, _ string) ([]AgentCheckJob, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := f.pendingChecks
|
||||
f.pendingChecks = nil // claimed once, like the real UPDATE ... RETURNING
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RecordCheckResult(_ context.Context, _ string, res AgentCheckResult) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.checkResults = append(f.checkResults, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ReleaseStaleChecks(_ context.Context, _ time.Duration) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.releasedStale++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListClients(_ context.Context) ([]ClientRow, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.clients, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateClientWithOwner(_ context.Context, in NewClientInput) (
|
||||
NewClientResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastNewClient = in
|
||||
if f.newClientErr != nil {
|
||||
return NewClientResult{}, f.newClientErr
|
||||
}
|
||||
pw := in.Password
|
||||
if pw == "" {
|
||||
pw = "generated-password"
|
||||
}
|
||||
return NewClientResult{ClientID: "new-client-id", Slug: in.Slug,
|
||||
OwnerEmail: in.OwnerEmail, Password: pw}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Audit(_ context.Context, e AuditEntry) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.audits = append(f.audits, e)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// -- images and agents ------------------------------------------------------
|
||||
|
||||
func (f *fakeStore) SetAgentAPIToken(_ context.Context, agentID string, hash []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.agentTokens == nil {
|
||||
f.agentTokens = map[string]AgentPrincipal{}
|
||||
}
|
||||
f.agentTokens[hex.EncodeToString(hash)] = AgentPrincipal{
|
||||
AgentID: agentID, ClientID: "client-acme", SiteID: "site-1",
|
||||
Slug: "acme.store1", Client: "acme", Site: "store1",
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addAgent registers a plaintext agent token, hashed the way the middleware
|
||||
// will look it up.
|
||||
func (f *fakeStore) addAgent(token string, ap AgentPrincipal) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.agentTokens[hex.EncodeToString(auth.HashToken(token))] = ap
|
||||
}
|
||||
|
||||
func (f *fakeStore) AgentByToken(_ context.Context, hash []byte) (AgentPrincipal, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
ap, ok := f.agentTokens[hex.EncodeToString(hash)]
|
||||
if !ok {
|
||||
return AgentPrincipal{}, errors.New("no such agent")
|
||||
}
|
||||
return ap, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitorImageKey(_ context.Context, _, visitorID string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.imageKeys[visitorID], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitorImageKeys(_ context.Context, _, visitorID string) ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if k := f.imageKeys[visitorID]; k != "" {
|
||||
return []string{k}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ForgetVisitor(_ context.Context, _, visitorID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.forgetErr != nil {
|
||||
return f.forgetErr
|
||||
}
|
||||
f.forgotten = append(f.forgotten, visitorID)
|
||||
delete(f.imageKeys, visitorID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeBlob records what the handlers asked storage to do. Deleting is the part
|
||||
// worth recording: an erasure that reports success without removing the object
|
||||
// is the failure this whole path exists to prevent.
|
||||
type fakeBlob struct {
|
||||
mu sync.Mutex
|
||||
deleted []string
|
||||
presigns []string
|
||||
failNext error
|
||||
}
|
||||
|
||||
func (b *fakeBlob) Key(client, site, objectID string, at time.Time) string {
|
||||
return fmt.Sprintf("behavision/%s/%s/%04d/%02d/%02d/%s.jpg",
|
||||
client, site, at.Year(), int(at.Month()), at.Day(), objectID)
|
||||
}
|
||||
|
||||
func (b *fakeBlob) PresignPut(key string, _ time.Duration) (string, http.Header, error) {
|
||||
h := http.Header{}
|
||||
h.Set("x-amz-acl", "private")
|
||||
h.Set("Content-Type", "image/jpeg")
|
||||
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", h, nil
|
||||
}
|
||||
|
||||
func (b *fakeBlob) PresignGet(key string, _ time.Duration) (string, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.presigns = append(b.presigns, key)
|
||||
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", nil
|
||||
}
|
||||
|
||||
func (b *fakeBlob) Delete(_ context.Context, key string) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.failNext != nil {
|
||||
err := b.failNext
|
||||
b.failNext = nil
|
||||
return err
|
||||
}
|
||||
b.deleted = append(b.deleted, key)
|
||||
return nil
|
||||
}
|
||||
133
server/internal/api/handlers_admin.go
Normal file
133
server/internal/api/handlers_admin.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Platform administration: creating the tenants everything else belongs to.
|
||||
//
|
||||
// Deliberately NOT public registration. An open endpoint that mints tenants is
|
||||
// a far larger thing to have to secure than one behind an account that already
|
||||
// exists, and a stranger creating a tenant on this platform is not a customer -
|
||||
// it is a database row nobody asked for holding a place in a table every query
|
||||
// joins against.
|
||||
//
|
||||
// The `provision` CLI still exists and still works. It is the recovery path:
|
||||
// creating the FIRST platform admin cannot itself require being signed in as
|
||||
// one, and a bootstrap that only works over HTTP is a bootstrap that fails
|
||||
// exactly when HTTP is what is broken.
|
||||
|
||||
// adminOnly gates the routes below on a platform administrator.
|
||||
//
|
||||
// A platform admin is defined by having NO client - the scope is the absence,
|
||||
// not a flag - so this checks both. A tenant-scoped account with the role
|
||||
// somehow set to "admin" would otherwise read every customer of every client.
|
||||
func (s *Server) adminOnly(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.authed(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.IsAdmin() || p.ClientID != "" {
|
||||
// 404, not 403. A tenant user has no business knowing that a
|
||||
// platform-administration surface exists at all.
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such page.")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListClients(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.Store.ListClients(r.Context())
|
||||
if err != nil {
|
||||
s.serverError(w, "list clients", err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []ClientRow{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateClient(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
|
||||
var in NewClientInput
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
in.CompanyName = clip(trim(in.CompanyName), 200)
|
||||
in.OwnerName = clip(trim(in.OwnerName), 200)
|
||||
in.OwnerEmail = strings.ToLower(trim(in.OwnerEmail))
|
||||
in.Slug = slugify(in.Slug)
|
||||
if in.Slug == "" {
|
||||
// Derived from the company name when not given, because the slug is a
|
||||
// technical detail (it becomes the MQTT topic prefix) and asking an
|
||||
// operator to invent one is asking them to get it wrong.
|
||||
in.Slug = slugify(in.CompanyName)
|
||||
}
|
||||
|
||||
switch {
|
||||
case in.CompanyName == "":
|
||||
badRequest(w, "the company needs a name")
|
||||
return
|
||||
case in.OwnerEmail == "":
|
||||
badRequest(w, "an owner email is required - without one nobody can sign in")
|
||||
return
|
||||
case in.Slug == "":
|
||||
badRequest(w, "the company name has no letters or digits to build a short name from")
|
||||
return
|
||||
}
|
||||
|
||||
out, err := s.Store.CreateClientWithOwner(r.Context(), in)
|
||||
if err != nil {
|
||||
// Duplicate slug and duplicate email are the two an operator can act
|
||||
// on, and both are ordinary typing mistakes rather than faults.
|
||||
if msg, ok := conflictMessage(err); ok {
|
||||
writeErr(w, http.StatusConflict, "conflict", msg)
|
||||
return
|
||||
}
|
||||
s.serverError(w, "create client", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Creating a tenant is rare and consequential; it should leave a trace with
|
||||
// a name against it.
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ActorID: p.UserID, ActorKind: "user", Action: "client.create",
|
||||
Entity: "client", EntityID: out.ClientID,
|
||||
Detail: map[string]any{"slug": out.Slug, "owner": out.OwnerEmail},
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// slugify turns "Nearle Retail Pvt Ltd" into "nearle-retail-pvt-ltd".
|
||||
//
|
||||
// The result becomes an MQTT topic segment, so it is restricted to characters
|
||||
// that cannot change what a topic means: no '/', no '+', no '#'.
|
||||
func slugify(s string) string {
|
||||
var b strings.Builder
|
||||
lastDash := true // never start with a dash
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
case !lastDash:
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
func conflictMessage(err error) (string, bool) {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "clients_slug_key"):
|
||||
return "A company with that short name already exists - choose another.", true
|
||||
case strings.Contains(msg, "app_users_email_idx"):
|
||||
return "That email address already has an account.", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
100
server/internal/api/handlers_agent.go
Normal file
100
server/internal/api/handlers_agent.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// handleEnrol turns an anonymous install into a known site.
|
||||
//
|
||||
// The installer ships with no credentials of any kind, so a leaked build hands
|
||||
// out nothing at all. An operator types a one-shot code once; the server
|
||||
// answers with the broker login for exactly one site and marks the code spent.
|
||||
//
|
||||
// Deliberately NOT behind a session: the PC doing this has no user signed in
|
||||
// yet, and requiring one would mean shipping a password to every shop that
|
||||
// installs the software.
|
||||
func (s *Server) handleEnrol(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
SiteToken string `json:"site_token"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
token := trim(body.SiteToken)
|
||||
if token == "" {
|
||||
badRequest(w, "site_token is required")
|
||||
return
|
||||
}
|
||||
token = auth.NormalizeCode(token)
|
||||
|
||||
en, err := s.Store.RedeemEnrolment(r.Context(), auth.HashToken(token))
|
||||
if err != nil {
|
||||
// One message for unknown, expired and already-used. The difference is
|
||||
// only useful to somebody guessing codes, and an operator's next step
|
||||
// is the same in all three cases: ask for a new one.
|
||||
s.logf("enrolment refused: %v", err)
|
||||
writeErr(w, http.StatusUnauthorized, "bad_token",
|
||||
"That installation code is not valid. Ask for a new one.")
|
||||
return
|
||||
}
|
||||
// Mint the agent's own HTTPS credential now, while we have a proven
|
||||
// one-shot token in hand. Separate from the broker password because they
|
||||
// authenticate different things, so rotating one must not break the other.
|
||||
agentToken, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.serverError(w, "mint agent token", err)
|
||||
return
|
||||
}
|
||||
if err := s.Store.SetAgentAPIToken(r.Context(), en.AgentID, agentToken.Hash); err != nil {
|
||||
s.serverError(w, "store agent token", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: en.ClientID, ActorKind: "agent",
|
||||
Action: "agent.enrol", Entity: "site", EntityID: en.SiteID,
|
||||
Detail: map[string]any{"device": clip(trim(body.Device), 120)},
|
||||
})
|
||||
|
||||
models := s.Bootstrap.Models
|
||||
if models == nil {
|
||||
// An empty list, not null: the agent then falls back to its own built-in
|
||||
// download list rather than treating the field as missing.
|
||||
models = []ModelRef{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"site_id": en.SiteID,
|
||||
"site_name": en.SiteName,
|
||||
"site_slug": en.SiteSlug,
|
||||
// Derived from the broker username rather than looked up separately,
|
||||
// because the agent uses <client>.<site> as its topic prefix and the
|
||||
// broker's ACL is written against that exact username. Deriving it
|
||||
// makes the two equal by construction; a second lookup could drift.
|
||||
"client_slug": clientSlugOf(en.MQTTUser),
|
||||
"topic_prefix": "bv/" + en.MQTTUser,
|
||||
"mqtt_url": s.Bootstrap.MQTTURL,
|
||||
"mqtt_username": en.MQTTUser,
|
||||
"mqtt_password": en.MQTTPass,
|
||||
// Used for HTTPS calls the PC makes on its own behalf, such as asking
|
||||
// for an image upload URL. Shown once and never returned again.
|
||||
"agent_token": agentToken.Plain,
|
||||
"ca_cert": s.Bootstrap.CACert,
|
||||
"models": models,
|
||||
})
|
||||
}
|
||||
|
||||
// clientSlugOf pulls the tenant out of a broker username of the form
|
||||
// "<client>.<site>". Split on the FIRST dot only in the sense that a username
|
||||
// always has exactly one - the same rule contract.ParseTopic enforces on the
|
||||
// way back in.
|
||||
func clientSlugOf(brokerUser string) string {
|
||||
if i := strings.Index(brokerUser, "."); i > 0 {
|
||||
return brokerUser[:i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
326
server/internal/api/handlers_arrivals.go
Normal file
326
server/internal/api/handlers_arrivals.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// A poller asking for more than this is either paging history through the
|
||||
// wrong endpoint or has lost its cursor. Capped rather than rejected: a
|
||||
// mobile app coming back from a tunnel should get a big catch-up page and
|
||||
// a fresh cursor, not an error it has no way to recover from.
|
||||
maxArrivals = 200
|
||||
defaultArrivals = 50
|
||||
|
||||
// How often a live stream re-queries even if no doorbell rings. This is the
|
||||
// safety net for a second server instance whose ingest this process cannot
|
||||
// hear, so it is slow on purpose - it is the fallback, not the mechanism.
|
||||
streamFallback = 15 * time.Second
|
||||
// SSE comment sent on an idle connection. Mobile networks and reverse
|
||||
// proxies both close a stream that has been silent for a minute or two,
|
||||
// and a client that reconnects every ninety seconds is a client that
|
||||
// re-queries constantly.
|
||||
streamKeepalive = 20 * time.Second
|
||||
)
|
||||
|
||||
// handleArrivals is the live feed: who walked in, with their photos, in one
|
||||
// request.
|
||||
//
|
||||
// This is the endpoint a mobile app or a shop-floor screen actually needs, and
|
||||
// it is the one thing the API could not previously answer. `GET /api/visitors`
|
||||
// searches a customer list by name; it cannot tell you that four people just
|
||||
// came through the door, and until it could, a client had no way to know which
|
||||
// customer ids to ask about.
|
||||
func (s *Server) handleArrivals(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
|
||||
q := ArrivalQuery{
|
||||
ClientID: p.ClientID,
|
||||
SiteID: trim(r.URL.Query().Get("site_id")),
|
||||
Limit: queryInt(r, "limit", defaultArrivals, maxArrivals),
|
||||
}
|
||||
if q.SiteID != "" && !looksLikeUUID(q.SiteID) {
|
||||
badRequest(w, "site_id must be a site identifier")
|
||||
return
|
||||
}
|
||||
// The site is still filtered by client_id in SQL as well. A site_id from
|
||||
// the query string is caller-controlled, and this is a read of other
|
||||
// people's customers if it is ever trusted on its own.
|
||||
if cur := trim(r.URL.Query().Get("cursor")); cur != "" {
|
||||
seq, err := decodeCursor(cur)
|
||||
if err != nil {
|
||||
badRequest(w, "that cursor is not one of ours - drop it and poll again without one")
|
||||
return
|
||||
}
|
||||
q.AfterSeq = &seq
|
||||
} else if since := trim(r.URL.Query().Get("since")); since != "" {
|
||||
at, err := time.Parse(time.RFC3339, since)
|
||||
if err != nil {
|
||||
badRequest(w, "since must look like 2026-09-02T10:30:00Z")
|
||||
return
|
||||
}
|
||||
at = at.UTC()
|
||||
q.Since = &at
|
||||
}
|
||||
|
||||
page, err := s.arrivalPage(r, q)
|
||||
if err != nil {
|
||||
s.serverError(w, "arrivals", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page)
|
||||
}
|
||||
|
||||
// arrivalPage runs the query, attaches photos and returns the next cursor.
|
||||
// Shared by the poll and the stream so the two cannot answer differently.
|
||||
func (s *Server) arrivalPage(r *http.Request, q ArrivalQuery) (ArrivalPage, error) {
|
||||
rows, err := s.Store.Arrivals(r.Context(), q)
|
||||
if err != nil {
|
||||
return ArrivalPage{}, err
|
||||
}
|
||||
s.attachImages(r, rows)
|
||||
|
||||
page := ArrivalPage{
|
||||
Arrivals: rows,
|
||||
PolledAt: s.now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if page.Arrivals == nil {
|
||||
// An empty list, never null. A client looping over the response should
|
||||
// not have to special-case a quiet minute.
|
||||
page.Arrivals = []Arrival{}
|
||||
}
|
||||
if n := len(rows); n > 0 {
|
||||
// The LAST row, and the rows are ascending by position, so this is the
|
||||
// highest position the caller has now seen.
|
||||
page.Cursor = encodeCursor(rows[n-1].Seq)
|
||||
} else if q.AfterSeq != nil {
|
||||
// Nothing new. Hand the caller its own position back rather than an
|
||||
// empty string, so a poll that returns nothing does not reset the feed
|
||||
// to the beginning on the next request.
|
||||
page.Cursor = encodeCursor(*q.AfterSeq)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// attachImages swaps each row's object key for a short-lived signed link.
|
||||
//
|
||||
// One audit row for the whole page, not one per photo. Every read of a face is
|
||||
// worth recording - "who looked at my customers" has to be answerable - but a
|
||||
// tablet polling this feed every two seconds would write tens of thousands of
|
||||
// rows a day and bury the single deliberate look that an investigation is
|
||||
// actually after. The row records how many faces were surfaced and to whom,
|
||||
// which is the fact worth keeping.
|
||||
func (s *Server) attachImages(r *http.Request, rows []Arrival) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
seen := make([]string, 0, len(rows))
|
||||
|
||||
for i := range rows {
|
||||
key := rows[i].ImageKey
|
||||
rows[i].ImageKey = ""
|
||||
switch {
|
||||
case s.Blob == nil:
|
||||
rows[i].Image.Reason = "This system is not storing customer photos."
|
||||
case key == "":
|
||||
rows[i].Image.Reason = "No photo was captured for this visit."
|
||||
default:
|
||||
url, err := s.Blob.PresignGet(key, viewTTL)
|
||||
if err != nil {
|
||||
// Log it, but never fail the feed over a picture. The visit is
|
||||
// the number the customer pays for; the photo is decoration on
|
||||
// top of it. This is the same rule the agent follows when an
|
||||
// upload fails.
|
||||
s.logf("ERROR presign arrival image: %v", err)
|
||||
rows[i].Image.Reason = "That photo could not be loaded."
|
||||
continue
|
||||
}
|
||||
rows[i].Image = Image{Available: true, URL: url,
|
||||
ExpiresIn: int(viewTTL.Seconds())}
|
||||
if rows[i].VisitorID != "" {
|
||||
seen = append(seen, rows[i].VisitorID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(seen) == 0 {
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "image.view.feed", Entity: "visits",
|
||||
Detail: map[string]any{"count": len(seen), "visitor_ids": seen},
|
||||
})
|
||||
}
|
||||
|
||||
// handleArrivalStream is the same feed pushed instead of polled.
|
||||
//
|
||||
// Server-sent events rather than websockets: this direction is one-way, SSE is
|
||||
// stdlib with no dependency, it survives the reverse proxy in front of this
|
||||
// server unchanged, and browsers and mobile HTTP clients reconnect it on their
|
||||
// own. A websocket would buy bidirectionality that nothing here wants.
|
||||
func (s *Server) handleArrivalStream(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
// Without flushing this is not a stream, it is a response that arrives
|
||||
// at the end of the day. Say so rather than appearing to work.
|
||||
writeErr(w, http.StatusInternalServerError, "server_error",
|
||||
"streaming is not available on this connection")
|
||||
return
|
||||
}
|
||||
p := PrincipalFrom(r.Context())
|
||||
|
||||
q := ArrivalQuery{
|
||||
ClientID: p.ClientID,
|
||||
SiteID: trim(r.URL.Query().Get("site_id")),
|
||||
Limit: queryInt(r, "limit", defaultArrivals, maxArrivals),
|
||||
}
|
||||
if q.SiteID != "" && !looksLikeUUID(q.SiteID) {
|
||||
badRequest(w, "site_id must be a site identifier")
|
||||
return
|
||||
}
|
||||
// Last-Event-ID is what the browser's EventSource resends automatically on
|
||||
// a dropped connection, so honouring it is what makes a reconnect lossless
|
||||
// without the client writing any recovery code. ?cursor= is the same thing
|
||||
// for a native client that cannot set the header.
|
||||
cur := trim(r.Header.Get("Last-Event-ID"))
|
||||
if cur == "" {
|
||||
cur = trim(r.URL.Query().Get("cursor"))
|
||||
}
|
||||
if cur != "" {
|
||||
if seq, err := decodeCursor(cur); err == nil {
|
||||
q.AfterSeq = &seq
|
||||
}
|
||||
// A cursor we cannot read is not worth failing a reconnect over: the
|
||||
// client falls back to the recent window, which is the same thing it
|
||||
// would get on a fresh connection.
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "keep-alive")
|
||||
// Traefik and nginx both buffer by default, which turns an event stream
|
||||
// into a file that arrives when the connection closes.
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
bell, release := s.hub().Subscribe()
|
||||
defer release()
|
||||
|
||||
ctx := r.Context()
|
||||
fallback := time.NewTicker(streamFallback)
|
||||
defer fallback.Stop()
|
||||
keepalive := time.NewTicker(streamKeepalive)
|
||||
defer keepalive.Stop()
|
||||
|
||||
// Send whatever is already there before waiting for a doorbell, so a client
|
||||
// that connects after people have walked in is not blind until the next
|
||||
// one does.
|
||||
send := func() bool {
|
||||
page, err := s.arrivalPage(r, q)
|
||||
if err != nil {
|
||||
s.logf("ERROR arrival stream: %v", err)
|
||||
// Keep the connection: a transient database error should not log
|
||||
// a shop's screen out and start a reconnect storm across an estate.
|
||||
return true
|
||||
}
|
||||
if len(page.Arrivals) == 0 {
|
||||
return true
|
||||
}
|
||||
if page.Cursor != "" {
|
||||
// The SSE id becomes the client's Last-Event-ID, so the cursor
|
||||
// rides the protocol's own reconnect machinery instead of needing
|
||||
// application-level recovery.
|
||||
fmt.Fprintf(w, "id: %s\n", page.Cursor)
|
||||
// Advance our own position from the rows we just sent, so the next
|
||||
// wake-up asks for what comes after them.
|
||||
last := page.Arrivals[len(page.Arrivals)-1].Seq
|
||||
q.AfterSeq = &last
|
||||
}
|
||||
fmt.Fprint(w, "event: arrivals\ndata: ")
|
||||
if err := writeCompactJSON(w, page); err != nil {
|
||||
return false
|
||||
}
|
||||
fmt.Fprint(w, "\n\n")
|
||||
flusher.Flush()
|
||||
return true
|
||||
}
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case id, open := <-bell:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
// One process serves every tenant, so a doorbell for somebody
|
||||
// else's shop must not cost this connection a query.
|
||||
if id != p.ClientID {
|
||||
continue
|
||||
}
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
case <-fallback.C:
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
case <-keepalive.C:
|
||||
// A comment line. Keeps proxies and mobile radios from deciding
|
||||
// the connection is dead, and is ignored by every SSE client.
|
||||
fmt.Fprint(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) hub() *Hub {
|
||||
if s.Hub == nil {
|
||||
// A server built without one still streams; it just has no doorbell,
|
||||
// so it falls back to the slow tick. A nil map panic on an endpoint
|
||||
// somebody forgot to wire is a worse outcome than a slower feed.
|
||||
return nil
|
||||
}
|
||||
return s.Hub
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cursors
|
||||
|
||||
// Cursors are opaque on purpose, and base64 is what makes them look it. A
|
||||
// caller that reads one starts depending on the ordering column, and changing
|
||||
// that later then breaks every deployed mobile app rather than just this file -
|
||||
// which is exactly what happened once already, when the feed was ordered by a
|
||||
// timestamp and a random uuid.
|
||||
func encodeCursor(seq int64) string {
|
||||
return base64.RawURLEncoding.EncodeToString(
|
||||
[]byte("v1:" + strconv.FormatInt(seq, 10)))
|
||||
}
|
||||
|
||||
func decodeCursor(s string) (int64, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cursor is not base64: %w", err)
|
||||
}
|
||||
// The version prefix is what lets the ordering change again without
|
||||
// silently misreading cursors already held by deployed clients: an old
|
||||
// cursor fails to parse and the client restarts cleanly from the recent
|
||||
// window, rather than resuming at a position that now means something else.
|
||||
body, ok := strings.CutPrefix(string(raw), "v1:")
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("cursor is not a v1 cursor")
|
||||
}
|
||||
seq, err := strconv.ParseInt(body, 10, 64)
|
||||
if err != nil || seq < 0 {
|
||||
return 0, fmt.Errorf("cursor position is not a number")
|
||||
}
|
||||
return seq, nil
|
||||
}
|
||||
117
server/internal/api/handlers_assistant.go
Normal file
117
server/internal/api/handlers_assistant.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// The assistant, as an HTTP route.
|
||||
//
|
||||
// Session-authenticated like every other person-facing endpoint, and the
|
||||
// principal it derives is handed to every tool the model calls - so the
|
||||
// assistant can only ever see what the person asking could already see.
|
||||
|
||||
// Assistant is what the API needs from the assistant package.
|
||||
//
|
||||
// Declared HERE with the api package's own types, because `assistant` imports
|
||||
// `api` for the report and camera shapes - so the dependency can only run one
|
||||
// way, and main.go supplies a small adapter. That also makes the handler
|
||||
// testable with no API key and no network call.
|
||||
type Assistant interface {
|
||||
Configured() bool
|
||||
Ask(ctx context.Context, p auth.Principal, history []AssistantTurn) (AssistantAnswer, error)
|
||||
}
|
||||
|
||||
// ErrAssistantOff is returned when no API key is configured. A supported
|
||||
// state, not a fault.
|
||||
var ErrAssistantOff = errors.New("the assistant is not switched on for this server")
|
||||
|
||||
// ErrAssistantMisconfigured means the credentials are present but incomplete -
|
||||
// today, an identity-linked API key with no workspace id.
|
||||
var ErrAssistantMisconfigured = errors.New("the assistant is configured incorrectly")
|
||||
|
||||
type AssistantTurn struct {
|
||||
Role string `json:"role"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type AssistantAnswer struct {
|
||||
Text string `json:"text"`
|
||||
Used []string `json:"used,omitempty"`
|
||||
}
|
||||
|
||||
// AssistantRequest is one question plus the conversation so far. The client
|
||||
// holds the history: this server keeps no chat state, so there is no per-user
|
||||
// transcript sitting in a database that nobody agreed to.
|
||||
type AssistantRequest struct {
|
||||
History []AssistantTurn `json:"history"`
|
||||
}
|
||||
|
||||
const (
|
||||
// A conversation longer than this is not a support question any more, and
|
||||
// every turn is resent on every request.
|
||||
maxAssistantTurns = 24
|
||||
maxQuestionChars = 2000
|
||||
)
|
||||
|
||||
func (s *Server) handleAssistant(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if s.Assistant == nil || !s.Assistant.Configured() {
|
||||
// 501, not 500. "This deployment has no assistant" is a supported
|
||||
// configuration; the UI hides the panel rather than showing an error.
|
||||
writeErr(w, http.StatusNotImplemented, "assistant_off",
|
||||
"The assistant is not switched on for this server.")
|
||||
return
|
||||
}
|
||||
var body AssistantRequest
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if len(body.History) == 0 {
|
||||
badRequest(w, "ask a question")
|
||||
return
|
||||
}
|
||||
if len(body.History) > maxAssistantTurns {
|
||||
// Keep the most recent turns rather than refusing: a long conversation
|
||||
// is a person still trying to solve their problem.
|
||||
body.History = body.History[len(body.History)-maxAssistantTurns:]
|
||||
}
|
||||
for i := range body.History {
|
||||
body.History[i].Text = clip(trim(body.History[i].Text), maxQuestionChars)
|
||||
if body.History[i].Role != "assistant" {
|
||||
body.History[i].Role = "user"
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(body.History[len(body.History)-1].Text) == "" {
|
||||
badRequest(w, "ask a question")
|
||||
return
|
||||
}
|
||||
|
||||
answer, err := s.Assistant.Ask(r.Context(), p, body.History)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAssistantOff) {
|
||||
writeErr(w, http.StatusNotImplemented, "assistant_off",
|
||||
"The assistant is not switched on for this server.")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrAssistantMisconfigured) {
|
||||
// Told to the operator, not swallowed. "Something went wrong at our
|
||||
// end" is true and useless when the fix is one environment
|
||||
// variable, and this failure happens on the very first request so
|
||||
// it is exactly when a clear message is worth most.
|
||||
s.logf("ERROR assistant: %v", err)
|
||||
writeErr(w, http.StatusServiceUnavailable, "assistant_misconfigured",
|
||||
"The assistant is switched on but not configured correctly. "+
|
||||
"This API key needs ANTHROPIC_WORKSPACE_ID set on the server.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "assistant", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, answer)
|
||||
}
|
||||
206
server/internal/api/handlers_auth.go
Normal file
206
server/internal/api/handlers_auth.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
email := auth.NormalizeEmail(body.Email)
|
||||
|
||||
// Two limiters, at very different sizes. Per-account stops somebody working
|
||||
// through a password list against one known address; per-IP is a much
|
||||
// looser backstop against spraying one guess across many addresses, because
|
||||
// an entire shop shares a single NAT address and a tight limit there locks
|
||||
// out the whole staff when one person mistypes.
|
||||
perUser, perIP := s.throttles()
|
||||
ipKey, userKey := clientIP(r), email
|
||||
if !perIP.Allow(ipKey) || !perUser.Allow(userKey) {
|
||||
writeErr(w, http.StatusTooManyRequests, "too_many_attempts",
|
||||
"Too many sign-in attempts. Wait a few minutes and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := s.Store.UserByEmail(r.Context(), email)
|
||||
if err != nil {
|
||||
s.serverError(w, "login lookup", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify unconditionally, against a dummy hash when the address is unknown.
|
||||
// Returning early on "no such user" makes login response time a membership
|
||||
// oracle for your customer's staff directory.
|
||||
hash := rec.PasswordHash
|
||||
if !rec.Found || !rec.Active || hash == "" {
|
||||
hash = auth.DummyHash
|
||||
}
|
||||
ok := auth.VerifyPassword(hash, body.Password)
|
||||
if !ok || !rec.Found || !rec.Active {
|
||||
perIP.Fail(ipKey)
|
||||
perUser.Fail(userKey)
|
||||
// One message for every failure. "No such account" and "wrong password"
|
||||
// are the same answer to anyone who is not already the account holder.
|
||||
writeErr(w, http.StatusUnauthorized, "bad_credentials",
|
||||
"Email or password is incorrect.")
|
||||
return
|
||||
}
|
||||
// Cleared on success, so one forgotten password in the morning does not
|
||||
// lock a shop out at lunchtime.
|
||||
perIP.Reset(ipKey)
|
||||
perUser.Reset(userKey)
|
||||
|
||||
sess, err := s.mint(r, rec, body.Device)
|
||||
if err != nil {
|
||||
s.serverError(w, "create session", err)
|
||||
return
|
||||
}
|
||||
if err := s.Store.TouchUserLogin(r.Context(), rec.ID); err != nil {
|
||||
// Not fatal. Failing a successful login because a bookkeeping column
|
||||
// would not update locks people out for nothing.
|
||||
s.logf("WARN could not record last_login for %s: %v", rec.ID, err)
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: rec.ClientID, ActorID: rec.ID, ActorKind: "user",
|
||||
Action: "auth.login", Entity: "session",
|
||||
Detail: map[string]any{"device": trim(body.Device)},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, sess)
|
||||
}
|
||||
|
||||
func (s *Server) mint(r *http.Request, rec UserRecord, device string) (Session, error) {
|
||||
access, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
refresh, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
now := s.now()
|
||||
ns := NewSession{
|
||||
UserID: rec.ID,
|
||||
ClientID: rec.ClientID,
|
||||
AccessHash: access.Hash,
|
||||
RefreshHash: refresh.Hash,
|
||||
AccessExpiry: now.Add(auth.AccessTTL),
|
||||
RefreshExp: now.Add(auth.RefreshTTL),
|
||||
Device: clip(trim(device), 120),
|
||||
}
|
||||
if err := s.Store.CreateSession(r.Context(), ns); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
return Session{
|
||||
Token: access.Plain,
|
||||
RefreshToken: refresh.Plain,
|
||||
ExpiresAt: ns.AccessExpiry.UTC().Format(time.RFC3339),
|
||||
User: User{
|
||||
ID: rec.ID, Email: rec.Email, FullName: rec.FullName,
|
||||
Role: rec.Role, ClientID: rec.ClientID, Client: rec.ClientName,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleRefresh swaps a refresh token for a new pair.
|
||||
//
|
||||
// The old refresh token is invalidated in the same statement that issues the
|
||||
// new one. Leaving it usable would mean a token copied off a resold shop PC
|
||||
// keeps working forever alongside the real one.
|
||||
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if trim(body.RefreshToken) == "" {
|
||||
badRequest(w, "refresh_token is required")
|
||||
return
|
||||
}
|
||||
p, expires, err := s.Store.SessionByRefresh(r.Context(),
|
||||
auth.HashToken(body.RefreshToken))
|
||||
if err != nil {
|
||||
unauthorized(w, "Please sign in again.")
|
||||
return
|
||||
}
|
||||
if s.now().After(expires) {
|
||||
unauthorized(w, "Please sign in again.")
|
||||
return
|
||||
}
|
||||
|
||||
access, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.serverError(w, "refresh mint", err)
|
||||
return
|
||||
}
|
||||
refresh, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.serverError(w, "refresh mint", err)
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
ns := NewSession{
|
||||
UserID: p.UserID,
|
||||
ClientID: p.ClientID,
|
||||
AccessHash: access.Hash,
|
||||
RefreshHash: refresh.Hash,
|
||||
AccessExpiry: now.Add(auth.AccessTTL),
|
||||
// The refresh window slides. A shop PC that is used every day never has
|
||||
// to be logged in again; one left in a cupboard for two months does.
|
||||
RefreshExp: now.Add(auth.RefreshTTL),
|
||||
Device: clip(trim(body.Device), 120),
|
||||
}
|
||||
if err := s.Store.RotateSession(r.Context(), p.SessionID, ns); err != nil {
|
||||
s.serverError(w, "rotate session", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, Session{
|
||||
Token: access.Plain,
|
||||
RefreshToken: refresh.Plain,
|
||||
ExpiresAt: ns.AccessExpiry.UTC().Format(time.RFC3339),
|
||||
User: User{
|
||||
ID: p.UserID, Email: p.Email, FullName: p.FullName,
|
||||
Role: p.Role, ClientID: p.ClientID, Client: p.ClientName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if err := s.Store.RevokeSession(r.Context(), p.SessionID); err != nil {
|
||||
s.serverError(w, "revoke session", err)
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "auth.logout", Entity: "session", EntityID: p.SessionID,
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
writeJSON(w, http.StatusOK, User{
|
||||
ID: p.UserID, Email: p.Email, FullName: p.FullName,
|
||||
Role: p.Role, ClientID: p.ClientID, Client: p.ClientName,
|
||||
})
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
274
server/internal/api/handlers_cameras.go
Normal file
274
server/internal/api/handlers_cameras.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cameras, onboarded from head office instead of from the shop floor.
|
||||
//
|
||||
// The shop PC remains the thing that CONNECTS to a camera - it is on the same
|
||||
// LAN and nothing else can be - so these routes write desired state that the
|
||||
// agent pulls and applies. Two audiences, two shapes: a tenant never receives
|
||||
// a camera password, and an agent receives one only for its own site.
|
||||
|
||||
// A snapshot is refreshed every minute or so, so a link outliving that is
|
||||
// pointless; short enough that one in a screenshot is worthless by the time
|
||||
// anyone reads it.
|
||||
const snapshotTTL = 5 * time.Minute
|
||||
|
||||
func (s *Server) handleCameras(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
siteID := trim(r.URL.Query().Get("site_id"))
|
||||
if siteID != "" && !looksLikeUUID(siteID) {
|
||||
badRequest(w, "site_id must be a site identifier")
|
||||
return
|
||||
}
|
||||
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
|
||||
if err != nil {
|
||||
s.serverError(w, "cameras", err)
|
||||
return
|
||||
}
|
||||
if cams == nil {
|
||||
cams = []Camera{}
|
||||
}
|
||||
s.attachSnapshots(cams)
|
||||
writeJSON(w, http.StatusOK, cams)
|
||||
}
|
||||
|
||||
// attachSnapshots swaps each camera's object key for a signed link.
|
||||
//
|
||||
// A snapshot is a frame of a shop floor, so it gets the same treatment as a
|
||||
// face: a short-lived signed URL, never a stored one. Absence is data - most
|
||||
// deployments store no images at all, and a camera that is merely new has no
|
||||
// frame yet.
|
||||
func (s *Server) attachSnapshots(cams []Camera) {
|
||||
for i := range cams {
|
||||
key := cams[i].Snapshot.Key
|
||||
cams[i].Snapshot.Key = ""
|
||||
switch {
|
||||
case s.Blob == nil:
|
||||
cams[i].Snapshot.Reason = "This system is not storing images."
|
||||
case key == "":
|
||||
cams[i].Snapshot.Reason = "No picture from this camera yet."
|
||||
default:
|
||||
url, err := s.Blob.PresignGet(key, snapshotTTL)
|
||||
if err != nil {
|
||||
s.logf("ERROR presign snapshot: %v", err)
|
||||
cams[i].Snapshot.Reason = "That picture could not be loaded."
|
||||
continue
|
||||
}
|
||||
cams[i].Snapshot = Image{Available: true, URL: url,
|
||||
ExpiresIn: int(snapshotTTL.Seconds())}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateCamera(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot change camera settings.")
|
||||
return
|
||||
}
|
||||
siteID := r.PathValue("site")
|
||||
if !looksLikeUUID(siteID) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
|
||||
return
|
||||
}
|
||||
var in CameraInput
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
id := ""
|
||||
if in.CameraID != nil {
|
||||
id = cameraSlug(*in.CameraID)
|
||||
}
|
||||
if id == "" && in.Label != nil {
|
||||
id = cameraSlug(*in.Label)
|
||||
}
|
||||
if id == "" {
|
||||
badRequest(w, "give the camera a name, such as Entrance")
|
||||
return
|
||||
}
|
||||
if in.Label == nil || trim(*in.Label) == "" {
|
||||
in.Label = &id
|
||||
}
|
||||
if msg, ok := cameraProblem(in); !ok {
|
||||
badRequest(w, msg)
|
||||
return
|
||||
}
|
||||
s.saveCamera(w, r, p.ClientID, siteID, id, in, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateCamera(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot change camera settings.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
return
|
||||
}
|
||||
existing, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
return
|
||||
}
|
||||
var in CameraInput
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
// The camera id is what visits are recorded against. Renaming it would
|
||||
// orphan every visit already attributed to the old name, so the label is
|
||||
// the thing an operator may change.
|
||||
in.CameraID = nil
|
||||
s.saveCamera(w, r, p.ClientID, existing.SiteID, existing.CameraID, in, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) saveCamera(w http.ResponseWriter, r *http.Request,
|
||||
clientID, siteID, cameraID string, in CameraInput, code int) {
|
||||
|
||||
p := PrincipalFrom(r.Context())
|
||||
cam, err := s.Store.SaveCamera(r.Context(), clientID, siteID, cameraID, in)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoSecrets) {
|
||||
// A camera saved with its password silently dropped is a camera
|
||||
// that will not connect, and the operator could not tell that from
|
||||
// a wrong password.
|
||||
writeErr(w, http.StatusServiceUnavailable, "no_secret_key", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "save camera", err)
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: clientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "camera.save", Entity: "camera", EntityID: cam.ID,
|
||||
Detail: map[string]any{"camera_id": cam.CameraID, "site_id": siteID},
|
||||
})
|
||||
// Through the slice, not around it. `attachSnapshots([]Camera{cam})` would
|
||||
// decorate a COPY and then serialise the untouched original, so a created
|
||||
// camera came back with an empty snapshot object and no reason - the one
|
||||
// field whose whole job is to say why there is no picture.
|
||||
cams := []Camera{cam}
|
||||
s.attachSnapshots(cams)
|
||||
writeJSON(w, code, cams[0])
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteCamera(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot change camera settings.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
return
|
||||
}
|
||||
cam, err := s.Store.DeleteCamera(r.Context(), p.ClientID, id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "camera.delete", Entity: "camera", EntityID: cam.ID,
|
||||
Detail: map[string]any{"camera_id": cam.CameraID},
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ agent
|
||||
|
||||
// handleAgentCameras is the shop PC asking what it should be running.
|
||||
//
|
||||
// Authenticated by the agent's own token, and scoped to that agent's site by
|
||||
// the credential rather than by anything in the request - a site id a caller
|
||||
// could set would hand one shop another shop's camera passwords.
|
||||
func (s *Server) handleAgentCameras(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
cams, err := s.Store.AgentCameras(r.Context(), ap.SiteID)
|
||||
if err != nil {
|
||||
s.serverError(w, "agent cameras", err)
|
||||
return
|
||||
}
|
||||
if cams == nil {
|
||||
cams = []AgentCamera{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cameras": cams})
|
||||
}
|
||||
|
||||
// handleAgentCameraReport records what the shop PC observes and adopts any
|
||||
// camera it is running that head office does not know about.
|
||||
func (s *Server) handleAgentCameraReport(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
var rep AgentCameraReport
|
||||
if err := decode(w, r, &rep); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
for i := range rep.Adopt {
|
||||
// Slugged here as well as on the operator path. An agent is trusted to
|
||||
// report its own site, not to choose an identifier that could collide
|
||||
// with a topic segment or a path.
|
||||
rep.Adopt[i].CameraID = cameraSlug(rep.Adopt[i].CameraID)
|
||||
}
|
||||
// ClientID, not Client. AgentPrincipal carries both the tenant's uuid and
|
||||
// its human slug, and the slug is the one that reads correctly in a log
|
||||
// line - which is exactly why it gets used by mistake in a query that wants
|
||||
// the uuid.
|
||||
if err := s.Store.ApplyAgentReport(r.Context(), ap.ClientID, ap.SiteID, rep); err != nil {
|
||||
s.serverError(w, "agent camera report", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
// cameraSlug normalises the id the engine will know this camera by.
|
||||
//
|
||||
// It ends up in an object key, a URL path and a topic segment, so it is
|
||||
// restricted to characters that cannot change what any of those mean.
|
||||
func cameraSlug(s string) string {
|
||||
var b strings.Builder
|
||||
lastDash := true
|
||||
for _, r := range strings.ToLower(trim(s)) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
case !lastDash:
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
return clip(strings.Trim(b.String(), "-"), 64)
|
||||
}
|
||||
|
||||
// cameraProblem rejects a camera that cannot possibly connect, with the
|
||||
// sentence an operator needs rather than a validation code.
|
||||
func cameraProblem(in CameraInput) (string, bool) {
|
||||
if in.Host == nil || trim(*in.Host) == "" {
|
||||
return "the camera needs an address on the shop's network, such as 192.168.0.138", false
|
||||
}
|
||||
if in.Port != nil && (*in.Port < 1 || *in.Port > 65535) {
|
||||
return "the port must be between 1 and 65535 - RTSP cameras are usually 554", false
|
||||
}
|
||||
if in.MaxWidth != nil && *in.MaxWidth < 320 {
|
||||
return "frames narrower than 320 pixels are too small to recognise a face in", false
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
320
server/internal/api/handlers_checks.go
Normal file
320
server/internal/api/handlers_checks.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Proving a camera works, and proving a site works.
|
||||
//
|
||||
// The engine already answers both questions and already phrases its answers for
|
||||
// whoever is standing next to the camera. Nothing here re-words them; this is
|
||||
// the channel that was missing, plus the one judgement head office can make on
|
||||
// its own - whether the shop PC is even talking to us.
|
||||
|
||||
const (
|
||||
// A placement check asks somebody to walk through the frame and out of it.
|
||||
// Under ~15 s and an installer has no time to do that; over ~60 s and they
|
||||
// have wandered off.
|
||||
minCheckSeconds = 15
|
||||
defaultCheckSeconds = 25
|
||||
maxCheckSeconds = 60
|
||||
// A shop PC that claimed a check and never reported is assumed to have been
|
||||
// restarted mid-check. Long enough that a slow placement run is not
|
||||
// stolen from itself.
|
||||
checkStaleAfter = 5 * time.Minute
|
||||
)
|
||||
|
||||
func (s *Server) handleRequestCheck(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot run camera checks.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
return
|
||||
}
|
||||
var req CheckRequest
|
||||
if err := decode(w, r, &req); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
switch req.Kind {
|
||||
case "connection", "placement":
|
||||
case "":
|
||||
req.Kind = "connection"
|
||||
default:
|
||||
badRequest(w, `kind must be "connection" or "placement"`)
|
||||
return
|
||||
}
|
||||
if req.Seconds == 0 {
|
||||
req.Seconds = defaultCheckSeconds
|
||||
}
|
||||
if req.Seconds < minCheckSeconds || req.Seconds > maxCheckSeconds {
|
||||
badRequest(w, fmt.Sprintf(
|
||||
"a placement check runs for between %d and %d seconds - long enough "+
|
||||
"to walk through the frame, short enough that nobody wanders off",
|
||||
minCheckSeconds, maxCheckSeconds))
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Store.RequestCheck(r.Context(), p.ClientID, id, req.Kind, req.Seconds); err != nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
return
|
||||
}
|
||||
cam, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
|
||||
if err != nil {
|
||||
s.serverError(w, "camera after check request", err)
|
||||
return
|
||||
}
|
||||
cams := []Camera{cam}
|
||||
s.attachSnapshots(cams)
|
||||
// 202: the shop PC has not run it yet, and saying 200 would invite a client
|
||||
// to read the (empty) result as the answer.
|
||||
writeJSON(w, http.StatusAccepted, cams[0])
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ agent
|
||||
|
||||
func (s *Server) handleAgentChecks(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
// Release anything a previous run claimed and abandoned before handing out
|
||||
// work, so a PC restarted mid-check picks its own job back up rather than
|
||||
// leaving the camera showing "checking..." for ever.
|
||||
if err := s.Store.ReleaseStaleChecks(r.Context(), checkStaleAfter); err != nil {
|
||||
s.logf("ERROR releasing stale checks: %v", err)
|
||||
}
|
||||
jobs, err := s.Store.ClaimChecks(r.Context(), ap.SiteID)
|
||||
if err != nil {
|
||||
s.serverError(w, "claim checks", err)
|
||||
return
|
||||
}
|
||||
if jobs == nil {
|
||||
jobs = []AgentCheckJob{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"checks": jobs})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentCheckResult(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
var res AgentCheckResult
|
||||
if err := decode(w, r, &res); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
res.CameraID = cameraSlug(res.CameraID)
|
||||
if res.CameraID == "" {
|
||||
badRequest(w, "camera_id is required")
|
||||
return
|
||||
}
|
||||
if err := s.Store.RecordCheckResult(r.Context(), ap.SiteID, res); err != nil {
|
||||
s.serverError(w, "record check result", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- the site smoke test
|
||||
|
||||
// handleSiteCheck answers "is this shop working", end to end.
|
||||
//
|
||||
// Assembled entirely from what head office already knows, so it costs no round
|
||||
// trip to the shop and works when the PC is off - which is itself one of the
|
||||
// answers, and the one a footfall report cannot give.
|
||||
//
|
||||
// Ordered, and it stops judging once something fails: asking whether cameras
|
||||
// see faces on a PC that is switched off produces an answer that means nothing,
|
||||
// and printing it next to a real failure buries the real failure.
|
||||
func (s *Server) handleSiteCheck(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
siteID := r.PathValue("site")
|
||||
if !looksLikeUUID(siteID) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
|
||||
return
|
||||
}
|
||||
sites, err := s.Store.SiteHealth(r.Context(), p.ClientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "site check", err)
|
||||
return
|
||||
}
|
||||
var site *SiteHealth
|
||||
for i := range sites {
|
||||
if sites[i].SiteID == siteID {
|
||||
site = &sites[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if site == nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
|
||||
return
|
||||
}
|
||||
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
|
||||
if err != nil {
|
||||
s.serverError(w, "site check cameras", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := SiteCheck{SiteID: site.SiteID, Site: site.Name,
|
||||
Steps: BuildSiteSteps(site, cams, s.now())}
|
||||
out.OK = true
|
||||
for _, st := range out.Steps {
|
||||
if st.Status != "pass" {
|
||||
out.OK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// buildSiteSteps is the whole judgement, in one place and with no I/O, so it
|
||||
// can be tested against every combination without a database.
|
||||
func BuildSiteSteps(site *SiteHealth, cams []Camera, now time.Time) []CheckStep {
|
||||
steps := make([]CheckStep, 0, 5)
|
||||
|
||||
// 1. Is the shop PC talking to us at all? Everything below is unknowable
|
||||
// until this passes, so a failure here stops the rest being judged.
|
||||
pc := CheckStep{Name: "The shop's PC is online"}
|
||||
switch {
|
||||
case site.LastHeartbeatAt == "":
|
||||
pc.Status, pc.Detail = "fail", "This PC has never reported in."
|
||||
pc.Advice = "Install Behavision on the shop's PC and claim it with the enrolment code for this shop."
|
||||
case !site.Online:
|
||||
pc.Status = "fail"
|
||||
pc.Detail = "Last heard from " + humanAgo(site.LastHeartbeatAt, now) + "."
|
||||
pc.Advice = "Check the PC is switched on, and that it has internet."
|
||||
default:
|
||||
pc.Status, pc.Detail = "pass", "Reported in "+humanAgo(site.LastHeartbeatAt, now)+"."
|
||||
}
|
||||
steps = append(steps, pc)
|
||||
if pc.Status == "fail" {
|
||||
return append(steps, unknownStep("Cameras are connected"),
|
||||
unknownStep("Cameras can recognise faces"),
|
||||
unknownStep("Visits are reaching head office"))
|
||||
}
|
||||
|
||||
// 2. Recognition running, and WHICH model - on a memory-starved box the
|
||||
// large model loses the fallback chain and the process stays up anyway.
|
||||
rec := CheckStep{Name: "Recognition is running"}
|
||||
if site.RecognitionModel == "" {
|
||||
rec.Status = "warn"
|
||||
rec.Detail = "The PC is online but has not said which recognition model it loaded."
|
||||
rec.Advice = "Open Behavision on the shop's PC and check it is started."
|
||||
} else {
|
||||
rec.Status, rec.Detail = "pass", "Using "+site.RecognitionModel+"."
|
||||
}
|
||||
steps = append(steps, rec)
|
||||
|
||||
// 3. Cameras connected.
|
||||
cam := CheckStep{Name: "Cameras are connected"}
|
||||
switch {
|
||||
case len(cams) == 0:
|
||||
cam.Status, cam.Detail = "fail", "No cameras have been set up for this shop."
|
||||
cam.Advice = "Add a camera, then run this check again."
|
||||
default:
|
||||
var up, down, untested int
|
||||
for _, c := range cams {
|
||||
switch {
|
||||
case c.Connected == nil:
|
||||
untested++
|
||||
case *c.Connected:
|
||||
up++
|
||||
default:
|
||||
down++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case down > 0:
|
||||
cam.Status = "fail"
|
||||
cam.Detail = fmt.Sprintf("%d of %d cameras are not connecting.", down, len(cams))
|
||||
cam.Advice = "Open the camera and use Check connection to see why."
|
||||
case untested > 0 && up == 0:
|
||||
cam.Status = "warn"
|
||||
cam.Detail = fmt.Sprintf("%d camera(s) set up, none tried yet by the shop's PC.", untested)
|
||||
cam.Advice = "Wait a couple of minutes, or use Check connection on a camera."
|
||||
default:
|
||||
cam.Status = "pass"
|
||||
cam.Detail = fmt.Sprintf("%d of %d connected.", up, len(cams))
|
||||
}
|
||||
}
|
||||
steps = append(steps, cam)
|
||||
|
||||
// 4. The question the whole product turns on, and the one Office1 failed
|
||||
// silently for weeks: not "is a camera plugged in" but "does a person
|
||||
// walking past produce a view good enough to recognise".
|
||||
faces := CheckStep{Name: "Cameras can recognise faces"}
|
||||
switch {
|
||||
case site.FractionBelowGate > 0.5:
|
||||
faces.Status = "fail"
|
||||
faces.Detail = fmt.Sprintf(
|
||||
"%.0f%% of the faces seen were too poor to recognise.",
|
||||
site.FractionBelowGate*100)
|
||||
faces.Advice = "The camera needs moving: face the way people walk in, at about head height."
|
||||
case site.FractionBelowGate > 0.2:
|
||||
faces.Status = "warn"
|
||||
faces.Detail = fmt.Sprintf("%.0f%% of faces seen were too poor to recognise.",
|
||||
site.FractionBelowGate*100)
|
||||
faces.Advice = "Some visitors are being missed. Run a walk-past check on each camera."
|
||||
case site.LastEventAt == "":
|
||||
// Not a failure. A shop that has just been set up has seen nobody yet,
|
||||
// and calling that broken sends an installer looking for a fault that
|
||||
// does not exist.
|
||||
faces.Status = "unknown"
|
||||
faces.Detail = "Nobody has walked past yet."
|
||||
faces.Advice = "Run a walk-past check on a camera to prove it before the shop opens."
|
||||
default:
|
||||
faces.Status = "pass"
|
||||
faces.Detail = "Faces seen are good enough to recognise."
|
||||
}
|
||||
steps = append(steps, faces)
|
||||
|
||||
// 5. Do visits actually arrive? A shop can be recognising people perfectly
|
||||
// and reporting none of it.
|
||||
send := CheckStep{Name: "Visits are reaching head office"}
|
||||
switch {
|
||||
case site.Dropped > 0:
|
||||
send.Status = "fail"
|
||||
send.Detail = fmt.Sprintf("%d visits were lost - this PC was offline too long.", site.Dropped)
|
||||
send.Advice = "Check this shop's internet. Those visits cannot be recovered."
|
||||
case site.Queued > 20:
|
||||
send.Status = "warn"
|
||||
send.Detail = fmt.Sprintf("%d visits are waiting to be sent.", site.Queued)
|
||||
send.Advice = "The PC is recording but not sending. Check its internet connection."
|
||||
case site.LastEventAt == "":
|
||||
send.Status = "unknown"
|
||||
send.Detail = "No visits recorded yet."
|
||||
default:
|
||||
send.Status = "pass"
|
||||
send.Detail = "Last visit received " + humanAgo(site.LastEventAt, now) + "."
|
||||
}
|
||||
steps = append(steps, send)
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
func unknownStep(name string) CheckStep {
|
||||
return CheckStep{Name: name, Status: "unknown",
|
||||
Detail: "Cannot be checked until the shop's PC is online."}
|
||||
}
|
||||
|
||||
// humanAgo phrases a timestamp the way somebody reading a status page would say
|
||||
// it. Deliberately vague at the top end: "3 days ago" is as actionable as
|
||||
// "3 days and 4 hours ago" and far easier to scan.
|
||||
func humanAgo(iso string, now time.Time) string {
|
||||
t, err := time.Parse(time.RFC3339, iso)
|
||||
if err != nil {
|
||||
return "at an unknown time"
|
||||
}
|
||||
d := now.Sub(t)
|
||||
switch {
|
||||
case d < 90*time.Second:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%d h ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
67
server/internal/api/handlers_enrolment.go
Normal file
67
server/internal/api/handlers_enrolment.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Issuing the code that claims a shop PC.
|
||||
//
|
||||
// This existed only as a provisioning command, which made every replacement PC
|
||||
// a support ticket and an SSH session - and a shop PC is exactly the kind of
|
||||
// machine that gets replaced, reimaged and swapped between branches. The
|
||||
// command remains the bootstrap, because a brand new customer has nobody to
|
||||
// sign in as yet; this is for every time after that.
|
||||
//
|
||||
// Manager and above, never staff: the code is redeemed for the site's broker
|
||||
// password, so it is a credential in its own right, not a convenience.
|
||||
func (s *Server) handleIssueEnrolmentCode(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot set up shop computers. Ask a manager or the owner.")
|
||||
return
|
||||
}
|
||||
site := r.PathValue("site")
|
||||
if !looksLikeUUID(site) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
|
||||
return
|
||||
}
|
||||
var in NewEnrolmentCodeInput
|
||||
if err := decodeOptional(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
// A week by default, and a month at the very most. The code is read aloud,
|
||||
// photographed and pasted into chat on its way to a shop; a long-lived one
|
||||
// is a broker credential lying about in a WhatsApp thread.
|
||||
days := in.Days
|
||||
if days <= 0 {
|
||||
days = 7
|
||||
}
|
||||
if days > 30 {
|
||||
days = 30
|
||||
}
|
||||
out, err := s.Store.IssueEnrolmentCode(r.Context(), p.ClientID, site,
|
||||
p.UserID, clip(trim(in.Label), 120), time.Duration(days)*24*time.Hour)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "issue enrolment code", err)
|
||||
return
|
||||
}
|
||||
// A code hands out a site's broker password, so who minted one and when is
|
||||
// worth a row - the same reason every read of a face image writes one.
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "enrolment_code.issued", Entity: "site", EntityID: site,
|
||||
Detail: map[string]any{"label": out.Label, "expires_at": out.ExpiresAt},
|
||||
})
|
||||
// 201: a credential was created. The body is the only time it is readable.
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
180
server/internal/api/handlers_images.go
Normal file
180
server/internal/api/handlers_images.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// agentAuthed authenticates a store PC by its own API token.
|
||||
//
|
||||
// Deliberately a separate middleware from authed(): an agent has no user, no
|
||||
// role and no session, and folding it into the person path would mean one set
|
||||
// of permission checks answering two very different questions about who is
|
||||
// asking.
|
||||
func (s *Server) agentAuthed(next func(http.ResponseWriter, *http.Request, AgentPrincipal)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tok := auth.BearerToken(r)
|
||||
if tok == "" {
|
||||
unauthorized(w, "this endpoint is for a Behavision agent")
|
||||
return
|
||||
}
|
||||
ap, err := s.Store.AgentByToken(r.Context(), auth.HashToken(tok))
|
||||
if err != nil {
|
||||
unauthorized(w, "this agent is not enrolled")
|
||||
return
|
||||
}
|
||||
next(w, r, ap)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUploadURL hands a store PC permission to write exactly one object.
|
||||
//
|
||||
// The shop PC never holds bucket credentials. That is not belt-and-braces: the
|
||||
// bucket is shared with another application and is world-readable at the bucket
|
||||
// level, so a full key on a machine that sits on a shop counter would expose
|
||||
// far more than this product's own data. A stolen PC gives up, at most, a few
|
||||
// minutes of write access to one key it was already going to write.
|
||||
func (s *Server) handleUploadURL(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
if s.Blob == nil {
|
||||
// Not an error the agent should retry against: images are simply off
|
||||
// for this deployment, and it should carry on sending visits without
|
||||
// one rather than queueing failures.
|
||||
writeErr(w, http.StatusNotImplemented, "images_disabled",
|
||||
"This server is not configured to store images.")
|
||||
return
|
||||
}
|
||||
// The KEY is built here, from the credential the request authenticated
|
||||
// with. Accepting a caller-supplied key would let one site overwrite
|
||||
// another's images, which is the whole reason this endpoint exists instead
|
||||
// of a shared bucket password.
|
||||
key := s.Blob.Key(ap.Client, ap.Site, newObjectID(), s.now())
|
||||
url, hdr, err := s.Blob.PresignPut(key, uploadTTL)
|
||||
if err != nil {
|
||||
s.serverError(w, "presign upload", err)
|
||||
return
|
||||
}
|
||||
// Lower-cased deliberately. SigV4 signs header names in lower case, and
|
||||
// this map is a wire contract that a non-Go client will copy literally -
|
||||
// http.Header's canonical "X-Amz-Acl" would send them looking for a
|
||||
// mismatch that only exists in Go's map keys.
|
||||
headers := map[string]string{}
|
||||
for k := range hdr {
|
||||
headers[strings.ToLower(k)] = hdr.Get(k)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, UploadTarget{
|
||||
Key: key, URL: url, Headers: headers,
|
||||
ExpiresIn: int(uploadTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
// Long enough for a slow shop connection to finish a 30 KB JPEG, short
|
||||
// enough that a URL captured in a log is worthless by the time anyone
|
||||
// reads it.
|
||||
uploadTTL = 10 * time.Minute
|
||||
// Read URLs end up in browser history, screenshots and support tickets.
|
||||
viewTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
// handleVisitorImage returns a short-lived link to a customer's most recent
|
||||
// face image.
|
||||
//
|
||||
// A link that expires, never a stored URL: "delete my data" has to mean the
|
||||
// link stops working, not that we stop publishing it.
|
||||
func (s *Server) handleVisitorImage(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
|
||||
return
|
||||
}
|
||||
if s.Blob == nil {
|
||||
writeErr(w, http.StatusNotFound, "images_disabled",
|
||||
"This server does not store images.")
|
||||
return
|
||||
}
|
||||
key, err := s.Store.VisitorImageKey(r.Context(), p.ClientID, id)
|
||||
if err != nil || key == "" {
|
||||
writeErr(w, http.StatusNotFound, "no_image",
|
||||
"There is no photo for this customer.")
|
||||
return
|
||||
}
|
||||
url, err := s.Blob.PresignGet(key, viewTTL)
|
||||
if err != nil {
|
||||
s.serverError(w, "presign read", err)
|
||||
return
|
||||
}
|
||||
// Every read of a face image is worth a row. If a client asks "who looked
|
||||
// at my customers", an audit trail is the only answer that is not a guess.
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "image.view", Entity: "visitor", EntityID: id,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"url": url, "expires_in": int(viewTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleForgetVisitor is the erasure path.
|
||||
//
|
||||
// It destroys the biometric template and the face image outright, and keeps
|
||||
// only what is genuinely aggregate: the visit rows stay so a shop's past
|
||||
// footfall does not silently change, but they no longer point at a person, a
|
||||
// name or a picture.
|
||||
//
|
||||
// The images go FIRST. If the database transaction commits and the object
|
||||
// delete then fails, the keys are gone and nothing knows which files to remove
|
||||
// - the image outlives the erasure request with no record that it should not.
|
||||
func (s *Server) handleForgetVisitor(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot delete customer records.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
|
||||
return
|
||||
}
|
||||
|
||||
keys, err := s.Store.VisitorImageKeys(r.Context(), p.ClientID, id)
|
||||
if err != nil {
|
||||
s.serverError(w, "list images for erasure", err)
|
||||
return
|
||||
}
|
||||
if s.Blob != nil {
|
||||
for _, key := range keys {
|
||||
if err := s.Blob.Delete(r.Context(), key); err != nil {
|
||||
// Refuse the whole request. Reporting an erasure as done while
|
||||
// a face image is still in the bucket is the one outcome this
|
||||
// endpoint must never produce.
|
||||
s.logf("ERROR erasure %s: cannot delete %s: %v", id, key, err)
|
||||
writeErr(w, http.StatusBadGateway, "storage_error",
|
||||
"The photo could not be deleted, so nothing was erased. "+
|
||||
"Please try again.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.Store.ForgetVisitor(r.Context(), p.ClientID, id); err != nil {
|
||||
if strings.Contains(err.Error(), "no such visitor") {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "forget visitor", err)
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "visitor.forget", Entity: "visitor", EntityID: id,
|
||||
Detail: map[string]any{"images_deleted": len(keys)},
|
||||
})
|
||||
s.logf("erasure: visitor %s for client %s, %d image(s) deleted",
|
||||
id, p.ClientID, len(keys))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
172
server/internal/api/handlers_people.go
Normal file
172
server/internal/api/handlers_people.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
func (s *Server) handleVisitors(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
q := trim(r.URL.Query().Get("q"))
|
||||
limit := queryInt(r, "limit", 50, 500)
|
||||
|
||||
out, err := s.Store.SearchVisitors(r.Context(), p.ClientID, q, limit)
|
||||
if err != nil {
|
||||
s.serverError(w, "search visitors", err)
|
||||
return
|
||||
}
|
||||
if out == nil {
|
||||
out = []Customer{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleVisitorHistory(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
// 404, not 400: to the caller a malformed id and an id that does not
|
||||
// exist are the same thing - the customer is not there.
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
|
||||
return
|
||||
}
|
||||
rows, err := s.Store.VisitorHistory(r.Context(), p.ClientID, id,
|
||||
queryInt(r, "limit", 100, 1000))
|
||||
if err != nil {
|
||||
s.serverError(w, "visitor history", err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []VisitRow{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// handleSaveProfile attaches a name, a phone number and a consent record to a
|
||||
// face the system already knows.
|
||||
//
|
||||
// PUT, and idempotent on visitor_id: staff fill this in on a shop floor with
|
||||
// bad wifi, and a resubmit must correct the record rather than create a second
|
||||
// one for the same person.
|
||||
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanWriteProfiles() {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot edit customer details.")
|
||||
return
|
||||
}
|
||||
var body Profile
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
// The path wins over the body. Trusting the body would let a client PUT to
|
||||
// one customer's URL and write to another's record.
|
||||
body.VisitorID = r.PathValue("id")
|
||||
if !looksLikeUUID(body.VisitorID) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
|
||||
return
|
||||
}
|
||||
body.FullName = clip(trim(body.FullName), 200)
|
||||
body.Phone = clip(trim(body.Phone), 40)
|
||||
body.Email = auth.NormalizeEmail(body.Email)
|
||||
body.Gender = clip(trim(body.Gender), 32)
|
||||
body.Notes = clip(trim(body.Notes), 2000)
|
||||
if body.DateOfBirth != "" {
|
||||
if _, err := time.Parse("2006-01-02", body.DateOfBirth); err != nil {
|
||||
badRequest(w, "date of birth must look like 2001-04-23")
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.FullName == "" && body.Phone == "" && body.Email == "" {
|
||||
badRequest(w, "give at least a name, a phone number or an email")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Store.SaveProfile(r.Context(), p.ClientID, body, p.UserID); err != nil {
|
||||
if strings.Contains(err.Error(), "no such visitor") {
|
||||
// 404, not 403: within one client this is a typo, and the tenant
|
||||
// scoping in the query already made a cross-tenant id unfindable.
|
||||
writeErr(w, http.StatusNotFound, "not_found",
|
||||
"That customer no longer exists.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "save profile", err)
|
||||
return
|
||||
}
|
||||
// Audited because it links a real name to a biometric template. If a client
|
||||
// ever asks who put a name to a face, a guess is not an answer.
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "profile.save", Entity: "visitor", EntityID: body.VisitorID,
|
||||
Detail: map[string]any{"consent": body.Consent},
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanWriteProfiles() {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot record purchases.")
|
||||
return
|
||||
}
|
||||
var body PurchaseInput
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
body.VisitorID = trim(body.VisitorID)
|
||||
if body.VisitorID == "" {
|
||||
badRequest(w, "visitor_id is required")
|
||||
return
|
||||
}
|
||||
if !looksLikeUUID(body.VisitorID) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
|
||||
return
|
||||
}
|
||||
if body.Amount < 0 {
|
||||
// A refund is a different record with a different meaning, not a
|
||||
// negative sale. Allowing it here would quietly deflate the revenue
|
||||
// figure the conversion report is judged by.
|
||||
badRequest(w, "amount cannot be negative")
|
||||
return
|
||||
}
|
||||
if body.Currency == "" {
|
||||
body.Currency = "INR"
|
||||
}
|
||||
if len(body.Currency) != 3 {
|
||||
badRequest(w, "currency must be a 3-letter code")
|
||||
return
|
||||
}
|
||||
body.Currency = strings.ToUpper(body.Currency)
|
||||
if body.Source == "" {
|
||||
body.Source = "manual"
|
||||
}
|
||||
body.Notes = clip(trim(body.Notes), 2000)
|
||||
|
||||
if err := s.Store.RecordPurchase(r.Context(), p.ClientID, body, p.UserID); err != nil {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "no such visitor"):
|
||||
writeErr(w, http.StatusNotFound, "not_found",
|
||||
"That customer no longer exists.")
|
||||
case strings.Contains(err.Error(), "no site"):
|
||||
// This is actionable, so it says what to do rather than failing
|
||||
// with a foreign key error nobody can read.
|
||||
badRequest(w, "this customer has never been seen at a store, "+
|
||||
"so there is no site to book the sale against - pass site_id")
|
||||
default:
|
||||
s.serverError(w, "record purchase", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "purchase.record", Entity: "visitor", EntityID: body.VisitorID,
|
||||
Detail: map[string]any{"amount": body.Amount, "currency": body.Currency},
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
139
server/internal/api/handlers_reports.go
Normal file
139
server/internal/api/handlers_reports.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Buckets a report may be cut into. A whitelist rather than passing the string
|
||||
// through: date_trunc takes a text argument, so an unchecked value is either a
|
||||
// database error surfaced to a shop floor or, in a query built by
|
||||
// concatenation, something much worse.
|
||||
var buckets = map[string]bool{
|
||||
"hour": true, "day": true, "week": true, "month": true,
|
||||
}
|
||||
|
||||
// maxWindow bounds a report at two years. Not for safety - for honesty: an
|
||||
// open-ended range on a 2 vCPU box times out at the proxy and the user sees a
|
||||
// blank screen with no explanation.
|
||||
const maxWindow = 2 * 366 * 24 * time.Hour
|
||||
|
||||
func (s *Server) reportQuery(r *http.Request) (ReportQuery, error) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
q := r.URL.Query()
|
||||
|
||||
// The tenant comes from the session. A client_id parameter would be a
|
||||
// cross-tenant read waiting for somebody to try it.
|
||||
out := ReportQuery{ClientID: p.ClientID, SiteID: trim(q.Get("site"))}
|
||||
|
||||
now := s.now()
|
||||
from, err := parseDay(q.Get("from"), now.AddDate(0, 0, -29))
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("`from` is not a date: %w", err)
|
||||
}
|
||||
to, err := parseDay(q.Get("to"), now)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("`to` is not a date: %w", err)
|
||||
}
|
||||
// `to` is inclusive to the user ("1st to the 7th" includes the 7th) and
|
||||
// exclusive in SQL. Doing that conversion in one place is the difference
|
||||
// between a report that quietly misses its own last day and one that does
|
||||
// not.
|
||||
if len(trim(q.Get("to"))) == 10 {
|
||||
to = to.AddDate(0, 0, 1)
|
||||
}
|
||||
if !to.After(from) {
|
||||
return out, fmt.Errorf("`to` must be after `from`")
|
||||
}
|
||||
if to.Sub(from) > maxWindow {
|
||||
return out, fmt.Errorf("that range is longer than two years - " +
|
||||
"please narrow it")
|
||||
}
|
||||
out.From, out.To = from, to
|
||||
|
||||
out.Bucket = trim(q.Get("bucket"))
|
||||
if out.Bucket == "" {
|
||||
out.Bucket = "day"
|
||||
}
|
||||
if !buckets[out.Bucket] {
|
||||
return out, fmt.Errorf("bucket must be hour, day, week or month")
|
||||
}
|
||||
|
||||
out.Timezone = trim(q.Get("tz"))
|
||||
if out.Timezone == "" {
|
||||
out.Timezone = "UTC"
|
||||
}
|
||||
// Validated here, where a bad name is a 400 the user can fix, rather than
|
||||
// in Postgres where it is a 500.
|
||||
if _, err := time.LoadLocation(out.Timezone); err != nil {
|
||||
return out, fmt.Errorf("unknown timezone %q", out.Timezone)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseDay accepts a plain date or a full RFC3339 timestamp. Shop staff type
|
||||
// dates; the desktop app sends timestamps.
|
||||
func parseDay(s string, def time.Time) (time.Time, error) {
|
||||
s = trim(s)
|
||||
if s == "" {
|
||||
return def.UTC(), nil
|
||||
}
|
||||
if len(s) == 10 {
|
||||
return time.Parse("2006-01-02", s)
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
return t.UTC(), err
|
||||
}
|
||||
|
||||
func (s *Server) handleFootfall(w http.ResponseWriter, r *http.Request) {
|
||||
q, err := s.reportQuery(r)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
points, totals, err := s.Store.Footfall(r.Context(), q)
|
||||
if err != nil {
|
||||
s.serverError(w, "footfall", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, FootfallReport{
|
||||
From: q.From.Format(time.RFC3339),
|
||||
To: q.To.Format(time.RFC3339),
|
||||
Bucket: q.Bucket,
|
||||
TZ: q.Timezone,
|
||||
Points: points,
|
||||
Total: totals.UniqueVisitors,
|
||||
Visits: totals.Visits,
|
||||
FractionBelowGate: totals.FractionBelowGate,
|
||||
WorstSite: totals.WorstSite,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConversion(w http.ResponseWriter, r *http.Request) {
|
||||
q, err := s.reportQuery(r)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
rep, err := s.Store.Conversion(r.Context(), q)
|
||||
if err != nil {
|
||||
s.serverError(w, "conversion", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
}
|
||||
|
||||
func (s *Server) handleSites(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
sites, err := s.Store.SiteHealth(r.Context(), p.ClientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "site health", err)
|
||||
return
|
||||
}
|
||||
if sites == nil {
|
||||
// A JSON null would make every caller handle two empty cases.
|
||||
sites = []SiteHealth{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sites)
|
||||
}
|
||||
86
server/internal/api/hub.go
Normal file
86
server/internal/api/hub.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package api
|
||||
|
||||
import "sync"
|
||||
|
||||
// Hub wakes live listeners when a tenant's data changes.
|
||||
//
|
||||
// It is a DOORBELL, not a delivery service: a notification carries a client id
|
||||
// and nothing else, and every listener answers it by running the same keyset
|
||||
// query a polling client would. That is the whole design decision, and it buys
|
||||
// three things that a hub carrying the rows does not:
|
||||
//
|
||||
// - One query path. The stream and the poll cannot disagree about what an
|
||||
// arrival looks like, because there is only one piece of code that reads
|
||||
// one.
|
||||
// - No lost events. A subscriber that is mid-reconnect, or slow, or was not
|
||||
// listening yet, misses a doorbell and loses nothing - its next query
|
||||
// starts from its own cursor and picks up everything in between. A hub
|
||||
// that pushed rows would have to buffer per subscriber and decide what to
|
||||
// drop, which is a queue, and we already have a durable one.
|
||||
// - Degrades to polling. If a second server instance is ever added, its
|
||||
// ingest rings a doorbell this process never hears. The stream keeps a
|
||||
// slow fallback tick for exactly that, so the failure mode is latency,
|
||||
// not silence.
|
||||
//
|
||||
// Notify is called from the MQTT consumer's goroutine and must never block it:
|
||||
// a slow subscriber must not be able to stall ingest for the whole estate.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
next int
|
||||
subs map[int]chan string
|
||||
}
|
||||
|
||||
func NewHub() *Hub { return &Hub{subs: map[int]chan string{}} }
|
||||
|
||||
// Subscribe returns a channel of client ids and a function to release it.
|
||||
// Callers MUST call the returned func, or the subscriber leaks for the life of
|
||||
// the process - one goroutine and one buffered channel per abandoned HTTP
|
||||
// connection, on an endpoint mobile clients reconnect to all day.
|
||||
func (h *Hub) Subscribe() (<-chan string, func()) {
|
||||
// Buffered by one. A doorbell is idempotent - two rings while the listener
|
||||
// is busy mean the same thing as one, because it re-queries from its
|
||||
// cursor either way - so a single slot is enough and a full channel is a
|
||||
// normal state, not backpressure to worry about.
|
||||
ch := make(chan string, 1)
|
||||
h.mu.Lock()
|
||||
id := h.next
|
||||
h.next++
|
||||
h.subs[id] = ch
|
||||
h.mu.Unlock()
|
||||
|
||||
return ch, func() {
|
||||
h.mu.Lock()
|
||||
if c, ok := h.subs[id]; ok {
|
||||
delete(h.subs, id)
|
||||
close(c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Notify rings every subscriber. Never blocks: a subscriber whose slot is
|
||||
// already full is skipped, because it has a pending wake-up that will make it
|
||||
// re-query anyway.
|
||||
func (h *Hub) Notify(clientID string) {
|
||||
if h == nil || clientID == "" {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, ch := range h.subs {
|
||||
select {
|
||||
case ch <- clientID:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribers is the count, for tests and for /healthz.
|
||||
func (h *Hub) Subscribers() int {
|
||||
if h == nil {
|
||||
return 0
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.subs)
|
||||
}
|
||||
240
server/internal/api/images_test.go
Normal file
240
server/internal/api/images_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// enrol runs a real enrolment and returns the agent's own API token.
|
||||
func enrol(t *testing.T, s *Server, fs *fakeStore) string {
|
||||
t.Helper()
|
||||
code := "ABCDEF-123456"
|
||||
fs.enrolment[hashHex(code)] = Enrolment{
|
||||
ClientID: "client-acme", AgentID: "agent-1", SiteID: "site-1",
|
||||
SiteName: "Chennai", SiteSlug: "store1",
|
||||
MQTTUser: "acme.store1", MQTTPass: "broker-secret",
|
||||
}
|
||||
rec := do(t, s, "POST", "/api/agent/enrol", "", map[string]string{"site_token": code})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("enrol failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck
|
||||
tok, _ := got["agent_token"].(string)
|
||||
if tok == "" {
|
||||
t.Fatal("enrolment did not return an agent token")
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func TestEnrolmentIssuesAnAgentTokenSeparateFromTheBrokerPassword(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
tok := enrol(t, s, fs)
|
||||
// Two secrets for two different questions: the broker password says this
|
||||
// site may publish events, the agent token says it may ask the API for
|
||||
// something. One secret for both means rotating either breaks the other.
|
||||
if tok == "broker-secret" {
|
||||
t.Fatal("the agent token is the broker password")
|
||||
}
|
||||
if _, err := fs.AgentByToken(t.Context(), auth.HashToken(tok)); err != nil {
|
||||
t.Fatalf("the issued token does not authenticate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadURLRequiresAnEnrolledAgent(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
|
||||
if rec := do(t, s, "POST", "/api/agent/upload-url", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated upload-url returned %d", rec.Code)
|
||||
}
|
||||
if rec := do(t, s, "POST", "/api/agent/upload-url", "not-a-token", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a bogus agent token was accepted: %d", rec.Code)
|
||||
}
|
||||
// A staff session is not an agent. The two are authenticated differently
|
||||
// and must not be interchangeable.
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
if rec := do(t, s, "POST", "/api/agent/upload-url", sess.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a user session was accepted as an agent: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The server picks the key from the credential the request authenticated with.
|
||||
// A caller-supplied key would let one site overwrite another's images, which is
|
||||
// the entire reason uploads are presigned instead of shipping a bucket password.
|
||||
func TestTheServerChoosesTheKeyNotTheAgent(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
blob := &fakeBlob{}
|
||||
s.Blob = blob
|
||||
tok := enrol(t, s, fs)
|
||||
|
||||
rec := do(t, s, "POST", "/api/agent/upload-url", tok,
|
||||
map[string]string{"content_type": "image/jpeg"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var target UploadTarget
|
||||
json.Unmarshal(rec.Body.Bytes(), &target) //nolint:errcheck
|
||||
|
||||
if !strings.HasPrefix(target.Key, "behavision/acme/store1/") {
|
||||
t.Fatalf("key is not namespaced to the authenticated site: %q", target.Key)
|
||||
}
|
||||
// The ACL must be handed back for the agent to send, because it is inside
|
||||
// the signature: the shop PC cannot decide to publish the image instead.
|
||||
if target.Headers["x-amz-acl"] != "private" {
|
||||
t.Fatalf("upload does not force a private ACL: %v", target.Headers)
|
||||
}
|
||||
if target.URL == "" || target.ExpiresIn <= 0 {
|
||||
t.Fatalf("incomplete upload target: %+v", target)
|
||||
}
|
||||
|
||||
// Two requests must not collide on one object.
|
||||
rec2 := do(t, s, "POST", "/api/agent/upload-url", tok, nil)
|
||||
var second UploadTarget
|
||||
json.Unmarshal(rec2.Body.Bytes(), &second) //nolint:errcheck
|
||||
if second.Key == target.Key {
|
||||
t.Fatal("two uploads were given the same key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadIsRefusedCleanlyWhenImagesAreOff(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = nil // the default: this product stores no images unless told to
|
||||
tok := enrol(t, s, fs)
|
||||
|
||||
rec := do(t, s, "POST", "/api/agent/upload-url", tok, nil)
|
||||
// 501, not 500: the agent should carry on sending visits without a photo
|
||||
// rather than treating this as a failure to retry.
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("got %d, want 501", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisitorImageIsAShortLivedLinkAndIsAudited(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
blob := &fakeBlob{}
|
||||
s.Blob = blob
|
||||
seedUser(fs)
|
||||
fs.imageKeys[visitorAID] = "behavision/acme/store1/2026/08/31/abc.jpg"
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", visitorA+"/image", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &body) //nolint:errcheck
|
||||
url, _ := body["url"].(string)
|
||||
if !strings.Contains(url, "X-Amz-Signature") {
|
||||
t.Fatalf("not a presigned link: %q", url)
|
||||
}
|
||||
if body["expires_in"] == nil {
|
||||
t.Fatal("the caller is not told the link expires")
|
||||
}
|
||||
// Every read of a face image is worth a row: "who looked at my customers"
|
||||
// needs an answer that is not a guess.
|
||||
var audited bool
|
||||
for _, a := range fs.audits {
|
||||
if a.Action == "image.view" && a.EntityID == visitorAID {
|
||||
audited = true
|
||||
}
|
||||
}
|
||||
if !audited {
|
||||
t.Fatalf("viewing a face image was not audited: %+v", fs.audits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherTenantsImageIsNotFound(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
// The store scopes by client, so a foreign id simply has no key.
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, s, "GET", "/api/visitors/"+visitorB+"/image", sess.Token, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// -- erasure ----------------------------------------------------------------
|
||||
|
||||
func TestErasureDeletesTheImageBeforeTheDatabaseRow(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
blob := &fakeBlob{}
|
||||
s.Blob = blob
|
||||
seedUser(fs)
|
||||
key := "behavision/acme/store1/2026/08/31/abc.jpg"
|
||||
fs.imageKeys[visitorAID] = key
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "DELETE", visitorA, sess.Token, nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(blob.deleted) != 1 || blob.deleted[0] != key {
|
||||
t.Fatalf("the face image was not deleted from storage: %v", blob.deleted)
|
||||
}
|
||||
if len(fs.forgotten) != 1 || fs.forgotten[0] != visitorAID {
|
||||
t.Fatalf("the database record was not erased: %v", fs.forgotten)
|
||||
}
|
||||
}
|
||||
|
||||
// If the object delete fails and the row is erased anyway, the keys are gone
|
||||
// and nothing knows which files to remove - the image outlives the request with
|
||||
// no record that it should not. Reporting success there is the one outcome this
|
||||
// endpoint must never produce.
|
||||
func TestAFailedImageDeleteAbortsTheWholeErasure(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
blob := &fakeBlob{failNext: errors.New("bucket unreachable")}
|
||||
s.Blob = blob
|
||||
seedUser(fs)
|
||||
fs.imageKeys[visitorAID] = "behavision/acme/store1/2026/08/31/abc.jpg"
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "DELETE", visitorA, sess.Token, nil)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("got %d, want 502", rec.Code)
|
||||
}
|
||||
if len(fs.forgotten) != 0 {
|
||||
t.Fatal("the database row was erased while the photo survived")
|
||||
}
|
||||
// And the operator is told to retry rather than believing it is done.
|
||||
if !strings.Contains(strings.ToLower(rec.Body.String()), "try again") {
|
||||
t.Fatalf("unhelpful message: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyManagersAndAboveCanErase(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
fs.addUser("shopfloor@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u7", ClientID: "client-acme", Role: "staff", Active: true,
|
||||
})
|
||||
sess := login(t, s, "shopfloor@acme.com", "correct horse battery")
|
||||
// Staff fill in the customer form; destroying a record is a different
|
||||
// decision with a different blast radius.
|
||||
if rec := do(t, s, "DELETE", visitorA, sess.Token, nil); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("staff could erase a customer: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErasureWithNoImageStillErasesTheRecord(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
// Most visitors have no photo. Erasure must not depend on there being one.
|
||||
if rec := do(t, s, "DELETE", visitorA, sess.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.forgotten) != 1 {
|
||||
t.Fatalf("record not erased: %v", fs.forgotten)
|
||||
}
|
||||
}
|
||||
25
server/internal/api/main_test.go
Normal file
25
server/internal/api/main_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// The whole package runs at bcrypt's minimum cost.
|
||||
//
|
||||
// Almost every test here signs in, and at the production cost of 12 that is
|
||||
// ~500 ms of hashing per test for a hash and a verify. Under the race detector
|
||||
// that pushed this package past `go test`'s ten-minute default - a CI failure
|
||||
// with no failing assertion in it, which is the worst kind to debug.
|
||||
//
|
||||
// What this does NOT weaken: the tests that care about hashing care about
|
||||
// whether two paths take the SAME time as each other, not how long either
|
||||
// takes. Lowering both sides equally leaves that intact.
|
||||
func TestMain(m *testing.M) {
|
||||
restore := auth.UseTestCost()
|
||||
code := m.Run()
|
||||
restore()
|
||||
os.Exit(code)
|
||||
}
|
||||
17
server/internal/api/testerrors_test.go
Normal file
17
server/internal/api/testerrors_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoSite = errors.New("no site for this visitor")
|
||||
errBoom = errors.New(`relation "visitor_profiles" does not exist: boom`)
|
||||
)
|
||||
|
||||
func hashHex(code string) string {
|
||||
return hex.EncodeToString(auth.HashToken(auth.NormalizeCode(code)))
|
||||
}
|
||||
114
server/internal/api/throttle.go
Normal file
114
server/internal/api/throttle.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Throttle limits failed sign-in attempts.
|
||||
//
|
||||
// bcrypt at cost 12 already makes each guess cost ~250 ms, but that is a
|
||||
// per-attempt cost, not a per-attacker one: a hundred parallel guesses is a
|
||||
// hundred parallel bcrypts on a 2 vCPU box, which is both a brute force and a
|
||||
// denial of service on the machine every shop depends on.
|
||||
//
|
||||
// Only FAILURES count. A busy shop where staff sign in all morning is not an
|
||||
// attack, and a limiter that cannot tell the difference gets switched off.
|
||||
//
|
||||
// In memory, not in Postgres: this is one process, and a lockout table would
|
||||
// add a write to the very path an attacker is trying to flood.
|
||||
type Throttle struct {
|
||||
// Max failures within Window before refusing.
|
||||
Max int
|
||||
Window time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewThrottle(max int, window time.Duration) *Throttle {
|
||||
return &Throttle{
|
||||
Max: max, Window: window,
|
||||
hits: make(map[string][]time.Time),
|
||||
now: func() time.Time { return time.Now() },
|
||||
}
|
||||
}
|
||||
|
||||
// Allow reports whether a key may attempt again, without recording anything.
|
||||
func (t *Throttle) Allow(key string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.live(key)) < t.Max
|
||||
}
|
||||
|
||||
// Fail records a failed attempt.
|
||||
func (t *Throttle) Fail(key string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.hits[key] = append(t.live(key), t.now())
|
||||
}
|
||||
|
||||
// Reset clears a key after a success, so one forgotten password in the morning
|
||||
// does not lock somebody out at lunchtime.
|
||||
func (t *Throttle) Reset(key string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
delete(t.hits, key)
|
||||
}
|
||||
|
||||
// live returns the still-relevant attempts and prunes the rest. Pruning on read
|
||||
// is what keeps the map from growing forever without a sweeper goroutine —
|
||||
// every key that stops being touched stops existing the next time it is.
|
||||
func (t *Throttle) live(key string) []time.Time {
|
||||
cutoff := t.now().Add(-t.Window)
|
||||
kept := t.hits[key][:0]
|
||||
for _, at := range t.hits[key] {
|
||||
if at.After(cutoff) {
|
||||
kept = append(kept, at)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(t.hits, key)
|
||||
return nil
|
||||
}
|
||||
t.hits[key] = kept
|
||||
return kept
|
||||
}
|
||||
|
||||
// Sweep drops keys with nothing live left. Called on a timer so an attacker
|
||||
// spraying a million distinct addresses cannot grow the map without bound
|
||||
// between requests for those same addresses.
|
||||
func (t *Throttle) Sweep() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for k := range t.hits {
|
||||
t.live(k)
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP prefers the proxy's forwarded address because Traefik terminates
|
||||
// TLS in front of this, so RemoteAddr is always the proxy.
|
||||
//
|
||||
// Trusting X-Forwarded-For is only safe BECAUSE nothing reaches this port
|
||||
// except through that proxy; exposed directly, a client sets the header itself
|
||||
// and defeats the limiter. If the listener ever becomes reachable, this must
|
||||
// change with it.
|
||||
func clientIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
// Left-most is the original client; the rest are proxies.
|
||||
for i := 0; i < len(fwd); i++ {
|
||||
if fwd[i] == ',' {
|
||||
return trim(fwd[:i])
|
||||
}
|
||||
}
|
||||
return trim(fwd)
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
79
server/internal/api/throttle_test.go
Normal file
79
server/internal/api/throttle_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestThrottleBlocksAfterMaxAndForgetsAfterTheWindow(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
th := NewThrottle(3, time.Minute)
|
||||
th.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if !th.Allow("k") {
|
||||
t.Fatalf("blocked after %d failures, max is 3", i)
|
||||
}
|
||||
th.Fail("k")
|
||||
}
|
||||
if th.Allow("k") {
|
||||
t.Fatal("still allowed after hitting the maximum")
|
||||
}
|
||||
// A different key is a different attacker.
|
||||
if !th.Allow("other") {
|
||||
t.Fatal("one key's failures blocked another")
|
||||
}
|
||||
|
||||
now = now.Add(time.Minute + time.Second)
|
||||
if !th.Allow("k") {
|
||||
t.Fatal("the window never expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottleResetClearsAKey(t *testing.T) {
|
||||
th := NewThrottle(2, time.Minute)
|
||||
th.Fail("k")
|
||||
th.Fail("k")
|
||||
if th.Allow("k") {
|
||||
t.Fatal("not blocked")
|
||||
}
|
||||
th.Reset("k")
|
||||
if !th.Allow("k") {
|
||||
t.Fatal("a successful sign-in did not clear the counter")
|
||||
}
|
||||
}
|
||||
|
||||
// Pruning happens on read, so a key nobody touches again must not survive a
|
||||
// sweep. Otherwise an attacker spraying distinct addresses grows the map
|
||||
// without bound.
|
||||
func TestThrottleDoesNotGrowForever(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
th := NewThrottle(5, time.Minute)
|
||||
th.now = func() time.Time { return now }
|
||||
for i := 0; i < 1000; i++ {
|
||||
th.Fail("key" + itoa(i))
|
||||
}
|
||||
if len(th.hits) != 1000 {
|
||||
t.Fatalf("expected 1000 keys, got %d", len(th.hits))
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
th.Sweep()
|
||||
if len(th.hits) != 0 {
|
||||
t.Fatalf("%d keys survived the sweep", len(th.hits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPPrefersTheProxyHeader(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/api/auth/login", nil)
|
||||
r.RemoteAddr = "10.0.0.5:44321"
|
||||
if got := clientIP(r); got != "10.0.0.5" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
// Traefik terminates TLS in front of this, so RemoteAddr is always the
|
||||
// proxy and the left-most forwarded address is the real client.
|
||||
r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
|
||||
if got := clientIP(r); got != "203.0.113.9" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
585
server/internal/api/types.go
Normal file
585
server/internal/api/types.go
Normal file
@@ -0,0 +1,585 @@
|
||||
package api
|
||||
|
||||
import "time"
|
||||
|
||||
// UserRecord is the row behind a login. It carries the password hash, so it
|
||||
// must never be serialised - the wire type is User.
|
||||
type UserRecord struct {
|
||||
ID string
|
||||
ClientID string
|
||||
ClientName string
|
||||
Email string
|
||||
FullName string
|
||||
Role string
|
||||
Active bool
|
||||
PasswordHash string
|
||||
// Found is false when no such address exists. The handler still verifies a
|
||||
// password against a dummy hash in that case, so an unknown address costs
|
||||
// the same time as a wrong password.
|
||||
Found bool
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
ClientID string `json:"client_id"`
|
||||
Client string `json:"client_name"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
// NewSession is a session about to be written. Only hashes cross this
|
||||
// boundary: the plaintext tokens exist in the handler and in the response, and
|
||||
// nowhere else.
|
||||
type NewSession struct {
|
||||
UserID string
|
||||
ClientID string
|
||||
AccessHash []byte
|
||||
RefreshHash []byte
|
||||
AccessExpiry time.Time
|
||||
RefreshExp time.Time
|
||||
Device string
|
||||
}
|
||||
|
||||
// ReportQuery is a resolved, validated window. Handlers build it; the store
|
||||
// trusts it. In particular ClientID here always comes from the session.
|
||||
type ReportQuery struct {
|
||||
ClientID string
|
||||
SiteID string // empty = every site this client has
|
||||
From time.Time
|
||||
To time.Time
|
||||
Bucket string // hour | day | week | month
|
||||
Timezone string // IANA name; buckets are cut in local time
|
||||
}
|
||||
|
||||
type FootfallPoint struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Visitors int `json:"visitors"`
|
||||
New int `json:"new"`
|
||||
Returning int `json:"returning"`
|
||||
}
|
||||
|
||||
// Totals are computed over the whole window, not summed from the points.
|
||||
//
|
||||
// A person who came on Monday and Thursday is two bucket-visitors and one
|
||||
// unique visitor, so the chart does not add up to the total. That is the
|
||||
// arithmetic being right, not a bug — and it is why both numbers ship.
|
||||
type Totals struct {
|
||||
UniqueVisitors int
|
||||
Visits int
|
||||
// Worst fraction_below_gate across the client's sites, and which site that
|
||||
// was. Worst rather than average: one badly placed camera is a hole in the
|
||||
// report, and averaging it against three good ones hides exactly the site
|
||||
// that needs attention.
|
||||
FractionBelowGate float64
|
||||
WorstSite string
|
||||
}
|
||||
|
||||
type FootfallReport struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Bucket string `json:"bucket"`
|
||||
TZ string `json:"timezone"`
|
||||
Points []FootfallPoint `json:"points"`
|
||||
// Total is unique people over the window. Summing Points instead counts a
|
||||
// returning customer once per bucket they appear in.
|
||||
Total int `json:"total"`
|
||||
Visits int `json:"visits"`
|
||||
// Share of faces the cameras saw that fell below the enrolment gate. A
|
||||
// footfall figure from a badly placed camera is wrong in a way the figure
|
||||
// itself cannot show, so it travels with its own confidence.
|
||||
FractionBelowGate float64 `json:"fraction_below_gate"`
|
||||
WorstSite string `json:"worst_site,omitempty"`
|
||||
}
|
||||
|
||||
type SalesReport struct {
|
||||
Visitors int `json:"visitors"`
|
||||
Purchasers int `json:"purchasers"`
|
||||
Conversion float64 `json:"conversion"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AvgBasket float64 `json:"average_basket"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
VisitCount int `json:"visit_count"`
|
||||
FirstSeenAt string `json:"first_seen_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
HasProfile bool `json:"has_profile"`
|
||||
HasConsent bool `json:"has_consent"`
|
||||
}
|
||||
|
||||
type VisitRow struct {
|
||||
ID string `json:"id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Site string `json:"site"`
|
||||
CameraID string `json:"camera_id"`
|
||||
IsNew bool `json:"is_new_visitor"`
|
||||
Similarity float64 `json:"similarity,omitempty"`
|
||||
Quality float64 `json:"quality,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
VisitorID string `json:"visitor_id"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
Gender string `json:"gender"`
|
||||
DateOfBirth string `json:"date_of_birth"`
|
||||
Notes string `json:"notes"`
|
||||
Consent bool `json:"consent"`
|
||||
}
|
||||
|
||||
type PurchaseInput struct {
|
||||
VisitorID string `json:"visitor_id"`
|
||||
SiteID string `json:"site_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Items []string `json:"items"`
|
||||
Source string `json:"source"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// SiteHealth is what the dashboard needs to distinguish "no customers" from
|
||||
// "this shop's PC has been unplugged for a week" - two identical rows of zeroes
|
||||
// with completely different responses.
|
||||
type SiteHealth struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
Online bool `json:"online"`
|
||||
LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"`
|
||||
LastEventAt string `json:"last_event_at,omitempty"`
|
||||
RecognitionModel string `json:"recognition_model,omitempty"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
CamerasUp int `json:"cameras_up"`
|
||||
CamerasTotal int `json:"cameras_total"`
|
||||
FractionBelowGate float64 `json:"fraction_below_gate"`
|
||||
Queued int `json:"queued"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
}
|
||||
|
||||
// Enrolment is one redeemed install token: which site this PC now is, and the
|
||||
// broker credentials for it.
|
||||
type Enrolment struct {
|
||||
ClientID string
|
||||
AgentID string
|
||||
SiteID string
|
||||
SiteName string
|
||||
SiteSlug string
|
||||
MQTTUser string
|
||||
MQTTPass string
|
||||
}
|
||||
|
||||
type AuditEntry struct {
|
||||
ClientID string
|
||||
ActorID string
|
||||
ActorKind string
|
||||
Action string
|
||||
Entity string
|
||||
EntityID string
|
||||
Detail map[string]any
|
||||
}
|
||||
|
||||
// UploadTarget is a one-object write permit.
|
||||
//
|
||||
// The server picks the key, so a site cannot write into another site's prefix;
|
||||
// the URL expires; and the ACL is inside the signature, so the agent cannot
|
||||
// decide to publish the image instead of keeping it private.
|
||||
type UploadTarget struct {
|
||||
Key string `json:"key"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// AgentPrincipal is a store PC, authenticated by its own API token. It is not
|
||||
// a Principal: an agent has no user, no role and no session, and giving it one
|
||||
// would mean one set of permission checks answering two different questions.
|
||||
type AgentPrincipal struct {
|
||||
AgentID string
|
||||
ClientID string
|
||||
SiteID string
|
||||
Slug string // <client>.<site>
|
||||
Client string
|
||||
Site string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- arrivals
|
||||
|
||||
// Arrival is one person walking in, as a mobile app or a shop screen needs it:
|
||||
// the visit, who it was, and a link to their face — in ONE row.
|
||||
//
|
||||
// This is deliberately not VisitRow. VisitRow answers "when has this customer
|
||||
// been here before", so it already knows who the person is and needs no photo.
|
||||
// An arrivals feed answers the opposite question — the caller does not know who
|
||||
// walked in — so the identity and the picture have to travel with the visit.
|
||||
// Splitting them would mean a client that sees four people arrive together
|
||||
// makes nine requests to render one screen, and writes four rows into the image
|
||||
// audit log to do it.
|
||||
type Arrival struct {
|
||||
VisitID string `json:"visit_id"`
|
||||
// Seq is this visit's position in the feed - assigned by the server when it
|
||||
// learned of the visit, not by the camera. Exposed because a client that
|
||||
// wants to know whether it has fallen behind can compare two of them; the
|
||||
// cursor remains the supported way to page.
|
||||
Seq int64 `json:"seq"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
SiteID string `json:"site_id"`
|
||||
Site string `json:"site"`
|
||||
CameraID string `json:"camera_id"`
|
||||
IsNew bool `json:"is_new_visitor"`
|
||||
Similarity float64 `json:"similarity,omitempty"`
|
||||
Quality float64 `json:"quality,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
|
||||
// VisitorID is empty when the site sent a count with no template. That is
|
||||
// real footfall by an unknown person, not an error, and it must still
|
||||
// appear in the feed - a shop watching arrivals would otherwise see fewer
|
||||
// people than walked in.
|
||||
VisitorID string `json:"visitor_id,omitempty"`
|
||||
// Label is the system's own name ("Visitor 12"); Name is what a human
|
||||
// typed. Both are sent so the client does not have to guess which is
|
||||
// present, and so a screen can show the real name and still let staff
|
||||
// search on the label they see in the desktop app.
|
||||
Label string `json:"label,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Image is always present, never omitted. An absent field would make a
|
||||
// client treat "photos are switched off for this deployment" and "the
|
||||
// upload failed" as the same thing, and they need opposite reactions.
|
||||
Image Image `json:"image"`
|
||||
|
||||
// ImageKey is the object-store key, carried from the store to the handler
|
||||
// that presigns it. `json:"-"` is load-bearing: a raw key names another
|
||||
// tenant's prefix and is the input to every signing call, so if a handler
|
||||
// ever forgets to swap it for a signed link the field must be incapable of
|
||||
// reaching a client. Marshalling is the wrong place to find that out.
|
||||
ImageKey string `json:"-"`
|
||||
}
|
||||
|
||||
// Image is a short-lived link to a face, or a sentence saying why there is not
|
||||
// one.
|
||||
//
|
||||
// Absence is DATA here, not an error. Images default to off across the whole
|
||||
// product, so on most deployments every arrival legitimately has no photo; a
|
||||
// client that renders a failure state for that shows a screen full of red for
|
||||
// a system working exactly as configured.
|
||||
type Image struct {
|
||||
Available bool `json:"available"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
// Reason is user-facing prose, present only when Available is false.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Key is the object-store key, carried from the store to the handler that
|
||||
// presigns it. `json:"-"` is load-bearing: a raw key names a tenant's
|
||||
// storage prefix, so a handler that forgets to swap it for a signed link
|
||||
// must be incapable of leaking it. Marshalling is the wrong place to find
|
||||
// that out.
|
||||
Key string `json:"-"`
|
||||
}
|
||||
|
||||
// ArrivalPage is one poll of the feed.
|
||||
type ArrivalPage struct {
|
||||
Arrivals []Arrival `json:"arrivals"`
|
||||
// Cursor is opaque and MUST be echoed back on the next poll. It is the
|
||||
// only thing that makes the feed lossless: a burst bigger than `limit`
|
||||
// leaves rows behind, and a caller polling by timestamp alone would skip
|
||||
// them permanently.
|
||||
Cursor string `json:"cursor"`
|
||||
// PolledAt lets a client show "as of ..." without trusting its own clock,
|
||||
// which on a shop tablet is routinely minutes out.
|
||||
PolledAt string `json:"polled_at"`
|
||||
}
|
||||
|
||||
// ArrivalQuery is the keyset window the store reads.
|
||||
type ArrivalQuery struct {
|
||||
ClientID string
|
||||
SiteID string
|
||||
// AfterSeq is the keyset position: the last position this caller has seen.
|
||||
//
|
||||
// A POINTER, so that "no cursor" and "the cursor is zero" stay different
|
||||
// questions. Nil is an app opening for the first time and gets the most
|
||||
// recent window; zero is a caller deliberately replaying from the
|
||||
// beginning, which positions start at 1 so nothing is excluded. Collapsing
|
||||
// the two onto a zero int silently turns a replay request into "give me
|
||||
// the newest page", which is a client that believes it has caught up on
|
||||
// history it never received.
|
||||
AfterSeq *int64
|
||||
// Since is the timestamp form of the same question, for a caller that has
|
||||
// no cursor yet but knows when it last looked. Resolved against when the
|
||||
// server LEARNED of a visit, so it means the same thing as the cursor it
|
||||
// becomes on the next poll.
|
||||
Since *time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tenancy
|
||||
|
||||
// NewClientInput creates a tenant and the person who owns it, together.
|
||||
//
|
||||
// One call, not two, because a client with no owner is a tenant nobody can
|
||||
// sign into - a half-created state an operator would have to notice and repair
|
||||
// by hand, on the one screen where they have least context.
|
||||
type NewClientInput struct {
|
||||
CompanyName string `json:"company_name"`
|
||||
Slug string `json:"slug"`
|
||||
OwnerEmail string `json:"owner_email"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
// Password is optional. Empty means "generate one", which is the better
|
||||
// default: an operator typing a password for someone else invents a weak
|
||||
// one and then sends it over chat.
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// NewClientResult is the only moment the owner's password exists in readable
|
||||
// form. It is bcrypt-hashed on the way in and is not recoverable afterwards.
|
||||
type NewClientResult struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Slug string `json:"slug"`
|
||||
OwnerEmail string `json:"owner_email"`
|
||||
// Password is shown once. A credential a support engineer can look up
|
||||
// later is a credential everyone with support access holds.
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// ClientRow is one tenant on the platform-admin list.
|
||||
type ClientRow struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Sites int `json:"sites"`
|
||||
Users int `json:"users"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cameras
|
||||
|
||||
// Camera is one camera as head office sees it: how it is configured, and
|
||||
// whether it is actually working.
|
||||
//
|
||||
// Those two are deliberately one object. "Is this camera set up" and "is this
|
||||
// camera working" are the two halves of the only question anyone asks about a
|
||||
// camera, and answering them from two places produces a screen where a camera
|
||||
// can look configured and dead at the same time with no indication which fact
|
||||
// is stale.
|
||||
type Camera struct {
|
||||
ID string `json:"id"`
|
||||
SiteID string `json:"site_id"`
|
||||
Site string `json:"site,omitempty"`
|
||||
// CameraID is what the ENGINE knows it by, and what lands in
|
||||
// visits.camera_id. Stable for the life of the camera.
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Path string `json:"path"`
|
||||
Username string `json:"username"`
|
||||
// HasPassword, never the password. A camera credential is a live path into
|
||||
// the camera itself; the only consumer that needs the plaintext is the
|
||||
// agent for its own site, through a different endpoint. A field called
|
||||
// `password` that is sometimes populated is how one gets returned by
|
||||
// accident.
|
||||
HasPassword bool `json:"has_password"`
|
||||
|
||||
MaxWidth int `json:"max_width"`
|
||||
Tuning map[string]any `json:"tuning,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Revision int64 `json:"revision"`
|
||||
|
||||
// Observed, reported by the agent. Connected is a POINTER because "this
|
||||
// camera is down" and "no agent has told us anything yet" need different
|
||||
// words on screen, and a bare false says the first when it means the second.
|
||||
Connected *bool `json:"connected,omitempty"`
|
||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||
Snapshot Image `json:"snapshot"`
|
||||
SnapshotAt string `json:"snapshot_at,omitempty"`
|
||||
|
||||
// Check is the last attempt to prove this camera works. Always present so
|
||||
// a client can tell "never checked" from "checked and failed" without
|
||||
// guessing from an absent field.
|
||||
Check CameraCheck `json:"check"`
|
||||
}
|
||||
|
||||
// CameraInput is what an operator submits. Pointers throughout, because a
|
||||
// blank field means "leave this alone" and an omitted one must not overwrite
|
||||
// a stored value with an empty string - the same rule the desktop camera form
|
||||
// already follows.
|
||||
type CameraInput struct {
|
||||
CameraID *string `json:"camera_id,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Host *string `json:"host,omitempty"`
|
||||
Port *int `json:"port,omitempty"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
MaxWidth *int `json:"max_width,omitempty"`
|
||||
Tuning *map[string]any `json:"tuning,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// AgentCamera is the same camera as the AGENT needs it: with the plaintext
|
||||
// password, because it is the thing that has to connect.
|
||||
//
|
||||
// A separate type from Camera on purpose. If one struct served both, the only
|
||||
// thing stopping a tenant response carrying camera passwords would be
|
||||
// remembering to blank a field, on every path, forever.
|
||||
type AgentCamera struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Path string `json:"path"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
MaxWidth int `json:"max_width"`
|
||||
Tuning map[string]any `json:"tuning,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Revision int64 `json:"revision"`
|
||||
// Deleted cameras are SENT, not omitted. The agent cannot tell "removed by
|
||||
// head office" from "not yet adopted" by absence alone, and would re-adopt
|
||||
// the camera somebody just deleted.
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
// EnrolmentCode is the one-shot code an installer types into a shop PC.
|
||||
//
|
||||
// Returned in full exactly once, at the moment it is minted: only a hash is
|
||||
// stored, so nobody - support included - can look it up again. Losing it costs
|
||||
// one more code, which is cheap; being able to read one back would mean every
|
||||
// person with database access could claim a PC into somebody's shop.
|
||||
type EnrolmentCode struct {
|
||||
Code string `json:"code"`
|
||||
SiteID string `json:"site_id"`
|
||||
SiteName string `json:"site_name"`
|
||||
Label string `json:"label,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// NewEnrolmentCodeInput is what an operator submits. Both fields optional: a
|
||||
// label is a note to themselves, and the default lifetime is a week.
|
||||
type NewEnrolmentCodeInput struct {
|
||||
Label string `json:"label"`
|
||||
Days int `json:"days"`
|
||||
}
|
||||
|
||||
// AgentCameraState is the agent reporting back what it observes.
|
||||
type AgentCameraState struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
Connected bool `json:"connected"`
|
||||
SnapshotKey string `json:"snapshot_key,omitempty"`
|
||||
}
|
||||
|
||||
// AgentCameraReport is one sync from a shop PC: what it sees, and any camera
|
||||
// configured locally that head office does not know about yet.
|
||||
type AgentCameraReport struct {
|
||||
State []AgentCameraState `json:"state,omitempty"`
|
||||
// Adopt carries cameras found in the engine's own store but absent here.
|
||||
// Without adoption, switching this feature on would delete the cameras a
|
||||
// site is already running - including the one it was commissioned with.
|
||||
Adopt []AgentCamera `json:"adopt,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- checks
|
||||
|
||||
// CameraCheck is the state of proving one camera works.
|
||||
//
|
||||
// The engine's own words travel through untouched. `verdict`, `headline` and
|
||||
// `advice` are written for the person standing next to the camera, and
|
||||
// re-wording them here and again in the browser is how three descriptions of
|
||||
// one failure drift apart.
|
||||
type CameraCheck struct {
|
||||
// Kind is "connection" (can the shop PC open the stream) or "placement"
|
||||
// (does somebody walking past produce a view worth enrolling). Two
|
||||
// questions, two answers, because a camera can pass the first and fail the
|
||||
// second - which is exactly what happened on the Office1 camera for weeks.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
// State is requested | running | done. Absent means no check has been run.
|
||||
State string `json:"state,omitempty"`
|
||||
RequestedAt string `json:"requested_at,omitempty"`
|
||||
FinishedAt string `json:"finished_at,omitempty"`
|
||||
Seconds int `json:"seconds,omitempty"`
|
||||
|
||||
OK bool `json:"ok"`
|
||||
// Verdict, Headline and Advice come from the engine verbatim.
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Advice []string `json:"advice,omitempty"`
|
||||
// Detail is whatever else the engine reported - resolution, face counts,
|
||||
// quality spread. Passed through so a new engine field reaches the UI
|
||||
// without a schema change on the way.
|
||||
Detail map[string]any `json:"detail,omitempty"`
|
||||
// Image is the frame captured during the check: the operator's proof that
|
||||
// the camera is pointing where they think it is.
|
||||
Image Image `json:"image"`
|
||||
}
|
||||
|
||||
// CheckRequest is an operator asking for a check.
|
||||
type CheckRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
// Seconds applies to a placement check only. Long enough for somebody to
|
||||
// walk through the frame and out of it, which is what the check measures.
|
||||
Seconds int `json:"seconds,omitempty"`
|
||||
}
|
||||
|
||||
// AgentCheckJob is one pending check as the shop PC receives it.
|
||||
type AgentCheckJob struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
Kind string `json:"kind"`
|
||||
Seconds int `json:"seconds"`
|
||||
}
|
||||
|
||||
// AgentCheckResult is the shop PC reporting back.
|
||||
type AgentCheckResult struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
OK bool `json:"ok"`
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Advice []string `json:"advice,omitempty"`
|
||||
Detail map[string]any `json:"detail,omitempty"`
|
||||
ImageKey string `json:"image_key,omitempty"`
|
||||
}
|
||||
|
||||
// SiteCheck is the end-to-end answer for one shop: not "is a camera plugged
|
||||
// in" but "is this site actually working".
|
||||
//
|
||||
// Assembled from what head office already knows, so it costs no round trip to
|
||||
// the shop and works even when the PC is off - which is itself one of the
|
||||
// answers.
|
||||
type SiteCheck struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Site string `json:"site"`
|
||||
// Steps run in order and each names what to do when it fails. Ordered
|
||||
// because a later step cannot be judged while an earlier one is failing:
|
||||
// asking whether cameras see faces on a PC that is switched off produces
|
||||
// an answer that means nothing.
|
||||
Steps []CheckStep `json:"steps"`
|
||||
// OK is true only when every step passed. A partial pass is not a working
|
||||
// site, and calling it one is how Office1 was signed off.
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
type CheckStep struct {
|
||||
Name string `json:"name"`
|
||||
// Status is pass | warn | fail | unknown. `unknown` is its own state, not a
|
||||
// failure: "we have not been able to check this" and "this is broken" need
|
||||
// different reactions from whoever is reading.
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
Advice string `json:"advice,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user