package e2e import ( "encoding/json" "net/http" "net/url" "strings" "testing" ) // TestOrgManagementWeb covers running an organization from the browser: // membership and teams, admin-gated, dispatched through the same commands // the CLI uses. func TestOrgManagementWeb(t *testing.T) { t.Parallel() inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") aliceKey := inst.newKey(t, "alice") bobKey := inst.newKey(t, "bob") inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 { t.Fatalf("org create: %s", errOut) } if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/widget"); code != 0 { t.Fatalf("repo create: %s", errOut) } alice := loginBrowser(t, inst, aliceKey) // The management sections are admin-only: bob is not even a member. bob := loginBrowser(t, inst, bobKey) // The people tab is the admin panel, so an outsider gets the 404 a // page nobody has rather than a page with the controls hidden. if status, body := browserGet(t, bob, inst.base()+"/acme/-/people"); status != 404 || strings.Contains(body, `value="member-add"`) { t.Fatalf("a non-member reaches the organization controls: %d", status) } // And POSTing anyway is refused by the command, not by the template. browserPost(t, bob, inst.base()+"/acme", url.Values{ "field": {"member-add"}, "user": {"bob"}, "role": {"admin"}, }) if members := orgMembers(t, inst, aliceKey); len(members) != 1 { t.Fatalf("non-admin added themselves: %v", members) } status, body := browserGet(t, alice, inst.base()+"/acme/-/people") if status != 200 || !strings.Contains(body, `value="member-add"`) { t.Fatalf("admin sees no controls: %d", status) } // Add bob as a member through the form; confirm over SSH. browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"member-add"}, "user": {"bob"}, "role": {"member"}, }) if members := orgMembers(t, inst, aliceKey); len(members) != 2 { t.Fatalf("member not added: %v", members) } // Create a team, put bob in it, and grant it write on the repo. browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"team-create"}, "team": {"builders"}, }) browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"team-add"}, "team": {"builders"}, "user": {"bob"}, }) browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"team-grant"}, "team": {"builders"}, "repo": {"acme/widget"}, "role": {"write"}, }) out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json") if !strings.Contains(out, `"bob"`) || !strings.Contains(out, `"acme/widget"`) || !strings.Contains(out, `"write"`) { t.Fatalf("team not configured: %s", out) } // The grant is real access, not just a row: bob can now push. if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "show", "acme/widget"); code != 0 { t.Fatalf("team grant did not confer access: %s", errOut) } // The people tab shows what was built. _, body = browserGet(t, alice, inst.base()+"/acme/-/people") for _, want := range []string{"builders", "acme/widget", "1 member"} { if !strings.Contains(body, want) { t.Errorf("org page missing %q", want) } } // Revoking and removing need the team's name typed; a bare post // changes nothing. browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"team-revoke"}, "team": {"builders"}, "repo": {"acme/widget"}, }) if out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); !strings.Contains(out, `"acme/widget"`) { t.Fatalf("unconfirmed revoke dropped the grant: %s", out) } browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"team-revoke"}, "team": {"builders"}, "repo": {"acme/widget"}, "confirm": {"builders"}, }) if out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); strings.Contains(out, `"acme/widget"`) { t.Fatalf("confirmed revoke left the grant: %s", out) } browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"team-remove"}, "team": {"builders"}, "user": {"bob"}, }) if out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); !strings.Contains(out, `"bob"`) { t.Fatalf("unconfirmed team remove took bob out: %s", out) } browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"team-remove"}, "team": {"builders"}, "user": {"bob"}, "confirm": {"builders"}, }) if out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); strings.Contains(out, `"bob"`) { t.Fatalf("confirmed team remove left bob in: %s", out) } // Deleting the team needs its name typed; a bare post is refused and // the team stays. _, body = browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"team-delete"}, "team": {"builders"}, }) if !strings.Contains(body, "type builders to confirm") { t.Fatalf("unconfirmed team delete was not refused:\n%s", body) } if _, _, code := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); code != 0 { t.Fatal("team deleted without confirmation") } browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"team-delete"}, "team": {"builders"}, "confirm": {"builders"}, }) if _, _, code := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); code != 3 { t.Fatalf("team not deleted: exit %d", code) } _, body = browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"member-remove"}, "user": {"bob"}, }) if !strings.Contains(body, "type bob to confirm") { t.Fatalf("unconfirmed member remove was not refused:\n%s", body) } if members := orgMembers(t, inst, aliceKey); len(members) != 2 { t.Fatalf("member removed without confirmation: %v", members) } browserPost(t, alice, inst.base()+"/acme", url.Values{ "field": {"member-remove"}, "user": {"bob"}, "confirm": {"bob"}, }) if members := orgMembers(t, inst, aliceKey); len(members) != 1 { t.Fatalf("member not removed: %v", members) } } // loginBrowser mints a session over SSH and returns a browser holding it. func loginBrowser(t *testing.T, inst *instance, key string) *http.Client { t.Helper() out, errOut, code := inst.ssh(t, key, "", "web", "login", "--json") if code != 0 { t.Fatalf("web login: %s", errOut) } var env struct { Data struct { URL string `json:"url"` } `json:"data"` } json.Unmarshal([]byte(out), &env) c := newBrowser(t) browserGet(t, c, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):]) return c } func orgMembers(t *testing.T, inst *instance, key string) []string { t.Helper() out, _, _ := inst.ssh(t, key, "", "org", "members", "list", "acme", "--json") var env struct { Data struct { Members []struct { User string `json:"user"` } `json:"members"` } `json:"data"` } if err := json.Unmarshal([]byte(out), &env); err != nil { t.Fatalf("members JSON: %v\n%s", err, out) } var names []string for _, m := range env.Data.Members { names = append(names, m.User) } return names } // The organization lifecycle from a browser: create from your own page, // rename from the org's. Delete stays on the CLI, where a typed // confirmation is the norm (#167). func TestOrgLifecycleWeb(t *testing.T) { t.Parallel() inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") aliceKey := inst.newKey(t, "alice") bobKey := inst.newKey(t, "bob") inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") alice := loginBrowser(t, inst, aliceKey) bob := loginBrowser(t, inst, bobKey) // The create form is on /new, beside the repository form, and no // profile page carries it. if _, body := browserGet(t, alice, inst.base()+"/new"); !strings.Contains(body, `value="org-create"`) { t.Fatalf("no create form on /new:\n%s", body) } if _, body := browserGet(t, alice, inst.base()+"/alice"); strings.Contains(body, `value="org-create"`) { t.Fatal("create form still on the profile page") } if status, _ := browserPost(t, alice, inst.base()+"/new", url.Values{ "field": {"org-create"}, "name": {"acmeco"}}); status != 200 { t.Fatal("org create failed") } if out, _, _ := inst.ssh(t, aliceKey, "", "org", "list", "--json"); !strings.Contains(out, "acmeco") { t.Fatalf("org not created:\n%s", out) } // Rename is offered to its admin, and the org moves. _, body := browserGet(t, alice, inst.base()+"/acmeco/-/people") if !strings.Contains(body, `value="org-rename"`) { t.Fatalf("no rename form for the org admin:\n%s", body) } if !strings.Contains(body, "gitbay org delete") || strings.Contains(body, `value="org-delete"`) { t.Error("delete is not recorded as a CLI operation") } if status, _ := browserPost(t, alice, inst.base()+"/acmeco", url.Values{ "field": {"org-rename"}, "name": {"acmeltd"}}); status != 200 { t.Fatal("org rename failed") } if _, _, code := inst.ssh(t, aliceKey, "", "org", "show", "acmeltd"); code != 0 { t.Fatal("renamed org not found under its new name") } if status, _ := browserGet(t, alice, inst.base()+"/acmeco"); status != http.StatusNotFound { t.Errorf("old org name still resolves: %d", status) } // A non-admin cannot rename it, form or no form. browserPost(t, bob, inst.base()+"/acmeltd", url.Values{ "field": {"org-rename"}, "name": {"bobsltd"}}) if _, _, code := inst.ssh(t, aliceKey, "", "org", "show", "acmeltd"); code != 0 { t.Fatal("a non-admin renamed the organization") } }