C1. A membership of an organization may be admin as well as member and manager. An admin sees /org as a manager does and changes the organization from it: its teams and projects, who of the approved accounts is in them, its members' roles, and the keys spent in its projects. Every id a form names is checked against the organization; limits, accounts, the organization's name and the cluster stay with the cluster's staff, and an admin cannot take their own role away. A group from OIDC, SCIM or a directory no longer rewrites a membership it names as a plain member at every sign-in, which took a manager's role away (EnsureMembership). Pages draw a form only for someone who may send it: Nav.May asks the staff rule staffMay keeps, and a page whose forms a person may not send gets no form token. A viewer sees the people, limits and mirror pages without forms; an operator the mirror's. Forms are compact: labels close to their fields, fields as wide as what goes in them, smaller buttons, and the short forms on one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
7.9 KiB
Go
211 lines
7.9 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// fakeOrgEditor is fakeOrgs that also changes: u1 administers Acme, u4
|
|
// manages it, and what each change did is written down.
|
|
type fakeOrgEditor struct {
|
|
fakeOrgs
|
|
admins map[string]string // org -> admin user id
|
|
did []string
|
|
revoked []string
|
|
}
|
|
|
|
func (f *fakeOrgEditor) ManagesOrg(_ context.Context, user, org string) (bool, error) {
|
|
return f.admins[org] == user || f.managers[org] == user, nil
|
|
}
|
|
func (f *fakeOrgEditor) ManagedOrgs(ctx context.Context, user string) ([]store.Org, error) {
|
|
var out []store.Org
|
|
for _, o := range []store.Org{{ID: "org-a", Name: "Acme"}, {ID: "org-s", Name: "Scania"}} {
|
|
if ok, _ := f.ManagesOrg(ctx, user, o.ID); ok {
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeOrgEditor) AdministersOrg(_ context.Context, user, org string) (bool, error) {
|
|
return f.admins[org] == user, nil
|
|
}
|
|
func (f *fakeOrgEditor) UserByEmail(_ context.Context, email string) (store.User, error) {
|
|
switch email {
|
|
case "new@acme":
|
|
return store.User{ID: "u5", Email: email, State: store.UserActive}, nil
|
|
case "waiting@acme":
|
|
return store.User{ID: "u6", Email: email, State: store.UserPending}, nil
|
|
}
|
|
return store.User{}, store.ErrNotFound
|
|
}
|
|
func (f *fakeOrgEditor) CreateTeam(_ context.Context, org, name string) (string, error) {
|
|
f.did = append(f.did, "team "+org+" "+name)
|
|
return "t9", nil
|
|
}
|
|
func (f *fakeOrgEditor) CreateProject(_ context.Context, team, name string) (string, error) {
|
|
f.did = append(f.did, "project "+team+" "+name)
|
|
return "p9", nil
|
|
}
|
|
func (f *fakeOrgEditor) RenameTeam(_ context.Context, id, name string) error {
|
|
f.did = append(f.did, "rename team "+id+" "+name)
|
|
return nil
|
|
}
|
|
func (f *fakeOrgEditor) RenameProject(_ context.Context, id, name string) error {
|
|
f.did = append(f.did, "rename project "+id+" "+name)
|
|
return nil
|
|
}
|
|
func (f *fakeOrgEditor) DeleteTeam(_ context.Context, id string) error {
|
|
f.did = append(f.did, "delete team "+id)
|
|
return nil
|
|
}
|
|
func (f *fakeOrgEditor) DeleteProject(_ context.Context, id string) error {
|
|
f.did = append(f.did, "delete project "+id)
|
|
return nil
|
|
}
|
|
func (f *fakeOrgEditor) AddMembership(_ context.Context, user string, scope store.Scope, id, role, _ string, _ time.Time) error {
|
|
f.did = append(f.did, "join "+user+" "+string(scope)+":"+id+" "+role)
|
|
return nil
|
|
}
|
|
func (f *fakeOrgEditor) RemoveMembership(_ context.Context, user string, scope store.Scope, id string, _ time.Time) (int, error) {
|
|
f.did = append(f.did, "leave "+user+" "+string(scope)+":"+id)
|
|
return 0, nil
|
|
}
|
|
func (f *fakeOrgEditor) AllKeys(context.Context) ([]store.KeyRow, error) {
|
|
return []store.KeyRow{
|
|
{Key: store.Key{ID: "k-acme", Prefix: "sk-a", ProjectID: "p1"}, ProjectPath: "Acme / ml / training"},
|
|
{Key: store.Key{ID: "k-scania", Prefix: "sk-s", ProjectID: "p-s"}},
|
|
}, nil
|
|
}
|
|
func (f *fakeOrgEditor) RevokeKey(_ context.Context, id string, _ time.Time) error {
|
|
f.revoked = append(f.revoked, id)
|
|
return nil
|
|
}
|
|
|
|
func orgAdminPage(t *testing.T, user store.User) (*http.ServeMux, *fakeOrgEditor) {
|
|
t.Helper()
|
|
f := &fakeOrgEditor{
|
|
fakeOrgs: fakeOrgs{managers: map[string]string{"org-a": "u4"}},
|
|
admins: map[string]string{"org-a": "u1"},
|
|
}
|
|
p := NewAccountPages(fakeAccounts{user: user}, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
mux := http.NewServeMux()
|
|
p.RegisterOrgPages(mux, f, nil)
|
|
return mux, f
|
|
}
|
|
|
|
func form(kv ...string) url.Values {
|
|
v := url.Values{"csrf": {"tok"}}
|
|
for i := 0; i+1 < len(kv); i += 2 {
|
|
v.Set(kv[i], kv[i+1])
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestAnOrganizationsAdminIsGivenTheFormsAndAManagerIsNot(t *testing.T) {
|
|
admin, _ := orgAdminPage(t, store.User{ID: "u1", Email: "boss@acme"})
|
|
body := mirrorGet(t, admin, "/org/org-a").Body.String()
|
|
for _, want := range []string{`action="/org/org-a/teams"`, `action="/org/org-a/members"`,
|
|
`action="/org/org-a/keys/k-acme/revoke"`, "sk-a"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the admin's page lacks %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(body, "k-scania") {
|
|
t.Error("a key of another organization is on the page")
|
|
}
|
|
|
|
manager, f := orgAdminPage(t, store.User{ID: "u4", Email: "watch@acme"})
|
|
w := mirrorGet(t, manager, "/org/org-a")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("the manager lost their page: %d", w.Code)
|
|
}
|
|
if strings.Contains(w.Body.String(), "<form method=\"post\" action=\"/org/") {
|
|
t.Error("a manager is shown forms they cannot send")
|
|
}
|
|
if w := mirrorPost(t, manager, "/org/org-a/teams", form("name", "x")); w.Code != http.StatusNotFound {
|
|
t.Errorf("a manager changed the organization: %d", w.Code)
|
|
}
|
|
if len(f.did) != 0 {
|
|
t.Errorf("a manager's change reached the store: %v", f.did)
|
|
}
|
|
}
|
|
|
|
func TestAnOrganizationsAdminChangesOnlyTheirOwn(t *testing.T) {
|
|
mux, f := orgAdminPage(t, store.User{ID: "u1", Email: "boss@acme"})
|
|
|
|
if w := mirrorPost(t, mux, "/org/org-a/teams", form("name", "vision")); w.Code != http.StatusOK ||
|
|
!strings.Contains(w.Body.String(), "Team vision added") {
|
|
t.Errorf("adding a team: %d", w.Code)
|
|
}
|
|
if w := mirrorPost(t, mux, "/org/org-s/teams", form("name", "x")); w.Code != http.StatusNotFound {
|
|
t.Errorf("an admin of Acme added a team to Scania: %d", w.Code)
|
|
}
|
|
// Ids in a form are the sender's to choose: a team of another
|
|
// organization, named on Acme's page, is refused.
|
|
mirrorPost(t, mux, "/org/org-a/projects", form("team", "t-scania", "name", "x"))
|
|
mirrorPost(t, mux, "/org/org-a/members", form("email", "new@acme", "where", "org:org-s"))
|
|
mirrorPost(t, mux, "/org/org-a/keys/k-scania/revoke", form())
|
|
mirrorPost(t, mux, "/org/org-a/project/p-scania/delete", form())
|
|
if len(f.revoked) != 0 {
|
|
t.Errorf("a key of another organization was revoked: %v", f.revoked)
|
|
}
|
|
|
|
mirrorPost(t, mux, "/org/org-a/projects", form("team", "t1", "name", "eval"))
|
|
mirrorPost(t, mux, "/org/org-a/members", form("email", "new@acme", "where", "project:p1"))
|
|
mirrorPost(t, mux, "/org/org-a/members/u2/role", form("role", "manager"))
|
|
mirrorPost(t, mux, "/org/org-a/keys/k-acme/revoke", form())
|
|
want := []string{"team org-a vision", "project t1 eval", "join u5 project:p1 member", "join u2 org:org-a manager"}
|
|
if strings.Join(f.did, "|") != strings.Join(want, "|") {
|
|
t.Errorf("the store was asked\n %v\nwant\n %v", f.did, want)
|
|
}
|
|
if len(f.revoked) != 1 || f.revoked[0] != "k-acme" {
|
|
t.Errorf("revoked %v", f.revoked)
|
|
}
|
|
}
|
|
|
|
func TestAnOrganizationsAdminAddsOnlyApprovedPeople(t *testing.T) {
|
|
mux, f := orgAdminPage(t, store.User{ID: "u1", Email: "boss@acme"})
|
|
for email, says := range map[string]string{
|
|
"nobody@acme": "there is no account for nobody@acme",
|
|
"waiting@acme": "waits for the cluster",
|
|
} {
|
|
w := mirrorPost(t, mux, "/org/org-a/members", form("email", email, "where", "org:org-a"))
|
|
if !strings.Contains(w.Body.String(), says) {
|
|
t.Errorf("%s: the page does not say %q", email, says)
|
|
}
|
|
}
|
|
if len(f.did) != 0 {
|
|
t.Errorf("someone not approved was added: %v", f.did)
|
|
}
|
|
}
|
|
|
|
func TestAnOrganizationsAdminCannotLockThemselvesOut(t *testing.T) {
|
|
mux, f := orgAdminPage(t, store.User{ID: "u1", Email: "boss@acme"})
|
|
mirrorPost(t, mux, "/org/org-a/members/u1/role", form("role", "member"))
|
|
mirrorPost(t, mux, "/org/org-a/members/u1/leave", form("where", "org:org-a"))
|
|
if len(f.did) != 0 {
|
|
t.Errorf("an admin took their own role away: %v", f.did)
|
|
}
|
|
}
|
|
|
|
func TestAViewerIsShownNoFormsOnThePeoplePage(t *testing.T) {
|
|
for role, wantForms := range map[string]bool{store.RoleViewer: false, store.RoleAdmin: true} {
|
|
u := store.User{ID: "s", Email: "s@x", Role: role, IsAdmin: role == store.RoleAdmin}
|
|
p := NewAccountPages(fakeAccounts{user: u}, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
mux := http.NewServeMux()
|
|
p.RegisterAdminUsers(mux, &fakeAdmin{})
|
|
body := mirrorGet(t, mux, "/admin/users").Body.String()
|
|
if got := strings.Contains(body, `method="post" action="/admin`); got != wantForms {
|
|
t.Errorf("%s: forms on the page = %v, want %v", role, got, wantForms)
|
|
}
|
|
}
|
|
}
|