From a649bf72b6f2a59b29cdde96e1706752e60096db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Sun, 22 Mar 2026 01:23:32 -0300 Subject: [PATCH 1/3] test(signer): add unit tests for signserver package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Matías Insaurralde --- .../signer/signserver/signserver_test.go | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 pkg/attestation/signer/signserver/signserver_test.go diff --git a/pkg/attestation/signer/signserver/signserver_test.go b/pkg/attestation/signer/signserver/signserver_test.go new file mode 100644 index 000000000..8ff528141 --- /dev/null +++ b/pkg/attestation/signer/signserver/signserver_test.go @@ -0,0 +1,336 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signserver + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "math/big" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- helpers ----------------------------------------------------------------- + +func generateSelfSignedCertPEM(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func generatePrivateKeyPEM(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + der, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) +} + +// generateEncryptedPrivateKeyPEM creates a PEM-encrypted private key (RFC1423/legacy format). +// +//nolint:staticcheck +func generateEncryptedPrivateKeyPEM(t *testing.T, password string) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + der, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + // x509.EncryptPEMBlock is deprecated but remains the standard way to test + // the RFC1423 decryption path exercised by privateKeyFromPem. + block, err := x509.EncryptPEMBlock(rand.Reader, "PRIVATE KEY", der, []byte(password), x509.PEMCipherAES128) //nolint:staticcheck + require.NoError(t, err) + return pem.EncodeToMemory(block) +} + +// writePEMToTempFile writes a raw PEM block to a temp file and returns its path. +func writePEMToTempFile(t *testing.T, blockType string, der []byte) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "*.pem") + require.NoError(t, err) + defer f.Close() + err = pem.Encode(f, &pem.Block{Type: blockType, Bytes: der}) + require.NoError(t, err) + return f.Name() +} + +// tlsServerCAFile starts a TLS httptest server and returns it together with a +// temp-file path containing its CA certificate. Caller is responsible for +// calling srv.Close(). +func tlsServerWithCA(t *testing.T, handler http.Handler) (*httptest.Server, string) { + t.Helper() + srv := httptest.NewTLSServer(handler) + caPath := writePEMToTempFile(t, "CERTIFICATE", srv.TLS.Certificates[0].Certificate[0]) + return srv, caPath +} + +// --- TestParseKeyReference --------------------------------------------------- + +func TestParseKeyReference(t *testing.T) { + tests := []struct { + name string + input string + wantHost string + wantWorker string + wantErr bool + }{ + { + name: "valid reference", + input: "signserver://myhost.com/CMSSigner", + wantHost: "myhost.com", + wantWorker: "CMSSigner", + }, + { + name: "valid reference with port", + input: "signserver://myhost.com:8443/PlainSigner", + wantHost: "myhost.com:8443", + wantWorker: "PlainSigner", + }, + { + name: "missing worker segment", + input: "signserver://myhost.com", + wantErr: true, + }, + { + name: "too many path segments", + input: "signserver://myhost.com/worker/extra", + wantErr: true, + }, + { + name: "no scheme separator", + input: "noscheme", + wantErr: true, + }, + { + // ParseKeyReference only validates structure, not the scheme prefix. + // Scheme enforcement (signserver://) is the caller's responsibility (GetSigner). + name: "non-signserver scheme parses successfully", + input: "https://myhost.com/worker", + wantHost: "myhost.com", + wantWorker: "worker", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + host, worker, err := ParseKeyReference(tc.input) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantHost, host) + assert.Equal(t, tc.wantWorker, worker) + }) + } +} + +// --- TestNewSigner_WithOptions ------------------------------------------------ + +func TestNewSigner_WithOptions(t *testing.T) { + s := NewSigner("myhost.com", "CMSSigner", + WithCAPath("/ca.pem"), + WithClientCertPath("/cert.pem"), + WithClientCertPass("secret"), + ) + assert.Equal(t, "myhost.com", s.host) + assert.Equal(t, "CMSSigner", s.worker) + assert.Equal(t, "/ca.pem", s.caPath) + assert.Equal(t, "/cert.pem", s.clientCertPath) + assert.Equal(t, "secret", s.clientCertPass) +} + +func TestNewSigner_Defaults(t *testing.T) { + s := NewSigner("host", "worker") + assert.Empty(t, s.caPath) + assert.Empty(t, s.clientCertPath) + assert.Empty(t, s.clientCertPass) +} + +// --- TestPublicKey ----------------------------------------------------------- + +func TestPublicKey_NotSupported(t *testing.T) { + s := NewSigner("host", "worker") + key, err := s.PublicKey() + assert.Error(t, err) + assert.Nil(t, key) +} + +// --- TestSignMessage --------------------------------------------------------- + +func TestSignMessage_Success(t *testing.T) { + var gotWorker string + var gotFileName string + var gotFileContent []byte + + srv, caPath := tlsServerWithCA(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseMultipartForm(1<<20)) + gotWorker = r.FormValue("workerName") + f, fh, err := r.FormFile("file") + require.NoError(t, err) + gotFileName = fh.Filename + gotFileContent, _ = io.ReadAll(f) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("fakesignature")) + })) + defer srv.Close() + + host := strings.TrimPrefix(srv.URL, "https://") + s := NewSigner(host, "CMSSigner", WithCAPath(caPath)) + + sig, err := s.SignMessage(strings.NewReader("hello attestation")) + require.NoError(t, err) + assert.Equal(t, []byte("fakesignature"), sig) + assert.Equal(t, "CMSSigner", gotWorker) + assert.Equal(t, "attestation.json", gotFileName) + assert.Equal(t, []byte("hello attestation"), gotFileContent) +} + +func TestSignMessage_Non200Status(t *testing.T) { + srv, caPath := tlsServerWithCA(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + host := strings.TrimPrefix(srv.URL, "https://") + s := NewSigner(host, "CMSSigner", WithCAPath(caPath)) + + _, err := s.SignMessage(strings.NewReader("data")) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestSignMessage_NetworkError(t *testing.T) { + srv, caPath := tlsServerWithCA(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) + host := strings.TrimPrefix(srv.URL, "https://") + srv.Close() // closed before the call + + s := NewSigner(host, "CMSSigner", WithCAPath(caPath)) + _, err := s.SignMessage(strings.NewReader("data")) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to send request") +} + +func TestSignMessage_InvalidCAPath(t *testing.T) { + s := NewSigner("host", "worker", WithCAPath("/nonexistent/ca.pem")) + _, err := s.SignMessage(strings.NewReader("data")) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read ca cert") +} + +// --- TestCertFromPem --------------------------------------------------------- + +func TestCertFromPem(t *testing.T) { + cert1 := generateSelfSignedCertPEM(t) + cert2 := generateSelfSignedCertPEM(t) + keyOnly := generatePrivateKeyPEM(t) + + tests := []struct { + name string + input []byte + wantCerts int + }{ + {"single certificate", cert1, 1}, + {"two certificates", append(cert1, cert2...), 2}, + {"empty input", []byte{}, 0}, + {"key block only — no certs extracted", keyOnly, 0}, + {"cert followed by key", append(cert1, keyOnly...), 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := certFromPem(tc.input) + require.NoError(t, err) + assert.Len(t, result.Certificate, tc.wantCerts) + }) + } +} + +// --- TestPrivateKeyFromPem --------------------------------------------------- + +func TestPrivateKeyFromPem(t *testing.T) { + tests := []struct { + name string + pemBytes func(t *testing.T) []byte + password string + wantErr bool + }{ + { + name: "unencrypted PKCS8 key with empty password", + pemBytes: func(t *testing.T) []byte { return generatePrivateKeyPEM(t) }, + password: "", + }, + { + name: "RFC1423 encrypted key with correct password", + pemBytes: func(t *testing.T) []byte { return generateEncryptedPrivateKeyPEM(t, "correct") }, + password: "correct", + }, + { + name: "RFC1423 encrypted key with wrong password", + pemBytes: func(t *testing.T) []byte { return generateEncryptedPrivateKeyPEM(t, "correct") }, + password: "wrong", + wantErr: true, + }, + { + name: "no PEM data at all", + pemBytes: func(_ *testing.T) []byte { return []byte("not-pem-data") }, + password: "", + wantErr: true, + }, + { + name: "certificate block only — no key", + pemBytes: func(t *testing.T) []byte { return generateSelfSignedCertPEM(t) }, + password: "", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + key, err := privateKeyFromPem(tc.pemBytes(t), tc.password) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotNil(t, key) + _, ok := key.(*ecdsa.PrivateKey) + assert.True(t, ok, "expected *ecdsa.PrivateKey") + }) + } +} From 382b91c95669650541231dab6ddad2cdf7da7084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Sun, 22 Mar 2026 01:34:21 -0300 Subject: [PATCH 2/3] test(signserver): assert io.ReadAll succeeds in success-path test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Matías Insaurralde --- pkg/attestation/signer/signserver/signserver_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/attestation/signer/signserver/signserver_test.go b/pkg/attestation/signer/signserver/signserver_test.go index 8ff528141..1f9ef4e45 100644 --- a/pkg/attestation/signer/signserver/signserver_test.go +++ b/pkg/attestation/signer/signserver/signserver_test.go @@ -204,7 +204,8 @@ func TestSignMessage_Success(t *testing.T) { f, fh, err := r.FormFile("file") require.NoError(t, err) gotFileName = fh.Filename - gotFileContent, _ = io.ReadAll(f) + gotFileContent, err = io.ReadAll(f) + require.NoError(t, err) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("fakesignature")) })) From 681e497849d4dd625cfbf10dbbd8b16cc20bbfa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Sun, 22 Mar 2026 01:34:57 -0300 Subject: [PATCH 3/3] test(signserver): fix lint issues in signserver tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Matías Insaurralde --- pkg/attestation/signer/signserver/signserver_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/attestation/signer/signserver/signserver_test.go b/pkg/attestation/signer/signserver/signserver_test.go index 1f9ef4e45..3dbb5e976 100644 --- a/pkg/attestation/signer/signserver/signserver_test.go +++ b/pkg/attestation/signer/signserver/signserver_test.go @@ -237,7 +237,7 @@ func TestSignMessage_Non200Status(t *testing.T) { } func TestSignMessage_NetworkError(t *testing.T) { - srv, caPath := tlsServerWithCA(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) + srv, caPath := tlsServerWithCA(t, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) host := strings.TrimPrefix(srv.URL, "https://") srv.Close() // closed before the call @@ -293,7 +293,7 @@ func TestPrivateKeyFromPem(t *testing.T) { }{ { name: "unencrypted PKCS8 key with empty password", - pemBytes: func(t *testing.T) []byte { return generatePrivateKeyPEM(t) }, + pemBytes: generatePrivateKeyPEM, password: "", }, { @@ -315,7 +315,7 @@ func TestPrivateKeyFromPem(t *testing.T) { }, { name: "certificate block only — no key", - pemBytes: func(t *testing.T) []byte { return generateSelfSignedCertPEM(t) }, + pemBytes: generateSelfSignedCertPEM, password: "", wantErr: true, },