e2e/orgweb_test.go
243 lines · 9701 bytes
1package e2e
2
3import (
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.
14func TestOrgManagementWeb(t *testing.T) {
15 t.Parallel()
16 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
17 aliceKey := inst.newKey(t, "alice")
18 bobKey := inst.newKey(t, "bob")
19 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
20 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 {
22 t.Fatalf("org create: %s", errOut)
23 }
24 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/widget"); code != 0 {
25 t.Fatalf("repo create: %s", errOut)
26 }
27
28 alice := loginBrowser(t, inst, aliceKey)
29
30 // The management sections are admin-only: bob is not even a member.
31 bob := loginBrowser(t, inst, bobKey)
32 // The people tab is the admin panel, so an outsider gets the 404 a
33 // page nobody has rather than a page with the controls hidden.
34 if status, body := browserGet(t, bob, inst.base()+"/acme/-/people"); status != 404 ||
35 strings.Contains(body, `value="member-add"`) {
36 t.Fatalf("a non-member reaches the organization controls: %d", status)
37 }
38 // And POSTing anyway is refused by the command, not by the template.
39 browserPost(t, bob, inst.base()+"/acme", url.Values{
40 "field": {"member-add"}, "user": {"bob"}, "role": {"admin"},
41 })
42 if members := orgMembers(t, inst, aliceKey); len(members) != 1 {
43 t.Fatalf("non-admin added themselves: %v", members)
44 }
45
46 status, body := browserGet(t, alice, inst.base()+"/acme/-/people")
47 if status != 200 || !strings.Contains(body, `value="member-add"`) {
48 t.Fatalf("admin sees no controls: %d", status)
49 }
50
51 // Add bob as a member through the form; confirm over SSH.
52 browserPost(t, alice, inst.base()+"/acme", url.Values{
53 "field": {"member-add"}, "user": {"bob"}, "role": {"member"},
54 })
55 if members := orgMembers(t, inst, aliceKey); len(members) != 2 {
56 t.Fatalf("member not added: %v", members)
57 }
58
59 // Create a team, put bob in it, and grant it write on the repo.
60 browserPost(t, alice, inst.base()+"/acme", url.Values{
61 "field": {"team-create"}, "team": {"builders"},
62 })
63 browserPost(t, alice, inst.base()+"/acme", url.Values{
64 "field": {"team-add"}, "team": {"builders"}, "user": {"bob"},
65 })
66 browserPost(t, alice, inst.base()+"/acme", url.Values{
67 "field": {"team-grant"}, "team": {"builders"},
68 "repo": {"acme/widget"}, "role": {"write"},
69 })
70 out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json")
71 if !strings.Contains(out, `"bob"`) || !strings.Contains(out, `"acme/widget"`) ||
72 !strings.Contains(out, `"write"`) {
73 t.Fatalf("team not configured: %s", out)
74 }
75 // The grant is real access, not just a row: bob can now push.
76 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "show", "acme/widget"); code != 0 {
77 t.Fatalf("team grant did not confer access: %s", errOut)
78 }
79
80 // The people tab shows what was built.
81 _, body = browserGet(t, alice, inst.base()+"/acme/-/people")
82 for _, want := range []string{"builders", "acme/widget", "1 member"} {
83 if !strings.Contains(body, want) {
84 t.Errorf("org page missing %q", want)
85 }
86 }
87
88 // Revoking and removing need the team's name typed; a bare post
89 // changes nothing.
90 browserPost(t, alice, inst.base()+"/acme", url.Values{
91 "field": {"team-revoke"}, "team": {"builders"}, "repo": {"acme/widget"},
92 })
93 if out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); !strings.Contains(out, `"acme/widget"`) {
94 t.Fatalf("unconfirmed revoke dropped the grant: %s", out)
95 }
96 browserPost(t, alice, inst.base()+"/acme", url.Values{
97 "field": {"team-revoke"}, "team": {"builders"}, "repo": {"acme/widget"}, "confirm": {"builders"},
98 })
99 if out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); strings.Contains(out, `"acme/widget"`) {
100 t.Fatalf("confirmed revoke left the grant: %s", out)
101 }
102 browserPost(t, alice, inst.base()+"/acme", url.Values{
103 "field": {"team-remove"}, "team": {"builders"}, "user": {"bob"},
104 })
105 if out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); !strings.Contains(out, `"bob"`) {
106 t.Fatalf("unconfirmed team remove took bob out: %s", out)
107 }
108 browserPost(t, alice, inst.base()+"/acme", url.Values{
109 "field": {"team-remove"}, "team": {"builders"}, "user": {"bob"}, "confirm": {"builders"},
110 })
111 if out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); strings.Contains(out, `"bob"`) {
112 t.Fatalf("confirmed team remove left bob in: %s", out)
113 }
114
115 // Deleting the team needs its name typed; a bare post is refused and
116 // the team stays.
117 _, body = browserPost(t, alice, inst.base()+"/acme", url.Values{
118 "field": {"team-delete"}, "team": {"builders"},
119 })
120 if !strings.Contains(body, "type builders to confirm") {
121 t.Fatalf("unconfirmed team delete was not refused:\n%s", body)
122 }
123 if _, _, code := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); code != 0 {
124 t.Fatal("team deleted without confirmation")
125 }
126 browserPost(t, alice, inst.base()+"/acme", url.Values{
127 "field": {"team-delete"}, "team": {"builders"}, "confirm": {"builders"},
128 })
129 if _, _, code := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); code != 3 {
130 t.Fatalf("team not deleted: exit %d", code)
131 }
132
133 _, body = browserPost(t, alice, inst.base()+"/acme", url.Values{
134 "field": {"member-remove"}, "user": {"bob"},
135 })
136 if !strings.Contains(body, "type bob to confirm") {
137 t.Fatalf("unconfirmed member remove was not refused:\n%s", body)
138 }
139 if members := orgMembers(t, inst, aliceKey); len(members) != 2 {
140 t.Fatalf("member removed without confirmation: %v", members)
141 }
142 browserPost(t, alice, inst.base()+"/acme", url.Values{
143 "field": {"member-remove"}, "user": {"bob"}, "confirm": {"bob"},
144 })
145 if members := orgMembers(t, inst, aliceKey); len(members) != 1 {
146 t.Fatalf("member not removed: %v", members)
147 }
148}
149
150// loginBrowser mints a session over SSH and returns a browser holding it.
151func loginBrowser(t *testing.T, inst *instance, key string) *http.Client {
152 t.Helper()
153 out, errOut, code := inst.ssh(t, key, "", "web", "login", "--json")
154 if code != 0 {
155 t.Fatalf("web login: %s", errOut)
156 }
157 var env struct {
158 Data struct {
159 URL string `json:"url"`
160 } `json:"data"`
161 }
162 json.Unmarshal([]byte(out), &env)
163 c := newBrowser(t)
164 browserGet(t, c, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):])
165 return c
166}
167
168func orgMembers(t *testing.T, inst *instance, key string) []string {
169 t.Helper()
170 out, _, _ := inst.ssh(t, key, "", "org", "members", "list", "acme", "--json")
171 var env struct {
172 Data struct {
173 Members []struct {
174 User string `json:"user"`
175 } `json:"members"`
176 } `json:"data"`
177 }
178 if err := json.Unmarshal([]byte(out), &env); err != nil {
179 t.Fatalf("members JSON: %v\n%s", err, out)
180 }
181 var names []string
182 for _, m := range env.Data.Members {
183 names = append(names, m.User)
184 }
185 return names
186}
187
188// The organization lifecycle from a browser: create from your own page,
189// rename from the org's. Delete stays on the CLI, where a typed
190// confirmation is the norm (#167).
191func TestOrgLifecycleWeb(t *testing.T) {
192 t.Parallel()
193 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
194 aliceKey := inst.newKey(t, "alice")
195 bobKey := inst.newKey(t, "bob")
196 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
197 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
198 alice := loginBrowser(t, inst, aliceKey)
199 bob := loginBrowser(t, inst, bobKey)
200
201 // The create form is on /new, beside the repository form, and no
202 // profile page carries it.
203 if _, body := browserGet(t, alice, inst.base()+"/new"); !strings.Contains(body, `value="org-create"`) {
204 t.Fatalf("no create form on /new:\n%s", body)
205 }
206 if _, body := browserGet(t, alice, inst.base()+"/alice"); strings.Contains(body, `value="org-create"`) {
207 t.Fatal("create form still on the profile page")
208 }
209
210 if status, _ := browserPost(t, alice, inst.base()+"/new", url.Values{
211 "field": {"org-create"}, "name": {"acmeco"}}); status != 200 {
212 t.Fatal("org create failed")
213 }
214 if out, _, _ := inst.ssh(t, aliceKey, "", "org", "list", "--json"); !strings.Contains(out, "acmeco") {
215 t.Fatalf("org not created:\n%s", out)
216 }
217
218 // Rename is offered to its admin, and the org moves.
219 _, body := browserGet(t, alice, inst.base()+"/acmeco/-/people")
220 if !strings.Contains(body, `value="org-rename"`) {
221 t.Fatalf("no rename form for the org admin:\n%s", body)
222 }
223 if !strings.Contains(body, "gitbay org delete") || strings.Contains(body, `value="org-delete"`) {
224 t.Error("delete is not recorded as a CLI operation")
225 }
226 if status, _ := browserPost(t, alice, inst.base()+"/acmeco", url.Values{
227 "field": {"org-rename"}, "name": {"acmeltd"}}); status != 200 {
228 t.Fatal("org rename failed")
229 }
230 if _, _, code := inst.ssh(t, aliceKey, "", "org", "show", "acmeltd"); code != 0 {
231 t.Fatal("renamed org not found under its new name")
232 }
233 if status, _ := browserGet(t, alice, inst.base()+"/acmeco"); status != http.StatusNotFound {
234 t.Errorf("old org name still resolves: %d", status)
235 }
236
237 // A non-admin cannot rename it, form or no form.
238 browserPost(t, bob, inst.base()+"/acmeltd", url.Values{
239 "field": {"org-rename"}, "name": {"bobsltd"}})
240 if _, _, code := inst.ssh(t, aliceKey, "", "org", "show", "acmeltd"); code != 0 {
241 t.Fatal("a non-admin renamed the organization")
242 }
243}