diff --git a/SgfDevs.Tests/PublicJobContractTests.cs b/SgfDevs.Tests/PublicJobContractTests.cs new file mode 100644 index 0000000..4d575d7 --- /dev/null +++ b/SgfDevs.Tests/PublicJobContractTests.cs @@ -0,0 +1,145 @@ +#nullable enable +using System.Reflection; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using SGFDevs.Controllers; +using SGFDevs.ViewModels; +using SgfDevs.Dev; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.PublishedModels; +using Xunit; + +namespace SgfDevs.Tests; + +public class PublicJobContractTests +{ + [Fact] + public async Task MissingPreviewAndProtectedAncestorsNeverReturnFakeSuccess() + { + foreach (var mode in new[] { "missing", "preview", "home", "company", "job", "wrong-parent" }) + { + var f = new Fixture(mode); + Assert.IsType((await new PublicJobController(f.Service).Get("custom-company", "custom-job")).Result); + if (mode is "missing" or "preview" or "home") Assert.Null(await f.Service.ListAsync()); + else Assert.Empty((await f.Service.ListAsync())!); + } + } + + [Fact] + public async Task ListingKeepsNativeDescendantOrderWithoutExpirySortOrLimitAndDetailUsesResolvedParent() + { + var f = new Fixture(); + var rows = (await f.Service.ListAsync())!; + Assert.Equal(["Z old job", "A new job"], rows.Select(j => j.Name)); + Assert.Equal("January 2, 2001", rows[0].Posted); + Assert.All(rows, j => { Assert.Equal("Parent company", j.CompanyName); Assert.Null(j.DescriptionHtml); Assert.Empty(j.Skills); }); + var job = (await f.Service.GetAsync("custom-company", "custom-job"))!; + Assert.Equal("/companies/custom-company/custom-job/", job.Path); + Assert.Equal("Remote", job.Location); + Assert.Equal("Full time", job.EmploymentType); + Assert.Equal("$90,000", job.Compensation); + Assert.Equal(["Visible skill", "Fallback skill", "Visible skill"], job.Skills); + Assert.Equal("

Public job description

", job.DescriptionHtml); + Assert.Equal("https://example.test/apply", job.ApplyUrl); + Assert.DoesNotContain("private-", JsonSerializer.Serialize(job)); + } + + [Fact] + public async Task InvalidSlugsMissingJobsAndUnsafeApplyKeepNarrowPublicContract() + { + var f = new Fixture(); + foreach (var bad in new[] { "", "../private", "a/b", "a%2fb", "a?preview=true", "a\\b" }) + Assert.Null(await f.Service.GetAsync("custom-company", bad)); + Assert.Null(await f.Service.GetAsync("custom-company", "missing-job")); + Assert.Null((await new Fixture("unsafe-apply").Service.GetAsync("custom-company", "custom-job"))!.ApplyUrl); + Assert.False(PublicJobService.IsJobPath("/companies/x/y//")); + Assert.DoesNotContain(typeof(PublicJobDto).GetProperties(), p => p.Name.Contains("Id") || p.Name.Contains("Key") || p.Name.Contains("Tags")); + } + + private sealed class Fixture + { + public PublicJobService Service { get; } + public Fixture(string mode = "") + { + var fallback = Proxy((_, _) => null); + var homeKey = Guid.NewGuid(); var pageKey = Guid.NewGuid(); var companyKey = Guid.NewGuid(); + var jobKey = Guid.NewGuid(); var nextKey = Guid.NewGuid(); + var home = Content("Home", homeKey, "-1,10", "home"); + var page = new Jobs(Content("Jobs", pageKey, "-1,10,20", "jobs"), fallback); + var company = new Company(Content("Parent company", companyKey, "-1,10,30,40", "company"), fallback); + Tag Skill(string name, string display, string path) => new(Content(name, Guid.NewGuid(), path, "tag", new() { ["displayName"] = display }), fallback); + var skill = Skill("Skill", "Visible skill", "-1,10,80"); + var hidden = Skill("private-name", "private-display", "-1,secret,81"); + var job = new Job(Content("Z old job", jobKey, "-1,10,30,40,50", "job", new() { + ["location"] = "Remote", ["employmentType"] = "Full time", ["compensation"] = "$90,000", + ["jobDescription"] = new HtmlEncodedString("

Public job description

"), + ["applyURL"] = mode == "unsafe-apply" ? "javascript:private()" : "https://example.test/apply", + ["skillTags"] = new[] { skill, hidden, Skill("Fallback skill", "", "-1,10,82"), skill }, + ["jobTags"] = new[] { "private-picker" }, ["email"] = "private-email" + }), fallback); + var next = new Job(Content("A new job", nextKey, "-1,10,30,40,51", "job"), fallback); + var documents = new Dictionary { [homeKey] = home, [pageKey] = page, [companyKey] = company, [jobKey] = job, [nextKey] = next }; + var routes = Proxy((_, a) => { + Assert.False((bool)a![3]!); + if (mode == "missing") return null; + return (string)a[0]! switch { "/jobs" => pageKey, "/companies/custom-company" => companyKey, + "/companies/custom-company/custom-job" => jobKey, _ => null }; + }); + var cache = Proxy((_, a) => { Assert.Equal(false, a![0]); return documents[(Guid)a[1]!]; }); + var context = Proxy((m, _) => m.Name switch { "get_Content" => cache, "get_InPreviewMode" => mode == "preview", _ => null }); + var accessor = Proxy((_, a) => { a![0] = context; return true; }); + var navigation = Proxy((m, a) => { + var key = (Guid)a![0]!; + switch (m.Name) { + case "TryGetParentKey": a[1] = key == homeKey ? null : key == pageKey || key == companyKey || mode == "wrong-parent" ? homeKey : companyKey; return true; + case "TryGetAncestorsKeys": a[1] = new[] { homeKey }; return true; + case "TryGetDescendantsKeys": a[1] = new[] { jobKey, nextKey }; return true; + case "TryGetDescendantsKeysOfType": a[2] = new[] { jobKey, nextKey }; return true; + default: throw new Exception("Unexpected navigation " + m.Name); + } + }); + var published = Proxy((_, a) => ((IEnumerable)a![0]!).Select(k => documents[k])); + var urls = Proxy((_, a) => { + var key = a![0] is IPublishedContent c ? c.Key : (Guid)a[0]!; + return key == companyKey ? "/companies/custom-company/" : key == jobKey ? "/companies/custom-company/custom-job/" : "/companies/custom-company/next-job/"; + }); + var protection = Proxy((_, a) => { + var path = (string)a![0]!; + return path.Contains("secret") || mode == "home" && path.Contains(",10") || + mode == "company" && path.Contains(",40") || mode == "job" && (path.EndsWith(",50") || path.EndsWith(",51")); + }); + var checker = Proxy((_, a) => { + Assert.False(((ClaimsPrincipal)a![1]!).Identity!.IsAuthenticated); + return Task.FromResult(PublicAccessStatus.NotLoggedIn); + }); + Service = new(routes, accessor, navigation, published, urls, new(checker, protection)); + } + private static IPublishedContent Content(string name, Guid key, string path, string alias, Dictionary? values = null) => + Proxy((m, a) => m.Name switch { + "get_Id" => 900, "get_Key" => key, "get_Path" => path, "get_Name" => name, + "get_CreateDate" => name.StartsWith("Z") ? new DateTime(2001, 1, 2) : new DateTime(2026, 9, 3), + "get_ItemType" => PublishedItemType.Content, + "get_ContentType" => Proxy((p, _) => p.Name switch { "get_Alias" => alias, "get_ItemType" => PublishedItemType.Content, _ => null }), + "GetProperty" => values is not null && values.TryGetValue((string)a![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/PublicJobController.cs b/SgfDevs/Controllers/PublicJobController.cs new file mode 100644 index 0000000..b36d0dd --- /dev/null +++ b/SgfDevs/Controllers/PublicJobController.cs @@ -0,0 +1,38 @@ +#nullable enable +using System.Collections.Generic; +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 PublicJobController(PublicJobService jobs) : ControllerBase +{ + [HttpGet("api/v1/public/jobs", Name = "PublicJobs_List")] + [ProducesResponseType>(StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(StatusCodes.Status404NotFound, "application/problem+json")] + public async Task>> List() + { + var result = await jobs.ListAsync(); + return result is null ? Missing() : Ok(result); + } + + [HttpGet("api/v1/public/jobs/{company}/{job}", Name = "PublicJobs_Get")] + [ProducesResponseType(StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(StatusCodes.Status404NotFound, "application/problem+json")] + public async Task> Get(string? company, string? job) + { + var result = await jobs.GetAsync(company, job); + return result is null ? Missing() : Ok(result); + } + + private NotFoundObjectResult Missing() => NotFound(new ProblemDetails + { + Title = "Job content not found.", Status = StatusCodes.Status404NotFound + }); +} diff --git a/SgfDevs/Dev/PublicJobService.cs b/SgfDevs/Dev/PublicJobService.cs new file mode 100644 index 0000000..9bb783e --- /dev/null +++ b/SgfDevs/Dev/PublicJobService.cs @@ -0,0 +1,96 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using SGFDevs.ViewModels; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.PublishedModels; +using Umbraco.Extensions; + +namespace SgfDevs.Dev; + +// Native Delivery has neither the legacy root-descendant order nor parent company +// fields. Leave raw job/tag pickers excluded and project only the Razor page values. +public class PublicJobService( + IDocumentUrlService documentUrls, IUmbracoContextAccessor contexts, + IDocumentNavigationQueryService navigation, IPublishedContentStatusFilteringService published, + IPublishedUrlProvider urls, PublicContentAccessGuard access) +{ + internal static bool IsJobPath(string? path) + { + var parts = path?.Split('/', StringSplitOptions.RemoveEmptyEntries); + return path is not null && path.StartsWith("/companies/", StringComparison.Ordinal) && + parts is { Length: 3 } && PublicGroupService.IsValidSlug(parts[1]) && + PublicGroupService.IsValidSlug(parts[2]) && + (path == $"/companies/{parts[1]}/{parts[2]}" || path == $"/companies/{parts[1]}/{parts[2]}/"); + } + + public async Task?> ListAsync() + { + var key = documentUrls.GetDocumentKeyByRoute("/jobs", null, null, false); + if (key is null || !contexts.TryGetUmbracoContext(out var context) || context.InPreviewMode || + context.Content?.GetById(false, key.Value) is not Jobs page || + !await access.AllowsAnonymousAsync(page)) return null; + var root = page.Root(navigation, published); + if (root is null || !await access.AllowsAnonymousAsync(root)) return null; + var result = new List(); + // Exactly the legacy Root().Descendants() sequence. No date/type filter, + // explicit sort, pagination or limit. + foreach (var job in root.Descendants(navigation, published)) + { + var dto = await ProjectAsync(job, false); + if (dto is not null) result.Add(dto); + } + return result; + } + + public async Task GetAsync(string? company, string? job) + { + if (!PublicGroupService.IsValidSlug(company) || !PublicGroupService.IsValidSlug(job) || + !contexts.TryGetUmbracoContext(out var context) || context.InPreviewMode) return null; + var companyKey = documentUrls.GetDocumentKeyByRoute($"/companies/{company}", null, null, false); + var jobKey = documentUrls.GetDocumentKeyByRoute($"/companies/{company}/{job}", null, null, false); + if (companyKey is null || jobKey is null || + context.Content?.GetById(false, companyKey.Value) is not Company parent || + context.Content.GetById(false, jobKey.Value) is not Job document || + document.Parent(navigation, published)?.Key != parent.Key || + !await access.AllowsAnonymousAsync(parent)) return null; + return await ProjectAsync(document, true); + } + + internal static string? SafeApplyUrl(string? value) + { + if (value is not null && value.StartsWith('/') && !value.StartsWith("//", StringComparison.Ordinal) && + !value.Contains('\\') && !value.Contains('%') && !System.Linq.Enumerable.Any(value, c => char.IsWhiteSpace(c) || char.IsControl(c))) + return value; + return PublicMemberService.GetSafeHttpUrl(value); + } + + internal async Task ProjectAsync(Job job, bool detail) + { + var company = job.Parent(navigation, published); + if (company is null || !await access.AllowsAnonymousAsync(company) || + !await access.AllowsAnonymousAsync(job)) return null; + var path = job.Url(urls); + var companyPath = company.Url(urls).TrimEnd('/'); + if (!IsJobPath(path) || !path.StartsWith(companyPath + "/", StringComparison.OrdinalIgnoreCase)) return null; + var skills = new List(); + if (detail) + foreach (var tag in job.SkillTags ?? []) + if (tag is Tag skill && await access.AllowsAnonymousAsync(skill)) + skills.Add(string.IsNullOrEmpty(skill.DisplayName) ? skill.Name : skill.DisplayName); + return new PublicJobDto + { + Name = job.Name, CompanyName = company.Name, Path = path, + Location = job.Location, EmploymentType = job.EmploymentType, Compensation = job.Compensation, + Posted = job.CreateDate.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture), + DescriptionHtml = detail ? job.JobDescription?.ToHtmlString() : null, + ApplyUrl = detail ? SafeApplyUrl(job.ApplyUrl) : null, + Skills = skills + }; + } +} diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index f6a722f..299a510 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -91,7 +91,9 @@ serverRole is ServerRole.Unknown || "PublicMember_Get" or "PublicGroups_List" or "PublicGroups_Get" or - "PublicLeadership_Get"; + "PublicLeadership_Get" or + "PublicJobs_List" or + "PublicJobs_Get"; }; }); builder.Services.AddOpenApiDocumentToUi("sgf-public-v1", "SGF public API v1"); @@ -148,6 +150,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/PublicJobDto.cs b/SgfDevs/ViewModels/PublicJobDto.cs new file mode 100644 index 0000000..0f3c622 --- /dev/null +++ b/SgfDevs/ViewModels/PublicJobDto.cs @@ -0,0 +1,18 @@ +#nullable enable +using System.Collections.Generic; + +namespace SGFDevs.ViewModels; + +public class PublicJobDto +{ + public string Name { get; init; } = string.Empty; + public string CompanyName { get; init; } = string.Empty; + public string Path { get; init; } = string.Empty; + public string? Location { get; init; } + public string? EmploymentType { get; init; } + public string? Compensation { get; init; } + public string Posted { get; init; } = string.Empty; + public string? DescriptionHtml { get; init; } + public string? ApplyUrl { get; init; } + public IReadOnlyList Skills { get; init; } = []; +} diff --git a/jobs-pages.md b/jobs-pages.md new file mode 100644 index 0000000..400f149 --- /dev/null +++ b/jobs-pages.md @@ -0,0 +1,7 @@ +# Jobs pages + +GET `/api/v1/public/jobs` follows the published `/jobs` page's `Root().Descendants()` sequence, matching the legacy table. No sort, expiry filter, pagination or result limit is added. The parent company's name and native published job URL are projected with location, employment type, compensation and the invariant `MMMM d, yyyy` creation date. + +GET `/api/v1/public/jobs/{company}/{job}` resolves both actual published routes through `IDocumentUrlService`, including custom URL names. The resolved job must have the resolved Company as its direct parent. Preview is disabled, and anonymous protection checks include content ancestor paths. Missing, mismatched or protected content returns 404. + +Native Delivery cannot supply this ordered descendant list with its parent-company shape. This service uses native navigation, published filtering, URL and model value services instead. Existing raw job/tag exclusions, company converters, protected content rules and Delivery API-key policy remain unchanged. Only detail includes description HTML, a safe apply URL and visible skill labels. No member, picker ID, unused jobTags or banner values are returned. The frontend sanitizes description HTML and apply links before rendering.