Files
llmgw/internal/web/admin_tenancy_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

202 lines
7.3 KiB
Go

package web
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// fakeAdmin is the store half of the accounts page.
type fakeAdmin struct {
orgs []store.Org
teams []store.TeamPath
projects []store.ProjectPath
createdOrg [1]string
createdTeam [2]string
createdProject [2]string
joined, left []string
err error
}
func (f *fakeAdmin) Users(context.Context, store.UserState) ([]store.User, error) {
return nil, nil
}
func (f *fakeAdmin) UserByID(context.Context, string) (store.User, error) {
return store.User{}, errors.New("no")
}
func (f *fakeAdmin) ApproveUser(context.Context, string, string, string, time.Time) error {
return nil
}
func (f *fakeAdmin) SetUserState(context.Context, string, store.UserState) error { return nil }
func (f *fakeAdmin) DeleteUserSessions(context.Context, string) error { return nil }
func (f *fakeAdmin) AllProjects(context.Context) ([]store.ProjectPath, error) {
return f.projects, nil
}
func (f *fakeAdmin) AllOrgs(context.Context) ([]store.Org, error) { return f.orgs, nil }
func (f *fakeAdmin) AllTeams(context.Context) ([]store.TeamPath, error) { return f.teams, nil }
func (f *fakeAdmin) CreateOrg(_ context.Context, name string) (string, error) {
f.createdOrg = [1]string{name}
return "org-1", f.err
}
func (f *fakeAdmin) CreateTeam(_ context.Context, orgID, name string) (string, error) {
f.createdTeam = [2]string{orgID, name}
return "team-1", f.err
}
func (f *fakeAdmin) CreateProject(_ context.Context, teamID, name string) (string, error) {
f.createdProject = [2]string{teamID, name}
return "proj-1", f.err
}
func (f *fakeAdmin) Tree(context.Context) ([]store.TreeOrg, error) {
var out []store.TreeOrg
for _, o := range f.orgs {
out = append(out, store.TreeOrg{ID: o.ID, Name: o.Name})
}
return out, nil
}
func (f *fakeAdmin) RenameOrg(context.Context, string, string) error { return f.err }
func (f *fakeAdmin) RenameTeam(context.Context, string, string) error { return f.err }
func (f *fakeAdmin) RenameProject(context.Context, string, string) error { return f.err }
func (f *fakeAdmin) MoveTeam(context.Context, string, string) error { return f.err }
func (f *fakeAdmin) MoveProject(context.Context, string, string) error { return f.err }
func (f *fakeAdmin) DeleteOrg(context.Context, string) error { return f.err }
func (f *fakeAdmin) DeleteTeam(context.Context, string) error { return f.err }
func (f *fakeAdmin) DeleteProject(context.Context, string) error { return f.err }
func (f *fakeAdmin) AddMembership(_ context.Context, user string, scope store.Scope, id, _, _ string, _ time.Time) error {
f.joined = append(f.joined, user+"@"+string(scope)+":"+id)
return f.err
}
func (f *fakeAdmin) RemoveMembership(_ context.Context, user string, scope store.Scope, id string, _ time.Time) (int, error) {
f.left = append(f.left, user+"@"+string(scope)+":"+id)
return 1, f.err
}
func (f *fakeAdmin) AllMemberships(context.Context) ([]store.Membership, error) { return nil, nil }
func (f *fakeAdmin) DeleteUser(context.Context, string, time.Time) error { return f.err }
func (f *fakeAdmin) AllKeys(context.Context) ([]store.KeyRow, error) { return nil, nil }
func (f *fakeAdmin) RevokeKey(context.Context, string, time.Time) error { return f.err }
func tenancyPage(t *testing.T, s *fakeAdmin, admin bool) *http.ServeMux {
t.Helper()
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: admin}}
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
mux := http.NewServeMux()
p.RegisterAdminUsers(mux, s)
return mux
}
func TestAnAdminCanMakeSomewhereToPutPeople(t *testing.T) {
s := &fakeAdmin{}
mux := tenancyPage(t, s, true)
// Creating the tree used to need a shell — `llmgw keys init` — so the first
// thing to do after installing was leave the interface you were in.
body := mirrorPost(t, mux, "/admin/tenancy/org",
url.Values{"csrf": {"tok"}, "name": {"Scania"}}).Body.String()
if s.createdOrg[0] != "Scania" {
t.Errorf("the organization name did not arrive: %q", s.createdOrg[0])
}
if !strings.Contains(body, "created") {
t.Errorf("the page should confirm it; got %s", body)
}
}
func TestATeamIsCreatedInsideTheOrganizationChosen(t *testing.T) {
s := &fakeAdmin{orgs: []store.Org{{ID: "org-1", Name: "Scania"}}}
mirrorPost(t, tenancyPage(t, s, true), "/admin/tenancy/team",
url.Values{"csrf": {"tok"}, "name": {"platform"}, "org": {"org-1"}})
if s.createdTeam != [2]string{"org-1", "platform"} {
t.Errorf("the team's parent did not arrive intact: %v", s.createdTeam)
}
}
func TestATeamWithNoOrganizationIsRefusedBeforeTheStore(t *testing.T) {
s := &fakeAdmin{}
body := mirrorPost(t, tenancyPage(t, s, true), "/admin/tenancy/team",
url.Values{"csrf": {"tok"}, "name": {"platform"}}).Body.String()
if s.createdTeam[1] != "" {
t.Error("nothing should have reached the store")
}
if !strings.Contains(body, "organization") {
t.Errorf("the error should say what is missing; got %s", body)
}
}
func TestADuplicateIsReportedInPlainWords(t *testing.T) {
s := &fakeAdmin{err: store.ErrDuplicateName}
body := mirrorPost(t, tenancyPage(t, s, true), "/admin/tenancy/org",
url.Values{"csrf": {"tok"}, "name": {"Scania"}}).Body.String()
if !strings.Contains(body, "already one of those") {
t.Errorf("a duplicate should read as a mistake, not a constraint; got %s", body)
}
}
func TestCreatingTheTreeNeedsTheCSRFToken(t *testing.T) {
s := &fakeAdmin{}
w := mirrorPost(t, tenancyPage(t, s, true), "/admin/tenancy/org",
url.Values{"name": {"Scania"}})
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 without a CSRF token, got %d", w.Code)
}
if s.createdOrg[0] != "" {
t.Error("nothing should have been created")
}
}
func TestSomebodyWhoIsNotAnAdminCannotCreateAnything(t *testing.T) {
s := &fakeAdmin{}
w := mirrorPost(t, tenancyPage(t, s, false), "/admin/tenancy/org",
url.Values{"csrf": {"tok"}, "name": {"Scania"}})
// 404 rather than 403, like every other page under /admin: a 403 confirms
// the endpoint is there.
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for a non-admin, got %d", w.Code)
}
if s.createdOrg[0] != "" {
t.Error("nothing should have been created")
}
}
func TestThePageSaysWhatToDoWhenTheTreeIsEmpty(t *testing.T) {
body := mirrorGet(t, tenancyPage(t, &fakeAdmin{}, true), "/admin/users").Body.String()
// An empty tree means no account can be approved at all, and the reason is
// not guessable from an approval form with an empty dropdown.
if !strings.Contains(body, "No organizations yet") {
t.Errorf("the empty case should say what to do first; got %s", body)
}
}
func TestThisPageSetsNoQuotas(t *testing.T) {
body := mirrorGet(t, tenancyPage(t, &fakeAdmin{}, true), "/admin/users").Body.String()
// Quotas have their own page; this one arranges people. What stays off
// the web entirely is what would hand a stolen session more access.
if strings.Contains(body, `name="budget"`) {
t.Error("a budget field has appeared on the people page")
}
for _, shell := range []string{"llmgw users promote", "reset-password", "llmgw keys create"} {
if !strings.Contains(body, shell) {
t.Errorf("the page should send %q to the shell", shell)
}
}
}