diff --git a/SgfDevs.Tests/PublicLeadershipContractTests.cs b/SgfDevs.Tests/PublicLeadershipContractTests.cs new file mode 100644 index 0000000..67d7d0c --- /dev/null +++ b/SgfDevs.Tests/PublicLeadershipContractTests.cs @@ -0,0 +1,145 @@ +#nullable enable +using System.Reflection; +using System.Security.Claims; +using System.Text.Json; +using SGFDevs.Controllers; +using SGFDevs.ViewModels; +using SgfDevs.Dev; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.PublishedModels; +using Xunit; + +namespace SgfDevs.Tests; + +public class PublicLeadershipContractTests +{ + [Fact] + public async Task MissingProtectedAncestorOrPreviewPageReturns404BeforeMemberLookup() + { + foreach (var mode in new[] { "missing", "protected", "preview" }) + { + var fixture = new Fixture(mode); + var response = await new PublicLeadershipController(fixture.Service).Get(); + Assert.IsType(response.Result); + Assert.Equal(0, fixture.MemberLookups); + } + } + + [Fact] + public async Task OnlySelectedMembersKeepPickerOrderAndHistoryTitleWithoutInventedYears() + { + var fixture = new Fixture(); + var dto = Assert.IsType(await fixture.Service.GetAsync()); + Assert.Equal(["Z public name", "A public name"], dto.Officers.Select(m => m.Name)); + Assert.Equal(["A public name", "Z public name"], dto.BoardOfDirectors.Select(m => m.Name)); + Assert.Equal(["Z public name", "A public name", "Z public name"], dto.History.Select(m => m.Name)); + Assert.Equal("President", dto.Officers[0].OfficerTitle); + Assert.Equal("

Public officer biography

", dto.Officers[0].OfficerBio); + Assert.Equal("Jane", dto.Officers[0].Username); + Assert.Equal("/images/pipey.jpg", dto.Officers[0].ImageUrl); + Assert.Null(dto.BoardOfDirectors[0].OfficerTitle); + Assert.All(dto.BoardOfDirectors.Concat(dto.History), m => Assert.Null(m.OfficerBio)); + Assert.Equal("", dto.History[1].OfficerTitle); // renderer retains legacy Board Member fallback + Assert.DoesNotContain("private-", JsonSerializer.Serialize(dto)); + } + + [Fact] + public async Task ProtectedMemberAnchorOrMissingPublicMemberOmitsSelectionsAndContractIsExplicit() + { + foreach (var mode in new[] { "anchor", "member" }) + { + var dto = Assert.IsType(await new Fixture(mode).Service.GetAsync()); + Assert.Empty(dto.Officers); + Assert.Empty(dto.BoardOfDirectors); + Assert.Empty(dto.History); + } + using var json = JsonDocument.Parse(JsonSerializer.Serialize(await new Fixture().Service.GetAsync(), JsonSerializerOptions.Web)); + Assert.Equal(["boardOfDirectors", "history", "officers"], json.RootElement.EnumerateObject().Select(p => p.Name).Order()); + Assert.Equal(["imageUrl", "name", "officerBio", "officerTitle", "username"], + json.RootElement.GetProperty("officers")[0].EnumerateObject().Select(p => p.Name).Order()); + } + + private sealed class Fixture + { + public PublicLeadershipService Service { get; } + public int MemberLookups { get; private set; } + public Fixture(string mode = "") + { + var fallback = Proxy((_, _) => null); + IPublishedContent Selected(string name, string title, string path = "-1,900", PublishedItemType type = PublishedItemType.Member) => + Content(new() { ["username"] = "Jane", ["officerTitle"] = title, + ["officerBio"] = new HtmlEncodedString("

Public officer biography

"), + ["availableForHire"] = false, ["availableForContractWork"] = false, + ["email"] = "private-email", ["securityStamp"] = "private-security" }, name, path, type); + var z = Selected("Z public name", "President"); + var a = Selected("A public name", ""); + var protectedMember = Selected("private-protected", "private-title", "-1,secret,900"); + var wrongType = Selected("private-document", "private-title", type: PublishedItemType.Content); + var page = new Leadership(Content(new() { + ["officers"] = new[] { z, protectedMember, wrongType, a }, + ["boardOfDirectors"] = new[] { a, z }, ["history"] = new[] { z, a, z } + }, "Leadership", "-1,10,20"), fallback); + var anchor = Content([], "Member", "-1,10,30"); + var pageKey = Guid.NewGuid(); + var anchorKey = Guid.NewGuid(); + var routes = Proxy((_, args) => { + Assert.False((bool)args![3]!); + return (string)args[0]! == "/member" ? anchorKey : mode == "missing" ? null : pageKey; + }); + var cache = Proxy((_, args) => { + if (args!.Contains(pageKey)) { + Assert.Equal(false, args![0]); // explicitly published cache, never preview + return page; + } + return anchor; + }); + var context = Proxy((m, _) => m.Name switch { + "get_Content" => cache, "get_InPreviewMode" => mode == "preview", _ => null + }); + var accessor = Proxy((_, args) => { args![0] = context; return true; }); + var protection = Proxy((_, args) => + ((string)args![0]!).Contains("secret") || mode == "protected" && (string)args[0]! == "-1,10,20" || + mode == "anchor" && (string)args[0]! == "-1,10,30"); + var checker = Proxy((_, args) => { + Assert.False(((ClaimsPrincipal)args![1]!).Identity!.IsAuthenticated); + return Task.FromResult(PublicAccessStatus.NotLoggedIn); + }); + var guard = new PublicContentAccessGuard(checker, protection); + var converter = new MemberConverter(fallback, Proxy((_, _) => null)); + var manager = Proxy((m, _) => { + if (m.Name == "FindByNameAsync") { + MemberLookups++; + return Task.FromResult(mode == "member" ? null : new MemberIdentityUser { UserName = "Jane" }); + } + return z; + }); + var members = new PublicMemberService(manager, routes, accessor, converter, new MemberTagDisplayService(), guard); + Service = new PublicLeadershipService(routes, accessor, guard, converter, members); + } + private static IPublishedContent Content(Dictionary values, string name, string path, + PublishedItemType type = PublishedItemType.Content) => Proxy((m, args) => m.Name switch { + "get_Id" => 900, "get_Path" => path, "get_Name" => name, "get_ItemType" => type, + "get_ContentType" => Proxy((p, _) => p.Name == "get_Alias" ? Member.ModelTypeAlias : null), + "GetProperty" => values.TryGetValue((string)args![0]!, out var v) ? Proxy((p, _) => p.Name switch { + "GetValue" => v, "HasValue" => v is not null, _ => null + }) : null, _ => null + }); + } + public class InterfaceProxy : DispatchProxy + { + public Func Handler { get; set; } = null!; + protected override object? Invoke(MethodInfo? method, object?[]? args) => Handler(method!, args) ?? + (method!.ReturnType.IsValueType ? Activator.CreateInstance(method.ReturnType) : null); + } + private static T Proxy(Func handler) where T : class { + var proxy = DispatchProxy.Create(); + ((InterfaceProxy)(object)proxy).Handler = handler; + return proxy; + } +} diff --git a/SgfDevs/Controllers/PublicLeadershipController.cs b/SgfDevs/Controllers/PublicLeadershipController.cs new file mode 100644 index 0000000..acc3cff --- /dev/null +++ b/SgfDevs/Controllers/PublicLeadershipController.cs @@ -0,0 +1,26 @@ +#nullable enable +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using SGFDevs.ViewModels; +using SgfDevs.Dev; + +namespace SGFDevs.Controllers; + +[ApiController] +[AllowAnonymous] +public class PublicLeadershipController(PublicLeadershipService leadership) : ControllerBase +{ + [HttpGet("api/v1/public/leadership", Name = "PublicLeadership_Get")] + [ProducesResponseType(StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(StatusCodes.Status404NotFound, "application/problem+json")] + public async Task> Get() + { + var result = await leadership.GetAsync(); + return result is null ? NotFound(new ProblemDetails + { + Title = "Leadership content not found.", Status = StatusCodes.Status404NotFound + }) : Ok(result); + } +} diff --git a/SgfDevs/Dev/PublicLeadershipService.cs b/SgfDevs/Dev/PublicLeadershipService.cs new file mode 100644 index 0000000..1d62e9e --- /dev/null +++ b/SgfDevs/Dev/PublicLeadershipService.cs @@ -0,0 +1,60 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading.Tasks; +using SGFDevs.ViewModels; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.PublishedModels; +using Umbraco.Extensions; + +namespace SgfDevs.Dev; + +// Leadership stays excluded from Delivery. Only these published selections grant +// access to officer fields; the public member/profile contract stays unchanged. +public class PublicLeadershipService( + IDocumentUrlService documentUrls, + IUmbracoContextAccessor contextAccessor, + PublicContentAccessGuard accessGuard, + MemberConverter memberConverter, + PublicMemberService publicMembers) +{ + public async Task GetAsync() + { + var key = documentUrls.GetDocumentKeyByRoute("/about/leadership", null, null, false); + if (key is null || !contextAccessor.TryGetUmbracoContext(out var context) || context.InPreviewMode) return null; + if (context.Content?.GetById(false, key.Value) is not Leadership page || + !await accessGuard.AllowsAnonymousAsync(page)) return null; + return new PublicLeadershipDto + { + Officers = await ProjectAsync(page.Officers, includeTitle: true, includeBio: true), + BoardOfDirectors = await ProjectAsync(page.BoardOfDirectors, includeTitle: false, includeBio: false), + History = await ProjectAsync(page.History, includeTitle: true, includeBio: false) + }; + } + + private async Task> ProjectAsync( + IEnumerable? selections, bool includeTitle, bool includeBio) + { + var result = new List(); + // Retain picker order, including repeated selections. Legacy history has no year field. + foreach (var picked in selections ?? []) + { + if (picked.ItemType != PublishedItemType.Member || picked.ContentType.Alias != Member.ModelTypeAlias || + !await accessGuard.AllowsAnonymousAsync(picked)) continue; + var member = memberConverter.FromContent(picked); + // Reuse published member lookup, username normalization and anonymous /member anchor protection. + var profile = await publicMembers.GetAsync(member.Username); + if (profile is null) continue; + result.Add(new PublicLeadershipMemberDto + { + Name = member.Name, + Username = profile.Username, + ImageUrl = PublicHomeBuilder.GetSafePathOrHttpUrl(member.ProfileImage?.Url()) ?? "/images/pipey.jpg", + OfficerTitle = includeTitle ? member.OfficerTitle : null, + OfficerBio = includeBio ? member.OfficerBio?.ToHtmlString() : null + }); + } + return result; + } +} diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index 0646734..f6a722f 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -90,7 +90,8 @@ serverRole is ServerRole.Unknown || "PublicHome_Get" or "PublicMember_Get" or "PublicGroups_List" or - "PublicGroups_Get"; + "PublicGroups_Get" or + "PublicLeadership_Get"; }; }); builder.Services.AddOpenApiDocumentToUi("sgf-public-v1", "SGF public API v1"); @@ -146,6 +147,7 @@ serverRole is ServerRole.Unknown || builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SgfDevs/ViewModels/PublicLeadershipDto.cs b/SgfDevs/ViewModels/PublicLeadershipDto.cs new file mode 100644 index 0000000..92268d9 --- /dev/null +++ b/SgfDevs/ViewModels/PublicLeadershipDto.cs @@ -0,0 +1,20 @@ +#nullable enable +using System.Collections.Generic; + +namespace SGFDevs.ViewModels; + +public class PublicLeadershipDto +{ + public IReadOnlyList Officers { get; set; } = []; + public IReadOnlyList BoardOfDirectors { get; set; } = []; + public IReadOnlyList History { get; set; } = []; +} + +public class PublicLeadershipMemberDto +{ + public string Name { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string ImageUrl { get; set; } = string.Empty; + public string? OfficerTitle { get; set; } + public string? OfficerBio { get; set; } +} diff --git a/leadership-page.md b/leadership-page.md new file mode 100644 index 0000000..b01154d --- /dev/null +++ b/leadership-page.md @@ -0,0 +1,7 @@ +# Public leadership page + +GET `/api/v1/public/leadership` resolves the published `/about/leadership` route as the existing Leadership model. Missing, protected or preview content returns 404. + +The projection reads only the model's officers, boardOfDirectors and history selections, in picker order. It uses MemberConverter and the existing public profile lookup for member publication, normalized usernames and anonymous member-anchor access. Non-member and protected selections are omitted. Names and uncropped images match the legacy view. Officer titles are included for officers and history; biographies only for officers. History has no year field in the native model or legacy view. + +The leadership alias remains excluded from Delivery. No picker values, member IDs, keys, email, security fields or general member properties enter this response. The frontend must sanitize public biographies and map images through its approved media helper.