Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions SgfDevs.Tests/PublicLeadershipContractTests.cs
Original file line number Diff line number Diff line change
@@ -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<NotFoundObjectResult>(response.Result);
Assert.Equal(0, fixture.MemberLookups);
}
}

[Fact]
public async Task OnlySelectedMembersKeepPickerOrderAndHistoryTitleWithoutInventedYears()
{
var fixture = new Fixture();
var dto = Assert.IsType<PublicLeadershipDto>(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("<p>Public officer biography</p>", 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<PublicLeadershipDto>(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<IPublishedValueFallback>((_, _) => null);
IPublishedContent Selected(string name, string title, string path = "-1,900", PublishedItemType type = PublishedItemType.Member) =>
Content(new() { ["username"] = "Jane", ["officerTitle"] = title,
["officerBio"] = new HtmlEncodedString("<p>Public officer biography</p>"),
["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<IDocumentUrlService>((_, args) => {
Assert.False((bool)args![3]!);
return (string)args[0]! == "/member" ? anchorKey : mode == "missing" ? null : pageKey;
});
var cache = Proxy<IPublishedContentCache>((_, args) => {
if (args!.Contains(pageKey)) {
Assert.Equal(false, args![0]); // explicitly published cache, never preview
return page;
}
return anchor;
});
var context = Proxy<IUmbracoContext>((m, _) => m.Name switch {
"get_Content" => cache, "get_InPreviewMode" => mode == "preview", _ => null
});
var accessor = Proxy<IUmbracoContextAccessor>((_, args) => { args![0] = context; return true; });
var protection = Proxy<IPublicContentProtectionLookup>((_, 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<IPublicAccessChecker>((_, 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<IPublishedMemberCache>((_, _) => null));
var manager = Proxy<IMemberManager>((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<string, object?> values, string name, string path,
PublishedItemType type = PublishedItemType.Content) => Proxy<IPublishedContent>((m, args) => m.Name switch {
"get_Id" => 900, "get_Path" => path, "get_Name" => name, "get_ItemType" => type,
"get_ContentType" => Proxy<IPublishedContentType>((p, _) => p.Name == "get_Alias" ? Member.ModelTypeAlias : null),
"GetProperty" => values.TryGetValue((string)args![0]!, out var v) ? Proxy<IPublishedProperty>((p, _) => p.Name switch {
"GetValue" => v, "HasValue" => v is not null, _ => null
}) : null, _ => null
});
}
public class InterfaceProxy : DispatchProxy
{
public Func<MethodInfo, object?[]?, object?> 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<T>(Func<MethodInfo, object?[]?, object?> handler) where T : class {
var proxy = DispatchProxy.Create<T, InterfaceProxy>();
((InterfaceProxy)(object)proxy).Handler = handler;
return proxy;
}
}
26 changes: 26 additions & 0 deletions SgfDevs/Controllers/PublicLeadershipController.cs
Original file line number Diff line number Diff line change
@@ -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<PublicLeadershipDto>(StatusCodes.Status200OK, "application/json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound, "application/problem+json")]
public async Task<ActionResult<PublicLeadershipDto>> Get()
{
var result = await leadership.GetAsync();
return result is null ? NotFound(new ProblemDetails
{
Title = "Leadership content not found.", Status = StatusCodes.Status404NotFound
}) : Ok(result);
}
}
60 changes: 60 additions & 0 deletions SgfDevs/Dev/PublicLeadershipService.cs
Original file line number Diff line number Diff line change
@@ -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<PublicLeadershipDto?> 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<IReadOnlyList<PublicLeadershipMemberDto>> ProjectAsync(
IEnumerable<IPublishedContent>? selections, bool includeTitle, bool includeBio)
{
var result = new List<PublicLeadershipMemberDto>();
// 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;
}
}
4 changes: 3 additions & 1 deletion SgfDevs/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -146,6 +147,7 @@ serverRole is ServerRole.Unknown ||
builder.Services.AddScoped<PublicHomeService>();
builder.Services.AddScoped<PublicMemberService>();
builder.Services.AddScoped<PublicGroupService>();
builder.Services.AddScoped<PublicLeadershipService>();
builder.Services.AddScoped<MemberProfileChoices>();
builder.Services.AddScoped<MemberAvatarService>();
builder.Services.AddScoped<DirectoryHelper>();
Expand Down
20 changes: 20 additions & 0 deletions SgfDevs/ViewModels/PublicLeadershipDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#nullable enable
using System.Collections.Generic;

namespace SGFDevs.ViewModels;

public class PublicLeadershipDto
{
public IReadOnlyList<PublicLeadershipMemberDto> Officers { get; set; } = [];
public IReadOnlyList<PublicLeadershipMemberDto> BoardOfDirectors { get; set; } = [];
public IReadOnlyList<PublicLeadershipMemberDto> 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; }
}
7 changes: 7 additions & 0 deletions leadership-page.md
Original file line number Diff line number Diff line change
@@ -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.
Loading