fleet/server/service/integration_certificate_authorities_test.go
Magnus Jensen a8c9e261d7
speed up macOS profile delivery for initial enrollments (#41960)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #34433 

It speeds up the cron, meaning fleetd, bootstrap and now profiles should
be sent within 10 seconds of being known to fleet, compared to the
previous 1 minute.

It's heavily based on my last PR, so the structure and changes are close
to identical, with some small differences.
**I did not do the redis key part in this PR, as I think that should
come in it's own PR, to avoid overlooking logic bugs with that code, and
since this one is already quite sized since we're moving core pieces of
code around.**

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.


## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Faster macOS onboarding: device profiles are delivered and installed
as part of DEP enrollment, shortening initial setup.
* Improved profile handling: per-host profile preprocessing, secret
detection, and clearer failure marking.

* **Improvements**
  * Consolidated SCEP/NDES error messaging for clearer diagnostics.
  * Cron/work scheduling tuned to prioritize Apple MDM profile delivery.

* **Tests**
* Expanded MDM unit and integration tests, including
DeclarativeManagement handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-03-19 14:58:10 -05:00

1973 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"
"github.com/fleetdm/fleet/v4/ee/server/service/scep"
"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 := scep.NewTestSCEPServer(t)
ndesAdminServer := scep.NewTestNDESAdminServer(t, "mscep_admin_password", http.StatusOK)
dynamicChallengeServer := scep.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) {
// Verify that the request includes Authorization header with Basic auth
authHeader := r.Header.Get("Authorization")
require.NotEmpty(t, authHeader, "Authorization header should be present")
require.Contains(t, authHeader, "Basic ", "Authorization header should use Basic auth")
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)
}