223 lines
10 KiB
C#
223 lines
10 KiB
C#
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));
|
||
}
|
||
}
|