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) } }