Files
crm-wiki/backend/Wiki.Api.Tests/WorkspaceAccessTests.cs

223 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using Microsoft.Extensions.Configuration;
using Wiki.Api;
namespace Wiki.Api.Tests;
/// <summary>Проверка доступа к workspace: формат id и запрос к Supabase (RLS) — с подменённым HTTP.</summary>
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";
/// <summary>
/// Фальшивый Supabase: /rest/v1/workspaces отдаёт строку, если id в списке «видимых» пользователю (по умолчанию он
/// её участник); /rest/v1/access_grants — строки Grants, отфильтрованные как PostgREST (owner_uid, grantee_uid, shared).
/// </summary>
private sealed class FakeSupabase(params string[] visible) : HttpMessageHandler
{
public List<Uri> 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<HttpResponseMessage> 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<string, string?>
{
["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));
}
}