diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 8dd41a223..9c4cd986d 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -51,6 +51,9 @@ jobs: PN_PAM_PUB_KEY: ${{ secrets.SDK_PAM_PUB_KEY }} PN_PAM_SUB_KEY: ${{ secrets.SDK_PAM_SUB_KEY }} PN_PAM_SEC_KEY: ${{ secrets.SDK_PAM_SEC_KEY }} + PN_CAT_PUB_KEY: ${{ secrets.SDK_CAT_PUB_KEY }} + PN_CAT_SUB_KEY: ${{ secrets.SDK_CAT_SUB_KEY }} + PN_CAT_SEC_KEY: ${{ secrets.SDK_CAT_SEC_KEY }} run: dotnet test .\\src\\UnitTests\\PubnubApiPCL.Tests\\PubnubApiPCL.Tests.csproj --verbosity normal --logger "console;verbosity=detailed" - name: Cancel workflow runs for commit on error if: failure() diff --git a/.pubnub.yml b/.pubnub.yml index 1f63858c1..28688e124 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,8 +1,15 @@ name: c-sharp -version: "9.0.0" +version: "9.1.0" schema: 1 scm: github.com/pubnub/c-sharp changelog: + - date: 2026-10-13 + version: v9.1.0 + changes: + - type: feature + text: "Categories permission support for Grant and GrantToken apis." + - type: bug + text: "Ensure unsubscribeAll clears subscription state when event engine is enabled." - date: 2026-09-08 version: v9.0.0 changes: @@ -1058,14 +1065,14 @@ features: - QUERY-PARAM supported-platforms: - - version: Pubnub 'C#' 9.0.0 + version: Pubnub 'C#' 9.1.0 platforms: - Windows 10 and up - Windows Server 2008 and up frameworks: - .Net Framework 4.5+ - - version: PubnubPCL 'C#' 9.0.0 + version: PubnubPCL 'C#' 9.1.0 platforms: - Xamarin.Android - Xamarin.iOS @@ -1077,7 +1084,7 @@ supported-platforms: frameworks: - .Net 4.5+ - - version: PubnubUWP 'C#' 9.0.0 + version: PubnubUWP 'C#' 9.1.0 platforms: - Windows Phone 10 - Universal Windows Apps @@ -1101,7 +1108,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: Pubnub - location: https://github.com/pubnub/c-sharp/releases/tag/v9.0.0 + location: https://github.com/pubnub/c-sharp/releases/tag/v9.1.0 requires: - name: ".Net" @@ -1342,7 +1349,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: PubNubPCL - location: https://github.com/pubnub/c-sharp/releases/tag/v9.0.0 + location: https://github.com/pubnub/c-sharp/releases/tag/v9.1.0 requires: - name: ".Net" @@ -1693,7 +1700,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: PubnubUWP - location: https://github.com/pubnub/c-sharp/releases/tag/v9.0.0 + location: https://github.com/pubnub/c-sharp/releases/tag/v9.1.0 requires: - name: "Universal Windows Platform Development" diff --git a/CHANGELOG b/CHANGELOG index 8e6d3ec22..003b0e5a3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +v9.1.0 - October 13 2026 +----------------------------- +- Added: categories permission support for Grant and GrantToken apis. + +- Fixed: ensure unsubscribeAll clears subscription state when event engine is enabled. + v9.0.0 - September 08 2026 ----------------------------- - Added: added Data Sync feature support. diff --git a/src/Api/PubnubApi/EndPoint/Access/GrantOperation.cs b/src/Api/PubnubApi/EndPoint/Access/GrantOperation.cs index 535e99dfe..a2b61eca3 100644 --- a/src/Api/PubnubApi/EndPoint/Access/GrantOperation.cs +++ b/src/Api/PubnubApi/EndPoint/Access/GrantOperation.cs @@ -16,6 +16,7 @@ public class GrantOperation : PubnubCoreBase private string[] pubnubChannelNames; private string[] pubnubChannelGroupNames; private string[] pubnubTargetUuids; + private PNGrantCategory[] pubnubCategories; private string[] pamAuthenticationKeys; private bool grantWrite; private bool grantRead; @@ -53,6 +54,16 @@ public GrantOperation Uuids(string[] targetUuids) return this; } + /// + /// App Context categories whose metadata the auth keys may list in full. + /// Requires AuthKeys and accepts only the Get permission: Get(true) grants, Get(false) revokes. + /// + public GrantOperation Categories(PNGrantCategory[] categories) + { + pubnubCategories = categories; + return this; + } + public GrantOperation AuthKeys(string[] authKeys) { pamAuthenticationKeys = authKeys; @@ -129,6 +140,7 @@ public void Execute(PNCallback callback) { throw new InvalidOperationException("Only Get/Update/Delete permissions are allowed for UUID"); } + ValidateCategories(); savedCallback = callback; GrantAccess(callback); } @@ -143,6 +155,7 @@ public async Task> ExecuteAsync() { throw new InvalidOperationException("Only Get/Update/Delete permissions are allowed for UUID"); } + ValidateCategories(); return await GrantAccess().ConfigureAwait(false); } @@ -151,6 +164,39 @@ internal void Retry() GrantAccess(savedCallback); } + private void ValidateCategories() + { + if (pubnubCategories == null || pubnubCategories.Length == 0) + { + return; + } + if (pubnubCategories.Any(category => !Enum.IsDefined(typeof(PNGrantCategory), category))) + { + throw new InvalidOperationException("Only Channels and Uuids categories are supported"); + } + if (pamAuthenticationKeys == null || !pamAuthenticationKeys.Any(auth => !string.IsNullOrEmpty(auth) && auth.Trim().Length > 0)) + { + throw new InvalidOperationException("Category grants require at least one auth key"); + } + if (grantRead || grantWrite || grantManage || grantDelete || grantUpdate || grantJoin) + { + throw new InvalidOperationException("Only Get permission is allowed for categories"); + } + } + + private static string GetCategoryQueryValue(PNGrantCategory category) + { + switch (category) + { + case PNGrantCategory.Channels: + return "channels"; + case PNGrantCategory.Uuids: + return "uuids"; + default: + throw new InvalidOperationException("Only Channels and Uuids categories are supported"); + } + } + internal void GrantAccess(PNCallback callback) { if (string.IsNullOrEmpty(config.SecretKey) || string.IsNullOrEmpty(config.SecretKey.Trim()) || config.SecretKey.Length <= 0) @@ -161,6 +207,7 @@ internal void GrantAccess(PNCallback callback) "channels", pubnubChannelNames, "channelGroups", pubnubChannelGroupNames, "uuids", pubnubTargetUuids, + "categories", pubnubCategories, "authKeys", pamAuthenticationKeys, "read", grantRead, "write", grantWrite, @@ -212,6 +259,7 @@ internal async Task> GrantAccess() "channels", pubnubChannelNames, "channelGroups", pubnubChannelGroupNames, "uuids", pubnubTargetUuids, + "categories", pubnubCategories, "authKeys", pamAuthenticationKeys, "read", grantRead, "write", grantWrite, @@ -277,6 +325,7 @@ private RequestParameter CreateRequestParameter() List channelList = new List(); List channelGroupList = new List(); List uuidList = new List(); + List categoryList = new List(); List authList = new List(); if (pubnubChannelNames != null && pubnubChannelNames.Length > 0) @@ -297,6 +346,11 @@ private RequestParameter CreateRequestParameter() uuidList = uuidList.Where(uuid => !string.IsNullOrEmpty(uuid) && uuid.Trim().Length > 0).Distinct().ToList(); } + if (pubnubCategories != null && pubnubCategories.Length > 0) + { + categoryList = pubnubCategories.Select(GetCategoryQueryValue).Distinct().ToList(); + } + if (pamAuthenticationKeys != null && pamAuthenticationKeys.Length > 0) { authList = new List(pamAuthenticationKeys); @@ -306,6 +360,7 @@ private RequestParameter CreateRequestParameter() string channelsCommaDelimited = string.Join(",", channelList.OrderBy(x => x).ToArray()); string channelGroupsCommaDelimited = string.Join(",", channelGroupList.OrderBy(x => x).ToArray()); string targetUuidsCommaDelimited = string.Join(",", uuidList.OrderBy(x => x).ToArray()); + string categoriesCommaDelimited = string.Join(",", categoryList.OrderBy(x => x).ToArray()); string authKeysCommaDelimited = string.Join(",", authList.OrderBy(x => x).ToArray()); List pathSegments = new List() { @@ -338,6 +393,11 @@ private RequestParameter CreateRequestParameter() requestQueryStringParams.Add("target-uuid", UriUtil.EncodeUriComponent(targetUuidsCommaDelimited, PNOperationType.PNAccessManagerGrant, false, false, false)); } + if (!string.IsNullOrEmpty(categoriesCommaDelimited)) + { + requestQueryStringParams.Add("category", UriUtil.EncodeUriComponent(categoriesCommaDelimited, PNOperationType.PNAccessManagerGrant, false, false, false)); + } + if (grantTTL > -1) { requestQueryStringParams.Add("ttl", grantTTL.ToString(CultureInfo.InvariantCulture)); diff --git a/src/Api/PubnubApi/EndPoint/Access/GrantTokenOperation.cs b/src/Api/PubnubApi/EndPoint/Access/GrantTokenOperation.cs index b2efcc99e..d88e90858 100644 --- a/src/Api/PubnubApi/EndPoint/Access/GrantTokenOperation.cs +++ b/src/Api/PubnubApi/EndPoint/Access/GrantTokenOperation.cs @@ -44,6 +44,7 @@ public class GrantTokenOperation : PubnubCoreBase private string pubnubAuthorizedUuid = string.Empty; private string pubnubAuthorizedUserId = string.Empty; private PNDataSyncProjections pubnubDataSyncProjections; + private PNTokenCategories pubnubCategories; public GrantTokenOperation(PNConfiguration pubnubConfig, IJsonPluggableLibrary jsonPluggableLibrary, IPubnubUnitTest pubnubUnit, EndPoint.TokenManager tokenManager, Pubnub instance) : base(pubnubConfig, jsonPluggableLibrary, pubnubUnit, tokenManager, instance) { @@ -135,6 +136,16 @@ public GrantTokenOperation Patterns(PNTokenPatterns patterns) return this; } + /// + /// Category-level App Context permissions, e.g. listing all channel or uuid metadata. + /// Resource-level or pattern Get permissions do not imply them. + /// + public GrantTokenOperation Categories(PNTokenCategories categories) + { + this.pubnubCategories = categories; + return this; + } + /// /// Assigns Data Sync projections to resources/patterns. The projection name /// per resource is encoded into the token's meta "pn-projections" section. @@ -167,6 +178,7 @@ public GrantTokenOperation QueryParam(Dictionary customQueryPara public void Execute(PNCallback callback) { logger?.Trace($"{GetType().Name} Execute invoked"); + savedCallbackGrantToken = callback; GrantAccess(callback); } @@ -194,6 +206,7 @@ internal void GrantAccess(PNCallback callback) logger?.Debug(() => $"{GetType().Name} " + PubnubLogFormatter.Parameters("GrantToken", "resources", pubnubResources, "patterns", pubnubPatterns, + "categories", BuildCategoryPermissions(), "ttl", grantTTL, "authorizedUuid", pubnubAuthorizedUuid, "authorizedUserId", pubnubAuthorizedUserId, @@ -275,6 +288,21 @@ private bool AddDataSyncScope(Dictionary collection, string key, return atleastOnePermission; } + private Dictionary BuildCategoryPermissions() + { + Dictionary categories = new Dictionary(); + if (this.pubnubCategories == null) { + return categories; + } + if (this.pubnubCategories.Channels != null && this.pubnubCategories.Channels.Get) { + categories.Add("channels", (int)GrantBitFlag.GET); + } + if (this.pubnubCategories.Uuids != null && this.pubnubCategories.Uuids.Get) { + categories.Add("uuids", (int)GrantBitFlag.GET); + } + return categories; + } + private Dictionary BuildProjectionsMeta() { if (this.pubnubDataSyncProjections == null) { @@ -332,6 +360,7 @@ internal async Task> GrantAccess() logger?.Debug(() => $"{GetType().Name} " + PubnubLogFormatter.Parameters("GrantToken", "resources", pubnubResources, "patterns", pubnubPatterns, + "categories", BuildCategoryPermissions(), "ttl", grantTTL, "authorizedUuid", pubnubAuthorizedUuid, "authorizedUserId", pubnubAuthorizedUserId, @@ -483,8 +512,13 @@ private RequestParameter CreateRequestParameter() atleastOnePermission = AddDataSyncScopes(resourcesCollection, this.pubnubResources.DataSync, atleastOnePermission); atleastOnePermission = AddDataSyncScopes(patternsCollection, this.pubnubPatterns.DataSync, atleastOnePermission); + Dictionary categoryBitmaskPermCollection = BuildCategoryPermissions(); + if (categoryBitmaskPermCollection.Count > 0) { + atleastOnePermission = true; + } + if (!atleastOnePermission) { - config?.Logger?.Warn("GrantToken At least one permission is needed for at least one or more of uuids/users, channels/spaces, groups or datasync entities/relationships/memberships"); + config?.Logger?.Warn("GrantToken At least one permission is needed for at least one or more of uuids/users, channels/spaces, groups, categories or datasync entities/relationships/memberships"); } Dictionary optimizedMeta = new Dictionary(); @@ -503,6 +537,9 @@ private RequestParameter CreateRequestParameter() { "patterns", patternsCollection }, { "meta", optimizedMeta } }; + if (categoryBitmaskPermCollection.Count > 0) { + permissionCollection.Add("categories", categoryBitmaskPermCollection); + } if (!string.IsNullOrEmpty(this.pubnubAuthorizedUuid) && this.pubnubAuthorizedUuid.Trim().Length > 0) { permissionCollection.Add("uuid", this.pubnubAuthorizedUuid); } else if (!string.IsNullOrEmpty(this.pubnubAuthorizedUserId) && this.pubnubAuthorizedUserId.Trim().Length > 0) { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Channel/CreateChannelParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Channel/CreateChannelParameters.cs index 638d6ce58..69d82a3c6 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Channel/CreateChannelParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Channel/CreateChannelParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class CreateChannelParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Channel/DeleteChannelParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Channel/DeleteChannelParameters.cs index dc3d6d918..176d89c6d 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Channel/DeleteChannelParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Channel/DeleteChannelParameters.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint +namespace PubnubApi { public class DeleteChannelParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Channel/GetChannelParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Channel/GetChannelParameters.cs index 02b016b1a..1721f9972 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Channel/GetChannelParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Channel/GetChannelParameters.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint +namespace PubnubApi { public class GetChannelParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Channel/GetChannelsParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Channel/GetChannelsParameters.cs index 0fc648c96..d69db0ca5 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Channel/GetChannelsParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Channel/GetChannelsParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class GetChannelsParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Channel/PNDataSyncChannelResult.cs b/src/Api/PubnubApi/EndPoint/DataSync/Channel/PNDataSyncChannelResult.cs index 9828b6ad9..493291c7a 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Channel/PNDataSyncChannelResult.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Channel/PNDataSyncChannelResult.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint; +namespace PubnubApi; public class PNDataSyncChannelResult { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Channel/PNDataSyncDeleteChannelResult.cs b/src/Api/PubnubApi/EndPoint/DataSync/Channel/PNDataSyncDeleteChannelResult.cs index f1169ac68..14a646302 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Channel/PNDataSyncDeleteChannelResult.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Channel/PNDataSyncDeleteChannelResult.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint; +namespace PubnubApi; public class PNDataSyncDeleteChannelResult { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Channel/SetChannelParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Channel/SetChannelParameters.cs index 855f6a2d5..79b5bc0f3 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Channel/SetChannelParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Channel/SetChannelParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class SetChannelParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Channel/UpdateChannelParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Channel/UpdateChannelParameters.cs index cbaaa61f8..aff574b82 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Channel/UpdateChannelParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Channel/UpdateChannelParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class UpdateChannelParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/DataSync.cs b/src/Api/PubnubApi/EndPoint/DataSync/DataSync.cs index 645948593..73d0517e0 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/DataSync.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/DataSync.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; +using PubnubApi.EndPoint; -namespace PubnubApi.EndPoint; +namespace PubnubApi; /// /// Entrypoint for PubNub Data Sync operations diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Entity/CreateEntityParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Entity/CreateEntityParameters.cs index 690ef3742..aa141a649 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Entity/CreateEntityParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Entity/CreateEntityParameters.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for creating a new entity via DataSync. diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Entity/DeleteEntityParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Entity/DeleteEntityParameters.cs index 926a6025e..e40fa7e85 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Entity/DeleteEntityParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Entity/DeleteEntityParameters.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for deleting an entity by ID via DataSync. diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Entity/GetEntitiesParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Entity/GetEntitiesParameters.cs index 4ba572956..0cc991139 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Entity/GetEntitiesParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Entity/GetEntitiesParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for listing entities via DataSync. diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Entity/GetEntityParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Entity/GetEntityParameters.cs index be6c079f8..2a716d85d 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Entity/GetEntityParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Entity/GetEntityParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for retrieving an entity by ID via DataSync. diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Entity/PNDataSyncDeleteEntityResult.cs b/src/Api/PubnubApi/EndPoint/DataSync/Entity/PNDataSyncDeleteEntityResult.cs index 49705f0eb..e2fc7dcdb 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Entity/PNDataSyncDeleteEntityResult.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Entity/PNDataSyncDeleteEntityResult.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint; +namespace PubnubApi; public class PNDataSyncDeleteEntityResult { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Entity/PNDataSyncEntityResult.cs b/src/Api/PubnubApi/EndPoint/DataSync/Entity/PNDataSyncEntityResult.cs index 824825670..c6d99b9fd 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Entity/PNDataSyncEntityResult.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Entity/PNDataSyncEntityResult.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint; +namespace PubnubApi; /// /// Result returned by a single-entity operation (Create, Get, Update, Patch). diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Entity/SetEntityParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Entity/SetEntityParameters.cs index 466851587..60a1049af 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Entity/SetEntityParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Entity/SetEntityParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for updating an entity with complete resource replacement (PUT) via DataSync. diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Entity/UpdateEntityParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Entity/UpdateEntityParameters.cs index ea1a5f597..ae0074eaa 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Entity/UpdateEntityParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Entity/UpdateEntityParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class UpdateEntityParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Membership/CreateMembershipParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Membership/CreateMembershipParameters.cs index 78e9a0280..db6dd1705 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Membership/CreateMembershipParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Membership/CreateMembershipParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class CreateMembershipParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Membership/DeleteMembershipParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Membership/DeleteMembershipParameters.cs index b9f26d685..5c15a9c1c 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Membership/DeleteMembershipParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Membership/DeleteMembershipParameters.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint +namespace PubnubApi { public class DeleteMembershipParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Membership/GetMembershipParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Membership/GetMembershipParameters.cs index 46365381e..6dafa551d 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Membership/GetMembershipParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Membership/GetMembershipParameters.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint +namespace PubnubApi { public class GetMembershipParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Membership/GetMembershipsParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Membership/GetMembershipsParameters.cs index 687f779a1..c023dd53e 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Membership/GetMembershipsParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Membership/GetMembershipsParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class GetMembershipsParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Membership/PNDataSyncDeleteMembershipResult.cs b/src/Api/PubnubApi/EndPoint/DataSync/Membership/PNDataSyncDeleteMembershipResult.cs index 9d7896dce..c8ee09e0b 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Membership/PNDataSyncDeleteMembershipResult.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Membership/PNDataSyncDeleteMembershipResult.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint; +namespace PubnubApi; public class PNDataSyncDeleteMembershipResult { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Membership/PNDataSyncMembershipResult.cs b/src/Api/PubnubApi/EndPoint/DataSync/Membership/PNDataSyncMembershipResult.cs index 6c584a2b4..2c11ec1e8 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Membership/PNDataSyncMembershipResult.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Membership/PNDataSyncMembershipResult.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint; +namespace PubnubApi; public class PNDataSyncMembershipResult { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Membership/SetMembershipParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Membership/SetMembershipParameters.cs index 3763735dc..0733c92f0 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Membership/SetMembershipParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Membership/SetMembershipParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class UpdateMembershipParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Membership/UpdateMembershipParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Membership/UpdateMembershipParameters.cs index 16e45831d..c4d990cb8 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Membership/UpdateMembershipParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Membership/UpdateMembershipParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class PatchMembershipParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/CreateRelationshipParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/CreateRelationshipParameters.cs index 03afac7ef..8d30eeb72 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/CreateRelationshipParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/CreateRelationshipParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for creating a new relationship via DataSync. diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/DeleteRelationshipParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/DeleteRelationshipParameters.cs index 55dcbba8e..5586168d4 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/DeleteRelationshipParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/DeleteRelationshipParameters.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for deleting a relationship by ID via DataSync. diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/GetRelationshipParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/GetRelationshipParameters.cs index 51d859de1..1b79c5b16 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/GetRelationshipParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/GetRelationshipParameters.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for retrieving a relationship by ID via DataSync. diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/GetRelationshipsParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/GetRelationshipsParameters.cs index d5e6b17ce..c4eb02b6b 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/GetRelationshipsParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/GetRelationshipsParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for listing relationships via DataSync. diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/PNDataSyncDeleteRelationshipResult.cs b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/PNDataSyncDeleteRelationshipResult.cs index 7ba18de2f..9c8d316a4 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/PNDataSyncDeleteRelationshipResult.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/PNDataSyncDeleteRelationshipResult.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint; +namespace PubnubApi; public class PNDataSyncDeleteRelationshipResult { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/PNDataSyncRelationshipResult.cs b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/PNDataSyncRelationshipResult.cs index 39a464999..16f54b3a6 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/PNDataSyncRelationshipResult.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/PNDataSyncRelationshipResult.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint; +namespace PubnubApi; /// /// Result returned by a single-relationship operation (Create, Get, Update, Patch). diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/SetRelationshipParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/SetRelationshipParameters.cs index dfa637da6..f26bc4bad 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/SetRelationshipParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/SetRelationshipParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for updating a relationship with complete resource replacement (PUT) via DataSync. diff --git a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/UpdateRelationshipParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/UpdateRelationshipParameters.cs index 3013ac337..8a1f009ec 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/Relationship/UpdateRelationshipParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/Relationship/UpdateRelationshipParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { /// /// Parameters for partially updating a relationship via JSON Patch (RFC 6902). diff --git a/src/Api/PubnubApi/EndPoint/DataSync/User/CreateUserParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/User/CreateUserParameters.cs index f7270bf9b..143b5db0b 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/User/CreateUserParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/User/CreateUserParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class CreateUserParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/User/DeleteUserParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/User/DeleteUserParameters.cs index 3b3633fa8..48f3e5d66 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/User/DeleteUserParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/User/DeleteUserParameters.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint +namespace PubnubApi { public class DeleteUserParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/User/GetUserParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/User/GetUserParameters.cs index f4522bf3f..e33ff69d1 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/User/GetUserParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/User/GetUserParameters.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint +namespace PubnubApi { public class GetUserParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/User/GetUsersParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/User/GetUsersParameters.cs index 97f9b517b..4d8608731 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/User/GetUsersParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/User/GetUsersParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class GetUsersParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/User/PNDataSyncDeleteUserResult.cs b/src/Api/PubnubApi/EndPoint/DataSync/User/PNDataSyncDeleteUserResult.cs index 717226c98..206d80322 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/User/PNDataSyncDeleteUserResult.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/User/PNDataSyncDeleteUserResult.cs @@ -1,4 +1,4 @@ -namespace PubnubApi.EndPoint; +namespace PubnubApi; public class PNDataSyncDeleteUserResult { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/User/PNDataSyncUserResult.cs b/src/Api/PubnubApi/EndPoint/DataSync/User/PNDataSyncUserResult.cs index 9bcdd191b..ba79eafa6 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/User/PNDataSyncUserResult.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/User/PNDataSyncUserResult.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint; +namespace PubnubApi; public class PNDataSyncUserResult { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/User/SetUserParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/User/SetUserParameters.cs index 758eebc90..9c07fce7d 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/User/SetUserParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/User/SetUserParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class SetUserParameters { diff --git a/src/Api/PubnubApi/EndPoint/DataSync/User/UpdateUserParameters.cs b/src/Api/PubnubApi/EndPoint/DataSync/User/UpdateUserParameters.cs index 983912a5b..8f23bb27e 100644 --- a/src/Api/PubnubApi/EndPoint/DataSync/User/UpdateUserParameters.cs +++ b/src/Api/PubnubApi/EndPoint/DataSync/User/UpdateUserParameters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PubnubApi.EndPoint +namespace PubnubApi { public class UpdateUserParameters { diff --git a/src/Api/PubnubApi/EndPoint/PubSub/UnsubscribeAllEndpoint.cs b/src/Api/PubnubApi/EndPoint/PubSub/UnsubscribeAllEndpoint.cs index c21a85de5..89e1cea29 100644 --- a/src/Api/PubnubApi/EndPoint/PubSub/UnsubscribeAllEndpoint.cs +++ b/src/Api/PubnubApi/EndPoint/PubSub/UnsubscribeAllEndpoint.cs @@ -1,4 +1,6 @@ -using PubnubApi.EventEngine.Presence; +using System.Collections.Generic; +using System.Linq; +using PubnubApi.EventEngine.Presence; using PubnubApi.EventEngine.Subscribe; namespace PubnubApi.EndPoint @@ -10,13 +12,15 @@ public class UnsubscribeAllEndpoint: UnsubscribeAllOperation private readonly IPubnubUnitTest unit; private readonly SubscribeEventEngineFactory subscribeEventEngineFactory; private readonly PresenceEventEngineFactory presenceEventEngineFactory; + private readonly LeaveOperation leaveOperation; private readonly string instanceId; - public UnsubscribeAllEndpoint(PNConfiguration pubnubConfig, IJsonPluggableLibrary jsonPluggableLibrary, IPubnubUnitTest pubnubUnit, EndPoint.TokenManager tokenManager, SubscribeEventEngineFactory subscribeEventEngineFactory, PresenceEventEngineFactory presenceEventEngineFactory ,Pubnub instance) : base(pubnubConfig, jsonPluggableLibrary, pubnubUnit, tokenManager, instance) + public UnsubscribeAllEndpoint(PNConfiguration pubnubConfig, IJsonPluggableLibrary jsonPluggableLibrary, IPubnubUnitTest pubnubUnit, EndPoint.TokenManager tokenManager, LeaveOperation leaveOperation, SubscribeEventEngineFactory subscribeEventEngineFactory, PresenceEventEngineFactory presenceEventEngineFactory ,Pubnub instance) : base(pubnubConfig, jsonPluggableLibrary, pubnubUnit, tokenManager, instance, false) { config = pubnubConfig; jsonLibrary = jsonPluggableLibrary; unit = pubnubUnit; + this.leaveOperation = leaveOperation; this.subscribeEventEngineFactory = subscribeEventEngineFactory; this.presenceEventEngineFactory = presenceEventEngineFactory; instanceId = PubnubInstance.InstanceId; @@ -28,24 +32,32 @@ private void UnsubscribeAll() { logger?.Trace($"{GetType().Name} Execute invoked"); + var subscribeEventEngines = new List(); if (config.SplitSubscribeCalls) { - var allEngines = subscribeEventEngineFactory.GetAllEngineInstances(instanceId); - foreach (var engine in allEngines) - { - engine.UnsubscribeAll(); - } + subscribeEventEngines = subscribeEventEngineFactory.GetAllEngineInstances(instanceId); + } + else if (subscribeEventEngineFactory.HasEventEngine(instanceId)) + { + logger?.Trace($"EventEngine instance found."); + subscribeEventEngines.Add(subscribeEventEngineFactory.GetEventEngine(instanceId)); } - else + + bool usePresenceEventEngine = config.PresenceInterval > 0 && presenceEventEngineFactory.HasEventEngine(instanceId); + foreach (var subscribeEventEngine in subscribeEventEngines) { - if (subscribeEventEngineFactory.HasEventEngine(instanceId)) { - logger?.Trace($"EventEngine instance found."); - SubscribeEventEngine subscribeEventEngine = subscribeEventEngineFactory.GetEventEngine(instanceId); - subscribeEventEngine.UnsubscribeAll(); + var channelsToLeave = subscribeEventEngine.Channels.Where(c => !c.EndsWith(Constants.Pnpres)).Distinct().ToArray(); + var channelGroupsToLeave = subscribeEventEngine.ChannelGroups.Where(cg => !cg.EndsWith(Constants.Pnpres)).Distinct().ToArray(); + + subscribeEventEngine.UnsubscribeAll(); + + if (!usePresenceEventEngine && !config.SuppressLeaveEvents && (channelsToLeave.Length > 0 || channelGroupsToLeave.Length > 0)) + { + SendLeave(channelsToLeave, channelGroupsToLeave); } } - if (config.PresenceInterval > 0 && presenceEventEngineFactory.HasEventEngine(instanceId)) { + if (usePresenceEventEngine) { PresenceEventEngine presenceEventEngine = presenceEventEngineFactory.GetEventEngine(instanceId); presenceEventEngine.EventQueue.Enqueue(new EventEngine.Presence.Events.LeftAllEvent()); } @@ -54,11 +66,21 @@ private void UnsubscribeAll() if (ChannelLocalUserState.TryGetValue(instanceId, out var channelStates)) { channelStates.Clear(); } - if (ChannelLocalUserState.TryGetValue(instanceId, out var channelGroupStates)) { + if (ChannelGroupLocalUserState.TryGetValue(instanceId, out var channelGroupStates)) { channelGroupStates.Clear(); } } } + + private void SendLeave(string[] channels, string[] channelGroups) + { + leaveOperation.LeaveRequest(channels, channelGroups).ContinueWith(t => + { + if (t.Result is { Error: true } status) + { + logger?.Warn($"UnsubscribeAll leave request failed for channel(s)={string.Join(",", channels)} channelGroup(s)={string.Join(",", channelGroups)}: {status.ErrorData?.Information}"); + } + }, System.Threading.Tasks.TaskContinuationOptions.OnlyOnRanToCompletion); + } } } - diff --git a/src/Api/PubnubApi/EndPoint/PubSub/UnsubscribeAllOperation.cs b/src/Api/PubnubApi/EndPoint/PubSub/UnsubscribeAllOperation.cs index cbef4f05b..2f5e3c43e 100644 --- a/src/Api/PubnubApi/EndPoint/PubSub/UnsubscribeAllOperation.cs +++ b/src/Api/PubnubApi/EndPoint/PubSub/UnsubscribeAllOperation.cs @@ -12,7 +12,15 @@ public class UnsubscribeAllOperation : PubnubCoreBase private readonly TokenManager pubnubTokenManager; private Dictionary queryParam; - public UnsubscribeAllOperation(PNConfiguration pubnubConfig, IJsonPluggableLibrary jsonPluggableLibrary, IPubnubUnitTest pubnubUnit, TokenManager tokenManager, Pubnub instance) : base(pubnubConfig, jsonPluggableLibrary, pubnubUnit, tokenManager, instance) + public UnsubscribeAllOperation(PNConfiguration pubnubConfig, IJsonPluggableLibrary jsonPluggableLibrary, IPubnubUnitTest pubnubUnit, TokenManager tokenManager, Pubnub instance) : this(pubnubConfig, jsonPluggableLibrary, pubnubUnit, tokenManager, instance, true) + { + } + + /// + /// When false, the legacy SubscribeManager logic is skipped. Derived types use this to run their own + /// unsubscribe logic, which cannot be done through a virtual call from this constructor. + /// + protected UnsubscribeAllOperation(PNConfiguration pubnubConfig, IJsonPluggableLibrary jsonPluggableLibrary, IPubnubUnitTest pubnubUnit, TokenManager tokenManager, Pubnub instance, bool runLegacyUnsubscribe) : base(pubnubConfig, jsonPluggableLibrary, pubnubUnit, tokenManager, instance) { config = pubnubConfig; jsonLibrary = jsonPluggableLibrary; @@ -20,7 +28,10 @@ public UnsubscribeAllOperation(PNConfiguration pubnubConfig, IJsonPluggableLibra pubnubTokenManager = tokenManager; CurrentPubnubInstance(instance); - UnsubscribeAll(); + if (runLegacyUnsubscribe) + { + UnsubscribeAll(); + } } public UnsubscribeAllOperation QueryParam(Dictionary customQueryParam) diff --git a/src/Api/PubnubApi/EndPoint/TokenManager.cs b/src/Api/PubnubApi/EndPoint/TokenManager.cs index 793d2db15..ab8141dc4 100644 --- a/src/Api/PubnubApi/EndPoint/TokenManager.cs +++ b/src/Api/PubnubApi/EndPoint/TokenManager.cs @@ -89,6 +89,7 @@ public PNTokenContent ParseToken(string token) Spaces = new Dictionary(), DataSync = NewDataSyncScopes() }; + result.Categories = new PNTokenCategories(); ParseCBOR(cborDict, ref result); } } @@ -178,6 +179,34 @@ private void FillTokenPermissionMapping(Dictionary permissionVal } } + private void FillTokenCategories(Dictionary categoryValues, PNTokenCategories categories) + { + if (categoryValues == null) + { + return; + } + foreach (var categoryKvp in categoryValues) + { + var categoryType = ByteToString(categoryKvp.Key); + var categoryPermission = new PNTokenCategoryPermissions + { + Get = (Convert.ToInt32(categoryKvp.Value) & (int)GrantBitFlag.GET) != 0 + }; + switch (categoryType) + { + case "chan": + categories.Channels = categoryPermission; + break; + case "uuid": + categories.Uuids = categoryPermission; + break; + default: + logger?.Error($"Unidentified category when parsing token! {categoryType}"); + break; + } + } + } + private void ParseCBOR(Dictionary cbor, ref PNTokenContent pnGrantTokenDecoded) { foreach (var kvp in cbor) @@ -215,6 +244,9 @@ private void ParseCBOR(Dictionary cbor, ref PNTokenContent pnGra case "pat": FillTokenPermissionMapping(kvp.Value as Dictionary, pnGrantTokenDecoded.Patterns); break; + case "cat": + FillTokenCategories(kvp.Value as Dictionary, pnGrantTokenDecoded.Categories); + break; default: logger?.Error($"Unidentified key when parsing token! {key}"); break; diff --git a/src/Api/PubnubApi/Enum/PNGrantCategory.cs b/src/Api/PubnubApi/Enum/PNGrantCategory.cs new file mode 100644 index 000000000..4ae9c4a8a --- /dev/null +++ b/src/Api/PubnubApi/Enum/PNGrantCategory.cs @@ -0,0 +1,18 @@ +namespace PubnubApi +{ + /// + /// App Context resource types that a PAMv2 category grant applies to as a whole. + /// + public enum PNGrantCategory + { + /// + /// Listing all channel metadata on the subscribe key. + /// + Channels, + + /// + /// Listing all uuid metadata on the subscribe key. + /// + Uuids + } +} diff --git a/src/Api/PubnubApi/EventEngine/Subscribe/SubscribeEventEngine.cs b/src/Api/PubnubApi/EventEngine/Subscribe/SubscribeEventEngine.cs index 84ab31120..e2854e305 100644 --- a/src/Api/PubnubApi/EventEngine/Subscribe/SubscribeEventEngine.cs +++ b/src/Api/PubnubApi/EventEngine/Subscribe/SubscribeEventEngine.cs @@ -100,6 +100,8 @@ public void Subscribe(string[] channels, string[] channelGroups, SubscriptionCur public void UnsubscribeAll() { + Channels.Clear(); + ChannelGroups.Clear(); EventQueue.Enqueue(new UnsubscribeAllEvent()); } diff --git a/src/Api/PubnubApi/JsonDataParse/PNAccessManagerGrantJsonDataParse.cs b/src/Api/PubnubApi/JsonDataParse/PNAccessManagerGrantJsonDataParse.cs index ee9fb1d59..a4094dff4 100644 --- a/src/Api/PubnubApi/JsonDataParse/PNAccessManagerGrantJsonDataParse.cs +++ b/src/Api/PubnubApi/JsonDataParse/PNAccessManagerGrantJsonDataParse.cs @@ -298,6 +298,11 @@ internal static PNAccessManagerGrantResult GetObject(IJsonPluggableLibrary jsonP } } //end of if uuid + if (grantAckPayloadDict.ContainsKey("categories")) + { + ack.Categories = GetResourceAuthKeyData(jsonPlug, grantAckPayloadDict["categories"]); + } + } //end of else subkey } @@ -308,6 +313,44 @@ internal static PNAccessManagerGrantResult GetObject(IJsonPluggableLibrary jsonP return ack; } + private static Dictionary> GetResourceAuthKeyData(IJsonPluggableLibrary jsonPlug, object resourceList) + { + Dictionary> resourceAuthKeyData = new Dictionary>(); + + Dictionary resourceListDict = jsonPlug.ConvertToDictionaryObject(resourceList); + if (resourceListDict == null) + { + return resourceAuthKeyData; + } + + foreach (string resource in resourceListDict.Keys) + { + Dictionary resourceDataDict = jsonPlug.ConvertToDictionaryObject(resourceListDict[resource]); + if (resourceDataDict == null || !resourceDataDict.ContainsKey("auths")) + { + continue; + } + + Dictionary authKeyDataDict = new Dictionary(); + Dictionary authListDict = jsonPlug.ConvertToDictionaryObject(resourceDataDict["auths"]); + if (authListDict != null) + { + foreach (string authKey in authListDict.Keys) + { + Dictionary authDataDict = jsonPlug.ConvertToDictionaryObject(authListDict[authKey]); + if (authDataDict != null && authDataDict.Count > 0) + { + authKeyDataDict.Add(authKey, GetAccessManagerKeyData(authDataDict)); + } + } + } + + resourceAuthKeyData.Add(resource, authKeyDataDict); + } + + return resourceAuthKeyData; + } + private static PNAccessManagerKeyData GetAccessManagerKeyData(Dictionary grantAccessDataDict) { PNAccessManagerKeyData pamData = new PNAccessManagerKeyData(); diff --git a/src/Api/PubnubApi/Model/Consumer/AccessManager/PNAccessManagerGrantResult.cs b/src/Api/PubnubApi/Model/Consumer/AccessManager/PNAccessManagerGrantResult.cs index a4c8ba52e..fa33509b5 100644 --- a/src/Api/PubnubApi/Model/Consumer/AccessManager/PNAccessManagerGrantResult.cs +++ b/src/Api/PubnubApi/Model/Consumer/AccessManager/PNAccessManagerGrantResult.cs @@ -20,5 +20,10 @@ public PNAccessManagerGrantResult() public Dictionary> Uuids { get; internal set; } + /// + /// Category-level permissions keyed by category ("channels", "uuids") and then by auth key. + /// + public Dictionary> Categories { get; internal set; } + } } diff --git a/src/Api/PubnubApi/Model/Consumer/AccessManager/PNTokenCategories.cs b/src/Api/PubnubApi/Model/Consumer/AccessManager/PNTokenCategories.cs new file mode 100644 index 000000000..c27703236 --- /dev/null +++ b/src/Api/PubnubApi/Model/Consumer/AccessManager/PNTokenCategories.cs @@ -0,0 +1,30 @@ +namespace PubnubApi +{ + /// + /// Category-level App Context permissions of a token. A category applies to a resource type + /// as a whole: resource-level or pattern Get permissions do not allow listing all metadata of that type. + /// + public class PNTokenCategories + { + /// + /// Get allows listing all channel metadata on the subscribe key. + /// + public PNTokenCategoryPermissions Channels { get; set; } = new PNTokenCategoryPermissions(); + + /// + /// Get allows listing all uuid metadata on the subscribe key. + /// + public PNTokenCategoryPermissions Uuids { get; set; } = new PNTokenCategoryPermissions(); + } + + /// + /// Permissions granted on a single App Context category. + /// + public class PNTokenCategoryPermissions + { + /// + /// Get. The only permission accepted for categories. + /// + public bool Get { get; set; } + } +} diff --git a/src/Api/PubnubApi/Model/Consumer/AccessManager/PNTokenContent.cs b/src/Api/PubnubApi/Model/Consumer/AccessManager/PNTokenContent.cs index f54f341a0..e0e25c3e7 100644 --- a/src/Api/PubnubApi/Model/Consumer/AccessManager/PNTokenContent.cs +++ b/src/Api/PubnubApi/Model/Consumer/AccessManager/PNTokenContent.cs @@ -17,6 +17,11 @@ public class PNTokenContent public PNTokenPatterns Patterns { get; set; } + /// + /// Category-level App Context permissions decoded from the token's "cat" section. + /// + public PNTokenCategories Categories { get; set; } + public Dictionary Meta { get; set; } /// diff --git a/src/Api/PubnubApi/Model/Consumer/Pubsub/PNDataSyncEventResult.cs b/src/Api/PubnubApi/Model/Consumer/Pubsub/PNDataSyncEventResult.cs index df40c6125..0e8f67755 100644 --- a/src/Api/PubnubApi/Model/Consumer/Pubsub/PNDataSyncEventResult.cs +++ b/src/Api/PubnubApi/Model/Consumer/Pubsub/PNDataSyncEventResult.cs @@ -9,8 +9,8 @@ public class PNDataSyncEventResult public string ClassName { get; internal set; } = ""; public int ClassVersion { get; internal set; } public string ClassLevel { get; internal set; } = ""; //values = Global/SubKey; distinguishes built-in classes from developer-defined ones of the same name - public PubnubApi.EndPoint.PNDataSyncEntityResult EntityData { get; internal set; } //Populated when Type = entity/user/channel (create/update) - public PubnubApi.EndPoint.PNDataSyncRelationshipResult RelationshipData { get; internal set; } //Populated when Type = relationship/membership (create/update) + public PNDataSyncEntityResult EntityData { get; internal set; } //Populated when Type = entity/user/channel (create/update) + public PNDataSyncRelationshipResult RelationshipData { get; internal set; } //Populated when Type = relationship/membership (create/update) public string Id { get; internal set; } //Populated for delete events public string DeletedAt { get; internal set; } //Populated for delete events public long Timestamp { get; internal set; } diff --git a/src/Api/PubnubApi/Properties/AssemblyInfo.cs b/src/Api/PubnubApi/Properties/AssemblyInfo.cs index 9ec5a35d2..bc4e36875 100644 --- a/src/Api/PubnubApi/Properties/AssemblyInfo.cs +++ b/src/Api/PubnubApi/Properties/AssemblyInfo.cs @@ -11,8 +11,8 @@ [assembly: AssemblyProduct("Pubnub C# SDK")] [assembly: AssemblyCopyright("Copyright © 2021")] [assembly: AssemblyTrademark("")] -[assembly: AssemblyVersion("9.0.0")] -[assembly: AssemblyFileVersion("9.0.0")] +[assembly: AssemblyVersion("9.1.0")] +[assembly: AssemblyFileVersion("9.1.0")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from // COM, set the ComVisible attribute to true on that type. diff --git a/src/Api/PubnubApi/Pubnub.cs b/src/Api/PubnubApi/Pubnub.cs index 503757737..21e85f66f 100644 --- a/src/Api/PubnubApi/Pubnub.cs +++ b/src/Api/PubnubApi/Pubnub.cs @@ -114,10 +114,13 @@ public UnsubscribeAllOperation UnsubscribeAll() { if (pubnubConfig[InstanceId].EnableEventEngine) { + leaveOperation ??= + new LeaveOperation(pubnubConfig.ContainsKey(InstanceId) ? pubnubConfig[InstanceId] : null, + JsonPluggableLibrary, pubnubUnitTest, tokenManager, this); UnsubscribeAllEndpoint unsubscribeAllEndpoint = new UnsubscribeAllEndpoint( pubnubConfig.ContainsKey(InstanceId) ? pubnubConfig[InstanceId] : null, JsonPluggableLibrary, - pubnubUnitTest, tokenManager, subscribeEventEngineFactory, presenceEventengineFactory, - this); + pubnubUnitTest, tokenManager, leaveOperation, subscribeEventEngineFactory, + presenceEventengineFactory, this); return unsubscribeAllEndpoint; } else diff --git a/src/Api/PubnubApi/PubnubApi.csproj b/src/Api/PubnubApi/PubnubApi.csproj index 48f8a20d2..4c4ed49c6 100644 --- a/src/Api/PubnubApi/PubnubApi.csproj +++ b/src/Api/PubnubApi/PubnubApi.csproj @@ -14,7 +14,7 @@ Pubnub - 9.0.0 + 9.1.0 PubNub C# .NET SDK Pandu Masabathula PubNub @@ -22,7 +22,8 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Added Data Sync feature support. + Categories permission support for Grant and GrantToken apis. +Ensure unsubscribeAll clears subscription state when event engine is enabled. https://www.pubnub.com/docs/sdks/c-sharp Pubnub Realtime Real-time Publish-subscribe Pubsub Messaging Occupancy Presence Message-ordering Message-history Chat Long-poll PubNub SDK for C# .NET. Publish/subscribe messaging over HTTP long-poll with server-assigned message ordering, channel occupancy via Here Now, and message history through Message Persistence. diff --git a/src/Api/PubnubApiPCL/PubnubApiPCL.csproj b/src/Api/PubnubApiPCL/PubnubApiPCL.csproj index 16a7eaf97..356d614f1 100644 --- a/src/Api/PubnubApiPCL/PubnubApiPCL.csproj +++ b/src/Api/PubnubApiPCL/PubnubApiPCL.csproj @@ -14,7 +14,7 @@ PubnubPCL - 9.0.0 + 9.1.0 PubNub C# .NET SDK Pandu Masabathula PubNub @@ -22,7 +22,8 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Added Data Sync feature support. + Categories permission support for Grant and GrantToken apis. +Ensure unsubscribeAll clears subscription state when event engine is enabled. https://www.pubnub.com/docs/sdks/c-sharp Pubnub Realtime Real-time Publish-subscribe Pubsub Messaging Occupancy Presence Message-ordering Message-history Chat Long-poll PubNub SDK for C# .NET. Publish/subscribe messaging over HTTP long-poll with server-assigned message ordering, channel occupancy via Here Now, and message history through Message Persistence. @@ -391,6 +392,7 @@ + Enum\PNHeartbeatNotificationOption.cs @@ -587,6 +589,7 @@ Model\Consumer\AccessManager\PNDataSyncTokenScopes.cs + diff --git a/src/Api/PubnubApiUWP/PubnubApiUWP.csproj b/src/Api/PubnubApiUWP/PubnubApiUWP.csproj index 71a0f89ca..8327ba53f 100644 --- a/src/Api/PubnubApiUWP/PubnubApiUWP.csproj +++ b/src/Api/PubnubApiUWP/PubnubApiUWP.csproj @@ -16,7 +16,7 @@ PubnubUWP - 9.0.0 + 9.1.0 PubNub C# .NET SDK Pandu Masabathula PubNub @@ -24,7 +24,8 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Added Data Sync feature support. + Categories permission support for Grant and GrantToken apis. +Ensure unsubscribeAll clears subscription state when event engine is enabled. https://www.pubnub.com/docs/sdks/c-sharp Pubnub Realtime Real-time Publish-subscribe Pubsub Messaging Occupancy Presence Message-ordering Message-history Chat Long-poll PubNub SDK for C# .NET. Publish/subscribe messaging over HTTP long-poll with server-assigned message ordering, channel occupancy via Here Now, and message history through Message Persistence. @@ -517,6 +518,7 @@ + Enum\PNHeartbeatNotificationOption.cs @@ -715,6 +717,7 @@ Model\Consumer\AccessManager\PNDataSyncTokenScopes.cs + diff --git a/src/Api/PubnubApiUnity/PubnubApiUnity.csproj b/src/Api/PubnubApiUnity/PubnubApiUnity.csproj index 98d0c1566..11f8fe359 100644 --- a/src/Api/PubnubApiUnity/PubnubApiUnity.csproj +++ b/src/Api/PubnubApiUnity/PubnubApiUnity.csproj @@ -15,7 +15,7 @@ PubnubApiUnity - 9.0.0 + 9.1.0 PubNub C# .NET SDK Pandu Masabathula PubNub @@ -562,6 +562,7 @@ + Enum\PNHeartbeatNotificationOption.cs @@ -659,6 +660,7 @@ + diff --git a/src/PubnubApi.Snippets/AccessManager/AccessManagerSample.cs b/src/PubnubApi.Snippets/AccessManager/AccessManagerSample.cs index da4da15fa..3096e5e32 100644 --- a/src/PubnubApi.Snippets/AccessManager/AccessManagerSample.cs +++ b/src/PubnubApi.Snippets/AccessManager/AccessManagerSample.cs @@ -244,6 +244,33 @@ static async Task GrantTokenDataSyncProjection() // snippet.end } + static async Task GrantTokenCategories() + { + // snippet.grant_token_categories + PNResult grantTokenResponse = await pubnub.GrantToken() + .TTL(15) + .AuthorizedUuid("my-authorized-uuid") + .Categories(new PNTokenCategories() + { + // allow listing all channel metadata + Channels = new PNTokenCategoryPermissions() { Get = true }, + // allow listing all uuid metadata + Uuids = new PNTokenCategoryPermissions() { Get = true } + }) + .ExecuteAsync(); + PNAccessManagerTokenResult grantTokenResult = grantTokenResponse.Result; + PNStatus grantTokenStatus = grantTokenResponse.Status; + if (!grantTokenStatus.Error && grantTokenResult != null) + { + Console.WriteLine(pubnub.JsonPluggableLibrary.SerializeToJsonString(grantTokenResult)); + } + else + { + Console.WriteLine(pubnub.JsonPluggableLibrary.SerializeToJsonString(grantTokenStatus)); + } + // snippet.end + } + static async Task GrantTokenOldBasicUsage() { // snippet.basic_usage_old diff --git a/src/PubnubApi.Snippets/AccessManager/AccessManagerV2Sample.cs b/src/PubnubApi.Snippets/AccessManager/AccessManagerV2Sample.cs index 318579860..85f38e86f 100644 --- a/src/PubnubApi.Snippets/AccessManager/AccessManagerV2Sample.cs +++ b/src/PubnubApi.Snippets/AccessManager/AccessManagerV2Sample.cs @@ -235,4 +235,38 @@ static async Task GrantUserLevel() //PNAccessManagerGrantResult is a parsed and abstracted response from server // snippet.end } + + static async Task GrantCategories() + { + // snippet.grant_categories + PNResult grantResponse = await pubnub.Grant() + .Categories(new PNGrantCategory[] { + // allow listing all channel and uuid metadata + PNGrantCategory.Channels, + PNGrantCategory.Uuids + }) + .Get(true) // the only permission allowed for categories + .AuthKeys(new string[] { + "my_authkey" // required for categories + }) + .TTL(1440) + .ExecuteAsync(); + + PNAccessManagerGrantResult grantResult = grantResponse.Result; + PNStatus status = grantResponse.Status; + //PNAccessManagerGrantResult.Categories holds the granted categories per auth key + + //Revoke by granting the same categories again with Get(false) + PNResult revokeResponse = await pubnub.Grant() + .Categories(new PNGrantCategory[] { + PNGrantCategory.Channels, + PNGrantCategory.Uuids + }) + .Get(false) + .AuthKeys(new string[] { + "my_authkey" + }) + .ExecuteAsync(); + // snippet.end + } } \ No newline at end of file diff --git a/src/PubnubApi.Snippets/DataSync/DataSyncSample.cs b/src/PubnubApi.Snippets/DataSync/DataSyncSample.cs index 33412eb91..8dd948be6 100644 --- a/src/PubnubApi.Snippets/DataSync/DataSyncSample.cs +++ b/src/PubnubApi.Snippets/DataSync/DataSyncSample.cs @@ -1,6 +1,5 @@ // snippet.using using PubnubApi; -using PubnubApi.EndPoint; // snippet.end diff --git a/src/UnitTests/PubnubApi.Tests/EncryptionTests.cs b/src/UnitTests/PubnubApi.Tests/EncryptionTests.cs index 7318b162e..062f962cf 100644 --- a/src/UnitTests/PubnubApi.Tests/EncryptionTests.cs +++ b/src/UnitTests/PubnubApi.Tests/EncryptionTests.cs @@ -167,7 +167,7 @@ public static void Exit() public void ParseGrantTokenTest() { string expected = - "{\"Version\":2,\"Timestamp\":1568739458,\"TTL\":100,\"Resources\":{\"Channels\":{},\"ChannelGroups\":{},\"Uuids\":{},\"Users\":{},\"Spaces\":{},\"DataSync\":{\"Entities\":{},\"Relationships\":{},\"Memberships\":{}}},\"Patterns\":{\"Channels\":{},\"ChannelGroups\":{},\"Uuids\":{},\"Users\":{\"^emp-*\":{\"Read\":true,\"Write\":true,\"Manage\":false,\"Delete\":false,\"Create\":false,\"Get\":false,\"Update\":false,\"Join\":false},\"^mgr-*\":{\"Read\":true,\"Write\":true,\"Manage\":false,\"Delete\":true,\"Create\":true,\"Get\":false,\"Update\":false,\"Join\":false}},\"Spaces\":{\"^public-*\":{\"Read\":true,\"Write\":true,\"Manage\":false,\"Delete\":false,\"Create\":false,\"Get\":false,\"Update\":false,\"Join\":false},\"^private-*\":{\"Read\":true,\"Write\":true,\"Manage\":false,\"Delete\":true,\"Create\":true,\"Get\":false,\"Update\":false,\"Join\":false}},\"DataSync\":{\"Entities\":{},\"Relationships\":{},\"Memberships\":{}}},\"Meta\":{},\"Projections\":null,\"AuthorizedUuid\":null,\"Signature\":\"LL8xpndq3ILa/a3LOK9ragvO2EqaUmKrPQin2jOSEWQ=\"}"; + "{\"Version\":2,\"Timestamp\":1568739458,\"TTL\":100,\"Resources\":{\"Channels\":{},\"ChannelGroups\":{},\"Uuids\":{},\"Users\":{},\"Spaces\":{},\"DataSync\":{\"Entities\":{},\"Relationships\":{},\"Memberships\":{}}},\"Patterns\":{\"Channels\":{},\"ChannelGroups\":{},\"Uuids\":{},\"Users\":{\"^emp-*\":{\"Read\":true,\"Write\":true,\"Manage\":false,\"Delete\":false,\"Create\":false,\"Get\":false,\"Update\":false,\"Join\":false},\"^mgr-*\":{\"Read\":true,\"Write\":true,\"Manage\":false,\"Delete\":true,\"Create\":true,\"Get\":false,\"Update\":false,\"Join\":false}},\"Spaces\":{\"^public-*\":{\"Read\":true,\"Write\":true,\"Manage\":false,\"Delete\":false,\"Create\":false,\"Get\":false,\"Update\":false,\"Join\":false},\"^private-*\":{\"Read\":true,\"Write\":true,\"Manage\":false,\"Delete\":true,\"Create\":true,\"Get\":false,\"Update\":false,\"Join\":false}},\"DataSync\":{\"Entities\":{},\"Relationships\":{},\"Memberships\":{}}},\"Categories\":{\"Channels\":{\"Get\":false},\"Uuids\":{\"Get\":false}},\"Meta\":{},\"Projections\":null,\"AuthorizedUuid\":null,\"Signature\":\"LL8xpndq3ILa/a3LOK9ragvO2EqaUmKrPQin2jOSEWQ=\"}"; string actual = ""; string token = "p0F2AkF0Gl2BEIJDdHRsGGRDcmVzpERjaGFuoENncnCgQ3VzcqBDc3BjoENwYXSkRGNoYW6gQ2dycKBDdXNyomZeZW1wLSoDZl5tZ3ItKhgbQ3NwY6JpXnB1YmxpYy0qA2pecHJpdmF0ZS0qGBtEbWV0YaBDc2lnWCAsvzGmd2rcgtr9rcs4r2tqC87YSppSYqs9CKfaM5IRZA=="; diff --git a/src/UnitTests/PubnubApi.Tests/PubnubCommon.cs b/src/UnitTests/PubnubApi.Tests/PubnubCommon.cs index c49d4c5ab..ae4b15527 100644 --- a/src/UnitTests/PubnubApi.Tests/PubnubCommon.cs +++ b/src/UnitTests/PubnubApi.Tests/PubnubCommon.cs @@ -31,6 +31,10 @@ public static class PubnubCommon public static readonly string DataSyncPublishKey = Environment.GetEnvironmentVariable("PN_DS_PUB_KEY"); public static readonly string DataSyncSecretKey = Environment.GetEnvironmentVariable("PN_DS_SEC_KEY"); + public static readonly string CategorySubscribeKey = Environment.GetEnvironmentVariable("PN_CAT_SUB_KEY"); + public static readonly string CategoryPublishKey = Environment.GetEnvironmentVariable("PN_CAT_PUB_KEY"); + public static readonly string CategorySecretKey = Environment.GetEnvironmentVariable("PN_CAT_SEC_KEY"); + static PubnubCommon() { } diff --git a/src/UnitTests/PubnubApi.Tests/WhenCategoryGrantIsEnforced.cs b/src/UnitTests/PubnubApi.Tests/WhenCategoryGrantIsEnforced.cs new file mode 100644 index 000000000..6ba179359 --- /dev/null +++ b/src/UnitTests/PubnubApi.Tests/WhenCategoryGrantIsEnforced.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using NUnit.Framework; +using PubnubApi; + +namespace PubNubMessaging.Tests +{ + /// + /// Runs against a keyset with Get All enforcement enabled, where listing all App Context + /// metadata requires a category grant. + /// + [TestFixture] + public class WhenCategoryGrantIsEnforced : TestHarness + { + private const int PropagationTimeoutMs = 10000; + private const int PollIntervalMs = 500; + + private Pubnub admin; + private readonly List clients = new List(); + + [SetUp] + public void Init() + { + if (string.IsNullOrEmpty(PubnubCommon.CategoryPublishKey) + || string.IsNullOrEmpty(PubnubCommon.CategorySubscribeKey) + || string.IsNullOrEmpty(PubnubCommon.CategorySecretKey)) + { + Assert.Ignore("PN_CAT_PUB_KEY, PN_CAT_SUB_KEY and PN_CAT_SEC_KEY must be set to run category grant enforcement tests."); + } + + admin = createPubNubInstance(new PNConfiguration(new UserId(UniqueId("category-admin"))) + { + PublishKey = PubnubCommon.CategoryPublishKey, + SubscribeKey = PubnubCommon.CategorySubscribeKey, + SecretKey = PubnubCommon.CategorySecretKey, + }); + } + + [TearDown] + public void Cleanup() + { + foreach (Pubnub client in clients) + { + try { client.Destroy(); } catch { /* ignore */ } + } + clients.Clear(); + + if (admin != null) + { + admin.Destroy(); + admin = null; + } + } + + private static string UniqueId(string prefix) => $"{prefix}-{Guid.NewGuid():N}"; + + // The client must not have the secret key, because the secret key bypasses Access Manager. + private Pubnub CreateClient(string userId, Action configure = null) + { + PNConfiguration config = new PNConfiguration(new UserId(userId)) + { + PublishKey = PubnubCommon.CategoryPublishKey, + SubscribeKey = PubnubCommon.CategorySubscribeKey, + }; + configure?.Invoke(config); + Pubnub client = createPubNubInstance(config); + clients.Add(client); + return client; + } + + private async Task GrantTokenAsync(string clientUserId, PNTokenResources resources, PNTokenCategories categories) + { + PNResult grant = await admin.GrantToken() + .TTL(10) + .AuthorizedUuid(clientUserId) + .Resources(resources) + .Categories(categories) + .ExecuteAsync(); + Assert.That(grant.Status.Error, Is.False, grant.Status.ErrorData?.Information); + Assert.That(grant.Result?.Token, Is.Not.Null.And.Not.Empty, "GrantToken returned no token"); + return grant.Result.Token; + } + + private async Task> GrantCategoriesAsync(string authKey, bool get) + { + PNResult grant = await admin.Grant() + .AuthKeys(new[] { authKey }) + .Categories(new[] { PNGrantCategory.Channels, PNGrantCategory.Uuids }) + .Get(get) + .TTL(10) + .ExecuteAsync(); + Assert.That(grant.Status.Error, Is.False, grant.Status.ErrorData?.Information); + Assert.That(grant.Result, Is.Not.Null, "Grant returned no result"); + return grant; + } + + private static Task AssertGetAllChannelMetadata(Pubnub client, bool expectAllowed) => + AssertGetAllEventually(async () => (await client.GetAllChannelMetadata().Limit(1).ExecuteAsync()).Status, + expectAllowed, "GetAllChannelMetadata"); + + private static Task AssertGetAllUuidMetadata(Pubnub client, bool expectAllowed) => + AssertGetAllEventually(async () => (await client.GetAllUuidMetadata().Limit(1).ExecuteAsync()).Status, + expectAllowed, "GetAllUuidMetadata"); + + // Grants and revokes take effect asynchronously, so retry until the expected outcome or the timeout. + private static async Task AssertGetAllEventually(Func> getAll, bool expectAllowed, string operation) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + PNStatus status = await getAll(); + while (!HasExpectedOutcome(status, expectAllowed) && stopwatch.ElapsedMilliseconds < PropagationTimeoutMs) + { + await Task.Delay(PollIntervalMs); + status = await getAll(); + } + AssertGetAllStatus(status, expectAllowed, operation); + } + + private static bool HasExpectedOutcome(PNStatus status, bool expectAllowed) => + status != null && (expectAllowed + ? !status.Error && status.StatusCode == 200 + : status.Error && status.StatusCode == 403); + + private static void AssertGetAllStatus(PNStatus status, bool expectAllowed, string operation) + { + Assert.That(status, Is.Not.Null, $"{operation} returned no status"); + if (expectAllowed) + { + Assert.That(status.Error, Is.False, $"{operation} should be allowed: {status.ErrorData?.Information}"); + Assert.That(status.StatusCode, Is.EqualTo(200), $"{operation} status code"); + } + else + { + Assert.That(status.Error, Is.True, $"{operation} should be denied"); + Assert.That(status.StatusCode, Is.EqualTo(403), $"{operation} status code"); + } + } + + [Test] + public async Task ThenTokenWithOnlyResourceLevelGetShouldNotAllowGetAll() + { + string clientUserId = UniqueId("category-client"); + string token = await GrantTokenAsync(clientUserId, new PNTokenResources + { + Channels = new Dictionary + { + { UniqueId("category-channel"), new PNTokenAuthValues { Get = true } } + }, + Uuids = new Dictionary + { + { clientUserId, new PNTokenAuthValues { Get = true } } + } + }, null); + Pubnub client = CreateClient(clientUserId); + client.SetAuthToken(token); + + await AssertGetAllChannelMetadata(client, false); + await AssertGetAllUuidMetadata(client, false); + } + + [Test] + public async Task ThenTokenWithBothCategoriesShouldAllowGetAll() + { + string clientUserId = UniqueId("category-client"); + string token = await GrantTokenAsync(clientUserId, null, new PNTokenCategories + { + Channels = new PNTokenCategoryPermissions { Get = true }, + Uuids = new PNTokenCategoryPermissions { Get = true } + }); + + PNTokenContent content = admin.ParseToken(token); + Assert.That(content.Categories.Channels.Get, Is.True); + Assert.That(content.Categories.Uuids.Get, Is.True); + + Pubnub client = CreateClient(clientUserId); + client.SetAuthToken(token); + + await AssertGetAllChannelMetadata(client, true); + await AssertGetAllUuidMetadata(client, true); + } + + [Test] + public async Task ThenTokenWithChannelsCategoryShouldOnlyAllowGetAllChannelMetadata() + { + string clientUserId = UniqueId("category-client"); + string token = await GrantTokenAsync(clientUserId, null, new PNTokenCategories + { + Channels = new PNTokenCategoryPermissions { Get = true } + }); + + PNTokenContent content = admin.ParseToken(token); + Assert.That(content.Categories.Channels.Get, Is.True); + Assert.That(content.Categories.Uuids.Get, Is.False); + + Pubnub client = CreateClient(clientUserId); + client.SetAuthToken(token); + + await AssertGetAllChannelMetadata(client, true); + await AssertGetAllUuidMetadata(client, false); + } + + [Test] + public async Task ThenGrantWithCategoriesShouldAllowGetAllForAuthKey() + { + string authKey = UniqueId("category-auth"); + + PNResult grant = await GrantCategoriesAsync(authKey, true); + + Assert.That(grant.Result.Level, Is.EqualTo("category")); + Assert.That(grant.Result.Categories?.Keys, Is.EquivalentTo(new[] { "channels", "uuids" })); + Assert.That(grant.Result.Categories["channels"][authKey].GetEnabled, Is.True); + Assert.That(grant.Result.Categories["uuids"][authKey].GetEnabled, Is.True); + + Pubnub client = CreateClient(UniqueId("category-client"), config => config.AuthKey = authKey); + + await AssertGetAllChannelMetadata(client, true); + await AssertGetAllUuidMetadata(client, true); + } + + [Test] + public async Task ThenRevokingCategoryGrantShouldDenyGetAll() + { + string authKey = UniqueId("category-auth"); + await GrantCategoriesAsync(authKey, true); + Pubnub client = CreateClient(UniqueId("category-client"), config => config.AuthKey = authKey); + await AssertGetAllChannelMetadata(client, true); + await AssertGetAllUuidMetadata(client, true); + + PNResult revoke = await GrantCategoriesAsync(authKey, false); + + Assert.That(revoke.Result.Categories?.Keys, Is.EquivalentTo(new[] { "channels", "uuids" })); + Assert.That(revoke.Result.Categories["channels"][authKey].GetEnabled, Is.False); + Assert.That(revoke.Result.Categories["uuids"][authKey].GetEnabled, Is.False); + + await AssertGetAllChannelMetadata(client, false); + await AssertGetAllUuidMetadata(client, false); + } + + [Test] + public async Task ThenOneMinuteCategoryGrantShouldBeRejectedByServer() + { + PNResult grant = await admin.Grant() + .AuthKeys(new[] { UniqueId("category-auth") }) + .Categories(new[] { PNGrantCategory.Channels }) + .Get(true) + .TTL(1) + .ExecuteAsync(); + + Assert.That(grant.Status.Error, Is.True, "One-minute category grants should be rejected"); + Assert.That(grant.Status.StatusCode, Is.EqualTo(400)); + } + } +} diff --git a/src/UnitTests/PubnubApi.Tests/WhenCategoryGrantIsRequested.cs b/src/UnitTests/PubnubApi.Tests/WhenCategoryGrantIsRequested.cs new file mode 100644 index 000000000..cf8de9ddb --- /dev/null +++ b/src/UnitTests/PubnubApi.Tests/WhenCategoryGrantIsRequested.cs @@ -0,0 +1,370 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using PubnubApi; +using PubnubApi.EndPoint; + +namespace PubNubMessaging.Tests +{ + [TestFixture] + public class WhenCategoryGrantIsRequested : TestHarness + { + private const string AuthKey = "category-auth-key"; + private const string TokenWithoutCategories = + "p0F2AkF0Gl2BEIJDdHRsGGRDcmVzpERjaGFuoENncnCgQ3VzcqBDc3BjoENwYXSkRGNoYW6gQ2dycKBDdXNyomZeZW1wLSoDZl5tZ3ItKhgbQ3NwY6JpXnB1YmxpYy0qA2pecHJpdmF0ZS0qGBtEbWV0YaBDc2lnWCAsvzGmd2rcgtr9rcs4r2tqC87YSppSYqs9CKfaM5IRZA=="; + + private Pubnub pubnub; + + [SetUp] + public void Init() + { + PNConfiguration config = new PNConfiguration(new UserId("category-grant-uuid")) + { + PublishKey = PubnubCommon.PublishKey, + SubscribeKey = PubnubCommon.SubscribeKey, + SecretKey = PubnubCommon.SecretKey, + }; + pubnub = createPubNubInstance(config); + } + + [TearDown] + public void Cleanup() + { + if (pubnub != null) + { + pubnub.Destroy(); + pubnub.PubnubUnitTest = null; + pubnub = null; + } + } + + private static RequestParameter CreateRequestParameter(object operation) + { + MethodInfo method = operation.GetType().GetMethod( + "CreateRequestParameter", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.That(method, Is.Not.Null, "CreateRequestParameter not found via reflection"); + return (RequestParameter)method.Invoke(operation, null); + } + + private static string CreateToken(Dictionary tokenContent) + { + Type cbor = typeof(Pubnub).Assembly.GetType("PubnubApi.CBOR"); + Assert.That(cbor, Is.Not.Null, "CBOR not found via reflection"); + MethodInfo encode = cbor.GetMethod("Encode", BindingFlags.Public | BindingFlags.Static); + object byteStringType = cbor.GetField("TYPE_BYTE_STRING").GetValue(null); + string hex = (string)encode.Invoke(null, new object[] { tokenContent, byteStringType }); + return Convert.ToBase64String(Convert.FromHexString(hex)); + } + + private PNAccessManagerGrantResult ParseGrantResponse(string response) + { + List responseList = new List + { + pubnub.JsonPluggableLibrary.DeserializeToDictionaryOfObject(response) + }; + return pubnub.JsonPluggableLibrary.DeserializeToObject(responseList); + } + + private static GrantOperation SetPermission(GrantOperation operation, string permission) + { + switch (permission) + { + case "Read": + return operation.Read(true); + case "Write": + return operation.Write(true); + case "Manage": + return operation.Manage(true); + case "Delete": + return operation.Delete(true); + case "Update": + return operation.Update(true); + case "Join": + return operation.Join(true); + default: + throw new ArgumentException($"Unknown permission {permission}"); + } + } + + [Test] + public void ThenCategoriesShouldBeSentAsCategoryQueryParameter() + { + GrantOperation operation = pubnub.Grant() + .AuthKeys(new[] { AuthKey }) + .Categories(new[] { PNGrantCategory.Channels, PNGrantCategory.Uuids }) + .Get(true) + .TTL(10); + + Dictionary query = CreateRequestParameter(operation).Query; + + Assert.That(query["auth"], Is.EqualTo(AuthKey)); + Assert.That(query["category"], Is.EqualTo("channels%2Cuuids")); + Assert.That(query["ttl"], Is.EqualTo("10")); + Assert.That(query["g"], Is.EqualTo("1")); + foreach (string permission in new[] { "r", "w", "m", "d", "u", "j" }) + { + Assert.That(query[permission], Is.EqualTo("0"), $"'{permission}' should not be granted with categories"); + } + } + + [Test] + public void ThenCategoryRevokeShouldSendGetPermissionAsZero() + { + GrantOperation operation = pubnub.Grant() + .AuthKeys(new[] { AuthKey }) + .Categories(new[] { PNGrantCategory.Channels }) + .Get(false); + + Dictionary query = CreateRequestParameter(operation).Query; + + Assert.That(query["category"], Is.EqualTo("channels")); + Assert.That(query["g"], Is.EqualTo("0")); + } + + [Test] + public void ThenCategoriesCombinedWithChannelShouldSendBoth() + { + GrantOperation operation = pubnub.Grant() + .AuthKeys(new[] { AuthKey }) + .Channels(new[] { "ch-a" }) + .Categories(new[] { PNGrantCategory.Uuids }) + .Get(true); + + Dictionary query = CreateRequestParameter(operation).Query; + + Assert.That(query["channel"], Is.EqualTo("ch-a")); + Assert.That(query["category"], Is.EqualTo("uuids")); + Assert.That(query["g"], Is.EqualTo("1")); + } + + [Test] + public void ThenDuplicateCategoriesShouldBeSentOnceInSortedOrder() + { + GrantOperation operation = pubnub.Grant() + .AuthKeys(new[] { AuthKey }) + .Categories(new[] { PNGrantCategory.Uuids, PNGrantCategory.Channels, PNGrantCategory.Uuids }) + .Get(true); + + Dictionary query = CreateRequestParameter(operation).Query; + + Assert.That(query["category"], Is.EqualTo("channels%2Cuuids")); + } + + [Test] + public void ThenGrantWithoutCategoriesShouldNotSendCategoryQueryParameter() + { + GrantOperation operation = pubnub.Grant() + .Channels(new[] { "ch-a" }) + .Read(true); + + Dictionary query = CreateRequestParameter(operation).Query; + + Assert.That(query.ContainsKey("category"), Is.False); + Assert.That(query["r"], Is.EqualTo("1")); + } + + [Test] + public void ThenCategoryGrantWithoutAuthKeyShouldThrow() + { + GrantOperation operation = pubnub.Grant() + .Categories(new[] { PNGrantCategory.Channels }) + .Get(true); + + Assert.Throws(() => operation.Execute(new PNAccessManagerGrantResultExt((r, s) => { }))); + } + + [Test] + public void ThenCategoryGrantWithOnlyEmptyAuthKeysShouldThrow() + { + GrantOperation operation = pubnub.Grant() + .AuthKeys(new[] { "", " " }) + .Categories(new[] { PNGrantCategory.Channels }) + .Get(true); + + Assert.ThrowsAsync(async () => await operation.ExecuteAsync()); + } + + [TestCase("Read")] + [TestCase("Write")] + [TestCase("Manage")] + [TestCase("Delete")] + [TestCase("Update")] + [TestCase("Join")] + public void ThenCategoryGrantWithNonGetPermissionShouldThrow(string permission) + { + GrantOperation operation = SetPermission(pubnub.Grant() + .AuthKeys(new[] { AuthKey }) + .Categories(new[] { PNGrantCategory.Channels }) + .Get(true), permission); + + Assert.Throws(() => operation.Execute(new PNAccessManagerGrantResultExt((r, s) => { }))); + Assert.ThrowsAsync(async () => await operation.ExecuteAsync()); + } + + [Test] + public void ThenUndefinedCategoryShouldThrow() + { + GrantOperation operation = pubnub.Grant() + .AuthKeys(new[] { AuthKey }) + .Categories(new[] { (PNGrantCategory)99 }) + .Get(true); + + Assert.Throws(() => operation.Execute(new PNAccessManagerGrantResultExt((r, s) => { }))); + } + + [Test] + public void ThenCategoryLevelGrantResponseShouldPopulateCategories() + { + string response = "{\"message\":\"Success\",\"payload\":{\"level\":\"category\",\"subscribe_key\":\"sub-key\",\"ttl\":1440,\"categories\":{\"channels\":{\"auths\":{\"auth_key\":{\"r\":0,\"w\":0,\"m\":0,\"d\":0,\"g\":1,\"u\":0,\"j\":0}}},\"uuids\":{\"auths\":{\"auth_key\":{\"r\":0,\"w\":0,\"m\":0,\"d\":0,\"g\":1,\"u\":0,\"j\":0}}}}},\"service\":\"Access Manager\",\"status\":200}"; + + PNAccessManagerGrantResult result = ParseGrantResponse(response); + + Assert.That(result, Is.Not.Null); + Assert.That(result.Level, Is.EqualTo("category")); + Assert.That(result.Ttl, Is.EqualTo(1440)); + Assert.That(result.Categories, Is.Not.Null); + Assert.That(result.Categories.Keys, Is.EquivalentTo(new[] { "channels", "uuids" })); + foreach (string category in new[] { "channels", "uuids" }) + { + PNAccessManagerKeyData keyData = result.Categories[category]["auth_key"]; + Assert.That(keyData.GetEnabled, Is.True, $"{category}: Get should be granted"); + Assert.That(keyData.ReadEnabled || keyData.WriteEnabled || keyData.ManageEnabled || keyData.DeleteEnabled + || keyData.UpdateEnabled || keyData.JoinEnabled, Is.False, $"{category}: only Get should be granted"); + } + } + + [Test] + public void ThenGrantResponseWithoutCategoriesShouldLeaveCategoriesNull() + { + string response = "{\"message\":\"Success\",\"payload\":{\"level\":\"user\",\"subscribe_key\":\"sub-key\",\"ttl\":5,\"channel\":\"ch-a\",\"auths\":{\"auth_key\":{\"r\":1,\"w\":1,\"m\":0}}},\"service\":\"Access Manager\",\"status\":200}"; + + PNAccessManagerGrantResult result = ParseGrantResponse(response); + + Assert.That(result, Is.Not.Null); + Assert.That(result.Channels["ch-a"]["auth_key"].ReadEnabled, Is.True); + Assert.That(result.Categories, Is.Null); + } + + [Test] + public void ThenTokenCategoriesShouldBeSerialized() + { + GrantTokenOperation operation = pubnub.GrantToken() + .TTL(60) + .Categories(new PNTokenCategories + { + Channels = new PNTokenCategoryPermissions { Get = true }, + Uuids = new PNTokenCategoryPermissions { Get = true } + }); + + string body = CreateRequestParameter(operation).BodyContentString; + + Assert.That(body, Does.Contain("\"categories\":{\"channels\":32,\"uuids\":32}")); + } + + [Test] + public void ThenChannelsCategoryWithChannelResourceShouldBeSerialized() + { + GrantTokenOperation operation = pubnub.GrantToken() + .TTL(60) + .Resources(new PNTokenResources + { + Channels = new Dictionary + { + { "ch-a", new PNTokenAuthValues { Read = true } } + } + }) + .Categories(new PNTokenCategories + { + Channels = new PNTokenCategoryPermissions { Get = true } + }); + + string body = CreateRequestParameter(operation).BodyContentString; + + Assert.That(body, Does.Contain("\"channels\":{\"ch-a\":1}")); + Assert.That(body, Does.Contain("\"categories\":{\"channels\":32}")); + } + + [Test] + public void ThenTokenCategoriesWithoutGetShouldBeOmitted() + { + PNTokenResources resources = new PNTokenResources + { + Channels = new Dictionary + { + { "ch-a", new PNTokenAuthValues { Read = true } } + } + }; + PNTokenCategories[] categoriesWithoutGet = + { + null, + new PNTokenCategories(), + new PNTokenCategories { Channels = null, Uuids = null }, + new PNTokenCategories { Channels = new PNTokenCategoryPermissions { Get = false } } + }; + + Assert.That(CreateRequestParameter(pubnub.GrantToken().TTL(60).Resources(resources)).BodyContentString, + Does.Not.Contain("categories")); + foreach (PNTokenCategories categories in categoriesWithoutGet) + { + GrantTokenOperation operation = pubnub.GrantToken() + .TTL(60) + .Resources(resources) + .Categories(categories); + + Assert.That(CreateRequestParameter(operation).BodyContentString, Does.Not.Contain("categories")); + } + } + + [Test] + public void ThenParseTokenShouldDecodeCategories() + { + string token = CreateToken(new Dictionary + { + { "v", 2 }, + { "t", 1234567890 }, + { "ttl", 60 }, + { "cat", new Dictionary { { "chan", 32 }, { "uuid", 32 } } } + }); + + PNTokenContent content = pubnub.ParseToken(token); + + Assert.That(content, Is.Not.Null); + Assert.That(content.TTL, Is.EqualTo(60)); + Assert.That(content.Categories.Channels.Get, Is.True); + Assert.That(content.Categories.Uuids.Get, Is.True); + } + + [Test] + public void ThenParseTokenWithChannelsCategoryOnlyShouldNotGrantUuidsCategory() + { + string token = CreateToken(new Dictionary + { + { "v", 2 }, + { "t", 1234567890 }, + { "ttl", 60 }, + { "res", new Dictionary { { "chan", new Dictionary { { "ch-a", 1 } } } } }, + { "cat", new Dictionary { { "chan", 32 } } } + }); + + PNTokenContent content = pubnub.ParseToken(token); + + Assert.That(content, Is.Not.Null); + Assert.That(content.Categories.Channels.Get, Is.True); + Assert.That(content.Categories.Uuids.Get, Is.False); + Assert.That(content.Resources.Channels["ch-a"].Read, Is.True); + Assert.That(content.Resources.Channels["ch-a"].Get, Is.False); + } + + [Test] + public void ThenParseTokenWithoutCategoriesShouldReturnEmptyCategories() + { + PNTokenContent content = pubnub.ParseToken(TokenWithoutCategories); + + Assert.That(content, Is.Not.Null); + Assert.That(content.Categories, Is.Not.Null); + Assert.That(content.Categories.Channels.Get, Is.False); + Assert.That(content.Categories.Uuids.Get, Is.False); + } + } +} diff --git a/src/UnitTests/PubnubApi.Tests/WhenUnsubscribeAllIsCalled.cs b/src/UnitTests/PubnubApi.Tests/WhenUnsubscribeAllIsCalled.cs new file mode 100644 index 000000000..d8d462707 --- /dev/null +++ b/src/UnitTests/PubnubApi.Tests/WhenUnsubscribeAllIsCalled.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using NUnit.Framework; +using PubnubApi; +using PubnubApi.EventEngine.Subscribe.Common; + +namespace PubNubMessaging.Tests +{ + /// + /// Event Engine regression tests for UnsubscribeAll() followed by Subscribe(), for both the + /// Subscribe<T>().Channels() builder and the entity API. + /// Tests use NonPAMPublishKey and NONPAMSubscribeKey against production PubNub servers. + /// + [TestFixture] + public class WhenUnsubscribeAllIsCalled : TestHarness + { + private const int TimeoutMs = 10000; + private const int NoDeliveryWaitMs = 5000; + + private Pubnub pubnub; + private Recorder recorder; + + [SetUp] + public void Setup() + { + if (string.IsNullOrEmpty(PubnubCommon.NonPAMPublishKey) || string.IsNullOrEmpty(PubnubCommon.NONPAMSubscribeKey)) + { + Assert.Ignore("PN_PUB_KEY / PN_SUB_KEY are not set."); + } + recorder = new Recorder(); + var config = new PNConfiguration(new UserId($"unsub_all_{Guid.NewGuid().ToString().Substring(0, 8)}")) + { + PublishKey = PubnubCommon.NonPAMPublishKey, + SubscribeKey = PubnubCommon.NONPAMSubscribeKey, + Secure = true, + EnableEventEngine = true, + LogLevel = PubnubLogLevel.Debug + }; + pubnub = createPubNubInstance(config); + pubnub.SetLogger(recorder); + pubnub.AddListener(new SubscribeCallbackExt( + (_, message) => recorder.Messages.Enqueue(message.Message?.ToString()), + (_, presence) => { }, + (_, status) => recorder.Statuses.Enqueue(status.Category))); + } + + [TearDown] + public void TearDown() + { + if (pubnub != null) + { + try + { + pubnub.UnsubscribeAll(); + pubnub.Destroy(); + } + catch + { + // Ignore cleanup errors + } + pubnub.PubnubUnitTest = null; + pubnub = null; + } + } + + [Test] + public async Task ThenSubscribeBuilderShouldReconnectAndReceiveMessages() + { + string channel = RandomChannel("unsub_all_builder"); + + await SubscribeAndWaitForConnected(() => pubnub.Subscribe().Channels(new[] { channel }).Execute()); + await UnsubscribeAllAndWaitForDisconnected(); + + await SubscribeAndWaitForConnected(() => pubnub.Subscribe().Channels(new[] { channel }).Execute()); + Assert.IsTrue(await PublishAndWaitForMessage(channel, "builder-after"), "Message was not delivered after UnsubscribeAll + Subscribe"); + } + + [Test] + public async Task ThenEntitySubscriptionShouldReconnectAndReceiveMessagesAcrossCycles() + { + string channel = RandomChannel("unsub_all_entity"); + var subscription = pubnub.Channel(channel).Subscription(); + + await SubscribeAndWaitForConnected(() => subscription.Subscribe()); + for (int cycle = 1; cycle <= 2; cycle++) + { + await UnsubscribeAllAndWaitForDisconnected(); + await SubscribeAndWaitForConnected(() => subscription.Subscribe()); + Assert.IsTrue(await PublishAndWaitForMessage(channel, $"entity-after-{cycle}"), $"Message was not delivered after cycle {cycle}"); + } + } + + [Test] + public async Task ThenSubscribedChannelsShouldBeEmpty() + { + string channel = RandomChannel("unsub_all_channels"); + + await SubscribeAndWaitForConnected(() => pubnub.Subscribe().Channels(new[] { channel }).Execute()); + await UnsubscribeAllAndWaitForDisconnected(); + + Assert.IsEmpty(pubnub.GetSubscribedChannels(), "GetSubscribedChannels() should be empty after UnsubscribeAll"); + Assert.IsEmpty(pubnub.GetSubscribedChannelGroups(), "GetSubscribedChannelGroups() should be empty after UnsubscribeAll"); + } + + [Test] + public async Task ThenUnsubscribeAfterCursorResubscribeShouldStopDelivery() + { + string channel = RandomChannel("unsub_all_cursor"); + var subscription = pubnub.Channel(channel).Subscription(); + + await SubscribeAndWaitForConnected(() => subscription.Subscribe()); + long timetoken = await Publish(channel, "cursor-before"); + Assert.IsTrue(await WaitUntil(() => recorder.Messages.Any(m => m.Contains("cursor-before"))), "Initial message was not delivered"); + + await UnsubscribeAllAndWaitForDisconnected(); + await SubscribeAndWaitForConnected(() => subscription.Subscribe(new SubscriptionCursor(timetoken))); + + int statusIndex = recorder.Statuses.Count; + subscription.Unsubscribe(); + Assert.IsTrue(await WaitUntil(() => recorder.Statuses.Skip(statusIndex).Contains(PNStatusCategory.PNDisconnectedCategory)), + "Unsubscribe after a cursor resubscribe did not disconnect"); + + await Publish(channel, "cursor-after-unsubscribe"); + Assert.IsFalse(await WaitUntil(() => recorder.Messages.Any(m => m.Contains("cursor-after-unsubscribe")), NoDeliveryWaitMs), + "Message was delivered after Unsubscribe"); + } + + [Test] + public async Task ThenLeaveShouldBeSentForSubscribedChannels() + { + string channel = RandomChannel("unsub_all_leave"); + + await SubscribeAndWaitForConnected(() => pubnub.Subscribe().Channels(new[] { channel }).Execute()); + await UnsubscribeAllAndWaitForDisconnected(); + + Assert.IsTrue(await WaitUntil(() => recorder.LeaveUrls.Any(url => url.Contains($"/channel/{channel}/leave"))), + "UnsubscribeAll did not send a leave request for the subscribed channel"); + Assert.IsFalse(recorder.LeaveUrls.Any(url => url.Contains("/channel/,/leave")), + "UnsubscribeAll sent a leave request without channels"); + } + + [Test] + public void ThenLocalPresenceStateShouldBeCleared() + { + var channelStates = SeedLocalUserState("ChannelLocalUserState", RandomChannel("unsub_all_state_ch")); + var channelGroupStates = SeedLocalUserState("ChannelGroupLocalUserState", RandomChannel("unsub_all_state_cg")); + + pubnub.UnsubscribeAll(); + + Assert.IsEmpty(channelStates, "Channel presence state should be cleared by UnsubscribeAll"); + Assert.IsEmpty(channelGroupStates, "Channel group presence state should be cleared by UnsubscribeAll"); + } + + private ConcurrentDictionary> SeedLocalUserState(string propertyName, string name) + { + var statesByInstance = (ConcurrentDictionary>>)typeof(PubnubCoreBase) + .GetProperty(propertyName, BindingFlags.NonPublic | BindingFlags.Static) + .GetValue(null); + var states = statesByInstance.GetOrAdd(pubnub.InstanceId, _ => new ConcurrentDictionary>()); + states[name] = new Dictionary { { "mood", "happy" } }; + return states; + } + + private static string RandomChannel(string prefix) => $"{prefix}_{Guid.NewGuid().ToString().Substring(0, 8)}"; + + private async Task SubscribeAndWaitForConnected(Action subscribe) + { + int statusIndex = recorder.Statuses.Count; + subscribe(); + Assert.IsTrue(await WaitUntil(() => recorder.Statuses.Skip(statusIndex).Contains(PNStatusCategory.PNConnectedCategory)), + "Subscribe did not connect"); + } + + private async Task UnsubscribeAllAndWaitForDisconnected() + { + int statusIndex = recorder.Statuses.Count; + pubnub.UnsubscribeAll(); + Assert.IsTrue(await WaitUntil(() => recorder.Statuses.Skip(statusIndex).Contains(PNStatusCategory.PNDisconnectedCategory)), + "UnsubscribeAll did not disconnect"); + } + + private async Task Publish(string channel, string text) + { + var result = await pubnub.Publish().Channel(channel).Message(text).ExecuteAsync(); + Assert.IsFalse(result.Status.Error, $"Publish of '{text}' failed"); + return result.Result.Timetoken; + } + + private async Task PublishAndWaitForMessage(string channel, string text) + { + await Publish(channel, text); + return await WaitUntil(() => recorder.Messages.Any(m => m.Contains(text))); + } + + private static async Task WaitUntil(Func condition, int timeoutMs = TimeoutMs) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + if (condition()) return true; + await Task.Delay(100); + } + return condition(); + } + + private sealed class Recorder : IPubnubLogger + { + public ConcurrentQueue Messages { get; } = new ConcurrentQueue(); + public ConcurrentQueue Statuses { get; } = new ConcurrentQueue(); + public ConcurrentQueue LeaveUrls { get; } = new ConcurrentQueue(); + + public void Trace(string logMessage) => Capture(logMessage); + public void Debug(string logMessage) => Capture(logMessage); + public void Info(string logMessage) => Capture(logMessage); + public void Warn(string logMessage) => Capture(logMessage); + public void Error(string logMessage) => Capture(logMessage); + + private void Capture(string logMessage) + { + if (logMessage != null && logMessage.Contains("Sending HTTP request") && logMessage.Contains("/leave")) + { + LeaveUrls.Enqueue(logMessage); + } + } + } + } +} diff --git a/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj b/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj index b57a33322..ad10da8c6 100644 --- a/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj +++ b/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj @@ -115,7 +115,10 @@ + + + @@ -147,6 +150,7 @@ WhenSubscriptionSplittingIsEnabled.cs +