CI / test (push) Successful in 1m35s
An account is approved into a project, a project belongs to a team, a team to an organization — and until now the only way to make any of them was `llmgw keys init` on the control plane. So the first thing to do after installing was leave the interface you were already in, and an approval form with an empty dropdown gave no hint why. The accounts page now creates all three. Creating a container is not granting access: nobody can use one until an account is approved into it, which is the step already on this page. Quotas stay on the CLI. Making somewhere to put people is structural; raising what they may spend is financial, and a stolen admin session should be able to do the first and not the second. Ids are random now instead of slugs of the name, which fixes a bug that only appears when you have a second organization — which is exactly what this feature invites. Team ids were slugs in one flat namespace and PutTeam upserts on id, so creating "platform" under a second organization moved the first one's team into it. Nothing failed. The team changed owner. Create* is separate from Put* rather than replacing it. The upserts exist so `keys init` can be re-run while provisioning a machine; a person filling in a form means something else by a name that already exists, and gets told. A duplicate is refused among siblings only. Two companies both having a "platform" team is ordinary, and refusing the second would be the flat namespace again wearing a better error message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
172 lines
5.6 KiB
Go
172 lines
5.6 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 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 TestQuotasAreStillNotSettableFromTheBrowser(t *testing.T) {
|
|
body := mirrorGet(t, tenancyPage(t, &fakeAdmin{}, true), "/admin/users").Body.String()
|
|
|
|
// Making somewhere to put people is structural; raising what they may spend
|
|
// is financial. A stolen admin session should be able to do the first and
|
|
// not the second, and the page says so.
|
|
if !strings.Contains(body, "llmgw keys limits") {
|
|
t.Error("the page should still send quota changes to the shell")
|
|
}
|
|
if strings.Contains(body, `name="budget"`) {
|
|
t.Error("a budget field has appeared on the accounts page")
|
|
}
|
|
}
|