Files
Behavision/server/internal/api/admin_clients_test.go
Suriyakumarvijayanayagam 8786a5b0b4 The platform admin's last shell-only jobs are endpoints
Suspend or reinstate a company (PATCH /api/admin/clients/{id}), reset
its owner's password (shown once), and delete it - and an owner can
remove a shop opened by mistake (DELETE /api/sites/{site}, empty only).

Suspension ends every session the company holds in the same
transaction: login and ingest already refused an inactive client, but a
live access token would have kept reading for up to twelve hours, so
'suspend' would have meant 'suspend some time tomorrow'. Deletion is
deliberately two steps - the company must already be suspended and the
request repeats the slug - because the data under it is biometric.
Face images go first (a storage failure aborts with nothing touched),
then the broker logins, then the rows by cascade.

Exercised against the local Postgres and broker: create, open a shop,
remove it (two plugin commands), refuse delete while active, suspend
(owner's token 401 immediately), reset, delete, zero rows left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 12:36:21 +05:30

129 lines
4.8 KiB
Go

package api
import (
"encoding/json"
"net/http"
"testing"
)
func seedTenantWithOwner(fs *fakeStore) {
fs.clients = []ClientRow{{ID: "client-acme", Slug: "acme", Name: "Acme Retail", Active: true}}
fs.addUser("owner@acme.com", "correct horse battery", UserRecord{
ID: "u-owner", ClientID: "client-acme", Role: "owner", Active: true, Email: "owner@acme.com",
})
}
func TestSuspendingACompanyEndsItsSessionsNow(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
seedTenantWithOwner(fs)
owner := login(t, s, "owner@acme.com", "correct horse battery")
admin := login(t, s, "root@loyaly.ai", "admin123")
rec := do(t, s, "PATCH", "/api/admin/clients/client-acme", admin.Token, map[string]any{"active": false})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out struct {
SessionsRevoked int `json:"sessions_revoked"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.SessionsRevoked != 1 {
t.Fatalf("expected the owner's one session revoked, got %d", out.SessionsRevoked)
}
// The owner's token stops working immediately, not at expiry.
if rec := do(t, s, "GET", "/api/team", owner.Token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("suspended tenant's session still works: %d", rec.Code)
}
}
func TestDeletingACompanyIsATwoStepDecision(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
seedTenantWithOwner(fs)
b := &fakeBroker{}
s.Broker = b
admin := login(t, s, "root@loyaly.ai", "admin123")
// Active: refused, whatever the confirmation says.
rec := do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acme"})
if rec.Code != http.StatusConflict {
t.Fatalf("deleted an active company: %d %s", rec.Code, rec.Body.String())
}
do(t, s, "PATCH", "/api/admin/clients/client-acme", admin.Token, map[string]any{"active": false})
// Suspended but the slug is wrong: refused.
rec = do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acm"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("deleted without the slug: %d %s", rec.Code, rec.Body.String())
}
rec = do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acme"})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if len(fs.clients) != 0 {
t.Fatal("company row survived")
}
if len(b.deleted) != 1 || b.deleted[0] != "acme.shop1" {
t.Fatalf("broker logins not removed: %v", b.deleted)
}
}
func TestAdminResetsTheOwnersPasswordAndItIsShownOnce(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
seedTenantWithOwner(fs)
admin := login(t, s, "root@loyaly.ai", "admin123")
rec := do(t, s, "POST", "/api/admin/clients/client-acme/owner-password", admin.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out struct{ Email, Password string }
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.Email != "owner@acme.com" || out.Password == "" {
t.Fatalf("unexpected result: %s", rec.Body.String())
}
if rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "owner@acme.com", "password": "correct horse battery"}); rec.Code != http.StatusUnauthorized {
t.Fatalf("old password still works: %d", rec.Code)
}
login(t, s, "owner@acme.com", out.Password)
}
func TestATenantUserCannotReachTheAdminClientRoutes(t *testing.T) {
s, fs := newServer(t)
seedTenantWithOwner(fs)
owner := login(t, s, "owner@acme.com", "correct horse battery")
for _, c := range []struct{ method, path string }{
{"PATCH", "/api/admin/clients/client-acme"},
{"POST", "/api/admin/clients/client-acme/owner-password"},
{"DELETE", "/api/admin/clients/client-acme"},
} {
if rec := do(t, s, c.method, c.path, owner.Token, map[string]any{"active": false, "confirm": "acme"}); rec.Code != http.StatusNotFound {
t.Errorf("%s %s: tenant user got %d, want 404", c.method, c.path, rec.Code)
}
}
}
func TestAnOwnerRemovesAnEmptyShopButNotOneWithCameras(t *testing.T) {
s, fs := newServer(t)
b := &fakeBroker{}
s.Broker = b
seedTenantWithOwner(fs)
fs.sites = []SiteHealth{
{SiteID: "site-empty", Slug: "empty", Name: "Empty"},
{SiteID: siteA, Slug: "chennai", Name: "TeNext Chennai"},
}
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}}
owner := login(t, s, "owner@acme.com", "correct horse battery")
if rec := do(t, s, "DELETE", "/api/sites/chennai", owner.Token, nil); rec.Code != http.StatusConflict {
t.Fatalf("removed a shop with a camera: %d %s", rec.Code, rec.Body.String())
}
if rec := do(t, s, "DELETE", "/api/sites/empty", owner.Token, nil); rec.Code != http.StatusNoContent {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if len(b.deleted) != 1 {
t.Fatalf("broker login not removed: %v", b.deleted)
}
}