Workspace: принимать текстовые id CRM (wsmv5l7d425re160), тесты проверки доступа

This commit is contained in:
crm-wiki agent
2026-10-13 15:01:19 +00:00
parent 1104838d12
commit 9f2e91bfc1
4 changed files with 147 additions and 8 deletions

View 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>

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

View File

@@ -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:&lt;uid&gt; — только владельцу, &lt;uuid&gt; (custom) — по таблице workspaces с RLS.
/// shared — для всех, personal:&lt;uid&gt; — только владельцу, остальные (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);