e2e/accountweb_test.go
162 lines · 5519 bytes
1package e2e
2
3import (
4 "encoding/json"
5 "io"
6 "net/url"
7 "os"
8 "strings"
9 "testing"
10)
11
12// TestAccountSettingsWeb covers managing your own keys and addresses from a
13// browser session. Public keys are the only credential-shaped input the web
14// accepts; secrets and token minting stay on SSH.
15func TestAccountSettingsWeb(t *testing.T) {
16 t.Parallel()
17 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
18 aliceKey := inst.newKey(t, "alice")
19 inst.admin(t, "admin", "user", "create", "alice",
20 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
21
22 out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
23 if code != 0 {
24 t.Fatal("web login failed")
25 }
26 var env struct {
27 Data struct {
28 URL string `json:"url"`
29 } `json:"data"`
30 }
31 json.Unmarshal([]byte(out), &env)
32 browser := newBrowser(t)
33 browserGet(t, browser, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):])
34
35 status, body := browserGet(t, browser, inst.base()+"/settings")
36 if status != 200 {
37 t.Fatalf("account settings: %d", status)
38 }
39 // The key that signed us in is listed, and its address shows verified.
40 if !strings.Contains(body, "SHA256:") {
41 t.Error("no SSH key fingerprint listed")
42 }
43 // Keys are stored in wire format, which holds no comment; anything
44 // pulled out of it and printed would be binary noise.
45 if strings.Contains(body, "\ufffd") {
46 t.Error("key row is rendering raw blob bytes")
47 }
48 if !strings.Contains(body, "alice@example.test") || !strings.Contains(body, "verified") {
49 t.Error("verified address not shown")
50 }
51
52 // Add a second key through the form, then confirm it over SSH — the
53 // web write must land in the same place the CLI reads.
54 second := inst.newKey(t, "alice2")
55 raw, err := os.ReadFile(second + ".pub")
56 if err != nil {
57 t.Fatal(err)
58 }
59 pub := string(raw)
60 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
61 "field": {"key-add"}, "key": {pub}, "scope": {"git"},
62 }); status != 303 && status != 200 {
63 t.Fatalf("key add: %d", status)
64 }
65 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
66 if strings.Count(out, "SHA256:") != 2 || !strings.Contains(out, `"scope":"git"`) {
67 t.Fatalf("key not registered with its scope: %s", out)
68 }
69
70 // A git-scoped key can move git data but cannot run commands, so the
71 // scope the form set is really enforced.
72 if _, _, code := inst.ssh(t, second, "", "whoami"); code == 0 {
73 t.Error("git-scoped key ran a control command")
74 }
75
76 // Removing it through the form needs the fingerprint's prefix typed
77 // to confirm; a bare post leaves the key in place.
78 fp := gitScopedFingerprint(t, out)
79 prefix := strings.TrimPrefix(fp, "SHA256:")[:8]
80 _, body = browserPost(t, browser, inst.base()+"/settings", url.Values{
81 "field": {"key-remove"}, "fingerprint": {fp},
82 })
83 if !strings.Contains(body, "to confirm") {
84 t.Fatalf("unconfirmed key remove was not refused:\n%s", body)
85 }
86 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
87 if !strings.Contains(out, fp) {
88 t.Fatalf("key removed without confirmation: %s", out)
89 }
90 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
91 "field": {"key-remove"}, "fingerprint": {fp}, "confirm": {prefix},
92 }); status != 303 && status != 200 {
93 t.Fatalf("key remove: %d", status)
94 }
95 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
96 if strings.Count(out, "SHA256:") != 1 {
97 t.Fatalf("key not removed: %s", out)
98 }
99
100 // Garbage is refused by the same validation the CLI uses, and says so.
101 // The redirect carries the message, so the followed page shows it.
102 _, body = browserPost(t, browser, inst.base()+"/settings", url.Values{
103 "field": {"key-add"}, "key": {"not a key"},
104 })
105 if !strings.Contains(body, `class="error"`) {
106 t.Error("invalid key accepted without an error")
107 }
108
109 // The settings page has no token form. Nothing refuses one now
110 // (#234); there is simply no page for it yet, and a minted token is
111 // shown once, which wants a page designed for it.
112 if strings.Contains(body, `value="token-mint"`) {
113 t.Error("token minting exposed on the web")
114 }
115
116 // The account bundle downloads as an attachment, carrying what
117 // "account export" writes (#166).
118 resp, err := browser.Get(inst.base() + "/settings/export")
119 if err != nil {
120 t.Fatal(err)
121 }
122 defer resp.Body.Close()
123 bundle, _ := io.ReadAll(resp.Body)
124 if resp.StatusCode != 200 {
125 t.Fatalf("export: %d", resp.StatusCode)
126 }
127 if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="alice.bundle"`) {
128 t.Errorf("export is not an attachment: %q", resp.Header.Get("Content-Disposition"))
129 }
130 var got struct {
131 Bundle string `json:"bundle"`
132 Username string `json:"username"`
133 }
134 if err := json.Unmarshal(bundle, &got); err != nil {
135 t.Fatalf("bundle is not JSON: %v\n%s", err, bundle)
136 }
137 if got.Username != "alice" || !strings.HasPrefix(got.Bundle, "gitbay-account/") {
138 t.Errorf("wrong bundle: %s", bundle)
139 }
140}
141
142// gitScopedFingerprint pulls the fingerprint of the git-scoped key out of
143// "auth keys list --json".
144func gitScopedFingerprint(t *testing.T, blob string) string {
145 t.Helper()
146 var env struct {
147 Data []struct {
148 Fingerprint string `json:"fingerprint"`
149 Scope string `json:"scope"`
150 } `json:"data"`
151 }
152 if err := json.Unmarshal([]byte(blob), &env); err != nil {
153 t.Fatalf("keys list JSON: %v\n%s", err, blob)
154 }
155 for _, k := range env.Data {
156 if k.Scope == "git" {
157 return k.Fingerprint
158 }
159 }
160 t.Fatalf("no git-scoped key in %s", blob)
161 return ""
162}