2026-09-09 15:45:20 +02:00
|
|
|
//
|
|
|
|
|
// MDMRestrictionsTests.swift
|
|
|
|
|
// NetBirdTests
|
|
|
|
|
//
|
|
|
|
|
// Covers the decode side of the MDM enforcement snapshot: the Swift mirror
|
|
|
|
|
// must survive a Go layer that adds, drops or nulls a key, and must always
|
|
|
|
|
// degrade to "nothing managed" rather than locking the user out.
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
import XCTest
|
|
|
|
|
@testable import NetBird
|
|
|
|
|
|
|
|
|
|
final class MDMRestrictionsTests: XCTestCase {
|
|
|
|
|
|
|
|
|
|
/// The full snapshot shape, as documented for getRestrictionsJSON().
|
|
|
|
|
private let fullSnapshot = """
|
|
|
|
|
{
|
|
|
|
|
"mdm": {
|
|
|
|
|
"managementURL": "https://mgmt.corp.example:443",
|
|
|
|
|
"preSharedKey": true,
|
|
|
|
|
"wireguardPort": false,
|
|
|
|
|
"rosenpassEnabled": true,
|
|
|
|
|
"rosenpassPermissive": false,
|
|
|
|
|
"disableClientRoutes": false,
|
|
|
|
|
"disableServerRoutes": false,
|
|
|
|
|
"allowServerSSH": null,
|
|
|
|
|
"disableAutoConnect": true,
|
|
|
|
|
"disableAutostart": false,
|
|
|
|
|
"blockInbound": false,
|
|
|
|
|
"disableMetricsCollection": false,
|
|
|
|
|
"splitTunnelMode": false,
|
|
|
|
|
"splitTunnelApps": false,
|
2026-09-23 15:14:26 +02:00
|
|
|
"allowRemoteJobs": true,
|
2026-09-09 15:45:20 +02:00
|
|
|
"disableAdvancedView": false
|
|
|
|
|
},
|
|
|
|
|
"features": {
|
|
|
|
|
"disableProfiles": true,
|
|
|
|
|
"disableNetworks": false,
|
|
|
|
|
"disableUpdateSettings": false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
func testDecodesFullSnapshot() {
|
|
|
|
|
let r = MDMRestrictions.decode(fullSnapshot)
|
|
|
|
|
XCTAssertEqual(r.mdm.managementURL, "https://mgmt.corp.example:443")
|
|
|
|
|
XCTAssertTrue(r.mdm.managesManagementURL)
|
|
|
|
|
XCTAssertTrue(r.mdm.preSharedKey)
|
|
|
|
|
XCTAssertTrue(r.mdm.rosenpassEnabled)
|
|
|
|
|
XCTAssertFalse(r.mdm.rosenpassPermissive)
|
|
|
|
|
XCTAssertTrue(r.mdm.disableAutoConnect)
|
|
|
|
|
XCTAssertEqual(r.mdm.disableAdvancedView, false)
|
|
|
|
|
XCTAssertFalse(r.mdm.hidesAdvancedView)
|
2026-09-23 15:14:26 +02:00
|
|
|
XCTAssertTrue(r.mdm.remoteJobsAllowed)
|
2026-09-09 15:45:20 +02:00
|
|
|
XCTAssertTrue(r.features.disableProfiles)
|
|
|
|
|
XCTAssertFalse(r.features.disableNetworks)
|
|
|
|
|
XCTAssertFalse(r.features.disableUpdateSettings)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 15:14:26 +02:00
|
|
|
func testRemoteJobsAllowedDefaultsToUnmanaged() {
|
|
|
|
|
XCTAssertFalse(MDMRestrictions.decode(#"{"mdm":{}}"#).mdm.remoteJobsAllowed)
|
|
|
|
|
XCTAssertFalse(MDMRestrictions.empty.mdm.remoteJobsAllowed)
|
|
|
|
|
XCTAssertTrue(MDMRestrictions.decode(#"{"mdm":{"allowRemoteJobs":true}}"#).mdm.remoteJobsAllowed)
|
|
|
|
|
XCTAssertFalse(MDMRestrictions.decode(#"{"mdm":{"allowRemoteJobs":false}}"#).mdm.remoteJobsAllowed)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 15:45:20 +02:00
|
|
|
/// `allowServerSSH` is tri-state: absent and explicit null both mean
|
|
|
|
|
/// "not managed", and must not collapse into `false`.
|
|
|
|
|
func testAllowServerSSHTriState() {
|
|
|
|
|
XCTAssertNil(MDMRestrictions.decode(fullSnapshot).mdm.allowServerSSH)
|
|
|
|
|
XCTAssertNil(MDMRestrictions.decode(#"{"mdm":{}}"#).mdm.allowServerSSH)
|
|
|
|
|
XCTAssertEqual(MDMRestrictions.decode(#"{"mdm":{"allowServerSSH":true}}"#).mdm.allowServerSSH, true)
|
|
|
|
|
XCTAssertEqual(MDMRestrictions.decode(#"{"mdm":{"allowServerSSH":false}}"#).mdm.allowServerSSH, false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `disableAdvancedView` is tri-state like `allowServerSSH`: only an
|
|
|
|
|
/// explicit true hides the section, so an unmanaged or explicitly-allowed
|
|
|
|
|
/// key must leave it in place.
|
|
|
|
|
func testDisableAdvancedViewTriState() {
|
|
|
|
|
XCTAssertNil(MDMRestrictions.decode(#"{"mdm":{}}"#).mdm.disableAdvancedView)
|
|
|
|
|
XCTAssertFalse(MDMRestrictions.decode(#"{"mdm":{}}"#).mdm.hidesAdvancedView)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(MDMRestrictions.decode(#"{"mdm":{"disableAdvancedView":null}}"#).mdm.disableAdvancedView, nil)
|
|
|
|
|
XCTAssertFalse(MDMRestrictions.decode(#"{"mdm":{"disableAdvancedView":null}}"#).mdm.hidesAdvancedView)
|
|
|
|
|
|
|
|
|
|
XCTAssertFalse(MDMRestrictions.decode(#"{"mdm":{"disableAdvancedView":false}}"#).mdm.hidesAdvancedView)
|
|
|
|
|
XCTAssertTrue(MDMRestrictions.decode(#"{"mdm":{"disableAdvancedView":true}}"#).mdm.hidesAdvancedView)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// An empty policy must leave every control usable.
|
|
|
|
|
func testEmptyPolicyManagesNothing() {
|
|
|
|
|
let r = MDMRestrictions.decode(#"{"mdm":{"managementURL":""},"features":{}}"#)
|
|
|
|
|
XCTAssertEqual(r, .empty)
|
|
|
|
|
XCTAssertFalse(r.mdm.managesManagementURL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEmptyStringDecodesToEmpty() {
|
|
|
|
|
XCTAssertEqual(MDMRestrictions.decode(""), .empty)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A malformed snapshot must not lock the settings screens; it degrades
|
|
|
|
|
/// to "nothing managed".
|
|
|
|
|
func testMalformedSnapshotDegradesToEmpty() {
|
|
|
|
|
XCTAssertEqual(MDMRestrictions.decode("not json"), .empty)
|
|
|
|
|
XCTAssertEqual(MDMRestrictions.decode("{"), .empty)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Keys the Swift side does not know about must be ignored rather than
|
|
|
|
|
/// failing the whole decode - the Go layer may ship new ones first.
|
|
|
|
|
func testUnknownKeysAreIgnored() {
|
|
|
|
|
let json = #"{"mdm":{"preSharedKey":true,"someFutureKey":"x"},"features":{"disableNetworks":true,"anotherOne":1}}"#
|
|
|
|
|
let r = MDMRestrictions.decode(json)
|
|
|
|
|
XCTAssertTrue(r.mdm.preSharedKey)
|
|
|
|
|
XCTAssertTrue(r.features.disableNetworks)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Missing keys fall back to "not managed", so a Go layer that drops a
|
|
|
|
|
/// key does not start reporting it as locked.
|
|
|
|
|
func testMissingKeysDefaultToUnmanaged() {
|
|
|
|
|
let r = MDMRestrictions.decode(#"{"mdm":{"preSharedKey":true}}"#)
|
|
|
|
|
XCTAssertTrue(r.mdm.preSharedKey)
|
|
|
|
|
XCTAssertFalse(r.mdm.rosenpassEnabled)
|
|
|
|
|
XCTAssertFalse(r.mdm.disableClientRoutes)
|
|
|
|
|
XCTAssertEqual(r.mdm.managementURL, "")
|
|
|
|
|
XCTAssertFalse(r.features.disableProfiles)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 16:40:06 +02:00
|
|
|
/// A rejected commit must name the setting the policy refused, and must
|
|
|
|
|
/// not swallow an ordinary failure as a policy one.
|
|
|
|
|
func testManagedRejectionMessage() {
|
|
|
|
|
let refusal = "fields managed by MDM cannot be modified: [rosenpassEnabled]"
|
|
|
|
|
let message = MDMRestrictions.rejectionMessage(from: refusal)
|
|
|
|
|
XCTAssertNotNil(message)
|
|
|
|
|
XCTAssertTrue(message!.contains("rosenpassEnabled"), "message was: \(message!)")
|
|
|
|
|
XCTAssertTrue(message!.contains("managed by your organization"))
|
|
|
|
|
|
|
|
|
|
// Several keys come through as Go formats them.
|
|
|
|
|
let many = MDMRestrictions.rejectionMessage(
|
|
|
|
|
from: "fields managed by MDM cannot be modified: [rosenpassEnabled preSharedKey]"
|
|
|
|
|
)
|
|
|
|
|
XCTAssertEqual(many?.contains("preSharedKey"), true)
|
|
|
|
|
|
|
|
|
|
// No key list still yields the generic explanation.
|
|
|
|
|
XCTAssertNotNil(MDMRestrictions.rejectionMessage(from: "fields managed by MDM cannot be modified"))
|
|
|
|
|
|
|
|
|
|
// An unrelated failure is not a policy refusal.
|
|
|
|
|
XCTAssertNil(MDMRestrictions.rejectionMessage(from: "no space left on device"))
|
|
|
|
|
XCTAssertNil(MDMRestrictions.rejectionMessage(from: ""))
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 15:45:20 +02:00
|
|
|
/// Only a non-empty managementURL means the URL is enforced.
|
|
|
|
|
func testManagesManagementURLRequiresNonEmptyValue() {
|
|
|
|
|
XCTAssertFalse(MDMRestrictions.decode(#"{"mdm":{"managementURL":""}}"#).mdm.managesManagementURL)
|
|
|
|
|
XCTAssertTrue(MDMRestrictions.decode(#"{"mdm":{"managementURL":"https://x"}}"#).mdm.managesManagementURL)
|
|
|
|
|
}
|
|
|
|
|
}
|