using System.Net; using Microsoft.Extensions.Configuration; using Wiki.Api; namespace Wiki.Api.Tests; /// Проверка доступа к workspace: формат id и запрос к Supabase (RLS) — с подменённым HTTP. public class WorkspaceAccessTests { private const string Uid = "11111111-1111-1111-1111-111111111111"; private static readonly WikiUser User = new(Uid, "u@test", "U", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds()); private const string Owner = "33333333-3333-3333-3333-333333333333"; /// /// Фальшивый Supabase: /rest/v1/workspaces отдаёт строку, если id в списке «видимых» пользователю (по умолчанию он /// её участник); /rest/v1/access_grants — строки Grants, отфильтрованные как PostgREST (owner_uid, grantee_uid, shared). /// private sealed class FakeSupabase(params string[] visible) : HttpMessageHandler { public List Requests { get; } = []; public string WsOwner { get; set; } = Owner; public string[] WsMembers { get; set; } = [Uid]; public List<(string Owner, string Grantee, string Projects, string Level, string Shared)> Grants { get; } = []; public HttpStatusCode GrantsStatus { get; set; } = HttpStatusCode.OK; protected override Task SendAsync(HttpRequestMessage req, CancellationToken ct) { Requests.Add(req.RequestUri!); var q = System.Web.HttpUtility.ParseQueryString(req.RequestUri!.Query); string? Eq(string k) => q[k]?.Replace("eq.", ""); string body; if (req.RequestUri.AbsolutePath == "/rest/v1/access_grants") { if (GrantsStatus != HttpStatusCode.OK) return Task.FromResult(new HttpResponseMessage(GrantsStatus)); var rows = Grants.Where(g => (Eq("owner_uid") is not { } o || g.Owner == o) && (Eq("grantee_uid") is not { } u || g.Grantee == u) && (Eq("shared") is not { } sh || g.Shared == sh)) .Select(g => $"{{\"owner_uid\":\"{g.Owner}\",\"projects\":\"{g.Projects}\",\"level\":\"{g.Level}\"}}"); body = "[" + string.Join(",", rows) + "]"; } else { var id = Eq("id") ?? ""; var members = string.Join(",", WsMembers.Select(m => $"\"{m}\"")); body = visible.Contains(id) ? $"[{{\"id\":\"{id}\",\"owner_uid\":\"{WsOwner}\",\"members\":[{members}]}}]" : "[]"; } return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) }); } } private sealed class Factory(HttpMessageHandler h) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(h, disposeHandler: false); } private static (SupabaseAuth Auth, FakeSupabase Fake) Make(params string[] visible) { var fake = new FakeSupabase(visible); var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Supabase:Url"] = "https://supabase.test", ["Supabase:PublishableKey"] = "sb_publishable_test", }).Build(); return (new SupabaseAuth(new Factory(fake), cfg), fake); } [Fact] public async Task CrmWorkspaceId_IsCheckedInSupabase_AndAllowedWhenVisible() { var (auth, fake) = Make("wsmv5l7d425re160"); Assert.True(await auth.CanAccessAsync(User, "token", "wsmv5l7d425re160", default)); var uri = Assert.Single(fake.Requests); Assert.Equal("/rest/v1/workspaces", uri.AbsolutePath); Assert.Contains("id=eq.wsmv5l7d425re160", uri.Query); } [Fact] public async Task CrmWorkspaceId_DeniedWhenRlsHidesRow() { var (auth, fake) = Make(); Assert.False(await auth.CanAccessAsync(User, "token", "wsmv5l7d425re160", default)); Assert.Single(fake.Requests); // формат не отсекается — решает Supabase } [Fact] public async Task Uuid_StillSupported() { const string id = "22222222-2222-2222-2222-222222222222"; var (auth, fake) = Make(id); Assert.True(await auth.CanAccessAsync(User, "token", id, default)); Assert.Single(fake.Requests); } [Fact] public async Task Shared_And_OwnPersonal_NoRequest() { var (auth, fake) = Make(); Assert.True(await auth.CanAccessAsync(User, "token", "shared", default)); Assert.True(await auth.CanAccessAsync(User, "token", $"personal:{Uid}", default)); Assert.False(await auth.CanAccessAsync(User, "token", "personal:someone-else", default)); // не uuid — без запроса Assert.Empty(fake.Requests); } [Theory] [InlineData("")] [InlineData("ws x")] [InlineData("ws,mv5")] [InlineData("x' or 1=1")] [InlineData("ws&select=*")] [InlineData("ws.mv5")] public async Task UnsafeIds_RejectedBeforeSupabase(string id) { var (auth, fake) = Make(id); Assert.False(await auth.CanAccessAsync(User, "token", id, default)); Assert.Empty(fake.Requests); } [Fact] public async Task TooLongId_Rejected() { var (auth, fake) = Make(); Assert.False(await auth.CanAccessAsync(User, "token", new string('a', 65), default)); Assert.Empty(fake.Requests); } [Fact] public async Task Result_IsCachedPerUserAndWorkspace() { var (auth, fake) = Make("wsmv5l7d425re160"); Assert.True(await auth.CanAccessAsync(User, "token", "wsmv5l7d425re160", default)); Assert.True(await auth.CanAccessAsync(User, "token", "wsmv5l7d425re160", default)); Assert.Single(fake.Requests); } // ------------------------------------------------------------ чужое личное: access_grants [Fact] public async Task OthersPersonal_DeniedWithoutGrant() { var (auth, fake) = Make(); Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", $"personal:{Owner}", default)); var uri = Assert.Single(fake.Requests); Assert.Equal("/rest/v1/access_grants", uri.AbsolutePath); Assert.Contains($"owner_uid=eq.{Owner}", uri.Query); Assert.Contains($"grantee_uid=eq.{Uid}", uri.Query); } [Theory] [InlineData("all", "full", SupabaseAuth.Access.Full)] [InlineData("all", "read", SupabaseAuth.Access.Read)] [InlineData("selected", "full", SupabaseAuth.Access.None)] // вибрані проєкти вики не открывают [InlineData("none", "full", SupabaseAuth.Access.None)] // только план — тоже public async Task OthersPersonal_ByGrantProjects(string projects, string level, SupabaseAuth.Access expected) { var (auth, fake) = Make(); fake.Grants.Add((Owner, Uid, projects, level, "none")); Assert.Equal(expected, await auth.AccessAsync(User, "token", $"personal:{Owner}", default)); } [Fact] public async Task OthersPersonal_GrantToSomeoneElse_DoesNotCount() { var (auth, fake) = Make(); fake.Grants.Add((Owner, "44444444-4444-4444-4444-444444444444", "all", "full", "none")); Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", $"personal:{Owner}", default)); } [Fact] public async Task OthersPersonal_SupabaseError_DeniedAndNotCached() { var (auth, fake) = Make(); fake.Grants.Add((Owner, Uid, "all", "full", "none")); fake.GrantsStatus = HttpStatusCode.InternalServerError; Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", $"personal:{Owner}", default)); fake.GrantsStatus = HttpStatusCode.OK; Assert.Equal(SupabaseAuth.Access.Full, await auth.AccessAsync(User, "token", $"personal:{Owner}", default)); } [Theory] [InlineData("personal:")] [InlineData("personal:x&owner_uid=neq.0")] [InlineData("personal:33333333-3333-3333-3333-333333333333&select=*")] public async Task OthersPersonal_NonUuidOwner_RejectedBeforeSupabase(string id) { var (auth, fake) = Make(); Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", id, default)); Assert.Empty(fake.Requests); } // ------------------------------------------------------------ custom: участник или через access_grants.shared [Fact] public async Task Custom_OwnerOrMember_Full() { var (auth, fake) = Make("wsmv5l7d425re160", "wsmv5l7d425re161"); Assert.Equal(SupabaseAuth.Access.Full, await auth.AccessAsync(User, "token", "wsmv5l7d425re160", default)); // участник fake.WsOwner = Uid; fake.WsMembers = []; Assert.Equal(SupabaseAuth.Access.Full, await auth.AccessAsync(User, "token", "wsmv5l7d425re161", default)); // владелец Assert.DoesNotContain(fake.Requests, u => u.AbsolutePath == "/rest/v1/access_grants"); } [Theory] [InlineData("full", SupabaseAuth.Access.Full)] [InlineData("read", SupabaseAuth.Access.Read)] public async Task Custom_VisibleViaSharedGrant_LevelFromGrant(string level, SupabaseAuth.Access expected) { var (auth, fake) = Make("wsmv5l7d425re160"); fake.WsMembers = ["55555555-5555-5555-5555-555555555555"]; fake.Grants.Add(("55555555-5555-5555-5555-555555555555", Uid, "none", level, "all")); Assert.Equal(expected, await auth.AccessAsync(User, "token", "wsmv5l7d425re160", default)); } [Fact] public async Task Custom_VisibleButNotMemberAndNoGrant_Denied() { var (auth, fake) = Make("wsmv5l7d425re160"); fake.WsMembers = []; fake.Grants.Add((Owner, Uid, "all", "full", "none")); // личное открыто, спільні — нет Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", "wsmv5l7d425re160", default)); } }