Files
fleet/server/service/microsoft_mdm_test.go
T
Victor LyuboslavskyandSharon Katz f04430a5a6 Added redirect_uri validation for Windows TOS endpoint. (#49699)
<!-- 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>
2026-07-22 09:24:40 -05:00

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&amp;T")
require.Contains(t, payload, "&lt;Reader&gt;")
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)
})
}
}