From 996759c1f97a21804a8233cc10e57deae59d5810 Mon Sep 17 00:00:00 2001 From: Eduward Post <38214928+eduwardpost@users.noreply.github.com> Date: Sat, 27 Jan 2024 15:14:17 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20added=20more=20unit=20tests=20and?= =?UTF-8?q?=20improved=20single=20user=20mode=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added more unit test usage * Made webfinger controller work with single user mode and added unit tests * More single user mode stuff * Updated version * Made webfinger controller work with single user mode and added unit tests * More single user mode stuff * Updated version --- .../ContentPublishPostHandlerTests.cs | 169 +++++++++---- .../WebfingerControllerTests.cs | 227 ++++++++++++++++++ .../{ => HelperTests}/ActivityHelperTests.cs | 4 +- .../ServiceTests/InboxServiceTests.cs | 69 ++++++ .../{ => ServiceTests}/OutboxServiceTests.cs | 20 +- .../SignatureServiceTests.cs | 50 +--- .../UActivitySettingsServiceTests.cs | 59 +++++ .../uActivitySettingsHelper.cs | 4 +- .../uActivityPub.Tests.csproj | 1 + src/uActivityPub.sln | 7 +- .../Composers/UActivityPubComposer.cs | 2 + .../Controllers/ActivityPubController.cs | 96 +++++--- .../Controllers/WebfingerController.cs | 54 +++-- src/uActivityPub/Helpers/GravatarHelper.cs | 18 +- .../Helpers/uActivitySettingKeys.cs | 1 + src/uActivityPub/Models/Actor.cs | 55 +++++ .../Services/ContentPublishPostHandler.cs | 32 ++- src/uActivityPub/Services/IInboxService.cs | 2 +- src/uActivityPub/Services/IOutboxService.cs | 2 +- .../Services/ISignatureService.cs | 3 +- src/uActivityPub/Services/InboxService.cs | 39 ++- src/uActivityPub/Services/OutboxService.cs | 4 +- src/uActivityPub/Services/SignatureService.cs | 33 +-- src/uActivityPub/uActivityPub.cs | 3 + src/uActivityPub/uActivityPub.csproj | 4 +- 25 files changed, 732 insertions(+), 226 deletions(-) create mode 100644 src/uActivityPub.Tests/ControllerTests/WebfingerControllerTests.cs rename src/uActivityPub.Tests/{ => HelperTests}/ActivityHelperTests.cs (99%) create mode 100644 src/uActivityPub.Tests/ServiceTests/InboxServiceTests.cs rename src/uActivityPub.Tests/{ => ServiceTests}/OutboxServiceTests.cs (91%) rename src/uActivityPub.Tests/{ => ServiceTests}/SignatureServiceTests.cs (84%) create mode 100644 src/uActivityPub.Tests/ServiceTests/UActivitySettingsServiceTests.cs rename src/uActivityPub.Tests/{ => TestHelpers}/uActivitySettingsHelper.cs (94%) diff --git a/src/uActivityPub.Tests/ContentPublishPostHandlerTests.cs b/src/uActivityPub.Tests/ContentPublishPostHandlerTests.cs index 6a1043b..79f3a92 100644 --- a/src/uActivityPub.Tests/ContentPublishPostHandlerTests.cs +++ b/src/uActivityPub.Tests/ContentPublishPostHandlerTests.cs @@ -6,8 +6,10 @@ using Microsoft.Extensions.Options; using RichardSzalay.MockHttp; using uActivityPub.Data; +using uActivityPub.Helpers; using uActivityPub.Models; using uActivityPub.Services; +using uActivityPub.Tests.TestHelpers; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; @@ -35,15 +37,21 @@ public ContentPublishPostHandlerTests() public void HandlePostsToFollowers() { //Arrange - var blogPostMock = new Mock(); var contentTypeMock = new Mock(); var iUActivitySettingsServiceMock = new Mock(); - + iUActivitySettingsServiceMock.Setup(x => x.GetAllSettings()) .Returns(uActivitySettingsHelper.GetSettings); - - + + iUActivitySettingsServiceMock.Setup(x => x.GetSettings(uActivitySettingKeys.SingleUserMode)) + .Returns(new uActivitySettings + { + Id = 4, + Key = uActivitySettingKeys.SingleUserMode, + Value = "false" + }); + contentTypeMock.Setup(x => x.Alias).Returns("article"); blogPostMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object); blogPostMock.Setup(x => x.GetValue("authorName", null, null, false)) @@ -62,7 +70,7 @@ public void HandlePostsToFollowers() { new() { - Actor = "testactor", + Actor = "test-actor", Object = "", Type = "Follow", Id = 1 @@ -87,7 +95,7 @@ public void HandlePostsToFollowers() Inbox = "http://localhost/inbox" }); - signatureServiceMock.Setup(x => x.GetPrimaryKeyForUser(userMock.Object)) + signatureServiceMock.Setup(x => x.GetPrimaryKeyForUser("uActivityPub", 1)) .ReturnsAsync(("key", RSA.Create(2048))); var singedRequestHandlerMock = new Mock(); singedRequestHandlerMock.Setup(x => @@ -130,14 +138,21 @@ public void HandlePostsToFollowers() public void HandlePostsToMultipleFollowers() { //Arrange - var blogPostMock = new Mock(); var contentTypeMock = new Mock(); var iUActivitySettingsServiceMock = new Mock(); - + iUActivitySettingsServiceMock.Setup(x => x.GetAllSettings()) .Returns(uActivitySettingsHelper.GetSettings); - + + iUActivitySettingsServiceMock.Setup(x => x.GetSettings(uActivitySettingKeys.SingleUserMode)) + .Returns(new uActivitySettings + { + Id = 4, + Key = uActivitySettingKeys.SingleUserMode, + Value = "false" + }); + contentTypeMock.Setup(x => x.Alias).Returns("article"); blogPostMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object); blogPostMock.Setup(x => x.GetValue("authorName", null, null, false)) @@ -156,14 +171,14 @@ public void HandlePostsToMultipleFollowers() { new() { - Actor = "testactor", + Actor = "test-actor", Object = "", Type = "Follow", Id = 1 }, new() { - Actor = "testactor2", + Actor = "test-actor2", Object = "", Type = "Follow", Id = 2 @@ -188,7 +203,7 @@ public void HandlePostsToMultipleFollowers() Inbox = "http://localhost/inbox" }); - signatureServiceMock.Setup(x => x.GetPrimaryKeyForUser(userMock.Object)) + signatureServiceMock.Setup(x => x.GetPrimaryKeyForUser("uActivityPub", 1)) .ReturnsAsync(("key", RSA.Create(2048))); var singedRequestHandlerMock = new Mock(); singedRequestHandlerMock.Setup(x => @@ -235,11 +250,11 @@ public void HandlePostsToFollowersForNonArticle() var blogPostMock = new Mock(); var contentTypeMock = new Mock(); var iUActivitySettingsServiceMock = new Mock(); - + iUActivitySettingsServiceMock.Setup(x => x.GetAllSettings()) - .Returns(uActivitySettingsHelper.GetSettings); - - + .Returns(uActivitySettingsHelper.GetSettings); + + contentTypeMock.Setup(x => x.Alias).Returns("nonArticle"); blogPostMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object); @@ -274,14 +289,13 @@ public void HandlePostsToFollowersForNonArticle() public void HandlePostsToFollowersWithAlreadyPostedArticle() { //Arrange - var blogPostMock = new Mock(); var contentTypeMock = new Mock(); var iUActivitySettingsServiceMock = new Mock(); - + iUActivitySettingsServiceMock.Setup(x => x.GetAllSettings()) .Returns(uActivitySettingsHelper.GetSettings); - + contentTypeMock.Setup(x => x.Alias).Returns("article"); blogPostMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object); blogPostMock.Setup(x => x.GetValue("authorName", null, null, false)) @@ -298,7 +312,7 @@ public void HandlePostsToFollowersWithAlreadyPostedArticle() { new() { - Actor = "testactor", + Actor = "test-actor", Object = "", Type = "Post", Id = 1 @@ -328,6 +342,47 @@ public void HandlePostsToFollowersWithAlreadyPostedArticle() Times.Never); } + [Fact] + public void HandlePostsToFollowersForNotFoundContentAliasSettingThrowsInvalidOperation() + { + //Arrange + var blogPostMock = new Mock(); + var contentTypeMock = new Mock(); + var iUActivitySettingsServiceMock = new Mock(); + + contentTypeMock.Setup(x => x.Alias).Returns("article"); + + + var databaseFactoryMock = new Mock(); + var databaseMock = new Mock(); + var userServiceMock = new Mock(); + var signatureServiceMock = new Mock(); + var singedRequestHandlerMock = new Mock(); + var activityHelperMock = new Mock(); + var notification = new ContentPublishedNotification(blogPostMock.Object, null!); + + var unitUnderTest = new ContentPublishPostHandler(databaseFactoryMock.Object, _webRouterSettingsMock.Object, + userServiceMock.Object, signatureServiceMock.Object, singedRequestHandlerMock.Object, + activityHelperMock.Object, iUActivitySettingsServiceMock.Object); + + try + { + //Act + unitUnderTest.Handle(notification); + } + catch (Exception e) + { + //Assert + Assert.IsType(e); + databaseMock.Verify( + x => x.Insert("receivedActivityPubActivities", "Id", true, It.IsAny()), + Times.Never); + singedRequestHandlerMock.Verify( + x => x.SendSingedPost(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + } + [Fact] public void HandlePostsToFollowersForNotFoundUserThrowsInvalidOperation() { @@ -336,23 +391,24 @@ public void HandlePostsToFollowersForNotFoundUserThrowsInvalidOperation() var blogPostMock = new Mock(); var contentTypeMock = new Mock(); var iUActivitySettingsServiceMock = new Mock(); - + iUActivitySettingsServiceMock.Setup(x => x.GetAllSettings()) + .Returns(new List + { + new() + { + Id = 1, + Key = uActivitySettingKeys.ContentTypeAlias, + Value = "unit" + } + }); + contentTypeMock.Setup(x => x.Alias).Returns("article"); blogPostMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object); - blogPostMock.Setup(x => x.GetValue("authorName", null, null, false)) - .Returns(1); var databaseFactoryMock = new Mock(); var databaseMock = new Mock(); - databaseFactoryMock.Setup(x => x.CreateDatabase()) - .Returns(databaseMock.Object); - - databaseMock.Setup(x => x.Query(It.IsAny(), "Post", It.IsAny())) - .Returns(new List()); var userServiceMock = new Mock(); - - var signatureServiceMock = new Mock(); var singedRequestHandlerMock = new Mock(); var activityHelperMock = new Mock(); @@ -361,22 +417,26 @@ public void HandlePostsToFollowersForNotFoundUserThrowsInvalidOperation() var unitUnderTest = new ContentPublishPostHandler(databaseFactoryMock.Object, _webRouterSettingsMock.Object, userServiceMock.Object, signatureServiceMock.Object, singedRequestHandlerMock.Object, activityHelperMock.Object, iUActivitySettingsServiceMock.Object); - + try { //Act unitUnderTest.Handle(notification); } - catch(Exception e) + catch (Exception e) { //Assert Assert.IsType(e); - databaseMock.Verify(x => x.Insert("receivedActivityPubActivities", "Id", true, It.IsAny()), Times.Never); - singedRequestHandlerMock.Verify(x => x.SendSingedPost(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + databaseMock.Verify( + x => x.Insert("receivedActivityPubActivities", "Id", true, It.IsAny()), + Times.Never); + singedRequestHandlerMock.Verify( + x => x.SendSingedPost(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); } } - - [Fact] + + [Fact] public void HandlePostsToFollowersDoesNotCrashIfActorNoLongerExists() { //Arrange @@ -384,9 +444,17 @@ public void HandlePostsToFollowersDoesNotCrashIfActorNoLongerExists() var blogPostMock = new Mock(); var contentTypeMock = new Mock(); var iUActivitySettingsServiceMock = new Mock(); - + iUActivitySettingsServiceMock.Setup(x => x.GetAllSettings()) .Returns(uActivitySettingsHelper.GetSettings); + + iUActivitySettingsServiceMock.Setup(x => x.GetSettings(uActivitySettingKeys.SingleUserMode)) + .Returns(new uActivitySettings + { + Id = 4, + Key = uActivitySettingKeys.SingleUserMode, + Value = "false" + }); contentTypeMock.Setup(x => x.Alias).Returns("article"); blogPostMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object); @@ -406,7 +474,7 @@ public void HandlePostsToFollowersDoesNotCrashIfActorNoLongerExists() { new() { - Actor = "testactor", + Actor = "test-actor", Object = "", Type = "Follow", Id = 1 @@ -425,12 +493,12 @@ public void HandlePostsToFollowersDoesNotCrashIfActorNoLongerExists() var signatureServiceMock = new Mock(); signatureServiceMock.Setup(x => x.GetActor(It.IsAny())) - .ReturnsAsync((Actor) null!); + .ReturnsAsync((Actor)null!); - signatureServiceMock.Setup(x => x.GetPrimaryKeyForUser(userMock.Object)) + signatureServiceMock.Setup(x => x.GetPrimaryKeyForUser("uActivityPub", 1)) .ReturnsAsync(("key", RSA.Create(2048))); var singedRequestHandlerMock = new Mock(); - + var activityHelperMock = new Mock(); var activity = new Activity @@ -462,19 +530,26 @@ public void HandlePostsToFollowersDoesNotCrashIfActorNoLongerExists() x => x.SendSingedPost(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } - - [Fact] + + [Fact] public void HandlePostsToFollowersDoesNotCrashIfRequestFails() { //Arrange - var blogPostMock = new Mock(); var contentTypeMock = new Mock(); var iUActivitySettingsServiceMock = new Mock(); - + iUActivitySettingsServiceMock.Setup(x => x.GetAllSettings()) .Returns(uActivitySettingsHelper.GetSettings); + iUActivitySettingsServiceMock.Setup(x => x.GetSettings(uActivitySettingKeys.SingleUserMode)) + .Returns(new uActivitySettings + { + Id = 4, + Key = uActivitySettingKeys.SingleUserMode, + Value = "false" + }); + contentTypeMock.Setup(x => x.Alias).Returns("article"); blogPostMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object); blogPostMock.Setup(x => x.GetValue("authorName", null, null, false)) @@ -493,7 +568,7 @@ public void HandlePostsToFollowersDoesNotCrashIfRequestFails() { new() { - Actor = "testactor", + Actor = "test-actor", Object = "", Type = "Follow", Id = 1 @@ -518,7 +593,7 @@ public void HandlePostsToFollowersDoesNotCrashIfRequestFails() Inbox = "http://localhost/inbox" }); - signatureServiceMock.Setup(x => x.GetPrimaryKeyForUser(userMock.Object)) + signatureServiceMock.Setup(x => x.GetPrimaryKeyForUser("uActivityPub", 1)) .ReturnsAsync(("key", RSA.Create(2048))); var singedRequestHandlerMock = new Mock(); singedRequestHandlerMock.Setup(x => diff --git a/src/uActivityPub.Tests/ControllerTests/WebfingerControllerTests.cs b/src/uActivityPub.Tests/ControllerTests/WebfingerControllerTests.cs new file mode 100644 index 0000000..5189e8f --- /dev/null +++ b/src/uActivityPub.Tests/ControllerTests/WebfingerControllerTests.cs @@ -0,0 +1,227 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using uActivityPub.Controllers; +using uActivityPub.Data; +using uActivityPub.Helpers; +using uActivityPub.Models; +using uActivityPub.Services; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; + +namespace uActivityPub.Tests.ControllerTests; + +public class WebfingerControllerTests +{ + private readonly WebfingerController _unitUnderTest; + + // Mocks + private readonly Mock _userServiceMock = new Mock(); + private readonly Mock> _webRoutingSettingsMock = new Mock>(); + + private readonly Mock _uActivitySettingsServiceMock = new Mock(); + + public WebfingerControllerTests() + { + const string baseApplicationUrl = "https://localhost.test/"; + _webRoutingSettingsMock.Setup(x => x.Value).Returns(new WebRoutingSettings + { + UmbracoApplicationUrl = baseApplicationUrl + }); + + _unitUnderTest = new WebfingerController(_userServiceMock.Object, _webRoutingSettingsMock.Object, _uActivitySettingsServiceMock.Object); + } + + [Fact] + public void HandleRequest_Returns_Bad_Request_When_No_RequestQueryString_Is_Provided() + { + // Arrange + _unitUnderTest.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + _unitUnderTest.ControllerContext.HttpContext.Request.Query = new QueryCollection(new Dictionary()); + + // Act + var response = _unitUnderTest.HandleRequest(); + + // Assert + response.Should().NotBeNull(); + response.Result.Should().BeOfType(); + } + + [Fact] + public void HandleRequest_Returns_Bad_Request_When_Not_Usable_RequestQueryString_Is_Provided() + { + // Arrange + _unitUnderTest.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + var collectionStore = new Dictionary { { "resource", "" } }; + _unitUnderTest.ControllerContext.HttpContext.Request.Query = new QueryCollection(collectionStore); + + // Act + var response = _unitUnderTest.HandleRequest(); + + // Assert + response.Should().NotBeNull(); + response.Result.Should().BeOfType(); + } + + [Fact] + public void HandleRequest_Returns_Bad_Request_When_Not_Usable_Resource_Is_Requested() + { + // Arrange + _unitUnderTest.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + var collectionStore = new Dictionary { { "resource", "unit:test" } }; + _unitUnderTest.ControllerContext.HttpContext.Request.Query = new QueryCollection(collectionStore); + + // Act + var response = _unitUnderTest.HandleRequest(); + + // Assert + response.Should().NotBeNull(); + response.Result.Should().BeOfType(); + } + + [Fact] + public void HandleRequest_Returns_Webfinger_Response_When_Account_Is_Requested_In_Single_User_Mode() + { + // Arrange + _unitUnderTest.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + var collectionStore = new Dictionary { { "resource", "acct:uActivityPub@umbracoSite.domain" } }; + _unitUnderTest.ControllerContext.HttpContext.Request.Query = new QueryCollection(collectionStore); + + _uActivitySettingsServiceMock.Setup(x => x.GetSettings(uActivitySettingKeys.SingleUserMode)) + .Returns(new uActivitySettings + { + Id = 1, + Key = uActivitySettingKeys.SingleUserMode, + Value = "true" + }); + + _uActivitySettingsServiceMock.Setup(x => x.GetSettings(uActivitySettingKeys.SingleUserModeUserName)) + .Returns(new uActivitySettings + { + Id = 2, + Key = uActivitySettingKeys.SingleUserModeUserName, + Value = "uActivityPub" + }); + + // Act + var response = _unitUnderTest.HandleRequest(); + + // Assert + response.Should().NotBeNull(); + response.Should().BeOfType>(); + response.Result.Should().BeOfType(); + + var objectResponse = response.Result as OkObjectResult; + objectResponse.Should().NotBeNull(); + objectResponse!.Value.Should().NotBeNull(); + var webFingerResponse = objectResponse!.Value as WebFingerResponse; + webFingerResponse.Should().NotBeNull(); + webFingerResponse!.Subject.Should().Be("acct:uActivityPub@umbracoSite.domain"); + webFingerResponse.Links.Length.Should().Be(1); + webFingerResponse.Links.First().Rel.Should().Be("self"); + webFingerResponse.Links.First().Type.Should().Be("application/activity+json"); + webFingerResponse.Links.First().Href.Should().Be("https://localhost.test/activitypub/actor/uactivitypub"); + } + + [Fact] + public void HandleRequest_Returns_Webfinger_Response_When_Account_Is_Requested_Not_In_Single_User_Mode() + { + // Arrange + _unitUnderTest.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + var collectionStore = new Dictionary { { "resource", "acct:uActivityPub@umbracoSite.domain" } }; + _unitUnderTest.ControllerContext.HttpContext.Request.Query = new QueryCollection(collectionStore); + + _uActivitySettingsServiceMock.Setup(x => x.GetSettings(uActivitySettingKeys.SingleUserMode)) + .Returns(new uActivitySettings + { + Id = 1, + Key = uActivitySettingKeys.SingleUserMode, + Value = "false" + }); + + long outTotal; + _userServiceMock.Setup(x => x.GetAll(0, 100, out outTotal)) + .Returns(new List + { + new User(new GlobalSettings()) + { + Name = "uActivityPub" + } + }); + + // Act + var response = _unitUnderTest.HandleRequest(); + + // Assert + response.Should().NotBeNull(); + response.Should().BeOfType>(); + response.Result.Should().BeOfType(); + + var objectResponse = response.Result as OkObjectResult; + objectResponse.Should().NotBeNull(); + objectResponse!.Value.Should().NotBeNull(); + var webFingerResponse = objectResponse!.Value as WebFingerResponse; + webFingerResponse.Should().NotBeNull(); + webFingerResponse!.Subject.Should().Be("acct:uActivityPub@umbracoSite.domain"); + webFingerResponse.Links.Length.Should().Be(1); + webFingerResponse.Links.First().Rel.Should().Be("self"); + webFingerResponse.Links.First().Type.Should().Be("application/activity+json"); + webFingerResponse.Links.First().Href.Should().Be("https://localhost.test/activitypub/actor/uactivitypub"); + } + + [Fact] + public void HandleRequest_Returns_NotFound_Response_When_Non_Existing_Account_Is_Requested_Not_In_Single_User_Mode() + { + // Arrange + _unitUnderTest.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + var collectionStore = new Dictionary { { "resource", "acct:uActivityPub@umbracoSite.domain" } }; + _unitUnderTest.ControllerContext.HttpContext.Request.Query = new QueryCollection(collectionStore); + + _uActivitySettingsServiceMock.Setup(x => x.GetSettings(uActivitySettingKeys.SingleUserMode)) + .Returns(new uActivitySettings + { + Id = 1, + Key = uActivitySettingKeys.SingleUserMode, + Value = "false" + }); + + long outTotal; + _userServiceMock.Setup(x => x.GetAll(0, 100, out outTotal)) + .Returns(new List + { + Capacity = 0 + }); + + // Act + var response = _unitUnderTest.HandleRequest(); + + // Assert + response.Should().NotBeNull(); + response.Should().BeOfType>(); + response.Result.Should().BeOfType(); + } +} \ No newline at end of file diff --git a/src/uActivityPub.Tests/ActivityHelperTests.cs b/src/uActivityPub.Tests/HelperTests/ActivityHelperTests.cs similarity index 99% rename from src/uActivityPub.Tests/ActivityHelperTests.cs rename to src/uActivityPub.Tests/HelperTests/ActivityHelperTests.cs index de6652f..2d366d0 100644 --- a/src/uActivityPub.Tests/ActivityHelperTests.cs +++ b/src/uActivityPub.Tests/HelperTests/ActivityHelperTests.cs @@ -1,15 +1,15 @@ using System; using Microsoft.Extensions.Options; using uActivityPub.Models; -using Umbraco.Cms.Core.Configuration.Models; using uActivityPub.Services; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Web; -namespace uActivityPub.Tests; +namespace uActivityPub.Tests.HelperTests; public class ActivityHelperTests { diff --git a/src/uActivityPub.Tests/ServiceTests/InboxServiceTests.cs b/src/uActivityPub.Tests/ServiceTests/InboxServiceTests.cs new file mode 100644 index 0000000..a194a5c --- /dev/null +++ b/src/uActivityPub.Tests/ServiceTests/InboxServiceTests.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; +using uActivityPub.Data; +using uActivityPub.Models; +using uActivityPub.Services; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Infrastructure.Persistence; + +namespace uActivityPub.Tests.ServiceTests; + +public class InboxServiceTests +{ + private readonly InboxService _unitUnderTest; + + //Mocks + private readonly Mock _dataBaseMock; + private readonly Mock> _webRoutingSettingsMock; + private readonly Mock _signatureServiceMock; + private readonly Mock _singedRequestHandlerMock; + + public InboxServiceTests() + { + var dataBaseFactoryMock = new Mock(); + _dataBaseMock = new Mock(); + + dataBaseFactoryMock.Setup(x => x.CreateDatabase()) + .Returns(_dataBaseMock.Object); + _webRoutingSettingsMock = new Mock>(); + _signatureServiceMock = new Mock(); + _singedRequestHandlerMock = new Mock(); + + + _unitUnderTest = new InboxService(dataBaseFactoryMock.Object, _webRoutingSettingsMock.Object, + _signatureServiceMock.Object, _singedRequestHandlerMock.Object); + } + + [Fact] + public async Task HandleUndo_Returns_Null_After_Delete() + { + // Arrange + var followActivity = JObject.FromObject(new Activity() + { + Type = "Follow", + Actor = "UnitTest" + }); + + var activity = new Activity + { + Object = followActivity + }; + + var receivedActivitiesSchema = new ReceivedActivitiesSchema(); + + _dataBaseMock.Setup(x => + x.FirstOrDefaultAsync(It.IsAny(), "Follow", "UnitTest")) + .ReturnsAsync(receivedActivitiesSchema); + + + // Act + var returnedActivity = await _unitUnderTest.HandleUndo(activity, string.Empty); + + + // Assert + _dataBaseMock.Verify(x => x.DeleteAsync(receivedActivitiesSchema), Times.Once); + returnedActivity.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/uActivityPub.Tests/OutboxServiceTests.cs b/src/uActivityPub.Tests/ServiceTests/OutboxServiceTests.cs similarity index 91% rename from src/uActivityPub.Tests/OutboxServiceTests.cs rename to src/uActivityPub.Tests/ServiceTests/OutboxServiceTests.cs index f449418..9ce6595 100644 --- a/src/uActivityPub.Tests/OutboxServiceTests.cs +++ b/src/uActivityPub.Tests/ServiceTests/OutboxServiceTests.cs @@ -1,15 +1,13 @@ using System.Collections.Generic; using Microsoft.Extensions.Options; -using uActivityPub.Data; -using uActivityPub.Helpers; using uActivityPub.Models; using uActivityPub.Services; +using uActivityPub.Tests.TestHelpers; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Services; -namespace uActivityPub.Tests; +namespace uActivityPub.Tests.ServiceTests; public class OutboxServiceTests { @@ -37,8 +35,6 @@ public void GetPublicOutboxForExistingUserReturnsOutbox() iUActivitySettingsServiceMock.Setup(x => x.GetAllSettings()) .Returns(uActivitySettingsHelper.GetSettings); - var userMock = new Mock(); - var rootContentMock = new Mock(); rootContentMock.Setup(x => x.Id) .Returns(1); @@ -91,12 +87,12 @@ public void GetPublicOutboxForExistingUserReturnsOutbox() iUActivitySettingsServiceMock.Object); //Act - var outbox = unitUnderTest.GetPublicOutbox(userMock.Object); + var outbox = unitUnderTest.GetPublicOutbox("uActivityPub"); //Assert Assert.NotNull(outbox); - Assert.Single(outbox?.Items!); - Assert.Contains("https://www.w3.org/ns/activitystreams", outbox?.Context!); + Assert.Single(outbox.Items); + Assert.Contains("https://www.w3.org/ns/activitystreams", outbox.Context); activityHelperMock.Verify(x => x.GetActivityFromContent(It.IsAny(), It.IsAny()), Times.Once); } @@ -106,7 +102,6 @@ public void GetPublicOutboxForWithoutRootContentReturnsNull() //Arrange var contentServiceMock = new Mock(); var activityHelperMock = new Mock(); - var userMock = new Mock(); var iUActivitySettingsServiceMock = new Mock(); iUActivitySettingsServiceMock.Setup(x => x.GetAllSettings()) @@ -122,7 +117,7 @@ public void GetPublicOutboxForWithoutRootContentReturnsNull() iUActivitySettingsServiceMock.Object); //Act - var outbox = unitUnderTest.GetPublicOutbox(userMock.Object); + var outbox = unitUnderTest.GetPublicOutbox("uActivityPub"); //Assert Assert.Null(outbox); @@ -134,7 +129,6 @@ public void GetPublicOutboxForNonExistingBlogRootReturnsNull() //Arrange var contentServiceMock = new Mock(); var activityHelperMock = new Mock(); - var userMock = new Mock(); var iUActivitySettingsServiceMock = new Mock(); iUActivitySettingsServiceMock.Setup(x => x.GetAllSettings()) @@ -162,7 +156,7 @@ public void GetPublicOutboxForNonExistingBlogRootReturnsNull() iUActivitySettingsServiceMock.Object); //Act - var outbox = unitUnderTest.GetPublicOutbox(userMock.Object); + var outbox = unitUnderTest.GetPublicOutbox("uActivityPub"); //Assert Assert.Null(outbox); diff --git a/src/uActivityPub.Tests/SignatureServiceTests.cs b/src/uActivityPub.Tests/ServiceTests/SignatureServiceTests.cs similarity index 84% rename from src/uActivityPub.Tests/SignatureServiceTests.cs rename to src/uActivityPub.Tests/ServiceTests/SignatureServiceTests.cs index df95c21..6fa4165 100644 --- a/src/uActivityPub.Tests/SignatureServiceTests.cs +++ b/src/uActivityPub.Tests/ServiceTests/SignatureServiceTests.cs @@ -5,14 +5,12 @@ using Microsoft.Extensions.Options; using RichardSzalay.MockHttp; using uActivityPub.Data; -using uActivityPub.Helpers; using uActivityPub.Models; using uActivityPub.Services; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Infrastructure.Persistence; -namespace uActivityPub.Tests; +namespace uActivityPub.Tests.ServiceTests; public class SignatureServiceTests { @@ -20,7 +18,7 @@ public class SignatureServiceTests #region testRSA //randomly generated keypair that is not used elsewhere - private const string testPrivateKeyPem = @"-----BEGIN RSA PRIVATE KEY----- + private const string TestPrivateKeyPem = @"-----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEA1CUZkCH8QIXroxDE2NapFlf3QGwrTEqmQSJGTqfBawGSDhBW mwd1OSv7DxWour9lxUsLOLBc299H6aKzYGduJON+V255i6wIvzrK23q36DduNnF5 aY48MgFKgU33/La9N9fDU4/d3yUbImyOG8doJws6GwCXIYYOLprAmNf0cmHffE9S @@ -48,7 +46,7 @@ public class SignatureServiceTests aGw+fVn4z0e0N//l7HJ8s3U1sSjWa4dhnCfgqIz+cw7E7E1WTWRKqSY= -----END RSA PRIVATE KEY-----"; - private const string testPublicKeyPem = @"-----BEGIN RSA PUBLIC KEY----- + private const string TestPublicKeyPem = @"-----BEGIN RSA PUBLIC KEY----- MIIBCgKCAQEA1CUZkCH8QIXroxDE2NapFlf3QGwrTEqmQSJGTqfBawGSDhBWmwd1 OSv7DxWour9lxUsLOLBc299H6aKzYGduJON+V255i6wIvzrK23q36DduNnF5aY48 MgFKgU33/La9N9fDU4/d3yUbImyOG8doJws6GwCXIYYOLprAmNf0cmHffE9SFMJ3 @@ -58,7 +56,6 @@ public class SignatureServiceTests -----END RSA PUBLIC KEY-----"; #endregion - public SignatureServiceTests() { const string baseApplicationUrl = "https://localhost.test/"; @@ -101,7 +98,7 @@ public async Task GetActorReturnsActor() //Assert Assert.NotNull(retrievedActor); - Assert.Equal(actorString, retrievedActor?.Id); + Assert.Equal(actorString, retrievedActor.Id); } [Fact] @@ -142,26 +139,18 @@ public async Task GetPrimaryKeyForUserReturnsKeyPair() var databaseMock = new Mock(); var httpClientFactoryMock = new Mock(); - var userKeySchema = new UserKeysSchema() + var userKeySchema = new UserKeysSchema { Id = 2, - PrivateKey = testPrivateKeyPem, - PublicKey = testPublicKeyPem + PrivateKey = TestPrivateKeyPem, + PublicKey = TestPublicKeyPem }; - var userMock = new Mock(); - userMock.Setup(x => x.Id) - .Returns(1); - userMock.Setup(x => x.Name) - .Returns("testuser"); - databaseFactoryMock.Setup(x => x.CreateDatabase()) .Returns(databaseMock.Object); databaseMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny(), 1)) .ReturnsAsync(userKeySchema); - - var unitUnderTest = new SignatureService( databaseFactoryMock.Object, @@ -169,14 +158,14 @@ public async Task GetPrimaryKeyForUserReturnsKeyPair() _webRouterSettingsMock.Object); //Act - var keyPairTuple = await unitUnderTest.GetPrimaryKeyForUser(userMock.Object); + var keyPairTuple = await unitUnderTest.GetPrimaryKeyForUser("test-user", 1); //Assert Assert.NotNull(keyPairTuple.KeyId); - Assert.Equal(testPrivateKeyPem.Replace("\r\n", "\n"), keyPairTuple.Rsa.ExportRSAPrivateKeyPem()); - Assert.Equal(testPublicKeyPem.Replace("\r\n", "\n"), keyPairTuple.Rsa.ExportRSAPublicKeyPem()); - Assert.Contains("activitypub/actor/testuser", keyPairTuple.KeyId); + Assert.Equal(TestPrivateKeyPem.Replace("\r\n", "\n"), keyPairTuple.Rsa.ExportRSAPrivateKeyPem()); + Assert.Equal(TestPublicKeyPem.Replace("\r\n", "\n"), keyPairTuple.Rsa.ExportRSAPublicKeyPem()); + Assert.Contains("activitypub/actor/test-user", keyPairTuple.KeyId); } [Fact] @@ -187,19 +176,6 @@ public async Task GetPrimaryKeyForUserReturnsGeneratedKeyPair() var databaseMock = new Mock(); var httpClientFactoryMock = new Mock(); - var userKeySchema = new UserKeysSchema() - { - Id = 2, - PrivateKey = testPrivateKeyPem, - PublicKey = testPublicKeyPem - }; - - var userMock = new Mock(); - userMock.Setup(x => x.Id) - .Returns(1); - userMock.Setup(x => x.Name) - .Returns("testuser"); - databaseFactoryMock.Setup(x => x.CreateDatabase()) .Returns(databaseMock.Object); databaseMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny(), 1)) @@ -211,14 +187,14 @@ public async Task GetPrimaryKeyForUserReturnsGeneratedKeyPair() _webRouterSettingsMock.Object); //Act - var keyPairTuple = await unitUnderTest.GetPrimaryKeyForUser(userMock.Object); + var keyPairTuple = await unitUnderTest.GetPrimaryKeyForUser("test-user", 1); //Assert Assert.NotNull(keyPairTuple.KeyId); Assert.False(string.IsNullOrEmpty(keyPairTuple.Rsa.ExportRSAPrivateKeyPem())); Assert.False(string.IsNullOrEmpty(keyPairTuple.Rsa.ExportRSAPublicKeyPem())); - Assert.Contains("activitypub/actor/testuser", keyPairTuple.KeyId); + Assert.Contains("activitypub/actor/test-user", keyPairTuple.KeyId); databaseMock.Verify(x => x.Insert(It.IsAny()), Times.Once); } } \ No newline at end of file diff --git a/src/uActivityPub.Tests/ServiceTests/UActivitySettingsServiceTests.cs b/src/uActivityPub.Tests/ServiceTests/UActivitySettingsServiceTests.cs new file mode 100644 index 0000000..9fbc2b4 --- /dev/null +++ b/src/uActivityPub.Tests/ServiceTests/UActivitySettingsServiceTests.cs @@ -0,0 +1,59 @@ +using System.Linq; +using FluentAssertions; +using uActivityPub.Data; +using uActivityPub.Helpers; +using uActivityPub.Services; +using uActivityPub.Tests.TestHelpers; +using Umbraco.Cms.Infrastructure.Persistence; + +namespace uActivityPub.Tests.ServiceTests; + +public class UActivitySettingsServiceTests +{ + + private readonly IUActivitySettingsService _unitUnderTest; + + // Mock + private readonly Mock _dataBaseMock; + + public UActivitySettingsServiceTests() + { + var dataBaseFactoryMock = new Mock(); + _dataBaseMock = new Mock(); + + dataBaseFactoryMock.Setup(x => x.CreateDatabase()) + .Returns(_dataBaseMock.Object); + + _unitUnderTest = new UActivitySettingsService(dataBaseFactoryMock.Object); + } + + [Fact] + public void GetAllSettings_Returns_All_Settings_In_Database() + { + // Arrange + _dataBaseMock.Setup(x => x.Fetch(It.IsAny())) + .Returns(uActivitySettingsHelper.GetSettings()); + + // Act + var settings = _unitUnderTest.GetAllSettings()?.ToList(); + + // Assert + settings.Should().NotBeNull(); + settings!.Count.Should().Be(uActivitySettingsHelper.GetSettings().Count); + } + + [Fact] + public void GetSetting_Returns_Specific_Settings_In_Database() + { + // Arrange + _dataBaseMock.Setup(x => x.Fetch(It.IsAny())) + .Returns(uActivitySettingsHelper.GetSettings()); + + // Act + var setting = _unitUnderTest.GetSettings(uActivitySettingKeys.SingleUserMode); + + // Assert + setting.Should().NotBeNull(); + setting!.Key.Should().Be(uActivitySettingKeys.SingleUserMode); + } +} \ No newline at end of file diff --git a/src/uActivityPub.Tests/uActivitySettingsHelper.cs b/src/uActivityPub.Tests/TestHelpers/uActivitySettingsHelper.cs similarity index 94% rename from src/uActivityPub.Tests/uActivitySettingsHelper.cs rename to src/uActivityPub.Tests/TestHelpers/uActivitySettingsHelper.cs index eb1fcba..7a2c676 100644 --- a/src/uActivityPub.Tests/uActivitySettingsHelper.cs +++ b/src/uActivityPub.Tests/TestHelpers/uActivitySettingsHelper.cs @@ -2,7 +2,7 @@ using uActivityPub.Data; using uActivityPub.Helpers; -namespace uActivityPub.Tests; +namespace uActivityPub.Tests.TestHelpers; public static class uActivitySettingsHelper { @@ -36,7 +36,7 @@ public static List GetSettings() }, new uActivitySettings { - Id = 4, + Id = 5, Key = uActivitySettingKeys.SingleUserModeUserName, Value = "uActivityPub" } diff --git a/src/uActivityPub.Tests/uActivityPub.Tests.csproj b/src/uActivityPub.Tests/uActivityPub.Tests.csproj index 963f403..ae58881 100644 --- a/src/uActivityPub.Tests/uActivityPub.Tests.csproj +++ b/src/uActivityPub.Tests/uActivityPub.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/src/uActivityPub.sln b/src/uActivityPub.sln index 115928c..4005ffd 100644 --- a/src/uActivityPub.sln +++ b/src/uActivityPub.sln @@ -9,9 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "uActivityPub.Tests", "uActi EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github-workflows", "github-workflows", "{3EF6B8E5-E1D0-4BF1-8A1B-4499215FDDBD}" ProjectSection(SolutionItems) = preProject - .github\workflows\publish-nuget.yml = .github\workflows\publish-nuget.yml - .github\workflows\create-release.yml = .github\workflows\create-release.yml - .github\release.yml = .github\release.yml + ..\.github\workflows\publish-nuget.yml = ..\.github\workflows\publish-nuget.yml + ..\.github\workflows\publish-documentation.yml = ..\.github\workflows\publish-documentation.yml + ..\.github\workflows\create-release.yml = ..\.github\workflows\create-release.yml + ..\.github\release.yml = ..\.github\release.yml EndProjectSection EndProject Global diff --git a/src/uActivityPub/Composers/UActivityPubComposer.cs b/src/uActivityPub/Composers/UActivityPubComposer.cs index e808e67..9efa77e 100644 --- a/src/uActivityPub/Composers/UActivityPubComposer.cs +++ b/src/uActivityPub/Composers/UActivityPubComposer.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using uActivityPub.Authorization; @@ -15,6 +16,7 @@ namespace uActivityPub.Composers; // ReSharper disable once UnusedType.Global +[ExcludeFromCodeCoverage] public class UActivityPubComposer : IComposer { public void Compose(IUmbracoBuilder builder) diff --git a/src/uActivityPub/Controllers/ActivityPubController.cs b/src/uActivityPub/Controllers/ActivityPubController.cs index 8664e29..930c4c4 100644 --- a/src/uActivityPub/Controllers/ActivityPubController.cs +++ b/src/uActivityPub/Controllers/ActivityPubController.cs @@ -13,42 +13,41 @@ namespace uActivityPub.Controllers; [Route("/activitypub")] -public class ActivityPubController : UmbracoApiController +public class ActivityPubController( + IUserService userService, + IInboxService inboxService, + IOutboxService outboxService, + IOptions webRoutingSettings, + IScopeProvider scopeProvider, + IUActivitySettingsService uActivitySettingsService) + : UmbracoApiController { - private readonly IUserService _userService; - private readonly IInboxService _inboxService; - private readonly IOutboxService _outboxService; - private readonly IOptions _webRoutingSettings; - private readonly IScopeProvider _scopeProvider; - - public ActivityPubController( - IUserService userService, - IInboxService inboxService, - IOutboxService outboxService, - IOptions webRoutingSettings, - IScopeProvider scopeProvider) - { - _userService = userService; - _inboxService = inboxService; - _outboxService = outboxService; - _webRoutingSettings = webRoutingSettings; - _scopeProvider = scopeProvider; - } - [HttpGet("actor/{userName}")] public ActionResult GetActor(string userName) { - var user = _userService.GetUserByActivityPubName(userName); - if (user == null) - return NotFound(); + Actor actor; + if (uActivitySettingsService.GetSettings(uActivitySettingKeys.SingleUserMode)!.Value == "false") + { + var user = userService.GetUserByActivityPubName(userName); + if (user == null) + return NotFound(); + - return Ok(new Actor(user, _webRoutingSettings, _scopeProvider)); + actor = new Actor(user, webRoutingSettings, scopeProvider); + } + else + { + var user = uActivitySettingsService.GetSettings(uActivitySettingKeys.SingleUserModeUserName)!.Value; + actor = new Actor(user, webRoutingSettings, scopeProvider); + } + + return Ok(actor); } [HttpGet("actor/{userName}/followers")] public async Task> GetFollowersCollection(string userName) { - using var scope = _scopeProvider.CreateScope(); + using var scope = scopeProvider.CreateScope(); var followers = await scope.Database.FetchAsync( "SELECT * FROM receivedActivityPubActivities WHERE Type = @0", "Follow"); @@ -74,9 +73,23 @@ public async Task> PostInbox(string userName, [FromBody] if (!ModelState.IsValid) return BadRequest(ModelState); - var user = _userService.GetUserByActivityPubName(userName); - if (user == null) - return NotFound(); + string activityPubUserName; + int userId; + + if (uActivitySettingsService.GetSettings(uActivitySettingKeys.SingleUserMode)!.Value == "false") + { + var user = userService.GetUserByActivityPubName(userName); + if (user == null) + return NotFound(); + + activityPubUserName = user.ActivityPubUserName()!; + userId = user.Id; + } + else + { + userId = uActivitySettingKeys.SingleUserModeUserId; + activityPubUserName = uActivitySettingsService.GetSettings(uActivitySettingKeys.SingleUserModeUserName)!.Value; + } var signature = Request.Headers["Signature"]; @@ -85,9 +98,9 @@ public async Task> PostInbox(string userName, [FromBody] switch (activity.Type) { case "Follow": - return Ok(await _inboxService.HandleFollow(activity, signature, user)); + return Ok(await inboxService.HandleFollow(activity, signature, activityPubUserName, userId)); case "Undo": - return Ok(await _inboxService.HandleUndo(activity, signature)); + return Ok(await inboxService.HandleUndo(activity, signature)); default: return BadRequest($"{activity.Type} is not supported on this server"); } @@ -101,13 +114,24 @@ public async Task> PostInbox(string userName, [FromBody] [HttpGet("outbox/{userName}")] public ActionResult GetOutbox(string userName) { - var user = _userService.GetUserByActivityPubName(userName); - if (user == null) - return NotFound(); - + OrderedCollection? outbox; + if (uActivitySettingsService.GetSettings(uActivitySettingKeys.SingleUserMode)!.Value == "false") + { + var user = userService.GetUserByActivityPubName(userName); + if (user == null) + return NotFound(); + + outbox = outboxService.GetPublicOutbox(user.ActivityPubUserName()!); + } + else + { + var activityPubUserName = uActivitySettingsService.GetSettings(uActivitySettingKeys.SingleUserModeUserName)!.Value; + outbox = outboxService.GetPublicOutbox(activityPubUserName); + } + try { - return Ok(_outboxService.GetPublicOutbox(user)); + return Ok(outbox); } catch { diff --git a/src/uActivityPub/Controllers/WebfingerController.cs b/src/uActivityPub/Controllers/WebfingerController.cs index 818b11d..91c02c2 100644 --- a/src/uActivityPub/Controllers/WebfingerController.cs +++ b/src/uActivityPub/Controllers/WebfingerController.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Options; using uActivityPub.Helpers; using uActivityPub.Models; +using uActivityPub.Services; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Controllers; @@ -9,24 +10,18 @@ namespace uActivityPub.Controllers; [Route("/.well-known/webfinger")] -public class WebfingerController : UmbracoApiController +public class WebfingerController( + IUserService userService, + IOptions webRoutingSettings, + IUActivitySettingsService uActivitySettingsService) + : UmbracoApiController { - private readonly IUserService _userService; - private readonly IOptions _webRoutingSettings; - - public WebfingerController(IUserService userService, IOptions webRoutingSettings) - { - _userService = userService; - _webRoutingSettings = webRoutingSettings; - } - - [HttpGet("")] public ActionResult HandleRequest() { var resource = Request.Query["resource"]; - if (!resource.Any()) + if (resource.Count == 0) return BadRequest("Can't search for something without resource request"); var requestedResource = resource.First(); @@ -48,22 +43,43 @@ public ActionResult HandleRequest() private ActionResult GetAccount(string last, string requestedResource) { var userName = last.Split('@').First().ToLower(); - var user = _userService.GetAll(0, 100, out _).FirstOrDefault(u => u.ActivityPubUserName() == userName); - if (user == null) - return NotFound(); + if (uActivitySettingsService.GetSettings(uActivitySettingKeys.SingleUserMode)!.Value == "false") + { + var user = userService.GetAll(0, 100, out _).FirstOrDefault(u => u.ActivityPubUserName() == userName); + + if (user == null) + return NotFound(); + + return Ok(new WebFingerResponse + { + Subject = requestedResource, + Links = + [ + new WebFingerLink + { + Rel = "self", + Href = + $"{webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{user.ActivityPubUserName()}" + } + ] + }); + } + + var singleUserModeName = uActivitySettingsService.GetSettings(uActivitySettingKeys.SingleUserModeUserName); return Ok(new WebFingerResponse { Subject = requestedResource, - Links = new[] - { + Links = + [ new WebFingerLink { Rel = "self", - Href = $"{_webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{user.ActivityPubUserName()}" + Href = + $"{webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{singleUserModeName!.Value.ToLowerInvariant()}" } - } + ] }); } } \ No newline at end of file diff --git a/src/uActivityPub/Helpers/GravatarHelper.cs b/src/uActivityPub/Helpers/GravatarHelper.cs index bb8430b..448250c 100644 --- a/src/uActivityPub/Helpers/GravatarHelper.cs +++ b/src/uActivityPub/Helpers/GravatarHelper.cs @@ -8,9 +8,23 @@ public static class GravatarHelper { public static string GetGravatarUrl(this IUser user) { - using var md5 = MD5.Create(); var inputBytes = Encoding.ASCII.GetBytes(user.Email); - var hash = md5.ComputeHash(inputBytes); + var hash = MD5.HashData(inputBytes); + + // convert byte array to hex string + var sb = new StringBuilder(); + foreach (var @byte in hash) + { + sb.Append(@byte.ToString("X2")); + } + + return $"https://www.gravatar.com/avatar/{sb}".ToLowerInvariant(); + } + + public static string GetGravatarUrl(this string user) + { + var inputBytes = Encoding.ASCII.GetBytes(user); + var hash = MD5.HashData(inputBytes); // convert byte array to hex string var sb = new StringBuilder(); diff --git a/src/uActivityPub/Helpers/uActivitySettingKeys.cs b/src/uActivityPub/Helpers/uActivitySettingKeys.cs index 61e7940..c239409 100644 --- a/src/uActivityPub/Helpers/uActivitySettingKeys.cs +++ b/src/uActivityPub/Helpers/uActivitySettingKeys.cs @@ -9,4 +9,5 @@ public static class uActivitySettingKeys public const string ContentTypeAlias = "contentTypeAlias"; public const string ListContentTypeAlias = "listContentTypeAlias"; public const string UserNameContentAlias = "authorName"; + public const int SingleUserModeUserId = -99; } \ No newline at end of file diff --git a/src/uActivityPub/Models/Actor.cs b/src/uActivityPub/Models/Actor.cs index 6c680db..259d8bc 100644 --- a/src/uActivityPub/Models/Actor.cs +++ b/src/uActivityPub/Models/Actor.cs @@ -25,6 +25,9 @@ public class Actor : ActivityPubBase public PublicKey? PublicKey { get; set; } + /// + /// Create an empty actor + /// public Actor() { Type = "Person"; @@ -35,6 +38,12 @@ public Actor() }; } + /// + /// Create an actor for uActivityPub in multi user mode + /// + /// + /// + /// public Actor(IUser user, IOptions webRoutingSettings, IScopeProvider scopeProvider) : this() { PreferredUsername = user.ActivityPubUserName() ?? user.Id.ToString(); @@ -66,6 +75,52 @@ public Actor(IUser user, IOptions webRoutingSettings, IScope } + PublicKey = new PublicKey + { + Id = $"{Id}#main-key", + Owner = Id, + PublicKeyPem = userKey.PublicKey + }; + + scope.Complete(); + } + + /// + /// Create an actor for uActivityPub in single user mode + /// + /// + /// + /// + public Actor(string userName, IOptions webRoutingSettings, IScopeProvider scopeProvider) : this() + { + Id = $"{webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{userName}"; + Inbox = $"{webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/inbox/{userName}"; + Outbox = $"{webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/outbox/{userName}"; + Followers = $"{webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{userName}/followers"; + Icon = new Icon + { + Url = userName.GetGravatarUrl() //todo get url to avatar if custom uploaded + }; + + using var scope = scopeProvider.CreateScope(); + + var userKey = scope.Database.FirstOrDefault("SELECT * FROM userKeys WHERE UserId = @0", uActivitySettingKeys.SingleUserModeUserId); + if (userKey == null) + { + //user does not have pub private key yet, lets make a pair + var rsa = RSA.Create(2048); + + userKey = new UserKeysSchema + { + Id = uActivitySettingKeys.SingleUserModeUserId, + PublicKey = rsa.ExportRSAPublicKeyPem(), + PrivateKey = rsa.ExportRSAPrivateKeyPem() + }; + + scope.Database.Insert(userKey); + } + + PublicKey = new PublicKey { Id = $"{Id}#main-key", diff --git a/src/uActivityPub/Services/ContentPublishPostHandler.cs b/src/uActivityPub/Services/ContentPublishPostHandler.cs index c520e20..df407e7 100644 --- a/src/uActivityPub/Services/ContentPublishPostHandler.cs +++ b/src/uActivityPub/Services/ContentPublishPostHandler.cs @@ -61,21 +61,33 @@ public void Handle(ContentPublishedNotification notification) if (posts.Any()) return; - - - var userId = post.GetValue(userPropertyAlias!.Value); - var user = _userService.GetUserById(userId); - if (user == null) + + string userName; + int userId; + + if (_uActivitySettingsService.GetSettings(uActivitySettingKeys.SingleUserMode)!.Value == "false") { - throw new InvalidOperationException("Could not find user or actor for article"); + userId = post.GetValue(userPropertyAlias!.Value); + var user = _userService.GetUserById(userId); + if (user == null) + { + throw new InvalidOperationException("Could not find user or actor for article"); + } + + userName = user.ActivityPubUserName()!; + } + else + { + userName = _uActivitySettingsService.GetSettings(uActivitySettingKeys.SingleUserModeUserName)!.Value; + userId = uActivitySettingKeys.SingleUserModeUserId; } - var actor = $"{_webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{user.ActivityPubUserName()}"; + var actor = $"{_webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{userName}"; var activity = _activityHelper.GetActivityFromContent(post, actor); - - var keyInfo = _signatureService.GetPrimaryKeyForUser(user).Result; - var serializedActivity = JsonSerializer.Serialize(activity, new JsonSerializerOptions() + + var keyInfo = _signatureService.GetPrimaryKeyForUser(userName, userId).Result; + var serializedActivity = JsonSerializer.Serialize(activity, new JsonSerializerOptions { DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase diff --git a/src/uActivityPub/Services/IInboxService.cs b/src/uActivityPub/Services/IInboxService.cs index 95ac135..5134933 100644 --- a/src/uActivityPub/Services/IInboxService.cs +++ b/src/uActivityPub/Services/IInboxService.cs @@ -5,6 +5,6 @@ namespace uActivityPub.Services; public interface IInboxService { - Task HandleFollow(Activity activity, string signature, IUser user); + Task HandleFollow(Activity activity, string signature, string userName, int userId); Task HandleUndo(Activity activity, string signature); } \ No newline at end of file diff --git a/src/uActivityPub/Services/IOutboxService.cs b/src/uActivityPub/Services/IOutboxService.cs index 9a54d23..c9d3ae6 100644 --- a/src/uActivityPub/Services/IOutboxService.cs +++ b/src/uActivityPub/Services/IOutboxService.cs @@ -5,5 +5,5 @@ namespace uActivityPub.Services; public interface IOutboxService { - OrderedCollection? GetPublicOutbox(IUser user); + OrderedCollection? GetPublicOutbox(string userName); } \ No newline at end of file diff --git a/src/uActivityPub/Services/ISignatureService.cs b/src/uActivityPub/Services/ISignatureService.cs index 0557a13..effa0d5 100644 --- a/src/uActivityPub/Services/ISignatureService.cs +++ b/src/uActivityPub/Services/ISignatureService.cs @@ -1,11 +1,10 @@ using System.Security.Cryptography; using uActivityPub.Models; -using Umbraco.Cms.Core.Models.Membership; namespace uActivityPub.Services; public interface ISignatureService { Task GetActor(string actorUrl); - Task<(string KeyId, RSA Rsa)> GetPrimaryKeyForUser(IUser user); + Task<(string KeyId, RSA Rsa)> GetPrimaryKeyForUser(string userName, int userId); } \ No newline at end of file diff --git a/src/uActivityPub/Services/InboxService.cs b/src/uActivityPub/Services/InboxService.cs index 8969775..07b4f2e 100644 --- a/src/uActivityPub/Services/InboxService.cs +++ b/src/uActivityPub/Services/InboxService.cs @@ -11,30 +11,18 @@ namespace uActivityPub.Services; -public class InboxService : IInboxService +public class InboxService( + IUmbracoDatabaseFactory databaseFactory, + IOptions webRoutingSettings, + ISignatureService signatureService, + ISingedRequestHandler singedRequestHandler) + : IInboxService { - private readonly IUmbracoDatabaseFactory _databaseFactory; - private readonly IOptions _webRoutingSettings; - private readonly ISignatureService _signatureService; - private readonly ISingedRequestHandler _singedRequestHandler; - - public InboxService( - IUmbracoDatabaseFactory databaseFactory, - IOptions webRoutingSettings, - ISignatureService signatureService, - ISingedRequestHandler singedRequestHandler) - { - _databaseFactory = databaseFactory; - _webRoutingSettings = webRoutingSettings; - _signatureService = signatureService; - _singedRequestHandler = singedRequestHandler; - } - - public async Task HandleFollow(Activity activity, string signature, IUser user) + public async Task HandleFollow(Activity activity, string signature, string userName, int userId) { Log.Information("Handling follow request for {Actor}. with activity {@Activity}", activity.Actor, activity); //todo 1. Check if valid (optional for now) - var actor = await _signatureService.GetActor(activity.Actor); + var actor = await signatureService.GetActor(activity.Actor); if (actor == null) return null; @@ -49,7 +37,7 @@ public InboxService( } //2. Check if already known - using var database = _databaseFactory.CreateDatabase(); + using var database = databaseFactory.CreateDatabase(); var follow = await database.FirstOrDefaultAsync("SELECT * FROM receivedActivityPubActivities WHERE Type = @0 AND Actor = @1", "Follow", activity.Actor); if (follow != null) @@ -68,14 +56,15 @@ public InboxService( //4. Create response var responseActivity = new Activity { - Id = $"{_webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{user.ActivityPubUserName()}/{activity.Type}/{receivedActivity.Id}", + Id = $"{webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{userName}/{activity.Type}/{receivedActivity.Id}", Type = "Accept", Actor = activity.Object as string ?? string.Empty, Object = activity }; - var keyInfo = await _signatureService.GetPrimaryKeyForUser(user); + + var keyInfo = await signatureService.GetPrimaryKeyForUser(userName, userId); - var response = await _singedRequestHandler.SendSingedPost(new Uri(actor.Inbox), keyInfo.Rsa, JsonSerializer.Serialize(responseActivity, new JsonSerializerOptions() + var response = await singedRequestHandler.SendSingedPost(new Uri(actor.Inbox), keyInfo.Rsa, JsonSerializer.Serialize(responseActivity, new JsonSerializerOptions { DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase @@ -99,7 +88,7 @@ public InboxService( throw new InvalidOperationException($"Can't undo {undoObject?.Type} at this time"); //It's an unfollow request - using var database = _databaseFactory.CreateDatabase(); + using var database = databaseFactory.CreateDatabase(); var follow = await database.FirstOrDefaultAsync("SELECT * FROM receivedActivityPubActivities WHERE Type = @0 AND Actor = @1", "Follow", undoObject.Actor); if (follow == null) diff --git a/src/uActivityPub/Services/OutboxService.cs b/src/uActivityPub/Services/OutboxService.cs index 52f4ad2..b107785 100644 --- a/src/uActivityPub/Services/OutboxService.cs +++ b/src/uActivityPub/Services/OutboxService.cs @@ -23,7 +23,7 @@ public OutboxService(IContentService contentService, IOptions? GetPublicOutbox(IUser user) + public OrderedCollection? GetPublicOutbox(string userName) { var settings = _uActivitySettingsService.GetAllSettings(); var contentListAlias = settings?.FirstOrDefault(s => s.Key == uActivitySettingKeys.ListContentTypeAlias); @@ -48,7 +48,7 @@ public OutboxService(IContentService contentService, IOptions(); - var actor = $"{_webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{user.ActivityPubUserName()}"; + var actor = $"{_webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{userName}"; foreach (var blogItem in blogItems) diff --git a/src/uActivityPub/Services/SignatureService.cs b/src/uActivityPub/Services/SignatureService.cs index a5f57b2..31b5d25 100644 --- a/src/uActivityPub/Services/SignatureService.cs +++ b/src/uActivityPub/Services/SignatureService.cs @@ -6,30 +6,19 @@ using uActivityPub.Helpers; using uActivityPub.Models; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Infrastructure.Persistence; namespace uActivityPub.Services; -public class SignatureService : ISignatureService +public class SignatureService( + IUmbracoDatabaseFactory databaseFactory, + IHttpClientFactory httpClientFactory, + IOptions webRoutingSettings) + : ISignatureService { - private readonly IUmbracoDatabaseFactory _databaseFactory; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IOptions _webRoutingSettings; - - public SignatureService( - IUmbracoDatabaseFactory databaseFactory, - IHttpClientFactory httpClientFactory, - IOptions webRoutingSettings) - { - _databaseFactory = databaseFactory; - _httpClientFactory = httpClientFactory; - _webRoutingSettings = webRoutingSettings; - } - public async Task GetActor(string actorUrl) { - var client = _httpClientFactory.CreateClient(); + var client = httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); var response = await client.GetAsync(actorUrl); @@ -42,11 +31,11 @@ public SignatureService( return actor; } - public async Task<(string KeyId, RSA Rsa)> GetPrimaryKeyForUser(IUser user) + public async Task<(string KeyId, RSA Rsa)> GetPrimaryKeyForUser(string userName, int userId) { - var database = _databaseFactory.CreateDatabase(); + var database = databaseFactory.CreateDatabase(); - var userKey = await database.FirstOrDefaultAsync("SELECT * FROM userKeys WHERE UserId = @0", user.Id); + var userKey = await database.FirstOrDefaultAsync("SELECT * FROM userKeys WHERE UserId = @0", userId); var rsa = RSA.Create(2048); if (userKey == null) @@ -55,7 +44,7 @@ public SignatureService( userKey = new UserKeysSchema { - Id = user.Id, + Id = userId, PublicKey = rsa.ExportRSAPublicKeyPem(), PrivateKey = rsa.ExportRSAPrivateKeyPem() }; @@ -67,6 +56,6 @@ public SignatureService( rsa = userKey.PrivateKey.GetRSAFromPem(); } - return ($"{_webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{user.ActivityPubUserName()}#main-key", rsa); + return ($"{webRoutingSettings.Value.UmbracoApplicationUrl}activitypub/actor/{userName}#main-key", rsa); } } \ No newline at end of file diff --git a/src/uActivityPub/uActivityPub.cs b/src/uActivityPub/uActivityPub.cs index 1a484b1..7990ccd 100644 --- a/src/uActivityPub/uActivityPub.cs +++ b/src/uActivityPub/uActivityPub.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Manifest; namespace uActivityPub; +[ExcludeFromCodeCoverage] public class StaticAssetsBoot : IComposer { public void Compose(IUmbracoBuilder builder) @@ -12,6 +14,7 @@ public void Compose(IUmbracoBuilder builder) } } +[ExcludeFromCodeCoverage] public static class USyncStaticAssetsExtensions { // ReSharper disable once UnusedMethodReturnValue.Global diff --git a/src/uActivityPub/uActivityPub.csproj b/src/uActivityPub/uActivityPub.csproj index aafb677..e369356 100644 --- a/src/uActivityPub/uActivityPub.csproj +++ b/src/uActivityPub/uActivityPub.csproj @@ -23,8 +23,8 @@ README.md uActivityPub uActivityPub - 1.0.13 - 1.0.13 + 1.0.14 + 1.0.14