mirror of
https://github.com/netbirdio/ios-client.git
synced 2026-09-23 09:28:33 -07:00
* Add a Troubleshoot toggle for remote debug bundle jobs The client refuses management-requested remote jobs unless RemoteJobsAllowed is enabled, and the iOS and tvOS apps had no way to turn it on. Expose the flag through ConfigurationProvider, add an "Allow remote debug bundles" toggle to the iOS Troubleshoot screen and a Troubleshooting section in the tvOS settings, and bump netbird-core to the commit that adds the SDK preference accessors. * Update submodule * Route the iOS remote jobs toggle through the view model setter The Toggle wrote the published property before onChange invoked setRemoteJobsAllowed, so on a failed commit the rollback restored the new value instead of the previous one. Use a Binding whose setter calls the view model directly, matching the tvOS settings view. * Lock the remote debug bundle toggle when MDM manages it (#224) * Lock the remote debug bundle toggle when MDM manages it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Decode the remote jobs snapshot key as allowRemoteJobs The Go snapshot reports this field under the policy key name, allowRemoteJobs, not remoteJobsAllowed. The synthesized coding key used the property name, so decodeIfPresent never found the key and the flag stayed false — the toggle looked editable under an MDM policy that manages it. Map the coding key explicitly and keep the property name, which the views already reference. Bumps netbird-core to pick up the matching Go-side fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Bump submodule * Use allowRemoteJobs key in MDM restriction test fixtures Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * Enforce the remote jobs policy before the next poll setRemoteJobsAllowed validated against the cached MDM snapshot, which is only refreshed on onAppear or a 30s stale poll. The OS writes managed configuration from another process, so the in-process change notification never fires for it and a policy pushed mid-session went unenforced until the next poll. Refresh the snapshot before validating. The guard also checked only mdm.remoteJobsAllowed, while both toggles lock on remoteJobsAllowed || disableUpdateSettings, so the backstop could disagree with the control the user sees. Extract remoteJobsForbiddenByPolicy to mirror the lock. Route the rejection through commitSettings() so an MDM-refused commit raises the standard alert instead of a silent print. This matters most on tvOS, where commit() always reports success and the pre-commit guard is the only backstop. * Restore the persisted remote jobs setting after the test testUnmanagedDeviceStillAcceptsTheChange writes through ConfigurationProvider, which persists into the Go preferences store. tearDown() only restored the MDM dictionary, so an enabled remoteJobsAllowed could leak into later tests or a reused test-host launch. Capture the original value and restore it via defer, which runs while the device is still unmanaged so the setter is not rejected. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
6.8 KiB
Swift
157 lines
6.8 KiB
Swift
//
|
|
// 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,
|
|
"allowRemoteJobs": true,
|
|
"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)
|
|
XCTAssertTrue(r.mdm.remoteJobsAllowed)
|
|
XCTAssertTrue(r.features.disableProfiles)
|
|
XCTAssertFalse(r.features.disableNetworks)
|
|
XCTAssertFalse(r.features.disableUpdateSettings)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// `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)
|
|
}
|
|
|
|
/// 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: ""))
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|