Commit fa1784cbc9
Verified · cmc ci/build: success
e2e/orgweb_test.go added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "net/http" | |
| 6 | "net/url" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | // TestOrgManagementWeb covers running an organization from the browser: | |
| 12 | // membership and teams, admin-gated, dispatched through the same commands | |
| 13 | // the CLI uses. | |
| 14 | func TestOrgManagementWeb(t *testing.T) { | |
| 15 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 16 | aliceKey := inst.newKey(t, "alice") | |
| 17 | bobKey := inst.newKey(t, "bob") | |
| 18 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 19 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 20 | if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 { | |
| 21 | t.Fatalf("org create: %s", errOut) | |
| 22 | } | |
| 23 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/widget"); code != 0 { | |
| 24 | t.Fatalf("repo create: %s", errOut) | |
| 25 | } | |
| 26 | ||
| 27 | alice := loginBrowser(t, inst, aliceKey) | |
| 28 | ||
| 29 | // The management sections are admin-only: bob is not even a member. | |
| 30 | bob := loginBrowser(t, inst, bobKey) | |
| 31 | if _, body := browserGet(t, bob, inst.base()+"/acme"); strings.Contains(body, `value="member-add"`) { | |
| 32 | t.Fatal("a non-member sees organization controls") | |
| 33 | } | |
| 34 | // And POSTing anyway is refused by the command, not by the template. | |
| 35 | browserPost(t, bob, inst.base()+"/acme", url.Values{ | |
| 36 | "field": {"member-add"}, "user": {"bob"}, "role": {"admin"}, | |
| 37 | }) | |
| 38 | if members := orgMembers(t, inst, aliceKey); len(members) != 1 { | |
| 39 | t.Fatalf("non-admin added themselves: %v", members) | |
| 40 | } | |
| 41 | ||
| 42 | status, body := browserGet(t, alice, inst.base()+"/acme") | |
| 43 | if status != 200 || !strings.Contains(body, `value="member-add"`) { | |
| 44 | t.Fatalf("admin sees no controls: %d", status) | |
| 45 | } | |
| 46 | ||
| 47 | // Add bob as a member through the form; confirm over SSH. | |
| 48 | browserPost(t, alice, inst.base()+"/acme", url.Values{ | |
| 49 | "field": {"member-add"}, "user": {"bob"}, "role": {"member"}, | |
| 50 | }) | |
| 51 | if members := orgMembers(t, inst, aliceKey); len(members) != 2 { | |
| 52 | t.Fatalf("member not added: %v", members) | |
| 53 | } | |
| 54 | ||
| 55 | // Create a team, put bob in it, and grant it write on the repo. | |
| 56 | browserPost(t, alice, inst.base()+"/acme", url.Values{ | |
| 57 | "field": {"team-create"}, "team": {"builders"}, | |
| 58 | }) | |
| 59 | browserPost(t, alice, inst.base()+"/acme", url.Values{ | |
| 60 | "field": {"team-add"}, "team": {"builders"}, "user": {"bob"}, | |
| 61 | }) | |
| 62 | browserPost(t, alice, inst.base()+"/acme", url.Values{ | |
| 63 | "field": {"team-grant"}, "team": {"builders"}, | |
| 64 | "repo": {"acme/widget"}, "role": {"write"}, | |
| 65 | }) | |
| 66 | out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json") | |
| 67 | if !strings.Contains(out, `"bob"`) || !strings.Contains(out, `"acme/widget"`) || | |
| 68 | !strings.Contains(out, `"write"`) { | |
| 69 | t.Fatalf("team not configured: %s", out) | |
| 70 | } | |
| 71 | // The grant is real access, not just a row: bob can now push. | |
| 72 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "show", "acme/widget"); code != 0 { | |
| 73 | t.Fatalf("team grant did not confer access: %s", errOut) | |
| 74 | } | |
| 75 | ||
| 76 | // The page shows what was built. | |
| 77 | _, body = browserGet(t, alice, inst.base()+"/acme") | |
| 78 | for _, want := range []string{"builders", "acme/widget", "1 member"} { | |
| 79 | if !strings.Contains(body, want) { | |
| 80 | t.Errorf("org page missing %q", want) | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | // Revoking and removing work the same way round. | |
| 85 | browserPost(t, alice, inst.base()+"/acme", url.Values{ | |
| 86 | "field": {"team-revoke"}, "team": {"builders"}, "repo": {"acme/widget"}, | |
| 87 | }) | |
| 88 | browserPost(t, alice, inst.base()+"/acme", url.Values{ | |
| 89 | "field": {"member-remove"}, "user": {"bob"}, | |
| 90 | }) | |
| 91 | if members := orgMembers(t, inst, aliceKey); len(members) != 1 { | |
| 92 | t.Fatalf("member not removed: %v", members) | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | // loginBrowser mints a session over SSH and returns a browser holding it. | |
| 97 | func loginBrowser(t *testing.T, inst *instance, key string) *http.Client { | |
| 98 | t.Helper() | |
| 99 | out, errOut, code := inst.ssh(t, key, "", "web", "login", "--json") | |
| 100 | if code != 0 { | |
| 101 | t.Fatalf("web login: %s", errOut) | |
| 102 | } | |
| 103 | var env struct { | |
| 104 | Data struct { | |
| 105 | URL string `json:"url"` | |
| 106 | } `json:"data"` | |
| 107 | } | |
| 108 | json.Unmarshal([]byte(out), &env) | |
| 109 | c := newBrowser(t) | |
| 110 | browserGet(t, c, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):]) | |
| 111 | return c | |
| 112 | } | |
| 113 | ||
| 114 | func orgMembers(t *testing.T, inst *instance, key string) []string { | |
| 115 | t.Helper() | |
| 116 | out, _, _ := inst.ssh(t, key, "", "org", "members", "list", "acme", "--json") | |
| 117 | var env struct { | |
| 118 | Data struct { | |
| 119 | Members []struct { | |
| 120 | User string `json:"user"` | |
| 121 | } `json:"members"` | |
| 122 | } `json:"data"` | |
| 123 | } | |
| 124 | if err := json.Unmarshal([]byte(out), &env); err != nil { | |
| 125 | t.Fatalf("members JSON: %v\n%s", err, out) | |
| 126 | } | |
| 127 | var names []string | |
| 128 | for _, m := range env.Data.Members { | |
| 129 | names = append(names, m.User) | |
| 130 | } | |
| 131 | return names | |
| 132 | } | |
internal/httpd/orgweb.go added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/http" | |
| 5 | "net/url" | |
| 6 | "strings" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | // teamView is a team with the two things an admin needs to see about it: | |
| 12 | // who is in it and what it reaches. | |
| 13 | type teamView struct { | |
| 14 | Name string | |
| 15 | Members []string | |
| 16 | Grants []store.TeamGrant | |
| 17 | } | |
| 18 | ||
| 19 | // orgAdminView gathers the management state for an organization page. It | |
| 20 | // returns ok=false for user pages and for viewers who do not administer the | |
| 21 | // org, so the page renders read-only for everyone else. | |
| 22 | func (s *Server) orgAdminView(viewer store.User, kind, name string) (teams []teamView, ok bool) { | |
| 23 | if kind != "org" || viewer.ID == 0 { | |
| 24 | return nil, false | |
| 25 | } | |
| 26 | org, err := s.st.OrgByName(name) | |
| 27 | if err != nil { | |
| 28 | return nil, false | |
| 29 | } | |
| 30 | if role, _ := s.st.OrgRole(org.ID, viewer.ID); role != "admin" { | |
| 31 | return nil, false | |
| 32 | } | |
| 33 | list, _ := s.st.ListTeams(org.ID) | |
| 34 | for _, t := range list { | |
| 35 | members, _ := s.st.TeamMembers(t.ID) | |
| 36 | grants, _ := s.st.TeamGrants(t.ID) | |
| 37 | teams = append(teams, teamView{Name: t.Name, Members: members, Grants: grants}) | |
| 38 | } | |
| 39 | return teams, true | |
| 40 | } | |
| 41 | ||
| 42 | // orgSubmit routes the organization forms on an owner page. Every branch | |
| 43 | // dispatches the matching control command, so membership rules and audit | |
| 44 | // entries stay in one implementation. | |
| 45 | func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 46 | owner := r.PathValue("owner") | |
| 47 | back := func(msg string) { | |
| 48 | dest := "/" + owner | |
| 49 | if msg != "" { | |
| 50 | dest += "?e=" + url.QueryEscape(msg) | |
| 51 | } | |
| 52 | http.Redirect(w, r, dest, http.StatusSeeOther) | |
| 53 | } | |
| 54 | field := r.FormValue("field") | |
| 55 | team := strings.TrimSpace(r.FormValue("team")) | |
| 56 | user := strings.TrimSpace(r.FormValue("user")) | |
| 57 | ||
| 58 | var argv []string | |
| 59 | switch field { | |
| 60 | case "member-add": | |
| 61 | argv = []string{"org", "members", "add", owner, user} | |
| 62 | if role := r.FormValue("role"); role == "admin" || role == "member" { | |
| 63 | argv = append(argv, "--role", role) | |
| 64 | } | |
| 65 | case "member-remove": | |
| 66 | argv = []string{"org", "members", "remove", owner, user} | |
| 67 | case "team-create": | |
| 68 | argv = []string{"org", "team", "create", owner, team} | |
| 69 | case "team-delete": | |
| 70 | argv = []string{"org", "team", "delete", owner, team} | |
| 71 | case "team-add": | |
| 72 | argv = append([]string{"org", "team", "add", owner, team}, strings.Fields(user)...) | |
| 73 | case "team-remove": | |
| 74 | argv = append([]string{"org", "team", "remove", owner, team}, strings.Fields(user)...) | |
| 75 | case "team-grant": | |
| 76 | argv = []string{"org", "team", "grant", owner, team, | |
| 77 | strings.TrimSpace(r.FormValue("repo")), r.FormValue("role")} | |
| 78 | case "team-revoke": | |
| 79 | argv = []string{"org", "team", "revoke", owner, team, strings.TrimSpace(r.FormValue("repo"))} | |
| 80 | default: | |
| 81 | back("unknown form") | |
| 82 | return | |
| 83 | } | |
| 84 | if _, msg, ok := s.runControl(u, argv); !ok { | |
| 85 | back(msg) | |
| 86 | return | |
| 87 | } | |
| 88 | back("") | |
| 89 | } | |
internal/httpd/routes.go +2
| @@ -89,6 +89,8 @@ func (s *Server) Routes() []Route { | ||
| 89 | 89 | Handler: s.checkOrigin(s.logout)}, |
| 90 | 90 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, |
| 91 | 91 | Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, |
| 92 | Route{Method: "POST", Pattern: "/{owner}", Mutating: true, | |
| 93 | Handler: s.checkOrigin(s.requireUser(s.orgSubmit))}, | |
| 92 | 94 | Route{Method: "POST", Pattern: "/settings", Mutating: true, |
| 93 | 95 | Handler: s.checkOrigin(s.requireUser(s.accountSubmit))}, |
| 94 | 96 | ) |
internal/httpd/web.go +5 −1
| @@ -392,6 +392,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 392 | 392 | } |
| 393 | 393 | weeks, activityTotal := activityGrid(counts) |
| 394 | 394 | |
| 395 | teams, canAdmin := s.orgAdminView(viewer, kind, name) | |
| 395 | 396 | s.render(w, "owner.html", struct { |
| 396 | 397 | basePage |
| 397 | 398 | Owner string |
| @@ -402,8 +403,11 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 402 | 403 | Orgs []store.OrgMember |
| 403 | 404 | Activity []activityWeek |
| 404 | 405 | ActivityTotal int |
| 406 | Teams []teamView | |
| 407 | CanAdmin bool | |
| 408 | Notice string | |
| 405 | 409 | }{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs, |
| 406 | weeks, activityTotal}) | |
| 410 | weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")}) | |
| 407 | 411 | } |
| 408 | 412 | |
| 409 | 413 | func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { |
internal/web/static/style.css +3
| @@ -797,6 +797,9 @@ pre.message { | ||
| 797 | 797 | } |
| 798 | 798 | |
| 799 | 799 | /* repo facts: the counts a visitor sizes a project up with */ |
| 800 | .teambody { padding: var(--sp-3); background: var(--bg); } | |
| 801 | .teambody .memberchip { margin-right: var(--sp-2); } | |
| 802 | ||
| 800 | 803 | /* account settings */ |
| 801 | 804 | table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; } |
| 802 | 805 | table.keys td.mono, .mono { font-family: var(--mono); font-size: var(--fs-1); overflow-wrap: anywhere; } |
internal/web/templates/owner.html +75
| @@ -20,4 +20,79 @@ | ||
| 20 | 20 | {{range .Repos}}{{template "reporow" .}} |
| 21 | 21 | {{else}}<li class="empty">no visible repositories</li>{{end}} |
| 22 | 22 | </ul> |
| 23 | ||
| 24 | {{if .CanAdmin}}{{$org := .Owner}} | |
| 25 | <h2>people <span class="count">{{len .Members}}</span></h2> | |
| 26 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 27 | <div class="tablewrap"><table class="keys"> | |
| 28 | <tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"></th></tr> | |
| 29 | {{range .Members}}<tr> | |
| 30 | <td><a href="/{{.Username}}">{{.Username}}</a></td> | |
| 31 | <td>{{.Role}}</td> | |
| 32 | <td class="act"><form method="post" action="/{{$org}}"><input type="hidden" name="field" value="member-remove"><input type="hidden" name="user" value="{{.Username}}"><button type="submit" class="linklike">Remove</button></form></td> | |
| 33 | </tr> | |
| 34 | {{end}}</table></div> | |
| 35 | <details class="editbox"> | |
| 36 | <summary>Add or change a member</summary> | |
| 37 | <form method="post" action="/{{$org}}" class="setform"> | |
| 38 | <input type="hidden" name="field" value="member-add"> | |
| 39 | <label for="member">Username</label> | |
| 40 | <input type="text" id="member" name="user" required> | |
| 41 | <label class="none" for="memberrole">Role</label> | |
| 42 | <select id="memberrole" name="role"> | |
| 43 | <option value="member">member</option> | |
| 44 | <option value="admin">admin</option> | |
| 45 | </select> | |
| 46 | <button type="submit">Apply</button> | |
| 47 | </form> | |
| 48 | </details> | |
| 49 | ||
| 50 | <h2>teams <span class="count">{{len .Teams}}</span></h2> | |
| 51 | <p class="meta">Teams scope repository access inside the organization: members | |
| 52 | of a team get its role on every repository it is granted.</p> | |
| 53 | {{range .Teams}}<details class="difffold" open> | |
| 54 | <summary><span class="fpath">{{.Name}}</span> | |
| 55 | <span class="fstat">{{len .Members}} member{{if ne (len .Members) 1}}s{{end}} · {{len .Grants}} repo{{if ne (len .Grants) 1}}s{{end}}</span> | |
| 56 | </summary> | |
| 57 | <div class="teambody"> | |
| 58 | <p class="meta">members: {{range .Members}}<a class="memberchip" href="/{{.}}">{{.}}</a> {{else}}<span class="none">none yet</span>{{end}}</p> | |
| 59 | <p class="meta">repositories: {{range .Grants}}<span class="memberchip"><a href="/{{.RepoPath}}">{{.RepoPath}}</a> <span class="role">{{.Role}}</span></span> {{else}}<span class="none">none yet</span>{{end}}</p> | |
| 60 | <form method="post" action="/{{$org}}" class="setform"> | |
| 61 | <input type="hidden" name="team" value="{{.Name}}"> | |
| 62 | <label class="none" for="tm-{{.Name}}">Members</label> | |
| 63 | <input type="text" id="tm-{{.Name}}" name="user" placeholder="usernames, space-separated"> | |
| 64 | <button type="submit" name="field" value="team-add">Add</button> | |
| 65 | <button type="submit" name="field" value="team-remove">Remove</button> | |
| 66 | </form> | |
| 67 | <form method="post" action="/{{$org}}" class="setform"> | |
| 68 | <input type="hidden" name="team" value="{{.Name}}"> | |
| 69 | <label class="none" for="tr-{{.Name}}">Repository</label> | |
| 70 | <input type="text" id="tr-{{.Name}}" name="repo" placeholder="owner/name"> | |
| 71 | <label class="none" for="trr-{{.Name}}">Role</label> | |
| 72 | <select id="trr-{{.Name}}" name="role"> | |
| 73 | <option value="read">read</option> | |
| 74 | <option value="write">write</option> | |
| 75 | <option value="admin">admin</option> | |
| 76 | </select> | |
| 77 | <button type="submit" name="field" value="team-grant">Grant</button> | |
| 78 | <button type="submit" name="field" value="team-revoke">Revoke</button> | |
| 79 | </form> | |
| 80 | <form method="post" action="/{{$org}}" class="setform"> | |
| 81 | <input type="hidden" name="field" value="team-delete"> | |
| 82 | <input type="hidden" name="team" value="{{.Name}}"> | |
| 83 | <button type="submit" class="linklike">Delete this team</button> | |
| 84 | </form> | |
| 85 | </div> | |
| 86 | </details> | |
| 87 | {{else}}<p class="none">No teams yet.</p>{{end}} | |
| 88 | <details class="editbox"> | |
| 89 | <summary>Create a team</summary> | |
| 90 | <form method="post" action="/{{$org}}" class="setform"> | |
| 91 | <input type="hidden" name="field" value="team-create"> | |
| 92 | <label for="newteam">Name</label> | |
| 93 | <input type="text" id="newteam" name="team" required> | |
| 94 | <button type="submit">Create</button> | |
| 95 | </form> | |
| 96 | </details> | |
| 97 | {{end}} | |
| 23 | 98 | {{end}} |