Skip to content

Android Wi-Fi profile withheld until cert installed on device - #42877

Merged
getvictor merged 17 commits into
mainfrom
victor/42405-android-onc
Apr 7, 2026
Merged

Android Wi-Fi profile withheld until cert installed on device#42877
getvictor merged 17 commits into
mainfrom
victor/42405-android-onc

Conversation

@getvictor

@getvictor getvictor commented Apr 2, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #42405

Demo video: https://www.youtube.com/watch?v=F3nfFvwdj-c

Checklist for submitter

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

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.

Testing

  • Added/updated automated tests
  • QA'd all new/changed functionality manually

Summary by CodeRabbit

  • New Features
    • Android Wi‑Fi configuration profiles that reference client certificates are withheld until the certificate is installed or reaches a terminal state.
    • Host OS settings now show the specific pending reason in the detail column when Android profiles are waiting on certificate installation.

@getvictor
getvictor requested a review from Copilot April 2, 2026 12:07
@getvictor

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR ensures Android Wi-Fi (ONC) profiles that reference a client certificate alias are not delivered until the referenced certificate is in a terminal state on the device, and adds GitOps-time validation to catch mismatched ClientCertKeyPairAlias references.

Changes:

  • Withhold Android openNetworkConfiguration profiles that reference ClientCertKeyPairAlias until the matching certificate is verified (or terminal failed) on the host.
  • Add fleetctl gitops validation that ONC ClientCertKeyPairAlias values match certificate template names defined in the same GitOps config.
  • Add unit/integration tests for ONC alias extraction, withholding behavior, and GitOps validation.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
server/mdm/android/service/profiles.go Adds ONC/certificate withholding during Android profile reconciliation.
server/datastore/mysql/host_certificate_templates.go Adds datastore query to fetch cert template statuses by name for a host.
server/fleet/datastore.go Extends datastore interface with cert-status lookup by host/name.
server/mock/datastore_mock.go Updates mock datastore to support new interface method.
server/mdm/android/onc.go Adds minimal ONC parsing + alias extraction helpers.
server/mdm/android/onc_test.go Tests ONC/profile alias extraction.
server/mdm/android/service/profiles_filter_test.go Tests filtering/withholding behavior given cert statuses.
server/mdm/android/service/profiles_test.go Adds reconciler-level test covering withheld→released behavior.
server/service/client.go Adds GitOps validation for ONC alias ↔ certificate template name matching.
server/service/client_onc_validation_test.go Tests GitOps ONC alias validation.
server/service/integration_android_certificate_templates_test.go Adds end-to-end integration test for withholding until cert verified.
changes/42405-android-onc-after-cert Adds user-visible change notes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/datastore/mysql/host_certificate_templates.go Outdated
Comment thread server/service/client.go Outdated
Comment thread server/service/client.go Outdated
@coderabbitai

coderabbitai Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Withholds Android ONC profiles that reference certificates via ClientCertKeyPairAlias until the referenced certificate on the host reaches a terminal state (verified or failed). Adds ONC JSON extraction helpers, a batched datastore method to fetch per-host certificate template statuses, filtering in profile reconciliation to withhold and annotate pending ONC profiles, requeueing of withheld profiles when certificate status changes, UI adjustments to show pending detail, and unit/integration tests covering the end-to-end behavior.

Possibly related PRs

  • fleetdm/fleet PR 36978: Modifies host_certificate_templates handling and status semantics used by the new batched datastore query.
  • fleetdm/fleet PR 36258: Touches Android MDM certificate-template reconciliation and datastore APIs that overlap with the reconciliation/filtering logic added here.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.93% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning PR description is incomplete; missing required sections from template including data validation, timeouts, endpoint compatibility, testing details, database checks, and configuration settings. Complete the PR description by filling in all relevant checklist sections from the template: data validation/SQL injection checks, testing details, database migration notes, and any configuration setting requirements.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: Android Wi-Fi profiles with certificate dependencies are withheld until the certificate is installed on the device.
Linked Issues check ✅ Passed The PR implements both core objectives from #42405: (1) reconciler-side profile withholding for ONC profiles pending certificate installation, and (2) extracting certificate aliases and checking statuses. GitOps validation is out of scope in this PR.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the Android ONC profile withholding feature. Minor changes (Storybook config, reflection refactor in google_client.go) are supporting updates for the feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch victor/42405-android-onc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/datastore/mysql/host_certificate_templates.go`:
- Around line 179-195: The query in the stmt that selects from
host_certificate_templates and builds result :=
map[string]fleet.CertificateTemplateStatus is missing a filter to only consider
install operations, causing nondeterministic overwrites when multiple rows for
the same template exist; update the SQL (the stmt used with sqlx.SelectContext)
to add a WHERE clause (or extend the existing WHERE) that restricts
hct.operation (or the operation column name used in host_certificate_templates)
to the install action so that the rows slice and the loop populating
result[r.Name] = fleet.CertificateTemplateStatus(r.Status) only reflect install
rows.

In `@server/mdm/android/service/profiles.go`:
- Around line 170-176: The code currently "fails open" when
r.DS.GetCertificateTemplateStatusesByNameForHost returns an error; instead of
proceeding you must withhold affected profiles: when
GetCertificateTemplateStatusesByNameForHost returns err, log the error and move
all candidate profiles (profilesToMerge) into withheldProfiles and clear
profilesToMerge (or return an error to abort sending) so no ONC profiles are
merged/sent; keep the normal path using
filterProfilesWithPendingCerts(certStatuses) when there is no error.
- Around line 463-466: The code currently treats errors from
android.ExtractCertAliasesFromProfileJSON(content) as "ready" by appending prof
to ready; change this to fail-closed: if err != nil then do not append prof and
instead return the error (or mark the profile as not ready) so ONC parsing
failures block delivery; only append prof to ready when err == nil and
len(aliases) > 0. Ensure you update the handling around
ExtractCertAliasesFromProfileJSON, ready, and prof accordingly.

In `@server/service/client.go`:
- Around line 3259-3263: Call validateONCCertificateReferences earlier in the
GitOps flow so ONC alias validation runs before any writes: move the invocation
out of the certificate application path and into the start of DoGitOps (or
immediately before ApplyGroup) so it executes before the zero-cert early-return
path that skips processing when android_settings.certificates is empty; ensure
validateONCCertificateReferences(config, certificates) is invoked even when the
certificates slice is empty so invalid ClientCertKeyPairAlias values are caught
and returned before any ApplyGroup or persistence occurs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1e9f0570-b601-4821-850d-8e94f86ac92f

📥 Commits

Reviewing files that changed from the base of the PR and between ddd9cf6 and c9141a8.

📒 Files selected for processing (12)
  • changes/42405-android-onc-after-cert
  • server/datastore/mysql/host_certificate_templates.go
  • server/fleet/datastore.go
  • server/mdm/android/onc.go
  • server/mdm/android/onc_test.go
  • server/mdm/android/service/profiles.go
  • server/mdm/android/service/profiles_filter_test.go
  • server/mdm/android/service/profiles_test.go
  • server/mock/datastore_mock.go
  • server/service/client.go
  • server/service/client_onc_validation_test.go
  • server/service/integration_android_certificate_templates_test.go

Comment thread server/datastore/mysql/host_certificate_templates.go Outdated
Comment thread server/mdm/android/service/profiles.go Outdated
Comment thread server/mdm/android/service/profiles.go Outdated
Comment thread server/service/client.go Outdated
@codecov

codecov Bot commented Apr 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.72093% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.87%. Comparing base (62a3316) to head (f6073b3).
⚠️ Report is 17 commits behind head on main.

Files with missing lines Patch % Lines
...rver/datastore/mysql/host_certificate_templates.go 70.37% 5 Missing and 3 partials ⚠️
server/mdm/android/service/profiles.go 88.70% 5 Missing and 2 partials ⚠️
server/mdm/android/onc.go 89.28% 2 Missing and 1 partial ⚠️
server/platform/logging/testutils/test_logger.go 40.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #42877      +/-   ##
==========================================
+ Coverage   66.85%   66.87%   +0.01%     
==========================================
  Files        2581     2583       +2     
  Lines      206998   207056      +58     
  Branches     9294     9279      -15     
==========================================
+ Hits       138386   138462      +76     
+ Misses      56041    56022      -19     
- Partials    12571    12572       +1     
Flag Coverage Δ
backend 68.65% <83.20%> (+0.01%) ⬆️
backend-activity 86.35% <ø> (ø)
frontend 54.76% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@getvictor

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
server/mdm/android/onc.go (1)

42-53: Consider de-duplicating extracted aliases.

If the same alias appears in multiple network blocks, returning unique aliases avoids redundant status checks downstream.

♻️ Proposed refactor
 func ExtractCertAliasesFromONC(oncJSON json.RawMessage) ([]string, error) {
 	var onc oncConfig
 	if err := json.Unmarshal(oncJSON, &onc); err != nil {
 		return nil, err
 	}
 
 	var aliases []string
+	seen := make(map[string]struct{})
+	addAlias := func(alias string) {
+		if alias == "" {
+			return
+		}
+		if _, ok := seen[alias]; ok {
+			return
+		}
+		seen[alias] = struct{}{}
+		aliases = append(aliases, alias)
+	}
+
 	for _, nc := range onc.NetworkConfigurations {
-		if nc.WiFi != nil && nc.WiFi.EAP != nil && nc.WiFi.EAP.ClientCertKeyPairAlias != "" {
-			aliases = append(aliases, nc.WiFi.EAP.ClientCertKeyPairAlias)
+		if nc.WiFi != nil && nc.WiFi.EAP != nil {
+			addAlias(nc.WiFi.EAP.ClientCertKeyPairAlias)
 		}
-		if nc.Ethernet != nil && nc.Ethernet.EAP != nil && nc.Ethernet.EAP.ClientCertKeyPairAlias != "" {
-			aliases = append(aliases, nc.Ethernet.EAP.ClientCertKeyPairAlias)
+		if nc.Ethernet != nil && nc.Ethernet.EAP != nil {
+			addAlias(nc.Ethernet.EAP.ClientCertKeyPairAlias)
 		}
-		if nc.VPN != nil && nc.VPN.ClientCertKeyPairAlias != "" {
-			aliases = append(aliases, nc.VPN.ClientCertKeyPairAlias)
+		if nc.VPN != nil {
+			addAlias(nc.VPN.ClientCertKeyPairAlias)
 		}
 	}
 	return aliases, nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/mdm/android/onc.go` around lines 42 - 53, The loop that appends
ClientCertKeyPairAlias values from onc.NetworkConfigurations into the aliases
slice can produce duplicates; change the collection to deduplicate by using a
temporary set (e.g., map[string]struct{}) keyed by the alias while iterating
over nc.WiFi, nc.Ethernet, and nc.VPN ClientCertKeyPairAlias values, then build
the final aliases slice from the set (or replace with a helper like
uniqueStrings) so aliases contains only unique entries before returning or using
it.
server/service/integration_android_certificate_templates_test.go (1)

1752-1767: Strengthen “applied” assertions with policy inclusion fields.

Right now “applied” is inferred from status=pending and detail text only. Consider asserting policy_request_uuid and/or included_in_policy_version are non-NULL so this can’t pass on false positives.

Also applies to: 1795-1803

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/integration_android_certificate_templates_test.go` around
lines 1752 - 1767, The test currently infers a profile was applied by checking
profileStatuses[0].Status == "pending" and the Detail text; strengthen this by
querying and asserting the database fields that indicate policy inclusion—update
the SELECT in the mysql.ExecAdhocSQL call (the query used to populate
profileStatuses) or add a separate query against host_mdm_android_profiles to
fetch policy_request_uuid and included_in_policy_version for the same
host/profile, then add require.NotNil (or equivalent) assertions on those fields
for the entries (e.g., the camera-policy and other profile entries) so the test
verifies policy_request_uuid and/or included_in_policy_version are non-NULL
instead of relying solely on Status and Detail.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.stories.tsx`:
- Around line 7-16: Lint errors come from empty async/no-op callbacks in the
story (e.g., noop and the NotificationWrapper's renderFlash, renderMultiFlash,
hideFlash), so replace their empty bodies with minimal returned values or
Storybook action calls: for noop return Promise.resolve(undefined) (or use
action('noop') if using `@storybook/addon-actions`), and have
NotificationWrapper's functions return a value (e.g., renderFlash: (msg)=>msg or
Promise.resolve) or call Storybook actions so they are non-empty and satisfy
no-empty-function lint rule; update references to noop and NotificationWrapper
accordingly.
- Line 13: The multiline object literal for the notification prop is not
Prettier-formatted; reformat the object/array literals (e.g., the notification:
{ alertType: null, isVisible: false, message: null, persistOnPageChange: false }
entry in the OSSettingsTable story) to follow Prettier's multiline wrapping
rules (one property per line, proper indentation and trailing commas where
required) so the file passes formatting checks; run Prettier or your project's
formatter on OSSettingsTable.stories.tsx and apply the same formatting to the
other reported location around line 169.

In `@server/mdm/android/service/androidmgmt/google_client.go`:
- Around line 171-173: Remove the redundant shadowing assignment inside the loop
over t.Fields(); instead of reassigning f with `f := f`, use the loop variable
directly (the for loop over t.Fields() already provides a fresh variable per
iteration in Go 1.26.1), i.e., update the loop in the code that iterates over
t.Fields() so the closure-capture mitigation (`f := f`) is removed and
subsequent uses such as the call to f.Tag.Lookup("json") reference the loop
variable directly.

In `@server/mdm/android/service/profiles_test.go`:
- Line 1310: The struct literal sets IncludedInPolicyVersion: new(1), which is
invalid because new expects a type; replace it with a pointer-to-int value
(e.g., create an int variable and take its address, use a small helper like
intPtr(1), or inline func(i int) *int { return &i }(1)) so
IncludedInPolicyVersion is a *int; update the test in profiles_test.go where the
struct with IncludedInPolicyVersion is constructed.

In `@server/mdm/android/service/pubsub_test.go`:
- Line 323: The code uses invalid Go syntax new(1)/new(2) to create integer
pointers (e.g., policyVersion := new(1)); replace these with the helper that
returns *int (e.g., policyVersion := ptr.Int(1) or ptr.Int(2)) across the test
file, updating each occurrence where an int pointer is needed so the code
compiles; look for variables like policyVersion and other test vars using
new(1)/new(2) and switch them to ptr.Int(value).

---

Nitpick comments:
In `@server/mdm/android/onc.go`:
- Around line 42-53: The loop that appends ClientCertKeyPairAlias values from
onc.NetworkConfigurations into the aliases slice can produce duplicates; change
the collection to deduplicate by using a temporary set (e.g.,
map[string]struct{}) keyed by the alias while iterating over nc.WiFi,
nc.Ethernet, and nc.VPN ClientCertKeyPairAlias values, then build the final
aliases slice from the set (or replace with a helper like uniqueStrings) so
aliases contains only unique entries before returning or using it.

In `@server/service/integration_android_certificate_templates_test.go`:
- Around line 1752-1767: The test currently infers a profile was applied by
checking profileStatuses[0].Status == "pending" and the Detail text; strengthen
this by querying and asserting the database fields that indicate policy
inclusion—update the SELECT in the mysql.ExecAdhocSQL call (the query used to
populate profileStatuses) or add a separate query against
host_mdm_android_profiles to fetch policy_request_uuid and
included_in_policy_version for the same host/profile, then add require.NotNil
(or equivalent) assertions on those fields for the entries (e.g., the
camera-policy and other profile entries) so the test verifies
policy_request_uuid and/or included_in_policy_version are non-NULL instead of
relying solely on Status and Detail.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 267fbdff-73d3-4a3c-833b-a4be5169fe29

📥 Commits

Reviewing files that changed from the base of the PR and between ddd9cf6 and 1a2aa4d.

📒 Files selected for processing (18)
  • .storybook/main.ts
  • changes/42405-android-onc-after-cert
  • frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/OSSettingsErrorCell.tsx
  • frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.stories.tsx
  • server/datastore/mysql/host_certificate_templates.go
  • server/fleet/datastore.go
  • server/mdm/android/onc.go
  • server/mdm/android/onc_test.go
  • server/mdm/android/service/androidmgmt/google_client.go
  • server/mdm/android/service/endpoint_utils.go
  • server/mdm/android/service/profiles.go
  • server/mdm/android/service/profiles_filter_test.go
  • server/mdm/android/service/profiles_test.go
  • server/mdm/android/service/pubsub.go
  • server/mdm/android/service/pubsub_test.go
  • server/mdm/android/service/service.go
  • server/mock/datastore_mock.go
  • server/service/integration_android_certificate_templates_test.go

Comment thread server/mdm/android/service/androidmgmt/google_client.go Outdated
Comment thread server/mdm/android/service/profiles_test.go
Comment thread server/mdm/android/service/pubsub_test.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 11 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/mdm/android/service/pubsub_test.go Outdated
Comment thread server/mdm/android/service/pubsub_test.go Outdated
Comment thread server/mdm/android/service/pubsub_test.go Outdated
Comment thread server/mdm/android/service/pubsub_test.go Outdated
Comment thread server/mdm/android/service/pubsub_test.go Outdated
Comment thread server/mdm/android/service/pubsub_test.go Outdated
Comment thread server/mdm/android/service/pubsub_test.go Outdated
Comment thread server/mdm/android/service/pubsub_test.go Outdated
Comment thread server/mdm/android/service/pubsub_test.go Outdated
Comment thread server/mdm/android/service/profiles_test.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
server/mdm/android/service/profiles.go (2)

178-234: ⚠️ Potential issue | 🟠 Major

Withheld profiles not persisted on max-failure early-return path.

When setFailCount >= maxRequestFailures (line 190), the function returns at line 234 without adding withheld profiles to bulkProfilesByUUID. These profiles lose their "Waiting for certificate..." status until the next reconcile.

The withheldProfiles slice is populated at line 182 before this check, but they're only added to bulkProfilesByUUID at lines 394-404, which is after this early return.

💡 Suggested fix

Add withheld profiles before returning on max-failure path:

 		}
+		// Persist withheld ONC profiles even on failure path
+		for _, prof := range withheldProfiles {
+			status := fleet.MDMDeliveryPending
+			bulkProfilesByUUID[prof.ProfileUUID] = &fleet.MDMAndroidProfilePayload{
+				HostUUID:      hostUUID,
+				Status:        &status,
+				OperationType: fleet.MDMOperationTypeInstall,
+				ProfileUUID:   prof.ProfileUUID,
+				ProfileName:   prof.ProfileName,
+				Detail:        prof.Detail,
+			}
+		}
 		return slices.Collect(maps.Values(bulkProfilesByUUID)), nil
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/mdm/android/service/profiles.go` around lines 178 - 234, The
early-return when setFailCount >= maxRequestFailures omits persisting
withheldProfiles, causing their "Waiting for certificate..." status to be lost;
before returning in that branch (the block using setFailCount,
maxRequestFailures, and building bulkProfilesByUUID), iterate withheldProfiles
and add each to bulkProfilesByUUID with HostUUID, OperationType (use
prof.OperationType or infer install/remove), Status pointing to the
waiting/certificate-pending state (same representation used later for withheld
handling), ProfileUUID/ProfileName and appropriate Detail, then return as
currently done so withheldProfiles are persisted even on the max-failure fast
path.

340-341: ⚠️ Potential issue | 🟠 Major

Withheld profiles not persisted on patchPolicy failure path.

Similar to the max-failure path, when patchPolicyReqFailed is true (line 340), the function returns at line 341 without persisting withheld profiles. Add them before this early return as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/mdm/android/service/profiles.go` around lines 340 - 341, The early
return when patchPolicyReqFailed is true skips persisting withheld profiles;
locate the same persistence logic used on the "max-failure" path (the code that
writes/flushes withheld profiles) and invoke it just before the return in the
branch that checks patchPolicyReqFailed (the block that currently returns
slices.Collect(maps.Values(bulkProfilesByUUID)), nil). Ensure you call the
identical persistence routine (the function/logic that handles withheld
profiles) using the same inputs (e.g., bulkProfilesByUUID or the withheld
subset) so withheld profiles are saved before returning.
♻️ Duplicate comments (1)
server/mdm/android/service/profiles.go (1)

460-471: ⚠️ Potential issue | 🟠 Major

Parse errors bypass ONC certificate gating.

When ExtractCertAliasesFromProfileJSON fails, the profile is excluded from result and will pass through filterProfilesWithPendingCerts as "ready" (line 484-486). This delivers potentially invalid ONC profiles.

Consider withholding profiles with extraction errors or treating them as failed:

💡 Suggested approach
 		aliases, err := android.ExtractCertAliasesFromProfileJSON(content)
 		if err != nil {
-			// Should not happen since profiles are validated on upload.
-			logger.ErrorContext(ctx, "failed to extract ONC cert aliases from profile", "profile.uuid", profileUUID, "err", err)
-			ctxerr.Handle(ctx, err)
-			continue
+			// Mark for withholding or failure - don't deliver a potentially broken ONC
+			return nil, ctxerr.Wrapf(ctx, err, "extract ONC cert aliases from profile %s", profileUUID)
 		}

Or mark the profile for withholding by setting a sentinel value in the map.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/mdm/android/service/profiles.go` around lines 460 - 471, When
ExtractCertAliasesFromProfileJSON(profile content) returns an error, do not just
skip the profile; instead mark it as withheld by inserting a sentinel value into
result keyed by profileUUID (e.g., define a package-level constant like
CERT_ALIAS_EXTRACTION_ERROR_SENTINEL and set result[profileUUID] =
[]string{CERT_ALIAS_EXTRACTION_ERROR_SENTINEL}) so downstream
filterProfilesWithPendingCerts can detect and treat it as not-ready/failed. Keep
the existing logger.ErrorContext and ctxerr.Handle calls, remove the
continue-only behavior, and ensure the sentinel is documented so other code can
check for it.
🧹 Nitpick comments (3)
server/platform/logging/testutils/test_logger.go (1)

26-28: Consider trimming trailing newline to avoid double-spacing.

slog.TextHandler appends a newline to each log record, and t.Log also adds its own newline. This will produce extra blank lines between log entries in test output.

🔧 Optional fix to trim trailing newline
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+)
+
 func (w tLogWriter) Write(p []byte) (int, error) {
-	w.t.Log(string(p))
+	w.t.Log(string(bytes.TrimRight(p, "\n")))
 	return len(p), nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/platform/logging/testutils/test_logger.go` around lines 26 - 28, The
test logger's Write method (tLogWriter.Write) passes the raw byte slice to
t.Log, which combined with slog.TextHandler's appended newline causes blank
lines; update tLogWriter.Write to trim trailing newline(s) from p (e.g., remove
trailing '\n' and/or '\r\n') before converting to string and calling w.t.Log,
then return the original len(p) and nil as before so callers see the same byte
count.
server/service/integration_android_certificate_templates_test.go (1)

1778-1793: Exercise the real release path instead of forcing DB state.

This test switches the certificate row to verified and nulls host_mdm_android_profiles.status directly, so it still passes if the service stops requeueing withheld profiles when a certificate becomes terminal. Driving the transition through the fleetd status endpoint would validate the intended end-to-end behavior and makes it easy to cover the terminal failed path too.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/integration_android_certificate_templates_test.go` around
lines 1778 - 1793, The test is directly mutating DB rows (UPDATE on
host_certificate_templates and host_mdm_android_profiles via mysql.ExecAdhocSQL)
instead of exercising the real release path; change it to drive the certificate
state transition through the fleetd status endpoint (the same endpoint used in
production to mark a certificate as verified/failed) for the host UUID used in
the test so the service requeues withheld Android profiles naturally, then call
s.awaitTriggerAndroidProfileSchedule(t) and assert expected behavior; also add a
subcase that posts a terminal failed status via the fleetd status endpoint to
cover the failed path instead of nulling host_mdm_android_profiles.status
directly.
server/mdm/android/service/profiles_filter_test.go (1)

48-86: Cover delivering and delivered in the blocker matrix.

The contract here is “withhold until terminal”, but this table only proves pending blocks and verified/failed release. A regression that treats delivering or delivered as ready would slip through this suite.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/mdm/android/service/profiles_filter_test.go` around lines 48 - 86, The
tests for filter currently assert that CertificateTemplateStatus pending blocks
and verified/failed release, but they miss non-terminal states delivering and
delivered; add two test cases (e.g., "delivering cert withholds ONC profile" and
"delivered cert withholds ONC profile") that call oncProfile("p1",
"wifi-profile", "my-cert") and pass certStatuses with "my-cert":
fleet.CertificateTemplateDelivering and fleet.CertificateTemplateDelivered
respectively, then assert ready is empty and withheld contains the expected
Waiting for certificate "my-cert" detail; this ensures filter handles
delivering/delivered like pending until a terminal status (verified/failed).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/fleet/datastore.go`:
- Around line 2780-2782: The current contract for
RequeueWithheldONCProfilesForHost is too broad; update its implementation so the
SQL UPDATE only targets rows that are ONC installs currently waiting on a
certificate (i.e., narrow the WHERE clause to status = the certificate-waiting
sentinel such as "withheld_onc_waiting_cert" and the ONC/install kind
discriminator such as profile_type/install_kind = "ONC", plus host_uuid =
hostUUID) instead of all pending Android profiles; adjust the query and any used
constants/enum names in the function to reflect this precise scoping.

---

Outside diff comments:
In `@server/mdm/android/service/profiles.go`:
- Around line 178-234: The early-return when setFailCount >= maxRequestFailures
omits persisting withheldProfiles, causing their "Waiting for certificate..."
status to be lost; before returning in that branch (the block using
setFailCount, maxRequestFailures, and building bulkProfilesByUUID), iterate
withheldProfiles and add each to bulkProfilesByUUID with HostUUID, OperationType
(use prof.OperationType or infer install/remove), Status pointing to the
waiting/certificate-pending state (same representation used later for withheld
handling), ProfileUUID/ProfileName and appropriate Detail, then return as
currently done so withheldProfiles are persisted even on the max-failure fast
path.
- Around line 340-341: The early return when patchPolicyReqFailed is true skips
persisting withheld profiles; locate the same persistence logic used on the
"max-failure" path (the code that writes/flushes withheld profiles) and invoke
it just before the return in the branch that checks patchPolicyReqFailed (the
block that currently returns slices.Collect(maps.Values(bulkProfilesByUUID)),
nil). Ensure you call the identical persistence routine (the function/logic that
handles withheld profiles) using the same inputs (e.g., bulkProfilesByUUID or
the withheld subset) so withheld profiles are saved before returning.

---

Duplicate comments:
In `@server/mdm/android/service/profiles.go`:
- Around line 460-471: When ExtractCertAliasesFromProfileJSON(profile content)
returns an error, do not just skip the profile; instead mark it as withheld by
inserting a sentinel value into result keyed by profileUUID (e.g., define a
package-level constant like CERT_ALIAS_EXTRACTION_ERROR_SENTINEL and set
result[profileUUID] = []string{CERT_ALIAS_EXTRACTION_ERROR_SENTINEL}) so
downstream filterProfilesWithPendingCerts can detect and treat it as
not-ready/failed. Keep the existing logger.ErrorContext and ctxerr.Handle calls,
remove the continue-only behavior, and ensure the sentinel is documented so
other code can check for it.

---

Nitpick comments:
In `@server/mdm/android/service/profiles_filter_test.go`:
- Around line 48-86: The tests for filter currently assert that
CertificateTemplateStatus pending blocks and verified/failed release, but they
miss non-terminal states delivering and delivered; add two test cases (e.g.,
"delivering cert withholds ONC profile" and "delivered cert withholds ONC
profile") that call oncProfile("p1", "wifi-profile", "my-cert") and pass
certStatuses with "my-cert": fleet.CertificateTemplateDelivering and
fleet.CertificateTemplateDelivered respectively, then assert ready is empty and
withheld contains the expected Waiting for certificate "my-cert" detail; this
ensures filter handles delivering/delivered like pending until a terminal status
(verified/failed).

In `@server/platform/logging/testutils/test_logger.go`:
- Around line 26-28: The test logger's Write method (tLogWriter.Write) passes
the raw byte slice to t.Log, which combined with slog.TextHandler's appended
newline causes blank lines; update tLogWriter.Write to trim trailing newline(s)
from p (e.g., remove trailing '\n' and/or '\r\n') before converting to string
and calling w.t.Log, then return the original len(p) and nil as before so
callers see the same byte count.

In `@server/service/integration_android_certificate_templates_test.go`:
- Around line 1778-1793: The test is directly mutating DB rows (UPDATE on
host_certificate_templates and host_mdm_android_profiles via mysql.ExecAdhocSQL)
instead of exercising the real release path; change it to drive the certificate
state transition through the fleetd status endpoint (the same endpoint used in
production to mark a certificate as verified/failed) for the host UUID used in
the test so the service requeues withheld Android profiles naturally, then call
s.awaitTriggerAndroidProfileSchedule(t) and assert expected behavior; also add a
subcase that posts a terminal failed status via the fleetd status endpoint to
cover the failed path instead of nulling host_mdm_android_profiles.status
directly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2fffe80b-c02d-4922-9485-b3f3792d7342

📥 Commits

Reviewing files that changed from the base of the PR and between ddd9cf6 and 051af04.

📒 Files selected for processing (19)
  • .storybook/main.ts
  • changes/42405-android-onc-after-cert
  • frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/OSSettingsErrorCell.tsx
  • frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.stories.tsx
  • server/datastore/mysql/host_certificate_templates.go
  • server/datastore/mysql/host_certificate_templates_test.go
  • server/fleet/datastore.go
  • server/fleet/host_certificate_template.go
  • server/mdm/android/onc.go
  • server/mdm/android/onc_test.go
  • server/mdm/android/service/androidmgmt/google_client.go
  • server/mdm/android/service/endpoint_utils.go
  • server/mdm/android/service/profiles.go
  • server/mdm/android/service/profiles_filter_test.go
  • server/mdm/android/service/profiles_test.go
  • server/mock/datastore_mock.go
  • server/platform/logging/testutils/test_logger.go
  • server/service/certificates.go
  • server/service/integration_android_certificate_templates_test.go

Comment thread server/fleet/datastore.go Outdated
@getvictor
getvictor marked this pull request as ready for review April 6, 2026 20:04
@getvictor
getvictor requested review from a team as code owners April 6, 2026 20:04

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

canRotateRecoveryLockPassword && (isFailed || isVerified);
const value = (isFailed && profile.detail) || DEFAULT_EMPTY_CELL_VALUE;
const value =
((isFailed || isPending) && profile.detail) || DEFAULT_EMPTY_CELL_VALUE;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to do any filtering for macos/windows here? They also use similar profile statuses and can be resent, correct?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about special casing Android here, but I didn't see anything in the codebase that set a detail status for pending items. So, it seemed like doing the general approach was simpler and less code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also worst case it shows a stale message

SELECT hct.host_uuid, ct.name, hct.status
FROM host_certificate_templates hct
JOIN certificate_templates ct ON ct.id = hct.certificate_template_id
WHERE hct.host_uuid IN (?) AND hct.operation_type = ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe MySQL will use the unique key on (host_uuid, certificate_template_id) to satisfy the host_uuid IN (?), then evaluate operation_type = ? as a post-filter on the matched rows, since there is not index on host_certificate_templates on (host_uuid, operation_type). But this feels like over engineering here, and we probably will never have any performance issues.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Should not be an issue with small number of templates per host. But we do need to have a real load test for Android hosts.

Comment thread server/datastore/mysql/host_certificate_templates.go
Comment thread server/datastore/mysql/host_certificate_templates.go Outdated
…3080)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #42405

Also, fixed bug with this edge case: Certificate removed while ONC
profile is withheld

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
@getvictor
getvictor marked this pull request as draft April 7, 2026 18:31
@getvictor
getvictor marked this pull request as ready for review April 7, 2026 20:18

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@getvictor

Copy link
Copy Markdown
Member Author

@ksykulev This is ready for re-review. I removed the unnecessary DB calls and also added the gitops ordering change.

@ksykulev ksykulev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 looks good.

@getvictor
getvictor merged commit 36ad83f into main Apr 7, 2026
58 checks passed
@getvictor
getvictor deleted the victor/42405-android-onc branch April 7, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android: deploy Wi-Fi configuration profile after certificate is deployed

3 participants