A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.
Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.
Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.
Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.
Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.
Found by running it, not by tests:
* UPDATE ... RETURNING gives the value AFTER the update, so the prune
read back empty keys, deleted nothing, and the table grew with
footfall exactly as if it were not there. The fake agreed with either
version; only the live Postgres test caught it.
* Trusting only the auth flag broke every shop card, because Sites.jsx
rebuilt a partial snapshot object and dropped it. A relative URL is
now sufficient on its own.
* ago() renders a future time as "just now", so a code valid for a week
read "expires just now".
Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
281 lines
9.2 KiB
Go
281 lines
9.2 KiB
Go
package bridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func writeImage(t *testing.T, dir, name string, body []byte) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// fakeServer plays both halves: the API that mints an upload URL and the
|
|
// bucket that receives the PUT.
|
|
func fakeServer(t *testing.T, uploaded *[]byte, sentACL *string) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
|
|
mux.HandleFunc("/api/agent/upload-url", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "Bearer agent-token" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(uploadTarget{ //nolint:errcheck
|
|
Key: "behavision/acme/store1/2026/08/31/abc.jpg",
|
|
URL: srv.URL + "/bucket/abc.jpg",
|
|
Headers: map[string]string{
|
|
"x-amz-acl": "private", "content-type": "image/jpeg",
|
|
},
|
|
ExpiresIn: 600,
|
|
})
|
|
})
|
|
mux.HandleFunc("/bucket/", func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
*uploaded = body
|
|
*sentACL = r.Header.Get("x-amz-acl")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
return srv
|
|
}
|
|
|
|
func TestUploadSendsTheFileAndTheSignedACL(t *testing.T) {
|
|
var got []byte
|
|
var acl string
|
|
srv := fakeServer(t, &got, &acl)
|
|
dir := t.TempDir()
|
|
path := writeImage(t, dir, "face.jpg", []byte("jpeg bytes"))
|
|
|
|
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token"}
|
|
key, err := u.Upload(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if key != "behavision/acme/store1/2026/08/31/abc.jpg" {
|
|
t.Fatalf("key = %q", key)
|
|
}
|
|
if string(got) != "jpeg bytes" {
|
|
t.Fatalf("uploaded %q", got)
|
|
}
|
|
// The ACL is inside the server's signature. Sending it exactly as handed
|
|
// back is what keeps the shop PC from deciding to publish the image.
|
|
if acl != "private" {
|
|
t.Fatalf("x-amz-acl = %q, want private", acl)
|
|
}
|
|
}
|
|
|
|
func TestUnclaimedPCReportsImagesOffRatherThanFailing(t *testing.T) {
|
|
u := &SpacesUploader{} // no base url, no token: not enrolled yet
|
|
_, err := u.Upload(context.Background(), "/nonexistent")
|
|
if !errors.Is(err, ErrImagesOff) {
|
|
t.Fatalf("got %v, want ErrImagesOff", err)
|
|
}
|
|
}
|
|
|
|
func TestServerWithoutABucketIsNotARetryableFailure(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotImplemented)
|
|
}))
|
|
defer srv.Close()
|
|
dir := t.TempDir()
|
|
path := writeImage(t, dir, "face.jpg", []byte("x"))
|
|
|
|
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token"}
|
|
// 501 means "this deployment stores no images". The agent must stop trying
|
|
// rather than retry every visitor forever.
|
|
if _, err := u.Upload(context.Background(), path); !errors.Is(err, ErrImagesOff) {
|
|
t.Fatalf("got %v, want ErrImagesOff", err)
|
|
}
|
|
}
|
|
|
|
func TestOversizedFilesAreRefusedBeforeTheUplink(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := writeImage(t, dir, "huge.jpg", make([]byte, maxImageBytes+1))
|
|
u := &SpacesUploader{BaseURL: "http://example.invalid", Token: "t"}
|
|
// A shop uplink should not spend minutes discovering that something other
|
|
// than a face crop landed in the outbox.
|
|
if _, err := u.Upload(context.Background(), path); err == nil ||
|
|
!strings.Contains(err.Error(), "limit") {
|
|
t.Fatalf("got %v", err)
|
|
}
|
|
}
|
|
|
|
// -- the bridge's use of it -------------------------------------------------
|
|
|
|
type stubUploader struct {
|
|
key string
|
|
err error
|
|
sent []string
|
|
}
|
|
|
|
func (s *stubUploader) Upload(_ context.Context, path string) (string, error) {
|
|
s.sent = append(s.sent, path)
|
|
return s.key, s.err
|
|
}
|
|
|
|
func TestVisitCarriesTheImageKeyAndTheLocalFileIsRemoved(t *testing.T) {
|
|
q := &fakeQueue{}
|
|
up := &stubUploader{key: "behavision/acme/store1/2026/08/31/abc.jpg"}
|
|
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1", Uploader: up}
|
|
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
|
|
|
|
err := b.Handle(context.Background(), Event{
|
|
Type: "person.new", CameraID: "entrance", TS: 1756_000_000,
|
|
Data: map[string]any{"identity_id": float64(7), "image_path": path},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(q.payloads) != 1 {
|
|
t.Fatalf("expected one queued visit, got %d", len(q.payloads))
|
|
}
|
|
visit := q.payloads[0]
|
|
if visit["image_key"] != up.key {
|
|
t.Fatalf("image_key = %v", visit["image_key"])
|
|
}
|
|
// The outbox is transient. Leaving files behind means a shop PC slowly
|
|
// filling with pictures of its customers.
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatal("the local image was not removed after upload")
|
|
}
|
|
}
|
|
|
|
// A footfall count without a photo is a real visit and the number the customer
|
|
// pays for. Losing it over an optional field would be the wrong trade - the
|
|
// same rule the bridge already follows for a missing embedding.
|
|
func TestAFailedUploadStillQueuesTheVisit(t *testing.T) {
|
|
q := &fakeQueue{}
|
|
up := &stubUploader{err: errors.New("bucket unreachable")}
|
|
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1", Uploader: up}
|
|
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
|
|
|
|
if err := b.Handle(context.Background(), Event{
|
|
Type: "person.seen", CameraID: "entrance", TS: 1756_000_001,
|
|
Data: map[string]any{"identity_id": float64(7), "image_path": path},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(q.payloads) != 1 {
|
|
t.Fatalf("the visit was dropped because its photo failed")
|
|
}
|
|
if _, ok := q.payloads[0]["image_key"]; ok {
|
|
t.Fatal("a key was attached despite the upload failing")
|
|
}
|
|
// Removed anyway: keeping it for a retry means an outbox that grows for as
|
|
// long as the failure lasts.
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatal("the local image survived a failed upload")
|
|
}
|
|
}
|
|
|
|
func TestNoUploaderMeansNoImageAndNoLeftovers(t *testing.T) {
|
|
q := &fakeQueue{}
|
|
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1"} // images off
|
|
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
|
|
|
|
if err := b.Handle(context.Background(), Event{
|
|
Type: "person.new", CameraID: "entrance", TS: 1756_000_002,
|
|
Data: map[string]any{"identity_id": float64(7), "image_path": path},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := q.payloads[0]["image_key"]; ok {
|
|
t.Fatal("an image key appeared with no uploader configured")
|
|
}
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatal("the local image was left on disk")
|
|
}
|
|
}
|
|
|
|
// A deployment with no object storage must still get a photo onto the customer
|
|
// record. Until the fallback existed, `images_disabled` meant every local
|
|
// install and every self-hosted site showed no face for anybody, forever.
|
|
func TestNoBucketFallsBackToTheServer(t *testing.T) {
|
|
var askedURL, postedFace bool
|
|
var gotBody []byte
|
|
var gotAuth, gotType string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/agent/upload-url":
|
|
askedURL = true
|
|
// What a server with no bucket answers.
|
|
w.WriteHeader(http.StatusNotImplemented)
|
|
_, _ = w.Write([]byte(`{"error":"images_disabled"}`))
|
|
case "/api/agent/faces":
|
|
postedFace = true
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotType = r.Header.Get("Content-Type")
|
|
gotBody, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"key":"db:11111111-1111-4111-8111-111111111111"}`))
|
|
default:
|
|
t.Errorf("unexpected request to %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()}
|
|
img := []byte{0xFF, 0xD8, 0xFF, 0xE0, 'x', 'y', 'z'}
|
|
key, err := u.UploadBytes(context.Background(), img)
|
|
if err != nil {
|
|
t.Fatalf("upload: %v", err)
|
|
}
|
|
if !askedURL {
|
|
t.Error("the presigned route must be tried first - it is the right one where a bucket exists")
|
|
}
|
|
if !postedFace {
|
|
t.Fatal("no fallback upload was made")
|
|
}
|
|
if !strings.HasPrefix(key, "db:") {
|
|
t.Errorf("want the server's own key, got %q", key)
|
|
}
|
|
if !bytes.Equal(gotBody, img) {
|
|
t.Error("the bytes sent are not the bytes given")
|
|
}
|
|
if gotAuth != "Bearer agent-token" || gotType != "image/jpeg" {
|
|
t.Errorf("auth %q type %q", gotAuth, gotType)
|
|
}
|
|
}
|
|
|
|
// A server that stores no images AT ALL must stop the agent trying, rather than
|
|
// have it retry every visitor forever. Distinct from a failure, which is why
|
|
// it is a sentinel and not a message.
|
|
func TestAServerThatStoresNothingSaysSoOnce(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotImplemented)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()}
|
|
_, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8, 0xFF, 0xE0})
|
|
if !errors.Is(err, ErrImagesOff) {
|
|
t.Fatalf("want ErrImagesOff so the caller stops trying, got %v", err)
|
|
}
|
|
}
|
|
|
|
// An unclaimed PC has no server to send anything to. The fallback must not fire
|
|
// there - it would be a request to nowhere on every single visit.
|
|
func TestAnUnclaimedAgentDoesNotTryToUpload(t *testing.T) {
|
|
u := &SpacesUploader{} // no BaseURL, no token
|
|
if _, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8}); !errors.Is(err, ErrImagesOff) {
|
|
t.Fatalf("want ErrImagesOff, got %v", err)
|
|
}
|
|
}
|