Skip to content

Commit fd5d8a7

Browse files
authored
test(certtest): make TestCertRotation actually verify hot cert reload (#3169)
The old test reused a single connection post-rotation; TLS never renegotiates, so it passed even with updateCachedCertificate disabled. Rewrite uses VerifyConnection on fresh per-dial ClientConns to assert the server presents the rotated certificate serial after reload.
1 parent 25f4e68 commit fd5d8a7

1 file changed

Lines changed: 104 additions & 63 deletions

File tree

internal/services/integrationtesting/certtest/cert_test.go

Lines changed: 104 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,25 @@ import (
77
"crypto/ecdsa"
88
"crypto/elliptic"
99
"crypto/rand"
10+
"crypto/tls"
1011
"crypto/x509"
1112
"crypto/x509/pkix"
1213
"encoding/pem"
14+
"fmt"
1315
"math/big"
1416
"os"
1517
"path/filepath"
18+
"sync"
1619
"testing"
1720
"time"
1821

1922
"github.com/stretchr/testify/require"
2023
"go.uber.org/goleak"
2124
"google.golang.org/grpc"
2225
"google.golang.org/grpc/backoff"
26+
"google.golang.org/grpc/credentials"
2327

2428
v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
25-
"github.com/authzed/grpcutil"
2629

2730
"github.com/authzed/spicedb/internal/datastore/dsfortesting"
2831
"github.com/authzed/spicedb/internal/dispatch/graph"
@@ -37,19 +40,34 @@ import (
3740
"github.com/authzed/spicedb/pkg/zedtoken"
3841
)
3942

43+
// servedCertRecorder records the leaf certificate presented by the server
44+
// during each TLS handshake. It is safe for concurrent use.
45+
type servedCertRecorder struct {
46+
mu sync.Mutex
47+
last *x509.Certificate
48+
}
49+
50+
func (r *servedCertRecorder) verifyConnection(cs tls.ConnectionState) error {
51+
if len(cs.PeerCertificates) == 0 {
52+
return fmt.Errorf("server presented no certificates")
53+
}
54+
r.mu.Lock()
55+
defer r.mu.Unlock()
56+
r.last = cs.PeerCertificates[0]
57+
return nil
58+
}
59+
60+
func (r *servedCertRecorder) lastServedCert() *x509.Certificate {
61+
r.mu.Lock()
62+
defer r.mu.Unlock()
63+
return r.last
64+
}
65+
4066
func TestCertRotation(t *testing.T) {
4167
t.Cleanup(func() {
4268
goleak.VerifyNone(t, testutil.GoLeakIgnores()...)
4369
})
4470

45-
const (
46-
// length of time the initial cert is valid
47-
initialValidDuration = 3 * time.Second
48-
49-
// continue making requests for waitFactor*initialValidDuration
50-
waitFactor = 2
51-
)
52-
5371
certDir := t.TempDir()
5472

5573
ca := &x509.Certificate{
@@ -71,21 +89,19 @@ func TestCertRotation(t *testing.T) {
7189
require.NoError(t, err)
7290
caFile, err := os.Create(filepath.Join(certDir, "ca.crt"))
7391
require.NoError(t, err)
74-
t.Cleanup(func() {
75-
require.NoError(t, caFile.Close())
76-
})
7792
require.NoError(t, pem.Encode(caFile, &pem.Block{
7893
Type: "CERTIFICATE",
7994
Bytes: caCert.Raw,
8095
}))
96+
require.NoError(t, caFile.Close())
8197

8298
old := &x509.Certificate{
8399
SerialNumber: big.NewInt(1),
84100
Subject: pkix.Name{
85101
Organization: []string{"initialTestCert"},
86102
},
87103
NotBefore: time.Now(),
88-
NotAfter: time.Now().Add(initialValidDuration),
104+
NotAfter: time.Now().Add(5 * time.Minute),
89105
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
90106
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
91107
BasicConstraintsValid: true,
@@ -133,10 +149,13 @@ func TestCertRotation(t *testing.T) {
133149
server.WithMaximumPreconditionCount(1000),
134150
server.WithMaximumUpdatesPerWrite(1000),
135151
server.WithGRPCServer(util.GRPCServerConfig{
136-
Network: util.BufferedNetwork,
137-
Enabled: true,
138-
TLSCertPath: certFile.Name(),
139-
TLSKeyPath: keyFile.Name(),
152+
Network: util.BufferedNetwork,
153+
Enabled: true,
154+
TLSCertPath: certFile.Name(),
155+
TLSKeyPath: keyFile.Name(),
156+
// ClientCAPath is used by the server's outbound dispatch credentials, not
157+
// for server-side client-cert validation — the server does not set ClientAuth,
158+
// so this test does not exercise mutual TLS.
140159
ClientCAPath: caFile.Name(),
141160
}),
142161
server.WithGRPCAuthFunc(func(ctx context.Context) (context.Context, error) {
@@ -202,43 +221,60 @@ func TestCertRotation(t *testing.T) {
202221
wait <- err
203222
}()
204223

205-
tlsCreds, err := grpcutil.WithCustomCerts(grpcutil.VerifyCA, caFile.Name())
206-
require.NoError(t, err)
224+
caPool := x509.NewCertPool()
225+
caPool.AddCert(caCert)
207226

208-
// "buffnet" matches the DNSNames in the TLS certificate issued above; WithAuthority
209-
// sets the SNI so TLS verification succeeds even though the dial target is localhost.
210-
conn, err := srv.NewClient(
211-
tlsCreds,
212-
grpc.WithAuthority("buffnet"),
213-
grpc.WithConnectParams(grpc.ConnectParams{
214-
Backoff: backoff.Config{
215-
BaseDelay: 1 * time.Second,
216-
Multiplier: 2,
217-
MaxDelay: 15 * time.Second,
227+
// newConn dials a brand-new *grpc.ClientConn with its own servedCertRecorder.
228+
// A new ClientConn has no cached TLS session state (nil ClientSessionCache),
229+
// so every dial performs a full handshake and VerifyConnection fires to capture
230+
// the leaf cert the server currently presents.
231+
newConn := func() (*grpc.ClientConn, *servedCertRecorder, error) {
232+
rec := &servedCertRecorder{}
233+
creds := credentials.NewTLS(&tls.Config{
234+
RootCAs: caPool,
235+
ServerName: "buffnet",
236+
MinVersion: tls.VersionTLS12,
237+
VerifyConnection: rec.verifyConnection,
238+
})
239+
conn, err := srv.NewClient(
240+
grpc.WithTransportCredentials(creds),
241+
grpc.WithConnectParams(grpc.ConnectParams{
242+
Backoff: backoff.Config{
243+
BaseDelay: 1 * time.Second,
244+
Multiplier: 2,
245+
MaxDelay: 15 * time.Second,
246+
},
247+
}),
248+
)
249+
return conn, rec, err
250+
}
251+
252+
rel := tuple.ToV1Relationship(tuple.MustParse(tf.StandardRelationships[0]))
253+
254+
checkPermission := func(client v1.PermissionsServiceClient) error {
255+
_, err := client.CheckPermission(ctx, &v1.CheckPermissionRequest{
256+
Consistency: &v1.Consistency{
257+
Requirement: &v1.Consistency_AtLeastAsFresh{
258+
AtLeastAsFresh: zedtoken.MustNewFromRevisionForTesting(revision, datalayer.NoSchemaHashInLegacyZedToken),
259+
},
218260
},
219-
}),
220-
)
261+
Resource: rel.Resource,
262+
Permission: "viewer",
263+
Subject: rel.Subject,
264+
})
265+
return err
266+
}
221267

268+
conn, rec, err := newConn()
222269
require.NoError(t, err)
223270
defer func() {
224-
if conn != nil {
225-
require.NoError(t, conn.Close())
226-
}
271+
require.NoError(t, conn.Close())
227272
}()
228-
// requests work with the old key
229-
client := v1.NewPermissionsServiceClient(conn)
230-
rel := tuple.ToV1Relationship(tuple.MustParse(tf.StandardRelationships[0]))
231-
_, err = client.CheckPermission(ctx, &v1.CheckPermissionRequest{
232-
Consistency: &v1.Consistency{
233-
Requirement: &v1.Consistency_AtLeastAsFresh{
234-
AtLeastAsFresh: zedtoken.MustNewFromRevisionForTesting(revision, datalayer.NoSchemaHashInLegacyZedToken),
235-
},
236-
},
237-
Resource: rel.Resource,
238-
Permission: "viewer",
239-
Subject: rel.Subject,
240-
})
241-
require.NoError(t, err)
273+
274+
require.NoError(t, checkPermission(v1.NewPermissionsServiceClient(conn)))
275+
served := rec.lastServedCert()
276+
require.NotNil(t, served)
277+
require.Equal(t, oldCert.SerialNumber, served.SerialNumber, "expected the initial certificate to be served before rotation")
242278

243279
// rotate the key
244280
newCert := &x509.Certificate{
@@ -279,21 +315,26 @@ func TestCertRotation(t *testing.T) {
279315
}))
280316
require.NoError(t, certFile.Close())
281317

282-
// check for waitFactor*initialValidDuration seconds
283-
for range waitFactor {
284-
_, err = client.CheckPermission(ctx, &v1.CheckPermissionRequest{
285-
Consistency: &v1.Consistency{
286-
Requirement: &v1.Consistency_AtLeastAsFresh{
287-
AtLeastAsFresh: zedtoken.MustNewFromRevisionForTesting(revision, datalayer.NoSchemaHashInLegacyZedToken),
288-
},
289-
},
290-
Resource: rel.Resource,
291-
Permission: "viewer",
292-
Subject: rel.Subject,
293-
})
294-
require.NoError(t, err)
295-
time.Sleep(initialValidDuration)
296-
}
318+
// Wait for the server to reload the rotated certificate. Each iteration dials
319+
// a fresh connection to force a new TLS handshake — a reused connection keeps
320+
// its existing session and would never re-present the cert. This assertion
321+
// fails if hot reloading in updateCachedCertificate is disabled.
322+
require.Eventually(t, func() bool {
323+
rotatedConn, rotatedRec, err := newConn()
324+
if err != nil {
325+
t.Logf("newConn failed: %v", err)
326+
return false
327+
}
328+
defer func() { _ = rotatedConn.Close() }()
329+
330+
if err := checkPermission(v1.NewPermissionsServiceClient(rotatedConn)); err != nil {
331+
t.Logf("checkPermission failed: %v", err)
332+
return false
333+
}
334+
335+
served := rotatedRec.lastServedCert()
336+
return served != nil && served.SerialNumber.Cmp(newCertParsed.SerialNumber) == 0
337+
}, 30*time.Second, 500*time.Millisecond, "server never served the rotated certificate")
297338

298339
cancel()
299340
select {

0 commit comments

Comments
 (0)