e2e/accountweb_test.go
121 lines · 4007 bytes
1package e2e
2
3import (
4 "encoding/json"
5 "net/url"
6 "os"
7 "strings"
8 "testing"
9)
10
11// TestAccountSettingsWeb covers managing your own keys and addresses from a
12// browser session. Public keys are the only credential-shaped input the web
13// accepts; secrets and token minting stay on SSH.
14func TestAccountSettingsWeb(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19
20 out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
21 if code != 0 {
22 t.Fatal("web login failed")
23 }
24 var env struct {
25 Data struct {
26 URL string `json:"url"`
27 } `json:"data"`
28 }
29 json.Unmarshal([]byte(out), &env)
30 browser := newBrowser(t)
31 browserGet(t, browser, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):])
32
33 status, body := browserGet(t, browser, inst.base()+"/settings")
34 if status != 200 {
35 t.Fatalf("account settings: %d", status)
36 }
37 // The key that signed us in is listed, and its address shows verified.
38 if !strings.Contains(body, "SHA256:") {
39 t.Error("no SSH key fingerprint listed")
40 }
41 // Keys are stored in wire format, which holds no comment; anything
42 // pulled out of it and printed would be binary noise.
43 if strings.Contains(body, "\ufffd") {
44 t.Error("key row is rendering raw blob bytes")
45 }
46 if !strings.Contains(body, "alice@example.test") || !strings.Contains(body, "verified") {
47 t.Error("verified address not shown")
48 }
49
50 // Add a second key through the form, then confirm it over SSH — the
51 // web write must land in the same place the CLI reads.
52 second := inst.newKey(t, "alice2")
53 raw, err := os.ReadFile(second + ".pub")
54 if err != nil {
55 t.Fatal(err)
56 }
57 pub := string(raw)
58 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
59 "field": {"key-add"}, "key": {pub}, "scope": {"git"},
60 }); status != 303 && status != 200 {
61 t.Fatalf("key add: %d", status)
62 }
63 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
64 if strings.Count(out, "SHA256:") != 2 || !strings.Contains(out, `"scope":"git"`) {
65 t.Fatalf("key not registered with its scope: %s", out)
66 }
67
68 // A git-scoped key can move git data but cannot run commands, so the
69 // scope the form set is really enforced.
70 if _, _, code := inst.ssh(t, second, "", "whoami"); code == 0 {
71 t.Error("git-scoped key ran a control command")
72 }
73
74 // Removing it through the form removes it for SSH too.
75 fp := gitScopedFingerprint(t, out)
76 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
77 "field": {"key-remove"}, "fingerprint": {fp},
78 }); status != 303 && status != 200 {
79 t.Fatalf("key remove: %d", status)
80 }
81 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
82 if strings.Count(out, "SHA256:") != 1 {
83 t.Fatalf("key not removed: %s", out)
84 }
85
86 // Garbage is refused by the same validation the CLI uses, and says so.
87 // The redirect carries the message, so the followed page shows it.
88 _, body = browserPost(t, browser, inst.base()+"/settings", url.Values{
89 "field": {"key-add"}, "key": {"not a key"},
90 })
91 if !strings.Contains(body, `class="error"`) {
92 t.Error("invalid key accepted without an error")
93 }
94
95 // Token minting is SSHOnly and has no web form to reach it.
96 if strings.Contains(body, `value="token-mint"`) {
97 t.Error("token minting exposed on the web")
98 }
99}
100
101// gitScopedFingerprint pulls the fingerprint of the git-scoped key out of
102// "auth keys list --json".
103func gitScopedFingerprint(t *testing.T, blob string) string {
104 t.Helper()
105 var env struct {
106 Data []struct {
107 Fingerprint string `json:"fingerprint"`
108 Scope string `json:"scope"`
109 } `json:"data"`
110 }
111 if err := json.Unmarshal([]byte(blob), &env); err != nil {
112 t.Fatalf("keys list JSON: %v\n%s", err, blob)
113 }
114 for _, k := range env.Data {
115 if k.Scope == "git" {
116 return k.Fingerprint
117 }
118 }
119 t.Fatalf("no git-scoped key in %s", blob)
120 return ""
121}