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 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) + } +}