detached 973e682

cli_web_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"errors"
7	"testing"
8
9	"connectrpc.com/connect"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12
13	"bnema.dev/ermite-api/gen/ermite"
14)
15
16func twoPasskeys() *ermite.ListPasskeysResponse {
17	return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{
18		{Id: "AQID", Name: "Pixel 9", CreatedAt: ts("2026-10-01T08:00:00Z"), LastUsedAt: ts("2026-10-09T18:30:00Z"), Synced: true},
19		{Id: "BAUG", Name: "yubikey", CreatedAt: ts("2026-10-02T09:15:00Z")},
20	}}
21}
22
23func TestWebPasskeyList(t *testing.T) {
24	f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
25	res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t)
26	assert.Empty(t, res.stderr)
27	assert.Regexp(t, `(?m)^ID\s+NAME\s+CREATED\s+LAST USED\s+SYNCED$`, res.stdout)
28	assert.Regexp(t, `(?m)^AQID\s+Pixel 9\s+2026-10-01 08:00\s+2026-10-09 18:30\s+yes$`, res.stdout)
29	assert.Regexp(t, `(?m)^BAUG\s+yubikey\s+2026-10-02 09:15\s+never\s+no$`, res.stdout)
30}
31
32func TestWebPasskeyListEmpty(t *testing.T) {
33	f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
34		return &ermite.ListPasskeysResponse{}, nil
35	}}
36	url := f.serve(t)
37	res := runCLI(t, envFor(url), "", "web", "passkey", "list").ok(t)
38	assert.Empty(t, res.stdout)
39	assert.Contains(t, res.stderr, "no passkeys")
40
41	res = runCLI(t, envFor(url), "", "web", "passkey", "list", "--json").ok(t)
42	assert.JSONEq(t, `[]`, res.stdout)
43}
44
45func TestWebPasskeyListJSON(t *testing.T) {
46	f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
47	res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list", "--json").ok(t)
48	assert.Empty(t, res.stderr)
49	assert.JSONEq(t, `[
50		{"id":"AQID","name":"Pixel 9","created_at":"2026-10-01T08:00:00Z","last_used_at":"2026-10-09T18:30:00Z","synced":true},
51		{"id":"BAUG","name":"yubikey","created_at":"2026-10-02T09:15:00Z","last_used_at":null,"synced":false}
52	]`, res.stdout)
53}
54
55func TestWebPasskeyListCleansServerText(t *testing.T) {
56	f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
57		return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{{Id: "AQ", Name: "evil\x1b[2J name", CreatedAt: ts("2026-10-01T08:00:00Z")}}}, nil
58	}}
59	res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t)
60	assert.NotContains(t, res.stdout, "\x1b")
61}
62
63func TestWebPasskeyRemove(t *testing.T) {
64	f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) {
65		return &ermite.RemovePasskeyResponse{}, nil
66	}}
67	res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID").ok(t)
68	assert.Equal(t, "removed AQID\n", res.stdout)
69	assert.Contains(t, res.stderr, "every browser is signed out")
70
71	calls, reqs := f.snapshot()
72	assert.Equal(t, []string{"RemovePasskey"}, calls)
73	assert.Equal(t, "AQID", reqs[0].(*ermite.RemovePasskeyRequest).GetId())
74}
75
76func TestWebPasskeyRemoveErrors(t *testing.T) {
77	for name, tc := range map[string]struct {
78		code connect.Code
79		msg  string
80		want string
81	}{
82		"unknown id":    {connect.CodeNotFound, "not found", "not found"},
83		"not the owner": {connect.CodePermissionDenied, "", "permission denied"},
84		"unavailable":   {connect.CodeFailedPrecondition, "passkeys are not available on this server", "passkeys are not available on this server"},
85		"bad id":        {connect.CodeInvalidArgument, "id is not a base64url credential id", "invalid request"},
86	} {
87		t.Run(name, func(t *testing.T) {
88			f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) {
89				return nil, connect.NewError(tc.code, errors.New(tc.msg))
90			}}
91			res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID")
92			require.Error(t, res.err)
93			assert.Contains(t, res.err.Error(), tc.want)
94			assert.Empty(t, res.stdout, "nothing is reported as removed")
95			assert.NotContains(t, res.stderr, "signed out")
96		})
97	}
98}
99
100func TestWebPasskeyListUnavailable(t *testing.T) {
101	f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
102		return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server"))
103	}}
104	res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list")
105	require.Error(t, res.err)
106	assert.Contains(t, res.err.Error(), "passkeys are not available on this server")
107}
108
109func TestWebPasskeyArgs(t *testing.T) {
110	f := &fakeAPI{}
111	url := f.serve(t)
112	for _, args := range [][]string{
113		{"web", "passkey", "remove"},
114		{"web", "passkey", "remove", "a", "b"},
115		{"web", "passkey", "list", "extra"},
116	} {
117		res := runCLI(t, envFor(url), "", args...)
118		require.Error(t, res.err, args)
119	}
120	calls, _ := f.snapshot()
121	assert.Empty(t, calls, "nothing reached the server")
122}
123
124// The ids complete from the server's list.
125func TestWebPasskeyRemoveCompletesIDs(t *testing.T) {
126	f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
127	res := runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "").ok(t)
128	assert.Contains(t, res.stdout, "AQID\tPixel 9")
129	assert.Contains(t, res.stdout, "BAUG\tyubikey")
130
131	res = runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "B").ok(t)
132	assert.NotContains(t, res.stdout, "AQID")
133	assert.Contains(t, res.stdout, "BAUG\tyubikey")
134}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close