fleet/server/service/integration_certificate_authorities_test.go

1968 lines
82 KiB
Go

package service
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"sync/atomic"
"testing"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/integrationtest/scep_server"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/micromdm/plist"
"github.com/smallstep/pkcs7"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func (s *integrationMDMTestSuite) TestBatchApplyCertificateAuthorities() {
t := s.T()
// TODO(hca): test each CA type activities once implemented
// TODO(hca) test each CA type cannot configure without private key?
// TODO(hca); add mechanisms for each CA type to check that external validation endpoints are called for
// new/modified CAs and that they are not called if nothing changes?
// TODO(hca): test free version disallows batch endpoint
ndesSCEPServer := eeservice.NewTestSCEPServer(t)
ndesAdminServer := eeservice.NewTestNDESAdminServer(t, "mscep_admin_password", http.StatusOK)
dynamicChallengeServer := eeservice.NewTestDynamicChallengeServer(t)
pathRegex := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`)
mockDigiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
matches := pathRegex.FindStringSubmatch(r.URL.Path)
if len(matches) != 2 {
w.WriteHeader(http.StatusBadRequest)
return
}
profileID := matches[1]
resp := map[string]string{
"id": profileID,
"name": "Test CA",
"status": "Active",
}
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
}))
t.Cleanup(mockDigiCertServer.Close)
mockHydrantServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/cacerts" {
w.Header().Set("Content-Type", "application/pkcs7-mime")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("Imagine if there was actually CA cert data here..."))
require.NoError(t, err)
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
}))
t.Cleanup(mockHydrantServer.Close)
mockSCEPServer := scep_server.StartTestSCEPServer(t)
t.Cleanup(mockSCEPServer.Close)
// goodNDESSCEPCA is a base object for testing with a valid NDES SCEP CA. Copy it to override specific fields in tests.
goodNDESSCEPCA := fleet.NDESSCEPProxyCA{
URL: ndesSCEPServer.URL + "/scep",
AdminURL: ndesAdminServer.URL + "/mscep_admin/",
Username: "user",
Password: "password",
}
// goodDigiCertCA is a base object for testing with a valid DigiCert CA. Copy it to override specific fields in tests.
goodDigiCertCA := fleet.DigiCertCA{
Name: "VALID_DIGICERT_CA",
URL: mockDigiCertServer.URL,
APIToken: "token",
ProfileID: "valid-profile-id",
CertificateCommonName: "common-name",
CertificateUserPrincipalNames: []string{"user1@example.com"},
CertificateSeatID: "seat-id",
}
// goodCustomSCEPCA is a base object for testing with a valid Custom SCEP CA. Copy it to override specific fields in tests.
goodCustomSCEPCA := fleet.CustomSCEPProxyCA{
Name: "VALID_CUSTOM_SCEP",
URL: mockSCEPServer.URL + "/scep",
Challenge: "challenge",
}
// goodHydrantCA is a base object for testing with a valid Hydrant CA. Copy it to override specific fields in tests.
goodHydrantCA := fleet.HydrantCA{
Name: "VALID_HYDRANT",
URL: mockHydrantServer.URL, // TODO: implement a test server?
ClientID: "client-id",
ClientSecret: "client-secret",
}
goodESTCA := fleet.ESTProxyCA{
Name: "VALID_EST",
URL: mockHydrantServer.URL,
Username: "username",
Password: "password",
}
// goodSmallstepCA is a base object for testing with a valid Smallstep SCEP CA. Copy it to override specific fields in tests.
goodSmallstepCA := fleet.SmallstepSCEPProxyCA{
Name: "VALID_SMALLSTEP_SCEP",
URL: mockSCEPServer.URL + "/scep",
ChallengeURL: dynamicChallengeServer.URL + "/challenge",
Username: "user",
Password: "password",
}
// newApplyRequest creates a new applyCertificateAuthoritiesSpecRequest. The given payload
// should be one of fleet.DigiCertCA, fleet.CustomSCEPProxyCA, fleet.HydrantCA, or fleet.NDESSCEPProxyCA.
newApplyRequest := func(p interface{}, dryRun bool) (batchApplyCertificateAuthoritiesRequest, error) {
switch v := p.(type) {
case fleet.CustomSCEPProxyCA:
return batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
CustomScepProxy: []fleet.CustomSCEPProxyCA{v},
},
DryRun: dryRun,
}, nil
case fleet.HydrantCA:
return batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
Hydrant: []fleet.HydrantCA{v},
},
DryRun: dryRun,
}, nil
case fleet.ESTProxyCA:
return batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
EST: []fleet.ESTProxyCA{v},
},
DryRun: dryRun,
}, nil
case fleet.NDESSCEPProxyCA:
return batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
NDESSCEP: &v,
},
DryRun: dryRun,
}, nil
case fleet.DigiCertCA:
return batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{v},
},
DryRun: dryRun,
}, nil
case fleet.SmallstepSCEPProxyCA:
return batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
Smallstep: []fleet.SmallstepSCEPProxyCA{v},
},
DryRun: dryRun,
}, nil
default:
return batchApplyCertificateAuthoritiesRequest{}, errors.New("invalid usage of newApplyRequest")
}
}
// common invalid name test cases for DigiCert, Custom SCEP, and Hydrant
invalidNameTestCases := []struct {
testName string
name string
errMessage string
}{
{
testName: "empty",
name: "",
errMessage: "name cannot be empty",
},
{
testName: "NDES",
name: "NDES",
errMessage: "CA name cannot be NDES",
},
{
testName: "too long",
name: strings.Repeat("a", 256),
errMessage: "CA name cannot be longer than ",
},
{
testName: "invalid characters",
name: "a/b",
errMessage: "Only letters, numbers and underscores allowed",
},
}
// common invalid URL test cases for DigiCert, Custom SCEP, and Hydrant
invalidURLTestCases := []struct {
testName string
url string
errMessage string
}{
{
testName: "empty",
url: "",
errMessage: "Invalid URL",
},
{
testName: "non-http",
url: "nonhttp://bad.com",
errMessage: "URL scheme must be https or http",
},
}
t.Run("ndes", func(t *testing.T) {
checkNDESApplied := func(t *testing.T, expectNDES *fleet.NDESSCEPProxyCA) {
cas, err := s.ds.GetGroupedCertificateAuthorities(context.Background(), true)
require.NoError(t, err)
if expectNDES != nil {
require.NotNil(t, cas.NDESSCEP)
require.NotZero(t, cas.NDESSCEP.ID)
require.Equal(t, expectNDES.URL, cas.NDESSCEP.URL)
require.Equal(t, expectNDES.AdminURL, cas.NDESSCEP.AdminURL)
require.Equal(t, expectNDES.Username, cas.NDESSCEP.Username)
require.Equal(t, expectNDES.Password, cas.NDESSCEP.Password)
} else {
require.Nil(t, cas.NDESSCEP)
}
}
t.Run("invalid SCEP URL", func(t *testing.T) {
testCopy := goodNDESSCEPCA
testCopy.URL = "://invalid-url"
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.ndes_scep_proxy")
require.Contains(t, errMsg, "Invalid NDES SCEP URL")
checkNDESApplied(t, nil)
})
t.Run("wrong SCEP URL", func(t *testing.T) {
testCopy := goodNDESSCEPCA
testCopy.URL = "https://new2.com/mscep/mscep.dll"
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.ndes_scep_proxy")
require.Contains(t, errMsg, "Invalid SCEP URL")
checkNDESApplied(t, nil)
})
t.Run("empty password", func(t *testing.T) {
testCopy := goodNDESSCEPCA
testCopy.Password = ""
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.ndes_scep_proxy")
require.Contains(t, errMsg, "NDES SCEP password cannot be empty.")
checkNDESApplied(t, nil)
})
t.Run("delete", func(t *testing.T) {
t.Run("nil NDES deletes existing", func(t *testing.T) {
// create a CA to delete
req, err := newApplyRequest(goodNDESSCEPCA, false)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
checkNDESApplied(t, &goodNDESSCEPCA)
// try dry run of deletion by making an apply request where NDES is nil and dry run is true
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
NDESSCEP: nil,
},
DryRun: true,
}, http.StatusOK)
checkNDESApplied(t, &goodNDESSCEPCA) // prior ndes should still exist
// now delete it by making an apply request where NDES is nil and dry run is false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
NDESSCEP: nil,
},
DryRun: false,
}, http.StatusOK)
checkNDESApplied(t, nil) // prior ndes deleted
})
t.Run("empty NDES deletes existing", func(t *testing.T) {
// create a CA to delete
req, err := newApplyRequest(goodNDESSCEPCA, false)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
checkNDESApplied(t, &goodNDESSCEPCA)
// try dry run of deletion by making an apply request where NDES is an empty struct and dry run is true
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
NDESSCEP: &fleet.NDESSCEPProxyCA{},
},
DryRun: true,
}, http.StatusOK)
checkNDESApplied(t, &goodNDESSCEPCA) // prior ndes should still exist
// now delete it by making an apply request where NDES is an empty struct
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
NDESSCEP: &fleet.NDESSCEPProxyCA{},
},
DryRun: false,
}, http.StatusOK)
checkNDESApplied(t, nil) // prior ndes deleted
})
t.Run("json null NDES deletes existing", func(t *testing.T) {
// create a CA to delete
req, err := newApplyRequest(goodNDESSCEPCA, false)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
checkNDESApplied(t, &goodNDESSCEPCA)
// mock apply request where API user sends json null
r := map[string]interface{}{
"certificate_authorities": map[string]interface{}{
"ndes_scep_proxy": nil,
},
"dry_run": true,
}
// first try a dry run
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", r, http.StatusOK)
checkNDESApplied(t, &goodNDESSCEPCA) // prior ndes should still exist
// now make a non-dry run apply request where NDES is nil, which should delete the
// existing CA
r["dry_run"] = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", r, http.StatusOK)
checkNDESApplied(t, nil) // prior ndes should be deleted
})
})
t.Run("happy path add then update then delete", func(t *testing.T) {
checkNDESApplied(t, nil)
req, err := newApplyRequest(goodNDESSCEPCA, true)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
checkNDESApplied(t, nil) // dry run
req.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
checkNDESApplied(t, &goodNDESSCEPCA)
// update
testCopy := goodNDESSCEPCA
testCopy.Password = "new-password"
req, err = newApplyRequest(testCopy, true)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
checkNDESApplied(t, &goodNDESSCEPCA) // dry run should not change anything
req.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
checkNDESApplied(t, &testCopy)
// delete
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{DryRun: true}, http.StatusOK)
checkNDESApplied(t, &testCopy) // dry run should not change anything
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{DryRun: false}, http.StatusOK)
checkNDESApplied(t, nil) // prior ndes should be deleted
})
})
t.Run("digicert", func(t *testing.T) {
t.Run("invalid name", func(t *testing.T) {
// run common invalid name test cases
for _, tc := range invalidNameTestCases {
t.Run(tc.testName, func(t *testing.T) {
testCopy := goodDigiCertCA
testCopy.Name = tc.name
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, tc.errMessage)
})
}
})
// run common invalid URL test cases
t.Run("invalid url", func(t *testing.T) {
for _, tc := range invalidURLTestCases {
t.Run(tc.testName, func(t *testing.T) {
testCopy := goodDigiCertCA
testCopy.URL = tc.url
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
if tc.errMessage == "Invalid URL" {
require.Contains(t, errMsg, "Invalid DigiCert URL")
} else {
require.Contains(t, errMsg, tc.errMessage)
}
})
}
})
// run additional duplicate name scenarios
t.Run("duplicate names", func(t *testing.T) {
// create one of each CA
req := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req.CertificateAuthorities)
t.Cleanup(func() {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, _ = q.ExecContext(context.Background(), "DELETE FROM certificate_authorities")
return nil
})
})
// try to create digicert with same name as another digicert
testCopy := goodDigiCertCA
testCopy.CertificateSeatID = "some-other-seat-id"
duplicateReq := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA, testCopy},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
},
DryRun: false,
}
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, "name is already used by another DigiCert certificate authority")
// try to create digicert with same name as another custom scep
testCopy = goodDigiCertCA
testCopy.Name = goodCustomSCEPCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA, testCopy},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
// try to create digicert with same name as another hydrant
testCopy = goodDigiCertCA
testCopy.Name = goodHydrantCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA, testCopy},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
// try to create digicert with same name as another smallstep
testCopy = goodDigiCertCA
testCopy.Name = goodSmallstepCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA, testCopy},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
})
t.Run("digicert more than 1 user principal name", func(t *testing.T) {
testCopy := goodDigiCertCA
testCopy.CertificateUserPrincipalNames = []string{"user1", "user2"}
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, "only one item can be added to certificate_user_principal_names")
})
t.Run("digicert empty user principal name", func(t *testing.T) {
testCopy := goodDigiCertCA
testCopy.CertificateUserPrincipalNames = []string{" "}
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, "certificate_user_principal_name cannot be empty if specified")
})
t.Run("digicert Fleet vars in user principal name", func(t *testing.T) {
// allowed usage
testCopy := goodDigiCertCA
testCopy.CertificateUserPrincipalNames = []string{"$FLEET_VAR_" + string(fleet.FleetVarHostEndUserEmailIDP) + " ${FLEET_VAR_" + string(fleet.FleetVarHostHardwareSerial) + "}"}
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req.CertificateAuthorities)
t.Cleanup(func() {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, _ = q.ExecContext(context.Background(), "DELETE FROM certificate_authorities")
return nil
})
})
// disallowed usage
testCopy.CertificateUserPrincipalNames = []string{"$FLEET_VAR_BOZO"}
req, err = newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, "FLEET_VAR_BOZO is not allowed")
})
t.Run("digicert Fleet vars in common name", func(t *testing.T) {
// allowed usage
testCopy := goodDigiCertCA
testCopy.CertificateCommonName = "${FLEET_VAR_" + string(fleet.FleetVarHostEndUserEmailIDP) + "}${FLEET_VAR_" + string(fleet.FleetVarHostHardwareSerial) + "}"
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req.CertificateAuthorities)
t.Cleanup(func() {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, _ = q.ExecContext(context.Background(), "DELETE FROM certificate_authorities")
return nil
})
})
// disallowed usage
testCopy.CertificateCommonName = "$FLEET_VAR_BOZO"
req, err = newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, "FLEET_VAR_BOZO is not allowed")
})
t.Run("digicert Fleet vars in seat id", func(t *testing.T) {
// allowed usage
testCopy := goodDigiCertCA
testCopy.CertificateSeatID = "${FLEET_VAR_" + string(fleet.FleetVarHostEndUserEmailIDP) + "}${FLEET_VAR_" + string(fleet.FleetVarHostHardwareSerial) + "}"
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req.CertificateAuthorities)
t.Cleanup(func() {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, _ = q.ExecContext(context.Background(), "DELETE FROM certificate_authorities")
return nil
})
})
// disallowed usage
testCopy.CertificateSeatID = "$FLEET_VAR_BOZO"
req, err = newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, "FLEET_VAR_BOZO is not allowed")
})
t.Run("digicert API token not set", func(t *testing.T) {
testCopy := goodDigiCertCA
testCopy.APIToken = ""
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, "Invalid API token. Please correct and try again.")
// try again with masked password, same as if it was not set
testCopy.APIToken = fleet.MaskedPassword
req, err = newApplyRequest(testCopy, false)
require.NoError(t, err)
res = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, "Invalid API token. Please correct and try again.")
})
t.Run("digicert common name not set", func(t *testing.T) {
testCopy := goodDigiCertCA
testCopy.CertificateCommonName = ""
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, "Common Name (CN) cannot be empty")
})
t.Run("digicert seat id not set", func(t *testing.T) {
testCopy := goodDigiCertCA
testCopy.CertificateSeatID = ""
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.digicert")
require.Contains(t, errMsg, "Seat ID cannot be empty")
})
t.Run("digicert happy path with activities add then modify then delete", func(t *testing.T) {
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{})
req1, err := newApplyRequest(goodDigiCertCA, true)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req1, http.StatusOK)
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{}) // dry run should not change anything
req1.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req1, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req1.CertificateAuthorities) // now it should be applied
wantAdded := fleet.ActivityAddedDigiCert{
Name: goodDigiCertCA.Name,
}
id := s.lastActivityMatches(wantAdded.ActivityName(), fmt.Sprintf(`{"name":%q}`, wantAdded.Name), 0)
testCopy := goodDigiCertCA
testCopy.CertificateCommonName = "new-common-name"
req2, err := newApplyRequest(testCopy, true)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req2, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req1.CertificateAuthorities) // dry run should not change anything
s.lastActivityMatches(wantAdded.ActivityName(), "", id) // no new activity yet
req2.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req2, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req2.CertificateAuthorities) // now it should be applied
wantEdited := fleet.ActivityEditedDigiCert{
Name: goodDigiCertCA.Name,
}
s.lastActivityMatches(wantEdited.ActivityName(), fmt.Sprintf(`{"name":%q}`, wantEdited.Name), 0)
s.lastActivityOfTypeMatches(wantAdded.ActivityName(), "", id) // last "added" activity is the prior one
// sending empty CAs deletes existing one
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{DryRun: true}, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req2.CertificateAuthorities) // dry run should not change anything
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{DryRun: false}, http.StatusOK)
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{}) // now delete should be applied
wantDeleted := fleet.ActivityDeletedDigiCert{
Name: goodDigiCertCA.Name,
}
s.lastActivityMatches(wantDeleted.ActivityName(), fmt.Sprintf(`{"name":%q}`, wantDeleted.Name), 0)
})
t.Run("digicert happy path add one, delete one, modify one", func(t *testing.T) {
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{})
// setup the test by creating two CAs
test1 := goodDigiCertCA
test2 := goodDigiCertCA
test2.Name = "VALID_DIGICERT_CA_2"
initialReq := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{test1, test2},
},
DryRun: false,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", initialReq, http.StatusOK)
s.checkAppliedCAs(t, s.ds, initialReq.CertificateAuthorities)
// add third
test3 := goodDigiCertCA
test3.Name = "VALID_DIGICERT_CA_3"
// modify first
test1.CertificateCommonName = "new-common-name"
// new request will modify test1, add test3, and delete test2
modifiedReq := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{test1, test3},
},
DryRun: true,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", modifiedReq, http.StatusOK)
s.checkAppliedCAs(t, s.ds, initialReq.CertificateAuthorities) // dry run should not change anything
modifiedReq.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", modifiedReq, http.StatusOK)
s.checkAppliedCAs(t, s.ds, modifiedReq.CertificateAuthorities) // now it should be applied
// delete the rest
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", fleet.GroupedCertificateAuthorities{}, http.StatusOK)
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{})
})
})
t.Run("custom_scep_proxy", func(t *testing.T) {
// run common invalid name test cases
t.Run("invalid name", func(t *testing.T) {
for _, tc := range invalidNameTestCases {
t.Run(tc.testName, func(t *testing.T) {
testCopy := goodCustomSCEPCA
testCopy.Name = tc.name
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.custom_scep_proxy")
require.Contains(t, errMsg, tc.errMessage)
})
}
})
// run common invalid url test cases
t.Run("invalid url", func(t *testing.T) {
for _, tc := range invalidURLTestCases {
testCopy := goodCustomSCEPCA
testCopy.URL = tc.url
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.custom_scep_proxy")
if tc.errMessage == "Invalid URL" {
require.Contains(t, errMsg, "Invalid SCEP URL")
} else {
require.Contains(t, errMsg, tc.errMessage)
}
}
})
// run additional duplicate name scenarios
t.Run("duplicate names", func(t *testing.T) {
// create one of each CA
req := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req.CertificateAuthorities)
t.Cleanup(func() {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, _ = q.ExecContext(context.Background(), "DELETE FROM certificate_authorities")
return nil
})
})
// try to create custom scep with same name as another custom scep
testCopy := goodCustomSCEPCA
testCopy.URL = "https://example.com"
duplicateReq := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA, testCopy},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.custom_scep_proxy")
require.Contains(t, errMsg, "name is already used by another Custom SCEP Proxy certificate authority")
// try to create custom scep with same name as the digicert. Should not error
testCopy = goodCustomSCEPCA
testCopy.Name = goodDigiCertCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA, testCopy},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
// try to create custom scep with same name as the Hydrant CA. Should not error
testCopy = goodCustomSCEPCA
testCopy.Name = goodHydrantCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA, testCopy},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
// try to create custom scep with same name as the Smallstep CA. Should not error
testCopy = goodCustomSCEPCA
testCopy.Name = goodSmallstepCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA, testCopy},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
})
t.Run("custom_scep challenge not set", func(t *testing.T) {
testCopy := goodCustomSCEPCA
testCopy.Challenge = ""
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.custom_scep_proxy")
require.Contains(t, errMsg, "Custom SCEP Proxy challenge cannot be empty")
// try with masked password, same as if it was not set
testCopy.Challenge = fleet.MaskedPassword
req, err = newApplyRequest(testCopy, false)
require.NoError(t, err)
res = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.custom_scep_proxy")
require.Contains(t, errMsg, "Custom SCEP Proxy challenge cannot be empty")
})
t.Run("custom scep happy path with activities add then modify then delete", func(t *testing.T) {
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{})
req1, err := newApplyRequest(goodCustomSCEPCA, true)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req1, http.StatusOK)
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{}) // dry run
req1.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req1, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req1.CertificateAuthorities)
wantAdded := fleet.ActivityAddedCustomSCEPProxy{
Name: goodCustomSCEPCA.Name,
}
id := s.lastActivityMatches(wantAdded.ActivityName(), fmt.Sprintf(`{"name":"%s"}`, wantAdded.Name), 0)
testCopy := goodCustomSCEPCA
testCopy.Challenge = "some-new-challenge"
req2, err := newApplyRequest(testCopy, true)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req2, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req1.CertificateAuthorities) // dry run so no changes
s.lastActivityMatches(wantAdded.ActivityName(), "", id) // no new activity
req2.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req2, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req2.CertificateAuthorities) // changes now applied
wantEdited := fleet.ActivityEditedCustomSCEPProxy{
Name: goodCustomSCEPCA.Name,
}
s.lastActivityMatches(wantEdited.ActivityName(), fmt.Sprintf(`{"name":"%s"}`, wantEdited.Name), 0) // last "edited" activity is the prior one
s.lastActivityOfTypeMatches(wantAdded.ActivityName(), "", id) // last "added" activity is the prior one
// sending empty CAs deletes existing one
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{DryRun: true}, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req2.CertificateAuthorities) // dry run should not change anything
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{DryRun: false}, http.StatusOK)
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{})
wantDeleted := fleet.ActivityDeletedCustomSCEPProxy{
Name: goodCustomSCEPCA.Name,
}
s.lastActivityMatches(wantDeleted.ActivityName(), fmt.Sprintf(`{"name":"%s"}`, wantDeleted.Name), 0)
})
t.Run("custom scep happy path add one, delete one, modify one", func(t *testing.T) {
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{})
// setup the test by creating two CAs
test1 := goodCustomSCEPCA
test2 := goodCustomSCEPCA
test2.Name = "VALID_CUSTOM_SCEP_CA_2"
req := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
CustomScepProxy: []fleet.CustomSCEPProxyCA{test1, test2},
},
DryRun: false,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req.CertificateAuthorities)
// add third
test3 := goodCustomSCEPCA
test3.Name = "VALID_CUSTOM_SCEP_CA_3"
// modify first
test1.Challenge = "new-challenge"
// new request will modify test1, add test3, and delete test2
req2 := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
CustomScepProxy: []fleet.CustomSCEPProxyCA{test1, test3},
},
DryRun: true,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req.CertificateAuthorities) // dry run should not change anything
req2.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req2, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req2.CertificateAuthorities)
// delete the rest
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", fleet.GroupedCertificateAuthorities{}, http.StatusOK)
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{})
})
})
t.Run("smallstep", func(t *testing.T) {
// run common invalid name test cases
t.Run("invalid name", func(t *testing.T) {
for _, tc := range invalidNameTestCases {
t.Run(tc.testName, func(t *testing.T) {
testCopy := goodSmallstepCA
testCopy.Name = tc.name
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.smallstep")
require.Contains(t, errMsg, tc.errMessage)
})
}
})
// run common invalid url test cases
t.Run("invalid url", func(t *testing.T) {
for _, tc := range invalidURLTestCases {
t.Run(tc.testName, func(t *testing.T) {
testCopy := goodSmallstepCA
testCopy.URL = tc.url
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.smallstep")
if tc.errMessage == "Invalid URL" {
require.Contains(t, errMsg, "Invalid Smallstep SCEP URL")
} else {
require.Contains(t, errMsg, tc.errMessage)
}
})
}
})
t.Run("duplicate names", func(t *testing.T) {
// create one of each CA
req := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req.CertificateAuthorities)
t.Cleanup(func() {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, _ = q.ExecContext(context.Background(), "DELETE FROM certificate_authorities")
return nil
})
})
// try to create smallstep with same name as another smallstep
testCopy := goodSmallstepCA
testCopy.URL = "https://example.com"
duplicateReq := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA, testCopy},
},
DryRun: false,
}
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.smallstep")
require.Contains(t, errMsg, "name is already used by another Smallstep certificate authority")
// try to create smallstep with same name as another digicert
testCopy = goodSmallstepCA
testCopy.Name = goodDigiCertCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA, testCopy},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
// try to create smallstep with same name as another hydrant
testCopy = goodSmallstepCA
testCopy.Name = goodHydrantCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA, testCopy},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
// try to create smallstep with same name as another custom scep
testCopy = goodSmallstepCA
testCopy.Name = goodCustomSCEPCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA, testCopy},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
})
t.Run("smallstep url not set", func(t *testing.T) {
testCopy := goodSmallstepCA
testCopy.URL = ""
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.smallstep")
require.Contains(t, errMsg, "Invalid Smallstep SCEP URL")
})
t.Run("smallstep challenge url not set", func(t *testing.T) {
testCopy := goodSmallstepCA
testCopy.ChallengeURL = ""
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.smallstep")
require.Contains(t, errMsg, "Invalid challenge URL or credentials")
})
t.Run("smallstep username not set", func(t *testing.T) {
testCopy := goodSmallstepCA
testCopy.Username = ""
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.smallstep")
require.Contains(t, errMsg, "Smallstep username cannot be empty")
})
t.Run("smallstep password not set", func(t *testing.T) {
testCopy := goodSmallstepCA
testCopy.Password = ""
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.smallstep")
require.Contains(t, errMsg, "Smallstep password cannot be empty")
// try with masked password, same as if it was not set
testCopy.Password = fleet.MaskedPassword
req, err = newApplyRequest(testCopy, false)
require.NoError(t, err)
res = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.smallstep")
require.Contains(t, errMsg, "Smallstep password cannot be empty")
})
t.Run("smallstep happy path with activities add then modify then delete", func(t *testing.T) {
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{})
req1, err := newApplyRequest(goodSmallstepCA, true)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req1, http.StatusOK)
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{}) // dry run should not change anything
req1.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req1, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req1.CertificateAuthorities) // now it should be applied
wantAdded := fleet.ActivityAddedSmallstep{
Name: goodSmallstepCA.Name,
}
id := s.lastActivityMatches(wantAdded.ActivityName(), fmt.Sprintf(`{"name":%q}`, wantAdded.Name), 0)
testCopy := goodSmallstepCA
testCopy.Username = "username"
req2, err := newApplyRequest(testCopy, true)
require.NoError(t, err)
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req2, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req1.CertificateAuthorities) // dry run should not change anything
s.lastActivityMatches(wantAdded.ActivityName(), "", id) // no new activity yet
req2.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req2, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req2.CertificateAuthorities) // now it should be applied
wantEdited := fleet.ActivityEditedSmallstep{
Name: goodSmallstepCA.Name,
}
s.lastActivityMatches(wantEdited.ActivityName(), fmt.Sprintf(`{"name":%q}`, wantEdited.Name), 0)
s.lastActivityOfTypeMatches(wantAdded.ActivityName(), "", id) // last "added" activity is the prior one
// sending empty CAs deletes existing one
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{DryRun: true}, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req2.CertificateAuthorities) // dry run should not change anything
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{DryRun: false}, http.StatusOK)
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{}) // now delete should be applied
wantDeleted := fleet.ActivityDeletedSmallstep{
Name: goodSmallstepCA.Name,
}
s.lastActivityMatches(wantDeleted.ActivityName(), fmt.Sprintf(`{"name":%q}`, wantDeleted.Name), 0)
})
t.Run("smallstep happy path add one, delete one, modify one", func(t *testing.T) {
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{})
// setup the test by creating two CAs
test1 := goodSmallstepCA
test2 := goodSmallstepCA
test2.Name = "VALID_SMALLSTEP_CA_2"
initialReq := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
Smallstep: []fleet.SmallstepSCEPProxyCA{test1, test2},
},
DryRun: false,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", initialReq, http.StatusOK)
s.checkAppliedCAs(t, s.ds, initialReq.CertificateAuthorities)
// add third
test3 := goodSmallstepCA
test3.Name = "VALID_SMALLSTEP_CA_3"
// modify first
test1.Username = "username"
// new request will modify test1, add test3, and delete test2
modifiedReq := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
Smallstep: []fleet.SmallstepSCEPProxyCA{test1, test3},
},
DryRun: true,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", modifiedReq, http.StatusOK)
s.checkAppliedCAs(t, s.ds, initialReq.CertificateAuthorities) // dry run should not change anything
modifiedReq.DryRun = false
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", modifiedReq, http.StatusOK)
s.checkAppliedCAs(t, s.ds, modifiedReq.CertificateAuthorities) // now it should be applied
// delete the rest
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", fleet.GroupedCertificateAuthorities{}, http.StatusOK)
s.checkAppliedCAs(t, s.ds, fleet.GroupedCertificateAuthorities{})
})
})
t.Run("hydrant", func(t *testing.T) {
// run common invalid name test cases
t.Run("invalid name", func(t *testing.T) {
for _, tc := range invalidNameTestCases {
t.Run(tc.testName, func(t *testing.T) {
testCopy := goodHydrantCA
testCopy.Name = tc.name
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.hydrant")
require.Contains(t, errMsg, tc.errMessage)
})
}
})
// run common invalid url test cases
t.Run("invalid url", func(t *testing.T) {
for _, tc := range invalidURLTestCases {
t.Run(tc.testName, func(t *testing.T) {
testCopy := goodHydrantCA
testCopy.URL = tc.url
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.hydrant")
if tc.errMessage == "Invalid URL" {
require.Contains(t, errMsg, "Invalid Hydrant URL")
} else {
require.Contains(t, errMsg, tc.errMessage)
}
})
}
})
// run additional duplicate name scenarios
t.Run("duplicate names", func(t *testing.T) {
// create one of each CA
req := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req.CertificateAuthorities)
t.Cleanup(func() {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, _ = q.ExecContext(context.Background(), "DELETE FROM certificate_authorities")
return nil
})
})
// try to create hydrant with same name as another hydrant
testCopy := goodHydrantCA
testCopy.ClientID = "some-other-client-id"
duplicateReq := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA, testCopy},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.hydrant")
require.Contains(t, errMsg, "name is already used by another Hydrant certificate authority")
// try to create hydrant with same name as another digicert
testCopy = goodHydrantCA
testCopy.Name = goodDigiCertCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA, testCopy},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
// try to create hydrant with same name as another custom scep
testCopy = goodHydrantCA
testCopy.Name = goodCustomSCEPCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA, testCopy},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
// try to create hydrant with same name as another smallstep
testCopy = goodHydrantCA
testCopy.Name = goodSmallstepCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA, testCopy},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
})
// TODO(hca): hydrant happy path and other specific tests
})
t.Run("custom est", func(t *testing.T) {
// run common invalid name test cases
t.Run("invalid name", func(t *testing.T) {
for _, tc := range invalidNameTestCases {
t.Run(tc.testName, func(t *testing.T) {
testCopy := goodESTCA
testCopy.Name = tc.name
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.custom_est_proxy")
require.Contains(t, errMsg, tc.errMessage)
})
}
})
// run common invalid url test cases
t.Run("invalid url", func(t *testing.T) {
for _, tc := range invalidURLTestCases {
t.Run(tc.testName, func(t *testing.T) {
testCopy := goodESTCA
testCopy.URL = tc.url
req, err := newApplyRequest(testCopy, false)
require.NoError(t, err)
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.custom_est_proxy")
if tc.errMessage == "Invalid URL" {
require.Contains(t, errMsg, "Invalid EST URL")
} else {
require.Contains(t, errMsg, tc.errMessage)
}
})
}
})
// run additional duplicate name scenarios
t.Run("duplicate names", func(t *testing.T) {
// create one of each CA
req := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", req, http.StatusOK)
s.checkAppliedCAs(t, s.ds, req.CertificateAuthorities)
t.Cleanup(func() {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, _ = q.ExecContext(context.Background(), "DELETE FROM certificate_authorities")
return nil
})
})
// try to create est ca proxy with same name as another est ca
testCopy := goodESTCA
testCopy.Username = "some-other-client-id"
duplicateReq := batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA, testCopy},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
res := s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "certificate_authorities.custom_est_proxy")
require.Contains(t, errMsg, "name is already used by another Custom EST Proxy certificate authority")
// try to create a custom est ca with same name as another digicert
testCopy = goodESTCA
testCopy.Name = goodDigiCertCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA, testCopy},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
// try to create est with same name as another custom scep
testCopy = goodESTCA
testCopy.Name = goodCustomSCEPCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA, testCopy},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
// try to create eset ca with same name as another smallstep
testCopy = goodESTCA
testCopy.Name = goodSmallstepCA.Name
duplicateReq = batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
DigiCert: []fleet.DigiCertCA{goodDigiCertCA},
CustomScepProxy: []fleet.CustomSCEPProxyCA{goodCustomSCEPCA},
Hydrant: []fleet.HydrantCA{goodHydrantCA},
EST: []fleet.ESTProxyCA{goodESTCA, testCopy},
Smallstep: []fleet.SmallstepSCEPProxyCA{goodSmallstepCA},
},
DryRun: false,
}
s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", duplicateReq, http.StatusOK)
})
})
}
func (s *integrationMDMTestSuite) checkAppliedCAs(t *testing.T, ds fleet.Datastore, expectedCAs fleet.GroupedCertificateAuthorities) {
var gotResp getCertificateAuthoritiesSpecResponse
s.DoJSON("GET", "/api/v1/fleet/spec/certificate_authorities?include_secrets=true", nil, http.StatusOK, &gotResp)
gotCAs := gotResp.CertificateAuthorities
if len(expectedCAs.DigiCert) != 0 {
assert.Len(t, gotCAs.DigiCert, len(expectedCAs.DigiCert))
wantByName := make(map[string]fleet.DigiCertCA)
gotByName := make(map[string]fleet.DigiCertCA)
for _, ca := range expectedCAs.DigiCert {
wantByName[ca.Name] = ca
}
for _, ca := range gotCAs.DigiCert {
ca.ID = 0 // ignore IDs when comparing
gotByName[ca.Name] = ca
}
assert.Equal(t, wantByName, gotByName)
} else {
assert.Empty(t, gotCAs.DigiCert)
}
if len(expectedCAs.CustomScepProxy) != 0 {
assert.Len(t, gotCAs.CustomScepProxy, len(expectedCAs.CustomScepProxy))
wantByName := make(map[string]fleet.CustomSCEPProxyCA)
gotByName := make(map[string]fleet.CustomSCEPProxyCA)
for _, ca := range expectedCAs.CustomScepProxy {
wantByName[ca.Name] = ca
}
for _, ca := range gotCAs.CustomScepProxy {
ca.ID = 0 // ignore IDs when comparing
gotByName[ca.Name] = ca
}
assert.Equal(t, wantByName, gotByName)
} else {
assert.Empty(t, gotCAs.CustomScepProxy)
}
if len(expectedCAs.Hydrant) != 0 {
assert.Len(t, gotCAs.Hydrant, len(expectedCAs.Hydrant))
wantByName := make(map[string]fleet.HydrantCA)
gotByName := make(map[string]fleet.HydrantCA)
for _, ca := range expectedCAs.Hydrant {
wantByName[ca.Name] = ca
}
for _, ca := range gotCAs.Hydrant {
ca.ID = 0 // ignore IDs when comparing
gotByName[ca.Name] = ca
}
assert.Equal(t, wantByName, gotByName)
} else {
assert.Empty(t, gotCAs.Hydrant)
}
if len(expectedCAs.EST) != 0 {
assert.Len(t, gotCAs.EST, len(expectedCAs.EST))
wantByName := make(map[string]fleet.ESTProxyCA)
gotByName := make(map[string]fleet.ESTProxyCA)
for _, ca := range expectedCAs.EST {
wantByName[ca.Name] = ca
}
for _, ca := range gotCAs.EST {
ca.ID = 0 // ignore IDs when comparing
gotByName[ca.Name] = ca
}
assert.Equal(t, wantByName, gotByName)
} else {
assert.Empty(t, gotCAs.EST)
}
if expectedCAs.NDESSCEP != nil {
assert.NotNil(t, gotCAs.NDESSCEP)
gotCAs.NDESSCEP.ID = 0 // ignore ID when comparing
assert.Equal(t, expectedCAs.NDESSCEP, gotCAs.NDESSCEP)
} else {
assert.Empty(t, gotCAs.NDESSCEP)
}
}
func (s *integrationMDMTestSuite) TestSCEPChallengeExpirationRetriesSmallStep() {
t := s.T()
ctx := context.Background()
s.setSkipWorkerJobs(t)
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Test setup
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// setup: create enroll secret, host, enroll to MDM
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
defaultProfiles := [][]byte{
setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil),
setupExpectedCAProfile(t, s.ds),
}
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setupPusher(s, t, mdmDevice)
s.awaitTriggerProfileSchedule(t)
installs, removes := checkNextPayloads(t, mdmDevice, false)
s.signedProfilesMatch(
defaultProfiles,
installs,
)
require.Empty(t, removes)
// setup: start smallstep scep server
scepServer := scep_server.StartTestSCEPServer(t)
// setup: start mock challenge server that returns new challenge value on each request
challengeCounter := atomic.Int64{}
challengeValue := atomic.Value{}
challengeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
challengeCounter.Add(1)
newChallengeValue := uuid.New().String()
challengeValue.Store(newChallengeValue)
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte(newChallengeValue))
require.NoError(t, err)
}))
t.Cleanup(func() {
challengeServer.Close()
})
// setup: create smallstep CA in Fleet that uses the mock servers
caName := "STEP_WIFI"
_ = s.Do("POST", "/api/v1/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesRequest{
CertificateAuthorities: fleet.GroupedCertificateAuthorities{
Smallstep: []fleet.SmallstepSCEPProxyCA{
{
Name: caName,
URL: scepServer.URL + "/scep",
ChallengeURL: challengeServer.URL,
Username: "testuser",
Password: "testpassword",
},
},
},
DryRun: false,
}, http.StatusOK)
require.NoError(t, err)
require.Equal(t, int64(1), challengeCounter.Load()) // challenge endpoint called once during CA creation
// setup: create a configuration profile that uses the smallstep CA for SCEP
var profUUID string
p := generateTestProfileSmallstepSCEP("$FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_STEP_WIFI", "$FLEET_VAR_SCEP_RENEWAL_ID", "$FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_STEP_WIFI")
body, headers := generateNewProfileMultipartRequest(t, "foobar.mobileconfig", []byte(p), s.token, nil)
_ = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &profUUID, "SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE name = ?", "Smallstep Fleet WIFI")
})
// scepProfileURL is the expected SCEP profile URL after variable substitution (see preprocessProfileContents for details)
scepProfileURL := fmt.Sprintf("%s%s%s", s.server.URL, apple_mdm.SCEPProxyPath,
url.PathEscape(fmt.Sprintf("%s,%s,%s", host.UUID, profUUID, caName)))
// expectPayloadWithChallenge executes the certificate profile template with current challenge value and other Fleet variables
expectPayloadWithChallenge := func() string {
challengeVal, ok := challengeValue.Load().(string)
require.True(t, ok, "challenge value not set")
return generateTestProfileSmallstepSCEP(
challengeVal,
"fleet-"+profUUID,
scepProfileURL,
)
}
// parseCommandPayload extracts and returns the profile payload from an InstallProfile command
parseCommandPayload := func(cmd *mdm.Command) string {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload)
require.NoError(t, err)
return string(p7.Content)
}
// hostProfile represents the relevant fields from host_mdm_apple_profiles table for verification
type hostProfile struct {
ProfileUUID string `db:"profile_uuid"`
ProfileIdentifier string `db:"profile_identifier"`
ProfileName string `db:"profile_name"`
Status *string `db:"status"`
OperationType *string `db:"operation_type"`
Retries int `db:"retries"`
CommandUUID string `db:"command_uuid"`
}
// listHostProfilesDB lists the host profiles for a given host from the database
listHostProfilesDB := func(hostUUID string) []hostProfile {
var got []hostProfile
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
// for the purpose of this test, we ignore the Fleet-internal profiles
// (we only care about the custom profiles)
return sqlx.SelectContext(t.Context(), q, &got, `
SELECT profile_uuid, profile_identifier, profile_name, status, operation_type, retries, command_uuid
FROM host_mdm_apple_profiles
WHERE host_uuid = ? AND profile_identifier NOT IN (?, ?)`,
hostUUID, mobileconfig.FleetdConfigPayloadIdentifier, mobileconfig.FleetCARootConfigPayloadIdentifier)
})
return got
}
// expectHostProf represents the expected host profile entry in the database; we'll update fields as we progress through the test
expectHostProf := hostProfile{
ProfileUUID: profUUID, // should never change
ProfileIdentifier: "Smallstep Fleet WIFI", // should never change
ProfileName: "Smallstep Fleet WIFI", // should never change
OperationType: ptr.String("install"), // should never change
Status: nil, // status is a key part of the test progression
Retries: 0, // retries is a key part of the test progression
CommandUUID: "", // command UUID is a key part of the test progression
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Test scenarios
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
s.awaitTriggerProfileSchedule(t)
require.Equal(t, int64(2), challengeCounter.Load()) // challenge endpoint called during host profile reconciliation
// MDM checkin should expect InstallProfile command with SCEP profile
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
prevCommandUUID := cmd.CommandUUID // save for later comparison
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
// verify that the install profile command contains the expected payload, including the expected challenge
require.Equal(t, expectPayloadWithChallenge(), parseCommandPayload(cmd))
// update expectations for host profile DB state
expectHostProf.CommandUUID = cmd.CommandUUID
expectHostProf.Status = ptr.String("pending")
expectHostProf.Retries = 0 // initial install attempt
// check DB state
gotHostProfs := listHostProfilesDB(host.UUID)
require.Len(t, gotHostProfs, 1)
require.Equal(t, expectHostProf, gotHostProfs[0])
// simulate random failure during SCEP protocol
cmd, err = mdmDevice.Err(prevCommandUUID, []mdm.ErrorChain{})
require.NoError(t, err) // error report accepted by server
require.Nil(t, cmd) // no new command should be issued yet
// update expectations for host profile DB state after failure
expectHostProf.CommandUUID = prevCommandUUID // unchanged
expectHostProf.Status = nil // status should be cleared to allow retry
expectHostProf.Retries = 1 // retries should be incremented
// check DB state after failure
gotHostProfs = listHostProfilesDB(host.UUID)
require.Len(t, gotHostProfs, 1)
require.Equal(t, expectHostProf, gotHostProfs[0])
// MDM checkin should not expect a new command yet
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// trigger another profile sync, which should resend SCEP profile
require.Equal(t, int64(2), challengeCounter.Load()) // challenge endpoint not called until reconcilation runs
s.awaitTriggerProfileSchedule(t)
require.Equal(t, int64(3), challengeCounter.Load()) // challenge endpoint called with host profile reconciliation
// MDM checkin should expect InstallProfile command with SCEP profile with new challenge
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.NotEqual(t, prevCommandUUID, cmd.CommandUUID) // new command UUID
prevCommandUUID = cmd.CommandUUID // save for later comparison
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
require.Equal(t, expectPayloadWithChallenge(), parseCommandPayload(cmd)) // challenge value should be updated
// update expectations for host profile DB state
expectHostProf.CommandUUID = cmd.CommandUUID // should be updated to new command UUID
expectHostProf.Status = ptr.String("pending") // should now be pending again
expectHostProf.Retries = 1 // unchanged
// check DB state
gotHostProfs = listHostProfilesDB(host.UUID)
require.Len(t, gotHostProfs, 1)
require.Equal(t, expectHostProf, gotHostProfs[0])
// simulate another failure during SCEP protocol, this time it won't be retried because normal retry limit is 1
cmd, err = mdmDevice.Err(prevCommandUUID, []mdm.ErrorChain{})
require.NoError(t, err) // error report accepted by server
require.Nil(t, cmd) // no new command
// update expectations for host profile DB state after failure
expectHostProf.CommandUUID = prevCommandUUID // unchanged
expectHostProf.Status = ptr.String("failed") // should now be failed
expectHostProf.Retries = 1 // unchanged
// check DB state after failure
gotHostProfs = listHostProfilesDB(host.UUID)
require.Len(t, gotHostProfs, 1)
require.Equal(t, expectHostProf, gotHostProfs[0])
// MDM checkin should not expect new command
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// trigger another profile sync, which should not resend SCEP profile
s.awaitTriggerProfileSchedule(t)
require.Equal(t, int64(3), challengeCounter.Load()) // challenge endpoint not called again because no retry should be attempted
// MDM checkin should not expect new command
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// check DB state to confirm no changes
gotHostProfs = listHostProfilesDB(host.UUID)
require.Len(t, gotHostProfs, 1)
require.Equal(t, expectHostProf, gotHostProfs[0])
// manually resend the profile installation, which ignores retry limit
// FIXME: manual resend doesn't change retries, but maybe it should reset to 0
_ = s.Do("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, profUUID), nil, http.StatusAccepted)
require.Equal(t, int64(3), challengeCounter.Load()) // challenge endpoint not called until reconcilation runs
// MDM checkin should not expect new command until reconciliation runs
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd) // no new command should be issued yet
// update expectations for host profile DB state after manual resend
expectHostProf.Status = nil // status should be cleared to allow retry
expectHostProf.Retries = 1 // unchanged for manual resend
expectHostProf.CommandUUID = prevCommandUUID // unchanged until reconcilation runs
// check DB state after manual resend request
gotHostProfs = listHostProfilesDB(host.UUID)
require.Len(t, gotHostProfs, 1)
require.Equal(t, expectHostProf, gotHostProfs[0])
// trigger another profile sync, which should resend SCEP profile
require.Equal(t, int64(3), challengeCounter.Load()) // challenge endpoint not called until reconcilation runs
s.awaitTriggerProfileSchedule(t)
require.Equal(t, int64(4), challengeCounter.Load()) // challenge endpoint called again during host profile reconciliation
// MDM checkin should expect InstallProfile command with SCEP profile with new challenge
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.NotEqual(t, prevCommandUUID, cmd.CommandUUID) // new command UUID
prevCommandUUID = cmd.CommandUUID // save for later comparison
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
require.Equal(t, expectPayloadWithChallenge(), parseCommandPayload(cmd)) // challenge value should be updated
// update expectations for host profile DB state
expectHostProf.CommandUUID = cmd.CommandUUID // should be updated to new command UUID
expectHostProf.Status = ptr.String("pending") // should now be pending again
expectHostProf.Retries = 1 // unchanged for manual resend
// check DB state
gotHostProfs = listHostProfilesDB(host.UUID)
require.Len(t, gotHostProfs, 1)
require.Equal(t, expectHostProf, gotHostProfs[0])
// simulate challenge expiration by backdating challenge_retrieved_at
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, _ = q.ExecContext(context.Background(), "UPDATE host_mdm_managed_certificates SET challenge_retrieved_at = DATE_SUB(challenge_retrieved_at, INTERVAL 270 SECOND) WHERE host_uuid = ?", host.UUID)
return nil
})
// simulate MDM client sending SCEP request after challenge has expired
resp, err := http.Get(scepProfileURL + "?operation=PKIOperation&message=" + base64.URLEncoding.EncodeToString([]byte("dummy")))
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Contains(t, extractServerErrorText(resp.Body), "challenge password has expired") // Fleet intercepts the SCEP request and returns an error
// expired challenge should cause retries to be reset and command UUID cleared in DB
expectHostProf.Status = nil // status should be cleared to allow retry
expectHostProf.Retries = 0 // retries should be reset to 0
expectHostProf.CommandUUID = "" // command UUID should be cleared
gotHostProfs = listHostProfilesDB(host.UUID)
require.Len(t, gotHostProfs, 1)
require.Equal(t, expectHostProf, gotHostProfs[0])
// MDM client reports error for the last InstallProfile command, which doesn't impact retries
// because the command UUID was cleared when challenge expired
cmd, err = mdmDevice.Err(prevCommandUUID, []mdm.ErrorChain{})
require.NoError(t, err) // server accepted the error report
require.Nil(t, cmd) // no new command should be issued yet
// reported error should not impact host profile DB state because command UUID was cleared when challenge expired
gotHostProfs = listHostProfilesDB(host.UUID)
require.Len(t, gotHostProfs, 1)
require.Equal(t, expectHostProf, gotHostProfs[0])
// MDM checkin should not expect new command until reconciliation runs
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// trigger another profile sync, which should resend the SCEP profile installation
require.Equal(t, int64(4), challengeCounter.Load()) // challenge endpoint not called until reconcilation runs
s.awaitTriggerProfileSchedule(t)
require.Equal(t, int64(5), challengeCounter.Load()) // challenge endpoint called with host profile reconciliation
// MDM checkin should expect InstallProfile command with SCEP profile with new challenge
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
require.NotEqual(t, prevCommandUUID, cmd.CommandUUID)
// prevCommandUUID = cmd.CommandUUID // save for later comparison
require.Equal(t, expectPayloadWithChallenge(), parseCommandPayload(cmd)) // challenge value should be updated
// verify that host profile DB state reflects new InstallProfile command
expectHostProf.Status = ptr.String("pending") // should now be pending again
expectHostProf.Retries = 0 // unchanged
expectHostProf.CommandUUID = cmd.CommandUUID // should be updated to new command UUID
gotHostProfs = listHostProfilesDB(host.UUID)
require.Len(t, gotHostProfs, 1)
require.Equal(t, expectHostProf, gotHostProfs[0])
}
func generateTestProfileSmallstepSCEP(challenge, ou, url string) string {
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadContent</key>
<dict>
<key>Challenge</key>
<string>%s</string>
<key>Key Type</key>
<string>RSA</string>
<key>Key Usage</key>
<integer>5</integer>
<key>Keysize</key>
<integer>2048</integer>
<key>Subject</key>
<array>
<array>
<array>
<string>CN</string>
<string>SerialNumber WIFI</string>
</array>
</array>
<array>
<array>
<string>OU</string>
<string>%s</string>
</array>
</array>
</array>
<key>URL</key>
<string>%s</string>
</dict>
<key>PayloadDisplayName</key>
<string>WIFI SCEP</string>
<key>PayloadIdentifier</key>
<string>com.apple.security.scep.9DCC35A5-72F9-42B7-9A98-7AD9A9CCA3AE</string>
<key>PayloadType</key>
<string>com.apple.security.scep</string>
<key>PayloadUUID</key>
<string>9DCC35A5-72F9-42B7-9A98-7AD9A9CCA3AE</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>Smallstep Fleet WIFI</string>
<key>PayloadIdentifier</key>
<string>Smallstep Fleet WIFI</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>4CD1BD65-1D2C-4E9E-9E18-9BCD400CDEDE</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`, challenge, ou, url)
}