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