Files
llmgw/internal/store/hierarchy_test.go
T
Ilker ManapandClaude Opus 5.5 098886e0e4 Let a person belong to many places, or to none
A person was in exactly one project, chosen at approval, and could not
use the gateway before that. People belong to several teams and to
none, so:

- memberships: a person is a member of any number of organizations,
  teams and projects, or of none; a member of a team or organization
  may use every project in it. users join/leave, and the people page
  adds and takes out from either side. Approval no longer needs a
  project.
- personal keys: a key may have an owner and no project. /profile asks
  what a new key spends against; keys create --for does the same.
  auth.personal sets default limits for personal use or turns it off.
- limits for people: --scope user holds a person across every key,
  project and the chat page; --per-member on an org, team or project
  gives each member their own share there. Resolution folds the tree,
  each per-member limit for the key's owner, and the owner's own, each
  in its own pool; the quotas page shows and sets both.
- the chat page asks what to spend against and checks it per message.
- usage records who spent it, so a person's budget survives a restart,
  personal use and chat included; restore no longer drops rows with no
  project.

Leaving a project revokes the keys made there unless another membership
still reaches it. A personal key keeps no threads or notes, which are
per project, and is refused rather than dropped into the anonymous pool.

Migration 0010 turns each person's project into a membership, adds
usage.user_id and backfills it, and makes api_keys.project_id nullable.
SQLite cannot drop NOT NULL, so the table is rebuilt; that needs foreign
keys off, which only works outside a transaction, so migrations marked
for it run on one connection with foreign_key_check before commit.
Tested on a populated SQLite store with claims, and on a copy of the
cluster's PostgreSQL store: 5 people became members, 17 keys and 3
claims intact, 145 of 577 usage rows and all 69 chat rows attributed.
The PostgreSQL store tests, including new membership cases, pass on 16.

users move is gone; join and leave replace it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:03:46 +02:00

238 lines
6.8 KiB
Go

package store
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// tree builds Acme/platform/{gateway,search} and Scania/ml, and an active
// user in gateway with one key of their own and one operator key there.
type fixture struct {
s *DB
acme, scania string
platform, ml string
gateway, search, research string
user, userKey, operatorKey string
}
func newFixture(t *testing.T) fixture {
t.Helper()
return newFixtureOn(t, tenancyStore(t))
}
// newFixtureOn builds the fixture in a given store, so the same cases run on
// PostgreSQL as on SQLite.
func newFixtureOn(t *testing.T, db *DB) fixture {
t.Helper()
ctx := t.Context()
f := fixture{s: db}
must := func(id string, err error) string {
t.Helper()
if err != nil {
t.Fatal(err)
}
return id
}
f.acme = must(f.s.CreateOrg(ctx, "Acme"))
f.scania = must(f.s.CreateOrg(ctx, "Scania"))
f.platform = must(f.s.CreateTeam(ctx, f.acme, "platform"))
f.ml = must(f.s.CreateTeam(ctx, f.scania, "ml"))
f.gateway = must(f.s.CreateProject(ctx, f.platform, "gateway"))
f.search = must(f.s.CreateProject(ctx, f.platform, "search"))
f.research = must(f.s.CreateProject(ctx, f.ml, "research"))
f.user = "usr-1"
if err := f.s.CreateUser(ctx, User{ID: f.user, Email: "a@x", Name: "a", PasswordHash: "h",
State: UserPending, CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
if err := f.s.ApproveUser(ctx, f.user, f.gateway, "test", time.Now()); err != nil {
t.Fatal(err)
}
_, k, _ := NewKey("key-u", "mine", f.gateway)
if err := f.s.CreateUserKey(ctx, f.user, k); err != nil {
t.Fatal(err)
}
f.userKey = k.ID
_, k, _ = NewKey("key-o", "ci", f.gateway)
if err := f.s.CreateKey(ctx, k); err != nil {
t.Fatal(err)
}
f.operatorKey = k.ID
return f
}
func TestTreeCountsWhatUsesEachProject(t *testing.T) {
f := newFixture(t)
tree, err := f.s.Tree(t.Context())
if err != nil {
t.Fatal(err)
}
if len(tree) != 2 || tree[0].Name != "Acme" || len(tree[0].Teams) != 1 {
t.Fatalf("tree = %+v", tree)
}
gw := tree[0].Teams[0].Projects[0]
if gw.Name != "gateway" || gw.Users != 1 || gw.Keys != 2 || gw.ActiveKeys != 2 {
t.Errorf("gateway = %+v", gw)
}
}
func TestASignupNeverApprovedCannotBeReinstated(t *testing.T) {
f := newFixture(t)
ctx := t.Context()
_ = f.s.CreateUser(ctx, User{ID: "usr-2", Email: "b@x", Name: "b", PasswordHash: "h",
State: UserPending, CreatedAt: time.Now()})
// Suspended while pending, then reinstated: that would be an approval
// nobody made.
if err := f.s.SetUserState(ctx, "usr-2", UserSuspended); err != nil {
t.Fatal(err)
}
if err := f.s.SetUserState(ctx, "usr-2", UserActive); err == nil {
t.Error("an account nobody approved was made active")
}
// One that was approved can be.
if err := f.s.SetUserState(ctx, f.user, UserSuspended); err != nil {
t.Fatal(err)
}
if err := f.s.SetUserState(ctx, f.user, UserActive); err != nil {
t.Errorf("an approved account could not be reinstated: %v", err)
}
}
func TestRenamingKeepsNamesUniqueAmongSiblingsOnly(t *testing.T) {
f := newFixture(t)
ctx := t.Context()
if err := f.s.RenameProject(ctx, f.search, "gateway"); !errors.Is(err, ErrDuplicateName) {
t.Errorf("a sibling's name was accepted: %v", err)
}
// research is in another team, so "gateway" is free there.
if err := f.s.RenameProject(ctx, f.research, "gateway"); err != nil {
t.Error(err)
}
if err := f.s.RenameOrg(ctx, f.acme, "Acme AB"); err != nil {
t.Error(err)
}
if err := f.s.RenameTeam(ctx, f.platform, " "); err == nil {
t.Error("an empty name was accepted")
}
}
func TestMovingATeamOrProjectChecksTheNewParent(t *testing.T) {
f := newFixture(t)
ctx := t.Context()
if err := f.s.MoveProject(ctx, f.search, f.ml); err != nil {
t.Fatal(err)
}
projects, _ := f.s.AllProjects(ctx)
var found bool
for _, p := range projects {
if p.ID == f.search && p.Path == "Scania / ml / search" {
found = true
}
}
if !found {
t.Errorf("search did not move: %+v", projects)
}
if err := f.s.MoveTeam(ctx, f.platform, "org-nope"); err == nil {
t.Error("moved into an organization that does not exist")
}
// A team named ml already exists in Scania.
if _, err := f.s.CreateTeam(ctx, f.acme, "ml"); err != nil {
t.Fatal(err)
}
teams, _ := f.s.AllTeams(ctx)
for _, tm := range teams {
if tm.OrgID == f.acme && tm.Name == "ml" {
if err := f.s.MoveTeam(ctx, tm.ID, f.scania); !errors.Is(err, ErrDuplicateName) {
t.Errorf("two teams named ml in one organization: %v", err)
}
}
}
}
func TestOnlyEmptyThingsAreDeleted(t *testing.T) {
f := newFixture(t)
ctx := t.Context()
err := f.s.DeleteProject(ctx, f.gateway)
if !errors.Is(err, ErrNotEmpty) || !strings.Contains(err.Error(), "1 member and 2 keys") {
t.Errorf("deleting a used project: %v", err)
}
if err := f.s.DeleteTeam(ctx, f.platform); !errors.Is(err, ErrNotEmpty) {
t.Errorf("deleting a team with projects: %v", err)
}
if err := f.s.DeleteProject(ctx, f.search); err != nil {
t.Errorf("deleting an unused project: %v", err)
}
if err := f.s.DeleteProject(ctx, f.research); err != nil {
t.Fatal(err)
}
if err := f.s.DeleteTeam(ctx, f.ml); err != nil {
t.Fatal(err)
}
if err := f.s.DeleteOrg(ctx, f.scania); err != nil {
t.Fatal(err)
}
if err := f.s.DeleteOrg(ctx, f.scania); !errors.Is(err, ErrNotFound) {
t.Errorf("deleting twice: %v", err)
}
}
func TestDeletingAUserRevokesTheirKeysAndKeepsThem(t *testing.T) {
f := newFixture(t)
ctx := t.Context()
if err := f.s.DeleteUser(ctx, f.user, time.Now()); err != nil {
t.Fatal(err)
}
if _, err := f.s.UserByID(ctx, f.user); !errors.Is(err, ErrNotFound) {
t.Errorf("user still there: %v", err)
}
keys, _ := f.s.Keys(ctx, f.gateway)
for _, k := range keys {
if k.ID == f.userKey && k.RevokedAt.IsZero() {
t.Error("the deleted person's key still works")
}
if k.ID == f.operatorKey && !k.RevokedAt.IsZero() {
t.Error("an operator key was revoked with the person")
}
}
if len(keys) != 2 {
t.Errorf("keys are kept for usage history; have %d", len(keys))
}
}
func TestAllKeysNamesProjectAndOwner(t *testing.T) {
f := newFixture(t)
rows, err := f.s.AllKeys(t.Context())
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("rows = %+v", rows)
}
for _, r := range rows {
if r.ProjectPath != "Acme / platform / gateway" {
t.Errorf("path %q", r.ProjectPath)
}
if (r.ID == f.userKey) != (r.OwnerEmail == "a@x") {
t.Errorf("owner of %s is %q", r.ID, r.OwnerEmail)
}
}
}
func TestRedeclaringATeamDoesNotMoveIt(t *testing.T) {
f := newFixture(t)
err := f.s.PutTeam(context.Background(), Team{ID: f.platform, OrgID: f.scania, Name: "platform"})
if err == nil {
t.Fatal("PutTeam moved a team to another organization")
}
teams, _ := f.s.AllTeams(context.Background())
for _, tm := range teams {
if tm.ID == f.platform && tm.OrgID != f.acme {
t.Error("the team changed owner")
}
}
}