Commit 9da25671b7
Verified · cmc
Layout: unified · split
e2e/profileweb_test.go added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/url" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | // TestProfileSettingsWeb covers profile set from the account settings page | |
| 10 | // (#161): description, website, links and about round-trip through the | |
| 11 | // form, and emptying a field actually clears it rather than being skipped. | |
| 12 | func TestProfileSettingsWeb(t *testing.T) { | |
| 13 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 14 | aliceKey := inst.newKey(t, "alice") | |
| 15 | inst.admin(t, "admin", "user", "create", "alice", | |
| 16 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 17 | alice := inst.login(t, aliceKey) | |
| 18 | settingsURL := inst.base() + "/settings" | |
| 19 | ||
| 20 | // The form is on the page, every input labelled. | |
| 21 | _, body := browserGet(t, alice, settingsURL) | |
| 22 | for _, want := range []string{ | |
| 23 | `<label for="p-description">`, `<label for="p-website">`, | |
| 24 | `<label for="p-links">`, `<label for="p-about">`, `<label for="format">`, | |
| 25 | } { | |
| 26 | if !strings.Contains(body, want) { | |
| 27 | t.Fatalf("profile form missing %q:\n%s", want, body) | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | // Setting every field lands where the CLI reads it. | |
| 32 | status, _ := browserPost(t, alice, settingsURL, url.Values{ | |
| 33 | "field": {"profile"}, | |
| 34 | "description": {"builds small tools"}, | |
| 35 | "website": {"https://alice.example"}, | |
| 36 | "links": {"Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now"}, | |
| 37 | "about": {"hello there"}, | |
| 38 | "format": {"md"}, | |
| 39 | }) | |
| 40 | if status != 200 && status != 303 { | |
| 41 | t.Fatalf("profile post: %d", status) | |
| 42 | } | |
| 43 | out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "--json") | |
| 44 | for _, want := range []string{ | |
| 45 | `"description":"builds small tools"`, `"website":"https://alice.example"`, | |
| 46 | `"label":"Mastodon"`, `"url":"https://fosstodon.example/@alice"`, | |
| 47 | `"url":"https://alice.example/now"`, `"about":"hello there"`, | |
| 48 | } { | |
| 49 | if !strings.Contains(out, want) { | |
| 50 | t.Fatalf("profile set missing %q: %s", want, out) | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | // The page shows what was just saved. | |
| 55 | _, body = browserGet(t, alice, settingsURL) | |
| 56 | for _, want := range []string{ | |
| 57 | "builds small tools", "https://alice.example", "Mastodon|https://fosstodon.example/@alice", | |
| 58 | "https://alice.example/now", "hello there", | |
| 59 | } { | |
| 60 | if !strings.Contains(body, want) { | |
| 61 | t.Fatalf("settings page did not round-trip %q:\n%s", want, body) | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | // The whole form resubmits every time, the way a settings page does: | |
| 66 | // each step below carries the fields already in place and changes one. | |
| 67 | links := "Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now" | |
| 68 | ||
| 69 | // The org choice is honoured on the profile page. | |
| 70 | if status, _ := browserPost(t, alice, settingsURL, url.Values{ | |
| 71 | "field": {"profile"}, "description": {"builds small tools"}, | |
| 72 | "website": {"https://alice.example"}, "links": {links}, | |
| 73 | "about": {"a /note/ in org"}, "format": {"org"}, | |
| 74 | }); status != 200 && status != 303 { | |
| 75 | t.Fatalf("profile post (org): %d", status) | |
| 76 | } | |
| 77 | if _, page := browserGet(t, alice, inst.base()+"/alice"); !strings.Contains(page, "<em>note</em>") { | |
| 78 | t.Fatalf("about did not render as org:\n%s", page) | |
| 79 | } | |
| 80 | ||
| 81 | // Emptying the website clears it, not leaves it alone. | |
| 82 | if status, _ := browserPost(t, alice, settingsURL, url.Values{ | |
| 83 | "field": {"profile"}, "description": {"builds small tools"}, | |
| 84 | "website": {""}, "links": {links}, "about": {"a /note/ in org"}, "format": {"org"}, | |
| 85 | }); status != 200 && status != 303 { | |
| 86 | t.Fatalf("profile post (clear website): %d", status) | |
| 87 | } | |
| 88 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json") | |
| 89 | if strings.Contains(out, "alice.example") && strings.Contains(out, `"website"`) { | |
| 90 | t.Fatalf("website not cleared: %s", out) | |
| 91 | } | |
| 92 | if !strings.Contains(out, "builds small tools") { | |
| 93 | t.Fatalf("clearing website clobbered the description: %s", out) | |
| 94 | } | |
| 95 | if !strings.Contains(out, `"label":"Mastodon"`) { | |
| 96 | t.Fatalf("clearing website clobbered the links: %s", out) | |
| 97 | } | |
| 98 | ||
| 99 | // Emptying the links field clears the whole list. | |
| 100 | if status, _ := browserPost(t, alice, settingsURL, url.Values{ | |
| 101 | "field": {"profile"}, "description": {"builds small tools"}, | |
| 102 | "links": {""}, "about": {"a /note/ in org"}, "format": {"org"}, | |
| 103 | }); status != 200 && status != 303 { | |
| 104 | t.Fatalf("profile post (clear links): %d", status) | |
| 105 | } | |
| 106 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json") | |
| 107 | if strings.Contains(out, "Mastodon") || strings.Contains(out, `"links"`) { | |
| 108 | t.Fatalf("links not cleared: %s", out) | |
| 109 | } | |
| 110 | } | |
internal/httpd/account.go +67 −10
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "net/url" |
| 7 | 7 | "strings" |
| 8 | 8 | |
| 9 | "gitbay.org/gitbay/internal/control" | |
| 9 | 10 | "gitbay.org/gitbay/internal/store" |
| 10 | 11 | ) |
| 11 | 12 | |
| @@ -46,21 +47,58 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use | ||
| 46 | 47 | } |
| 47 | 48 | emails, _ := s.st.ListEmails(u.ID) |
| 48 | 49 | |
| 50 | var profile control.ProfileOut | |
| 51 | s.runControlInto(u, []string{"profile", "show"}, &profile) | |
| 52 | ||
| 49 | 53 | s.render(w, "account.html", struct { |
| 50 | 54 | basePage |
| 51 | Tab string // marks the rail's Settings row as current | |
| 52 | Keys []accountKey | |
| 53 | PGP []accountPGP | |
| 54 | Emails []store.Email | |
| 55 | Host string | |
| 56 | Notice string | |
| 57 | Message string | |
| 58 | }{s.baseFor(u), "account", keys, pgp, emails, s.cfg.SiteHost(), | |
| 55 | Tab string // marks the rail's Settings row as current | |
| 56 | Keys []accountKey | |
| 57 | PGP []accountPGP | |
| 58 | Emails []store.Email | |
| 59 | Profile control.ProfileOut | |
| 60 | LinksText string | |
| 61 | Host string | |
| 62 | Notice string | |
| 63 | Message string | |
| 64 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(), | |
| 59 | 65 | s.takeFlash(w, r), r.URL.Query().Get("m")}) |
| 60 | 66 | } |
| 61 | 67 | |
| 62 | // accountSubmit routes the account forms to their commands. Everything | |
| 63 | // here is a public key or an address — no secret is accepted over the web. | |
| 68 | // profileLinksText turns a profile's links into the form the textarea | |
| 69 | // shows and reads back: one per line, "label|url" when there is a label | |
| 70 | // and the bare url otherwise. | |
| 71 | func profileLinksText(links []store.ProfileLink) string { | |
| 72 | lines := make([]string, len(links)) | |
| 73 | for i, l := range links { | |
| 74 | if l.Label != "" { | |
| 75 | lines[i] = l.Label + "|" + l.URL | |
| 76 | } else { | |
| 77 | lines[i] = l.URL | |
| 78 | } | |
| 79 | } | |
| 80 | return strings.Join(lines, "\n") | |
| 81 | } | |
| 82 | ||
| 83 | // profileLinkArgs turns the textarea back into the --link values profile | |
| 84 | // set expects: one per non-blank line, or a single empty one to clear the | |
| 85 | // list when the field was emptied. | |
| 86 | func profileLinkArgs(raw string) []string { | |
| 87 | var links []string | |
| 88 | for _, line := range strings.Split(raw, "\n") { | |
| 89 | if line = strings.TrimSpace(line); line != "" { | |
| 90 | links = append(links, line) | |
| 91 | } | |
| 92 | } | |
| 93 | if links == nil { | |
| 94 | return []string{""} | |
| 95 | } | |
| 96 | return links | |
| 97 | } | |
| 98 | ||
| 99 | // accountSubmit routes the account forms to their commands. Keys, | |
| 100 | // addresses and the profile are the whole surface — no secret is accepted | |
| 101 | // over the web. | |
| 64 | 102 | func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 65 | 103 | back := func(msg, note string) { |
| 66 | 104 | q := "" |
| @@ -122,6 +160,25 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U | ||
| 122 | 160 | return |
| 123 | 161 | } |
| 124 | 162 | back("", "address verified") |
| 163 | case "profile": | |
| 164 | format := r.FormValue("format") | |
| 165 | if format != "org" { | |
| 166 | format = "md" | |
| 167 | } | |
| 168 | argv := []string{"profile", "set", | |
| 169 | "--description", r.FormValue("description"), | |
| 170 | "--website", r.FormValue("website"), | |
| 171 | "--about-format", format, | |
| 172 | "--file", "-", | |
| 173 | } | |
| 174 | for _, link := range profileLinkArgs(r.FormValue("links")) { | |
| 175 | argv = append(argv, "--link", link) | |
| 176 | } | |
| 177 | if msg, ok := s.runControlStdin(u, argv, r.FormValue("about")); !ok { | |
| 178 | back(msg, "") | |
| 179 | return | |
| 180 | } | |
| 181 | back("", "profile updated") | |
| 125 | 182 | default: |
| 126 | 183 | back("unknown form", "") |
| 127 | 184 | } |
internal/web/templates/account.html +16
| @@ -4,6 +4,22 @@ | ||
| 4 | 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 5 | 5 | {{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} |
| 6 | 6 | |
| 7 | <h2>Profile</h2> | |
| 8 | <p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p> | |
| 9 | <form method="post" action="/settings" class="setform stack"> | |
| 10 | <input type="hidden" name="field" value="profile"> | |
| 11 | <label for="p-description">Description</label> | |
| 12 | <input type="text" id="p-description" name="description" value="{{.Profile.Description}}" placeholder="one line, shown in listings"> | |
| 13 | <label for="p-website">Website</label> | |
| 14 | <input type="text" id="p-website" name="website" value="{{.Profile.Website}}" placeholder="https://example.org"> | |
| 15 | <label for="p-links">Links</label> | |
| 16 | <textarea id="p-links" name="links" rows="3" placeholder="one per line: label|https://... or a bare https://... (at most 5)">{{.LinksText}}</textarea> | |
| 17 | <label for="p-about">About</label> | |
| 18 | <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Profile.About}}</textarea> | |
| 19 | {{template "formatpicker" .Profile.AboutFormat}} | |
| 20 | <button type="submit">Save profile</button> | |
| 21 | </form> | |
| 22 | ||
| 7 | 23 | <h2>SSH keys</h2> |
| 8 | 24 | <p class="meta">Your keys are your identity here. A <code>full</code> key can run |
| 9 | 25 | commands and push; a <code>git</code> key can only move git data, which is what |