Files
llmgw/internal/web/admin_tenancy_test.go
T

243 lines
8.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
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()
return tenancyPageAs(t, s, admin, nil)
}
func tenancyPageWith(t *testing.T, s *fakeAdmin, with func(*AccountPages)) *http.ServeMux {
t.Helper()
return tenancyPageAs(t, s, true, with)
}
func tenancyPageAs(t *testing.T, s *fakeAdmin, admin bool, with func(*AccountPages)) *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)))
if with != nil {
with(p)
}
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)
}
}
}
type auditMem struct{ got []store.AuditEvent }
func (a *auditMem) WriteAudit(_ context.Context, e store.AuditEvent) error {
a.got = append(a.got, e)
return nil
}
func TestAChangeOnTheAdminPagesIsAudited(t *testing.T) {
s := &fakeAdmin{}
am := &auditMem{}
mux := tenancyPageWith(t, s, func(p *AccountPages) { p.SetAudit(am) })
mirrorPost(t, mux, "/admin/tenancy/org", url.Values{"csrf": {"tok"}, "name": {"Scania"}})
s.err = store.ErrDuplicateName
mirrorPost(t, mux, "/admin/tenancy/org", url.Values{"csrf": {"tok"}, "name": {"Scania"}})
if len(am.got) != 2 {
t.Fatalf("recorded %d", len(am.got))
}
ok, refused := am.got[0], am.got[1]
if ok.Source != "web" || ok.Action != "POST /admin/tenancy/org" || ok.Actor == "" ||
!strings.Contains(ok.Detail, `"name":"Scania"`) || !strings.Contains(ok.Detail, "created") ||
strings.Contains(ok.Detail, "tok") {
t.Errorf("%+v", ok)
}
if !strings.Contains(refused.Detail, "refused") {
t.Errorf("a refusal reads as one: %+v", refused)
}
}