ca_test.go RawHistoryFolder

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}

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 7

Tags 106

↑↓ moveenter openesc close