From f0f82f2e2d29874973713ddfa1e38b4ea63e51f4 Mon Sep 17 00:00:00 2001 From: Frode Hus Date: Sat, 19 Sep 2026 01:48:56 +0200 Subject: [PATCH 1/2] Port the Azure scope tree to ElevateCore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArmScope and ScopeTree, transliterated from the C# added for #186 along with their tests, so the macOS Azure tab has the same tree to draw from and the two Cores do not drift. PanelFilter now searches the whole ARM path on this side too, which is the one behaviour change here: a row shows only the scope's caption, but "prod" should find an eligibility whose subscription is named in the path alone. The Azure tab itself is unchanged and still lists flat. This is the logic layer only — see the follow-up issue for the SwiftUI work. NOT COMPILED. Written on a Windows machine with no Swift toolchain, so 'swift test' has never run against it. The tests mirror the C# suite case for case and are the fastest way to find whatever this got wrong. Refs #186 Co-Authored-By: Claude Opus 5 --- .../ElevateCore/Support/ArmScope.swift | 165 ++++++++++++++++ .../ElevateCore/Support/PanelFilter.swift | 6 +- .../ElevateCore/Support/ScopeTree.swift | 179 ++++++++++++++++++ .../ElevateCoreTests/ArmScopeTests.swift | 118 ++++++++++++ .../ElevateCoreTests/PanelFilterTests.swift | 6 + .../ElevateCoreTests/ScopeTreeTests.swift | 174 +++++++++++++++++ 6 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/ElevateCore/Support/ArmScope.swift create mode 100644 macos/Sources/ElevateCore/Support/ScopeTree.swift create mode 100644 macos/Tests/ElevateCoreTests/ArmScopeTests.swift create mode 100644 macos/Tests/ElevateCoreTests/ScopeTreeTests.swift diff --git a/macos/Sources/ElevateCore/Support/ArmScope.swift b/macos/Sources/ElevateCore/Support/ArmScope.swift new file mode 100644 index 00000000..8dbafbde --- /dev/null +++ b/macos/Sources/ElevateCore/Support/ArmScope.swift @@ -0,0 +1,165 @@ +import Foundation + +/// What one step of an ARM scope path names. +public enum ArmScopeKind: Int, Hashable, Sendable, CaseIterable, Comparable { + /// The tenant root, `/`, or a path this parser did not recognise. + case unknown + case managementGroup + case subscription + case resourceGroup + case resource + + public static func < (lhs: ArmScopeKind, rhs: ArmScopeKind) -> Bool { lhs.rawValue < rhs.rawValue } +} + +/// One step of an ARM scope path: what it names, the name itself, and the scope string that +/// reaches it. `scope` is a prefix of the scope the segment came from, so it is a usable scope in +/// its own right and can key a node of the tree. +public struct ArmScopeSegment: Hashable, Sendable { + public let kind: ArmScopeKind + public let name: String + public let scope: String + + public init(kind: ArmScopeKind, name: String, scope: String) { + self.kind = kind + self.name = name + self.scope = scope + } +} + +/// Reading Azure Resource Manager scope strings as the hierarchy they already are: +/// `/subscriptions/{id}/resourceGroups/{name}/providers/{ns}/{type}/{name}`. The panel builds its +/// tree from this, so no extra calls are needed to learn the shape. +/// +/// One thing the string cannot tell us: which management group a subscription sits under. ARM +/// writes a management group scope as a flat `/providers/Microsoft.Management/managementGroups/{name}` +/// and never repeats it in a subscription's scope, so management groups are roots beside the +/// subscriptions rather than above them. +public enum ArmScope { + private static let managementGroupPrefix = "/providers/Microsoft.Management/managementGroups/" + + /// The steps of `scope`, outermost first; empty for a nil or root scope. + public static func segments(_ scope: String?) -> [ArmScopeSegment] { + let trimmed = (scope ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != "/" else { return [] } + + let parts = trimmed.split(separator: "/").map(String.init) + var segments: [ArmScopeSegment] = [] + var path = "" + var i = 0 + + // "/providers/Microsoft.Management/managementGroups/{name}" is a whole scope, not a resource + // under something: it only ever appears on its own, so it is recognised before the loop. + if trimmed.lowercased().hasPrefix(managementGroupPrefix.lowercased()), parts.count >= 4 { + path = managementGroupPrefix + parts[3] + segments.append(ArmScopeSegment(kind: .managementGroup, name: parts[3], scope: path)) + i = 4 + } + + while i < parts.count { + let token = parts[i].lowercased() + if i + 1 < parts.count, token == "subscriptions" { + path += "/subscriptions/" + parts[i + 1] + segments.append(ArmScopeSegment(kind: .subscription, name: parts[i + 1], scope: path)) + i += 2 + } else if i + 1 < parts.count, token == "resourcegroups" { + path += "/resourceGroups/" + parts[i + 1] + segments.append(ArmScopeSegment(kind: .resourceGroup, name: parts[i + 1], scope: path)) + i += 2 + } else if token == "providers", i + 3 < parts.count { + // providers/{namespace}/{type}/{name}, then (type, name) pairs for child resources. + path += "/providers/" + parts[i + 1] + "/" + parts[i + 2] + "/" + parts[i + 3] + segments.append(ArmScopeSegment(kind: .resource, name: parts[i + 3], scope: path)) + i += 4 + while i + 1 < parts.count { + path += "/" + parts[i] + "/" + parts[i + 1] + segments.append(ArmScopeSegment(kind: .resource, name: parts[i + 1], scope: path)) + i += 2 + } + } else { + // Something this parser does not know. Keep the rest as one step rather than + // guessing, so an unfamiliar scope still appears in the tree under a readable name. + path += "/" + parts[i...].joined(separator: "/") + segments.append(ArmScopeSegment(kind: .unknown, name: parts[parts.count - 1], scope: path)) + break + } + } + + return segments + } + + /// Whether `scope` is `ancestor` or sits below it. Compared step by step, so + /// `/subscriptions/abc` does not contain `/subscriptions/abcdef`. + public static func isAtOrUnder(_ scope: String?, ancestor: String?) -> Bool { + let above = segments(ancestor) + // The root contains everything. + guard !above.isEmpty else { return true } + + let below = segments(scope) + guard below.count >= above.count else { return false } + return zip(below, above).allSatisfy { $0.scope.lowercased() == $1.scope.lowercased() } + } + + /// Whether any step of `scope` is named `name` — the plain form of "under this subscription" or + /// "under this resource group", where the user names it rather than giving the whole path. A + /// step's own scope string is accepted too. + public static func hasSegmentNamed(_ scope: String?, name: String) -> Bool { + var term = name.trimmingCharacters(in: .whitespacesAndNewlines) + while term.hasSuffix("/") { term.removeLast() } + guard !term.isEmpty else { return false } + + return segments(scope).contains { + $0.name.lowercased() == term.lowercased() || $0.scope.lowercased() == term.lowercased() + } + } + + /// Whether `text` matches the glob `pattern`, where `*` absorbs any run of characters including + /// slashes — so `/subscriptions/*` reaches everything in every subscription, not only the + /// subscriptions themselves. The whole string must match, and case is ignored, as everywhere + /// else in ARM. + public static func matches(pattern: String, text: String?) -> Bool { + // Same glob ARM uses for action strings; ArmActions already implements it without recursion. + ArmActions.covers(pattern: pattern, action: text ?? "") + } + + /// Whether `term` is meant as a glob rather than as plain text. + public static func looksLikePattern(_ term: String?) -> Bool { (term ?? "").contains("*") } + + /// The display name an Azure role's `detail` caption carries — "Pay-As-You-Go · subscription" + /// is written by `AzureResourceProvider.caption`, and only the name part names the scope. + public static func displayName(detail: String?) -> String? { + let text = (detail ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + let name = text.range(of: " · ", options: .backwards).map { + String(text[text.startIndex..<$0.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines) + } ?? text + return name.isEmpty ? nil : name + } + + /// What to call a step of the path in the panel, matching the provider's captions. + public static func label(_ kind: ArmScopeKind) -> String { + switch kind { + case .managementGroup: "management group" + case .subscription: "subscription" + case .resourceGroup: "resource group" + case .resource: "resource" + case .unknown: "scope" + } + } + + /// The scope shortened for a narrow row: the last `steps` steps, with a leading ellipsis for + /// what was dropped. Truncating from the left keeps the leaf, which is the part that tells two + /// long paths apart. + public static func tail(_ scope: String?, steps: Int = 2) -> String { + let segments = segments(scope) + guard !segments.isEmpty else { return "/" } + + let take = max(1, steps) + guard segments.count > take else { + return scope?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "/" + } + + return "…/" + segments.suffix(take).map(\.name).joined(separator: "/") + } +} diff --git a/macos/Sources/ElevateCore/Support/PanelFilter.swift b/macos/Sources/ElevateCore/Support/PanelFilter.swift index 15556321..ad2bb3d0 100644 --- a/macos/Sources/ElevateCore/Support/PanelFilter.swift +++ b/macos/Sources/ElevateCore/Support/PanelFilter.swift @@ -16,7 +16,11 @@ public enum PanelFilter { public static func matches(query: String, role: EligibleRole, tenantName: String, upn: String) -> Bool { guard isActive(query) else { return true } - let fields = [role.displayName, role.detail ?? "", role.viaGroup ?? "", tenantName, upn] + // The whole ARM path too: a role row only shows the scope's caption, but "prod" should find + // an eligibility whose subscription is named only in the path. + let path: String + if case .azureResource(let scope, _) = role.key.scope { path = scope } else { path = "" } + let fields = [role.displayName, role.detail ?? "", role.viaGroup ?? "", tenantName, upn, path] return fields.contains { matches(query: query, text: $0) } } } diff --git a/macos/Sources/ElevateCore/Support/ScopeTree.swift b/macos/Sources/ElevateCore/Support/ScopeTree.swift new file mode 100644 index 00000000..b612a262 --- /dev/null +++ b/macos/Sources/ElevateCore/Support/ScopeTree.swift @@ -0,0 +1,179 @@ +import Foundation + +/// One scope in the Azure tab's tree: the roles held directly on it, and the scopes below it that +/// hold roles of their own. Built from the eligibilities alone — see `ArmScope`. +public final class ScopeNode: Identifiable, @unchecked Sendable { + public let kind: ArmScopeKind + /// The ARM name: a subscription id, a resource group name. + public let name: String + /// The whole scope string this node stands for. + public let scope: String + + /// The scope's friendly name, when some eligibility sits on this exact scope and the service + /// told us one. A subscription that is only an ancestor here has no caption to borrow, so it + /// shows its id. + public internal(set) var displayName: String? + + /// Scopes above this one that hold no role and lead nowhere else, outermost first. A + /// subscription whose only content is one resource group is not worth a row and a level of + /// indent of its own in a panel this narrow, so it is folded into the node below and named + /// here instead — the view draws it as a dimmed "Alpha /" ahead of the title. + public internal(set) var ancestors: [String] = [] + + /// Roles held on this exact scope. + public internal(set) var roles: [EligibleRole] = [] + + public internal(set) var children: [ScopeNode] = [] + + init(kind: ArmScopeKind, name: String, scope: String) { + self.kind = kind + self.name = name + self.scope = scope + } + + public var id: String { scope } + + /// What to show: the friendly name when there is one, otherwise the ARM name. + public var title: String { displayName ?? name } + + /// The folded scopes and this one, as one line: "Alpha / prod". + public var path: String { ancestors.isEmpty ? title : (ancestors + [title]).joined(separator: " / ") } + + /// Every role at or below this node. + public var roleCount: Int { roles.count + children.reduce(0) { $0 + $1.roleCount } } + + /// Every role at or below this node, in the order the tree shows them. + public var allRoles: [EligibleRole] { roles + children.flatMap(\.allRoles) } +} + +/// One line of the flattened tree: a scope header, or a role beneath one. +public struct ScopeTreeEntry: Identifiable, Sendable { + public let depth: Int + public let node: ScopeNode + public let role: EligibleRole? + + public var isScope: Bool { role == nil } + + /// Stable across redraws: a role appears once, under the node that owns it. + public var id: String { role.map { "role:\($0.key)" } ?? "scope:\(node.scope)" } + + init(depth: Int, node: ScopeNode, role: EligibleRole?) { + self.depth = depth + self.node = node + self.role = role + } +} + +/// Turns a tenant's Azure eligibilities into the management group / subscription / resource group / +/// resource tree the panel draws, and flattens it back into rows. Kept out of the view so both the +/// panel and the tests can reach it. +public enum ScopeTree { + /// The tree for `roles`, roots first. Roles that are not Azure resource roles are ignored; the + /// caller has already narrowed to the Azure tab. + public static func build(_ roles: [EligibleRole]) -> [ScopeNode] { + var byScope: [String: Builder] = [:] + var roots: [Builder] = [] + + for role in roles { + guard case .azureResource(let scope, _) = role.key.scope else { continue } + let segments = ArmScope.segments(scope) + guard !segments.isEmpty else { continue } + + var parent: Builder? + for segment in segments { + let lookup = segment.scope.lowercased() + let node: Builder + if let existing = byScope[lookup] { + node = existing + } else { + node = Builder(kind: segment.kind, name: segment.name, scope: segment.scope) + byScope[lookup] = node + if let parent { parent.children.append(node) } else { roots.append(node) } + } + parent = node + } + + // The caption the service gave names this role's own scope, not its ancestors. + parent?.roles.append(role) + if parent?.displayName == nil { parent?.displayName = ArmScope.displayName(detail: role.detail) } + } + + return sorted(roots.map(freeze)) + } + + /// The tree as rows, outermost first, skipping the children of a collapsed node. `isCollapsed` + /// is asked once per node; while the panel is filtering the caller passes a predicate that is + /// always false, so every match stays visible in place. + /// + /// A scope that only leads to one role and branches nowhere is left out and its role row takes + /// its place: sixty subscriptions with one eligibility each should not become a hundred and + /// twenty rows. The role row still carries the scope's caption, as it does today. + public static func flatten(_ nodes: [ScopeNode], isCollapsed: (ScopeNode) -> Bool = { _ in false }) -> [ScopeTreeEntry] { + var rows: [ScopeTreeEntry] = [] + walk(nodes, depth: 0, isCollapsed: isCollapsed, into: &rows) + return rows + } + + /// Whether the tree would draw a header for `node` at all. + public static func isRendered(_ node: ScopeNode) -> Bool { + !node.children.isEmpty || node.roles.count > 1 + } + + private static func walk(_ nodes: [ScopeNode], depth: Int, isCollapsed: (ScopeNode) -> Bool, into rows: inout [ScopeTreeEntry]) { + for node in nodes { + guard isRendered(node) else { + // Elided: one role, nowhere to branch. Its row stands where the header would have. + for role in node.roles { rows.append(ScopeTreeEntry(depth: depth, node: node, role: role)) } + continue + } + + rows.append(ScopeTreeEntry(depth: depth, node: node, role: nil)) + guard !isCollapsed(node) else { continue } + + for role in node.roles { rows.append(ScopeTreeEntry(depth: depth + 1, node: node, role: role)) } + walk(node.children, depth: depth + 1, isCollapsed: isCollapsed, into: &rows) + } + } + + private final class Builder { + let kind: ArmScopeKind + let name: String + let scope: String + var displayName: String? + var roles: [EligibleRole] = [] + var children: [Builder] = [] + + init(kind: ArmScopeKind, name: String, scope: String) { + self.kind = kind + self.name = name + self.scope = scope + } + + var title: String { displayName ?? name } + } + + private static func sorted(_ nodes: [ScopeNode]) -> [ScopeNode] { + nodes.sorted { + $0.kind == $1.kind ? $0.title.lowercased() < $1.title.lowercased() : $0.kind < $1.kind + } + } + + private static func freeze(_ builder: Builder) -> ScopeNode { + // Fold a chain that only passes through — no roles of its own, one way down — into the node + // where the chain ends. Selecting that node still reaches everything the folded scopes + // reached, because the chain is a straight line. + var ancestors: [String] = [] + var deepest = builder + while deepest.roles.isEmpty, deepest.children.count == 1 { + ancestors.append(deepest.title) + deepest = deepest.children[0] + } + + let node = ScopeNode(kind: deepest.kind, name: deepest.name, scope: deepest.scope) + node.displayName = deepest.displayName + node.ancestors = ancestors + node.roles = deepest.roles.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() } + node.children = sorted(deepest.children.map(freeze)) + return node + } +} diff --git a/macos/Tests/ElevateCoreTests/ArmScopeTests.swift b/macos/Tests/ElevateCoreTests/ArmScopeTests.swift new file mode 100644 index 00000000..efe7640f --- /dev/null +++ b/macos/Tests/ElevateCoreTests/ArmScopeTests.swift @@ -0,0 +1,118 @@ +import Testing +@testable import ElevateCore + +/// Port of the C# `ArmScopeTests`. +struct ArmScopeTests { + let mg = "/providers/Microsoft.Management/managementGroups/platform" + let sub = "/subscriptions/11111111-1111-1111-1111-111111111111" + var rg: String { sub + "/resourceGroups/prod-rg" } + var vm: String { rg + "/providers/Microsoft.Compute/virtualMachines/web01" } + + @Test func segmentsReadsSubscriptionResourceGroupAndResource() { + let segments = ArmScope.segments(vm) + #expect(segments.map(\.kind) == [.subscription, .resourceGroup, .resource]) + #expect(segments.map(\.name) == ["11111111-1111-1111-1111-111111111111", "prod-rg", "web01"]) + #expect(segments.map(\.scope) == [sub, rg, vm]) + } + + @Test func segmentsReadsAManagementGroupAsOneStep() { + let segments = ArmScope.segments(mg) + #expect(segments.count == 1) + #expect(segments.first == ArmScopeSegment(kind: .managementGroup, name: "platform", scope: mg)) + } + + @Test func segmentsReadsAResourceDirectlyUnderASubscription() { + let segments = ArmScope.segments(sub + "/providers/Microsoft.Network/virtualNetworks/hub") + #expect(segments.map(\.kind) == [.subscription, .resource]) + } + + @Test func segmentsReadsChildResourcesAsStepsOfTheirOwn() { + let scope = vm + "/extensions/monitor" + let segments = ArmScope.segments(scope) + #expect(segments.map(\.name) == ["11111111-1111-1111-1111-111111111111", "prod-rg", "web01", "monitor"]) + #expect(segments.last?.scope == scope) + } + + @Test func segmentsIsEmptyForTheRoot() { + for scope: String? in [nil, "", "/", " "] { + #expect(ArmScope.segments(scope).isEmpty, "scope \(scope ?? "nil")") + } + } + + @Test func segmentsKeepsAnUnfamiliarPathAsOneStepRatherThanDroppingIt() { + let segments = ArmScope.segments("/tenants/contoso/widgets/one") + #expect(segments.count == 1) + #expect(segments.first?.kind == .unknown) + } + + @Test func segmentsIgnoresCaseInThePathKeywords() { + #expect(ArmScope.segments("/SUBSCRIPTIONS/abc/RESOURCEGROUPS/rg").map(\.kind) == [.subscription, .resourceGroup]) + } + + @Test func isAtOrUnderComparesStepByStep() { + #expect(ArmScope.isAtOrUnder(vm, ancestor: sub)) + #expect(ArmScope.isAtOrUnder(vm, ancestor: rg)) + #expect(ArmScope.isAtOrUnder(vm, ancestor: vm)) + #expect(ArmScope.isAtOrUnder(vm, ancestor: "/")) + #expect(!ArmScope.isAtOrUnder(rg, ancestor: vm)) + #expect(!ArmScope.isAtOrUnder(sub, ancestor: rg)) + #expect(!ArmScope.isAtOrUnder(mg, ancestor: sub)) + } + + @Test func isAtOrUnderDoesNotTreatANamePrefixAsAnAncestor() { + // "/subscriptions/abc" must not swallow "/subscriptions/abcdef". + #expect(!ArmScope.isAtOrUnder("/subscriptions/abcdef", ancestor: "/subscriptions/abc")) + } + + @Test func isAtOrUnderIgnoresCase() { + #expect(ArmScope.isAtOrUnder(vm, ancestor: rg.uppercased())) + } + + @Test func hasSegmentNamedMatchesAStepsNameOrItsWholeScope() { + #expect(ArmScope.hasSegmentNamed(vm, name: "prod-rg")) + #expect(ArmScope.hasSegmentNamed(vm, name: "PROD-RG")) + #expect(ArmScope.hasSegmentNamed(vm, name: "web01")) + #expect(ArmScope.hasSegmentNamed(vm, name: rg)) + #expect(ArmScope.hasSegmentNamed(vm, name: rg + "/")) + #expect(ArmScope.hasSegmentNamed(mg, name: "platform")) + // A partial name is not a step. + #expect(!ArmScope.hasSegmentNamed(vm, name: "prod")) + #expect(!ArmScope.hasSegmentNamed(vm, name: "")) + } + + @Test func matchesPatternLetsStarCrossSlashes() { + #expect(ArmScope.matches(pattern: "/subscriptions/*", text: sub)) + #expect(ArmScope.matches(pattern: "/subscriptions/*", text: rg)) + #expect(ArmScope.matches(pattern: "/subscriptions/*", text: vm)) + #expect(!ArmScope.matches(pattern: "/subscriptions/*", text: mg)) + #expect(ArmScope.matches(pattern: "*/resourceGroups/prod-*", text: vm)) + #expect(!ArmScope.matches(pattern: "*/resourceGroups/prod-*", text: sub)) + #expect(ArmScope.matches(pattern: "*managementGroups*", text: mg)) + } + + @Test func matchesPatternMatchesTheWholeStringSoAPrefixAloneIsNotEnough() { + #expect(!ArmScope.matches(pattern: "/subscriptions", text: sub)) + } + + @Test func looksLikePatternIsAboutTheStar() { + #expect(ArmScope.looksLikePattern("prod*")) + #expect(ArmScope.looksLikePattern("*")) + #expect(!ArmScope.looksLikePattern("prod")) + #expect(!ArmScope.looksLikePattern(nil)) + } + + @Test func displayNameDropsTheKindTheCaptionAppends() { + #expect(ArmScope.displayName(detail: "Pay-As-You-Go · subscription") == "Pay-As-You-Go") + #expect(ArmScope.displayName(detail: "prod-rg · resource group") == "prod-rg") + #expect(ArmScope.displayName(detail: "just a name") == "just a name") + #expect(ArmScope.displayName(detail: "") == nil) + #expect(ArmScope.displayName(detail: nil) == nil) + } + + @Test func tailKeepsTheLeafAndDropsWhatIsAbove() { + #expect(ArmScope.tail(vm) == "…/prod-rg/web01") + #expect(ArmScope.tail(vm, steps: 1) == "…/web01") + #expect(ArmScope.tail(sub) == sub) + #expect(ArmScope.tail("/") == "/") + } +} diff --git a/macos/Tests/ElevateCoreTests/PanelFilterTests.swift b/macos/Tests/ElevateCoreTests/PanelFilterTests.swift index 841e6fb2..a6e6b5d2 100644 --- a/macos/Tests/ElevateCoreTests/PanelFilterTests.swift +++ b/macos/Tests/ElevateCoreTests/PanelFilterTests.swift @@ -26,6 +26,12 @@ import Foundation #expect(!PanelFilter.matches(query: "reader\n", text: "Group owner")) } + @Test func reachesTheArmPathEvenWhenNoCaptionShowsIt() { + // The row shows "Pay-As-You-Go · subscription"; the subscription id is only in the path. + #expect(PanelFilter.matches(query: "s1", role: role, tenantName: "Contoso", upn: "u@contoso.com")) + #expect(PanelFilter.matches(query: "/subscriptions/", role: role, tenantName: "Contoso", upn: "u@contoso.com")) + } + @Test func ignoresDiacritics() { var r = role r.displayName = "Sécurité" diff --git a/macos/Tests/ElevateCoreTests/ScopeTreeTests.swift b/macos/Tests/ElevateCoreTests/ScopeTreeTests.swift new file mode 100644 index 00000000..94c5db8c --- /dev/null +++ b/macos/Tests/ElevateCoreTests/ScopeTreeTests.swift @@ -0,0 +1,174 @@ +import Testing +@testable import ElevateCore + +/// Port of the C# `ScopeTreeTests`. +struct ScopeTreeTests { + let subA = "/subscriptions/aaaaaaaa-0000-0000-0000-000000000000" + let subB = "/subscriptions/bbbbbbbb-0000-0000-0000-000000000000" + let mg = "/providers/Microsoft.Management/managementGroups/platform" + + private func role(_ name: String, _ scope: String, detail: String? = nil) -> EligibleRole { + EligibleRole( + key: RoleKey(identityId: "i", tenantId: "t", scope: .azureResource(scope: scope, roleDefinitionId: "rd-" + name)), + displayName: name, detail: detail, source: .discovered, policy: .manualDefault) + } + + @Test func buildNestsResourceGroupsUnderTheirSubscription() { + let tree = ScopeTree.build([ + role("Contributor", subA + "/resourceGroups/prod", detail: "prod · resource group"), + role("Reader", subA + "/resourceGroups/test", detail: "test · resource group"), + role("Owner", subA, detail: "Platform · subscription"), + ]) + + #expect(tree.count == 1) + let sub = tree[0] + #expect(sub.kind == .subscription) + // The role held on the subscription itself carries its caption. + #expect(sub.title == "Platform") + #expect(sub.roles.map(\.displayName) == ["Owner"]) + #expect(sub.children.map(\.title) == ["prod", "test"]) + #expect(sub.roleCount == 3) + } + + @Test func buildShowsTheArmNameWhenNoRoleSitsOnTheScopeItself() { + let tree = ScopeTree.build([ + role("Contributor", subA + "/resourceGroups/prod", detail: "prod · resource group"), + role("Reader", subA + "/resourceGroups/test", detail: "test · resource group"), + ]) + + // Nothing is eligible on the subscription, so the service never named it. + #expect(tree[0].displayName == nil) + #expect(tree[0].title == "aaaaaaaa-0000-0000-0000-000000000000") + } + + @Test func buildPutsManagementGroupsBesideSubscriptionsRatherThanAboveThem() { + // ARM never repeats the management group in a subscription's scope, so the string cannot + // tell us the subscription sits under it. + let tree = ScopeTree.build([ + role("Reader", mg, detail: "Platform · management group"), + role("Owner", subA, detail: "A · subscription"), + ]) + #expect(tree.map(\.kind) == [.managementGroup, .subscription]) + } + + @Test func buildIgnoresRolesThatAreNotAzureResourceRoles() { + let entra = EligibleRole( + key: RoleKey(identityId: "i", tenantId: "t", scope: .entraDirectory(roleDefinitionId: "rd", directoryScopeId: "/")), + displayName: "Global Reader", source: .discovered, policy: .manualDefault) + #expect(ScopeTree.build([entra, role("Owner", subA)]).count == 1) + } + + @Test func buildSortsRootsByKindThenTitle() { + let tree = ScopeTree.build([ + role("Owner", subB, detail: "Zulu · subscription"), + role("Owner", subA, detail: "Alpha · subscription"), + role("Reader", mg, detail: "Platform · management group"), + ]) + #expect(tree.map(\.title) == ["Platform", "Alpha", "Zulu"]) + } + + @Test func flattenElidesAScopeThatLeadsToOneRoleAndBranchesNowhere() { + // The motivating case: many subscriptions, one eligibility each. A header per subscription + // would double the rows and show nothing the role row does not already say. + let tree = ScopeTree.build([ + role("Contributor", subA, detail: "Alpha · subscription"), + role("Contributor", subB, detail: "Zulu · subscription"), + ]) + let rows = ScopeTree.flatten(tree) + + #expect(rows.count == 2) + #expect(rows.allSatisfy { !$0.isScope }) + #expect(rows.allSatisfy { $0.depth == 0 }) + } + + @Test func flattenDrawsAHeaderAsSoonAsAScopeHasSomethingToBranchInto() { + let tree = ScopeTree.build([ + role("Owner", subA, detail: "Alpha · subscription"), + role("Contributor", subA + "/resourceGroups/prod", detail: "prod · resource group"), + ]) + let rows = ScopeTree.flatten(tree) + + #expect(rows.map(\.depth) == [0, 1, 1]) + #expect(rows.map(\.isScope) == [true, false, false]) + #expect(rows.compactMap { $0.role?.displayName } == ["Owner", "Contributor"]) + } + + @Test func flattenDrawsAHeaderWhenOneScopeHoldsSeveralRoles() { + let tree = ScopeTree.build([ + role("Owner", subA, detail: "Alpha · subscription"), + role("Reader", subA, detail: "Alpha · subscription"), + ]) + let rows = ScopeTree.flatten(tree) + #expect(rows[0].isScope) + #expect(rows.dropFirst().compactMap { $0.role?.displayName } == ["Owner", "Reader"]) + } + + @Test func flattenStopsAtACollapsedNode() { + let tree = ScopeTree.build([ + role("Owner", subA, detail: "Alpha · subscription"), + role("Contributor", subA + "/resourceGroups/prod", detail: "prod · resource group"), + ]) + let rows = ScopeTree.flatten(tree) { $0.scope == subA } + + #expect(rows.count == 1) + #expect(rows[0].isScope) + } + + @Test func flattenNestsAResourceUnderItsResourceGroup() { + let rg = subA + "/resourceGroups/prod" + let tree = ScopeTree.build([ + role("Owner", rg, detail: "prod · resource group"), + role("Reader", rg + "/providers/Microsoft.Compute/virtualMachines/web01", detail: "web01 · virtualmachines"), + role("Reader", rg + "/providers/Microsoft.Compute/virtualMachines/web02", detail: "web02 · virtualmachines"), + ]) + let rows = ScopeTree.flatten(tree) + + // The subscription is folded away — it holds no role of its own and leads to a single child. + #expect(rows.map(\.depth) == [0, 1, 1, 1]) + #expect(rows.map(\.isScope) == [true, false, false, false]) + #expect(rows[0].node.kind == .resourceGroup) + } + + @Test func buildFoldsAPassThroughScopeIntoTheNodeBelowIt() { + let rg = subA + "/resourceGroups/prod" + let tree = ScopeTree.build([ + role("Owner", rg, detail: "prod · resource group"), + role("Reader", rg, detail: "prod · resource group"), + ]) + + #expect(tree.count == 1) + #expect(tree[0].kind == .resourceGroup) + #expect(tree[0].ancestors == ["aaaaaaaa-0000-0000-0000-000000000000"]) + #expect(tree[0].path == "aaaaaaaa-0000-0000-0000-000000000000 / prod") + // Selecting the folded node must still reach the same subtree. + #expect(tree[0].scope == rg) + } + + @Test func buildDoesNotFoldAScopeThatHoldsARoleOfItsOwn() { + let tree = ScopeTree.build([ + role("Owner", subA, detail: "Alpha · subscription"), + role("Reader", subA + "/resourceGroups/prod", detail: "prod · resource group"), + ]) + #expect(tree[0].kind == .subscription) + #expect(tree[0].ancestors.isEmpty) + } + + @Test func buildDoesNotFoldAScopeThatBranches() { + let tree = ScopeTree.build([ + role("Owner", subA + "/resourceGroups/prod", detail: "prod · resource group"), + role("Reader", subA + "/resourceGroups/test", detail: "test · resource group"), + ]) + #expect(tree[0].kind == .subscription) + #expect(tree[0].children.count == 2) + } + + @Test func allRolesReachesEverythingBelowTheNode() { + let tree = ScopeTree.build([ + role("Owner", subA, detail: "Alpha · subscription"), + role("Contributor", subA + "/resourceGroups/prod", detail: "prod · resource group"), + role("Reader", subA + "/resourceGroups/prod/providers/Microsoft.Compute/virtualMachines/web01", detail: "web01 · vm"), + ]) + #expect(Set(tree[0].allRoles.map(\.displayName)) == ["Owner", "Contributor", "Reader"]) + #expect(tree[0].roleCount == 3) + } +} From 43779325b47338ea5c2438136aed790fb2c77db0 Mon Sep 17 00:00:00 2001 From: Frode Hus Date: Sat, 19 Sep 2026 01:57:05 +0200 Subject: [PATCH 2/2] Note the macOS path search in the changelog The search box reaching an Azure role's whole scope path is live on macOS the moment this merges, so it belongs under Unreleased rather than waiting for the tab's tree. CI caught the omission. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a13875ec..b13941e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the eligibilities alone cannot say which subscriptions belong to which management group. The macOS Azure tab still lists flat; its tree follows. ([#186](https://github.com/FrodeHus/elevate/issues/186)) +- macOS: the panel's search box reaches an Azure role's whole scope path, not just the caption the + row shows, so typing part of a subscription id or a resource group that is only named in the path + now finds it. The rest of the Azure tab's scope tree follows. + ([#194](https://github.com/FrodeHus/elevate/issues/194)) - macOS and Windows: the activation sheet no longer signs off with "Active", the word that means only that PIM wrote the assignment down. It holds for the first effective-access check and closes on **Ready** when the access is already there, on **Activated** when the check has not answered in