Workspace: принимать текстовые id CRM (wsmv5l7d425re160), тесты проверки доступа
This commit is contained in:
25
backend/Wiki.Api.Tests/Wiki.Api.Tests.csproj
Normal file
25
backend/Wiki.Api.Tests/Wiki.Api.Tests.csproj
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wiki.Api\Wiki.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
110
backend/Wiki.Api.Tests/WorkspaceAccessTests.cs
Normal file
110
backend/Wiki.Api.Tests/WorkspaceAccessTests.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
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());
|
||||
|
||||
/// <summary>Фальшивый Supabase: отвечает на /rest/v1/workspaces строкой, если id в списке «видимых» пользователю.</summary>
|
||||
private sealed class FakeSupabase(params string[] visible) : HttpMessageHandler
|
||||
{
|
||||
public List<Uri> Requests { get; } = [];
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
|
||||
{
|
||||
Requests.Add(req.RequestUri!);
|
||||
var q = System.Web.HttpUtility.ParseQueryString(req.RequestUri!.Query);
|
||||
var id = (q["id"] ?? "").Replace("eq.", "");
|
||||
var body = visible.Contains(id) ? $"[{{\"id\":\"{id}\"}}]" : "[]";
|
||||
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));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,10 @@ public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration
|
||||
|
||||
public const string Shared = "shared";
|
||||
|
||||
[GeneratedRegex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")]
|
||||
private static partial Regex UuidRx();
|
||||
// id пользовательского workspace CRM — текст: 'ws' + base36 (createWorkspace в CRM, напр. wsmv5l7d425re160);
|
||||
// принимаем и любые похожие текстовые id (в т. ч. uuid). Только безопасные символы — значение идёт в фильтр PostgREST.
|
||||
[GeneratedRegex("^[A-Za-z0-9_-]{1,64}$")]
|
||||
private static partial Regex CustomWsRx();
|
||||
|
||||
public enum Fail { None, BadToken, Expired, Rejected, Unavailable }
|
||||
|
||||
@@ -76,23 +78,24 @@ public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует и проверяет workspace. id из клиента не доверяем:
|
||||
/// shared — для всех, personal:<uid> — только владельцу, <uuid> (custom) — по таблице workspaces с RLS.
|
||||
/// shared — для всех, personal:<uid> — только владельцу, остальные (custom, напр. wsmv5l7d425re160) —
|
||||
/// запросом к таблице workspaces Supabase с токеном пользователя: RLS отдаёт строку, только если он владелец или участник.
|
||||
/// </summary>
|
||||
public async Task<bool> CanAccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct)
|
||||
{
|
||||
if (workspaceId == Shared) return true;
|
||||
if (workspaceId.StartsWith("personal:", StringComparison.Ordinal))
|
||||
return workspaceId == $"personal:{user.Id}";
|
||||
if (!UuidRx().IsMatch(workspaceId)) return false;
|
||||
if (!CustomWsRx().IsMatch(workspaceId)) return false;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var key = user.Id + "|" + workspaceId.ToLowerInvariant();
|
||||
var key = user.Id + "|" + workspaceId;
|
||||
if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Ok;
|
||||
bool ok;
|
||||
try
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get,
|
||||
$"{_url}/rest/v1/workspaces?id=eq.{workspaceId.ToLowerInvariant()}&select=id");
|
||||
$"{_url}/rest/v1/workspaces?id=eq.{Uri.EscapeDataString(workspaceId)}&select=id");
|
||||
req.Headers.Add("apikey", _key);
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
|
||||
|
||||
Reference in New Issue
Block a user