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}