Files
crm-calls/calls/backend/Calls.Api.Tests/ApiTests.cs

195 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 System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Calls.Api.Tests;
/// <summary>
/// HTTP API с подменённым Supabase: два пользователя, у каждого свой custom-workspace (RLS).
/// Проверка 2 спецификации на уровне API: пользователь другого workspace в комнату не попадает.
/// </summary>
public sealed class ApiTests : IClassFixture<ApiTests.App>
{
public const string Anna = "tok-anna", Carl = "tok-carl";
public const string AnnaUid = "11111111-1111-1111-1111-111111111111", CarlUid = "22222222-2222-2222-2222-222222222222";
private readonly App _app;
public ApiTests(App app) => _app = app;
/// <summary>Фальшивый Supabase: /auth/v1/user по токену, /rest/v1/workspaces с «RLS».</summary>
public sealed class FakeSupabase : HttpMessageHandler
{
private static readonly Dictionary<string, (string Uid, string Email, string Name, string[] Ws)> Users = new()
{
[Anna] = (AnnaUid, "anna@test", "Анна", ["wsteam0000001"]),
[Carl] = (CarlUid, "carl@test", "Карл", ["wsother000001"]),
};
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
{
var bearer = req.Headers.Authorization?.Parameter ?? "";
var key = Users.Keys.FirstOrDefault(k => bearer.EndsWith("." + k));
if (key == null) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));
var u = Users[key];
var path = req.RequestUri!.AbsolutePath;
string body;
if (path == "/auth/v1/user")
body = JsonSerializer.Serialize(new { id = u.Uid, aud = "authenticated", email = u.Email, user_metadata = new { name = u.Name } });
else if (path == "/rest/v1/workspaces")
{
var q = System.Web.HttpUtility.ParseQueryString(req.RequestUri.Query);
var id = q["id"]?.Replace("eq.", "");
var rows = u.Ws.Where(w => id == null || w == id)
.Select(w => new { id = w, kind = "custom", title = "Команда " + w[^1], owner_uid = u.Uid, members = Array.Empty<string>() });
body = JsonSerializer.Serialize(rows);
}
else return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") });
}
}
public sealed class App : WebApplicationFactory<Program>
{
public readonly string Dir = Path.Combine(Path.GetTempPath(), "calls-api-" + Guid.NewGuid().ToString("N"));
protected override void ConfigureWebHost(IWebHostBuilder b)
{
b.UseSetting("Storage:DataDir", Dir);
b.UseSetting("Jitsi:AppSecret", StoreTests.Secret);
b.UseSetting("Jitsi:Domain", "meet.test");
b.UseSetting("Calls:LinkSecret", "test-link-secret-0123456789abcdef0123456789");
b.UseSetting("Calls:PublicUrl", "https://calls.test");
b.UseSetting("Supabase:Url", "https://supabase.test");
b.UseSetting("Supabase:PublishableKey", "sb_publishable_test");
b.UseSetting("Crm:Origins", "https://crm.test");
b.UseSetting("CALLS_BACKGROUND", "false");
b.ConfigureTestServices(s => s.AddHttpClient("supabase").ConfigurePrimaryHttpMessageHandler(() => new FakeSupabase()));
}
}
/// <summary>Похожий на Supabase JWT: exp через час, aud = authenticated; подпись фальшивому Supabase не важна.</summary>
public static string Jwt(string who)
{
static string B(string s) => Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds();
return B("{\"alg\":\"HS256\"}") + "." + B($"{{\"exp\":{exp},\"aud\":\"authenticated\"}}") + "." + who;
}
private HttpClient Client(string? who, string? ws)
{
var c = _app.CreateClient();
if (who != null) c.DefaultRequestHeaders.Authorization = new("Bearer", Jwt(who));
if (ws != null) c.DefaultRequestHeaders.Add("X-Calls-Workspace", ws);
return c;
}
private static async Task<JsonElement> Json(HttpResponseMessage r) => JsonDocument.Parse(await r.Content.ReadAsStringAsync()).RootElement;
private async Task<JsonElement> PassFor(string session)
{
var r = await _app.CreateClient().PostAsJsonAsync("/api/call/pass", new { session });
Assert.Equal(HttpStatusCode.OK, r.StatusCode);
return await Json(r);
}
[Fact]
public async Task Member_JoinsOwnWorkspaceRoom_AsModerator()
{
var r = await Client(Anna, "wsteam0000001").PostAsync("/api/join", null);
Assert.Equal(HttpStatusCode.OK, r.StatusCode);
var join = await Json(r);
Assert.StartsWith("https://calls.test/call?code=", join.GetProperty("url").GetString());
var ex = await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = join.GetProperty("code").GetString() });
Assert.Equal(HttpStatusCode.OK, ex.StatusCode);
var pass = await PassFor((await Json(ex)).GetProperty("session").GetString()!);
Assert.Equal("member", pass.GetProperty("kind").GetString());
var claims = StoreTests.VerifyHs256(pass.GetProperty("jwt").GetString()!);
Assert.True(claims.GetProperty("context").GetProperty("user").GetProperty("moderator").GetBoolean());
Assert.Equal("Анна", claims.GetProperty("context").GetProperty("user").GetProperty("name").GetString());
// код одноразовый
var again = await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = join.GetProperty("code").GetString() });
Assert.Equal(HttpStatusCode.Gone, again.StatusCode);
}
[Theory]
[InlineData("wsteam0000001")] // custom-workspace Анны
[InlineData("personal:" + AnnaUid)] // личный Анны
public async Task OtherUser_CannotEnterSomeoneElsesWorkspace(string ws)
{
var c = Client(Carl, ws);
Assert.Equal(HttpStatusCode.Forbidden, (await c.PostAsync("/api/join", null)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await c.GetAsync("/api/room")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await c.PostAsync("/api/room/guest-link", null)).StatusCode);
}
[Fact]
public async Task Shared_IsOpenToAllCrmUsers_SameRoom()
{
async Task<string> RoomOf(string who)
{
var j = await Json(await Client(who, "shared").PostAsync("/api/join", null));
var ex = await Json(await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = j.GetProperty("code").GetString() }));
return (await PassFor(ex.GetProperty("session").GetString()!)).GetProperty("room").GetString()!;
}
Assert.Equal(await RoomOf(Anna), await RoomOf(Carl));
}
[Fact]
public async Task NoOrBadToken_401()
{
Assert.Equal(HttpStatusCode.Unauthorized, (await Client(null, "shared").PostAsync("/api/join", null)).StatusCode);
var c = _app.CreateClient();
c.DefaultRequestHeaders.Authorization = new("Bearer", Jwt("tok-nobody"));
c.DefaultRequestHeaders.Add("X-Calls-Workspace", "shared");
Assert.Equal(HttpStatusCode.Unauthorized, (await c.PostAsync("/api/join", null)).StatusCode);
}
[Fact]
public async Task Workspaces_ListMineOnly()
{
var j = await Json(await Client(Anna, null).GetAsync("/api/workspaces"));
var ids = j.GetProperty("workspaces").EnumerateArray().Select(w => w.GetProperty("id").GetString()!).ToArray();
Assert.Equal(["personal:" + AnnaUid, "shared", "wsteam0000001"], ids);
}
[Fact]
public async Task Guest_ByLink_NotModerator_RegenerateKillsOldLink()
{
var room = await Json(await Client(Anna, "wsteam0000001").GetAsync("/api/room"));
var url = room.GetProperty("guestLink").GetProperty("url").GetString()!;
Assert.StartsWith("https://calls.test/g/", url);
var token = url[(url.LastIndexOf('/') + 1)..];
var anon = _app.CreateClient();
Assert.Equal(HttpStatusCode.OK, (await anon.GetAsync($"/api/guest/{token}")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = " " })).StatusCode);
var g = await Json(await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = "Гість" }));
var pass = await PassFor(g.GetProperty("session").GetString()!);
Assert.Equal("guest", pass.GetProperty("kind").GetString());
Assert.False(StoreTests.VerifyHs256(pass.GetProperty("jwt").GetString()!).GetProperty("context").GetProperty("user").GetProperty("moderator").GetBoolean());
var regen = await Json(await Client(Anna, "wsteam0000001").PostAsync("/api/room/guest-link", null));
var newUrl = regen.GetProperty("guestLink").GetProperty("url").GetString()!;
Assert.NotEqual(url, newUrl);
Assert.Equal("Анна", regen.GetProperty("guestLink").GetProperty("updatedByName").GetString());
Assert.Equal(HttpStatusCode.NotFound, (await anon.GetAsync($"/api/guest/{token}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = "Гість" })).StatusCode);
var newToken = newUrl[(newUrl.LastIndexOf('/') + 1)..];
Assert.Equal(HttpStatusCode.OK, (await anon.PostAsJsonAsync($"/api/guest/{newToken}/join", new { name = "Гість" })).StatusCode);
}
[Fact]
public async Task Headers_FrameAncestorsCrmOnly_NoXFrameOptions()
{
var r = await _app.CreateClient().GetAsync("/api/config");
var csp = r.Headers.GetValues("Content-Security-Policy").Single();
Assert.Contains("frame-ancestors https://crm.test;", csp);
Assert.Contains("frame-src https://meet.test", csp);
Assert.False(r.Headers.Contains("X-Frame-Options"));
}
}