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