< Summary

Information
Class: Chronicis.Api.Services.ReadAccessPolicyService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ReadAccessPolicyService.cs
Line coverage
100%
Covered lines: 26
Uncovered lines: 0
Coverable lines: 26
Total lines: 81
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ReadAccessPolicyService.cs

#LineLine coverage
 1using Chronicis.Shared.Enums;
 2using Chronicis.Shared.Models;
 3
 4namespace Chronicis.Api.Services;
 5
 6public sealed class ReadAccessPolicyService : IReadAccessPolicyService
 7{
 8    public IQueryable<World> ApplyPublicWorldFilter(IQueryable<World> worlds)
 9    {
 110        return worlds.Where(w => w.IsPublic);
 11    }
 12
 13    public IQueryable<World> ApplyAuthenticatedWorldFilter(IQueryable<World> worlds, Guid userId)
 14    {
 515        return worlds.Where(w => w.Members.Any(m => m.UserId == userId));
 16    }
 17
 18    public IQueryable<Article> ApplyPublicVisibilityFilter(IQueryable<Article> articles)
 19    {
 420        return articles.Where(a => a.Visibility == ArticleVisibility.Public);
 21    }
 22
 23    public IQueryable<Article> ApplyPublicArticleFilter(IQueryable<Article> articles, Guid worldId)
 24    {
 125        return ApplyPublicVisibilityFilter(articles)
 126            .Where(a => a.WorldId == worldId);
 27    }
 28
 29    public IQueryable<Article> ApplyTutorialArticleFilter(IQueryable<Article> articles)
 30    {
 231        return articles.Where(a => a.Type == ArticleType.Tutorial && a.WorldId == Guid.Empty);
 32    }
 33
 34    public IQueryable<Article> ApplyAuthenticatedWorldArticleFilter(IQueryable<Article> articles, Guid userId)
 35    {
 6036        return articles
 6037            .Where(a => a.Type != ArticleType.Tutorial && a.WorldId != Guid.Empty)
 6038            .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == userId))
 6039            .Where(a => a.Visibility != ArticleVisibility.Private || a.CreatedBy == userId);
 40    }
 41
 42    public IQueryable<Article> ApplyAuthenticatedReadableArticleFilter(IQueryable<Article> articles, Guid userId)
 43    {
 44        // Single predicate instead of Concat of two filtered queries.
 45        //
 46        // Why: EF Core translates IQueryable.Concat/Union/Except into SQL set operations (UNION ALL / UNION / EXCEPT),
 47        // and entities returned from set operations are materialized as UNTRACKED, regardless of the underlying
 48        // DbSet's tracking behavior. That caused writes (e.g., ArticlesController.UpdateArticle) that read an
 49        // entity through this filter, mutated it, and called SaveChangesAsync to silently no-op because the change
 50        // tracker never saw the entity as Modified.
 51        //
 52        // A single .Where(...) predicate preserves the same semantic matrix (tutorials + membership-scoped world
 53        // articles respecting private ownership) while keeping returned entities tracked.
 1954        return articles.Where(a =>
 1955            (a.Type == ArticleType.Tutorial && a.WorldId == Guid.Empty)
 1956            ||
 1957            (a.Type != ArticleType.Tutorial
 1958                && a.WorldId != Guid.Empty
 1959                && a.World != null
 1960                && a.World.Members.Any(m => m.UserId == userId)
 1961                && (a.Visibility != ArticleVisibility.Private || a.CreatedBy == userId)));
 62    }
 63
 64    public IQueryable<Campaign> ApplyAuthenticatedCampaignFilter(IQueryable<Campaign> campaigns, Guid userId)
 65    {
 366        return campaigns
 367            .Where(c => c.World != null && c.World.Members.Any(m => m.UserId == userId));
 68    }
 69
 70    public IQueryable<Arc> ApplyAuthenticatedArcFilter(IQueryable<Arc> arcs, Guid userId)
 71    {
 372        return arcs
 373            .Where(a => a.Campaign != null
 374                        && a.Campaign.World != null
 375                        && a.Campaign.World.Members.Any(m => m.UserId == userId));
 76    }
 77
 378    public bool CanReadWorld(bool isPublic, bool userIsMember) => isPublic || userIsMember;
 79
 280    public bool CanReadMemberScopedEntity(bool userIsMember) => userIsMember;
 81}