<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves https://github.com/fleetdm/confidential/issues/16880 # 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 ## Summary by CodeRabbit * **Bug Fixes** * Secured the Windows MDM Terms of Service endpoint against reflected cross-site scripting. * Strengthened validation for the `redirect_uri` used in Terms of Service rendering, allowing only approved `https` and `ms-appx-web` schemes. * Unsafe, malformed, or non-allowlisted redirect values are now rejected and not displayed. * **Tests** * Added integration and unit coverage to verify unsafe redirects are blocked while valid ones continue to work. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Sharon Katz <121527325+sharon-fdm@users.noreply.github.com>
2535 lines
113 KiB
Go
2535 lines
113 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5" //nolint:gosec // Windows MDM Auth uses MD5
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
|
|
"github.com/fleetdm/fleet/v4/server/mock"
|
|
"github.com/fleetdm/fleet/v4/server/platform/logging/testutils"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// NewSoapRequest takes a SOAP request in the form of a byte slice and tries to unmarshal it into a SoapRequest struct.
|
|
func NewSoapRequest(request []byte) (fleet.SoapRequest, error) {
|
|
// Sanity check on input
|
|
if len(request) == 0 {
|
|
return fleet.SoapRequest{}, errors.New("soap request is invalid")
|
|
}
|
|
|
|
// Unmarshal the XML data from the request into the SoapRequest struct
|
|
var req fleet.SoapRequest
|
|
err := xml.Unmarshal(request, &req)
|
|
if err != nil {
|
|
return req, fmt.Errorf("there was a problem unmarshalling soap request: %v", err)
|
|
}
|
|
|
|
// If there was no error, return the SoapRequest and a nil error
|
|
return req, nil
|
|
}
|
|
|
|
func TestValidSoapResponse(t *testing.T) {
|
|
relatesTo := "urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749"
|
|
soapFaultMsg := NewSoapFault(syncml.SoapErrorAuthentication, fleet.MDEDiscovery, errors.New("test"))
|
|
sres, err := NewSoapResponse(&soapFaultMsg, relatesTo)
|
|
require.NoError(t, err)
|
|
outXML, err := xml.MarshalIndent(sres, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
require.Contains(t, string(outXML), fmt.Sprintf("<a:RelatesTo>%s</a:RelatesTo>", relatesTo))
|
|
}
|
|
|
|
func TestInvalidSoapResponse(t *testing.T) {
|
|
relatesTo := "urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749"
|
|
_, err := NewSoapResponse(relatesTo, relatesTo)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestFaultMessageSoapResponse(t *testing.T) {
|
|
targetErrorString := "invalid input request"
|
|
soapFaultMsg := NewSoapFault(syncml.SoapErrorAuthentication, fleet.MDEDiscovery, errors.New(targetErrorString))
|
|
sres, err := NewSoapResponse(&soapFaultMsg, "urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749")
|
|
require.NoError(t, err)
|
|
outXML, err := xml.MarshalIndent(sres, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
require.Contains(t, string(outXML), fmt.Sprintf("<s:text xml:lang=\"en-us\">%s</s:text>", targetErrorString))
|
|
}
|
|
|
|
// func NewRequestSecurityTokenResponseCollection(provisionedToken string) (fleet.RequestSecurityTokenResponseCollection, error) {
|
|
func TestRequestSecurityTokenResponseCollectionSoapResponse(t *testing.T) {
|
|
provisionedToken := "provisionedToken"
|
|
reqSecTokenCollectionMsg, err := NewRequestSecurityTokenResponseCollection(provisionedToken)
|
|
require.NoError(t, err)
|
|
sres, err := NewSoapResponse(&reqSecTokenCollectionMsg, "urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749")
|
|
require.NoError(t, err)
|
|
outXML, err := xml.MarshalIndent(sres, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
require.Contains(t, string(outXML), fmt.Sprintf("base64binary\">%s</BinarySecurityToken>", provisionedToken))
|
|
}
|
|
|
|
func TestGetPoliciesResponseSoapResponse(t *testing.T) {
|
|
minKey := "2048"
|
|
getPoliciesMsg, err := NewGetPoliciesResponse(minKey, "10", "20")
|
|
require.NoError(t, err)
|
|
sres, err := NewSoapResponse(&getPoliciesMsg, "urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749")
|
|
require.NoError(t, err)
|
|
outXML, err := xml.MarshalIndent(sres, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
require.Contains(t, string(outXML), fmt.Sprintf("<minimalKeyLength>%s</minimalKeyLength>", minKey))
|
|
}
|
|
|
|
func TestValidSoapRequestWithDiscoverMsg(t *testing.T) {
|
|
requestBytes := []byte(`
|
|
<s:Envelope xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
|
<s:Header>
|
|
<a:Action s:mustUnderstand="1">http://schemas.microsoft.com/windows/management/2012/01/enrollment/IDiscoveryService/Discover</a:Action>
|
|
<a:MessageID>urn:uuid:748132ec-a575-4329-b01b-6171a9cf8478</a:MessageID>
|
|
<a:ReplyTo>
|
|
<a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>
|
|
</a:ReplyTo>
|
|
<a:To s:mustUnderstand="1">https://mdmwindows.com:443/EnrollmentServer/Discovery.svc</a:To>
|
|
</s:Header>
|
|
<s:Body>
|
|
<Discover xmlns="http://schemas.microsoft.com/windows/management/2012/01/enrollment">
|
|
<request xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
|
|
<EmailAddress>demo@mdmwindows.com</EmailAddress>
|
|
<RequestVersion>5.0</RequestVersion>
|
|
<DeviceType>CIMClient_Windows</DeviceType>
|
|
<ApplicationVersion>6.2.9200.2965</ApplicationVersion>
|
|
<OSEdition>48</OSEdition>
|
|
<AuthPolicies>
|
|
<AuthPolicy>OnPremise</AuthPolicy>
|
|
<AuthPolicy>Federated</AuthPolicy>
|
|
</AuthPolicies>
|
|
</request>
|
|
</Discover>
|
|
</s:Body>
|
|
</s:Envelope>
|
|
`)
|
|
|
|
req, err := NewSoapRequest(requestBytes)
|
|
require.NoError(t, err)
|
|
err = req.IsValidDiscoveryMsg()
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestInvalidSoapRequestWithDiscoverMsg(t *testing.T) {
|
|
requestBytes := []byte(`
|
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wst="http://docs.oasis-open.org/ws-sx/ws-trust/200512" xmlns:ac="http://schemas.xmlsoap.org/ws/2006/12/authorization">
|
|
<s:Header>
|
|
<a:Action s:mustUnderstand="1">http://schemas.microsoft.com/windows/pki/2009/01/enrollment/RST/wstep</a:Action>
|
|
<a:MessageID>urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749</a:MessageID>
|
|
<a:ReplyTo>
|
|
<a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>
|
|
</a:ReplyTo>
|
|
<a:To s:mustUnderstand="1">https://mdmwindows.com/EnrollmentServer/Enrollment.svc</a:To>
|
|
<wsse:Security s:mustUnderstand="1">
|
|
<wsse:BinarySecurityToken ValueType="http://schemas.microsoft.com/5.0.0.0/ConfigurationManager/Enrollment/DeviceEnrollmentUserToken" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd#base64binary">aGVsbG93b3JsZA==</wsse:BinarySecurityToken>
|
|
</wsse:Security>
|
|
</s:Header>
|
|
<s:Body>
|
|
<wst:RequestSecurityToken>
|
|
<wst:TokenType>http://schemas.microsoft.com/5.0.0.0/ConfigurationManager/Enrollment/DeviceEnrollmentToken</wst:TokenType>
|
|
<wst:RequestType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue</wst:RequestType>
|
|
<wsse:BinarySecurityToken ValueType="http://schemas.microsoft.com/windows/pki/2009/01/enrollment#PKCS10" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd#base64binary">MIICzjCCAboCAQAwSzFJMEcGA1UEAxNAMkI5QjUyQUMtREYzOC00MTYxLTgxNDItRjRCMUUwIURCMjU3QzNBMDg3NzhGNEZCNjFFMjc0OTA2NkMxRjI3ADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKogsEpbKL8fuXpTNAE5RTZim8JO5CCpxj3z+SuWabs/s9Zse6RziKr12R4BXPiYE1zb8god4kXxet8x3ilGqAOoXKkdFTdNkdVa23PEMrIZSX5MuQ7mwGtctayARxmDvsWRF/icxJbqSO+bYIKvuifesOCHW2cJ1K+JSKijTMik1N8NFbLi5fg1J+xImT9dW1z2fLhQ7SNEMLosUPHsbU9WKoDBfnPsLHzmhM2IMw+5dICZRoxHZalh70FefBk0XoT8b6w4TIvc8572TyPvvdwhc5o/dvyR3nAwTmJpjBs1YhJfSdP+EBN1IC2T/i/mLNUuzUSC2OwiHPbZ6MMr/hUCAwEAAaBCMEAGCSqGSIb3DQEJDjEzMDEwLwYKKwYBBAGCN0IBAAQhREIyNTdDM0EwODc3OEY0RkI2MUUyNzQ5MDY2QzFGMjcAMAkGBSsOAwIdBQADggEBACQtxyy74sCQjZglwdh/Ggs6ofMvnWLMq9A9rGZyxAni66XqDUoOg5PzRtSt+Gv5vdLQyjsBYVzo42W2HCXLD2sErXWwh/w0k4H7vcRKgEqv6VYzpZ/YRVaewLYPcqo4g9NoXnbW345OPLwT3wFvVR5v7HnD8LB2wHcnMu0fAQORgafCRWJL1lgw8VZRaGw9BwQXCF/OrBNJP1ivgqtRdbSoH9TD4zivlFFa+8VDz76y2mpfo0NbbD+P0mh4r0FOJan3X9bLswOLFD6oTiyXHgcVSzLN0bQ6aQo0qKp3yFZYc8W4SgGdEl07IqNquKqJ/1fvmWxnXEbl3jXwb1efhbM=</wsse:BinarySecurityToken>
|
|
<ac:AdditionalContext xmlns="http://schemas.xmlsoap.org/ws/2006/12/authorization">
|
|
<ac:ContextItem Name="UXInitiated">
|
|
<ac:Value>false</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="HWDevID">
|
|
<ac:Value>BF2D12A95AE42E47D58465E9A71336CAF33FCCAD3088F140F4D50B371FB2256F</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="Locale">
|
|
<ac:Value>en-US</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="TargetedUserLoggedIn">
|
|
<ac:Value>true</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="OSEdition">
|
|
<ac:Value>48</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="DeviceName">
|
|
<ac:Value>DESKTOP-0C89RC0</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="MAC">
|
|
<ac:Value>00-0C-29-7B-4E-4C</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="MAC">
|
|
<ac:Value>00-0C-29-7B-4E-56</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="DeviceID">
|
|
<ac:Value>DB257C3A08778F4FB61E2749066C1F27</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="EnrollmentType">
|
|
<ac:Value>Full</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="DeviceType">
|
|
<ac:Value>CIMClient_Windows</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="OSVersion">
|
|
<ac:Value>10.0.19045.2965</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="ApplicationVersion">
|
|
<ac:Value>10.0.19045.2965</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="NotInOobe">
|
|
<ac:Value>false</ac:Value>
|
|
</ac:ContextItem>
|
|
<ac:ContextItem Name="RequestVersion">
|
|
<ac:Value>5.0</ac:Value>
|
|
</ac:ContextItem>
|
|
</ac:AdditionalContext>
|
|
</wst:RequestSecurityToken>
|
|
</s:Body>
|
|
</s:Envelope>
|
|
`)
|
|
|
|
req, err := NewSoapRequest(requestBytes)
|
|
require.NoError(t, err)
|
|
err = req.IsValidDiscoveryMsg()
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// TestRejectUnsupportedAuth verifies that a policy/enroll request following the OnPremise auth policy with a
|
|
// <wsse:UsernameToken> (username + plaintext password) is turned into an actionable fault, while other token errors pass
|
|
// through unchanged.
|
|
func TestRejectUnsupportedAuth(t *testing.T) {
|
|
sentinel := errors.New("binarySecurityToken is empty")
|
|
|
|
header := func(security string) []byte {
|
|
return []byte(`
|
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
|
|
<s:Header>
|
|
<a:Action s:mustUnderstand="1">http://schemas.microsoft.com/windows/pki/2009/01/enrollmentpolicy/IPolicy/GetPolicies</a:Action>
|
|
<a:MessageID>urn:uuid:148132ec-a575-4322-b01b-6172a9cf8478</a:MessageID>
|
|
<a:To s:mustUnderstand="1">https://mdmwindows.com/EnrollmentServer/Policy.svc</a:To>` + security + `
|
|
</s:Header>
|
|
</s:Envelope>`)
|
|
}
|
|
|
|
const secretPassword = "SuperSecret-PlaintextPassword"
|
|
usernameToken := `
|
|
<wsse:Security s:mustUnderstand="1">
|
|
<wsse:UsernameToken>
|
|
<wsse:Username>user@example.com</wsse:Username>
|
|
<wsse:Password>` + secretPassword + `</wsse:Password>
|
|
</wsse:UsernameToken>
|
|
</wsse:Security>`
|
|
binarySecurityToken := `
|
|
<wsse:Security s:mustUnderstand="1">
|
|
<wsse:BinarySecurityToken ValueType="` + syncml.BinarySecurityAzureEnroll + `" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd#base64binary">dG9rZW4=</wsse:BinarySecurityToken>
|
|
</wsse:Security>`
|
|
// A present-but-empty BinarySecurityToken element (ValueType/EncodingType set, no content) is a genuine empty-token
|
|
// error, not OnPremise auth, so it must keep the original "binarySecurityToken is empty" error.
|
|
emptyBinarySecurityToken := `
|
|
<wsse:Security s:mustUnderstand="1">
|
|
<wsse:BinarySecurityToken ValueType="` + syncml.BinarySecurityAzureEnroll + `" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd#base64binary"></wsse:BinarySecurityToken>
|
|
</wsse:Security>`
|
|
|
|
testCases := []struct {
|
|
name string
|
|
security string
|
|
wantActionableMsg bool
|
|
}{
|
|
{name: "username/password (OnPremise) is rejected with actionable message", security: usernameToken, wantActionableMsg: true},
|
|
{name: "binary security token passes the original error through", security: binarySecurityToken, wantActionableMsg: false},
|
|
{name: "present-but-empty binary security token passes the original error through", security: emptyBinarySecurityToken, wantActionableMsg: false},
|
|
{name: "missing security header passes the original error through", security: "", wantActionableMsg: false},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
raw := header(tc.security)
|
|
req, err := NewSoapRequest(raw)
|
|
require.NoError(t, err)
|
|
req.Raw = raw // DecodeBody populates Raw in production; the NewSoapRequest test helper does not.
|
|
|
|
got := rejectUnsupportedAuth(&req, sentinel)
|
|
|
|
if tc.wantActionableMsg {
|
|
require.Contains(t, got.Error(), "is not supported")
|
|
require.Contains(t, got.Error(), "Microsoft Entra ID")
|
|
// The plaintext password must never be echoed back into the fault (and therefore the logs).
|
|
require.NotContains(t, got.Error(), secretPassword)
|
|
} else {
|
|
require.Equal(t, sentinel, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProvisioningDocGeneration(t *testing.T) {
|
|
deviceIdentityFingerprint := "031336C933CC7E228B88880D78824FB2909A0A2F"
|
|
serverIdentityFingerprint := "F9A4F20FC50D990FDD0E3DB9AFCBF401818D5462"
|
|
|
|
// Preparing the WAP Provisioning Doc response
|
|
certStoreData := NewCertStoreProvisioningData(
|
|
"full",
|
|
deviceIdentityFingerprint,
|
|
[]byte{0x1, 0x2, 0x3},
|
|
serverIdentityFingerprint,
|
|
[]byte{0x4, 0x5, 0x6})
|
|
|
|
// Preparing the WAP Provisioning Doc response
|
|
appConfigData := NewApplicationProvisioningData(microsoft_mdm.MDE2EnrollPath, "testuser", "testpassword")
|
|
appDMClientData := NewDMClientProvisioningData()
|
|
provDoc := NewProvisioningDoc(certStoreData, appConfigData, appDMClientData)
|
|
|
|
outXML, err := xml.MarshalIndent(provDoc, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
require.Contains(t, string(outXML), deviceIdentityFingerprint)
|
|
require.Contains(t, string(outXML), serverIdentityFingerprint)
|
|
require.Contains(t, string(outXML), microsoft_mdm.MDE2EnrollPath)
|
|
require.Contains(t, string(outXML), "testuser")
|
|
require.Contains(t, string(outXML), "testpassword")
|
|
}
|
|
|
|
func TestValidSyncMLCmdStatus(t *testing.T) {
|
|
testMsgRef := "testmsgref"
|
|
testCmdRef := "testcmdref"
|
|
testCmdOrig := "testcmdorig"
|
|
testStatusCode := "teststatuscode"
|
|
cmdMsg := NewSyncMLCmdStatus(testMsgRef, testCmdRef, testCmdOrig, testStatusCode)
|
|
outXML, err := xml.MarshalIndent(cmdMsg, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
payload := string(outXML)
|
|
err = checkWrappedSyncMLCmd(fleet.CmdStatus, payload)
|
|
require.NoError(t, err)
|
|
require.Contains(t, payload, fmt.Sprintf("<MsgRef>%s</MsgRef>", testMsgRef))
|
|
require.Contains(t, payload, fmt.Sprintf("<CmdRef>%s</CmdRef>", testCmdRef))
|
|
require.Contains(t, payload, fmt.Sprintf("<Cmd>%s</Cmd>", testCmdOrig))
|
|
require.Contains(t, payload, fmt.Sprintf("<Data>%s</Data>", testStatusCode))
|
|
}
|
|
|
|
func TestValidNewSyncMLCmdGet(t *testing.T) {
|
|
testOmaURI := "testuri"
|
|
cmdMsg := newSyncMLNoFormat(fleet.CmdGet, testOmaURI)
|
|
outXML, err := xml.MarshalIndent(cmdMsg, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
payload := string(outXML)
|
|
err = checkWrappedSyncMLCmd(fleet.CmdGet, payload)
|
|
require.NoError(t, err)
|
|
require.Contains(t, payload, fmt.Sprintf("<LocURI>%s</LocURI>", testOmaURI))
|
|
}
|
|
|
|
func TestValidNewSyncMLCmdBool(t *testing.T) {
|
|
testOmaURI := "testuri"
|
|
testData := "testdata"
|
|
cmdMsg := newSyncMLCmdBool(fleet.CmdReplace, testOmaURI, testData)
|
|
outXML, err := xml.MarshalIndent(cmdMsg, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
payload := string(outXML)
|
|
err = checkWrappedSyncMLCmd(fleet.CmdReplace, payload)
|
|
require.NoError(t, err)
|
|
require.Contains(t, payload, fmt.Sprintf("<LocURI>%s</LocURI>", testOmaURI))
|
|
require.Contains(t, payload, fmt.Sprintf("<Data>%s</Data>", testData))
|
|
require.Contains(t, payload, "<Type xmlns=\"syncml:metinf\">text/plain</Type>")
|
|
require.Contains(t, payload, "<Format xmlns=\"syncml:metinf\">bool</Format>")
|
|
}
|
|
|
|
func TestValidNewSyncMLCmdInt(t *testing.T) {
|
|
testOmaURI := "testuri"
|
|
testData := "testdata"
|
|
cmdMsg := newSyncMLCmdInt(fleet.CmdReplace, testOmaURI, testData)
|
|
outXML, err := xml.MarshalIndent(cmdMsg, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
payload := string(outXML)
|
|
err = checkWrappedSyncMLCmd(fleet.CmdReplace, payload)
|
|
require.NoError(t, err)
|
|
require.Contains(t, payload, fmt.Sprintf("<LocURI>%s</LocURI>", testOmaURI))
|
|
require.Contains(t, payload, fmt.Sprintf("<Data>%s</Data>", testData))
|
|
require.Contains(t, payload, "<Type xmlns=\"syncml:metinf\">text/plain</Type>")
|
|
require.Contains(t, payload, "<Format xmlns=\"syncml:metinf\">int</Format>")
|
|
}
|
|
|
|
func TestValidSyncMLCmdText(t *testing.T) {
|
|
testOmaURI := "testuri"
|
|
testData := "testdata"
|
|
cmdMsg := newSyncMLCmdText(fleet.CmdReplace, testOmaURI, testData)
|
|
outXML, err := xml.MarshalIndent(cmdMsg, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
payload := string(outXML)
|
|
err = checkWrappedSyncMLCmd(fleet.CmdReplace, payload)
|
|
require.NoError(t, err)
|
|
require.Contains(t, payload, fmt.Sprintf("<LocURI>%s</LocURI>", testOmaURI))
|
|
require.Contains(t, payload, fmt.Sprintf("<Data>%s</Data>", testData))
|
|
require.Contains(t, payload, "<Type xmlns=\"syncml:metinf\">text/plain</Type>")
|
|
require.Contains(t, payload, "<Format xmlns=\"syncml:metinf\">chr</Format>")
|
|
}
|
|
|
|
func TestSyncMLCmdTextEscapesXMLMetacharacters(t *testing.T) {
|
|
t.Parallel()
|
|
cmdMsg := newSyncMLCmdText(fleet.CmdReplace, "testuri", `AT&T <Reader>`)
|
|
outXML, err := xml.MarshalIndent(cmdMsg, "", " ")
|
|
require.NoError(t, err)
|
|
payload := string(outXML)
|
|
|
|
// The marshaled XML must be well-formed (parseable) and carry escaped entities, not raw metacharacters. A raw
|
|
// "&"/"<" here would make xml.Unmarshal fail, which is exactly the device-side rejection we are preventing.
|
|
require.NoError(t, xml.Unmarshal(outXML, new(fleet.SyncMLCmd)), "escaped command must be well-formed XML")
|
|
require.Contains(t, payload, "AT&T")
|
|
require.Contains(t, payload, "<Reader>")
|
|
require.NotContains(t, payload, "AT&T", "raw ampersand must not appear unescaped")
|
|
}
|
|
|
|
func TestWindowsTOSRedirectURIAllowed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
redirectURI string
|
|
want bool
|
|
}{
|
|
// Legitimate Autopilot/Entra broker callback and browser-based federated flows.
|
|
{"ms-appx-web broker callback", "ms-appx-web://Microsoft.AAD.BrokerPlugin", true},
|
|
{"ms-appx-web mixed case scheme", "MS-APPX-WEB://Microsoft.AAD.BrokerPlugin", true},
|
|
{"https url", "https://enroll.example.com/continue", true},
|
|
|
|
// Script-executing schemes must be rejected (issue #16880).
|
|
{"javascript scheme", "javascript:console.log(424281957)//", false},
|
|
{"javascript mixed case scheme", "JavaScript:alert(1)", false},
|
|
{"data scheme", "data:text/html,<script>alert(1)</script>", false},
|
|
{"vbscript scheme", "vbscript:msgbox(1)", false},
|
|
|
|
// Other schemes and malformed/scheme-less values are rejected by the allow-list.
|
|
{"http scheme", "http://enroll.example.com/continue", false},
|
|
{"empty", "", false},
|
|
{"scheme-less relative", "Microsoft.AAD.BrokerPlugin", false},
|
|
{"leading space before javascript", " javascript:alert(1)", false},
|
|
{"control character in scheme", "java\tscript:alert(1)", false},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
require.Equal(t, tc.want, windowsTOSRedirectURIAllowed(tc.redirectURI))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidSyncMLCmdXml(t *testing.T) {
|
|
testOmaURI := "testuri"
|
|
testData := "testdata"
|
|
cmdMsg := newSyncMLCmdXml(fleet.CmdReplace, testOmaURI, testData)
|
|
outXML, err := xml.MarshalIndent(cmdMsg, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
payload := string(outXML)
|
|
err = checkWrappedSyncMLCmd(fleet.CmdReplace, payload)
|
|
require.NoError(t, err)
|
|
require.Contains(t, payload, fmt.Sprintf("<LocURI>%s</LocURI>", testOmaURI))
|
|
require.Contains(t, payload, fmt.Sprintf("<Data>%s</Data>", testData))
|
|
require.Contains(t, payload, "<Type xmlns=\"syncml:metinf\">text/plain</Type>")
|
|
require.Contains(t, payload, "<Format xmlns=\"syncml:metinf\">xml</Format>")
|
|
}
|
|
|
|
func TestValidSyncMLCmdAlert(t *testing.T) {
|
|
testData := "1234"
|
|
cmdMsg := newSyncMLNoItem(fleet.CmdAlert, testData)
|
|
outXML, err := xml.MarshalIndent(cmdMsg, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
payload := string(outXML)
|
|
err = checkWrappedSyncMLCmd(fleet.CmdAlert, payload)
|
|
require.NoError(t, err)
|
|
require.Contains(t, payload, fmt.Sprintf("<Data>%s</Data>", testData))
|
|
}
|
|
|
|
func TestValidSyncMLCmd(t *testing.T) {
|
|
testCmdSource := "testcmdsource"
|
|
testCmdTarget := "testcmdtarget"
|
|
testCmdDataType := "testcmddatatype"
|
|
testCmdDataFormat := "testchr"
|
|
testCmdDataValue := "testdata"
|
|
cmdMsg := NewSyncMLCmd(fleet.CmdReplace, testCmdSource, testCmdTarget, testCmdDataType, testCmdDataFormat, testCmdDataValue)
|
|
outXML, err := xml.MarshalIndent(cmdMsg, "", " ")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, outXML)
|
|
payload := string(outXML)
|
|
err = checkWrappedSyncMLCmd(fleet.CmdReplace, payload)
|
|
require.NoError(t, err)
|
|
require.Contains(t, payload, fmt.Sprintf("<LocURI>%s</LocURI>", testCmdSource))
|
|
require.Contains(t, payload, fmt.Sprintf("<LocURI>%s</LocURI>", testCmdTarget))
|
|
require.Contains(t, payload, fmt.Sprintf("<Data>%s</Data>", testCmdDataValue))
|
|
require.Contains(t, payload, fmt.Sprintf("<Type xmlns=\"syncml:metinf\">%s</Type>", testCmdDataType))
|
|
require.Contains(t, payload, fmt.Sprintf("<Format xmlns=\"syncml:metinf\">%s</Format>", testCmdDataFormat))
|
|
}
|
|
|
|
// checkWrappedSyncMLCmd checks that the payload is wrapped in the given tag.
|
|
func checkWrappedSyncMLCmd(tag string, data string) error {
|
|
trimmedData := strings.TrimSpace(data)
|
|
openTag := fmt.Sprintf("<%s>", tag)
|
|
closeTag := fmt.Sprintf("</%s>", tag)
|
|
if !strings.HasPrefix(trimmedData, openTag) || !strings.HasSuffix(trimmedData, closeTag) {
|
|
return fmt.Errorf("payload is not wrapped in %s%s", openTag, closeTag)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestBuildCommandFromProfileBytes(t *testing.T) {
|
|
t.Run("fail unmarshalling xml", func(t *testing.T) {
|
|
cmd, err := buildCommandFromProfileBytes([]byte("<Replace></Add>"), "")
|
|
require.Nil(t, cmd)
|
|
require.ErrorContains(t, err, "unmarshalling profile")
|
|
})
|
|
|
|
t.Run("non atomic profile", func(t *testing.T) {
|
|
// build and generate a command
|
|
cmd, err := buildCommandFromProfileBytes(syncMLForTest("foo/bar"), "uuid-1")
|
|
require.Nil(t, err)
|
|
require.Equal(t, "uuid-1", cmd.CommandUUID)
|
|
require.Empty(t, cmd.TargetLocURI)
|
|
|
|
cmds, err := fleet.UnmarshallMultiTopLevelXMLProfile(cmd.RawCommand)
|
|
require.NoError(t, err)
|
|
|
|
replaceCommandsSeen := 0
|
|
firstReplaceCmdID := ""
|
|
for _, cmdXML := range cmds {
|
|
if cmdXML.XMLName.Local == fleet.CmdReplace {
|
|
replaceCommandsSeen++
|
|
require.NotEmpty(t, cmdXML.CmdID.Value)
|
|
firstReplaceCmdID = cmdXML.CmdID.Value // This works because we only expect one
|
|
}
|
|
}
|
|
require.EqualValues(t, 1, replaceCommandsSeen)
|
|
// generated xml contains additional comments about CmdID
|
|
require.Equal(
|
|
t,
|
|
fmt.Sprintf(`<Add><!-- CmdID generated by Fleet --><CmdID>uuid-1</CmdID><Item><Target><LocURI>foo/bar</LocURI></Target></Item></Add><Replace><!-- CmdID generated by Fleet --><CmdID>%s</CmdID><Item><Target><LocURI>foo/bar</LocURI></Target></Item></Replace>`, firstReplaceCmdID),
|
|
string(cmd.RawCommand),
|
|
)
|
|
|
|
// build and generate a second command with the same syncml
|
|
cmd, err = buildCommandFromProfileBytes(syncMLForTestWithExec("foo/bar"), "uuid-2")
|
|
require.Nil(t, err)
|
|
require.Equal(t, "uuid-2", cmd.CommandUUID)
|
|
require.Empty(t, cmd.TargetLocURI)
|
|
cmds, err = fleet.UnmarshallMultiTopLevelXMLProfile(cmd.RawCommand)
|
|
require.NoError(t, err)
|
|
|
|
replaceCommandsSeen = 0
|
|
secondReplaceCmdID := ""
|
|
secondExecCmdID := ""
|
|
for _, cmdXML := range cmds {
|
|
if cmdXML.XMLName.Local == fleet.CmdReplace {
|
|
replaceCommandsSeen++
|
|
require.NotEmpty(t, cmdXML.CmdID.Value)
|
|
secondReplaceCmdID = cmdXML.CmdID.Value // This works because we only expect one
|
|
} else if cmdXML.XMLName.Local == fleet.CmdExec {
|
|
require.NotEmpty(t, cmdXML.CmdID.Value)
|
|
secondExecCmdID = cmdXML.CmdID.Value
|
|
}
|
|
}
|
|
require.EqualValues(t, 1, replaceCommandsSeen)
|
|
require.NotEqualValues(t, "", secondReplaceCmdID)
|
|
// generated xml contains additional comments about CmdID
|
|
require.Equal(
|
|
t,
|
|
fmt.Sprintf(`<Add><!-- CmdID generated by Fleet --><CmdID>uuid-2</CmdID><Item><Target><LocURI>foo/bar</LocURI></Target></Item></Add><Replace><!-- CmdID generated by Fleet --><CmdID>%s</CmdID><Item><Target><LocURI>foo/bar</LocURI></Target></Item></Replace><Exec><!-- CmdID generated by Fleet --><CmdID>%s</CmdID><Item><Target><LocURI>foo/bar</LocURI></Target></Item></Exec>`, secondReplaceCmdID, secondExecCmdID),
|
|
string(cmd.RawCommand),
|
|
)
|
|
|
|
// uuids of replaces are different
|
|
require.NotEqual(t, firstReplaceCmdID, secondReplaceCmdID)
|
|
})
|
|
|
|
t.Run("atomic profile", func(t *testing.T) {
|
|
// build and generate a command
|
|
cmd, err := buildCommandFromProfileBytes(atomicSyncMLForTest("foo/bar"), "uuid-1")
|
|
require.Nil(t, err)
|
|
require.Equal(t, "uuid-1", cmd.CommandUUID)
|
|
require.Empty(t, cmd.TargetLocURI)
|
|
|
|
syncOne := new(fleet.SyncMLCmd)
|
|
err = xml.Unmarshal(cmd.RawCommand, syncOne)
|
|
require.NoError(t, err)
|
|
require.Len(t, syncOne.ReplaceCommands, 1)
|
|
require.NotEmpty(t, syncOne.ReplaceCommands[0].CmdID.Value)
|
|
// generated xml contains additional comments about CmdID
|
|
require.Equal(
|
|
t,
|
|
fmt.Sprintf(`<Atomic><!-- CmdID generated by Fleet --><CmdID>uuid-1</CmdID><Replace><!-- CmdID generated by Fleet --><CmdID>%s</CmdID><Item><Target><LocURI>foo/bar</LocURI></Target></Item></Replace><Add><!-- CmdID generated by Fleet --><CmdID>%s</CmdID><Item><Target><LocURI>foo/bar</LocURI></Target></Item></Add></Atomic>`, syncOne.ReplaceCommands[0].CmdID.Value, syncOne.AddCommands[0].CmdID.Value),
|
|
string(cmd.RawCommand),
|
|
)
|
|
|
|
// build and generate a second command with the same syncml
|
|
cmd, err = buildCommandFromProfileBytes(atomicSyncMLForTestWithExec("foo/bar"), "uuid-2")
|
|
require.Nil(t, err)
|
|
require.Equal(t, "uuid-2", cmd.CommandUUID)
|
|
require.Empty(t, cmd.TargetLocURI)
|
|
syncTwo := new(fleet.SyncMLCmd)
|
|
err = xml.Unmarshal(cmd.RawCommand, syncTwo)
|
|
require.NoError(t, err)
|
|
require.Len(t, syncTwo.ReplaceCommands, 1)
|
|
require.NotEmpty(t, syncTwo.ReplaceCommands[0].CmdID.Value)
|
|
// generated xml contains additional comments about CmdID
|
|
require.Equal(
|
|
t,
|
|
fmt.Sprintf(`<Atomic><!-- CmdID generated by Fleet --><CmdID>uuid-2</CmdID><Replace><!-- CmdID generated by Fleet --><CmdID>%s</CmdID><Item><Target><LocURI>foo/bar</LocURI></Target></Item></Replace><Add><!-- CmdID generated by Fleet --><CmdID>%s</CmdID><Item><Target><LocURI>foo/bar</LocURI></Target></Item></Add><Exec><!-- CmdID generated by Fleet --><CmdID>%s</CmdID><Item><Target><LocURI>foo/bar</LocURI></Target></Item></Exec></Atomic>`, syncTwo.ReplaceCommands[0].CmdID.Value, syncTwo.AddCommands[0].CmdID.Value, syncTwo.ExecCommands[0].CmdID.Value),
|
|
string(cmd.RawCommand),
|
|
)
|
|
|
|
// uuids of replaces are different
|
|
require.NotEqual(t, syncOne.ReplaceCommands[0].CmdID.Value, syncTwo.ReplaceCommands[0].CmdID.Value)
|
|
})
|
|
|
|
t.Run("SCEP profiles", func(t *testing.T) {
|
|
// build and generate a command
|
|
scepCmdWithAtomic, err := buildCommandFromProfileBytes(atomicSyncMLForTest("/Vendor/MSFT/ClientCertificateInstall/SCEP"), "uuid-1")
|
|
require.Nil(t, err)
|
|
require.Equal(t, "uuid-1", scepCmdWithAtomic.CommandUUID)
|
|
require.Empty(t, scepCmdWithAtomic.TargetLocURI)
|
|
syncTwo := new(fleet.SyncMLCmd)
|
|
err = xml.Unmarshal(scepCmdWithAtomic.RawCommand, syncTwo)
|
|
require.NoError(t, err)
|
|
|
|
expectedString := "<Atomic><!-- CmdID generated by Fleet --><CmdID>uuid-1</CmdID><Replace><!-- CmdID generated by Fleet --><CmdID>%s</CmdID><Item><Target><LocURI>/Vendor/MSFT/ClientCertificateInstall/SCEP</LocURI></Target></Item></Replace><Add><!-- CmdID generated by Fleet --><CmdID>%s</CmdID><Item><Target><LocURI>/Vendor/MSFT/ClientCertificateInstall/SCEP</LocURI></Target></Item></Add></Atomic>"
|
|
require.Equal(
|
|
t,
|
|
fmt.Sprintf(expectedString, syncTwo.ReplaceCommands[0].CmdID.Value, syncTwo.AddCommands[0].CmdID.Value),
|
|
string(scepCmdWithAtomic.RawCommand),
|
|
)
|
|
|
|
scepCmdWithoutAtomic, err := buildCommandFromProfileBytes(syncMLForTest("/Vendor/MSFT/ClientCertificateInstall/SCEP"), "uuid-1")
|
|
require.Nil(t, err)
|
|
require.Equal(t, "uuid-1", scepCmdWithoutAtomic.CommandUUID)
|
|
require.Empty(t, scepCmdWithoutAtomic.TargetLocURI)
|
|
syncTwo = new(fleet.SyncMLCmd)
|
|
err = xml.Unmarshal(scepCmdWithAtomic.RawCommand, syncTwo)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(
|
|
t,
|
|
fmt.Sprintf(expectedString, syncTwo.ReplaceCommands[0].CmdID.Value, syncTwo.AddCommands[0].CmdID.Value),
|
|
string(scepCmdWithAtomic.RawCommand),
|
|
)
|
|
})
|
|
|
|
t.Run("scope-less SCEP profile is wrapped in Atomic", func(t *testing.T) {
|
|
scepLocURI := "Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/ServerURL"
|
|
cmd, err := buildCommandFromProfileBytes(syncMLForTest(scepLocURI), "uuid-scopeless")
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(cmd.RawCommand), "<Atomic>")
|
|
|
|
// A non-wrapped profile unmarshalls into a single top-level command; only an <Atomic> wrapper populates both nested
|
|
// command slices, so this is a definitive check that the scope-less SCEP profile was wrapped.
|
|
wrapped := new(fleet.SyncMLCmd)
|
|
require.NoError(t, xml.Unmarshal(cmd.RawCommand, wrapped))
|
|
require.Len(t, wrapped.ReplaceCommands, 1)
|
|
require.Len(t, wrapped.AddCommands, 1)
|
|
})
|
|
}
|
|
|
|
func syncMLForTest(locURI string) []byte {
|
|
return []byte(fmt.Sprintf(`
|
|
<Add>
|
|
<Item>
|
|
<Target>
|
|
<LocURI>%s</LocURI>
|
|
</Target>
|
|
</Item>
|
|
</Add>
|
|
<Replace>
|
|
<Item>
|
|
<Target>
|
|
<LocURI>%s</LocURI>
|
|
</Target>
|
|
</Item>
|
|
</Replace>`, locURI, locURI))
|
|
}
|
|
|
|
func atomicSyncMLForTest(locURI string) []byte {
|
|
data := syncMLForTest(locURI)
|
|
return fmt.Appendf([]byte{}, `
|
|
<Atomic>%s</Atomic>`, data)
|
|
}
|
|
|
|
func syncMLForTestWithExec(locURI string) []byte {
|
|
return []byte(fmt.Sprintf(`
|
|
<Add>
|
|
<Item>
|
|
<Target>
|
|
<LocURI>%s</LocURI>
|
|
</Target>
|
|
</Item>
|
|
</Add>
|
|
<Replace>
|
|
<Item>
|
|
<Target>
|
|
<LocURI>%s</LocURI>
|
|
</Target>
|
|
</Item>
|
|
</Replace>
|
|
<Exec>
|
|
<Item>
|
|
<Target>
|
|
<LocURI>%s</LocURI>
|
|
</Target>
|
|
</Item>
|
|
</Exec>`, locURI, locURI, locURI))
|
|
}
|
|
|
|
func atomicSyncMLForTestWithExec(locURI string) []byte {
|
|
data := syncMLForTestWithExec(locURI)
|
|
return fmt.Appendf([]byte{}, `
|
|
<Atomic>%s</Atomic>`, data)
|
|
}
|
|
|
|
// Setups a reconciler test run by mocking required datastore methods, for a single profile pending installation.
|
|
// Use $FLEET_VAR_HOST_UUID in the profile SyncML to simulate error in profile variable processing flow.
|
|
func setupReconcilerTest(ds *mock.Store, hostToProfile map[string]*fleet.MDMWindowsConfigProfile) (capturedUpdates *[]*fleet.MDMWindowsBulkUpsertHostProfilePayload, managedCerts *[]*fleet.MDMManagedCertificate) {
|
|
// Cursor stubs: tests don't care about cursor state, just need the
|
|
// reconciler not to panic on the calls.
|
|
ds.GetMDMWindowsReconcileCursorFunc = func(ctx context.Context) (string, error) {
|
|
return "", nil
|
|
}
|
|
ds.SetMDMWindowsReconcileCursorFunc = func(ctx context.Context, cursor string) error {
|
|
return nil
|
|
}
|
|
|
|
// The cron's batched path loads a snapshot (hosts + profiles + current state) per window and computes install/remove deltas in
|
|
// memory. Return every host from hostToProfile in a single window, each paired with its profile under a unique team_id so
|
|
// ComputeWindowsReconcileDeltas maps each host to exactly its mapped profile (regardless of whether two hosts share a profile).
|
|
// Empty current state => every mapped profile is a fresh install, matching the legacy listInstall fixture. The `after != ""`
|
|
// guard keeps these single-tick tests from looping (everything is delivered in the first window).
|
|
ds.GetWindowsProfileReconcileSnapshotFunc = func(ctx context.Context, after string, batch int) (
|
|
[]*fleet.WindowsHostReconcileInfo,
|
|
[]*fleet.WindowsProfileForReconcile,
|
|
map[uint]map[uint]struct{},
|
|
map[string][]*fleet.MDMWindowsProfilePayload,
|
|
error,
|
|
) {
|
|
if after != "" {
|
|
return nil, nil, nil, nil, nil
|
|
}
|
|
// Emit ONE profile row per unique ProfileUUID (the real snapshot is profile-scoped, not per-host). Each unique profile gets its
|
|
// own team, and every host that maps to that profile is placed in that team, so ComputeWindowsReconcileDeltas fans the single
|
|
// profile out to all its hosts, exercising shared-profile grouping the way production does. Hosts are returned ascending by UUID
|
|
// to match `ORDER BY h.uuid`.
|
|
hostUUIDs := make([]string, 0, len(hostToProfile))
|
|
for hostUUID := range hostToProfile {
|
|
hostUUIDs = append(hostUUIDs, hostUUID)
|
|
}
|
|
sort.Strings(hostUUIDs)
|
|
teamByProfile := make(map[string]uint, len(hostToProfile))
|
|
var hosts []*fleet.WindowsHostReconcileInfo
|
|
var profiles []*fleet.WindowsProfileForReconcile
|
|
var nextHostID uint
|
|
for _, hostUUID := range hostUUIDs {
|
|
profile := hostToProfile[hostUUID]
|
|
tid, ok := teamByProfile[profile.ProfileUUID]
|
|
if !ok {
|
|
tid = uint(len(teamByProfile) + 1)
|
|
teamByProfile[profile.ProfileUUID] = tid
|
|
profiles = append(profiles, &fleet.WindowsProfileForReconcile{
|
|
ProfileUUID: profile.ProfileUUID,
|
|
ProfileName: profile.Name,
|
|
TeamID: tid,
|
|
})
|
|
}
|
|
nextHostID++
|
|
hostID, teamID := nextHostID, tid
|
|
hosts = append(hosts, &fleet.WindowsHostReconcileInfo{HostID: hostID, UUID: hostUUID, TeamID: &teamID})
|
|
}
|
|
return hosts, profiles, nil, map[string][]*fleet.MDMWindowsProfilePayload{}, nil
|
|
}
|
|
|
|
ds.GetMDMWindowsProfilesContentsFunc = func(ctx context.Context, profileUUIDs []string) (map[string]fleet.MDMWindowsProfileContents, error) {
|
|
profileContentsMap := make(map[string]fleet.MDMWindowsProfileContents)
|
|
for _, profile := range hostToProfile {
|
|
profileContentsMap[profile.ProfileUUID] = fleet.MDMWindowsProfileContents{
|
|
SyncML: profile.SyncML,
|
|
Checksum: []byte("test-checksum"),
|
|
}
|
|
}
|
|
return profileContentsMap, nil
|
|
}
|
|
|
|
ds.MDMWindowsBulkInsertCommandsFunc = func(ctx context.Context, cmds []*fleet.MDMWindowsCommand) error {
|
|
return nil
|
|
}
|
|
ds.MDMWindowsEnqueueCommandAndUpsertHostProfilesFunc = func(ctx context.Context, hostUUIDs []string, cmd *fleet.MDMWindowsCommand, payload []*fleet.MDMWindowsBulkUpsertHostProfilePayload) error {
|
|
return nil
|
|
}
|
|
|
|
// Modify-install delete pass: default to no retained prior content (no LocURIs removed) and a no-op enqueue, so reconcile tests
|
|
// that don't exercise edited-profile <Delete> generation don't nil-panic. Tests covering it can override these.
|
|
ds.GetWindowsMDMProfilePriorContentsFunc = func(ctx context.Context, keys []fleet.MDMWindowsProfileVersionKey) ([]fleet.MDMWindowsProfilePriorContent, error) {
|
|
return nil, nil
|
|
}
|
|
ds.MDMWindowsInsertCommandForHostUUIDsFunc = func(ctx context.Context, hostUUIDs []string, cmd *fleet.MDMWindowsCommand) error {
|
|
return nil
|
|
}
|
|
|
|
// Default: every requested profile still exists. Tests that want to
|
|
// exercise the deletion-race guard can override this with their own Func.
|
|
ds.GetExistingMDMWindowsProfileUUIDsFunc = func(ctx context.Context, profileUUIDs []string) (map[string]struct{}, error) {
|
|
out := make(map[string]struct{}, len(profileUUIDs))
|
|
for _, u := range profileUUIDs {
|
|
out[u] = struct{}{}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
capturedUpdates = &[]*fleet.MDMWindowsBulkUpsertHostProfilePayload{}
|
|
ds.BulkUpsertMDMWindowsHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMWindowsBulkUpsertHostProfilePayload) error {
|
|
*capturedUpdates = append(*capturedUpdates, payload...)
|
|
return nil
|
|
}
|
|
|
|
ds.GetMDMWindowsBitLockerSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) {
|
|
return &fleet.MDMWindowsBitLockerSummary{}, nil
|
|
}
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
MDM: fleet.MDM{
|
|
WindowsEnabledAndConfigured: true,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.BulkDeleteMDMWindowsHostsConfigProfilesFunc = func(ctx context.Context, payload []*fleet.MDMWindowsProfilePayload) error {
|
|
return nil
|
|
}
|
|
|
|
ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) {
|
|
return &fleet.GroupedCertificateAuthorities{
|
|
CustomScepProxy: []fleet.CustomSCEPProxyCA{},
|
|
}, nil
|
|
}
|
|
|
|
managedCerts = &[]*fleet.MDMManagedCertificate{}
|
|
ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error {
|
|
*managedCerts = payload
|
|
return nil
|
|
}
|
|
|
|
return capturedUpdates, managedCerts
|
|
}
|
|
|
|
func TestReconcileWindowsProfilesWithFleetVariableError(t *testing.T) {
|
|
ctx := context.Background()
|
|
ds := new(mock.Store)
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
// Setup test data with a profile containing Fleet variable
|
|
testHostUUID := "test-host-uuid"
|
|
// Profile with Fleet variable that would cause preprocessing to succeed normally
|
|
testProfile := &fleet.MDMWindowsConfigProfile{
|
|
ProfileUUID: "test-profile-uuid",
|
|
Name: "Test Profile with Variable",
|
|
SyncML: []byte(`<Replace><Item><Target><LocURI>./Test</LocURI></Target><Data>Host: $FLEET_VAR_HOST_UUID</Data></Item></Replace>`),
|
|
}
|
|
|
|
hostToProfile := map[string]*fleet.MDMWindowsConfigProfile{
|
|
testHostUUID: testProfile,
|
|
}
|
|
capturedUpdates, managedCerts := setupReconcilerTest(ds, hostToProfile)
|
|
|
|
var receivedCommand *fleet.MDMWindowsCommand
|
|
ds.MDMWindowsInsertCommandAndUpsertHostProfilesForHostsFunc = func(ctx context.Context, hostUUIDs []string, cmd *fleet.MDMWindowsCommand, updates []*fleet.MDMWindowsBulkUpsertHostProfilePayload) error {
|
|
receivedCommand = cmd
|
|
// Simulate error only for commands with substituted UUID (to test error handling)
|
|
if strings.Contains(string(cmd.RawCommand), testHostUUID) {
|
|
return errors.New("command insert failed after preprocessing")
|
|
}
|
|
*capturedUpdates = append(*capturedUpdates, updates...)
|
|
return nil
|
|
}
|
|
|
|
// Run ReconcileWindowsProfiles
|
|
err := ReconcileWindowsProfiles(ctx, ds, logger)
|
|
require.NoError(t, err) // The function should not return an error even if insert fails
|
|
|
|
// Verify the command was preprocessed (UUID should be substituted)
|
|
require.NotNil(t, receivedCommand, "Command should have been created")
|
|
require.Contains(t, string(receivedCommand.RawCommand), testHostUUID, "UUID should have been substituted in the command")
|
|
require.NotContains(t, string(receivedCommand.RawCommand), "$FLEET_VAR_HOST_UUID", "Fleet variable should have been replaced")
|
|
|
|
// Verify managed certs is empty as no certs were in the profile
|
|
require.Empty(t, managedCerts, "No managed certificates should have been added")
|
|
|
|
// Verify that the error was captured and the profile was marked as failed
|
|
require.True(t, ds.MDMWindowsInsertCommandAndUpsertHostProfilesForHostsFuncInvoked, "MDMWindowsInsertCommandAndUpsertHostProfilesForHosts should have been called")
|
|
require.True(t, ds.BulkUpsertMDMWindowsHostProfilesFuncInvoked, "BulkUpsertMDMWindowsHostProfiles should have been called")
|
|
|
|
// Find the error status update
|
|
var foundError bool
|
|
for _, update := range *capturedUpdates {
|
|
if update.Status != nil && *update.Status == fleet.MDMDeliveryFailed {
|
|
foundError = true
|
|
require.Contains(t, update.Detail, "command insert failed after preprocessing", "Error detail should contain the original error message")
|
|
break
|
|
}
|
|
}
|
|
require.True(t, foundError, "Should have found a failed status update")
|
|
}
|
|
|
|
func TestReconcileWindowsProfileWithCertificateFailureDoesNotAddManagedCertificate(t *testing.T) {
|
|
ctx := t.Context()
|
|
ctx = license.NewContext(ctx, &fleet.LicenseInfo{
|
|
Tier: fleet.TierPremium,
|
|
})
|
|
ds := new(mock.Store)
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
// Setup test data with a profile containing a certificate that will fail processing
|
|
testHostUUID := "test-host-uuid"
|
|
testProfile := &fleet.MDMWindowsConfigProfile{
|
|
ProfileUUID: "test-profile-uuid",
|
|
Name: "Test Profile with Cert",
|
|
SyncML: []byte(`<Replace><Item><Target><LocURI>./Certificate</LocURI></Target><Data>$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_CA</Data></Item></Replace>`),
|
|
}
|
|
|
|
hostToProfile := map[string]*fleet.MDMWindowsConfigProfile{
|
|
testHostUUID: testProfile,
|
|
}
|
|
capturedUpdates, managedCerts := setupReconcilerTest(ds, hostToProfile)
|
|
|
|
// Override GetGroupedCertificateAuthorities to return a valid CA
|
|
ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) {
|
|
return &fleet.GroupedCertificateAuthorities{
|
|
CustomScepProxy: []fleet.CustomSCEPProxyCA{
|
|
{
|
|
ID: 1,
|
|
Name: "CA",
|
|
URL: "https://scep.proxy.url",
|
|
Challenge: "secret",
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.NewChallengeFunc = func(ctx context.Context) (string, error) {
|
|
return "secret", nil
|
|
}
|
|
|
|
ds.MDMWindowsInsertCommandAndUpsertHostProfilesForHostsFunc = func(ctx context.Context, hostUUIDs []string, cmd *fleet.MDMWindowsCommand, updates []*fleet.MDMWindowsBulkUpsertHostProfilePayload) error {
|
|
return errors.New("fake error to check managed certificate")
|
|
}
|
|
|
|
// Run ReconcileWindowsProfiles
|
|
err := ReconcileWindowsProfiles(ctx, ds, logger)
|
|
require.NoError(t, err) // The function should not return an error even if cert processing fails
|
|
|
|
// Verify no managed certificates were added due to failure
|
|
require.Empty(t, managedCerts, "No managed certificates should have been added")
|
|
|
|
// Verify that the error was captured and the profile was marked as failed
|
|
require.True(t, ds.BulkUpsertMDMWindowsHostProfilesFuncInvoked, "BulkUpsertMDMWindowsHostProfiles should have been called")
|
|
|
|
// Find the error status update
|
|
var foundError bool
|
|
for _, update := range *capturedUpdates {
|
|
if update.Status != nil && *update.Status == fleet.MDMDeliveryFailed {
|
|
foundError = true
|
|
require.Contains(t, update.Detail, "fake error to check managed certificate", "Error detail should indicate certificate processing failure")
|
|
break
|
|
}
|
|
}
|
|
require.True(t, foundError, "Should have found a failed status update")
|
|
}
|
|
|
|
func TestReconcileWindowsProfilesWithOneHostFailingStillAddsManagedCertificate(t *testing.T) {
|
|
ctx := t.Context()
|
|
ctx = license.NewContext(ctx, &fleet.LicenseInfo{
|
|
Tier: fleet.TierPremium,
|
|
})
|
|
ds := new(mock.Store)
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
// Setup test data with a profile containing a certificate that will fail processing
|
|
testHostUUID := "test-host-uuid"
|
|
testProfile := &fleet.MDMWindowsConfigProfile{
|
|
ProfileUUID: "test-profile-uuid",
|
|
Name: "Test Profile with Cert",
|
|
SyncML: []byte(`<Replace><Item><Target><LocURI>./Certificate</LocURI></Target><Data>$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_CA</Data></Item></Replace><Replace><Item><Target><LocURI>./Certificate</LocURI></Target><Data>$FLEET_VAR_HOST_END_USER_IDP_USERNAME</Data></Item></Replace>`),
|
|
}
|
|
testHostUUID2 := "test-host-uuid-2"
|
|
hostToProfile := map[string]*fleet.MDMWindowsConfigProfile{
|
|
testHostUUID: testProfile,
|
|
testHostUUID2: testProfile,
|
|
}
|
|
|
|
capturedUpdates, managedCerts := setupReconcilerTest(ds, hostToProfile)
|
|
|
|
// Override GetGroupedCertificateAuthorities to return a valid CA
|
|
ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) {
|
|
return &fleet.GroupedCertificateAuthorities{
|
|
CustomScepProxy: []fleet.CustomSCEPProxyCA{
|
|
{
|
|
ID: 1,
|
|
Name: "CA",
|
|
URL: "https://scep.proxy.url",
|
|
Challenge: "secret",
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.NewChallengeFunc = func(ctx context.Context) (string, error) {
|
|
return "secret", nil
|
|
}
|
|
|
|
ds.MDMWindowsInsertCommandAndUpsertHostProfilesForHostsFunc = func(ctx context.Context, hostUUIDs []string, cmd *fleet.MDMWindowsCommand, updates []*fleet.MDMWindowsBulkUpsertHostProfilePayload) error {
|
|
*capturedUpdates = append(*capturedUpdates, updates...)
|
|
return nil
|
|
}
|
|
|
|
ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) {
|
|
if hostnames[0] == testHostUUID {
|
|
return []uint{1}, nil
|
|
}
|
|
return []uint{2}, nil
|
|
}
|
|
|
|
ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
|
|
if hostID == 1 {
|
|
return &fleet.ScimUser{
|
|
UserName: "test@example.com",
|
|
}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) {
|
|
return []*fleet.HostDeviceMapping{}, nil
|
|
}
|
|
|
|
// Run ReconcileWindowsProfiles
|
|
err := ReconcileWindowsProfiles(ctx, ds, logger)
|
|
require.NoError(t, err) // The function should not return an error even if cert processing fails
|
|
|
|
// Verify one managed certificates were added, for the successful host, but not for the failing one
|
|
require.NotNil(t, managedCerts, "Managed certificates slice should not be nil")
|
|
require.Len(t, *managedCerts, 1, "No managed certificates should have been added")
|
|
|
|
// Verify that the error was captured and the profile was marked as failed
|
|
require.True(t, ds.BulkUpsertMDMWindowsHostProfilesFuncInvoked, "BulkUpsertMDMWindowsHostProfiles should have been called")
|
|
|
|
// Check the error and only one error
|
|
foundErrors := 0
|
|
for _, update := range *capturedUpdates {
|
|
if update.Status != nil && *update.Status == fleet.MDMDeliveryFailed {
|
|
foundErrors++
|
|
require.Contains(t, update.Detail, "There is no IdP username for this host.", "Error detail should indicate missing IdP username")
|
|
break
|
|
}
|
|
}
|
|
require.EqualValues(t, 1, foundErrors, "Should have found one failed status update")
|
|
}
|
|
|
|
// TestReconcileWindowsProfilesSkipsDeletedProfile covers the race where an
|
|
// admin deletes a Windows profile between the cron's initial list and the
|
|
// per-profile upsert. Without the guard, the cron would insert a
|
|
// host_mdm_windows_profiles row + enqueue an install command for a profile
|
|
// that no longer exists in mdm_windows_configuration_profiles; later the
|
|
// remove path can't build a <Delete> command (SyncML is gone) and the row
|
|
// is stuck. The fix: GetExistingMDMWindowsProfileUUIDs pre-filter right
|
|
// before the upsert loop.
|
|
func TestReconcileWindowsProfilesSkipsDeletedProfile(t *testing.T) {
|
|
ctx := context.Background()
|
|
ds := new(mock.Store)
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
deletedProfile := &fleet.MDMWindowsConfigProfile{
|
|
ProfileUUID: "deleted-profile-uuid",
|
|
Name: "Deleted Before Upsert",
|
|
SyncML: []byte(`<Replace><Item><Target><LocURI>./Test</LocURI></Target><Data>v</Data></Item></Replace>`),
|
|
}
|
|
hostToProfile := map[string]*fleet.MDMWindowsConfigProfile{
|
|
"host-a": deletedProfile,
|
|
}
|
|
setupReconcilerTest(ds, hostToProfile)
|
|
|
|
// Simulate the race: the reconcile snapshot and
|
|
// GetMDMWindowsProfilesContents already ran (both set up by
|
|
// setupReconcilerTest to include the profile). Between those and the
|
|
// upsert, the admin deleted the profile, so
|
|
// GetExistingMDMWindowsProfileUUIDs returns an empty set.
|
|
ds.GetExistingMDMWindowsProfileUUIDsFunc = func(ctx context.Context, profileUUIDs []string) (map[string]struct{}, error) {
|
|
return map[string]struct{}{}, nil
|
|
}
|
|
|
|
err := ReconcileWindowsProfiles(ctx, ds, logger)
|
|
require.NoError(t, err)
|
|
require.True(t, ds.GetExistingMDMWindowsProfileUUIDsFuncInvoked, "existence pre-check must run")
|
|
require.False(t, ds.MDMWindowsInsertCommandAndUpsertHostProfilesForHostsFuncInvoked,
|
|
"no zombie row should be written when the profile is gone")
|
|
}
|
|
|
|
// TestReconcileWindowsProfilesSkipsInsertLag covers the asymmetric race
|
|
// where a profile was just inserted on the primary but the replica
|
|
// hasn't caught up: GetMDMWindowsProfilesContents (replica) misses the
|
|
// row even though GetExistingMDMWindowsProfileUUIDs (primary) sees it.
|
|
// Without the skip-and-continue, the cron would error out with
|
|
// "missing profile content", leave the cursor unchanged, and re-fire the
|
|
// same race every 30s until the replica converges. The fix: log + skip
|
|
// + advance the cursor; the hosts stay in the listing universe and the
|
|
// next tick picks them up after replication catches up.
|
|
func TestReconcileWindowsProfilesSkipsInsertLag(t *testing.T) {
|
|
ctx := context.Background()
|
|
ds := new(mock.Store)
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
freshProfile := &fleet.MDMWindowsConfigProfile{
|
|
ProfileUUID: "fresh-profile-uuid",
|
|
Name: "Just-Inserted-On-Primary",
|
|
SyncML: []byte(`<Replace><Item><Target><LocURI>./Test</LocURI></Target><Data>v</Data></Item></Replace>`),
|
|
}
|
|
hostToProfile := map[string]*fleet.MDMWindowsConfigProfile{
|
|
"host-a": freshProfile,
|
|
}
|
|
setupReconcilerTest(ds, hostToProfile)
|
|
|
|
// Simulate insert-lag: the existence pre-check (primary) finds the
|
|
// profile, but the content fetch (replica) returns nothing because
|
|
// replication hasn't caught up to the just-committed insert.
|
|
ds.GetMDMWindowsProfilesContentsFunc = func(ctx context.Context, profileUUIDs []string) (map[string]fleet.MDMWindowsProfileContents, error) {
|
|
return map[string]fleet.MDMWindowsProfileContents{}, nil
|
|
}
|
|
|
|
err := ReconcileWindowsProfiles(ctx, ds, logger)
|
|
require.NoError(t, err, "insert-lag must not fail the tick; the cursor must advance so the next tick can retry")
|
|
require.True(t, ds.GetExistingMDMWindowsProfileUUIDsFuncInvoked,
|
|
"existence pre-check still runs (it confirms the profile exists on primary)")
|
|
require.False(t, ds.MDMWindowsInsertCommandAndUpsertHostProfilesForHostsFuncInvoked,
|
|
"no install command should be enqueued when content is not yet visible")
|
|
}
|
|
|
|
// TestReconcileWindowsProfilesEmptyPopulation covers the cron's two
|
|
// terminating branches when there is no pending Windows MDM work.
|
|
// A fresh ("") cursor stays empty and writes nothing. A non-empty cursor
|
|
// (left over from a prior partial pass) is reset to "" exactly once. In
|
|
// both cases the cron returns nil and the per-host / per-profile mocks
|
|
// are never reached, so leaving them nil on the mock store is itself an
|
|
// implicit assertion.
|
|
func TestReconcileWindowsProfilesEmptyPopulation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
initialCursor string
|
|
wantSetCalls int
|
|
wantFinalCursor string
|
|
}{
|
|
{
|
|
name: "fresh cursor and no work is a no-op",
|
|
initialCursor: "",
|
|
wantSetCalls: 0,
|
|
wantFinalCursor: "",
|
|
},
|
|
{
|
|
name: "non-empty cursor is reset to empty once",
|
|
initialCursor: "left-over-host-uuid",
|
|
wantSetCalls: 1,
|
|
wantFinalCursor: "",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
ds := new(mock.Store)
|
|
logger := slog.New(slog.DiscardHandler)
|
|
cursor := tc.initialCursor
|
|
var setCalls int
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
cfg := &fleet.AppConfig{}
|
|
cfg.MDM.WindowsEnabledAndConfigured = true
|
|
return cfg, nil
|
|
}
|
|
ds.GetMDMWindowsReconcileCursorFunc = func(ctx context.Context) (string, error) {
|
|
return cursor, nil
|
|
}
|
|
ds.SetMDMWindowsReconcileCursorFunc = func(ctx context.Context, c string) error {
|
|
cursor = c
|
|
setCalls++
|
|
return nil
|
|
}
|
|
ds.GetWindowsProfileReconcileSnapshotFunc = func(ctx context.Context, after string, batch int) (
|
|
[]*fleet.WindowsHostReconcileInfo,
|
|
[]*fleet.WindowsProfileForReconcile,
|
|
map[uint]map[uint]struct{},
|
|
map[string][]*fleet.MDMWindowsProfilePayload,
|
|
error,
|
|
) {
|
|
return nil, nil, nil, nil, nil
|
|
}
|
|
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Equal(t, tc.wantSetCalls, setCalls)
|
|
require.Equal(t, tc.wantFinalCursor, cursor)
|
|
})
|
|
}
|
|
}
|
|
|
|
// setReconcileWindowsBudgets sets the three drain-loop tunables for the duration of a test and restores them on cleanup.
|
|
func setReconcileWindowsBudgets(t *testing.T, scanBatch, deliveryCap int, scanBudget time.Duration) {
|
|
t.Helper()
|
|
savedBatch := reconcileWindowsProfilesBatchSize
|
|
savedCap := reconcileWindowsProfilesDeliveryCap
|
|
savedBudget := reconcileWindowsProfilesScanBudget
|
|
t.Cleanup(func() {
|
|
reconcileWindowsProfilesBatchSize = savedBatch
|
|
reconcileWindowsProfilesDeliveryCap = savedCap
|
|
reconcileWindowsProfilesScanBudget = savedBudget
|
|
})
|
|
reconcileWindowsProfilesBatchSize = scanBatch
|
|
reconcileWindowsProfilesDeliveryCap = deliveryCap
|
|
reconcileWindowsProfilesScanBudget = scanBudget
|
|
}
|
|
|
|
// setKeys returns the keys of a set as a slice (order unspecified).
|
|
func setKeys(m map[string]struct{}) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// windowSnapshotFunc returns a GetWindowsProfileReconcileSnapshot stub that pages allHosts (ascending) into windows of the
|
|
// requested batch size, honoring the `after` cursor, and returns the given profiles for every non-empty window. Hosts present in
|
|
// `delivered` get a matching verified install row per profile, so a delivered host no longer computes as work (modeling the real
|
|
// upsert flipping rows to verified) and a later full pass is a true no-op. If calls is non-nil it is incremented per invocation.
|
|
func windowSnapshotFunc(
|
|
allHosts []string,
|
|
profiles []*fleet.WindowsProfileForReconcile,
|
|
delivered map[string]struct{},
|
|
calls *int,
|
|
) func(context.Context, string, int) ([]*fleet.WindowsHostReconcileInfo, []*fleet.WindowsProfileForReconcile, map[uint]map[uint]struct{}, map[string][]*fleet.MDMWindowsProfilePayload, error) {
|
|
return func(_ context.Context, after string, batch int) (
|
|
[]*fleet.WindowsHostReconcileInfo,
|
|
[]*fleet.WindowsProfileForReconcile,
|
|
map[uint]map[uint]struct{},
|
|
map[string][]*fleet.MDMWindowsProfilePayload,
|
|
error,
|
|
) {
|
|
if calls != nil {
|
|
*calls++
|
|
}
|
|
var hosts []*fleet.WindowsHostReconcileInfo
|
|
for i, h := range allHosts {
|
|
if h > after {
|
|
hosts = append(hosts, &fleet.WindowsHostReconcileInfo{HostID: uint(i + 1), UUID: h}) //nolint:gosec
|
|
if len(hosts) == batch {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(hosts) == 0 {
|
|
return nil, nil, nil, nil, nil
|
|
}
|
|
currentByHost := map[string][]*fleet.MDMWindowsProfilePayload{}
|
|
for _, h := range hosts {
|
|
if _, ok := delivered[h.UUID]; !ok {
|
|
continue
|
|
}
|
|
for _, p := range profiles {
|
|
currentByHost[h.UUID] = append(currentByHost[h.UUID], &fleet.MDMWindowsProfilePayload{
|
|
ProfileUUID: p.ProfileUUID,
|
|
HostUUID: h.UUID,
|
|
Checksum: p.Checksum,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
Status: &fleet.MDMDeliveryVerified,
|
|
})
|
|
}
|
|
}
|
|
return hosts, profiles, nil, currentByHost, nil
|
|
}
|
|
}
|
|
|
|
// newDrainLoopTestDS wires a mock.Store for ReconcileWindowsProfiles drain-loop tests: Windows MDM enabled, a cursor backed by
|
|
// *cursor, the windowing snapshot over allHosts/profiles, and the downstream execute stubs a non-variable install needs. Enqueued
|
|
// hosts are recorded in `delivered` so a later pass is a no-op. Observe results via *cursor, the `delivered` set, and *calls.
|
|
func newDrainLoopTestDS(
|
|
ds *mock.Store,
|
|
allHosts []string,
|
|
profiles []*fleet.WindowsProfileForReconcile,
|
|
delivered map[string]struct{},
|
|
cursor *string,
|
|
calls *int,
|
|
) {
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
cfg := &fleet.AppConfig{}
|
|
cfg.MDM.WindowsEnabledAndConfigured = true
|
|
return cfg, nil
|
|
}
|
|
ds.GetMDMWindowsReconcileCursorFunc = func(ctx context.Context) (string, error) { return *cursor, nil }
|
|
ds.SetMDMWindowsReconcileCursorFunc = func(ctx context.Context, c string) error {
|
|
*cursor = c
|
|
return nil
|
|
}
|
|
ds.GetWindowsProfileReconcileSnapshotFunc = windowSnapshotFunc(allHosts, profiles, delivered, calls)
|
|
ds.GetMDMWindowsProfilesContentsFunc = func(ctx context.Context, uuids []string) (map[string]fleet.MDMWindowsProfileContents, error) {
|
|
out := map[string]fleet.MDMWindowsProfileContents{}
|
|
for _, p := range profiles {
|
|
out[p.ProfileUUID] = fleet.MDMWindowsProfileContents{
|
|
SyncML: []byte(`<Replace><Item><Target><LocURI>./Test</LocURI></Target><Data>v</Data></Item></Replace>`),
|
|
Checksum: p.Checksum,
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
ds.GetExistingMDMWindowsProfileUUIDsFunc = func(ctx context.Context, uuids []string) (map[string]struct{}, error) {
|
|
out := map[string]struct{}{}
|
|
for _, p := range profiles {
|
|
out[p.ProfileUUID] = struct{}{}
|
|
}
|
|
return out, nil
|
|
}
|
|
ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) {
|
|
return &fleet.GroupedCertificateAuthorities{}, nil
|
|
}
|
|
ds.MDMWindowsBulkInsertCommandsFunc = func(ctx context.Context, cmds []*fleet.MDMWindowsCommand) error { return nil }
|
|
ds.MDMWindowsEnqueueCommandAndUpsertHostProfilesFunc = func(ctx context.Context, hostUUIDs []string, cmd *fleet.MDMWindowsCommand, payload []*fleet.MDMWindowsBulkUpsertHostProfilePayload) error {
|
|
for _, h := range hostUUIDs {
|
|
delivered[h] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
ds.BulkUpsertMDMWindowsHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMWindowsBulkUpsertHostProfilePayload) error {
|
|
return nil
|
|
}
|
|
ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// TestReconcileWindowsProfilesDeliveryCapThrottlesPerTick exercises the within-tick drain loop's delivery cap: with a large scan
|
|
// window but a small per-tick delivery cap, a bulk change (every enrolled host needs the same profile) is throttled to
|
|
// deliveryCap hosts per tick, the cursor advances only to the last delivered host, and successive ticks drain the remainder until
|
|
// the host space is exhausted (cursor resets to ""). This preserves the writer-pressure smoothing the legacy 2000-host batch
|
|
// provided.
|
|
func TestReconcileWindowsProfilesDeliveryCapThrottlesPerTick(t *testing.T) {
|
|
ctx := context.Background()
|
|
ds := new(mock.Store)
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
// Large scan window (the whole fleet fits in one window), small delivery cap, no wall-clock limit.
|
|
setReconcileWindowsBudgets(t, 100 /*scanBatch*/, 3 /*deliveryCap*/, time.Hour)
|
|
|
|
allHosts := []string{"h00", "h01", "h02", "h03", "h04", "h05", "h06", "h07", "h08", "h09"}
|
|
profiles := []*fleet.WindowsProfileForReconcile{{ProfileUUID: "shared-profile", ProfileName: "Shared", TeamID: 0, Checksum: []byte("c")}}
|
|
delivered := map[string]struct{}{}
|
|
var cursor string
|
|
newDrainLoopTestDS(ds, allHosts, profiles, delivered, &cursor, nil)
|
|
|
|
// Capture exactly which hosts each enqueue delivered (still marking them delivered for convergence).
|
|
var deliveredBatches [][]string
|
|
ds.MDMWindowsEnqueueCommandAndUpsertHostProfilesFunc = func(ctx context.Context, hostUUIDs []string, cmd *fleet.MDMWindowsCommand, payload []*fleet.MDMWindowsBulkUpsertHostProfilePayload) error {
|
|
deliveredBatches = append(deliveredBatches, append([]string{}, hostUUIDs...))
|
|
for _, h := range hostUUIDs {
|
|
delivered[h] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Tick 1: deliver the first 3 hosts (contiguous prefix); cursor advances to the last delivered host.
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Equal(t, "h02", cursor)
|
|
require.Equal(t, [][]string{{"h00", "h01", "h02"}}, deliveredBatches)
|
|
|
|
// Ticks 2-3: next 3 hosts each.
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Equal(t, "h05", cursor)
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Equal(t, "h08", cursor)
|
|
|
|
// Tick 4: final host (short window) drains and resets the cursor.
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Empty(t, cursor)
|
|
|
|
// Tick 5: empty fleet pass, cursor stays reset, nothing re-delivered.
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Empty(t, cursor)
|
|
|
|
// Every host was delivered exactly once across the ticks.
|
|
var all []string
|
|
for _, b := range deliveredBatches {
|
|
all = append(all, b...)
|
|
}
|
|
require.ElementsMatch(t, allHosts, all)
|
|
}
|
|
|
|
// TestReconcileWindowsProfilesDrainsMultipleWindowsPerTick covers the core drain behavior the other tests don't: when the delivery
|
|
// cap spans several scan windows, one tick reads window after window (cheap indexed reads) accumulating delivered hosts until the
|
|
// cap is reached mid-window. With scanBatch=2 and cap=5 over 6 hosts that all need work, tick 1 makes 3 snapshot reads (delivering
|
|
// 2+2+1) and stops at the 5th host; tick 2 delivers the remainder and resets the cursor.
|
|
func TestReconcileWindowsProfilesDrainsMultipleWindowsPerTick(t *testing.T) {
|
|
ctx := context.Background()
|
|
ds := new(mock.Store)
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
setReconcileWindowsBudgets(t, 2 /*scanBatch*/, 5 /*deliveryCap*/, time.Hour)
|
|
|
|
allHosts := []string{"h0", "h1", "h2", "h3", "h4", "h5"}
|
|
profiles := []*fleet.WindowsProfileForReconcile{{ProfileUUID: "p", ProfileName: "P", TeamID: 0, Checksum: []byte("c")}}
|
|
delivered := map[string]struct{}{}
|
|
var cursor string
|
|
var snapshotCalls int
|
|
newDrainLoopTestDS(ds, allHosts, profiles, delivered, &cursor, &snapshotCalls)
|
|
|
|
// Tick 1: drains 3 windows (2+2+1) to reach the cap of 5, stopping mid-third-window at h4.
|
|
snapshotCalls = 0
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Equal(t, 3, snapshotCalls, "one tick should read multiple windows to fill the cap")
|
|
require.Equal(t, "h4", cursor)
|
|
require.ElementsMatch(t, []string{"h0", "h1", "h2", "h3", "h4"}, setKeys(delivered))
|
|
|
|
// Tick 2: delivers the last host; the short final window resets the cursor.
|
|
snapshotCalls = 0
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Empty(t, cursor)
|
|
require.ElementsMatch(t, allHosts, setKeys(delivered))
|
|
|
|
// Tick 3: full no-op pass over the now all-delivered fleet, cursor stays reset.
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Empty(t, cursor)
|
|
}
|
|
|
|
// TestReconcileWindowsProfilesScanBudgetHaltsDrain exercises the scan-budget branch of the drain loop: when the wall-clock budget
|
|
// is already exhausted, the tick stops after the first scanned window and persists the cursor at the last scanned host (it does
|
|
// NOT keep draining to the end of the fleet, and does NOT reset the cursor). The next tick resumes from there.
|
|
func TestReconcileWindowsProfilesScanBudgetHaltsDrain(t *testing.T) {
|
|
ctx := context.Background()
|
|
ds := new(mock.Store)
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
// Small windows, generous delivery cap (so the cap never governs), and an already-expired scan budget so the loop halts after
|
|
// the first window.
|
|
setReconcileWindowsBudgets(t, 2 /*scanBatch*/, 1000 /*deliveryCap*/, time.Nanosecond)
|
|
|
|
allHosts := []string{"h0", "h1", "h2", "h3", "h4", "h5"}
|
|
// No profiles => no work; this test is purely about the scan/cursor mechanics, so execute is never reached.
|
|
delivered := map[string]struct{}{}
|
|
var cursor string
|
|
var snapshotCalls int
|
|
newDrainLoopTestDS(ds, allHosts, nil /*profiles*/, delivered, &cursor, &snapshotCalls)
|
|
|
|
// Tick 1: the budget is already spent, so only the first window is scanned and the cursor advances to its last host (not reset).
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Equal(t, 1, snapshotCalls)
|
|
require.Equal(t, "h1", cursor)
|
|
|
|
// Tick 2: resumes from the persisted cursor, reads the NEXT window and advances again, confirming progress isn't lost.
|
|
snapshotCalls = 0
|
|
require.NoError(t, ReconcileWindowsProfiles(ctx, ds, logger))
|
|
require.Equal(t, 1, snapshotCalls)
|
|
require.Equal(t, "h3", cursor)
|
|
}
|
|
|
|
func TestRekeyWindowsDevice(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
kv := new(mock.KVStore)
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{
|
|
KeyValueStore: kv,
|
|
})
|
|
|
|
var credsHash *[]byte
|
|
const testEnrollmentID uint = 123
|
|
// Captured before the local `syncml` string variable below shadows the syncml package.
|
|
pollScheduleLocURI := syncml.DMClientPollIntervalLocURI
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return &fleet.MDMWindowsEnrolledDevice{
|
|
ID: testEnrollmentID,
|
|
MDMDeviceID: "device",
|
|
HostUUID: "host-uuid-123",
|
|
CredentialsHash: credsHash,
|
|
// Loaded as 1 so the per-session refresh fires when the pending fetch returns empty (asserted at the end of
|
|
// the test); a device loaded with the flag at 0 skips the refresh entirely.
|
|
HasPendingCommands: true,
|
|
}, nil
|
|
}
|
|
|
|
ds.MDMWindowsUpdateEnrolledDeviceCredentialsFunc = func(ctx context.Context, deviceId string, credentialsHash []byte) error {
|
|
require.Equal(t, "device", deviceId)
|
|
credsHash = &credentialsHash
|
|
return nil
|
|
}
|
|
|
|
ackCalled := 0
|
|
ds.MDMWindowsAcknowledgeEnrolledDeviceCredentialsFunc = func(ctx context.Context, deviceId string) error {
|
|
require.Equal(t, "device", deviceId)
|
|
ackCalled++
|
|
return nil
|
|
}
|
|
|
|
kv.SetFunc = func(ctx context.Context, key string, value string, expireTime time.Duration) error {
|
|
return nil
|
|
}
|
|
|
|
var nonce string
|
|
kv.GetFunc = func(ctx context.Context, key string) (*string, error) {
|
|
return &nonce, nil
|
|
}
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
ServerSettings: fleet.ServerSettings{
|
|
ServerURL: "fake-mdm-server.com",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
syncml := `<SyncML xmlns="SYNCML:SYNCML1.2">
|
|
<SyncHdr>
|
|
<VerDTD>1.2</VerDTD>
|
|
<VerProto>DM/1.2</VerProto>
|
|
<SessionID>1</SessionID>
|
|
<MsgID>1</MsgID>
|
|
<Target>
|
|
<LocURI>fake-mdm-server.com</LocURI>
|
|
</Target>
|
|
<Source>
|
|
<LocURI>device</LocURI>
|
|
</Source>
|
|
</SyncHdr>
|
|
<SyncBody>
|
|
<Alert>
|
|
<CmdID>2</CmdID>
|
|
<Data>1201</Data>
|
|
</Alert>
|
|
<Final />
|
|
</SyncBody>
|
|
</SyncML>`
|
|
|
|
var req *fleet.SyncML
|
|
err := xml.Unmarshal([]byte(syncml), &req)
|
|
require.NoError(t, err)
|
|
|
|
res, err := svc.GetMDMWindowsManagementResponse(ctx, req, []*x509.Certificate{})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
|
|
seenStatuses := 0
|
|
seenReplaces := 0
|
|
seenOther := 0
|
|
var username string
|
|
var password string
|
|
for _, cmd := range res.SyncBody.Raw {
|
|
switch cmd.XMLName.Local {
|
|
case fleet.CmdStatus:
|
|
require.Equal(t, "200", *cmd.Data)
|
|
seenStatuses++
|
|
case fleet.CmdReplace:
|
|
containsAuthReplace := strings.Contains(cmd.GetTargetURI(), "AAuthName") || strings.Contains(cmd.GetTargetURI(), "AAuthSecret")
|
|
require.True(t, containsAuthReplace, "Replace command should be for AAuthName or AAuthSecret")
|
|
seenReplaces++
|
|
|
|
if strings.Contains(cmd.GetTargetURI(), "AAuthName") {
|
|
username = cmd.GetTargetData()
|
|
} else if strings.Contains(cmd.GetTargetURI(), "AAuthSecret") {
|
|
password = cmd.GetTargetData()
|
|
}
|
|
default:
|
|
seenOther++
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, 1, seenStatuses, "should have one Status command")
|
|
assert.Equal(t, 2, seenReplaces, "should have two Replace commands")
|
|
assert.Equal(t, 0, seenOther, "should not have other commands")
|
|
|
|
// Respond with no credentials again to get a nonce
|
|
res, err = svc.GetMDMWindowsManagementResponse(ctx, req, []*x509.Certificate{})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
|
|
// Require Chal in header
|
|
require.Len(t, res.SyncBody.Raw, 1, "should short circuit with challenge")
|
|
chalFound := false
|
|
for _, cmd := range res.SyncBody.Raw {
|
|
if cmd.Chal != nil {
|
|
chalFound = true
|
|
nonce = *cmd.Chal.Meta.NextNonce.Content
|
|
break
|
|
}
|
|
}
|
|
require.True(t, chalFound, "should have challenge command")
|
|
|
|
// Now respond with credentials to ack the rekey
|
|
// WE only need to mock this as we short-circuit when challenging or invalid creds
|
|
ds.MDMWindowsGetPendingCommandsFunc = func(ctx context.Context, enrollmentID uint) ([]*fleet.MDMWindowsCommand, error) {
|
|
require.Equal(t, testEnrollmentID, enrollmentID)
|
|
// A still-pending internal poll-schedule Replace must NOT block the per-session refresh: the
|
|
// has_pending_commands flag excludes poll commands by definition, so the refresh gate must too.
|
|
return []*fleet.MDMWindowsCommand{
|
|
{
|
|
CommandUUID: "poll-schedule-cmd-uuid",
|
|
RawCommand: []byte(`<Replace><CmdID>poll-schedule-cmd-uuid</CmdID></Replace>`),
|
|
TargetLocURI: pollScheduleLocURI,
|
|
},
|
|
}, nil
|
|
}
|
|
ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
|
|
return document, nil
|
|
}
|
|
// No NON-POLL pending commands means the session has drained the flag-relevant queue, so the service refreshes the
|
|
// denormalized has_pending_commands flag (at most once per session).
|
|
ds.MDMWindowsRefreshHasPendingCommandsFunc = func(ctx context.Context, enrollmentID uint) error {
|
|
require.Equal(t, testEnrollmentID, enrollmentID)
|
|
return nil
|
|
}
|
|
ds.GetWindowsMDMCommandsForResendingFunc = func(ctx context.Context, deviceID string, failedCommandIds []string) ([]*fleet.MDMWindowsCommand, error) {
|
|
return []*fleet.MDMWindowsCommand{}, nil
|
|
}
|
|
|
|
deviceCredsHash := hashMDMCredentials(username, password, nonce)
|
|
syncmlWithCreds := fmt.Sprintf(`<SyncML xmlns="SYNCML:SYNCML1.2">
|
|
<SyncHdr>
|
|
<VerDTD>1.2</VerDTD>
|
|
<VerProto>DM/1.2</VerProto>
|
|
<SessionID>1</SessionID>
|
|
<MsgID>1</MsgID>
|
|
<Target>
|
|
<LocURI>fake-mdm-server.com</LocURI>
|
|
</Target>
|
|
<Source>
|
|
<LocURI>device</LocURI>
|
|
</Source>
|
|
<Cred>
|
|
<Meta>
|
|
<Format xmlns="syncml:metinf">b64</Format>
|
|
<Type xmlns="syncml:metinf">syncml:auth-md5</Type>
|
|
</Meta>
|
|
<Data>%s</Data>
|
|
</Cred>
|
|
</SyncHdr>
|
|
<SyncBody>
|
|
<Alert>
|
|
<CmdID>2</CmdID>
|
|
<Data>1201</Data>
|
|
</Alert>
|
|
<Final />
|
|
</SyncBody>
|
|
</SyncML>`, base64.StdEncoding.EncodeToString(deviceCredsHash))
|
|
err = xml.Unmarshal([]byte(syncmlWithCreds), &req)
|
|
require.NoError(t, err)
|
|
|
|
res, err = svc.GetMDMWindowsManagementResponse(ctx, req, []*x509.Certificate{})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
|
|
require.Equal(t, 1, ackCalled, "acknowledge should have been called once")
|
|
require.True(t, ds.MDMWindowsRefreshHasPendingCommandsFuncInvoked,
|
|
"refresh should run when no non-poll commands are pending, even with a poll-schedule command still queued")
|
|
}
|
|
|
|
func hashMDMCredentials(username, password, nonce string) []byte {
|
|
credsHash := md5.Sum([]byte(username + ":" + password)) //nolint:gosec // Windows MDM Auth uses MD5
|
|
encodedCreds := base64.StdEncoding.EncodeToString(credsHash[:])
|
|
nonceHash := md5.Sum([]byte(encodedCreds + ":" + nonce)) //nolint:gosec // Windows MDM Auth uses MD5
|
|
return nonceHash[:]
|
|
}
|
|
|
|
func TestGetESPCommands(t *testing.T) {
|
|
t.Parallel()
|
|
const deviceID = "test-device-id"
|
|
const hostUUID = "test-host-uuid"
|
|
|
|
// newSvc returns a mock-backed Service with every datastore method handleESPRelease can call defaulted to a
|
|
// no-op success return. Tests override ONLY the methods whose specific behavior they care about, which
|
|
// keeps each subtest focused on its one variable instead of being a wall of mock-setup boilerplate.
|
|
//
|
|
// Tests that need a method to return an error / different value / track invocations install their own
|
|
// override; tests that need to assert "this method must NOT be called" install a t.Fatal override or
|
|
// assert ds.<Func>Invoked == false (the auto-set flag is independent of the func body).
|
|
newSvc := func(t *testing.T) (*mock.Store, *Service) {
|
|
ds := new(mock.Store)
|
|
// HostLiteByIdentifier exposes OsqueryHostID so Stage 3's setupExperienceHostUUID() resolves to the same
|
|
// key Windows orbit uses as setup_experience_status_results.host_uuid (production data shape).
|
|
osqueryHostID := "osquery-" + hostUUID
|
|
ds.HostLiteByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.HostLite, error) {
|
|
return &fleet.HostLite{ID: 1, UUID: identifier, OsqueryHostID: &osqueryHostID, TeamID: nil}, nil
|
|
}
|
|
// Stage 1, 2, 3 listings default empty so the wait gates pass through to finalize cleanly.
|
|
ds.GetWindowsMDMHostForReconcileFunc = func(ctx context.Context, hUUID string) (*fleet.WindowsHostReconcileInfo, error) {
|
|
return nil, nil
|
|
}
|
|
ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hUUID string) ([]fleet.HostMDMWindowsProfile, error) {
|
|
return nil, nil
|
|
}
|
|
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) {
|
|
return nil, nil
|
|
}
|
|
// No setup-experience items configured: empty Stage 3 disambiguates to "safe to release". Tests that
|
|
// expect waiting due to items configured override this to return true.
|
|
ds.HasWindowsSetupExperienceItemsForTeamFunc = func(ctx context.Context, teamID uint) (bool, error) {
|
|
return false, nil
|
|
}
|
|
// require_all_software_windows defaults to false via the no-team / app-config path. setRequireAll(ds, true)
|
|
// flips it for tests that need require_all=true.
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{}, nil
|
|
}
|
|
// No release attempt queued yet.
|
|
ds.MDMWindowsGetESPReleaseAckStatusFunc = func(ctx context.Context, enrollmentID uint, targetLocURI, cmdUUIDPrefix string) (*fleet.MDMWindowsESPReleaseAckStatus, error) {
|
|
return &fleet.MDMWindowsESPReleaseAckStatus{}, nil
|
|
}
|
|
// Finalize side-effects: default no-op success. Tests that need to capture, fail, or assert ordering
|
|
// install their own override.
|
|
ds.MDMWindowsInsertCommandsForHostFunc = func(ctx context.Context, hostUUIDOrDeviceID string, cmds []*fleet.MDMWindowsCommand) error {
|
|
return nil
|
|
}
|
|
ds.SetMDMWindowsAwaitingConfigurationFunc = func(ctx context.Context, mdmDeviceID string, from, to fleet.WindowsMDMAwaitingConfiguration) (bool, error) {
|
|
return true, nil
|
|
}
|
|
ds.CancelPendingSetupExperienceStepsFunc = func(ctx context.Context, hUUID string) error {
|
|
return nil
|
|
}
|
|
ds.CancelHostUpcomingActivityFunc = func(ctx context.Context, hostID uint, executionID string) (fleet.ActivityDetails, error) {
|
|
return nil, nil
|
|
}
|
|
svc := &Service{ds: ds, logger: testutils.TestLogger(t)}
|
|
svc.SetActivityService(&mock.MockActivityService{})
|
|
return ds, svc
|
|
}
|
|
|
|
// newActiveDevice returns the most common device fixture used by these tests: AwaitingConfiguration=Active
|
|
// with the standard test deviceID/hostUUID. Tests that need a different state (Pending, None) or a timeout
|
|
// timestamp construct their own struct literal.
|
|
newActiveDevice := func() *fleet.MDMWindowsEnrolledDevice {
|
|
return &fleet.MDMWindowsEnrolledDevice{
|
|
MDMDeviceID: deviceID,
|
|
HostUUID: hostUUID,
|
|
AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationActive,
|
|
}
|
|
}
|
|
|
|
t.Run("no awaiting configuration returns nil", func(t *testing.T) {
|
|
_, svc := newSvc(t)
|
|
device := &fleet.MDMWindowsEnrolledDevice{
|
|
MDMDeviceID: deviceID,
|
|
AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone,
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), device, nil)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, cmds)
|
|
})
|
|
|
|
t.Run("pending without host UUID sends hold commands", func(t *testing.T) {
|
|
_, svc := newSvc(t)
|
|
device := &fleet.MDMWindowsEnrolledDevice{
|
|
MDMDeviceID: deviceID,
|
|
HostUUID: "",
|
|
AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationPending,
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), device, nil)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cmds, "should return hold commands")
|
|
})
|
|
|
|
t.Run("pending with host UUID transitions to active", func(t *testing.T) {
|
|
ds, svc := newSvc(t)
|
|
device := &fleet.MDMWindowsEnrolledDevice{
|
|
MDMDeviceID: deviceID,
|
|
HostUUID: hostUUID,
|
|
AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationPending,
|
|
}
|
|
transitioned := false
|
|
ds.SetMDMWindowsAwaitingConfigurationFunc = func(ctx context.Context, mdmDeviceID string, from, to fleet.WindowsMDMAwaitingConfiguration) (bool, error) {
|
|
transitioned = true
|
|
return true, nil
|
|
}
|
|
|
|
// At orbit-link transition, handleESPHoldOrTransition flips awaiting_configuration to Active and
|
|
// returns a single DevicePreparation/InstallationState=3 command to advance the ESP from the
|
|
// Device-setup phase to the Account-setup phase. ESP release itself is signaled later via
|
|
// ServerHasFinishedProvisioning from buildESPReleaseCommands.
|
|
cmds, err := svc.getESPCommands(t.Context(), device, nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, cmds, 1)
|
|
assert.Contains(t, cmds[0].GetTargetURI(), "DevicePreparation/PolicyProviders/")
|
|
assert.Contains(t, cmds[0].GetTargetURI(), "/InstallationState")
|
|
assert.True(t, transitioned)
|
|
})
|
|
|
|
t.Run("active with pending profiles waits", func(t *testing.T) {
|
|
ds, svc := newSvc(t)
|
|
ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hUUID string) ([]fleet.HostMDMWindowsProfile, error) {
|
|
return []fleet.HostMDMWindowsProfile{
|
|
{ProfileUUID: "prof-1", Name: "WiFi", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall},
|
|
}, nil
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, cmds, "should wait while profiles are pending")
|
|
})
|
|
|
|
t.Run("active with verifying profiles waits", func(t *testing.T) {
|
|
ds, svc := newSvc(t)
|
|
ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hUUID string) ([]fleet.HostMDMWindowsProfile, error) {
|
|
return []fleet.HostMDMWindowsProfile{
|
|
{ProfileUUID: "prof-1", Name: "WiFi", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
|
|
}, nil
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, cmds, "should wait while profiles are verifying")
|
|
})
|
|
|
|
t.Run("active queues unqueued profiles via per-host reconcile and waits", func(t *testing.T) {
|
|
ds, svc := newSvc(t)
|
|
// setupReconcilerTest wires the execute-step mocks (contents, command insert, host-profile upserts) and an
|
|
// AppConfig with Windows MDM enabled, so the ESP stage-1 per-host reconcile can actually queue the profile.
|
|
profile := &fleet.MDMWindowsConfigProfile{ProfileUUID: "prof-1", Name: "WiFi", SyncML: syncMLForTest("./Device/WiFi")}
|
|
setupReconcilerTest(ds, map[string]*fleet.MDMWindowsConfigProfile{hostUUID: profile})
|
|
// Non-variable installs dispatch through MDMWindowsEnqueueCommandAndUpsertHostProfiles; capture its payloads.
|
|
var queued []*fleet.MDMWindowsBulkUpsertHostProfilePayload
|
|
ds.MDMWindowsEnqueueCommandAndUpsertHostProfilesFunc = func(ctx context.Context, hostUUIDs []string, cmd *fleet.MDMWindowsCommand, payload []*fleet.MDMWindowsBulkUpsertHostProfilePayload) error {
|
|
queued = append(queued, payload...)
|
|
return nil
|
|
}
|
|
ds.GetWindowsMDMHostForReconcileFunc = func(ctx context.Context, hUUID string) (*fleet.WindowsHostReconcileInfo, error) {
|
|
return &fleet.WindowsHostReconcileInfo{HostID: 1, UUID: hUUID, TeamID: nil}, nil
|
|
}
|
|
ds.ListWindowsProfilesForReconcileByTeamFunc = func(ctx context.Context, teamID uint) ([]*fleet.WindowsProfileForReconcile, error) {
|
|
return []*fleet.WindowsProfileForReconcile{
|
|
{ProfileUUID: profile.ProfileUUID, ProfileName: profile.Name, TeamID: teamID, Checksum: []byte("c")},
|
|
}, nil
|
|
}
|
|
ds.BulkGetHostLabelMembershipsFunc = func(ctx context.Context, hostIDs []uint, labelIDs []uint) (map[uint]map[uint]struct{}, error) {
|
|
return nil, nil
|
|
}
|
|
ds.BulkGetHostMDMWindowsProfilesByUUIDsFunc = func(ctx context.Context, hostUUIDs []string) (map[string][]*fleet.MDMWindowsProfilePayload, error) {
|
|
return map[string][]*fleet.MDMWindowsProfilePayload{}, nil
|
|
}
|
|
// Stage 2 sees the freshly queued (pending) row and blocks the release.
|
|
ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hUUID string) ([]fleet.HostMDMWindowsProfile, error) {
|
|
return []fleet.HostMDMWindowsProfile{
|
|
{ProfileUUID: profile.ProfileUUID, Name: profile.Name, Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall},
|
|
}, nil
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, cmds, "should wait while the freshly queued profile is pending")
|
|
require.NotEmpty(t, queued, "per-host reconcile must queue the unqueued profile")
|
|
assert.Equal(t, profile.ProfileUUID, queued[0].ProfileUUID)
|
|
assert.Equal(t, hostUUID, queued[0].HostUUID)
|
|
})
|
|
|
|
t.Run("active with failing per-host reconcile returns error and does not release", func(t *testing.T) {
|
|
ds, svc := newSvc(t)
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{MDM: fleet.MDM{WindowsEnabledAndConfigured: true}}, nil
|
|
}
|
|
ds.GetWindowsMDMHostForReconcileFunc = func(ctx context.Context, hUUID string) (*fleet.WindowsHostReconcileInfo, error) {
|
|
return nil, errors.New("boom")
|
|
}
|
|
|
|
_, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.Error(t, err, "a reconcile failure must block the release; the next checkin retries")
|
|
assert.False(t, ds.GetHostMDMWindowsProfilesFuncInvoked, "should not evaluate delivery status when reconcile failed")
|
|
})
|
|
|
|
// setRequireAll flips the require_all_software_windows lookup to the given value via the no-team /
|
|
// app-config path. Default in newSvc is false; call this with true when a test needs require_all=true. The
|
|
// team-config path is covered explicitly by its own subtest.
|
|
setRequireAll := func(ds *mock.Store, requireAll bool) {
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
ac := &fleet.AppConfig{}
|
|
ac.MDM.MacOSSetup.RequireAllSoftwareWindows = requireAll
|
|
return ac, nil
|
|
}
|
|
}
|
|
|
|
t.Run("active with all profiles delivered releases device", func(t *testing.T) {
|
|
ds, svc := newSvc(t)
|
|
ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hUUID string) ([]fleet.HostMDMWindowsProfile, error) {
|
|
return []fleet.HostMDMWindowsProfile{
|
|
{ProfileUUID: "prof-1", Name: "WiFi", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
|
|
}, nil
|
|
}
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cmds, "should return release commands")
|
|
assert.True(t, ds.MDMWindowsInsertCommandsForHostFuncInvoked,
|
|
"release path must persist final commands as the dropped-response retry backup")
|
|
assert.False(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked,
|
|
"release path must stay Active until the user-scope ServerHasFinishedProvisioning Replace acks 200")
|
|
})
|
|
|
|
t.Run("active with no profiles releases device", func(t *testing.T) {
|
|
ds, svc := newSvc(t)
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cmds, "should return release commands when no profiles configured")
|
|
assert.False(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked,
|
|
"release path must stay Active until the user-scope release is acked")
|
|
})
|
|
|
|
// The user-scope release retry phase: once release commands have been queued (ack.Attempted), the handler bypasses
|
|
// the wait gates entirely and drives the Active -> None transition off the ack of the user-scope
|
|
// ServerHasFinishedProvisioning Replace.
|
|
t.Run("user-scope release retry phase", func(t *testing.T) {
|
|
// newRetrySvc wires newSvc with the given ack status and fails the test if the wait gates are consulted:
|
|
// the retry phase must decide from the ack alone.
|
|
newRetrySvc := func(t *testing.T, ack fleet.MDMWindowsESPReleaseAckStatus) (*mock.Store, *Service) {
|
|
ds, svc := newSvc(t)
|
|
ds.MDMWindowsGetESPReleaseAckStatusFunc = func(ctx context.Context, enrollmentID uint, targetLocURI, cmdUUIDPrefix string) (*fleet.MDMWindowsESPReleaseAckStatus, error) {
|
|
require.Contains(t, targetLocURI, "./User/", "ack status must be looked up for the user-scope release URI")
|
|
require.Equal(t, espReleaseAttemptCmdIDPrefix, cmdUUIDPrefix, "ack status must be scoped to Fleet's own release attempts")
|
|
return &ack, nil
|
|
}
|
|
ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hUUID string) ([]fleet.HostMDMWindowsProfile, error) {
|
|
t.Fatal("retry phase must not re-run the profile wait gate")
|
|
return nil, nil
|
|
}
|
|
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) {
|
|
t.Fatal("retry phase must not re-run the setup experience wait gate")
|
|
return nil, nil
|
|
}
|
|
return ds, svc
|
|
}
|
|
// sessionMsg builds a minimal incoming message with the given device MsgID.
|
|
sessionMsg := func(msgID string) *fleet.SyncML {
|
|
return &fleet.SyncML{SyncHdr: fleet.SyncHdr{MsgID: msgID}}
|
|
}
|
|
|
|
t.Run("acked 200 transitions to None", func(t *testing.T) {
|
|
ds, svc := newRetrySvc(t, fleet.MDMWindowsESPReleaseAckStatus{Attempted: true, Acked200: true, LatestStatus: "200"})
|
|
var casFrom, casTo fleet.WindowsMDMAwaitingConfiguration
|
|
ds.SetMDMWindowsAwaitingConfigurationFunc = func(ctx context.Context, mdmDeviceID string, from, to fleet.WindowsMDMAwaitingConfiguration) (bool, error) {
|
|
casFrom, casTo = from, to
|
|
return true, nil
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), sessionMsg("5"))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, cmds, "nothing to send once the release is acked")
|
|
require.True(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked, "200 ack must commit the ESP completion")
|
|
assert.Equal(t, fleet.WindowsMDMAwaitingConfigurationActive, casFrom)
|
|
assert.Equal(t, fleet.WindowsMDMAwaitingConfigurationNone, casTo)
|
|
})
|
|
|
|
t.Run("attempt in flight waits", func(t *testing.T) {
|
|
ds, svc := newRetrySvc(t, fleet.MDMWindowsESPReleaseAckStatus{Attempted: true, HasUnacked: true})
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), sessionMsg("2"))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, cmds, "must not stack another attempt while one is in flight")
|
|
assert.False(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked)
|
|
assert.False(t, ds.MDMWindowsInsertCommandsForHostFuncInvoked)
|
|
})
|
|
|
|
t.Run("acked 405 re-sends the user-scope Replace at session start", func(t *testing.T) {
|
|
ds, svc := newRetrySvc(t, fleet.MDMWindowsESPReleaseAckStatus{Attempted: true, LatestStatus: "405"})
|
|
var persistedUUIDs []string
|
|
ds.MDMWindowsInsertCommandsForHostFunc = func(ctx context.Context, hostUUIDOrDeviceID string, persistCmds []*fleet.MDMWindowsCommand) error {
|
|
for _, c := range persistCmds {
|
|
persistedUUIDs = append(persistedUUIDs, c.CommandUUID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), sessionMsg("2"))
|
|
require.NoError(t, err)
|
|
require.Len(t, cmds, 1, "retry sends exactly the user-scope Replace")
|
|
assert.Equal(t, fleet.CmdReplace, cmds[0].XMLName.Local)
|
|
assert.Contains(t, cmds[0].GetTargetURI(), "./User/Vendor/MSFT/DMClient/Provider/")
|
|
assert.Contains(t, cmds[0].GetTargetURI(), "ServerHasFinishedProvisioning")
|
|
assert.True(t, strings.HasPrefix(cmds[0].CmdID.Value, espReleaseAttemptCmdIDPrefix),
|
|
"the retry CmdID must carry the attempt prefix or the ack-status lookup will never see its ack")
|
|
require.Equal(t, []string{cmds[0].CmdID.Value}, persistedUUIDs,
|
|
"the retry must be persisted with the inline CmdID so the ack clears the backup and is recorded in results")
|
|
assert.False(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked, "must stay Active until a 200 ack")
|
|
})
|
|
|
|
// If we get a 405 mid-session, we do not send another retry right away but wait for the next session (typically within 60 seconds).
|
|
t.Run("acked 405 mid-session waits for the next session", func(t *testing.T) {
|
|
ds, svc := newRetrySvc(t, fleet.MDMWindowsESPReleaseAckStatus{Attempted: true, LatestStatus: "405"})
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), sessionMsg("5"))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, cmds, "a mid-session retry would ping-pong the failing Replace")
|
|
assert.False(t, ds.MDMWindowsInsertCommandsForHostFuncInvoked)
|
|
})
|
|
|
|
t.Run("nil request message still retries", func(t *testing.T) {
|
|
// Defensive default: a missing message must err toward retrying (never retrying wedges the device).
|
|
ds, svc := newRetrySvc(t, fleet.MDMWindowsESPReleaseAckStatus{Attempted: true, LatestStatus: "405"})
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, cmds, 1)
|
|
assert.True(t, ds.MDMWindowsInsertCommandsForHostFuncInvoked)
|
|
})
|
|
|
|
t.Run("timeout gives up and transitions to None", func(t *testing.T) {
|
|
ds, svc := newRetrySvc(t, fleet.MDMWindowsESPReleaseAckStatus{Attempted: true, LatestStatus: "405"})
|
|
device := newActiveDevice()
|
|
past := time.Now().Add(-4 * time.Hour)
|
|
device.AwaitingConfigurationAt = &past
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), device, sessionMsg("2"))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, cmds, "timeout stops the retry loop")
|
|
assert.True(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked,
|
|
"the timeout must bound the retry loop for devices whose user context never initializes")
|
|
assert.False(t, ds.MDMWindowsInsertCommandsForHostFuncInvoked)
|
|
})
|
|
})
|
|
|
|
// findCmdByLocURI returns the first SyncMLCmd whose target LocURI contains
|
|
// the given substring, or nil if none match.
|
|
findCmdByLocURI := func(cmds []*fleet.SyncMLCmd, substr string) *fleet.SyncMLCmd {
|
|
for _, c := range cmds {
|
|
if c.GetTargetURI() != "" && strings.Contains(c.GetTargetURI(), substr) {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
t.Run("profile failure alone does not block even with require_all=true", func(t *testing.T) {
|
|
// Profile delivery failures (e.g. CSP not supported on the host's edition) should not trigger the ESP
|
|
// block screen. The require_all_software_windows setting is software-scoped (matching macOS), so a
|
|
// failed profile with no software failure must release the device normally.
|
|
ds, svc := newSvc(t)
|
|
ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hUUID string) ([]fleet.HostMDMWindowsProfile, error) {
|
|
return []fleet.HostMDMWindowsProfile{
|
|
{ProfileUUID: "prof-1", Name: "WiFi", Status: &fleet.MDMDeliveryFailed, OperationType: fleet.MDMOperationTypeInstall},
|
|
}, nil
|
|
}
|
|
setRequireAll(ds, true)
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cmds, "profile failure alone should release the device")
|
|
|
|
// Release path: ServerHasFinishedProvisioning is set, BlockInStatusPage is not.
|
|
assert.NotNil(t, findCmdByLocURI(cmds, "ServerHasFinishedProvisioning"),
|
|
"profile-only failure must release the device")
|
|
assert.Nil(t, findCmdByLocURI(cmds, "BlockInStatusPage"),
|
|
"profile-only failure must not block the device")
|
|
// No software failure and no timeout means no error text on the release.
|
|
assert.Nil(t, findCmdByLocURI(cmds, "CustomErrorText"),
|
|
"profile-only failure should not surface error text")
|
|
// Cancel should NOT be called: profile failures don't trigger cancel.
|
|
assert.False(t, ds.CancelPendingSetupExperienceStepsFuncInvoked,
|
|
"profile failure must not cancel pending setup experience steps")
|
|
})
|
|
|
|
t.Run("profile failure combined with software failure still blocks on software", func(t *testing.T) {
|
|
// When BOTH a profile and a software install fail, the software failure still triggers the block (with
|
|
// require_all=true) and the software-specific error text wins (because it's more actionable than a
|
|
// generic timeout/profile message).
|
|
ds, svc := newSvc(t)
|
|
ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hUUID string) ([]fleet.HostMDMWindowsProfile, error) {
|
|
return []fleet.HostMDMWindowsProfile{
|
|
{ProfileUUID: "prof-1", Name: "WiFi", Status: &fleet.MDMDeliveryFailed, OperationType: fleet.MDMOperationTypeInstall},
|
|
}, nil
|
|
}
|
|
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) {
|
|
return []*fleet.SetupExperienceStatusResult{
|
|
{Name: "Critical App", Status: fleet.SetupExperienceStatusFailure, SoftwareInstallerID: new(uint(7))},
|
|
}, nil
|
|
}
|
|
setRequireAll(ds, true)
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cmds)
|
|
|
|
assert.NotNil(t, findCmdByLocURI(cmds, "BlockInStatusPage"),
|
|
"software failure with require_all=true blocks regardless of profile state")
|
|
errCmd := findCmdByLocURI(cmds, "CustomErrorText")
|
|
require.NotNil(t, errCmd)
|
|
require.NotNil(t, errCmd.Items[0].Data)
|
|
assert.Equal(t, microsoft_mdm.ESPSoftwareFailureErrorText, errCmd.Items[0].Data.Content,
|
|
"software failure error text takes precedence over profile/timeout text")
|
|
})
|
|
|
|
t.Run("software failure with require_all=false soft blocks with continue anyway", func(t *testing.T) {
|
|
// When software fails but "Cancel setup if software fails" is off, the device still surfaces the ESP failure
|
|
// UI listing the failed software by name, with a "Continue anyway" option so the user can proceed to the
|
|
// desktop and install the missing software via self-service.
|
|
ds, svc := newSvc(t)
|
|
hostTeamID := uint(9)
|
|
osqueryHostID := "osquery-" + hostUUID
|
|
ds.HostLiteByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.HostLite, error) {
|
|
return &fleet.HostLite{ID: 1, UUID: identifier, OsqueryHostID: &osqueryHostID, TeamID: &hostTeamID}, nil
|
|
}
|
|
// require_all_software_windows stays false via the team path (zero-value team config).
|
|
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
|
|
return &fleet.TeamLite{ID: tid}, nil
|
|
}
|
|
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) {
|
|
require.Equal(t, hostTeamID, teamID,
|
|
"list call must pass the host's team ID so display-name enrichment is team-scoped")
|
|
return []*fleet.SetupExperienceStatusResult{
|
|
{Name: "slack-installer", DisplayName: "Slack", Status: fleet.SetupExperienceStatusFailure, SoftwareInstallerID: new(uint(1))},
|
|
{Name: "Zoom", Status: fleet.SetupExperienceStatusFailure, SoftwareInstallerID: new(uint(2))},
|
|
{Name: "Notepad++", Status: fleet.SetupExperienceStatusSuccess, SoftwareInstallerID: new(uint(3))},
|
|
{Name: "Docker", Status: fleet.SetupExperienceStatusFailure, SoftwareInstallerID: new(uint(4))},
|
|
}, nil
|
|
}
|
|
activitySvc := &mock.MockActivityService{}
|
|
svc.SetActivityService(activitySvc)
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cmds)
|
|
|
|
blockCmd := findCmdByLocURI(cmds, "BlockInStatusPage")
|
|
require.NotNil(t, blockCmd, "soft block must surface the ESP failure UI")
|
|
require.NotNil(t, blockCmd.Items[0].Data)
|
|
assert.Equal(t, "5", blockCmd.Items[0].Data.Content,
|
|
"soft block must offer Reset PC and Continue Anyway (1|4) per DMClient CSP bit flags")
|
|
|
|
errCmd := findCmdByLocURI(cmds, "CustomErrorText")
|
|
require.NotNil(t, errCmd)
|
|
require.NotNil(t, errCmd.Items[0].Data)
|
|
assert.Equal(t,
|
|
"Slack, Zoom, and Docker failed to install. "+
|
|
"Reset your device to try again, or proceed and install missing software via self-service. "+
|
|
"If unavailable, contact your IT admin.",
|
|
errCmd.Items[0].Data.Content,
|
|
"soft block must list only the failed software, in result order, preferring custom display names")
|
|
|
|
assert.Nil(t, findCmdByLocURI(cmds, "ServerHasFinishedProvisioning"),
|
|
"soft block must NOT signal ESP success")
|
|
assert.False(t, ds.CancelPendingSetupExperienceStepsFuncInvoked,
|
|
"soft block must not cancel setup experience steps")
|
|
assert.False(t, ds.CancelHostUpcomingActivityFuncInvoked,
|
|
"soft block must not cancel upcoming activities")
|
|
assert.False(t, activitySvc.NewActivityFuncInvoked,
|
|
"soft block must not emit canceled_setup_experience: setup was not cancelled")
|
|
assert.True(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked,
|
|
"soft block finalizes: awaiting_configuration must transition out of Active")
|
|
})
|
|
|
|
t.Run("timeout with require_all=false and a failure soft blocks listing failed software", func(t *testing.T) {
|
|
// A failure that occurred before the 3-hour timeout (with a sibling still stuck) must still be surfaced: the
|
|
// timeout path scans for failures so it lists the failed software rather than releasing silently.
|
|
ds, svc := newSvc(t)
|
|
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) {
|
|
return []*fleet.SetupExperienceStatusResult{
|
|
{Name: "Slack", Status: fleet.SetupExperienceStatusFailure, SoftwareInstallerID: new(uint(1))},
|
|
{Name: "Stuck App", Status: fleet.SetupExperienceStatusPending, SoftwareInstallerID: new(uint(2)), HostSoftwareInstallsExecutionID: new("exec-stuck")},
|
|
}, nil
|
|
}
|
|
past := time.Now().Add(-4 * time.Hour)
|
|
device := newActiveDevice()
|
|
device.AwaitingConfigurationAt = &past
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), device, nil)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cmds)
|
|
|
|
blockCmd := findCmdByLocURI(cmds, "BlockInStatusPage")
|
|
require.NotNil(t, blockCmd, "timeout with a failure must surface the ESP failure UI")
|
|
assert.Equal(t, "5", blockCmd.Items[0].Data.Content,
|
|
"timeout soft block must offer Reset PC and Continue Anyway")
|
|
errCmd := findCmdByLocURI(cmds, "CustomErrorText")
|
|
require.NotNil(t, errCmd)
|
|
assert.Equal(t,
|
|
"Slack failed to install. "+
|
|
"Reset your device to try again, or proceed and install missing software via self-service. "+
|
|
"If unavailable, contact your IT admin.",
|
|
errCmd.Items[0].Data.Content,
|
|
"timeout with a failure must list the failed software, not the timeout text")
|
|
assert.Nil(t, findCmdByLocURI(cmds, "ServerHasFinishedProvisioning"),
|
|
"timeout soft block must NOT signal ESP success")
|
|
assert.True(t, ds.CancelPendingSetupExperienceStepsFuncInvoked,
|
|
"timeout must cancel the still-pending sibling")
|
|
})
|
|
|
|
t.Run("timeout cancel tolerates upcoming activity already gone", func(t *testing.T) {
|
|
// CancelHostUpcomingActivity returns notFound when the row is already absent (e.g., a previous finalize
|
|
// attempt cancelled the queue row and crashed before the status table update; the retry sees status
|
|
// still Pending and re-tries). Tolerating notFound keeps retries idempotent; anything stricter would
|
|
// loop forever on the same checkin until the 3-hour timeout expires server-side.
|
|
ds, svc := newSvc(t)
|
|
past := time.Now().Add(-4 * time.Hour)
|
|
device := newActiveDevice()
|
|
device.AwaitingConfigurationAt = &past
|
|
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) {
|
|
return []*fleet.SetupExperienceStatusResult{
|
|
{Name: "Already Cancelled Queue", Status: fleet.SetupExperienceStatusPending, HostSoftwareInstallsExecutionID: new("exec-gone")},
|
|
}, nil
|
|
}
|
|
ds.CancelHostUpcomingActivityFunc = func(ctx context.Context, hostID uint, executionID string) (fleet.ActivityDetails, error) {
|
|
return nil, newNotFoundError()
|
|
}
|
|
|
|
_, err := svc.getESPCommands(t.Context(), device, nil)
|
|
require.NoError(t, err,
|
|
"notFound from CancelHostUpcomingActivity must be tolerated -- otherwise mid-loop crashes loop forever on retry")
|
|
assert.True(t, ds.CancelPendingSetupExperienceStepsFuncInvoked,
|
|
"after tolerating the notFound, the status-table cancel must still run so the iteration eventually clears "+
|
|
"the rows and the next retry's pending check skips them")
|
|
})
|
|
|
|
t.Run("require_all read via team config blocks when team has require_all_software_windows=true", func(t *testing.T) {
|
|
// Covers the team-path branch of the require_all_software_windows lookup chain (HostLite returns
|
|
// TeamID set -> TeamLite -> team config). Other tests use the no-team path via setRequireAll.
|
|
ds, svc := newSvc(t)
|
|
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) {
|
|
return []*fleet.SetupExperienceStatusResult{
|
|
{Name: "Critical App", Status: fleet.SetupExperienceStatusFailure, SoftwareInstallerID: new(uint(7))},
|
|
}, nil
|
|
}
|
|
// Team-path overrides: HostLite returns a host with TeamID set; TeamLite returns the team config with
|
|
// require_all_software_windows=true. AppConfig MUST NOT be consulted on the team path.
|
|
teamID := uint(42)
|
|
ds.HostLiteByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.HostLite, error) {
|
|
return &fleet.HostLite{ID: 1, UUID: identifier, TeamID: &teamID}, nil
|
|
}
|
|
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
|
|
require.Equal(t, teamID, tid, "TeamLite must be called with the host's team_id")
|
|
return &fleet.TeamLite{
|
|
ID: tid,
|
|
Config: fleet.TeamConfigLite{
|
|
MDM: fleet.TeamMDM{MacOSSetup: fleet.MacOSSetup{RequireAllSoftwareWindows: true}},
|
|
},
|
|
}, nil
|
|
}
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
ac := &fleet.AppConfig{}
|
|
ac.MDM.MacOSSetup.RequireAllSoftwareWindows = false
|
|
return ac, nil
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cmds)
|
|
assert.True(t, ds.TeamLiteFuncInvoked, "TeamLite must be called on the team path")
|
|
assert.NotNil(t, findCmdByLocURI(cmds, "BlockInStatusPage"),
|
|
"team config require_all_software_windows=true must drive the block path")
|
|
})
|
|
|
|
t.Run("persist failure aborts finalize without committing CAS", func(t *testing.T) {
|
|
// Safety property: if the persist (dropped-response retry safety net) fails, we must NOT commit the CAS
|
|
// transition Active -> None. Otherwise the device would be left without an inline send AND without the
|
|
// retry backup -- stuck on "Working on it..." forever, since awaiting_configuration=None means subsequent
|
|
// management sessions return no ESP commands. Persist runs before the CAS for exactly this reason.
|
|
ds, svc := newSvc(t)
|
|
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) {
|
|
return []*fleet.SetupExperienceStatusResult{
|
|
{Name: "Critical App", Status: fleet.SetupExperienceStatusFailure, SoftwareInstallerID: new(uint(7))},
|
|
}, nil
|
|
}
|
|
setRequireAll(ds, true)
|
|
ds.MDMWindowsInsertCommandsForHostFunc = func(ctx context.Context, hostUUIDOrDeviceID string, cmds []*fleet.MDMWindowsCommand) error {
|
|
return errors.New("transient db error")
|
|
}
|
|
ds.SetMDMWindowsAwaitingConfigurationFunc = func(ctx context.Context, mdmDeviceID string, from, to fleet.WindowsMDMAwaitingConfiguration) (bool, error) {
|
|
t.Fatal("CAS Active->None must NOT run when persist fails")
|
|
return false, nil
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.Error(t, err, "must return error so device retries on next session")
|
|
assert.Nil(t, cmds)
|
|
assert.False(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked,
|
|
"CAS must NOT have been invoked when persist fails")
|
|
})
|
|
|
|
t.Run("cancel failure aborts finalize without committing CAS", func(t *testing.T) {
|
|
// Cancel runs before persist and CAS. A transient cancel failure must abort the finalize cleanly:
|
|
// otherwise we'd commit awaiting=None while leaving non-terminal setup-experience rows behind, which is
|
|
// exactly the state cancellation is supposed to prevent. CancelPendingSetupExperienceSteps is idempotent
|
|
// so a retry on the next session is safe.
|
|
ds, svc := newSvc(t)
|
|
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) {
|
|
return []*fleet.SetupExperienceStatusResult{
|
|
{Name: "Critical App", Status: fleet.SetupExperienceStatusFailure, SoftwareInstallerID: new(uint(7))},
|
|
}, nil
|
|
}
|
|
setRequireAll(ds, true)
|
|
ds.CancelPendingSetupExperienceStepsFunc = func(ctx context.Context, hUUID string) error {
|
|
return errors.New("transient db error")
|
|
}
|
|
ds.MDMWindowsInsertCommandsForHostFunc = func(ctx context.Context, hostUUIDOrDeviceID string, cmds []*fleet.MDMWindowsCommand) error {
|
|
t.Fatal("persist must NOT run when cancel fails")
|
|
return nil
|
|
}
|
|
ds.SetMDMWindowsAwaitingConfigurationFunc = func(ctx context.Context, mdmDeviceID string, from, to fleet.WindowsMDMAwaitingConfiguration) (bool, error) {
|
|
t.Fatal("CAS Active->None must NOT run when cancel fails")
|
|
return false, nil
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.Error(t, err, "must return error so device retries on next session")
|
|
assert.Nil(t, cmds)
|
|
assert.False(t, ds.MDMWindowsInsertCommandsForHostFuncInvoked,
|
|
"persist must NOT have been invoked when cancel fails")
|
|
assert.False(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked,
|
|
"CAS must NOT have been invoked when cancel fails")
|
|
})
|
|
|
|
t.Run("require_all lookup error returns error and keeps device active", func(t *testing.T) {
|
|
// Failing AppConfig (not HostLite) ensures the test exercises the require_all chain itself rather than
|
|
// erroring out earlier at setupExperienceHostUUID. With HostLite returning a valid host (default),
|
|
// loadRequireAll proceeds to AppConfig and gets the error injected here. Property under test is the
|
|
// same regardless of which lookup fails: any error in the finalize path must keep the device Active.
|
|
ds, svc := newSvc(t)
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return nil, errors.New("transient db error")
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.Error(t, err, "must return error so device retries on next session")
|
|
assert.Nil(t, cmds)
|
|
assert.False(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked,
|
|
"must NOT transition to None on lookup failure")
|
|
})
|
|
|
|
t.Run("active waits when results empty but setup experience configured", func(t *testing.T) {
|
|
// Setup experience is configured for the team but orbit hasn't called SetupExperienceInit yet, so
|
|
// results are empty. The disambiguation must wait for orbit rather than releasing.
|
|
ds, svc := newSvc(t)
|
|
ds.HasWindowsSetupExperienceItemsForTeamFunc = func(ctx context.Context, teamID uint) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
cmds, err := svc.getESPCommands(t.Context(), newActiveDevice(), nil)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, cmds, "should wait for orbit to initialize setup experience")
|
|
// Must NOT have proceeded to the Active->None transition.
|
|
assert.False(t, ds.SetMDMWindowsAwaitingConfigurationFuncInvoked,
|
|
"must not transition state while waiting for orbit init")
|
|
})
|
|
}
|
|
|
|
func TestReconcileWindowsMDMPollSchedule(t *testing.T) {
|
|
t.Parallel()
|
|
const deviceID = "test-device-id"
|
|
const enrollmentID = uint(7)
|
|
|
|
// assertEnqueuedInterval parses the captured raw command exactly as the session delivery path does, confirming it is a well-formed
|
|
// Replace on the Poll node with the expected interval.
|
|
assertEnqueuedInterval := func(t *testing.T, cmd *fleet.MDMWindowsCommand, interval string) {
|
|
t.Helper()
|
|
require.NotNil(t, cmd, "a poll command should have been enqueued")
|
|
assert.Equal(t, syncml.DMClientPollIntervalLocURI, cmd.TargetLocURI)
|
|
assert.NotEmpty(t, cmd.CommandUUID)
|
|
parsed, err := fleet.UnmarshallMultiTopLevelXMLProfile(cmd.RawCommand)
|
|
require.NoError(t, err)
|
|
require.Len(t, parsed, 1)
|
|
assert.Equal(t, fleet.CmdReplace, parsed[0].XMLName.Local)
|
|
assert.Equal(t, syncml.DMClientPollIntervalLocURI, parsed[0].GetTargetURI())
|
|
assert.Equal(t, interval, parsed[0].GetTargetData())
|
|
}
|
|
|
|
// The reconcile relaxes the poll iff the host's persisted fleetd_sync_capable differs from its current poll_schedule_relaxed: it enqueues
|
|
// a Replace carrying the relaxed (480m) or fast (1m) interval and records the new intended state. The not-capable+fast case also covers
|
|
// the unlinked / never-reported-capable enrollment (fleetd_sync_capable defaults to false).
|
|
for _, c := range []struct {
|
|
name string
|
|
syncCapable bool
|
|
relaxed bool
|
|
wantEnqueue bool
|
|
wantInterval string // only checked when wantEnqueue
|
|
}{
|
|
{"capable host on fast poll is relaxed", true, false, true, windowsMDMRelaxedPollIntervalMinutes},
|
|
{"capable host already relaxed is a no-op", true, true, false, ""},
|
|
{"not-capable host marked relaxed is restored to fast", false, true, true, windowsMDMFastPollIntervalMinutes},
|
|
{"not-capable host already on fast poll is a no-op", false, false, false, ""},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
var enqueued *fleet.MDMWindowsCommand
|
|
var intendedRelaxed *bool
|
|
ds := new(mock.Store)
|
|
ds.MDMWindowsEnqueuePollScheduleCommandFunc = func(
|
|
ctx context.Context, mdmDeviceID string, id uint, cmd *fleet.MDMWindowsCommand, relaxed bool,
|
|
) error {
|
|
assert.Equal(t, deviceID, mdmDeviceID, "poll command must target the enrollment's device id")
|
|
assert.Equal(t, enrollmentID, id)
|
|
enqueued, intendedRelaxed = cmd, &relaxed
|
|
return nil
|
|
}
|
|
svc := &Service{ds: ds, logger: testutils.TestLogger(t)}
|
|
|
|
device := &fleet.MDMWindowsEnrolledDevice{
|
|
ID: enrollmentID, MDMDeviceID: deviceID, PollScheduleRelaxed: c.relaxed, FleetdSyncCapable: c.syncCapable,
|
|
}
|
|
require.NoError(t, svc.reconcileWindowsMDMPollSchedule(t.Context(), device))
|
|
|
|
require.Equal(t, c.wantEnqueue, ds.MDMWindowsEnqueuePollScheduleCommandFuncInvoked)
|
|
if c.wantEnqueue {
|
|
assertEnqueuedInterval(t, enqueued, c.wantInterval)
|
|
require.NotNil(t, intendedRelaxed)
|
|
// The recorded intended state always equals the capability (relax iff capable).
|
|
assert.Equal(t, c.syncCapable, *intendedRelaxed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHasAuthorizedAzureAudience covers the audience-matching logic that authorizes Entra-issued tokens for Windows
|
|
// automatic enrollment, including the v2 (client ID / GUID `aud`) path added for issue #46388 and the unchanged v1
|
|
// (server-URL `aud`) path.
|
|
func TestHasAuthorizedAzureAudience(t *testing.T) {
|
|
const (
|
|
serverHost = "fleet.example.com"
|
|
clientID = "11111111-1111-1111-1111-111111111111"
|
|
clientID2 = "22222222-2222-2222-2222-222222222222"
|
|
serverURL = "https://fleet.example.com"
|
|
)
|
|
for _, tc := range []struct {
|
|
name string
|
|
audiences []string
|
|
clientIDs []string
|
|
want bool
|
|
}{
|
|
// v1 (server URL) path - unchanged behavior, no client IDs configured.
|
|
{"v1 server URL, no client IDs", []string{serverURL}, nil, true},
|
|
{"v1 server URL with path", []string{serverURL + "/some/path"}, nil, true},
|
|
{"v1 host case-insensitive (RFC 3986)", []string{"https://Fleet.Example.COM"}, nil, true},
|
|
{"v1 same host different port is rejected", []string{"https://fleet.example.com:8443"}, nil, false},
|
|
{"v1 different host", []string{"https://evil.example.com"}, nil, false},
|
|
|
|
// v2 (client ID) path.
|
|
{"v2 client ID match", []string{clientID}, []string{clientID}, true},
|
|
{"v2 matches second configured client ID", []string{clientID2}, []string{clientID, clientID2}, true},
|
|
{"v2 client ID, case-insensitive aud", []string{strings.ToUpper(clientID)}, []string{clientID}, true},
|
|
{"v2 client ID with surrounding whitespace", []string{" " + clientID + " "}, []string{clientID}, true},
|
|
{"v2 client ID not in allowlist", []string{"99999999-9999-9999-9999-999999999999"}, []string{clientID}, false},
|
|
|
|
// Backward compatibility: a v2-style GUID aud with no client IDs configured is not authorized.
|
|
{"GUID aud, no client IDs configured", []string{clientID}, nil, false},
|
|
|
|
// Mixed / multiple audiences: any one match wins.
|
|
{"multiple auds, client ID wins", []string{"urn:something", clientID}, []string{clientID}, true},
|
|
{"multiple auds, server URL wins", []string{"urn:something", serverURL}, []string{clientID}, true},
|
|
{"multiple auds, none match", []string{"urn:something", "https://other.example.com"}, []string{clientID}, false},
|
|
|
|
// Degenerate inputs.
|
|
{"empty audiences", nil, []string{clientID}, false},
|
|
{"empty everything", nil, nil, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.want, hasAuthorizedAzureAudience(tc.audiences, serverHost, tc.clientIDs))
|
|
})
|
|
}
|
|
|
|
// A GUID aud must not match a misconfigured (empty) serverHost - GUIDs parse to a URL with an empty host.
|
|
t.Run("empty serverHost does not match GUID aud", func(t *testing.T) {
|
|
assert.False(t, hasAuthorizedAzureAudience([]string{clientID}, "", nil))
|
|
})
|
|
}
|
|
|
|
// TestHasAuthorizedAzureTenant covers the tenant-matching logic that authorizes Entra-issued tokens by the `tid`
|
|
// claim. The comparison is case-insensitive: the GUID validator accepts upper-case tenant IDs, and
|
|
// Entra emits `tid` lower-cased, so a tenant ID stored with upper-case hex must still authorize enrollment.
|
|
func TestHasAuthorizedAzureTenant(t *testing.T) {
|
|
const (
|
|
tenantA = "1a86b496-e2a4-43ef-ba00-20004e29b13b"
|
|
tenantB = "6dca58c4-c817-4730-831b-f3348931df05"
|
|
)
|
|
for _, tc := range []struct {
|
|
name string
|
|
tenantIDs []string
|
|
token string
|
|
want bool
|
|
}{
|
|
{"exact match", []string{tenantA}, tenantA, true},
|
|
{"matches second configured", []string{tenantA, tenantB}, tenantB, true},
|
|
{"configured upper, token lower", []string{strings.ToUpper(tenantB)}, tenantB, true},
|
|
{"configured lower, token upper", []string{tenantB}, strings.ToUpper(tenantB), true},
|
|
{"surrounding whitespace", []string{" " + tenantA + " "}, tenantA, true},
|
|
{"not configured", []string{tenantA}, tenantB, false},
|
|
{"empty configured", nil, tenantA, false},
|
|
{"empty token", []string{tenantA}, "", false},
|
|
{"empty token with whitespace", []string{tenantA}, " ", false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.want, hasAuthorizedAzureTenant(tc.tenantIDs, tc.token))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsFleetdPresentOnDevice covers the fleetd-presence decision for a Windows MDM session.
|
|
func TestIsFleetdPresentOnDevice(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
enrolledAt := time.Date(2026, 6, 10, 9, 36, 32, 0, time.UTC)
|
|
|
|
cases := []struct {
|
|
name string
|
|
nonUPN bool // enroll_user_id is a device token (programmatic enrollment), not a UPN
|
|
unlinked bool // enrollment not yet linked to a host
|
|
noVersion bool // host_orbit_info has an empty version
|
|
seenOffset time.Duration // host's last check-in, relative to the enrollment's created_at
|
|
wantPresent bool
|
|
}{
|
|
{name: "non-UPN enrollment is always present", nonUPN: true, wantPresent: true},
|
|
{name: "UPN not yet linked to a host", unlinked: true, wantPresent: false},
|
|
{name: "UPN with empty orbit version", noVersion: true, seenOffset: time.Minute, wantPresent: false},
|
|
{name: "UPN stale check-in before enrollment (wipe)", seenOffset: -20 * 24 * time.Hour, wantPresent: false},
|
|
{name: "UPN fresh check-in after enrollment", seenOffset: time.Minute, wantPresent: true},
|
|
{name: "UPN check-in within grace before enrollment", seenOffset: -fleetdPresenceGracePeriod / 2, wantPresent: true},
|
|
// Exactly on the threshold (seen_time == created_at - grace) must count as present: the check is inclusive
|
|
// ("at/after"). This fails under a strict After() comparison and passes under !Before().
|
|
{name: "UPN check-in exactly at grace boundary", seenOffset: -fleetdPresenceGracePeriod, wantPresent: true},
|
|
{name: "UPN check-in beyond grace before enrollment", seenOffset: -2 * fleetdPresenceGracePeriod, wantPresent: false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
enrollUser := "alice@example.com"
|
|
if tc.nonUPN {
|
|
enrollUser = "device-token"
|
|
}
|
|
hostUUID := "host-1"
|
|
if tc.unlinked {
|
|
hostUUID = ""
|
|
}
|
|
version := "1.56.2"
|
|
if tc.noVersion {
|
|
version = ""
|
|
}
|
|
|
|
ds := new(mock.Store)
|
|
ds.HostLiteByIdentifierFunc = func(context.Context, string) (*fleet.HostLite, error) {
|
|
return &fleet.HostLite{ID: 1, SeenTime: enrolledAt.Add(tc.seenOffset)}, nil
|
|
}
|
|
ds.GetHostOrbitInfoFunc = func(context.Context, uint) (*fleet.HostOrbitInfo, error) {
|
|
return &fleet.HostOrbitInfo{Version: version}, nil
|
|
}
|
|
svc := &Service{ds: ds}
|
|
|
|
present, err := svc.isFleetdPresentOnDevice(t.Context(), &fleet.MDMWindowsEnrolledDevice{
|
|
MDMEnrollUserID: enrollUser,
|
|
HostUUID: hostUUID,
|
|
CreatedAt: enrolledAt,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.wantPresent, present)
|
|
})
|
|
}
|
|
}
|