diff --git a/SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs b/SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs
new file mode 100644
index 0000000..8cd3e4f
--- /dev/null
+++ b/SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs
@@ -0,0 +1,63 @@
+using SharepointToolbox.Core.Helpers;
+
+namespace SharepointToolbox.Tests.Services;
+
+///
+/// Tests for PERM-03: external user detection and permission-level filtering.
+/// Pure static logic — runs immediately without stubs.
+///
+public class PermissionEntryClassificationTests
+{
+ // ── IsExternalUser ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void IsExternalUser_WithExtHashInLoginName_ReturnsTrue()
+ {
+ // B2B guest login names contain the literal "#EXT#" fragment
+ Assert.True(PermissionEntryHelper.IsExternalUser("ext_user_domain.com#EXT#@contoso.onmicrosoft.com"));
+ }
+
+ [Fact]
+ public void IsExternalUser_WithNormalLoginName_ReturnsFalse()
+ {
+ Assert.False(PermissionEntryHelper.IsExternalUser("i:0#.f|membership|alice@contoso.com"));
+ }
+
+ // ── FilterPermissionLevels ─────────────────────────────────────────────────
+
+ [Fact]
+ public void PermissionEntry_FiltersOutLimitedAccess_WhenOnlyPermissionIsLimitedAccess()
+ {
+ // A principal whose sole permission level is "Limited Access" should produce
+ // an empty list after filtering — used to decide whether to include the entry.
+ var result = PermissionEntryHelper.FilterPermissionLevels(new[] { "Limited Access" });
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void FilterPermissionLevels_RetainsOtherLevels_WhenMixedWithLimitedAccess()
+ {
+ var result = PermissionEntryHelper.FilterPermissionLevels(new[] { "Limited Access", "Contribute" });
+ Assert.Equal(new[] { "Contribute" }, result);
+ }
+
+ // ── IsSharingLinksGroup ────────────────────────────────────────────────────
+
+ [Fact]
+ public void IsSharingLinksGroup_WithSharingLinksPrefix_ReturnsTrue()
+ {
+ Assert.True(PermissionEntryHelper.IsSharingLinksGroup("SharingLinks.abc123.Edit"));
+ }
+
+ [Fact]
+ public void IsSharingLinksGroup_WithLimitedAccessSystemGroup_ReturnsTrue()
+ {
+ Assert.True(PermissionEntryHelper.IsSharingLinksGroup("Limited Access System Group"));
+ }
+
+ [Fact]
+ public void IsSharingLinksGroup_WithNormalGroup_ReturnsFalse()
+ {
+ Assert.False(PermissionEntryHelper.IsSharingLinksGroup("Owners"));
+ }
+}
diff --git a/SharepointToolbox.Tests/Services/PermissionsServiceTests.cs b/SharepointToolbox.Tests/Services/PermissionsServiceTests.cs
new file mode 100644
index 0000000..390233e
--- /dev/null
+++ b/SharepointToolbox.Tests/Services/PermissionsServiceTests.cs
@@ -0,0 +1,31 @@
+using SharepointToolbox.Core.Models;
+using SharepointToolbox.Services;
+
+namespace SharepointToolbox.Tests.Services;
+
+///
+/// Test stubs for PERM-01 and PERM-04.
+/// These tests are skipped until IPermissionsService is implemented in Plan 02.
+///
+public class PermissionsServiceTests
+{
+ [Fact(Skip = "Requires live CSOM context — covered by Plan 02 implementation")]
+ public async Task ScanSiteAsync_ReturnsPermissionEntries_ForMockedSite()
+ {
+ // PERM-01: ScanSiteAsync returns a list of PermissionEntry records
+ // Arrange — requires a real or mocked ClientContext (CSOM)
+ // Act
+ // Assert
+ await Task.CompletedTask;
+ }
+
+ [Fact(Skip = "Requires live CSOM context — covered by Plan 02 implementation")]
+ public async Task ScanSiteAsync_WithIncludeInheritedFalse_SkipsItemsWithoutUniquePermissions()
+ {
+ // PERM-04: When IncludeInherited = false, items without unique permissions are excluded
+ // Arrange — requires a real or mocked ClientContext (CSOM)
+ // Act
+ // Assert
+ await Task.CompletedTask;
+ }
+}
diff --git a/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs b/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs
new file mode 100644
index 0000000..b45b2af
--- /dev/null
+++ b/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs
@@ -0,0 +1,22 @@
+using SharepointToolbox.Core.Models;
+using SharepointToolbox.Services;
+
+namespace SharepointToolbox.Tests.ViewModels;
+
+///
+/// Test stubs for PERM-02 (multi-site scan loop).
+/// Skipped until PermissionsViewModel is implemented in Plan 02.
+///
+public class PermissionsViewModelTests
+{
+ [Fact(Skip = "Requires live CSOM context — covered by Plan 02 implementation")]
+ public async Task StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl()
+ {
+ // PERM-02: When the user supplies N site URLs, IPermissionsService.ScanSiteAsync
+ // is invoked exactly once per URL (sequential, not parallel).
+ // Arrange — requires PermissionsViewModel and a mock IPermissionsService
+ // Act
+ // Assert
+ await Task.CompletedTask;
+ }
+}
diff --git a/SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs b/SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs
new file mode 100644
index 0000000..597cd72
--- /dev/null
+++ b/SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs
@@ -0,0 +1,30 @@
+namespace SharepointToolbox.Core.Helpers;
+
+///
+/// Pure static helpers for classifying SharePoint permission entries.
+///
+public static class PermissionEntryHelper
+{
+ ///
+ /// Returns true when the login name is a B2B guest (contains #EXT#).
+ ///
+ public static bool IsExternalUser(string loginName) =>
+ loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Removes "Limited Access" from the supplied permission levels.
+ /// Returns the remaining levels; returns an empty list when all are removed.
+ ///
+ public static IReadOnlyList FilterPermissionLevels(IEnumerable levels) =>
+ levels
+ .Where(l => !string.Equals(l, "Limited Access", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ ///
+ /// Returns true when the login name represents an internal sharing-link group
+ /// or the "Limited Access System Group" pseudo-principal.
+ ///
+ public static bool IsSharingLinksGroup(string loginName) =>
+ loginName.StartsWith("SharingLinks.", StringComparison.OrdinalIgnoreCase)
+ || loginName.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
+}