detached 973e682

cli_signing_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"errors"
7	"os"
8	"path/filepath"
9	"strings"
10	"testing"
11
12	"connectrpc.com/connect"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15
16	"bnema.dev/ermite-api/gen/ermite"
17)
18
19// withCommitSignatures is a GetVersion that lists commit_signatures.
20func withCommitSignatures(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
21	return &ermite.GetVersionResponse{Version: "v0.7.0", Features: []string{"commit_signatures"}}, nil
22}
23
24const testSSHKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE1234"
25
26func lastRequest[T any](t *testing.T, f *fakeAPI, call string) *T {
27	t.Helper()
28	calls, reqs := f.snapshot()
29	for i := len(calls) - 1; i >= 0; i-- {
30		if calls[i] == call {
31			return reqs[i].(*T)
32		}
33	}
34	t.Fatalf("no %s call", call)
35	return nil
36}
37
38func TestSigningKeyTrustList(t *testing.T) {
39	keys := []*ermite.TrustedKey{
40		{Id: "SHA256:builtin", Format: ermite.SignatureFormat_SIGNATURE_FORMAT_SSH, Title: "ermite", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"ermite@example.com"}, Builtin: true},
41		{Id: "ABCDEF0123", Format: ermite.SignatureFormat_SIGNATURE_FORMAT_GPG, Title: "work", PublicKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----", Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: ts("2026-10-10T12:00:00Z")},
42	}
43	f := &fakeAPI{getVersion: withCommitSignatures, listTrustedKeys: func(*ermite.ListTrustedKeysRequest) (*ermite.ListTrustedKeysResponse, error) {
44		return &ermite.ListTrustedKeysResponse{Keys: keys}, nil
45	}}
46	url := f.serve(t)
47
48	res := runCLI(t, envFor(url), "", "signing-key", "trust", "list").ok(t)
49	lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
50	require.Len(t, lines, 3, res.stdout)
51	assert.Regexp(t, `^ID\s+FORMAT\s+TITLE\s+EMAILS\s+BUILTIN$`, lines[0])
52	assert.Regexp(t, `^SHA256:builtin\s+ssh\s+ermite\s+ermite@example.com\s+yes$`, lines[1])
53	assert.Regexp(t, `^ABCDEF0123\s+gpg\s+work\s+a@b.c,d@e.f\s+-$`, lines[2])
54
55	res = runCLI(t, envFor(url), "", "signing-key", "trust", "list", "--json").ok(t)
56	assert.JSONEq(t, `[
57		{"id":"SHA256:builtin","format":"ssh","title":"ermite","public_key":"ssh-ed25519 AAAA","emails":["ermite@example.com"],"builtin":true,"created_at":null},
58		{"id":"ABCDEF0123","format":"gpg","title":"work","public_key":"-----BEGIN PGP PUBLIC KEY BLOCK-----","emails":["a@b.c","d@e.f"],"builtin":false,"created_at":"2026-10-10T12:00:00Z"}
59	]`, res.stdout)
60}
61
62func TestSigningKeyTrustListEmpty(t *testing.T) {
63	f := &fakeAPI{getVersion: withCommitSignatures, listTrustedKeys: func(*ermite.ListTrustedKeysRequest) (*ermite.ListTrustedKeysResponse, error) {
64		return &ermite.ListTrustedKeysResponse{}, nil
65	}}
66	url := f.serve(t)
67	res := runCLI(t, envFor(url), "", "signing-key", "trust", "list").ok(t)
68	assert.Empty(t, res.stdout)
69	assert.Contains(t, res.stderr, "no trusted keys")
70	res = runCLI(t, envFor(url), "", "signing-key", "trust", "list", "--json").ok(t)
71	assert.JSONEq(t, `[]`, res.stdout)
72}
73
74func addEcho(r *ermite.AddTrustedKeyRequest) (*ermite.AddTrustedKeyResponse, error) {
75	return &ermite.AddTrustedKeyResponse{Key: &ermite.TrustedKey{
76		Id: "SHA256:new", Format: r.GetFormat(), Title: r.GetTitle(), PublicKey: r.GetPublicKey(), Emails: r.GetEmails(), CreatedAt: ts("2026-10-10T12:00:00Z"),
77	}}, nil
78}
79
80func TestSigningKeyTrustAdd(t *testing.T) {
81	t.Run("from a file", func(t *testing.T) {
82		f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: addEcho}
83		file := filepath.Join(t.TempDir(), "id_ed25519.pub")
84		require.NoError(t, os.WriteFile(file, []byte(testSSHKey+" me@laptop\n"), 0o600))
85		res := runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "add", "--format", "ssh", "--title", "laptop",
86			"--email", "me@example.com", "--email", "me@work.example", file).ok(t)
87		got := lastRequest[ermite.AddTrustedKeyRequest](t, f, "AddTrustedKey")
88		assert.Equal(t, ermite.SignatureFormat_SIGNATURE_FORMAT_SSH, got.GetFormat())
89		assert.Equal(t, "laptop", got.GetTitle())
90		assert.Equal(t, testSSHKey+" me@laptop", got.GetPublicKey())
91		assert.Equal(t, []string{"me@example.com", "me@work.example"}, got.GetEmails())
92		assert.Equal(t, "SHA256:new\n", res.stdout)
93		assert.Contains(t, res.stderr, `trusted ssh key "laptop"`)
94	})
95
96	t.Run("gpg from stdin, json", func(t *testing.T) {
97		f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: addEcho}
98		armor := "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxxxx\n-----END PGP PUBLIC KEY BLOCK-----\n"
99		res := runCLI(t, envFor(f.serve(t)), armor, "signing-key", "trust", "add", "--format", "GPG", "--title", "work",
100			"--email", "me@example.com", "--json", "-").ok(t)
101		got := lastRequest[ermite.AddTrustedKeyRequest](t, f, "AddTrustedKey")
102		assert.Equal(t, ermite.SignatureFormat_SIGNATURE_FORMAT_GPG, got.GetFormat())
103		assert.Equal(t, strings.TrimSpace(armor), got.GetPublicKey())
104		assert.JSONEq(t, `{"id":"SHA256:new","format":"gpg","title":"work","public_key":"`+strings.ReplaceAll(strings.TrimSpace(armor), "\n", `\n`)+`","emails":["me@example.com"],"builtin":false,"created_at":"2026-10-10T12:00:00Z"}`, res.stdout)
105	})
106
107	t.Run("flag and input errors send nothing", func(t *testing.T) {
108		big := strings.Repeat("a", 64<<10+1)
109		for name, tt := range map[string]struct {
110			stdin string
111			args  []string
112			want  string
113		}{
114			"missing email":  {testSSHKey, []string{"--format", "ssh", "--title", "t", "-"}, `"email"`},
115			"missing title":  {testSSHKey, []string{"--format", "ssh", "--email", "a@b.c", "-"}, `"title"`},
116			"missing format": {testSSHKey, []string{"--title", "t", "--email", "a@b.c", "-"}, `"format"`},
117			"x509":           {testSSHKey, []string{"--format", "x509", "--title", "t", "--email", "a@b.c", "-"}, "use ssh or gpg"},
118			"empty input":    {"  \n", []string{"--format", "ssh", "--title", "t", "--email", "a@b.c", "-"}, "no key to read"},
119			"too large":      {big, []string{"--format", "ssh", "--title", "t", "--email", "a@b.c", "-"}, "larger than 64 KiB"},
120			"no such file":   {"", []string{"--format", "ssh", "--title", "t", "--email", "a@b.c", "/nonexistent/key.pub"}, "read /nonexistent/key.pub"},
121		} {
122			f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: addEcho}
123			res := runCLI(t, envFor(f.serve(t)), tt.stdin, append([]string{"signing-key", "trust", "add"}, tt.args...)...)
124			require.Error(t, res.err, name)
125			assert.Contains(t, res.err.Error(), tt.want, name)
126			calls, _ := f.snapshot()
127			assert.NotContains(t, calls, "AddTrustedKey", name)
128		}
129	})
130
131	t.Run("server errors are described", func(t *testing.T) {
132		for name, tt := range map[string]struct {
133			err  error
134			want string
135		}{
136			"not the owner": {connect.NewError(connect.CodePermissionDenied, errors.New("owner only")), "permission denied"},
137			"duplicate":     {connect.NewError(connect.CodeAlreadyExists, errors.New("key is already trusted")), "already exists"},
138			"invalid":       {connect.NewError(connect.CodeInvalidArgument, errors.New("not a public key")), "not a public key"},
139		} {
140			f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: func(*ermite.AddTrustedKeyRequest) (*ermite.AddTrustedKeyResponse, error) { return nil, tt.err }}
141			res := runCLI(t, envFor(f.serve(t)), testSSHKey, "signing-key", "trust", "add", "--format", "ssh", "--title", "t", "--email", "a@b.c", "-")
142			require.Error(t, res.err, name)
143			assert.Contains(t, res.err.Error(), tt.want, name)
144			assert.Empty(t, res.stdout, name)
145		}
146	})
147}
148
149func TestSigningKeyTrustRemove(t *testing.T) {
150	f := &fakeAPI{getVersion: withCommitSignatures, removeTrustedKey: func(*ermite.RemoveTrustedKeyRequest) (*ermite.RemoveTrustedKeyResponse, error) {
151		return &ermite.RemoveTrustedKeyResponse{}, nil
152	}}
153	res := runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "remove", "SHA256:abc").ok(t)
154	assert.Equal(t, "SHA256:abc", lastRequest[ermite.RemoveTrustedKeyRequest](t, f, "RemoveTrustedKey").GetId())
155	assert.Empty(t, res.stdout)
156	assert.Contains(t, res.stderr, "removed trusted key SHA256:abc")
157
158	f = &fakeAPI{getVersion: withCommitSignatures, removeTrustedKey: func(*ermite.RemoveTrustedKeyRequest) (*ermite.RemoveTrustedKeyResponse, error) {
159		return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the builtin key cannot be removed"))
160	}}
161	res = runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "remove", "SHA256:builtin")
162	require.Error(t, res.err)
163	assert.Contains(t, res.err.Error(), "builtin key cannot be removed")
164
165	require.Error(t, runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "remove").err, "the id is required")
166}
167
168func TestSigningKeyTrustNeedsServerFeature(t *testing.T) {
169	for _, tt := range []struct {
170		name    string
171		version func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
172		want    string
173	}{
174		{"feature missing", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
175			return &ermite.GetVersionResponse{Version: "v0.6.0", Features: []string{"signing_key"}}, nil
176		}, "does not support commit_signatures"},
177		{"version unreadable", nil, "does not implement this call"},
178		{"server unavailable", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
179			return nil, connect.NewError(connect.CodeUnavailable, errors.New("connection refused"))
180		}, "cannot reach the server"},
181	} {
182		for _, args := range [][]string{
183			{"list"},
184			{"add", "--format", "ssh", "--title", "t", "--email", "a@b.c", "-"},
185			{"remove", "SHA256:abc"},
186		} {
187			f := &fakeAPI{getVersion: tt.version}
188			res := runCLI(t, envFor(f.serve(t)), testSSHKey, append([]string{"signing-key", "trust"}, args...)...)
189			require.Error(t, res.err, tt.name)
190			assert.Contains(t, strings.ToLower(res.err.Error()), tt.want, tt.name)
191			calls, _ := f.snapshot()
192			assert.Equal(t, []string{"GetVersion"}, calls, "%s %v: no key call", tt.name, args)
193			assert.Empty(t, res.stdout)
194		}
195	}
196}
197
198func TestSigningKeyHelpMentionsTrust(t *testing.T) {
199	res := runCLI(t, nil, "", "signing-key", "--help").ok(t)
200	assert.Contains(t, res.stdout, "trust")
201	assert.Contains(t, res.stdout, "commit_signatures")
202	res = runCLI(t, nil, "", "signing-key", "trust", "list", "--help").ok(t)
203	assert.Contains(t, res.stdout, "JSON fields")
204}

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