Files
llmgw/internal/store/tenancy_test.go
T
Ilker ManapandClaude Opus 5 fc1b47976d
CI / test (push) Successful in 1m35s
Let an admin build the tenancy tree, instead of sending them to a shell
An account is approved into a project, a project belongs to a team, a team to
an organization — and until now the only way to make any of them was `llmgw
keys init` on the control plane. So the first thing to do after installing was
leave the interface you were already in, and an approval form with an empty
dropdown gave no hint why.

The accounts page now creates all three.

Creating a container is not granting access: nobody can use one until an
account is approved into it, which is the step already on this page. Quotas stay
on the CLI. Making somewhere to put people is structural; raising what they may
spend is financial, and a stolen admin session should be able to do the first
and not the second.

Ids are random now instead of slugs of the name, which fixes a bug that only
appears when you have a second organization — which is exactly what this
feature invites. Team ids were slugs in one flat namespace and PutTeam upserts
on id, so creating "platform" under a second organization moved the first one's
team into it. Nothing failed. The team changed owner.

Create* is separate from Put* rather than replacing it. The upserts exist so
`keys init` can be re-run while provisioning a machine; a person filling in a
form means something else by a name that already exists, and gets told.

A duplicate is refused among siblings only. Two companies both having a
"platform" team is ordinary, and refusing the second would be the flat namespace
again wearing a better error message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 12:36:13 +02:00

133 lines
3.7 KiB
Go

package store
import (
"context"
"errors"
"path/filepath"
"testing"
)
func tenancyStore(t *testing.T) *DB {
t.Helper()
s, err := OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "ten.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestTwoOrganizationsCanBothHaveAPlatformTeam(t *testing.T) {
s := tenancyStore(t)
a, err := s.CreateOrg(t.Context(), "Acme")
if err != nil {
t.Fatal(err)
}
b, err := s.CreateOrg(t.Context(), "Scania")
if err != nil {
t.Fatal(err)
}
ta, err := s.CreateTeam(t.Context(), a, "platform")
if err != nil {
t.Fatal(err)
}
tb, err := s.CreateTeam(t.Context(), b, "platform")
if err != nil {
t.Fatal(err)
}
// Ids were slugs of the name in one flat namespace, and PutTeam upserts on
// id — so the second of these moved the first organization's team into the
// second. Nothing failed; the team changed owner.
if ta == tb {
t.Fatal("both teams got the same id, so one overwrote the other")
}
teams, err := s.AllTeams(t.Context())
if err != nil {
t.Fatal(err)
}
if len(teams) != 2 {
t.Fatalf("expected a team in each organization, got %+v", teams)
}
for _, team := range teams {
if team.OrgName == "" {
t.Errorf("a team should come back with its organization: %+v", team)
}
}
}
func TestTheSameNameTwiceInOneOrganizationIsRefused(t *testing.T) {
s := tenancyStore(t)
org, _ := s.CreateOrg(t.Context(), "Acme")
if _, err := s.CreateTeam(t.Context(), org, "platform"); err != nil {
t.Fatal(err)
}
// Two identically named teams under one organization is somebody making a
// mistake in a form, not a script being re-run.
_, err := s.CreateTeam(t.Context(), org, "platform")
if !errors.Is(err, ErrDuplicateName) {
t.Errorf("expected a duplicate to be refused, got %v", err)
}
}
func TestADuplicateOrganizationIsRefused(t *testing.T) {
s := tenancyStore(t)
if _, err := s.CreateOrg(t.Context(), "Acme"); err != nil {
t.Fatal(err)
}
if _, err := s.CreateOrg(t.Context(), "Acme"); !errors.Is(err, ErrDuplicateName) {
t.Errorf("expected a duplicate organization to be refused, got %v", err)
}
}
func TestAProjectNeedsATeamAndANameThatIsNotBlank(t *testing.T) {
s := tenancyStore(t)
if _, err := s.CreateProject(t.Context(), "", "gateway"); err == nil {
t.Error("a project with no team should be refused")
}
org, _ := s.CreateOrg(t.Context(), "Acme")
team, _ := s.CreateTeam(t.Context(), org, "platform")
// Whitespace is not a name; it would produce a row nobody can point at.
if _, err := s.CreateProject(t.Context(), team, " "); err == nil {
t.Error("a blank name should be refused")
}
}
func TestANewProjectShowsItsWholePath(t *testing.T) {
s := tenancyStore(t)
org, _ := s.CreateOrg(t.Context(), "Acme")
team, _ := s.CreateTeam(t.Context(), org, "platform")
if _, err := s.CreateProject(t.Context(), team, "gateway"); err != nil {
t.Fatal(err)
}
paths, err := s.AllProjects(t.Context())
if err != nil {
t.Fatal(err)
}
// The chooser on the approval form shows this string, and an id alone
// would make two projects named "gateway" indistinguishable.
if len(paths) != 1 || paths[0].Path != "Acme / platform / gateway" {
t.Errorf("expected the full path, got %+v", paths)
}
}
func TestIdsAreNotDerivedFromTheName(t *testing.T) {
s := tenancyStore(t)
id, err := s.CreateOrg(t.Context(), "Acme")
if err != nil {
t.Fatal(err)
}
// A renamed organization keeping an id that spells its old name is the
// smaller half; the real reason is that a slug of a name is not unique and
// these are primary keys.
if id == "acme" || id == "Acme" {
t.Errorf("the id should not be the name; got %q", id)
}
if len(id) < 8 {
t.Errorf("the id looks too short to be random: %q", id)
}
}