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:
141
server/internal/auth/auth_test.go
Normal file
141
server/internal/auth/auth_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordRoundTrip(t *testing.T) {
|
||||
hash, err := HashPassword("correct horse battery")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(hash, "correct") {
|
||||
t.Fatal("the hash contains the password")
|
||||
}
|
||||
if !VerifyPassword(hash, "correct horse battery") {
|
||||
t.Fatal("the right password was rejected")
|
||||
}
|
||||
if VerifyPassword(hash, "correct horse batteru") {
|
||||
t.Fatal("a wrong password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordPolicyIsLengthOnly(t *testing.T) {
|
||||
if _, err := HashPassword("short"); err == nil {
|
||||
t.Fatal("a five-character password was accepted")
|
||||
}
|
||||
// Composition rules push people towards Passw0rd! for the same annoyance,
|
||||
// so a long all-lower-case passphrase must be fine.
|
||||
if _, err := HashPassword("all lower case and long enough"); err != nil {
|
||||
t.Fatalf("a good passphrase was refused: %v", err)
|
||||
}
|
||||
if _, err := HashPassword(strings.Repeat("x", 300)); err == nil {
|
||||
t.Fatal("a 300-character password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// DummyHash exists so an unknown address costs the same time as a wrong
|
||||
// password. A hash that does not parse returns instantly and puts the timing
|
||||
// leak straight back, which is why this asserts it actually verifies.
|
||||
func TestDummyHashIsARealBcryptHashThatNothingMatches(t *testing.T) {
|
||||
// Two separate properties, because the suite runs at a lowered cost and
|
||||
// only one of them is about the cost.
|
||||
//
|
||||
// 1. Production hashes at 12. Asserted against the constant rather than
|
||||
// against a hash, so lowering the cost for the tests cannot silently
|
||||
// lower it for real users too.
|
||||
if ProductionBcryptCost != 12 {
|
||||
t.Errorf("production bcrypt cost is %d - login should stay expensive",
|
||||
ProductionBcryptCost)
|
||||
}
|
||||
// 2. DummyHash is a real, parseable bcrypt hash. That is what makes an
|
||||
// unknown address cost the same time as a wrong password; a hash that
|
||||
// fails to parse returns instantly and puts the timing leak straight
|
||||
// back.
|
||||
if !strings.HasPrefix(DummyHash, "$2a$") {
|
||||
t.Fatalf("dummy hash is not a bcrypt hash at all: %q", DummyHash)
|
||||
}
|
||||
if VerifyPassword(DummyHash, "") || VerifyPassword(DummyHash, "password") {
|
||||
t.Fatal("something matched the dummy hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensAreDistinctAndOnlyTheHashIsStorable(t *testing.T) {
|
||||
a, err := NewToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := NewToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Plain == b.Plain {
|
||||
t.Fatal("two tokens came out the same")
|
||||
}
|
||||
if len(a.Plain) < 40 {
|
||||
t.Fatalf("token is only %d characters", len(a.Plain))
|
||||
}
|
||||
if strings.Contains(string(a.Hash), a.Plain) {
|
||||
t.Fatal("the stored hash contains the token")
|
||||
}
|
||||
if string(HashToken(a.Plain)) != string(a.Hash) {
|
||||
t.Fatal("hashing the token again gave a different answer")
|
||||
}
|
||||
// URL-safe alphabet: this ends up in config files and gets copied by hand.
|
||||
if strings.ContainsAny(a.Plain, "+/=") {
|
||||
t.Fatalf("token needs escaping: %q", a.Plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenIsReadFromTheHeaderOnly(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/api/auth/me?access_token=leaked", nil)
|
||||
if got := BearerToken(r); got != "" {
|
||||
t.Fatalf("a query parameter was accepted as a credential: %q", got)
|
||||
}
|
||||
r.Header.Set("Authorization", "bearer abc123")
|
||||
if got := BearerToken(r); got != "abc123" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
r.Header.Set("Authorization", "Basic abc123")
|
||||
if got := BearerToken(r); got != "" {
|
||||
t.Fatalf("Basic was read as a bearer token: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodeAcceptsHowPeopleActuallyType(t *testing.T) {
|
||||
want := "ABCDEF123456"
|
||||
for _, in := range []string{
|
||||
"ABCDEF-123456", "abcdef-123456", "abcdef 123456",
|
||||
"ABC DEF-123 456", "ABCDEF123456",
|
||||
} {
|
||||
if got := NormalizeCode(in); got != want {
|
||||
t.Errorf("NormalizeCode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolesDefaultToDenying(t *testing.T) {
|
||||
if (Principal{Role: "auditor"}).CanWriteProfiles() {
|
||||
t.Fatal("an unrecognised role could write customer details")
|
||||
}
|
||||
if (Principal{}).CanManageSites() {
|
||||
t.Fatal("the zero-value principal could manage sites")
|
||||
}
|
||||
if !(Principal{Role: "staff"}).CanWriteProfiles() {
|
||||
t.Fatal("staff must be able to fill in the in-store form")
|
||||
}
|
||||
// Staff fill the form in; they do not hand out broker credentials.
|
||||
if (Principal{Role: "staff"}).CanManageSites() {
|
||||
t.Fatal("staff could manage sites")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmailMatchesTheUniqueIndex(t *testing.T) {
|
||||
// The index is on lower(email); anything reaching the database must already
|
||||
// agree with it or the constraint stops meaning what it says.
|
||||
if NormalizeEmail(" Asha@Acme.COM ") != "asha@acme.com" {
|
||||
t.Fatal("email normalisation disagrees with lower(email)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user