Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
243 lines
8.6 KiB
Go
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)
|
|
}
|
|
}
|