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>
193 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|