e2e/profile_test.go
234 lines · 10011 bytes
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func TestOwnerProfiles(t *testing.T) {
11 inst := startInstance(t)
12 aliceKey := inst.newKey(t, "alice")
13 bobKey := inst.newKey(t, "bob")
14 // A verified address, because the about text is a commit now.
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
18
19 // Self-service user profile; website validated.
20 if _, errOut, code := inst.ssh(t, aliceKey, "",
21 "profile", "set", "--description", "'builds small tools'", "--website", "https://alice.example"); code != 0 {
22 t.Fatalf("profile set: %s", errOut)
23 }
24 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--website", "gopher://nope"); code != 2 {
25 t.Fatal("bad website scheme accepted")
26 }
27 out, _, _ := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
28 if !strings.Contains(out, `"description":"builds small tools"`) || !strings.Contains(out, `"website":"https://alice.example"`) {
29 t.Fatalf("profile show: %s", out)
30 }
31
32 // A profile is the whole page's worth: repositories the caller may
33 // see, org membership, and the activity window — all previously
34 // readable only by the web, which went straight to the store.
35 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/public-tool"); code != 0 {
36 t.Fatal("repo create")
37 }
38 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
39 t.Fatal("private repo create")
40 }
41 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "toolmakers"); code != 0 {
42 t.Fatal("org create")
43 }
44
45 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
46 for _, want := range []string{`"path":"alice/public-tool"`, `"path":"alice/secret"`,
47 `"name":"toolmakers"`, `"activity_total"`} {
48 if !strings.Contains(out, want) {
49 t.Errorf("own profile missing %q: %s", want, out)
50 }
51 }
52
53 // A profile's repository rows carry the listing metadata the web
54 // shows: topics, license, default branch, last commit. Without them
55 // the web had to decorate the rows itself, which is what kept it
56 // reading the store (krz/gitbay#47).
57 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/public-tool", "cli", "go"); code != 0 {
58 t.Fatalf("topics add: %s", errOut)
59 }
60 env := inst.gitEnv(aliceKey)
61 work := t.TempDir()
62 mustGit(t, work, env, "clone", inst.sshURL("alice/public-tool"), "w")
63 dir := filepath.Join(work, "w")
64 os.WriteFile(filepath.Join(dir, "LICENSE"), []byte("MIT License\n\nPermission is hereby granted, free of charge\n"), 0o644)
65 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
66 mustGit(t, dir, env, "add", ".")
67 mustGit(t, dir, env, "commit", "-q", "-m", "add license")
68 mustGit(t, dir, env, "push", "-q", "origin", "main")
69
70 out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
71 for _, want := range []string{`"cli"`, `"go"`, `"license":"MIT"`, `"default_branch":"main"`, `"updated"`} {
72 if !strings.Contains(out, want) {
73 t.Errorf("profile repo row missing %q: %s", want, out)
74 }
75 }
76 // The owner page renders those rows, having dispatched the same
77 // command rather than assembling them from the store again.
78 status, body := inst.get(t, "/alice")
79 if status != 200 {
80 t.Fatalf("owner page: %d", status)
81 }
82 for _, want := range []string{">public-tool<", "?q=cli", "MIT", "updated "} {
83 if !strings.Contains(body, want) {
84 t.Errorf("owner page missing %q:\n%s", want, body)
85 }
86 }
87 if strings.Contains(body, "secret") {
88 t.Fatal("owner page leaks a private repo")
89 }
90
91 // A stranger sees the public repository and not the private one.
92 out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
93 if !strings.Contains(out, `"path":"alice/public-tool"`) {
94 t.Errorf("stranger cannot see a public repo on a profile: %s", out)
95 }
96 if strings.Contains(out, `"alice/secret"`) {
97 t.Errorf("a private repository leaked onto a profile: %s", out)
98 }
99
100 // An org profile lists its members rather than org memberships.
101 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "toolmakers", "--json")
102 if !strings.Contains(out, `"kind":"org"`) || !strings.Contains(out, `"name":"alice"`) {
103 t.Errorf("org profile members: %s", out)
104 }
105
106 // Partial update leaves the other field untouched; empty clears.
107 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--description", "'tinkerer'"); code != 0 {
108 t.Fatal("partial set failed")
109 }
110 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json")
111 if !strings.Contains(out, "tinkerer") || !strings.Contains(out, "alice.example") {
112 t.Fatalf("partial update clobbered website: %s", out)
113 }
114 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--website", "''"); code != 0 {
115 t.Fatal("clear failed")
116 }
117 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json")
118 if strings.Contains(out, "alice.example") {
119 t.Fatalf("website not cleared: %s", out)
120 }
121
122 // Org profile: admin sets, member cannot; no flags shows.
123 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "workshop"); code != 0 {
124 t.Fatal("org create failed")
125 }
126 if _, _, code := inst.ssh(t, aliceKey, "", "org", "members", "add", "workshop", "bob"); code != 0 {
127 t.Fatal("member add failed")
128 }
129 if _, errOut, code := inst.ssh(t, aliceKey, "",
130 "org", "profile", "workshop", "--description", "'where things get made'", "--website", "https://workshop.example"); code != 0 {
131 t.Fatalf("org profile set: %s", errOut)
132 }
133 if _, _, code := inst.ssh(t, bobKey, "", "org", "profile", "workshop", "--description", "hax"); code != 4 {
134 t.Fatal("member set org profile")
135 }
136 out, _, _ = inst.ssh(t, bobKey, "", "org", "profile", "workshop")
137 if !strings.Contains(out, "where things get made") {
138 t.Fatalf("org profile show: %s", out)
139 }
140
141 // About: a file in <owner>/.gitbay, rendered on the page. The
142 // extension picks the renderer; there is no stored format.
143 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay"); code != 0 {
144 t.Fatalf("creating alice/.gitbay: %s", errOut)
145 }
146 if _, errOut, code := inst.ssh(t, aliceKey, "* Tools\n\nI maintain /small tools/.\n",
147 "repo", "commit-file", "alice/.gitbay", "profile/README.org",
148 "--ref", "main", "--file", "-"); code != 0 {
149 t.Fatalf("org about: %s", errOut)
150 }
151 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
152 if !strings.Contains(out, `"about_format":"org"`) {
153 t.Fatalf("about format not read from the extension: %s", out)
154 }
155 if !strings.Contains(out, "tinkerer") {
156 t.Fatalf("about clobbered the description: %s", out)
157 }
158 // Org emphasis parsed, not left as literal slashes the way the
159 // markdown renderer would.
160 _, body = inst.get(t, "/alice")
161 if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") {
162 t.Fatalf("org about not rendered as org: %s", body)
163 }
164
165 // Markdown for the rest of the checks: .md wins the resolution order.
166 if _, errOut, code := inst.ssh(t, aliceKey, "# Hello\n\nI maintain *small tools*.\n",
167 "repo", "commit-file", "alice/.gitbay", "profile/README.md",
168 "--ref", "main", "--file", "-"); code != 0 {
169 t.Fatalf("markdown about: %s", errOut)
170 }
171 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
172 if !strings.Contains(out, "I maintain *small tools*.") {
173 t.Fatalf("about not read from the repository: %s", out)
174 }
175
176 // Links: free-form, labelled or bare, capped, cleared by an empty one.
177 if _, errOut, code := inst.ssh(t, aliceKey, "", "profile", "set",
178 "--link", "'Mastodon|https://fosstodon.example/@alice'",
179 "--link", "https://alice.example/now"); code != 0 {
180 t.Fatalf("links: %s", errOut)
181 }
182 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
183 if !strings.Contains(out, `"label":"Mastodon"`) ||
184 !strings.Contains(out, `"url":"https://fosstodon.example/@alice"`) ||
185 !strings.Contains(out, `"url":"https://alice.example/now"`) {
186 t.Fatalf("links not stored: %s", out)
187 }
188 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--link", "'x|gopher://nope'"); code != 2 {
189 t.Fatal("bad link scheme accepted")
190 }
191 sixth := []string{"profile", "set"}
192 for i := 0; i < 6; i++ {
193 sixth = append(sixth, "--link", "https://example.org/"+string(rune('a'+i)))
194 }
195 if _, _, code := inst.ssh(t, aliceKey, "", sixth...); code != 2 {
196 t.Fatal("more than five links accepted")
197 }
198
199 // Owner pages render description and website link.
200 status, body = inst.get(t, "/alice")
201 if status != 200 || !strings.Contains(body, "tinkerer") {
202 t.Fatalf("user page profile: %d", status)
203 }
204 // About renders as markdown between the header and the activity graph;
205 // links render as chips.
206 if !strings.Contains(body, "<em>small tools</em>") {
207 t.Fatalf("about not rendered: %s", body)
208 }
209 if !strings.Contains(body, `href="https://fosstodon.example/@alice"`) ||
210 !strings.Contains(body, ">Mastodon<") {
211 t.Fatalf("links not rendered: %s", body)
212 }
213 if strings.Index(body, "<em>small tools</em>") > strings.Index(body, `class="activity"`) {
214 t.Error("about renders below the activity graph")
215 }
216 if strings.Index(body, `<ul class="repolist"`) < strings.Index(body, `class="activity"`) {
217 t.Error("repositories render above the activity graph")
218 }
219
220 // Clearing works the same way as the other fields. The about is not
221 // among them: it is a file, and it goes the way a file goes.
222 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--link", "''"); code != 0 {
223 t.Fatal("clear links failed")
224 }
225 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
226 if strings.Contains(out, "fosstodon") {
227 t.Fatalf("links not cleared: %s", out)
228 }
229 status, body = inst.get(t, "/workshop")
230 if status != 200 || !strings.Contains(body, "where things get made") ||
231 !strings.Contains(body, `href="https://workshop.example"`) {
232 t.Fatalf("org page profile: %d\n%s", status, body)
233 }
234}