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:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

View 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)
}
}