Files
Behavision/server/internal/api/admin_monitor_test.go
Suriyakumarvijayanayagam abcf6aa012 The admin console could list merchants and see nothing inside them
Six read-only routes: merchant detail, its shops, one shop, its cameras,
one camera, and the platform totals. The console drills down
merchant -> store -> camera and every level below the first showed
'Backend integration required'.

They cannot be the tenant routes, and the reason is structural rather
than incidental. Every tenant handler derives the client from the
SESSION - that is what makes cross-tenant access impossible rather than
merely disallowed - and a platform admin has no client at all. The three
workarounds each make it worse: passing a company id to a tenant route
puts a caller-chosen tenant back in the one place this system refuses to
take one, filtering the estate in the browser ships every merchant's
data to render one, and signing in as the owner audits the wrong person.
So the tenant STORE functions are reused with an explicit client id -
they already take one - and the scoping the tenant handlers get from the
session is done in the handler instead.

AdminCamera is a separate type from Camera, for the same reason
AgentCamera is. It cannot carry host, port, path, username or
has_password. A tenant seeing those for their own camera is correct; a
platform admin browsing another company's estate is a different
question, and an RTSP host with a username beside it is most of a live
path into a customer's camera. Blanking fields on a shared struct leaves
'remember to redact, on every path, forever' as the only thing
preventing a leak. The test asserts on the raw JSON, because decoding
into the struct would discard exactly what it is looking for.

An unowned site is 404, never an empty list. The tenant resolver returns
a uuid untouched and lets client_id =  downstream scope it, which is
sound only because that id comes from a session; here the caller names
both halves, so an unowned uuid would reach a query that quietly returns
nothing - 'this shop has no cameras' when the truth is 'not your shop'.
Both resolvers check the whole chain in one statement.

Two things the in-memory fake could not have caught, so neither was left
to it. The fake ignored clientID in SiteHealth and Cameras, which would
have made every cross-merchant test pass while returning another
company's shops; it is client-aware now for these paths. And the SQL was
written to make the documented $2-deduced-as-two-types bug impossible
rather than to be caught by a database later: id::text = $2 in place
of id = $2::uuid, one type per parameter, which also turns a malformed
path segment into the 404 it should be instead of a cast error.

Every read below the merchant list writes an audit row naming the admin
and the merchant - an admin is the one account for which nothing else
here leaves a trace. The counts-only summary does not: a console
refreshes it on a timer, and logging that buries the reads worth
finding. A suspended merchant stays readable, because that is precisely
what an admin opens the console to look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 16:01:22 +05:30

316 lines
11 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// Two merchants, each with a shop and a camera. The whole point of these tests
// is that the path names the merchant, so a fixture with only one proves
// nothing about scoping.
const (
merchantA = "11111111-1111-4111-8111-aaaaaaaaaaaa"
merchantB = "22222222-2222-4222-8222-bbbbbbbbbbbb"
shopA = "33333333-3333-4333-8333-aaaaaaaaaaaa"
shopB = "44444444-4444-4444-8444-bbbbbbbbbbbb"
)
func seedTwoMerchants(fs *fakeStore) {
seedPlatformAdmin(fs)
fs.clientRows = map[string]ClientDetail{
merchantA: {ClientRow: ClientRow{ID: merchantA, Slug: "acme", Name: "Acme Retail",
Active: true, Sites: 1, Users: 2}, OwnerEmail: "owner@acme.com", OwnerName: "Asha"},
merchantB: {ClientRow: ClientRow{ID: merchantB, Slug: "rival", Name: "Rival Stores",
Active: true, Sites: 1, Users: 1}, OwnerEmail: "owner@rival.com"},
}
fs.sites = []SiteHealth{
{SiteID: shopA, Slug: "chennai", Name: "Acme Chennai", CamerasUp: 1, CamerasTotal: 1},
{SiteID: shopB, Slug: "mumbai", Name: "Rival Mumbai", CamerasUp: 0, CamerasTotal: 1},
}
fs.siteOwner = map[string]string{shopA: merchantA, shopB: merchantB}
yes := true
fs.cameras = []Camera{
{ID: "cam-a", SiteID: shopA, CameraID: "entrance", Label: "Front door",
Host: "192.168.1.121", Port: 554, Path: "/ch0_1.264",
Username: "admin", HasPassword: true, Enabled: true, Connected: &yes},
{ID: "cam-b", SiteID: shopB, CameraID: "entrance", Label: "Rival door",
Host: "10.0.0.9", Port: 554, Username: "root", HasPassword: true},
}
fs.cameraRefs = map[string]cameraRef{
"cam-a": {client: merchantA, site: shopA, engineID: "entrance"},
"cam-b": {client: merchantB, site: shopB, engineID: "entrance"},
}
}
// adminReads picks out the rows this surface writes. Signing in audits too,
// so a bare count would couple these tests to unrelated behaviour.
func adminReads(fs *fakeStore) []AuditEntry {
var out []AuditEntry
for _, a := range fs.audits {
if strings.HasPrefix(a.Action, "admin.") {
out = append(out, a)
}
}
return out
}
func adminGet(t *testing.T, s *Server, token, path string) (int, string) {
t.Helper()
rec := do(t, s, "GET", path, token, nil)
return rec.Code, rec.Body.String()
}
// ------------------------------------------------- the surface is invisible
func TestAMerchantTokenGets404FromEveryAdminDrilldownRoute(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
seedUser(fs) // an ordinary manager inside another tenant
sess := login(t, s, "manager@acme.com", "correct horse battery")
for _, path := range []string{
"/api/admin/clients/" + merchantA,
"/api/admin/clients/" + merchantA + "/sites",
"/api/admin/clients/" + merchantA + "/sites/" + shopA,
"/api/admin/clients/" + merchantA + "/sites/" + shopA + "/cameras",
"/api/admin/clients/" + merchantA + "/sites/" + shopA + "/cameras/cam-a",
"/api/admin/monitoring/summary",
} {
if code, body := adminGet(t, s, sess.Token, path); code != http.StatusNotFound {
t.Errorf("%s: got %d, want 404 (never 403 - a tenant must not learn "+
"this surface exists): %s", path, code, body)
}
}
}
// ------------------------------------------------------------- scoping
func TestSitesAreScopedToTheMerchantInThePath(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites")
if code != http.StatusOK {
t.Fatalf("got %d: %s", code, body)
}
var rows []SiteHealth
if err := json.Unmarshal([]byte(body), &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].SiteID != shopA {
t.Fatalf("got %d rows %+v, want only merchant A's shop", len(rows), rows)
}
if strings.Contains(body, "Rival") {
t.Errorf("another merchant's shop leaked into the response: %s", body)
}
}
// The uuid branch is the one that matters. A tenant resolver hands a uuid back
// untouched and lets `client_id = $1` downstream do the scoping, which is safe
// only because the client id comes from a session. Here the caller names both.
func TestAnotherMerchantsShopIs404NotAnEmptyList(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
for _, path := range []string{
"/api/admin/clients/" + merchantA + "/sites/" + shopB,
"/api/admin/clients/" + merchantA + "/sites/" + shopB + "/cameras",
"/api/admin/clients/" + merchantA + "/sites/mumbai/cameras",
} {
code, body := adminGet(t, s, sess.Token, path)
if code != http.StatusNotFound {
t.Errorf("%s: got %d, want 404 - an empty list says 'this shop has "+
"nothing' when the truth is 'not your shop': %s", path, code, body)
}
}
}
func TestACameraFromAnotherShopIs404(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
// cam-b exists, and its engine id "entrance" is the same string as cam-a's
// - a camera id is unique per SITE, not per tenant, so the chain has to be
// checked rather than the name trusted.
code, _ := adminGet(t, s, sess.Token,
"/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras/cam-b")
if code != http.StatusNotFound {
t.Errorf("got %d, want 404 for a camera belonging to another shop", code)
}
}
func TestAnUnknownOrMalformedMerchantIs404NotAServerError(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
for _, id := range []string{
"99999999-9999-4999-8999-999999999999", // well formed, no such row
"not-a-uuid", // would be a Postgres cast error
} {
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+id+"/sites")
if code != http.StatusNotFound {
t.Errorf("merchant %q: got %d, want 404: %s", id, code, body)
}
}
}
// ------------------------------------------------------------- redaction
// The one that would be a real leak. An RTSP host next to a username is most
// of a live path into a customer's camera, and a platform admin browsing
// another company's estate has no business with either.
func TestAdminCameraRowsCarryNoCredentialFields(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
code, body := adminGet(t, s, sess.Token,
"/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras")
if code != http.StatusOK {
t.Fatalf("got %d: %s", code, body)
}
// Asserted on the raw JSON, not on a struct: decoding into AdminCamera
// would discard exactly the fields this test exists to catch.
for _, banned := range []string{
"host", "192.168.1.121", "port", "554", "path", "ch0_1.264",
"username", "admin", "has_password", "password",
} {
if strings.Contains(body, banned) {
t.Errorf("admin camera row contains %q: %s", banned, body)
}
}
// And it still answers the question the screen asks.
for _, want := range []string{"entrance", "Front door", "connected"} {
if !strings.Contains(body, want) {
t.Errorf("admin camera row is missing %q: %s", want, body)
}
}
}
// Connected is a pointer for a reason: null means no shop PC has ever reported,
// false means it is not connecting, and those send an installer to two
// different places. `omitempty` would collapse both into absent.
func TestAdminCameraKeepsConnectedAsThreeStates(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
fs.cameras[0].Connected = nil
sess := login(t, s, "root@loyaly.ai", "admin123")
_, body := adminGet(t, s, sess.Token,
"/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras")
if !strings.Contains(body, `"connected":null`) {
t.Errorf(`want "connected":null for a camera no PC has reported on: %s`, body)
}
}
// ------------------------------------------------------------- audit
func TestEveryAdminReadBelowTheMerchantListIsAudited(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
base := "/api/admin/clients/" + merchantA
for _, path := range []string{
base + "/sites",
base + "/sites/" + shopA,
base + "/sites/" + shopA + "/cameras",
base + "/sites/" + shopA + "/cameras/cam-a",
} {
if code, body := adminGet(t, s, sess.Token, path); code != http.StatusOK {
t.Fatalf("%s: got %d: %s", path, code, body)
}
}
// Filtered by action: signing in writes its own audit row, and counting
// every row would make this test pass or fail on unrelated behaviour.
reads := adminReads(fs)
if len(reads) != 4 {
t.Fatalf("got %d admin read rows, want one per read below the merchant "+
"list (all audits: %+v)", len(reads), fs.audits)
}
for _, a := range reads {
if a.ClientID != merchantA {
t.Errorf("audit row names client %q, want the merchant being looked at", a.ClientID)
}
if a.ActorID != "admin-1" || a.ActorKind != "admin" {
t.Errorf("audit row must name the admin who looked: %+v", a)
}
}
}
// Counts across the platform name no merchant and no person, and a console
// refreshes them on a timer. Logging that would bury the reads worth finding.
func TestTheSummaryIsNotAudited(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
if code, body := adminGet(t, s, sess.Token, "/api/admin/monitoring/summary"); code != http.StatusOK {
t.Fatalf("got %d: %s", code, body)
}
if n := len(adminReads(fs)); n != 0 {
t.Errorf("got %d admin read rows for a counts-only header strip, want 0", n)
}
}
// ------------------------------------------------------------- suspended
// "This company is suspended" is precisely what an admin opens the console to
// look at. Hiding it would make the one screen that can fix it the one screen
// that cannot see it.
func TestASuspendedMerchantStaysReadable(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
row := fs.clientRows[merchantA]
row.Active = false
fs.clientRows[merchantA] = row
sess := login(t, s, "root@loyaly.ai", "admin123")
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA)
if code != http.StatusOK {
t.Fatalf("got %d, want a suspended merchant to still read: %s", code, body)
}
if !strings.Contains(body, `"active":false`) {
t.Errorf("the response must say it is suspended: %s", body)
}
if code, _ := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites"); code != http.StatusOK {
t.Errorf("sites of a suspended merchant: got %d, want 200", code)
}
}
func TestMerchantDetailCarriesTheOwner(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA)
if code != http.StatusOK {
t.Fatalf("got %d: %s", code, body)
}
// Who to contact is the whole reason this is not just the list row.
if !strings.Contains(body, "owner@acme.com") {
t.Errorf("merchant detail must name the owner: %s", body)
}
}
// An empty list must serialise as [] and not null, or a console that maps over
// the response breaks on a merchant with no shops - which is every merchant on
// the day they are created.
func TestAMerchantWithNoShopsReturnsAnEmptyArray(t *testing.T) {
s, fs := newServer(t)
seedTwoMerchants(fs)
fs.siteOwner = map[string]string{shopB: merchantB} // A now owns nothing
sess := login(t, s, "root@loyaly.ai", "admin123")
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites")
if code != http.StatusOK || strings.TrimSpace(body) != "[]" {
t.Errorf("got %d %q, want 200 []", code, strings.TrimSpace(body))
}
}