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 | "net/url" | 6 | "net/url" |
| 7 | "strings" | 7 | "strings" |
| 8 | 8 | ||
| 9 | "gitbay.org/gitbay/internal/control" | ||
| 9 | "gitbay.org/gitbay/internal/store" | 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 | emails, _ := s.st.ListEmails(u.ID) | 48 | emails, _ := s.st.ListEmails(u.ID) |
| 48 | 49 | ||
| 50 | var profile control.ProfileOut | ||
| 51 | s.runControlInto(u, []string{"profile", "show"}, &profile) | ||
| 52 | |||
| 49 | s.render(w, "account.html", struct { | 53 | s.render(w, "account.html", struct { |
| 50 | basePage | 54 | basePage |
| 51 | Tab string // marks the rail's Settings row as current | 55 | Tab string // marks the rail's Settings row as current |
| 52 | Keys []accountKey | 56 | Keys []accountKey |
| 53 | PGP []accountPGP | 57 | PGP []accountPGP |
| 54 | Emails []store.Email | 58 | Emails []store.Email |
| 55 | Host string | 59 | Profile control.ProfileOut |
| 56 | Notice string | 60 | LinksText string |
| 57 | Message string | 61 | Host string |
| 58 | }{s.baseFor(u), "account", keys, pgp, emails, s.cfg.SiteHost(), | 62 | Notice string |
| 63 | Message string | ||
| 64 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(), | ||
| 59 | s.takeFlash(w, r), r.URL.Query().Get("m")}) | 65 | s.takeFlash(w, r), r.URL.Query().Get("m")}) |
| 60 | } | 66 | } |
| 61 | 67 | ||
| 62 | // accountSubmit routes the account forms to their commands. Everything | 68 | // profileLinksText turns a profile's links into the form the textarea |
| 63 | // here is a public key or an address — no secret is accepted over the web. | 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 | func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 102 | func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 65 | back := func(msg, note string) { | 103 | back := func(msg, note string) { |
| 66 | q := "" | 104 | q := "" |
| @@ -122,6 +160,25 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U | |||
| 122 | return | 160 | return |
| 123 | } | 161 | } |
| 124 | back("", "address verified") | 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 | default: | 182 | default: |
| 126 | back("unknown form", "") | 183 | back("unknown form", "") |
| 127 | } | 184 | } |
internal/web/templates/account.html +16
| @@ -4,6 +4,22 @@ | |||
| 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 5 | {{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} | 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 | <h2>SSH keys</h2> | 23 | <h2>SSH keys</h2> |
| 8 | <p class="meta">Your keys are your identity here. A <code>full</code> key can run | 24 | <p class="meta">Your keys are your identity here. A <code>full</code> key can run |
| 9 | commands and push; a <code>git</code> key can only move git data, which is what | 25 | commands and push; a <code>git</code> key can only move git data, which is what |