package api import ( "encoding/json" "errors" "net/http" "strings" "testing" ) func seedPlatformAdmin(fs *fakeStore) { // No client id. A platform admin is defined by the ABSENCE of a tenant, // not by a flag, which is what keeps its scope from depending on which // query happens to check first. fs.addUser("root@loyaly.ai", "admin123", UserRecord{ ID: "admin-1", ClientID: "", FullName: "Platform", Role: "admin", Active: true, }) } func TestAnAdminCreatesATenantAndItsOwnerInOneCall(t *testing.T) { s, fs := newServer(t) seedPlatformAdmin(fs) sess := login(t, s, "root@loyaly.ai", "admin123") rec := do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{ "company_name": "Nearle Retail", "owner_email": "aravind@nearle.in", "owner_name": "Aravind", "password": "admin123", }) if rec.Code != http.StatusCreated { t.Fatalf("got %d: %s", rec.Code, rec.Body.String()) } var out NewClientResult if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } if out.Password != "admin123" { t.Errorf("the password is shown once and must be the one that was set, got %q", out.Password) } // The slug becomes an MQTT topic segment, so it has to be derived rather // than left to whatever the operator typed. if out.Slug != "nearle-retail" { t.Errorf("slug %q, want it derived from the company name", out.Slug) } } // A tenant user must not learn that a platform-administration surface exists. func TestATenantUserGets404FromTheAdminRoutes(t *testing.T) { s, fs := newServer(t) seedUser(fs) // a manager inside client-acme sess := login(t, s, "manager@acme.com", "correct horse battery") for _, call := range []struct{ method, path string }{ {"GET", "/api/admin/clients"}, {"POST", "/api/admin/clients"}, } { rec := do(t, s, call.method, call.path, sess.Token, map[string]string{"company_name": "X", "owner_email": "x@x.com"}) if rec.Code != http.StatusNotFound { t.Errorf("%s %s: got %d, want 404 - a 403 confirms the surface exists", call.method, call.path, rec.Code) } } } // The one that would be a cross-tenant breach: an account INSIDE a client whose // role happens to be "admin". Scope is the absent client id, not the role. func TestARoleOfAdminInsideATenantIsNotAPlatformAdmin(t *testing.T) { s, fs := newServer(t) fs.addUser("sneaky@acme.com", "admin123", UserRecord{ ID: "u9", ClientID: "client-acme", Role: "admin", Active: true, }) sess := login(t, s, "sneaky@acme.com", "admin123") if rec := do(t, s, "GET", "/api/admin/clients", sess.Token, nil); rec.Code != http.StatusNotFound { t.Fatalf("a tenant-scoped 'admin' reached the platform routes: %d", rec.Code) } } func TestTheAdminRoutesNeedASession(t *testing.T) { s, fs := newServer(t) seedPlatformAdmin(fs) if rec := do(t, s, "GET", "/api/admin/clients", "", nil); rec.Code != http.StatusUnauthorized { t.Fatalf("got %d, want 401", rec.Code) } } // Without an owner the tenant is invisible-broken: it looks normal in every // list and nobody can sign into it. func TestACompanyWithNoOwnerEmailIsRefused(t *testing.T) { s, fs := newServer(t) seedPlatformAdmin(fs) sess := login(t, s, "root@loyaly.ai", "admin123") rec := do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{"company_name": "Nearle"}) if rec.Code != http.StatusBadRequest { t.Fatalf("got %d, want 400", rec.Code) } if !strings.Contains(rec.Body.String(), "nobody can sign in") { t.Errorf("the message should say why it matters: %s", rec.Body.String()) } } // A clashing slug means the operator is about to hand somebody else's tenant to // a new owner. It must fail, and say which mistake it was. func TestADuplicateIsAConflictAnOperatorCanActapon(t *testing.T) { s, fs := newServer(t) seedPlatformAdmin(fs) sess := login(t, s, "root@loyaly.ai", "admin123") for _, tc := range []struct{ pgErr, want string }{ {`duplicate key value violates unique constraint "clients_slug_key"`, "short name already exists"}, {`duplicate key value violates unique constraint "app_users_email_idx"`, "already has an account"}, } { fs.newClientErr = errors.New(tc.pgErr) rec := do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{ "company_name": "Nearle", "owner_email": "a@nearle.in"}) if rec.Code != http.StatusConflict { t.Errorf("got %d, want 409 for %q", rec.Code, tc.pgErr) } if !strings.Contains(rec.Body.String(), tc.want) { t.Errorf("message %q does not name the clash", rec.Body.String()) } } } func TestSlugsCannotChangeWhatAnMQTTTopicMeans(t *testing.T) { for in, want := range map[string]string{ "Nearle Retail Pvt Ltd": "nearle-retail-pvt-ltd", " Acme ": "acme", "a/b+c#d": "a-b-c-d", // the three MQTT wildcards and separator "---Nearle---": "nearle", "Café 21": "caf-21", "!!!": "", } { if got := slugify(in); got != want { t.Errorf("slugify(%q) = %q, want %q", in, got, want) } } } // Creating a tenant is rare and consequential. It should leave a name against it. func TestCreatingATenantIsAudited(t *testing.T) { s, fs := newServer(t) seedPlatformAdmin(fs) sess := login(t, s, "root@loyaly.ai", "admin123") do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{ "company_name": "Nearle", "owner_email": "aravind@nearle.in"}) fs.mu.Lock() defer fs.mu.Unlock() for _, a := range fs.audits { if a.Action == "client.create" && a.ActorID == "admin-1" { return } } t.Fatalf("no audit row for creating a tenant: %+v", fs.audits) } // An operator inventing a password for somebody else invents a weak one and // sends it over chat. Omitting it must generate one, not create an account with // an empty password. func TestAnOmittedPasswordIsGeneratedNotBlank(t *testing.T) { s, fs := newServer(t) seedPlatformAdmin(fs) sess := login(t, s, "root@loyaly.ai", "admin123") rec := do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{ "company_name": "Nearle", "owner_email": "aravind@nearle.in"}) var out NewClientResult json.Unmarshal(rec.Body.Bytes(), &out) //nolint:errcheck if out.Password == "" { t.Fatal("no password was returned, so nobody can ever sign in") } }