Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/admin_users_test.go

Line attribution on main · 301 line(s)

395554f4
Keenan Nemetz
15d ago
1 package server_test
2
3 import (
4 "net/url"
8c72543b
Keenan Nemetz
5d ago
5 "path/filepath"
395554f4
Keenan Nemetz
15d ago
6 "strings"
7 "testing"
5b814e99
Keenan Nemetz
14d ago
8
d37d301f
Keenan Nemetz
19h ago
9 "rodent.software/vole/forge/access"
395554f4
Keenan Nemetz
15d ago
10 )
11
12 func TestAdminUsersLifecycle(t *testing.T) {
13 t.Parallel()
14 f := browserFixture(t)
15 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
16 t.Fatal(err)
17 }
18 sess, cookie := f.browserSession(t, f.ownerID)
19
ea21f923
Keenan Nemetz
4d ago
20 // The admin tab creates accounts; it does not list the roster, which is now
21 // the paginated users directory.
ed03b3d1
Keenan Nemetz
6h ago
22 resp, body := f.browserGet(t, "/-/admin?tab=accounts", cookie)
ea21f923
Keenan Nemetz
4d ago
23 if resp.StatusCode != 200 || !strings.Contains(body, "Invite a person") ||
ed03b3d1
Keenan Nemetz
6h ago
24 strings.Contains(body, `<a href="/-/users/keenan">Manage</a>`) {
ea21f923
Keenan Nemetz
4d ago
25 t.Fatalf("accounts tab = %d:\n%s", resp.StatusCode, body)
395554f4
Keenan Nemetz
15d ago
26 }
ed03b3d1
Keenan Nemetz
6h ago
27 if !strings.Contains(body, `href="/-/admin?tab=accounts" aria-current="page"`) {
ea21f923
Keenan Nemetz
4d ago
28 t.Fatalf("accounts tab is missing the active top-nav link:\n%s", body)
29 }
ed03b3d1
Keenan Nemetz
6h ago
30 resp, body = f.browserGet(t, "/-/u", cookie)
ea21f923
Keenan Nemetz
4d ago
31 if resp.StatusCode != 200 ||
ed03b3d1
Keenan Nemetz
6h ago
32 !strings.Contains(body, `<a class="people-name" href="/-/u/keenan">keenan</a>`) ||
33 !strings.Contains(body, `<a href="/-/users/keenan">Manage</a>`) {
ea21f923
Keenan Nemetz
4d ago
34 t.Fatalf("users directory = %d:\n%s", resp.StatusCode, body)
381d6aac
Keenan Nemetz
14d ago
35 }
ed03b3d1
Keenan Nemetz
6h ago
36 if _, home := f.browserGet(t, "/", cookie); !strings.Contains(home, `href="/-/admin"`) {
9579c1f9
Keenan Nemetz
14d ago
37 t.Fatal("the top navigation does not link to Users for an instance admin")
395554f4
Keenan Nemetz
15d ago
38 }
39
40 create := url.Values{
5b814e99
Keenan Nemetz
14d ago
41 "csrf": {sess.CSRF}, "name": {"newbie"}, "display": {"New Bie"}, "role": {"contribute"},
395554f4
Keenan Nemetz
15d ago
42 }
ed03b3d1
Keenan Nemetz
6h ago
43 resp, body = f.browserPost(t, "/-/users", cookie, create)
395554f4
Keenan Nemetz
15d ago
44 if resp.StatusCode != 200 || !strings.Contains(body, "Enrollment link") || !strings.Contains(body, "newbie") {
45 t.Fatalf("create user = %d:\n%s", resp.StatusCode, body)
46 }
47 if resp.Header.Get("Cache-Control") != "no-store" {
48 t.Fatalf("enrollment page cache header = %q", resp.Header.Get("Cache-Control"))
49 }
ed03b3d1
Keenan Nemetz
6h ago
50 idx := strings.Index(body, "/-/invite#")
395554f4
Keenan Nemetz
15d ago
51 if idx < 0 {
52 t.Fatalf("enrollment page has no link:\n%s", body)
53 }
ed03b3d1
Keenan Nemetz
6h ago
54 rest := body[idx+len("/-/invite#"):]
395554f4
Keenan Nemetz
15d ago
55 token := rest[:strings.IndexAny(rest, "<\" ")]
edf9db6e
Keenan Nemetz
13d ago
56 if token == "" {
57 t.Fatal("enrollment link carries no token")
58 }
ed03b3d1
Keenan Nemetz
6h ago
59 if resp, page := f.browserGet(t, "/-/invite", ""); resp.StatusCode != 200 || !strings.Contains(page, "Create a passkey") {
edf9db6e
Keenan Nemetz
13d ago
60 t.Fatalf("enrollment page = %d:\n%s", resp.StatusCode, page)
395554f4
Keenan Nemetz
15d ago
61 }
62
63 acct, err := f.srv.Identity.Find("newbie")
64 if err != nil {
65 t.Fatal(err)
66 }
5b814e99
Keenan Nemetz
14d ago
67 if len(acct.Keys) != 0 || !acct.Active() || acct.Admin || acct.Role != access.Contribute {
395554f4
Keenan Nemetz
15d ago
68 t.Fatalf("created account = %+v", acct)
69 }
70
71 form := url.Values{
72 "csrf": {sess.CSRF}, "name": {"newbie"}, "display": {""},
73 }
ed03b3d1
Keenan Nemetz
6h ago
74 if resp, page := f.browserPost(t, "/-/users", cookie, form); resp.StatusCode != 400 || !strings.Contains(page, "belongs") {
395554f4
Keenan Nemetz
15d ago
75 t.Fatalf("duplicate name = %d:\n%s", resp.StatusCode, page)
76 }
77
ed03b3d1
Keenan Nemetz
6h ago
78 resp, body = f.browserGet(t, "/-/users/"+acct.Name, cookie)
395554f4
Keenan Nemetz
15d ago
79 if resp.StatusCode != 200 || !strings.Contains(body, "No SSH keys") || !strings.Contains(body, "No browser sessions") {
80 t.Fatalf("account page = %d:\n%s", resp.StatusCode, body)
81 }
82
ed03b3d1
Keenan Nemetz
6h ago
83 resp, _ = f.browserPost(t, "/-/users/"+acct.Name+"/role", cookie, url.Values{
5b814e99
Keenan Nemetz
14d ago
84 "csrf": {sess.CSRF}, "role": {"maintain"},
85 })
86 if resp.StatusCode != 303 {
87 t.Fatalf("set role = %d", resp.StatusCode)
88 }
89 if acct, err = f.srv.Identity.Find(acct.Name); err != nil || acct.Role != access.Maintain {
90 t.Fatalf("role after set = %+v, %v", acct, err)
91 }
ed03b3d1
Keenan Nemetz
6h ago
92 resp, _ = f.browserPost(t, "/-/users/"+acct.Name+"/role", cookie, url.Values{
5b814e99
Keenan Nemetz
14d ago
93 "csrf": {sess.CSRF}, "role": {"admin"},
395554f4
Keenan Nemetz
15d ago
94 })
62576fa8
Keenan Nemetz
14d ago
95 if resp.StatusCode != 303 || !f.srv.Identity.IsAdmin(acct.Name) {
96 t.Fatalf("grant admin = %d, admin=%t", resp.StatusCode, f.srv.Identity.IsAdmin(acct.Name))
395554f4
Keenan Nemetz
15d ago
97 }
ed03b3d1
Keenan Nemetz
6h ago
98 resp, _ = f.browserPost(t, "/-/users/"+acct.Name+"/role", cookie, url.Values{
5b814e99
Keenan Nemetz
14d ago
99 "csrf": {sess.CSRF}, "role": {"none"},
395554f4
Keenan Nemetz
15d ago
100 })
62576fa8
Keenan Nemetz
14d ago
101 if resp.StatusCode != 303 || f.srv.Identity.IsAdmin(acct.Name) {
102 t.Fatalf("revoke admin = %d, admin=%t", resp.StatusCode, f.srv.Identity.IsAdmin(acct.Name))
395554f4
Keenan Nemetz
15d ago
103 }
104
ed03b3d1
Keenan Nemetz
6h ago
105 resp, body = f.browserPost(t, "/-/users/"+acct.Name+"/invite", cookie, url.Values{"csrf": {sess.CSRF}})
395554f4
Keenan Nemetz
15d ago
106 if resp.StatusCode != 200 || !strings.Contains(body, "Enrollment link") {
107 t.Fatalf("refresh invite = %d:\n%s", resp.StatusCode, body)
108 }
109
110 // Keys and sessions belong to a second account so the operations have
111 // something to remove.
112 f.addWriter(t)
113 writer, err := f.srv.Identity.Find(f.writerID)
114 if err != nil {
115 t.Fatal(err)
116 }
117 if len(writer.Keys) != 1 {
118 t.Fatalf("writer keys = %d", len(writer.Keys))
119 }
62576fa8
Keenan Nemetz
14d ago
120 if _, err := f.srv.Sessions.Create(writer.Name, nil, "test-browser"); err != nil {
395554f4
Keenan Nemetz
15d ago
121 t.Fatal(err)
122 }
ed03b3d1
Keenan Nemetz
6h ago
123 resp, body = f.browserGet(t, "/-/users/"+writer.Name, cookie)
5cab7b18
Keenan Nemetz
15d ago
124 if resp.StatusCode != 200 || !strings.Contains(body, "ssh-ed25519") || !strings.Contains(body, "test-browser") {
395554f4
Keenan Nemetz
15d ago
125 t.Fatalf("account detail with key and session = %d:\n%s", resp.StatusCode, body)
126 }
ed03b3d1
Keenan Nemetz
6h ago
127 resp, _ = f.browserPost(t, "/-/users/"+writer.Name+"/sessions/revoke", cookie, url.Values{"csrf": {sess.CSRF}})
395554f4
Keenan Nemetz
15d ago
128 if resp.StatusCode != 303 {
129 t.Fatalf("revoke sessions = %d", resp.StatusCode)
130 }
62576fa8
Keenan Nemetz
14d ago
131 if list, err := f.srv.Sessions.List(writer.Name); err != nil || len(list) != 0 {
395554f4
Keenan Nemetz
15d ago
132 t.Fatalf("sessions after revoke = %d, %v", len(list), err)
133 }
ed03b3d1
Keenan Nemetz
6h ago
134 resp, _ = f.browserPost(t, "/-/users/"+writer.Name+"/keys/remove", cookie, url.Values{
395554f4
Keenan Nemetz
15d ago
135 "csrf": {sess.CSRF}, "fingerprint": {writer.Keys[0].Fingerprint},
136 })
137 if resp.StatusCode != 303 {
138 t.Fatalf("remove key = %d", resp.StatusCode)
139 }
62576fa8
Keenan Nemetz
14d ago
140 writer, err = f.srv.Identity.Find(writer.Name)
395554f4
Keenan Nemetz
15d ago
141 if err != nil {
142 t.Fatal(err)
143 }
144 if len(writer.Keys) != 0 {
145 t.Fatalf("writer still has %d key(s)", len(writer.Keys))
146 }
147
ed03b3d1
Keenan Nemetz
6h ago
148 resp, body = f.browserPost(t, "/-/users/"+writer.Name+"/retire", cookie, url.Values{
395554f4
Keenan Nemetz
15d ago
149 "csrf": {sess.CSRF}, "confirm": {"wrong"},
150 })
151 if resp.StatusCode != 400 || !strings.Contains(body, "Type the account name") {
152 t.Fatalf("retire without confirmation = %d:\n%s", resp.StatusCode, body)
153 }
ed03b3d1
Keenan Nemetz
6h ago
154 resp, _ = f.browserPost(t, "/-/users/"+writer.Name+"/retire", cookie, url.Values{
395554f4
Keenan Nemetz
15d ago
155 "csrf": {sess.CSRF}, "confirm": {"wilbur"},
156 })
ed03b3d1
Keenan Nemetz
6h ago
157 if resp.StatusCode != 303 || resp.Header.Get("Location") != "/-/admin?tab=accounts" {
395554f4
Keenan Nemetz
15d ago
158 t.Fatalf("retire = %d %q", resp.StatusCode, resp.Header.Get("Location"))
159 }
62576fa8
Keenan Nemetz
14d ago
160 writer, err = f.srv.Identity.Find(writer.Name)
395554f4
Keenan Nemetz
15d ago
161 if err != nil {
162 t.Fatal(err)
163 }
164 if writer.Active() {
165 t.Fatal("retired account is still active")
166 }
167
168 // CSRF is checked before any mutation.
ed03b3d1
Keenan Nemetz
6h ago
169 if resp, _ := f.browserPost(t, "/-/users/"+acct.Name+"/role", cookie, url.Values{"role": {"admin"}}); resp.StatusCode != 403 {
395554f4
Keenan Nemetz
15d ago
170 t.Fatalf("missing CSRF = %d, want 403", resp.StatusCode)
171 }
172 }
173
8c72543b
Keenan Nemetz
5d ago
174 // A bot has no browser sign-in, so its first credential is an SSH key. The
175 // Accounts page collects one and creates the same account the CLI would, with the
176 // same kind, role, and audit line.
177 func TestAdminCreatesBotAccount(t *testing.T) {
178 t.Parallel()
179 f := browserFixture(t)
180 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
181 t.Fatal(err)
182 }
183 sess, cookie := f.browserSession(t, f.ownerID)
184
ed03b3d1
Keenan Nemetz
6h ago
185 resp, body := f.browserGet(t, "/-/admin?tab=accounts", cookie)
186 if resp.StatusCode != 200 || !strings.Contains(body, "Create a bot account") || !strings.Contains(body, `action="/-/users/bots"`) {
8c72543b
Keenan Nemetz
5d ago
187 t.Fatalf("users page has no bot form (%d):\n%s", resp.StatusCode, body)
188 }
189
190 botKey, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_bot"))
191 form := url.Values{
192 "csrf": {sess.CSRF}, "name": {"ci-runner"}, "role": {"contribute"}, "key": {botKey},
193 }
ed03b3d1
Keenan Nemetz
6h ago
194 resp, body = f.browserPost(t, "/-/users/bots", cookie, form)
195 if resp.StatusCode != 303 || resp.Header.Get("Location") != "/-/users/ci-runner" {
8c72543b
Keenan Nemetz
5d ago
196 t.Fatalf("create bot = %d %q:\n%s", resp.StatusCode, resp.Header.Get("Location"), body)
197 }
198 acct, err := f.srv.Identity.Find("ci-runner")
199 if err != nil {
200 t.Fatal(err)
201 }
202 if !acct.Bot() || acct.Role != access.Contribute || len(acct.Keys) != 1 || acct.Keys[0].Line != botKey {
203 t.Fatalf("created bot = %+v", acct)
204 }
ed03b3d1
Keenan Nemetz
6h ago
205 if _, list := f.browserGet(t, "/-/u", cookie); !strings.Contains(list, `<a class="people-name" href="/-/u/ci-runner">ci-runner</a> <span class="state">bot</span>`) ||
206 !strings.Contains(list, `<a href="/-/users/ci-runner">Manage</a>`) {
ea21f923
Keenan Nemetz
4d ago
207 t.Fatalf("the users directory does not mark the bot or offer Manage:\n%s", list)
8c72543b
Keenan Nemetz
5d ago
208 }
209
210 // The page that manages the bot says what it is and offers only actions a bot
211 // can use: no enrollment link, and no instance-admin role.
ed03b3d1
Keenan Nemetz
6h ago
212 resp, body = f.browserGet(t, "/-/users/ci-runner", cookie)
8c72543b
Keenan Nemetz
5d ago
213 if resp.StatusCode != 200 || !strings.Contains(body, "ssh-ed25519") {
214 t.Fatalf("bot page = %d:\n%s", resp.StatusCode, body)
215 }
216 if !strings.Contains(body, `<span class="state">bot</span>`) {
217 t.Fatalf("bot page does not mark the account as a bot:\n%s", body)
218 }
219 if strings.Contains(body, "New enrollment link") {
220 t.Fatalf("bot page offers an enrollment link:\n%s", body)
221 }
222 if strings.Contains(body, `value="admin"`) {
223 t.Fatalf("bot role picker offers instance admin:\n%s", body)
224 }
225
226 // A bot without a key has no way in, so the form refuses instead of creating
227 // an account nobody can act as.
228 form.Set("name", "keyless-bot")
229 form.Del("key")
ed03b3d1
Keenan Nemetz
6h ago
230 resp, body = f.browserPost(t, "/-/users/bots", cookie, form)
8c72543b
Keenan Nemetz
5d ago
231 if resp.StatusCode != 400 || !strings.Contains(body, "Paste a public key for the bot") {
232 t.Fatalf("keyless bot = %d:\n%s", resp.StatusCode, body)
233 }
234 if _, err := f.srv.Identity.Find("keyless-bot"); err == nil {
235 t.Fatal("the keyless bot was created anyway")
236 }
237
238 // The hidden admin option is not what refuses this; the identity rule is.
239 adminKey, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_admin_bot"))
240 form.Set("name", "admin-bot")
241 form.Set("role", "admin")
242 form.Set("key", adminKey)
ed03b3d1
Keenan Nemetz
6h ago
243 resp, body = f.browserPost(t, "/-/users/bots", cookie, form)
8c72543b
Keenan Nemetz
5d ago
244 if resp.StatusCode != 400 || !strings.Contains(body, "cannot hold that role") {
245 t.Fatalf("admin bot = %d:\n%s", resp.StatusCode, body)
246 }
247 if _, err := f.srv.Identity.Find("admin-bot"); err == nil {
248 t.Fatal("the admin bot was created anyway")
249 }
250
251 // Instance-admin endpoints hide themselves from everybody else.
252 f.addWriter(t)
253 writerSess, writerCookie := f.browserSession(t, f.writerID)
254 form.Set("name", "writer-bot")
255 form.Set("role", "none")
ed03b3d1
Keenan Nemetz
6h ago
256 resp, _ = f.browserPost(t, "/-/users/bots", writerCookie, url.Values{
8c72543b
Keenan Nemetz
5d ago
257 "csrf": {writerSess.CSRF}, "name": {"writer-bot"}, "role": {"none"}, "key": {adminKey},
258 })
259 if resp.StatusCode != 404 {
260 t.Fatalf("non-admin create bot = %d, want 404", resp.StatusCode)
261 }
262 }
263
395554f4
Keenan Nemetz
15d ago
264 func TestAdminUsersRequiresInstanceAdmin(t *testing.T) {
265 t.Parallel()
266 f := browserFixture(t)
ed03b3d1
Keenan Nemetz
6h ago
267 if resp, _ := f.browserGet(t, "/-/users", ""); resp.StatusCode != 303 {
395554f4
Keenan Nemetz
15d ago
268 t.Fatalf("anonymous users page = %d, want login redirect", resp.StatusCode)
269 }
270 f.addWriter(t)
271 _, writerCookie := f.browserSession(t, f.writerID)
ed03b3d1
Keenan Nemetz
6h ago
272 if resp, _ := f.browserGet(t, "/-/users", writerCookie); resp.StatusCode != 404 {
395554f4
Keenan Nemetz
15d ago
273 t.Fatalf("non-admin users page = %d, want 404", resp.StatusCode)
274 }
275 }
ea21f923
Keenan Nemetz
4d ago
276
277 // Managing an account is an admin action, so the directory only grows the Manage
278 // column for an instance admin. Everyone else sees the same public roster.
279 func TestUsersDirectoryHidesManageFromNonAdmins(t *testing.T) {
280 t.Parallel()
281 f := browserFixture(t)
282 _, cookie := f.browserSession(t, f.ownerID)
283
ed03b3d1
Keenan Nemetz
6h ago
284 resp, body := f.browserGet(t, "/-/u", cookie)
ea21f923
Keenan Nemetz
4d ago
285 if resp.StatusCode != 200 {
286 t.Fatalf("GET /u = %d:\n%s", resp.StatusCode, body)
287 }
ed03b3d1
Keenan Nemetz
6h ago
288 for _, gone := range []string{"<th>Manage</th>", `href="/-/users/keenan">Manage</a>`} {
ea21f923
Keenan Nemetz
4d ago
289 if strings.Contains(body, gone) {
290 t.Fatalf("a non-admin sees %q in the users directory:\n%s", gone, body)
291 }
292 }
293
294 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
295 t.Fatal(err)
296 }
ed03b3d1
Keenan Nemetz
6h ago
297 if _, body := f.browserGet(t, "/-/u", cookie); !strings.Contains(body, "<th>Manage</th>") ||
298 !strings.Contains(body, `href="/-/users/keenan">Manage</a>`) {
ea21f923
Keenan Nemetz
4d ago
299 t.Fatalf("an instance admin has no Manage action in the users directory:\n%s", body)
300 }
301 }