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));
}
}