1package cli
2
3import (
4 "bytes"
5 "context"
6 "crypto/ecdsa"
7 "crypto/elliptic"
8 "crypto/rand"
9 "crypto/x509"
10 "crypto/x509/pkix"
11 "encoding/pem"
12 "errors"
13 "math/big"
14 "os"
15 "path/filepath"
16 "testing"
17 "time"
18
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require"
21
22 "github.com/bnema/gordon/internal/adapters/dto"
23 "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
24 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
25)
26
27func testCertPEM(t *testing.T, isCA bool) (pemStr, fingerprint string) {
28 t.Helper()
29 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
30 require.NoError(t, err)
31 tmpl := &x509.Certificate{
32 SerialNumber: big.NewInt(1),
33 Subject: pkix.Name{CommonName: "Remote Root"},
34 NotBefore: time.Now().Add(-time.Hour),
35 NotAfter: time.Now().Add(time.Hour),
36 IsCA: isCA,
37 BasicConstraintsValid: true,
38 }
39 der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
40 require.NoError(t, err)
41 cert, err := x509.ParseCertificate(der)
42 require.NoError(t, err)
43 return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), pkiadapter.CertFingerprint(cert)
44}
45
46func testRootPEM(t *testing.T) string {
47 t.Helper()
48 p, _ := testCertPEM(t, true)
49 return p
50}
51
52// stubCARemote swaps the package-level caResolveRemote. Tests using it must
53// not run in parallel.
54func stubCARemote(t *testing.T, cp ControlPlane) {
55 t.Helper()
56 prev := caResolveRemote
57 caResolveRemote = func() (ControlPlane, error) { return cp, nil }
58 t.Cleanup(func() { caResolveRemote = prev })
59}
60
61func TestCAExportRemoteUsesControlPlane(t *testing.T) {
62 rootPEM := testRootPEM(t)
63 cp := mocks.NewMockControlPlane(t)
64 cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{RootPEM: rootPEM, RootCN: "Remote Root"}, nil).Once()
65 stubCARemote(t, cp)
66
67 cmd := newCAExportCmd()
68 var buf, errBuf bytes.Buffer
69 cmd.SetOut(&buf)
70 cmd.SetErr(&errBuf)
71 cmd.SetContext(context.Background())
72 require.NoError(t, cmd.RunE(cmd, nil))
73
74 assert.Equal(t, rootPEM, buf.String(), "stdout carries only the PEM")
75 assert.Contains(t, errBuf.String(), "fingerprint", "stderr asks to verify the fingerprint out of band")
76 assert.Contains(t, errBuf.String(), "gordon ca info")
77}
78
79func TestCAExportRemoteWritesFile(t *testing.T) {
80 rootPEM := testRootPEM(t)
81 cp := mocks.NewMockControlPlane(t)
82 cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{RootPEM: rootPEM}, nil).Once()
83 stubCARemote(t, cp)
84
85 outPath := filepath.Join(t.TempDir(), "sub", "ca.pem")
86 cmd := newCAExportCmd()
87 require.NoError(t, cmd.Flags().Set("out", outPath))
88 var buf bytes.Buffer
89 cmd.SetOut(&buf)
90 cmd.SetContext(context.Background())
91 require.NoError(t, cmd.RunE(cmd, nil))
92
93 data, err := os.ReadFile(outPath)
94 require.NoError(t, err)
95 assert.Equal(t, rootPEM, string(data))
96}
97
98func TestCAInfoRemoteUsesControlPlane(t *testing.T) {
99 expiry := time.Now().Add(48 * time.Hour).UTC()
100 rootPEM, fp := testCertPEM(t, true)
101 cp := mocks.NewMockControlPlane(t)
102 cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{
103 RootPEM: rootPEM, RootCN: "ignored", Fingerprint: fp, IntermediateExpiry: expiry,
104 }, nil).Once()
105 stubCARemote(t, cp)
106
107 cmd := newCAInfoCmd()
108 require.NoError(t, cmd.Flags().Set("json", "true"))
109 var buf bytes.Buffer
110 cmd.SetOut(&buf)
111 cmd.SetContext(context.Background())
112 require.NoError(t, cmd.RunE(cmd, nil))
113
114 assert.Contains(t, buf.String(), `"root_cn": "Remote Root"`)
115 assert.Contains(t, buf.String(), `"fingerprint": "SHA256:`+fp+`"`)
116}
117
118func TestCAInfoRemoteText(t *testing.T) {
119 rootPEM, fp := testCertPEM(t, true)
120 cp := mocks.NewMockControlPlane(t)
121 cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{
122 RootPEM: rootPEM, Fingerprint: fp, IntermediateExpiry: time.Now().Add(time.Hour),
123 }, nil).Once()
124 stubCARemote(t, cp)
125
126 cmd := newCAInfoCmd()
127 var buf bytes.Buffer
128 cmd.SetOut(&buf)
129 cmd.SetContext(context.Background())
130 require.NoError(t, cmd.RunE(cmd, nil))
131
132 assert.Contains(t, buf.String(), "Remote Root")
133 assert.Contains(t, buf.String(), "SHA256:"+fp)
134}
135
136func TestCARemoteErrorIsWrapped(t *testing.T) {
137 cp := mocks.NewMockControlPlane(t)
138 cp.EXPECT().GetCA(context.Background()).Return(nil, errors.New("internal TLS is disabled on this server")).Once()
139 stubCARemote(t, cp)
140
141 cmd := newCAInfoCmd()
142 cmd.SetContext(context.Background())
143 err := cmd.RunE(cmd, nil)
144 require.Error(t, err)
145 assert.Contains(t, err.Error(), "failed to get CA from remote")
146 assert.Contains(t, err.Error(), "internal TLS is disabled on this server")
147}
148
149func TestCAInstallRejectsRemote(t *testing.T) {
150 // No GetCA expectation: the mock fails the test if the remote is contacted.
151 stubCARemote(t, mocks.NewMockControlPlane(t))
152
153 for _, uninstall := range []bool{false, true} {
154 cmd := newCAInstallCmd()
155 if uninstall {
156 require.NoError(t, cmd.Flags().Set("uninstall", "true"))
157 }
158 cmd.SetContext(context.Background())
159 err := cmd.RunE(cmd, nil)
160 require.Error(t, err)
161 assert.Contains(t, err.Error(), "ca install only works on the Gordon host")
162 assert.Contains(t, err.Error(), "gordon ca export --remote <name> --out gordon-ca.pem")
163 }
164}
165
166func TestCARemoteRejectsInvalidRoot(t *testing.T) {
167 nonCAPEM, nonCAFP := testCertPEM(t, false)
168 goodPEM, _ := testCertPEM(t, true)
169 twoPEM := goodPEM + goodPEM
170
171 tests := []struct {
172 name string
173 resp dto.CAResponse
174 wantErr string
175 }{
176 {name: "invalid pem", resp: dto.CAResponse{RootPEM: "not a pem"}, wantErr: "no CERTIFICATE PEM block"},
177 {name: "garbage der", resp: dto.CAResponse{RootPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("junk")}))}, wantErr: "parse certificate"},
178 {name: "wrong block type", resp: dto.CAResponse{RootPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte("x")}))}, wantErr: "no CERTIFICATE PEM block"},
179 {name: "multiple certs", resp: dto.CAResponse{RootPEM: twoPEM}, wantErr: "exactly one certificate"},
180 {name: "not a CA", resp: dto.CAResponse{RootPEM: nonCAPEM, Fingerprint: nonCAFP}, wantErr: "not a CA"},
181 {name: "fingerprint mismatch", resp: dto.CAResponse{RootPEM: goodPEM, Fingerprint: "AA:BB"}, wantErr: "does not match"},
182 }
183 for _, tt := range tests {
184 t.Run(tt.name, func(t *testing.T) {
185 resp := tt.resp
186 cp := mocks.NewMockControlPlane(t)
187 cp.EXPECT().GetCA(context.Background()).Return(&resp, nil).Once()
188
189 _, err := fetchRemoteCADetails(context.Background(), cp)
190 require.Error(t, err)
191 assert.Contains(t, err.Error(), "invalid root CA")
192 assert.Contains(t, err.Error(), tt.wantErr)
193 })
194 }
195}
196
197func TestCARemoteRejectsNotSelfSigned(t *testing.T) {
198 caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
199 require.NoError(t, err)
200 caTmpl := &x509.Certificate{
201 SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Issuer"},
202 NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
203 IsCA: true, BasicConstraintsValid: true,
204 }
205 otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
206 require.NoError(t, err)
207 // Subject claims CA, but it is signed by a different key than its own.
208 subTmpl := &x509.Certificate{
209 SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "Sub"},
210 NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
211 IsCA: true, BasicConstraintsValid: true,
212 }
213 der, err := x509.CreateCertificate(rand.Reader, subTmpl, caTmpl, &otherKey.PublicKey, caKey)
214 require.NoError(t, err)
215
216 cp := mocks.NewMockControlPlane(t)
217 cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{
218 RootPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
219 }, nil).Once()
220
221 _, err = fetchRemoteCADetails(context.Background(), cp)
222 require.Error(t, err)
223 assert.Contains(t, err.Error(), "not validly self-signed")
224}
225
226func TestCARemoteComputesFingerprintAndCNLocally(t *testing.T) {
227 rootPEM, fp := testCertPEM(t, true)
228 cp := mocks.NewMockControlPlane(t)
229 // Server omits fingerprint and CN; both are derived from the certificate.
230 cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{RootPEM: rootPEM}, nil).Once()
231
232 details, err := fetchRemoteCADetails(context.Background(), cp)
233 require.NoError(t, err)
234 assert.Equal(t, "Remote Root", details.rootCN)
235 assert.Equal(t, fp, details.fingerprint)
236}
237
238func TestCALocalErrorMentionsConfigAndRemote(t *testing.T) {
239 stubCARemote(t, nil)
240
241 cfg := filepath.Join(t.TempDir(), "gordon.toml")
242 require.NoError(t, os.WriteFile(cfg, []byte("[server]\nport = 8088\n"), 0600))
243 // cliConfigPath is a package var: this test must not run in parallel.
244 prev := cliConfigPath
245 cliConfigPath = cfg
246 t.Cleanup(func() { cliConfigPath = prev })
247
248 cmd := newCAExportCmd()
249 cmd.SetContext(context.Background())
250 err := cmd.RunE(cmd, nil)
251 require.Error(t, err)
252 assert.Contains(t, err.Error(), "internal TLS is disabled")
253 assert.Contains(t, err.Error(), cfg)
254 assert.Contains(t, err.Error(), "--remote")
255}
256
257func TestErrLocalInternalTLSDisabledWithoutConfigFile(t *testing.T) {
258 err := errLocalInternalTLSDisabled("", true)
259 assert.Contains(t, err.Error(), "no local server config file was found")
260 assert.Contains(t, err.Error(), "--remote")
261
262 // install is local-only, so it must not suggest --remote.
263 assert.NotContains(t, errLocalInternalTLSDisabled("", false).Error(), "--remote")
264}