package e2e import ( "encoding/json" "io" "net/url" "os" "strings" "testing" ) // TestAccountSettingsWeb covers managing your own keys and addresses from a // browser session. Public keys are the only credential-shaped input the web // accepts; secrets and token minting stay on SSH. func TestAccountSettingsWeb(t *testing.T) { inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") aliceKey := inst.newKey(t, "alice") inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json") if code != 0 { t.Fatal("web login failed") } var env struct { Data struct { URL string `json:"url"` } `json:"data"` } json.Unmarshal([]byte(out), &env) browser := newBrowser(t) browserGet(t, browser, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):]) status, body := browserGet(t, browser, inst.base()+"/settings") if status != 200 { t.Fatalf("account settings: %d", status) } // The key that signed us in is listed, and its address shows verified. if !strings.Contains(body, "SHA256:") { t.Error("no SSH key fingerprint listed") } // Keys are stored in wire format, which holds no comment; anything // pulled out of it and printed would be binary noise. if strings.Contains(body, "\ufffd") { t.Error("key row is rendering raw blob bytes") } if !strings.Contains(body, "alice@example.test") || !strings.Contains(body, "verified") { t.Error("verified address not shown") } // Add a second key through the form, then confirm it over SSH — the // web write must land in the same place the CLI reads. second := inst.newKey(t, "alice2") raw, err := os.ReadFile(second + ".pub") if err != nil { t.Fatal(err) } pub := string(raw) if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{ "field": {"key-add"}, "key": {pub}, "scope": {"git"}, }); status != 303 && status != 200 { t.Fatalf("key add: %d", status) } out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json") if strings.Count(out, "SHA256:") != 2 || !strings.Contains(out, `"scope":"git"`) { t.Fatalf("key not registered with its scope: %s", out) } // A git-scoped key can move git data but cannot run commands, so the // scope the form set is really enforced. if _, _, code := inst.ssh(t, second, "", "whoami"); code == 0 { t.Error("git-scoped key ran a control command") } // Removing it through the form needs the fingerprint's prefix typed // to confirm; a bare post leaves the key in place. fp := gitScopedFingerprint(t, out) prefix := strings.TrimPrefix(fp, "SHA256:")[:8] _, body = browserPost(t, browser, inst.base()+"/settings", url.Values{ "field": {"key-remove"}, "fingerprint": {fp}, }) if !strings.Contains(body, "to confirm") { t.Fatalf("unconfirmed key remove was not refused:\n%s", body) } out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json") if !strings.Contains(out, fp) { t.Fatalf("key removed without confirmation: %s", out) } if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{ "field": {"key-remove"}, "fingerprint": {fp}, "confirm": {prefix}, }); status != 303 && status != 200 { t.Fatalf("key remove: %d", status) } out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json") if strings.Count(out, "SHA256:") != 1 { t.Fatalf("key not removed: %s", out) } // Garbage is refused by the same validation the CLI uses, and says so. // The redirect carries the message, so the followed page shows it. _, body = browserPost(t, browser, inst.base()+"/settings", url.Values{ "field": {"key-add"}, "key": {"not a key"}, }) if !strings.Contains(body, `class="error"`) { t.Error("invalid key accepted without an error") } // The settings page has no token form. Nothing refuses one now // (#234); there is simply no page for it yet, and a minted token is // shown once, which wants a page designed for it. if strings.Contains(body, `value="token-mint"`) { t.Error("token minting exposed on the web") } // The account bundle downloads as an attachment, carrying what // "account export" writes (#166). resp, err := browser.Get(inst.base() + "/settings/export") if err != nil { t.Fatal(err) } defer resp.Body.Close() bundle, _ := io.ReadAll(resp.Body) if resp.StatusCode != 200 { t.Fatalf("export: %d", resp.StatusCode) } if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="alice.bundle"`) { t.Errorf("export is not an attachment: %q", resp.Header.Get("Content-Disposition")) } var got struct { Bundle string `json:"bundle"` Username string `json:"username"` } if err := json.Unmarshal(bundle, &got); err != nil { t.Fatalf("bundle is not JSON: %v\n%s", err, bundle) } if got.Username != "alice" || !strings.HasPrefix(got.Bundle, "gitbay-account/") { t.Errorf("wrong bundle: %s", bundle) } } // gitScopedFingerprint pulls the fingerprint of the git-scoped key out of // "auth keys list --json". func gitScopedFingerprint(t *testing.T, blob string) string { t.Helper() var env struct { Data []struct { Fingerprint string `json:"fingerprint"` Scope string `json:"scope"` } `json:"data"` } if err := json.Unmarshal([]byte(blob), &env); err != nil { t.Fatalf("keys list JSON: %v\n%s", err, blob) } for _, k := range env.Data { if k.Scope == "git" { return k.Fingerprint } } t.Fatalf("no git-scoped key in %s", blob) return "" }