e2e/accountweb_test.go

a32f7f2001c1c0bf38ba8c3360e6bc7ca3982fee
gitbay/e2e/accountweb_test.go history · blame · raw

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