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>
202 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|