Merge branch 'main' into embedded-vnc

This commit is contained in:
Viktor Liu
2026-09-09 09:30:51 +02:00
115 changed files with 4562 additions and 1324 deletions
+34
View File
@@ -0,0 +1,34 @@
package mdm
import "sync"
// ChangeDetector tracks the last observed policy of a Loader so an
// OS-notification-driven caller can ask whether the managed configuration
// actually changed before restarting anything.
type ChangeDetector struct {
mu sync.Mutex
loader *Loader
prev *Policy
}
// NewChangeDetector constructs a ChangeDetector seeded with the loader's
// current policy, so only a later change reports as changed.
func NewChangeDetector(loader *Loader) *ChangeDetector {
return &ChangeDetector{
loader: loader,
prev: loader.Load(),
}
}
// Changed re-reads the policy, logs the per-key diff, and reports whether it
// diverged from the last observation; the new snapshot becomes the baseline.
func (d *ChangeDetector) Changed() bool {
d.mu.Lock()
defer d.mu.Unlock()
curr := d.loader.Load()
if !policyChanged(d.prev, curr) {
return false
}
d.prev = curr
return true
}
+116
View File
@@ -0,0 +1,116 @@
package mdm
import (
"net/url"
"github.com/netbirdio/netbird/util"
)
// PreSharedKeyRedactedSentinel is the redaction mask returned in place of a
// real pre-shared key; an incoming value equal to it is a round-trip echo,
// never an override.
const PreSharedKeyRedactedSentinel = "**********"
// ConflictCheck is a value-aware comparison between a single requested field
// and the corresponding MDM-enforced value.
type ConflictCheck struct {
Key string
Check func(*Policy) bool
}
// ConflictBool builds a ConflictCheck for a boolean MDM key.
func ConflictBool(key string, p *bool) ConflictCheck {
return ConflictCheck{
Key: key,
Check: func(pol *Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetBool(key)
return ok && want == *p
},
}
}
// ConflictStringPtr builds a ConflictCheck for an optional string MDM key,
// where an explicit empty value is still a request to change the setting. A
// nil p means "field not set" (no override requested).
func ConflictStringPtr(key string, p *string) ConflictCheck {
return ConflictCheck{
Key: key,
Check: func(pol *Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetString(key)
return ok && want == *p
},
}
}
// ConflictURL builds a ConflictCheck for a URL-typed MDM key. The two sides are
// compared as the endpoints they address, not as strings: see
// util.SameServiceURL.
func ConflictURL(key, got string) ConflictCheck {
return ConflictCheck{
Key: key,
Check: func(pol *Policy) bool {
if got == "" {
return true
}
want, ok := pol.GetString(key)
return ok && util.SameServiceURLStrings(want, got)
},
}
}
// ConflictInt64 builds a ConflictCheck for an integer MDM key.
func ConflictInt64(key string, p *int64) ConflictCheck {
return ConflictCheck{
Key: key,
Check: func(pol *Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetInt(key)
return ok && want == *p
},
}
}
// ResolveConflicts returns the names of keys whose requested value diverges
// from the policy-enforced value; keys the policy does not manage are skipped,
// a managed key without a Check counts as a conflict.
func ResolveConflicts(policy *Policy, checks []ConflictCheck) []string {
if policy.IsEmpty() {
return nil
}
var conflicts []string
for _, c := range checks {
if !policy.HasKey(c.Key) {
continue
}
if c.Check == nil || !c.Check(policy) {
conflicts = append(conflicts, c.Key)
}
}
return conflicts
}
// CanonicalURL normalizes a service URL by appending the scheme default port
// when none is present; unparseable input is returned unchanged.
func CanonicalURL(s string) string {
u, err := url.ParseRequestURI(s)
if err != nil {
return s
}
if u.Port() == "" {
switch u.Scheme {
case "https":
u.Host += ":443"
case "http":
u.Host += ":80"
}
}
return u.String()
}
+40
View File
@@ -0,0 +1,40 @@
package mdm
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The same spellings, through the conflict check that decides whether a request
// is refused. An enforced URL restated in another spelling addresses the very
// server the policy names, so it must not be reported as a conflict.
func TestConflictURLComparesEndpoints(t *testing.T) {
policy := NewPolicy(map[string]any{KeyManagementURL: "https://mgmt.example.com"})
require.True(t, policy.HasKey(KeyManagementURL))
for _, restated := range []string{
"https://mgmt.example.com",
"https://mgmt.example.com:443",
"https://mgmt.example.com/",
"https://MGMT.example.com",
"https://mgmt.example.com:0443",
} {
conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, restated)})
assert.Empty(t, conflicts, "%q is the enforced endpoint written differently", restated)
}
for _, diverging := range []string{
"https://other.example.com",
"http://mgmt.example.com",
"https://mgmt.example.com:8443",
"https://mgmt.example.com/other",
} {
conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, diverging)})
assert.Equal(t, []string{KeyManagementURL}, conflicts, "%q addresses another endpoint", diverging)
}
// An unset field is not a request to change anything.
assert.Empty(t, ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, "")}))
}
+34
View File
@@ -0,0 +1,34 @@
package mdm
import (
"encoding/json"
log "github.com/sirupsen/logrus"
)
type jsonPolicyFetcher struct {
fetch func() string
}
// NewJSONLoader constructs a Loader whose policy source is a JSON-encoded
// object string, as produced by the mobile native layers; a nil fetch
// disables MDM enforcement.
func NewJSONLoader(fetch func() string) *Loader {
if fetch == nil {
return NewLoader(nil)
}
return NewLoader(&jsonPolicyFetcher{fetch: fetch})
}
func (f *jsonPolicyFetcher) Fetch() map[string]any {
raw := f.fetch()
if raw == "" {
return nil
}
var out map[string]any
if err := json.Unmarshal([]byte(raw), &out); err != nil {
log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err)
return nil
}
return out
}
+38 -6
View File
@@ -121,16 +121,46 @@ func NewPolicy(values map[string]any) *Policy {
return &Policy{values: values}
}
// LoadPolicy reads the platform-native MDM configuration. Returns an
// empty (but non-nil) Policy when no source is present, the source is
// empty, or the platform is unsupported.
// PolicyFetcher supplies the managed configuration to a Loader. Mobile
// platforms (Android / iOS) implement it to push the OS-managed values
// into the Go runtime. On every platform a non-nil fetcher takes
// precedence over the native source, which is the test seam for the
// registry / plist loaders; a nil fetcher leaves the native source in
// charge, or disables MDM enforcement where there is none.
type PolicyFetcher interface {
Fetch() map[string]any
}
// Loader is the DI-friendly entry point for reading the active MDM
// policy. Construct one at the daemon's lifecycle owner (Server on
// desktop, gomobile-exposed bridge on mobile) and pass it to anything
// that needs to read MDM state (the reload ticker, profilemanager's
// Config). Each callsite has the Loader handed in instead of looking
// up package-level state.
type Loader struct {
fetcher PolicyFetcher
}
// NewLoader constructs a Loader. A non-nil fetcher takes precedence over
// the platform-native source; production desktop callers pass nil so the
// registry / plist stays authoritative.
func NewLoader(f PolicyFetcher) *Loader {
return &Loader{fetcher: f}
}
// Load reads the platform-native MDM configuration and returns a
// Policy. Returns an empty (but non-nil) Policy when no source is
// present, the source is empty, or the platform is unsupported.
//
// Diagnostic logging differentiates the three states:
// - source absent / unsupported platform: trace log only
// - source present, zero keys: info "MDM enrolled (no managed keys)"
// - source present, N keys: info "MDM enrolled with N managed keys: [...]"
func LoadPolicy() *Policy {
values, err := loadPlatformPolicy()
func (l *Loader) Load() *Policy {
if l == nil {
return &Policy{values: map[string]any{}}
}
values, err := l.loadPlatform()
if err != nil {
log.Tracef("MDM policy load: %v", err)
return &Policy{values: map[string]any{}}
@@ -207,6 +237,8 @@ func (p *Policy) GetBool(key string) (bool, bool) {
return t != 0, true
case int64:
return t != 0, true
case float64:
return t != 0, true
}
return false, false
}
@@ -272,7 +304,7 @@ func (p *Policy) GetStringSlice(key string) ([]string, bool) {
}
// sortedKeys returns the keys of m as a deterministic, lexicographically
// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's
// sorted slice. Used internally by Policy.ManagedKeys and Loader.Load's
// diagnostic log line so callers see a stable key order across runs
// regardless of Go's randomised map iteration.
func sortedKeys(m map[string]any) []string {
+11 -4
View File
@@ -25,8 +25,9 @@ import (
// writable plist, as a defense against tampered installs.
const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist"
// loadPlatformPolicy reads the MDM-managed configuration from the macOS
// managed-preferences plist at policyPlistPath. Returns:
// loadPlatform reads the MDM-managed configuration from the macOS
// managed-preferences plist at policyPlistPath, unless a fetcher was
// injected, in which case its values are returned instead. Returns:
// - (nil, nil) when the plist is absent (device not MDM-enrolled for
// NetBird, or admin has not yet pushed a payload)
// - (map, nil) with N entries when N managed values are present
@@ -39,13 +40,19 @@ const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist"
// skipped so a stray entry in the payload does not block startup.
// Native plist value types map naturally onto the Policy accessor
// expectations (GetString / GetBool / GetInt / GetStringSlice).
func loadPlatformPolicy() (map[string]any, error) {
func (l *Loader) loadPlatform() (map[string]any, error) {
// Honour the injected fetcher when present so tests (and any
// future non-macOS MDM channel) can short-circuit the plist read
// with a scripted policy.
if l != nil && l.fetcher != nil {
return l.fetcher.Fetch(), nil
}
f, err := os.Open(policyPlistPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// Not enrolled for NetBird. Caller treats nil as
// "no MDM source present".
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load.
return nil, nil
}
return nil, fmt.Errorf("open %s: %w", policyPlistPath, err)
+10 -9
View File
@@ -2,13 +2,14 @@
package mdm
// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS,
// Kotlin/Java on Android) reads the OS managed-config store and pushes the
// resulting dictionary in-process via a gomobile entry point that lands in
// Phase 5 / Phase 6. The stub keeps the package compilable for mobile
// builds and returns (nil, nil) — the platform-absent sentinel that
// LoadPolicy in policy.go treats as "no MDM source present".
func loadPlatformPolicy() (map[string]any, error) {
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
return nil, nil
// loadPlatform reads the OS-managed configuration via the native
// PolicyFetcher injected at Loader construction. Returns
// (nil, nil) — the platform-absent sentinel that Loader.Load treats as
// "no MDM source present" — when no fetcher was provided.
func (l *Loader) loadPlatform() (map[string]any, error) {
if l == nil || l.fetcher == nil {
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load.
return nil, nil
}
return l.fetcher.Fetch(), nil
}
+12 -8
View File
@@ -2,13 +2,17 @@
package mdm
// loadPlatformPolicy returns no policy on platforms without an MDM channel
// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if
// the feature did not exist. Returns (nil, nil) — the platform-absent
// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM
// source present"; an error here would just translate to the same
// outcome with an extra log line.
func loadPlatformPolicy() (map[string]any, error) {
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
// loadPlatform reads the MDM policy on platforms without a native MDM
// channel (Linux, FreeBSD). When no fetcher was injected the policy is
// (nil, nil) — the platform-absent sentinel that Loader.Load treats as
// "MDM enforcement disabled". A non-nil fetcher takes precedence: it
// is the test-seam used by unit tests to inject a scripted policy
// without touching the OS, and the same hook supports any future
// non-mobile OS that grows an out-of-band MDM channel.
func (l *Loader) loadPlatform() (map[string]any, error) {
if l != nil && l.fetcher != nil {
return l.fetcher.Fetch(), nil
}
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load.
return nil, nil
}
+26 -5
View File
@@ -1,6 +1,7 @@
package mdm
import (
"runtime"
"testing"
"github.com/stretchr/testify/assert"
@@ -95,7 +96,8 @@ func TestPolicy_GetBool(t *testing.T) {
{"int64 nonzero", int64(2), true, true},
{"int64 zero", int64(0), false, true},
{"string garbage", "maybe", false, false},
{"float unsupported", 1.0, false, false},
{"float nonzero", 1.0, true, true},
{"float zero", 0.0, false, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -155,10 +157,29 @@ func TestPolicy_GetStringSlice(t *testing.T) {
})
}
func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) {
// loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must
// degrade gracefully and never return nil.
p := LoadPolicy()
// encoding/json decodes every JSON number into float64, so the mobile
// loaders never see int.
func TestJSONLoader_BoolFromNumber(t *testing.T) {
p := NewJSONLoader(func() string { return `{"blockInbound":1,"disableProfiles":0}` }).Load()
got, ok := p.GetBool(KeyBlockInbound)
assert.True(t, ok)
assert.True(t, got)
got, ok = p.GetBool(KeyDisableProfiles)
assert.True(t, ok)
assert.False(t, got)
}
func TestLoader_NilFetcherReturnsEmpty(t *testing.T) {
// Loader.Load with no fetcher (desktop construction) must degrade
// gracefully and never return nil; on linux loadPlatform is a stub
// returning (nil, nil), and Load is expected to translate that
// into a non-nil empty Policy.
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
t.Skip("a nil fetcher reads the OS-managed policy on this platform")
}
p := NewLoader(nil).Load()
require.NotNil(t, p)
assert.True(t, p.IsEmpty())
assert.Empty(t, p.ManagedKeys())
+11 -4
View File
@@ -61,8 +61,9 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an
}
}
// loadPlatformPolicy reads the MDM-managed configuration from the
// Windows registry under HKLM\Software\Policies\NetBird. Returns:
// loadPlatform reads the MDM-managed configuration from the Windows
// registry under HKLM\Software\Policies\NetBird, unless a fetcher was
// injected, in which case its values are returned instead. Returns:
// - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird)
// - (map, nil) with N entries when N managed values are set (N may be 0)
// - (nil, err) on open / enumerate registry errors
@@ -70,12 +71,18 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an
// Per-value type coercion + skip-on-error is delegated to
// readRegistryValue. Unknown value names are logged and skipped so a
// malformed deployment does not block startup.
func loadPlatformPolicy() (map[string]any, error) {
func (l *Loader) loadPlatform() (map[string]any, error) {
// Honour the injected fetcher when present so tests (and any
// future non-Windows MDM channel) can short-circuit the registry
// read with a scripted policy.
if l != nil && l.fetcher != nil {
return l.fetcher.Fetch(), nil
}
k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE)
if err != nil {
if errors.Is(err, registry.ErrNotExist) {
// Not enrolled. Caller treats nil as "no MDM source present".
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load.
return nil, nil
}
return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err)
+91
View File
@@ -0,0 +1,91 @@
package mdm
import "encoding/json"
// Fields carries the per-key MDM enforcement state for a UI: value-typed
// fields hold the enforced value (nil pointer = not managed), boolean
// fields report that the key is managed.
type Fields struct {
ManagementURL string `json:"managementURL"`
PreSharedKey bool `json:"preSharedKey"`
WireguardPort bool `json:"wireguardPort"`
RosenpassEnabled bool `json:"rosenpassEnabled"`
RosenpassPermissive bool `json:"rosenpassPermissive"`
DisableClientRoutes bool `json:"disableClientRoutes"`
DisableServerRoutes bool `json:"disableServerRoutes"`
AllowServerSSH *bool `json:"allowServerSSH"`
AllowServerVNC *bool `json:"allowServerVNC"`
DisableVNCApproval bool `json:"disableVNCApproval"`
DisableAutoConnect bool `json:"disableAutoConnect"`
DisableAutostart bool `json:"disableAutostart"`
BlockInbound bool `json:"blockInbound"`
DisableMetricsCollection bool `json:"disableMetricsCollection"`
SplitTunnelMode bool `json:"splitTunnelMode"`
SplitTunnelApps bool `json:"splitTunnelApps"`
DisableAdvancedView *bool `json:"disableAdvancedView"`
}
// Features carries the feature gates a UI must honor.
type Features struct {
DisableProfiles bool `json:"disableProfiles"`
DisableNetworks bool `json:"disableNetworks"`
DisableUpdateSettings bool `json:"disableUpdateSettings"`
}
// Restrictions is the UI-facing enforcement snapshot; the JSON shape is
// shared by the desktop frontend and the mobile bridges.
type Restrictions struct {
MDM Fields `json:"mdm"`
Features Features `json:"features"`
}
// BuildRestrictions derives the UI enforcement snapshot from the active
// policy.
func BuildRestrictions(policy *Policy) Restrictions {
var r Restrictions
if policy.IsEmpty() {
return r
}
if v, ok := policy.GetString(KeyManagementURL); ok {
r.MDM.ManagementURL = CanonicalURL(v)
}
r.MDM.PreSharedKey = policy.HasKey(KeyPreSharedKey)
r.MDM.WireguardPort = policy.HasKey(KeyWireguardPort)
r.MDM.RosenpassEnabled = policy.HasKey(KeyRosenpassEnabled)
r.MDM.RosenpassPermissive = policy.HasKey(KeyRosenpassPermissive)
r.MDM.DisableClientRoutes = policy.HasKey(KeyDisableClientRoutes)
r.MDM.DisableServerRoutes = policy.HasKey(KeyDisableServerRoutes)
r.MDM.DisableAutoConnect = policy.HasKey(KeyDisableAutoConnect)
r.MDM.DisableAutostart = policy.HasKey(KeyDisableAutostart)
r.MDM.BlockInbound = policy.HasKey(KeyBlockInbound)
r.MDM.DisableMetricsCollection = policy.HasKey(KeyDisableMetricsCollection)
r.MDM.SplitTunnelMode = policy.HasKey(KeySplitTunnelMode)
r.MDM.SplitTunnelApps = policy.HasKey(KeySplitTunnelApps)
if v, ok := policy.GetBool(KeyAllowServerSSH); ok {
r.MDM.AllowServerSSH = &v
}
if v, ok := policy.GetBool(KeyDisableAdvancedView); ok {
r.MDM.DisableAdvancedView = &v
}
if v, ok := policy.GetBool(KeyDisableProfiles); ok {
r.Features.DisableProfiles = v
}
if v, ok := policy.GetBool(KeyDisableNetworks); ok {
r.Features.DisableNetworks = v
}
if v, ok := policy.GetBool(KeyDisableUpdateSettings); ok {
r.Features.DisableUpdateSettings = v
}
return r
}
// JSON renders the snapshot in the shared UI JSON shape.
func (r Restrictions) JSON() (string, error) {
b, err := json.Marshal(r)
if err != nil {
return "", err
}
return string(b), nil
}
+26 -20
View File
@@ -15,33 +15,33 @@ import (
// instead, hence anticipating the ticker mechanism entirely.
const DefaultReloadInterval = 1 * time.Minute
// policyLoader is the indirection through which the ticker reads the
// OS-native policy, both for the initial observation and on every tick.
// Production points it at LoadPolicy; tests in this package override it to
// feed a scripted sequence of policies without touching the real OS store.
var policyLoader = LoadPolicy
// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and
// invokes the onChange callback (supplied to Run) whenever the observed
// Policy diverges from the last observation (added / removed / changed
// keys). Launch with Run from a goroutine; cancel the supplied context
// to stop.
// Ticker periodically re-reads the OS-native MDM policy via the
// injected Loader and invokes the onChange callback (supplied to Run)
// whenever the observed Policy diverges from the last observation
// (added / removed / changed keys). Launch with Run from a goroutine;
// cancel the supplied context to stop.
type Ticker struct {
interval time.Duration
loader *Loader
prev *Policy
}
// NewTicker constructs a Ticker that will re-read the OS-native policy
// every reloadInterval once Run is called.
// The initial snapshot is populated by calling policyLoader at
// every reloadInterval once Run is called. The Loader is injected so
// the ticker doesn't depend on any package-level state — production
// passes the daemon-owned Loader, tests pass a fake Loader (built with
// a fake PolicyFetcher).
//
// The initial snapshot is populated by calling loader.Load() at
// construction time so the first tick only fires
// onChange when the policy actually changed since boot — without
// this baseline the first tick would report every currently-managed
// key as "added" and trigger a spurious engine restart.
func NewTicker(reloadInterval time.Duration) *Ticker {
func NewTicker(reloadInterval time.Duration, loader *Loader) *Ticker {
return &Ticker{
interval: reloadInterval,
prev: policyLoader(),
loader: loader,
prev: loader.Load(),
}
}
@@ -58,13 +58,10 @@ func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) erro
log.Info("MDM policy reload ticker stopped")
return
case <-tk.C:
curr := policyLoader()
if policiesEqual(t.prev, curr) {
curr := t.loader.Load()
if !policyChanged(t.prev, curr) {
continue
}
added, removed, changed := diffPolicies(t.prev, curr)
log.Infof("MDM policy changed: added=%v removed=%v changed=%v",
added, removed, changed)
prev := t.prev
if err := onChange(prev, curr); err != nil {
log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err)
@@ -127,3 +124,12 @@ func mapOf(p *Policy) map[string]any {
}
return out
}
func policyChanged(prev, curr *Policy) bool {
if policiesEqual(prev, curr) {
return false
}
added, removed, changed := diffPolicies(prev, curr)
log.Infof("MDM policy changed: added=%v removed=%v changed=%v", added, removed, changed)
return true
}
+38 -29
View File
@@ -13,28 +13,40 @@ import (
// testReloadInterval for speeding up the ticker cadence under `go test`
const testReloadInterval = 1 * time.Second
// withPolicyLoader overrides the package-level policyLoader for the duration
// of the test so the ticker observes a scripted policy instead of the real
// OS-native store. The original loader is restored on cleanup.
func withPolicyLoader(t *testing.T, fn func() *Policy) {
t.Helper()
prev := policyLoader
policyLoader = fn
t.Cleanup(func() { policyLoader = prev })
// fakePolicyFetcher implements PolicyFetcher returning a scripted
// policy map. Goroutine-safe so the test can mutate the script while
// the ticker is observing it.
type fakePolicyFetcher struct {
mu sync.Mutex
values map[string]any
}
func (f *fakePolicyFetcher) Fetch() map[string]any {
f.mu.Lock()
defer f.mu.Unlock()
if f.values == nil {
return nil
}
out := make(map[string]any, len(f.values))
for k, v := range f.values {
out[k] = v
}
return out
}
func (f *fakePolicyFetcher) set(values map[string]any) {
f.mu.Lock()
defer f.mu.Unlock()
f.values = values
}
func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
var mu sync.Mutex
current := NewPolicy(nil) // initial observation: empty (no enforcement)
withPolicyLoader(t, func() *Policy {
mu.Lock()
defer mu.Unlock()
return current
})
fetcher := &fakePolicyFetcher{} // initial observation: empty (no enforcement)
loader := NewLoader(fetcher)
type change struct{ prev, curr *Policy }
changes := make(chan change, 1)
tk := NewTicker(testReloadInterval)
tk := NewTicker(testReloadInterval, loader)
require.Equal(t, testReloadInterval, tk.interval)
ctx, cancel := context.WithCancel(context.Background())
@@ -49,15 +61,13 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
})
close(done)
}()
// Stop Run and wait for it to exit before returning, so the policyLoader
// restore in t.Cleanup can't race the ticker goroutine still reading it.
// Stop Run and wait for it to exit before returning, so the test
// goroutine doesn't race the still-running ticker.
defer func() { cancel(); <-done }()
// Flip the OS-observed policy from empty to one managed key. The next
// tick must detect the diff and invoke onChange.
mu.Lock()
current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"})
mu.Unlock()
// Flip the OS-observed policy from empty to one managed key. The
// next tick must detect the diff and invoke onChange.
fetcher.set(map[string]any{KeyManagementURL: "https://mdm.example.com:443"})
select {
case c := <-changes:
@@ -69,12 +79,11 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
}
func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
withPolicyLoader(t, func() *Policy {
return NewPolicy(map[string]any{KeyBlockInbound: true})
})
fetcher := &fakePolicyFetcher{values: map[string]any{KeyBlockInbound: true}}
loader := NewLoader(fetcher)
fired := make(chan struct{}, 1)
tk := NewTicker(testReloadInterval)
tk := NewTicker(testReloadInterval, loader)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
@@ -90,8 +99,8 @@ func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
}()
defer func() { cancel(); <-done }()
// Over ~2 ticks at the 1s test cadence the policy never changes, so the
// diff guard must suppress the callback entirely.
// Over ~2 ticks at the 1s test cadence the policy never changes,
// so the diff guard must suppress the callback entirely.
select {
case <-fired:
t.Fatal("onChange fired despite an unchanged policy")