Files
llmgw/internal/web/admin_tenancy_test.go
T
Ilker ManapandClaude Opus 5.5 3104f4b706 Manage people and the tenancy tree, from the web and the CLI
Nothing in the tree could be renamed, moved or removed, and an approved
person stayed in the project they were first put in — from the web or
the CLI. Now:

- /admin/users is the tree, each project opening onto its people and
  keys, with approve, move, suspend, reinstate and remove for people,
  revoke for keys, and add, rename, move and remove at every level,
  plus a search over every account. Forms post and get the page back,
  so it works with no script; htmx 2.0.11 (0BSD, embedded, checked
  against npm's integrity hash) swaps only the changed part, keeping
  open projects open.
- llmgw org tree/add/rename/move/remove, users move/delete, and
  keys list --all.
- Projects, teams and organizations are named by id, path
  (Acme/platform/gateway) or unique name; users by id or email. With a
  single project --project can be left out, instead of defaulting to
  "default", which a tree built from the web does not have.

Moving a person moves the keys they made; operator keys stay with the
project. Only empty things are removed, and never a project that had a
key, since usage history refers to it.

Also: keys init no longer reparents an existing team or project;
reinstating an account with no project is refused (a rejected signup
used to come back active and fail at its first key); rejecting a
signup removes it; keys limits --budget takes whole units like the
quotas page.

Checked in headless Chrome: a move from an open project updates the
page in place, keeps the project open, and the search still filters.

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

193 lines
6.9 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
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) MoveUser(context.Context, string, string) (int, error) { return 0, f.err }
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)
}
}
}